715 lines
28 KiB
Go
715 lines
28 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"floorvisualizer/internal/openrouter"
|
||
"floorvisualizer/internal/repository"
|
||
)
|
||
|
||
// 閳光偓閳光偓 Floor options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
type FloorOption struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Category string `json:"category"`
|
||
Material string `json:"material"`
|
||
ColorTone string `json:"color_tone"`
|
||
Finish string `json:"finish"`
|
||
SizeLabel string `json:"size_label"`
|
||
WidthIn float64 `json:"width_in"`
|
||
LengthIn float64 `json:"length_in"`
|
||
ImageURL string `json:"image_url"`
|
||
SKU string `json:"sku"`
|
||
Description string `json:"description"`
|
||
FloorDescription string `json:"floor_description,omitempty"`
|
||
Variants []FloorOption `json:"variants,omitempty"`
|
||
}
|
||
|
||
type PatternOption struct {
|
||
Code int `json:"code"`
|
||
Name string `json:"name"`
|
||
Pattern string `json:"pattern"`
|
||
Category string `json:"category"`
|
||
}
|
||
|
||
type RoomOption struct {
|
||
Code int `json:"code"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
var woodPatterns = []PatternOption{
|
||
{Code: 1, Name: "直铺(工字拼)Straight Lay", Category: "wood", Pattern: "planks running from bottom-left toward top-right of the image, joints staggered randomly, at least 6 inch offset between rows"},
|
||
{Code: 2, Name: "横铺 Horizontal Lay", Category: "wood", Pattern: "planks running horizontally left to right, parallel to the bottom edge of the image, joints staggered randomly, at least 6 inch offset"},
|
||
{Code: 3, Name: "人字拼 Herringbone", Category: "wood", Pattern: "ALL planks must be IDENTICAL width - no piece may appear narrower. Planks at 90° forming continuous zigzag V-lines from bottom-left to top-right of the image. At far distance, planks remain distinct with visible seams - NOT merging into a solid mass."},
|
||
{Code: 4, Name: "鱼骨拼 Chevron", Category: "wood", Pattern: "planks with 45° mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"},
|
||
}
|
||
|
||
var tilePatterns = []PatternOption{
|
||
{Code: 5, Name: "直铺(网格对缝)Straight Grid", Category: "tile", Pattern: "tiles aligned in rows (parallel to bottom edge) and columns (perpendicular to bottom edge), all grout lines continuous, no offset"},
|
||
{Code: 6, Name: "工字铺(1/2错缝)Running Bond", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset 50% from previous, horizontal grout lines continuous, vertical grout lines staggered"},
|
||
{Code: 7, Name: "三七错铺 1/3 Offset", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset exactly 33% of tile length, every third row aligns"},
|
||
{Code: 8, Name: "六边形 Hexagonal", Category: "tile", Pattern: "six-sided tiles interlocked in honeycomb mesh, flat edges horizontal (parallel to bottom edge of image), grout follows hexagonal edges"},
|
||
{Code: 9, Name: "斜铺(60度对角)Diagonal", Category: "tile", Pattern: "tiles rotated 60° to the image frame, grout lines run diagonally at 60 and 150 degrees to the bottom edge, no lines parallel to image edges"},
|
||
}
|
||
|
||
var roomOptions = []RoomOption{
|
||
{Code: 0, Name: "自动识别"},
|
||
{Code: 1, Name: "客厅"},
|
||
{Code: 2, Name: "卧室"},
|
||
{Code: 3, Name: "厨房"},
|
||
{Code: 4, Name: "餐厅"},
|
||
{Code: 5, Name: "浴室"},
|
||
{Code: 6, Name: "书房"},
|
||
{Code: 7, Name: "走廊"},
|
||
}
|
||
|
||
func findPatternByCode(code int) *PatternOption {
|
||
for _, p := range append(woodPatterns, tilePatterns...) {
|
||
if p.Code == code {
|
||
return &p
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func findRoomByCode(code int) *RoomOption {
|
||
if code == 0 {
|
||
return nil
|
||
}
|
||
for _, r := range roomOptions {
|
||
if r.Code == code {
|
||
return &r
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadFloorOptions loads all products, deduplicated by brand+style_name. Variants
|
||
// (different sizes of the same style) are attached to the representative entry.
|
||
func loadFloorOptions(db *sql.DB) ([]FloorOption, error) {
|
||
// Use raw query without DISTINCT ON so all size variants are returned
|
||
rows, err := db.Query(`SELECT brand, group_name, sku, series_name, style_name,
|
||
category, material, color_tone, finish, price_per_sqft, price_tier, price_source,
|
||
is_official_price, main_image_url, room_image_url, source_url, description, status,
|
||
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
|
||
size_label, COALESCE(width_in,0), COALESCE(length_in,0)
|
||
FROM products ORDER BY brand, style_name, id LIMIT 5000`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
prods, err := repository.ScanProducts(rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
type key struct{ brand, style string }
|
||
seen := map[key]int{} // key 閳?index in out slice
|
||
var out []FloorOption
|
||
for _, p := range prods {
|
||
k := key{p.Brand, p.StyleName}
|
||
if idx, ok := seen[k]; ok {
|
||
// Add as variant to the representative entry
|
||
out[idx].Variants = append(out[idx].Variants, FloorOption{
|
||
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
|
||
SKU: p.SKU,
|
||
})
|
||
|
||
} else {
|
||
seen[k] = len(out)
|
||
out = append(out, FloorOption{
|
||
ID: p.SKU, Name: p.StyleName, Category: p.Category,
|
||
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
|
||
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
|
||
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
|
||
})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func isTileProduct(p FloorOption) bool {
|
||
m := strings.ToLower(p.Material)
|
||
return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") ||
|
||
strings.Contains(m, "stone look") || m == "glass"
|
||
}
|
||
func isVinylProduct(p FloorOption) bool {
|
||
m := strings.ToLower(p.Material)
|
||
return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl"
|
||
}
|
||
func isLaminateProduct(p FloorOption) bool {
|
||
return strings.EqualFold(p.Material, "laminate")
|
||
}
|
||
func isWoodProduct(p FloorOption) bool {
|
||
return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p)
|
||
}
|
||
|
||
func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
|
||
p, err := repository.GetProductBySKU(db, sku)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
f := &FloorOption{
|
||
ID: p.SKU, Name: p.StyleName, Category: p.Category,
|
||
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
|
||
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
|
||
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
|
||
}
|
||
// Fetch size variants (same style, different dimensions)
|
||
variants, _ := repository.GetVariantsByStyle(db, sku)
|
||
for _, vp := range variants {
|
||
f.Variants = append(f.Variants, FloorOption{
|
||
SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn,
|
||
SKU: vp.SKU,
|
||
})
|
||
}
|
||
// Canonical reference image = smallest size variant (by width)
|
||
// All sizes of the same style use the same reference image to prevent color drift
|
||
canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn
|
||
rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products
|
||
WHERE style_name = $1 AND brand = $2
|
||
ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`,
|
||
p.StyleName, p.Brand)
|
||
if rows != nil {
|
||
defer rows.Close()
|
||
if rows.Next() {
|
||
rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength)
|
||
}
|
||
}
|
||
f.ImageURL = canonicalImage
|
||
|
||
// Build dynamic size instruction if selected size differs from reference image size
|
||
if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) {
|
||
if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn {
|
||
f.Description = fmt.Sprintf(
|
||
`尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`,
|
||
canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description,
|
||
)
|
||
}
|
||
}
|
||
return f
|
||
}
|
||
|
||
// 閳光偓閳光偓 Public accessors for worker 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) }
|
||
func FindPatternByCodePublic(code string) *PatternOption {
|
||
c := 0
|
||
fmt.Sscanf(code, "%d", &c)
|
||
return findPatternByCode(c)
|
||
}
|
||
func FindRoomByCodePublic(code string) *RoomOption {
|
||
c := 0
|
||
fmt.Sscanf(code, "%d", &c)
|
||
return findRoomByCode(c)
|
||
}
|
||
func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) }
|
||
func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string {
|
||
return buildCompactFloorPrompt(f, p, room)
|
||
}
|
||
|
||
// BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it.
|
||
// No separate mask generation step 閳?the AI does both in one pass.
|
||
func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
|
||
return buildCompactCombinedFloorPrompt(f, p, room, groutLines, whiteBaseboard)
|
||
}
|
||
|
||
// buildPatternConstraint returns installation rules specific to each pattern code.
|
||
func buildPatternConstraint(code int) string {
|
||
switch code {
|
||
case 1:
|
||
return "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。"
|
||
case 2:
|
||
return "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。"
|
||
case 3:
|
||
return "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。"
|
||
case 4:
|
||
return "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。"
|
||
case 5:
|
||
return "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 1/8 英寸。"
|
||
case 6:
|
||
return "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。"
|
||
case 7:
|
||
return "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。"
|
||
case 8:
|
||
return "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。"
|
||
case 9:
|
||
return "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 閳光偓閳光偓 Floor Generate Handler (Redis queue) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
|
||
generateQueue = enqueue
|
||
}
|
||
|
||
var generateQueue func(ctx context.Context, jobID string, payload map[string]string) error
|
||
|
||
var queryStatus func(ctx context.Context, jobID string) (*JobStatusResp, error)
|
||
|
||
func SetStatusQuery(fn func(ctx context.Context, jobID string) (*JobStatusResp, error)) {
|
||
queryStatus = fn
|
||
}
|
||
|
||
type JobStatusResp struct {
|
||
JobID string `json:"job_id"`
|
||
Status string `json:"status"`
|
||
Step string `json:"step"`
|
||
Message string `json:"message"`
|
||
Result string `json:"result,omitempty"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
writeErr(w, 405, "Method not allowed")
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(20 << 20); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
file, _, err := r.FormFile("image")
|
||
if err != nil {
|
||
writeErr(w, 400, "No image")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
floor := findFloorBySKU(db, r.FormValue("floor_id"))
|
||
patternCode := 0
|
||
fmt.Sscanf(r.FormValue("pattern_code"), "%d", &patternCode)
|
||
pattern := findPatternByCode(patternCode)
|
||
if floor == nil || pattern == nil {
|
||
writeErr(w, 400, "Invalid options")
|
||
return
|
||
}
|
||
|
||
os.MkdirAll("uploads", 0755)
|
||
jobID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||
inputPath := filepath.Join("uploads", fmt.Sprintf("input_%s.png", jobID))
|
||
dst, err := os.Create(inputPath)
|
||
if err != nil {
|
||
writeErr(w, 500, "Failed to save image")
|
||
return
|
||
}
|
||
if _, err := io.Copy(dst, file); err != nil {
|
||
dst.Close()
|
||
writeErr(w, 500, "Failed to write image")
|
||
return
|
||
}
|
||
dst.Close()
|
||
os.MkdirAll("outputs", 0755)
|
||
|
||
if generateQueue == nil {
|
||
writeErr(w, 500, "Queue not initialized")
|
||
return
|
||
}
|
||
// Store job payload then enqueue
|
||
payload := map[string]string{
|
||
"input_path": inputPath,
|
||
"floor_id": r.FormValue("floor_id"),
|
||
"pattern_code": r.FormValue("pattern_code"),
|
||
"room_code": r.FormValue("room_code"),
|
||
"grout_lines": boolFormValue(r, "grout_lines"),
|
||
"white_baseboard": boolFormValue(r, "white_baseboard"),
|
||
"material_source": r.FormValue("material_source"),
|
||
}
|
||
if err := generateQueue(r.Context(), jobID, payload); err != nil {
|
||
writeErr(w, 500, "Failed to enqueue job")
|
||
return
|
||
}
|
||
_ = payload // payload stored inside generateQueue wrapper
|
||
writeJSON(w, 200, map[string]string{"job_id": jobID})
|
||
}
|
||
}
|
||
|
||
// 閳光偓閳光偓 Progress 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
// 閳光偓閳光偓 Floor Status Query 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
func FloorStatus(w http.ResponseWriter, r *http.Request) {
|
||
jobID := r.URL.Query().Get("job_id")
|
||
if jobID == "" {
|
||
writeErr(w, 400, "job_id is required")
|
||
return
|
||
}
|
||
if queryStatus == nil {
|
||
writeErr(w, 500, "status query not initialized")
|
||
return
|
||
}
|
||
js, err := queryStatus(r.Context(), jobID)
|
||
if err != nil {
|
||
writeJSON(w, 404, map[string]string{"error": "job not found"})
|
||
return
|
||
}
|
||
writeJSON(w, 200, js)
|
||
}
|
||
|
||
// 閳光偓閳光偓 Options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
func FloorOptions(db *sql.DB) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
all, _ := loadFloorOptions(db)
|
||
var allWood, allTile, vinylFloors []FloorOption
|
||
for _, f := range all {
|
||
switch {
|
||
case isVinylProduct(f):
|
||
vinylFloors = append(vinylFloors, f)
|
||
case isTileProduct(f) || isLaminateProduct(f):
|
||
allTile = append(allTile, f)
|
||
default:
|
||
allWood = append(allWood, f)
|
||
}
|
||
}
|
||
|
||
// Load special test products with floor descriptions
|
||
specialFloors := loadSpecialFloors(db)
|
||
|
||
writeJSON(w, 200, map[string]any{
|
||
"wood_floors": allWood,
|
||
"wood_patterns": woodPatterns,
|
||
"tile_floors": allTile,
|
||
"tile_patterns": tilePatterns,
|
||
"vinyl_floors": vinylFloors,
|
||
"special_floors": specialFloors,
|
||
"rooms": roomOptions,
|
||
})
|
||
}
|
||
}
|
||
|
||
// materialIndex caches SKU 閳?material description from MaterialAssets JSON files.
|
||
var materialIndex map[string]string
|
||
|
||
// LoadMaterialIndex loads the material description index from a JSON file.
|
||
func LoadMaterialIndex(path string) error {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(data, &materialIndex)
|
||
}
|
||
|
||
// LookupMaterialDesc returns the material description for a given SKU from the loaded index.
|
||
func LookupMaterialDesc(sku string) string {
|
||
if materialIndex == nil {
|
||
return ""
|
||
}
|
||
return materialIndex[sku]
|
||
}
|
||
|
||
// loadSpecialFloors loads the 7 special test products with pre-generated floor descriptions.
|
||
func loadSpecialFloors(db *sql.DB) []FloorOption {
|
||
data, err := os.ReadFile("outputs/special_7_products.json")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var raw []struct {
|
||
Brand string `json:"brand"`
|
||
Style string `json:"style"`
|
||
Material string `json:"material"`
|
||
Description string `json:"description"`
|
||
ImageURL string `json:"image_url"`
|
||
}
|
||
if json.Unmarshal(data, &raw) != nil {
|
||
return nil
|
||
}
|
||
var out []FloorOption
|
||
for _, r := range raw {
|
||
// Find any real SKU for this style to get DB variants and sizes
|
||
var sku string
|
||
db.QueryRow(`SELECT sku FROM products WHERE style_name=$1 AND brand=$2 LIMIT 1`,
|
||
r.Style, r.Brand).Scan(&sku)
|
||
if sku == "" {
|
||
continue
|
||
}
|
||
f := findFloorBySKU(db, sku)
|
||
if f == nil {
|
||
continue
|
||
}
|
||
f.FloorDescription = r.Description
|
||
// Keep f.ImageURL from DB (canonical smallest) 閳?do NOT override with local path
|
||
out = append(out, *f)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 閳光偓閳光偓 Prompts 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||
|
||
func buildMaskPrompt(room *RoomOption) string {
|
||
base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" +
|
||
"严格定义:\n" +
|
||
"- 白色(#FFFFFF):只表示平整、水平、可行走的地面。包含所有可见地面,一直到墙面、踢脚线、柜体或楼梯边缘。\n" +
|
||
"- 黑色(#000000):所有非水平地面的内容,包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" +
|
||
"边缘规则:\n" +
|
||
"- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" +
|
||
"- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" +
|
||
"- 楼梯和地毯都标为黑色。\n\n" +
|
||
"只输出遮罩图片,不要文字说明。"
|
||
if room != nil && room.Code != 0 {
|
||
base += fmt.Sprintf("\n房间类型:%s。", room.Name)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func buildMaterialRules(floor FloorOption) string {
|
||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||
return "整片地面必须是同一个 SKU,基础颜色在全地面保持一致。允许原图阴影让局部变暗,但不能改变地砖色相或材质。瓷砖表面平整,缝隙连续、透视一致;不要生成局部偏绿、偏蓝、偏黄、偏青的色块。反光只能来自原图已有光照,不能新增窗户反光、亮斑或湿地板效果。"
|
||
}
|
||
return "地面必须保持同一个 SKU 的颜色、纹理、木纹或板纹和光泽。保留原图已有光照和阴影;不要新增反光、亮斑或阴影。板缝透视一致,纹理自然但不能明显重复。"
|
||
}
|
||
|
||
func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
|
||
roomName := "自动识别"
|
||
if room != nil && room.Code != 0 {
|
||
roomName = room.Name
|
||
}
|
||
|
||
var priority []string
|
||
if whiteBaseboard {
|
||
priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。")
|
||
}
|
||
if groutLines {
|
||
priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。")
|
||
}
|
||
|
||
priorityText := ""
|
||
if len(priority) > 0 {
|
||
priorityText = strings.Join(priority, "\n\n") + "\n\n"
|
||
}
|
||
|
||
editScope := "只替换地面"
|
||
if whiteBaseboard {
|
||
editScope = "只替换地面和白色踢脚线"
|
||
}
|
||
|
||
return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。",
|
||
priorityText,
|
||
roomName,
|
||
editScope,
|
||
buildCompactFloorPrompt(f, p, room),
|
||
)
|
||
}
|
||
|
||
func buildCompactFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
|
||
matType := "木地板"
|
||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||
matType = "瓷砖"
|
||
}
|
||
|
||
materialDesc := fmt.Sprintf("%s,%s色调,%s表面", floor.Material, floor.ColorTone, floor.Finish)
|
||
if floor.SizeLabel != "" {
|
||
materialDesc += ",尺寸 " + floor.SizeLabel
|
||
}
|
||
|
||
var details []string
|
||
if floor.Description != "" {
|
||
details = append(details, "产品描述:"+floor.Description)
|
||
}
|
||
if floor.FloorDescription != "" {
|
||
details = append(details, "目录材质描述:"+floor.FloorDescription)
|
||
}
|
||
if sizeRule := compactSizeInstruction(floor, pattern); sizeRule != "" {
|
||
details = append(details, sizeRule)
|
||
}
|
||
if floor.ImageURL != "" {
|
||
details = append(details, "产品图是唯一材质参考:锁定基础颜色、纹理、纹路、光泽、材质类型;不要重新美化或改色。")
|
||
}
|
||
if guard := compactProductNameGuard(floor); guard != "" {
|
||
details = append(details, guard)
|
||
}
|
||
|
||
return fmt.Sprintf("地板产品:\nSKU:%s\n产品名:%s(这是商品目录名,不是视觉效果指令)\n类型:%s\n材质:%s\n铺法:%s。%s\n\n材质规则:\n%s\n%s\n\n禁止:\nno wet floor, no puddles, no water stains, no color shift, no green/blue/yellow/cyan patches, no new lights, no new shadows, no CGI, no text, no watermark。",
|
||
floor.SKU,
|
||
floor.Name,
|
||
matType,
|
||
materialDesc,
|
||
pattern.Name,
|
||
pattern.Pattern,
|
||
buildMaterialRules(floor),
|
||
strings.Join(details, "\n"),
|
||
)
|
||
}
|
||
|
||
func compactSizeInstruction(floor FloorOption, pattern PatternOption) string {
|
||
width, length, ok := floorDimensions(floor)
|
||
if !ok {
|
||
if pattern.Code == 8 {
|
||
return "尺寸规则:这是特殊形状砖,按产品图保持真实特殊形状和缝隙结构;不要改成方砖或普通长方形砖。"
|
||
}
|
||
if floor.SizeLabel != "" {
|
||
return "尺寸规则:使用所选尺寸 " + floor.SizeLabel + ";只调整板/砖大小和缝距,不改变材质颜色和纹理。"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
shortSide, longSide := width, length
|
||
if shortSide > longSide {
|
||
shortSide, longSide = longSide, shortSide
|
||
}
|
||
ratio := longSide / shortSide
|
||
sizeText := formatDimensionPair(width, length)
|
||
|
||
if ratio <= 1.08 {
|
||
return fmt.Sprintf("尺寸规则:每块砖必须是 %s 正方形,真实长宽比 %s。砖缝网格应形成接近 1:1 的方砖;只调整板/砖大小和缝距,不改变材质颜色和纹理。", sizeText, aspectRatioText(shortSide, longSide))
|
||
}
|
||
|
||
return fmt.Sprintf("尺寸规则:每块砖必须是 %s 长方形,真实长宽比 %s。近处可见的完整砖块也必须明显是长方形,长边约为短边的 %.2g 倍,不得变成接近正方形;砖缝网格必须形成同样比例的长方形砖块。只调整板/砖大小和缝距,不改变材质颜色和纹理。", sizeText, aspectRatioText(shortSide, longSide), ratio)
|
||
}
|
||
|
||
func floorDimensions(floor FloorOption) (float64, float64, bool) {
|
||
if floor.WidthIn > 0 && floor.LengthIn > 0 {
|
||
return floor.WidthIn, floor.LengthIn, true
|
||
}
|
||
|
||
text := strings.Join([]string{floor.SizeLabel, floor.Name}, " ")
|
||
re := regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:x|×|-|by)\s*(\d+(?:\.\d+)?)`)
|
||
match := re.FindStringSubmatch(text)
|
||
if len(match) != 3 {
|
||
return 0, 0, false
|
||
}
|
||
width, err1 := strconv.ParseFloat(match[1], 64)
|
||
length, err2 := strconv.ParseFloat(match[2], 64)
|
||
if err1 != nil || err2 != nil || width <= 0 || length <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
return width, length, true
|
||
}
|
||
|
||
func formatDimensionPair(width, length float64) string {
|
||
return fmt.Sprintf("%s x %s 英寸", formatDimension(width), formatDimension(length))
|
||
}
|
||
|
||
func formatDimension(v float64) string {
|
||
if v == float64(int(v)) {
|
||
return fmt.Sprintf("%d", int(v))
|
||
}
|
||
return fmt.Sprintf("%.1f", v)
|
||
}
|
||
|
||
func aspectRatioText(shortSide, longSide float64) string {
|
||
ratio := longSide / shortSide
|
||
bestNumer, bestDenom := 1, 1
|
||
bestErr := ratio - 1
|
||
if bestErr < 0 {
|
||
bestErr = -bestErr
|
||
}
|
||
for denom := 1; denom <= 12; denom++ {
|
||
numer := int(ratio*float64(denom) + 0.5)
|
||
if numer < 1 {
|
||
numer = 1
|
||
}
|
||
err := ratio - float64(numer)/float64(denom)
|
||
if err < 0 {
|
||
err = -err
|
||
}
|
||
if err < bestErr {
|
||
bestErr = err
|
||
bestNumer = numer
|
||
bestDenom = denom
|
||
}
|
||
}
|
||
return fmt.Sprintf("%d:%d", bestDenom, bestNumer)
|
||
}
|
||
|
||
func compactProductNameGuard(floor FloorOption) string {
|
||
text := strings.ToLower(floor.Name + " " + floor.Description + " " + floor.FloorDescription)
|
||
for _, word := range []string{"rain", "puddle", "water", "wet", "storm", "mist", "fog", "snow", "ice"} {
|
||
if strings.Contains(text, word) {
|
||
return "产品名防误解:不要按产品名生成雨水、积水、水渍、雾气、冰雪、湿地板或彩色斑块;只看产品参考图和结构化材质字段。"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
|
||
return buildCompactFloorPrompt(floor, pattern, room)
|
||
}
|
||
|
||
func buildProductNameGuard(floor FloorOption) string {
|
||
return compactProductNameGuard(floor)
|
||
}
|
||
|
||
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
|
||
imgData, err := os.ReadFile(imagePath)
|
||
if err != nil {
|
||
return false, 0
|
||
}
|
||
dataURI := "data:image/png;base64," + b64enc(imgData)
|
||
prompt := "请按 1-10 分评估这张图片是否适合做室内地面替换。\n8-10 分:清晰室内房间,地面可见。\n5-7 分:室内图片,但角度或地面可见度一般。\n1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。\n只返回一个数字(1-10)。"
|
||
resp, err := client.Chat(ctx, openrouter.ChatRequest{
|
||
Model: "google/gemini-2.5-flash",
|
||
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
|
||
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
|
||
{Type: "text", Text: prompt},
|
||
}}},
|
||
})
|
||
if err != nil || len(resp.Choices) == 0 {
|
||
return true, 0
|
||
}
|
||
score := parseScore(resp.Choices[0].Message.Content)
|
||
return score >= 6, score
|
||
}
|
||
func parseScore(raw string) int {
|
||
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
|
||
if len(raw) >= len(s) && raw[:len(s)] == s {
|
||
v := 0
|
||
fmt.Sscanf(s, "%d", &v)
|
||
return v
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func b64enc(data []byte) string {
|
||
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||
var b strings.Builder
|
||
for i := 0; i < len(data); i += 3 {
|
||
b0, b1, b2 := data[i], byte(0), byte(0)
|
||
if i+1 < len(data) {
|
||
b1 = data[i+1]
|
||
}
|
||
if i+2 < len(data) {
|
||
b2 = data[i+2]
|
||
}
|
||
b.WriteByte(tbl[b0>>2])
|
||
b.WriteByte(tbl[((b0&3)<<4)|(b1>>4)])
|
||
if i+1 < len(data) {
|
||
b.WriteByte(tbl[((b1&15)<<2)|(b2>>6)])
|
||
} else {
|
||
b.WriteByte('=')
|
||
}
|
||
if i+2 < len(data) {
|
||
b.WriteByte(tbl[b2&63])
|
||
} else {
|
||
b.WriteByte('=')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func boolFormValue(r *http.Request, key string) string {
|
||
value := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
|
||
switch value {
|
||
case "1", "true", "yes", "y", "on":
|
||
return "true"
|
||
default:
|
||
return "false"
|
||
}
|
||
}
|