- 添加 FloorOption 结构体字段支持尺寸、描述和变体信息 - 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体 - 区分木材、瓷砖、乙烯基和层压板产品,并分类返回 - 更新室内铺装纹理选项,完善图案描述和名称本地化 - 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段 - 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例 - 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求 - 改进地板识别蒙版生成逻辑,确保精准分割地板区域 - Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息 - 更新go.mod依赖,新增redis和gorm相关包支持
629 lines
26 KiB
Go
629 lines
26 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"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"`
|
||
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: "planks at 90° forming continuous zigzag V-lines from bottom-left to top-right of the image"},
|
||
{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° 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 3000`)
|
||
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(
|
||
`SIZE MAPPING: The reference image shows %.1f"×%.1f" planks. Generate %.1f"×%.1f" planks — same exact material, only scale the board size proportionally. | %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 buildFloorPrompt(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) string {
|
||
floorPrompt := buildFloorPrompt(f, p, room)
|
||
roomCtx := ""
|
||
if room != nil && room.Code != 0 {
|
||
roomCtx = fmt.Sprintf(" This is a %s.", room.Name)
|
||
}
|
||
|
||
return fmt.Sprintf(
|
||
`FIRST — identify the floor: Look at this room photo.%s
|
||
The floor is the flat horizontal walking surface — NOT walls, NOT baseboards, NOT furniture, NOT stairs.
|
||
You will replace ONLY the floor area. Every other pixel must remain bit-identical to the input photo.
|
||
|
||
%s
|
||
|
||
Return ONLY the completed image. No text.`,
|
||
roomCtx,
|
||
floorPrompt,
|
||
)
|
||
}
|
||
|
||
// buildPatternConstraint returns installation rules specific to each pattern code.
|
||
func buildPatternConstraint(code int) string {
|
||
switch code {
|
||
case 1:
|
||
return `PATTERN RULES — Straight Lay (wood): Planks run from the bottom-left of the image toward the top-right, at roughly 45° to the image frame. Long edges of each plank point toward the upper-right corner. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
|
||
case 2:
|
||
return `PATTERN RULES — Horizontal Lay (wood): Planks run horizontally — left to right, parallel to the bottom edge of the image. Long edges of each plank are horizontal across the photo. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
|
||
case 3:
|
||
return `PATTERN RULES — Herringbone (wood): Planks at 90° forming continuous zigzag. Each plank end meets neighbor at 90°. V-lines run from bottom-left to top-right of the image. Joints tight — continuous unbroken zigzag.`
|
||
case 4:
|
||
return `PATTERN RULES — Chevron (wood): Planks with 45° mitered ends form continuous V. V-points aligned in a straight line running bottom-left to top-right across the image. Joint lines are straight and continuous. NOT herringbone — mitered joints, not staggered.`
|
||
case 5:
|
||
return `PATTERN RULES — Straight Grid (tile): Tiles aligned in rows and columns. Grout lines run horizontally (parallel to bottom edge of image) and vertically (perpendicular to bottom edge). NO offset between rows. Grout ~1/8".`
|
||
case 6:
|
||
return `PATTERN RULES — Running Bond (tile): Each row offset 50% from previous — classic brickwork. Grout lines parallel to bottom edge of image are continuous; grout lines perpendicular to bottom edge are staggered. Grout ~1/8".`
|
||
case 7:
|
||
return `PATTERN RULES — 1/3 Offset (tile): Each row offset exactly 33% of tile length. Every third row aligns. Rows run parallel to bottom edge of image. Grout ~1/8". NOT 50% bond — offset is exactly 1/3.`
|
||
case 8:
|
||
return `PATTERN RULES — Hexagonal (tile): Six-sided tiles interlocked in honeycomb mesh. Each tile touches 6 neighbors. Flat edges of hexagons are horizontal (parallel to bottom edge of image). Grout follows hexagonal edges — network pattern, not grid.`
|
||
case 9:
|
||
return `PATTERN RULES — Diagonal (tile): Tiles rotated 60° relative to the image frame — all tiles at a steep diagonal, diamond orientation. Grout lines run diagonally across the photo at 60° and 150° to the bottom edge. None parallel or perpendicular to image edges. Triangle cuts at all walls. Grout ~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"),
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
writeJSON(w, 200, map[string]any{
|
||
"wood_floors": allWood,
|
||
"wood_patterns": woodPatterns,
|
||
"tile_floors": allTile,
|
||
"tile_patterns": tilePatterns,
|
||
"vinyl_floors": vinylFloors,
|
||
"rooms": roomOptions,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── Prompts ────────────────────────────────────────────────
|
||
|
||
func buildMaskPrompt(room *RoomOption) string {
|
||
base := `You are a precise floor segmentation tool. Create a pure black-and-white mask for this room photo.
|
||
|
||
STRICT DEFINITIONS:
|
||
- WHITE (#FFFFFF): ONLY the flat, horizontal walking surface (the floor). Include the entire visible floor area up to the exact edge where it meets walls, baseboards, cabinets, or stairs.
|
||
- BLACK (#000000): EVERYTHING that is not flat horizontal floor — walls, baseboards/skirting boards, stairs/stair risers (they are NOT flat floor), furniture and furniture legs, doors and door frames, windows, cabinets, appliances, rugs/carpets, ceiling, decorations, people, pets.
|
||
|
||
EDGE RULES:
|
||
- At the wall-floor boundary: cut exactly along the junction. The baseboard/skirting board is BLACK, the floor is WHITE.
|
||
- Furniture legs resting on the floor: the legs are BLACK. The floor visible BETWEEN and AROUND legs is WHITE.
|
||
- Stairs: each step's horizontal tread AND vertical riser are BLACK (stairs are not a continuous flat plane).
|
||
- Rugs/carpets on the floor: BLACK (they are not the permanent floor).
|
||
|
||
SELF-CHECK before output:
|
||
- Did you include any wall area? → fix it.
|
||
- Did you miss any floor area between furniture legs? → fill it WHITE.
|
||
- Did you paint any stair surface WHITE? → make it BLACK.
|
||
- Are all edges sharp and precise at boundaries? → verify.
|
||
|
||
Output ONLY the mask image. No text, no explanation.`
|
||
if room != nil && room.Code != 0 {
|
||
base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
|
||
}
|
||
return base
|
||
}
|
||
|
||
func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
|
||
var roomCtx string
|
||
if room != nil && room.Code != 0 {
|
||
roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name)
|
||
}
|
||
matType := "wood plank"
|
||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||
matType = "tile"
|
||
}
|
||
|
||
materialDesc := fmt.Sprintf("%s material, %s tone, %s finish", floor.Material, floor.ColorTone, floor.Finish)
|
||
if floor.SizeLabel != "" {
|
||
materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel)
|
||
}
|
||
|
||
// Include product description if available for richer visual detail
|
||
var descriptionText string
|
||
if floor.Description != "" {
|
||
descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description)
|
||
}
|
||
|
||
productLock := ""
|
||
if floor.ImageURL != "" {
|
||
productLock = fmt.Sprintf(
|
||
`The reference image is the EXACT product material — not style inspiration, not a mood board.
|
||
LOCK THESE PROPERTIES from the reference image: base color, color temperature, grain/vein pattern,
|
||
knot density, surface texture, gloss level, material character.
|
||
FORBIDDEN: color shift, different wood species or stone type, new grain pattern, different stain,
|
||
gloss change, "luxury upgrade" or aesthetic reinterpretation.`,
|
||
)
|
||
}
|
||
|
||
// Size instruction: only change board proportion, never the material itself
|
||
sizeInstruction := ""
|
||
if len(floor.Variants) > 0 {
|
||
var variantList []string
|
||
for _, v := range floor.Variants {
|
||
marker := ""
|
||
if v.SKU == floor.SKU {
|
||
marker = " ← SELECTED"
|
||
}
|
||
if v.SizeLabel != "" {
|
||
variantList = append(variantList, v.SizeLabel+marker)
|
||
} else if v.WidthIn > 0 && v.LengthIn > 0 {
|
||
variantList = append(variantList, fmt.Sprintf(`%.1f"×%.1f"`, v.WidthIn, v.LengthIn)+marker)
|
||
}
|
||
}
|
||
if len(variantList) > 0 {
|
||
sizeInstruction = fmt.Sprintf(
|
||
"\nSIZE: This product comes in multiple sizes: %s.\nONLY change board width and seam spacing. The material texture, color, and grain are from the reference image — identical in every size. Wider boards use the SAME texture source, just with fewer seams. Do NOT reinterpret the wood to look \"more premium\" for wider planks.",
|
||
strings.Join(variantList, ", "),
|
||
)
|
||
}
|
||
} else if floor.SizeLabel != "" {
|
||
sizeInstruction = fmt.Sprintf(
|
||
"\nSIZE: Each piece is %s. Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.",
|
||
floor.SizeLabel,
|
||
)
|
||
} else if floor.WidthIn > 0 && floor.LengthIn > 0 {
|
||
sizeInstruction = fmt.Sprintf(
|
||
`\nSIZE: Each piece is %.1f" × %.1f". Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.`,
|
||
floor.WidthIn, floor.LengthIn,
|
||
)
|
||
}
|
||
|
||
patternInstruction := buildPatternConstraint(pattern.Code)
|
||
|
||
return fmt.Sprintf(
|
||
`You are a flooring product visualization renderer for a commercial catalog. Your sole function is to replace the floor surface in interior photos with a specific flooring SKU. This is product-accurate catalog photography — NOT interior design or artistic interpretation.
|
||
|
||
STRICT OUTPUT RULES:
|
||
1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper.
|
||
2. The output must have the exact same pixel dimensions as the input image.
|
||
Do not crop, pad, upscale, downscale, rotate, or alter the image geometry in any way.
|
||
3. Do not apply global filtering, sharpening, blurring, tone-mapping, contrast adjustment,
|
||
or style transfer. The only permitted change is the floor surface.
|
||
|
||
FLOOR SURFACE DEFINITION:
|
||
- "Floor" — the flat horizontal walking surface. The visible ground plane inside the room.
|
||
DO NOT bleed onto: walls, baseboards/skirting boards, stairs, stair risers, furniture,
|
||
chair legs, table legs, cabinet bases, doors, door frames, window glass or frames,
|
||
trim, pipes, conduit, rugs, carpets, ceiling, appliances, decorations.
|
||
- Stairs are NOT floor — each tread and riser must remain completely unchanged.
|
||
- If a surface is not the flat horizontal floor, PRESERVE IT EXACTLY.
|
||
|
||
FLOOR REPLACEMENT RULES:
|
||
4. Apply the new floor ONLY to the floor surface. Every pixel outside must be bit-identical to input.
|
||
5. Preserve the original room lighting, shadows, and highlights ON the new floor.
|
||
6. ADD realistic contact shadows: chair wheels, table legs, cabinet bases MUST cast tight dark shadows
|
||
directly at the contact point — sharpest there, softening outward. This shows physical weight.
|
||
7. Maintain clean but physically installed-looking floor edges — not a Photoshop cutout.
|
||
8. The new floor must look physically installed and photographed — not rendered, not composited.
|
||
|
||
FIDELITY RULES:
|
||
9. The output must be a photorealistic edit of the input photograph.
|
||
Do not generate new content, synthesise areas, invent objects, or alter composition.
|
||
10. Preserve camera sensor noise, natural vignette, and original white balance on all non-floor surfaces.
|
||
|
||
AMBIGUITY RULE:
|
||
11. If a pixel could be floor OR furniture/wall/baseboard — preserve the original unchanged.
|
||
Precision over completeness.
|
||
|
||
FLOOR SPECIFICATION:
|
||
- Product: %s (%s %s)
|
||
- Pattern: %s — %s
|
||
The pattern changes layout only. The material comes from the reference image — identical regardless of pattern.
|
||
%s
|
||
- Material details: %s%s%s%s
|
||
|
||
%s
|
||
|
||
TEXTURE FIDELITY:
|
||
- Every plank/tile must have UNIQUE grain, vein, or tonal variation — no visible texture repetition.
|
||
- Natural imperfections: minor scratches, micro-wear near chair wheels, colour variance between pieces.
|
||
- Seams and joints: subtle dust in gaps, micro-shadow at edges, slight edge wear.
|
||
- Polished surfaces: reflections UNEVEN and angle-dependent — not uniform glossy sheen.
|
||
- Do NOT invent sunbeams, light rays, or window reflections not in the original.
|
||
|
||
NEGATIVE CONSTRAINTS — the floor must NOT contain:
|
||
CGI look, 3D render, plastic texture, repeating patterns, uniform gloss, invented lights,
|
||
HDR tone-mapping, "luxury showroom" aesthetic, sterile clean seams, floating furniture,
|
||
color shift, different wood species, new grain, different stain, aesthetic reinterpretation.
|
||
|
||
REQUIREMENTS SUMMARY:
|
||
✓ Only the flat horizontal floor changes — all other pixels preserved bit-identical.
|
||
✓ Contact shadows under all furniture — sharp at contact, softening outward.
|
||
✓ Original lighting and shadows match on new floor.
|
||
✓ Output geometry identical to input — same dimensions, no cropping.
|
||
✓ Scene composition unchanged — no new objects, no synthesised content.
|
||
✓ Material color, grain, gloss, species match the reference image exactly.
|
||
|
||
Return ONLY the completed image. No text.`,
|
||
floor.Name, matType, materialDesc,
|
||
pattern.Name, pattern.Pattern, patternInstruction,
|
||
materialDesc,
|
||
roomCtx, descriptionText, sizeInstruction, productLock,
|
||
)
|
||
}
|
||
|
||
// ── Content Check ─────────────────────────────────────────
|
||
|
||
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)
|
||
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: `Score this image 1-10 for indoor floor replacement suitability.
|
||
8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
|
||
Return ONLY a number (1-10).`},
|
||
}}},
|
||
})
|
||
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()
|
||
}
|