From a756c8c4640201640f1bacf764f90f7533117212 Mon Sep 17 00:00:00 2001 From: dindang Date: Thu, 16 Jul 2026 10:39:55 +0800 Subject: [PATCH 01/10] Update README with current project structure and features - Add api/ package, model/response.go, logger, queue docs - Document environment variables (WORKER_COUNT, LOG_DIR, LOG_LEVEL) - Add cache strategy and async queue sections - Update project structure tree - Add GORM + raw SQL hybrid approach doc Co-Authored-By: Claude --- README.md | 131 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 78 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 87f0e8e..cba7e37 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,33 @@ # FloorVisualizer -AI 换地板可视化后端服务 —— 上传房间照片,AI 自动识别地面区域并生成换地板效果图。 +AI 换地板可视化后端服务 — 上传房间照片,AI 自动识别地面区域并生成换地板效果图。 ## 技术栈 - **语言**: Go 1.23+ -- **数据库**: PostgreSQL 15 +- **数据库**: PostgreSQL 15(GORM + 原生 SQL) - **缓存/队列**: Redis 7 - **AI**: OpenRouter API (Gemini 3 Pro / Flash) - **部署**: Docker Compose ## 快速开始 -### 本地开发 +### 首次部署 ```bash -# 1. 起依赖(仅 DB + Redis) -docker compose -f docker-compose.dev.yml up -d +# 1. 初始化数据库(仅一次) +psql -U postgres < schema.sql # 2. 导入产品数据 -go run cmd/import/main.go +go run cmd/import/main.go # 3539 条产品 +go run cmd/import_articles/main.go # 30 篇科普文章 -# 3. 启动服务 +# 3. 启动开发服务(本地 DB + Redis) +docker compose -f docker-compose.dev.yml up -d go run main.go ``` -服务默认运行在 `http://localhost:8099`。 +服务运行在 `http://localhost:8099`。 ### 生产部署 @@ -36,43 +38,54 @@ docker compose up -d --build ## 项目结构 ``` -├── main.go # 入口 +├── main.go +├── schema.sql # 建表脚本(导入一次) ├── cmd/ │ ├── import/main.go # 产品数据导入 │ ├── import_articles/main.go # 科普文章导入 │ └── scrape_logos/main.py # 品牌 Logo 刮取 ├── internal/ -│ ├── handler/ # HTTP 处理器 -│ │ ├── router.go # 路由注册 -│ │ ├── product_handler.go # 产品列表/详情 -│ │ ├── floor_handler.go # AI 换地板 -│ │ ├── auth_handler.go # 注册/登录 -│ │ ├── user_handler.go # 用户/收藏/项目/头像 -│ │ ├── recommend_handler.go # 智能推荐 -│ │ ├── calc_handler.go # 面积计算 -│ │ ├── article_handler.go # 科普文章 -│ │ ├── upload_handler.go # 图片上传 -│ │ └── health_handler.go # 健康检查 -│ ├── model/ # 数据模型 -│ ├── repository/ # 数据库操作 +│ ├── api/ +│ │ └── router.go # 路由注册 + 依赖注入 + 响应 helper +│ ├── handler/ # HTTP 处理器(函数导出) +│ │ ├── router.go # writeJSON/writeErr +│ │ ├── auth_handler.go # Register / Login +│ │ ├── user_handler.go # Me / UploadAvatar / Favorites / Projects / FrequentBrands +│ │ ├── product_handler.go # Products / ProductOptions / FilterOptions +│ │ ├── recommend_handler.go # Recommend +│ │ ├── calc_handler.go # Calc +│ │ ├── floor_handler.go # FloorGenerate / FloorStatus / FloorOptions +│ │ ├── article_handler.go # Articles / ArticleRecommend +│ │ ├── health_handler.go # Healthz +│ │ └── upload_handler.go # Upload +│ ├── model/ # 数据模型 + 返回结构体 +│ │ ├── response.go # ApiResponse / ApiError +│ │ ├── calc.go # CalcInput / CalcResult / CalcDeduction +│ │ ├── product.go # Product / ProductSpec +│ │ ├── brand.go # BrandInfo +│ │ └── ... +│ ├── repository/ # 数据访问(GORM + 原生 SQL) +│ │ ├── gorm.go # GORM 初始化 +│ │ ├── db.go # PostgreSQL 连接 +│ │ ├── product_repo.go # 产品查询(简单 GORM,复杂 SQL) +│ │ ├── brand_view_repo.go # 品牌浏览统计 +│ │ └── ... │ ├── service/ # 业务逻辑(推荐算法) -│ ├── middleware/ # 中间件(JWT、日志、访问记录) -│ ├── queue/ # Redis 队列(异步任务) +│ ├── middleware/ # JWT 认证 + 访问日志 +│ ├── queue/ # Redis 异步任务队列 +│ │ ├── redis.go # 入队/出队/状态管理 +│ │ └── worker.go # Worker 池(并发消费) │ ├── openrouter/ # OpenRouter API 客户端 -│ └── logger/ # 分级日志 -├── data/products/ # 产品 JSON 数据(3539 条) -├── data/articles.json # 科普文章(30 篇) -├── knowledge_images/ # 文章图片 +│ └── logger/ # 分级日志(日切 + 30 天清理) +├── data/products/ # 产品 JSON(3539 条,不入 git) ├── Dockerfile -├── docker-compose.yml # 生产部署 -├── docker-compose.dev.yml # 本地开发 +├── docker-compose.yml +├── docker-compose.dev.yml └── apifox-import.json # API 文档 ``` ## 环境变量 -### 服务配置 - | 变量 | 默认值 | 说明 | |------|--------|------| | `PORT` | `8099` | HTTP 端口 | @@ -82,19 +95,9 @@ docker compose up -d --build | `OPENROUTER_API_KEY` | 内置默认值 | OpenRouter API Key | | `PROXY_URL` | `127.0.0.1:7897` | HTTP 代理(留空禁用) | | `DISABLE_PROXY` | — | 设为 `true` 强制禁用代理 | - -### 队列 - -| 变量 | 默认值 | 说明 | -|------|--------|------| | `WORKER_COUNT` | `5` | AI 生成并发 worker 数(1-20) | - -### 日志 - -| 变量 | 默认值 | 说明 | -|------|--------|------| | `LOG_DIR` | `logs` | 日志目录 | -| `LOG_LEVEL` | `INFO` | 日志级别:DEBUG / INFO / WARN / ERROR | +| `LOG_LEVEL` | `INFO` | DEBUG / INFO / WARN / ERROR | ## API 总览 @@ -106,23 +109,23 @@ docker compose up -d --build ### 产品(公开) - `GET /products` — 列表(多维度筛选+搜索+分页) -- `GET /products/{sku}` — 详情(含规格 specs、收藏状态) +- `GET /products/{sku}` — 详情(含 specs 规格、收藏状态、热点缓存) - `GET /product-options` — 筛选选项(含品牌 logo、款式数、合集数) ### 推荐(公开) -- `GET /recommend/api?sku=xxx` — 跨品牌相似推荐 +- `GET /recommend/api?sku=xxx` — 跨品牌相似推荐(specs 感知) -### 地板更换 +### 地板更换(需 Redis) - `GET /floor/options` — 地板样式+铺设方式+房间类型 - `POST /floor/generate` — 提交 AI 生成任务 → 返回 `job_id` -- `GET /floor/status?job_id=xxx` — 查询任务进度 +- `GET /floor/status?job_id=xxx` — 查询异步任务进度 ### 用户(需 JWT) - `GET /user/me` / `PUT /user/me` — 个人信息 - `POST /user/avatar` — 上传头像 - `GET /user/favorites` / `POST` / `DELETE` — 收藏管理 - `GET /user/projects` / `POST` / `PUT` / `DELETE` — 项目(生成记录) -- `GET /user/frequent-brands` — 常用品牌(浏览≥3 或 收藏≥2) +- `GET /user/frequent-brands` — 常用品牌(自动统计,浏览≥3 或 收藏≥2) ### 计算器 - `POST /calculator/calc` — 多房间面积+损耗+费用计算 @@ -133,7 +136,7 @@ docker compose up -d --build - `GET /articles/recommend` — 随机推荐 ### 其他 -- `POST /upload` — 通用图片上传 +- `POST /upload` — 通用图片上传(20MB) - `GET /healthz` — 健康检查 ## 数字编码 @@ -165,12 +168,34 @@ docker compose up -d --build ## 产品数据 -10 个品牌,3539 条产品,涵盖 Hardwood / Engineered Wood / Laminate / SPC/LVP / Wood-Look Tile 五大品类。 +10 个品牌,3539 条产品,覆盖 Hardwood / Engineered Wood / Laminate / SPC/LVP / Wood-Look Tile 五大品类。 -数据存储在 `data/products/*.json`,首次使用需运行 `go run cmd/import/main.go` 导入 PostgreSQL。 +数据存储在 `data/products/*.json`(不入 git),首次使用需运行 `go run cmd/import/main.go` 导入 PostgreSQL。 + +## 数据库 + +表结构统一管理在 `schema.sql`,服务启动时不再自动建表。修改表结构后更新此文件即可。 + +查询层使用 GORM + 原生 SQL 混合: +- 简单 CRUD(`GetProductBySKU`)→ GORM +- 复杂查询(动态筛选 `DISTINCT ON`、批量统计)→ 原生 SQL ## 日志 -- **格式**: `2026-07-10 16:30:01 [INFO] main.go:96 Server started` -- **存储**: `logs/app-YYYY-MM-DD.log`,每天一个文件 +- **格式**: `2026-07-10 16:30:01.234 [INFO] main.go:96 Server started` +- **存储**: `logs/app-YYYY-MM-DD.log`,每天自动切文件 - **清理**: 自动删除 30 天前的日志 +- **输出**: 同时写 stdout + 文件 + +## 缓存 + +- **热点产品缓存**: Redis sorted set 统计浏览量,Top 200 自动缓存,TTL 1h ± 10min 随机抖动防雪崩 +- **品牌浏览统计**: 自动记录(查询产品详情时触发) + +## 异步队列 + +AI 图片生成使用 Redis List 做消息队列,Worker 池消费: +- 提交任务 → RPUSH 入队 → 立即返回 `job_id` +- Worker 从 BRPOP 出队 → 调用 OpenRouter → 写入结果 +- 并发数通过 `WORKER_COUNT` 控制,默认 5 +- 进度查询: `GET /floor/status?job_id=xxx` -- 2.52.0 From 5e35532dbb98ea6f8b810bcdb076dee5761e3858 Mon Sep 17 00:00:00 2001 From: dindang Date: Wed, 22 Jul 2026 15:23:40 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(floor):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9C=B0=E6=9D=BF=E6=A0=B7=E5=BC=8F=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=92=8CAI=E6=8D=A2=E5=9C=B0=E6=9D=BF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 FloorOption 结构体字段支持尺寸、描述和变体信息 - 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体 - 区分木材、瓷砖、乙烯基和层压板产品,并分类返回 - 更新室内铺装纹理选项,完善图案描述和名称本地化 - 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段 - 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例 - 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求 - 改进地板识别蒙版生成逻辑,确保精准分割地板区域 - Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息 - 更新go.mod依赖,新增redis和gorm相关包支持 --- .gitignore | 3 + apifox-import.json | 207 ++++++++++++--- cmd/import/main.go | 45 +++- go.mod | 7 +- go.sum | 23 ++ internal/api/index_handler.go | 13 + internal/api/index_html.go | 6 + internal/api/router.go | 1 + internal/handler/floor_handler.go | 379 +++++++++++++++++++++++----- internal/handler/product_handler.go | 2 + internal/model/product.go | 51 ++-- internal/openrouter/image.go | 56 +++- internal/queue/redis.go | 71 ++++-- internal/queue/worker.go | 179 +++++++++++-- internal/repository/product_repo.go | 86 +++++-- main.go | 44 +++- schema.sql | 1 + 17 files changed, 949 insertions(+), 225 deletions(-) create mode 100644 internal/api/index_handler.go create mode 100644 internal/api/index_html.go diff --git a/.gitignore b/.gitignore index 2762182..80303d6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ __MACOSX/ # Claude .claude/ + +# Env +.env diff --git a/apifox-import.json b/apifox-import.json index ed8fb28..804db40 100644 --- a/apifox-import.json +++ b/apifox-import.json @@ -232,6 +232,10 @@ "spec_count": { "type": "integer", "description": "该花色的规格数量(同品牌同花色的SKU数,含自身)。>1 表示有多种尺寸可选" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -407,6 +411,10 @@ "is_favorited": { "type": "boolean", "description": "当前用户是否已收藏。未登录时始终为 false" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -616,7 +624,7 @@ }, "FloorStyleOption": { "type": "object", - "description": "Floor style loaded from product database", + "description": "Floor style loaded from product database. Each entry = one unique style (dedup by brand+style_name). Variants list different sizes.", "properties": { "id": { "type": "string", @@ -628,23 +636,31 @@ }, "name": { "type": "string", - "description": "Style name" + "description": "Style name (花色名称)" }, "material": { "type": "string", - "description": "Material (used in AI prompt)" + "description": "Material (材质,如 Oak/Porcelain/Luxury Vinyl)" }, "color_tone": { "type": "string", - "description": "Color tone (used in AI prompt)" + "description": "Color tone (色调,如 Light Gray/Natural Oak/Dark Brown)" }, "finish": { "type": "string", - "description": "Finish (used in AI prompt)" + "description": "Finish (表面处理,如 Matte/Gloss/Textured)" }, "size_label": { "type": "string", - "description": "Size label, e.g. 6 x 48 in" + "description": "Size label, e.g. 6 x 48 in. 多尺寸时为首个尺寸" + }, + "width_in": { + "type": "number", + "description": "Width in inches. Used in AI prompt for physical scale" + }, + "length_in": { + "type": "number", + "description": "Length in inches. Used in AI prompt for physical scale" }, "image_url": { "type": "string", @@ -652,7 +668,24 @@ }, "category": { "type": "string", - "description": "Product category" + "description": "Product category (Solid Hardwood / Engineered Hardwood / Tile/Stone / SPC-LVP / Laminate)" + }, + "description": { + "type": "string", + "description": "Product description. Used in AI prompt for richer visual detail" + }, + "variants": { + "type": "array", + "description": "Size variants of the same style (same brand+style_name, different SKU/size). Empty array if only one size.", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Variant SKU" }, + "size_label": { "type": "string", "description": "Variant size label" }, + "width_in": { "type": "number" }, + "length_in": { "type": "number" } + } + } } } }, @@ -664,21 +697,21 @@ "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Hardwood + Engineered Wood products" + "description": "Wood products (Oak/Ash/Hickory/Maple etc.) — classified by material field" }, "tile_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Wood-Look Tile + Laminate products" + "description": "Tile/Stone (Porcelain/Stone Look/Glass) + Laminate products" }, "vinyl_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "SPC/LVP products" + "description": "Vinyl products (Luxury Vinyl/Rigid Core)" }, "wood_patterns": { "type": "array", @@ -1817,8 +1850,8 @@ "tags": [ "地板更换" ], - "summary": "获取地板样式选项(从产品库动态加载)", - "description": "从产品数据库(3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU,/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明:\n- wood_floors:实木+实木复合产品\n- tile_floors:瓷砖仿木纹+强化复合产品\n- vinyl_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型", + "summary": "获取地板样式选项(从产品库动态加载,含尺寸变体)", + "description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU,含 width_in/length_in/description/variants 等字段。\n\n材质分类(按 material 字段自动归类):\n- wood_floors:Oak/Ash/Hickory/Maple 等木材\n- tile_floors:Porcelain/Stone Look/Glass + Laminate\n- vinyl_floors:Luxury Vinyl/Rigid Core\n\n前端应使用 variants 渲染尺寸选择面板。", "responses": { "200": { "description": "成功", @@ -1842,59 +1875,95 @@ "wood_floors": [], "tile_floors": [], "vinyl_floors": [], + "wood_floors": [ + { + "id": "CB218LG", + "sku": "CB218LG", + "name": "Cherry", + "material": "Oak", + "color_tone": "Natural Oak", + "finish": "Matte", + "size_label": "2.25 x 84 in", + "width_in": 2.25, + "length_in": 84.0, + "image_url": "https://...", + "category": "Solid Hardwood", + "description": "Natural cherry oak with subtle grain", + "variants": [ + { "sku": "CB4218", "size_label": "4 x 84 in", "width_in": 4.0, "length_in": 84.0 }, + { "sku": "CB5218", "size_label": "5 x 84 in", "width_in": 5.0, "length_in": 84.0 } + ] + } + ], + "tile_floors": [], + "vinyl_floors": [], "wood_patterns": [ { "code": 1, - "name": "Straight Lay", - "pattern": "straight/parallel planks...", + "name": "直铺(工字拼)Straight Lay", + "pattern": "straight/parallel planks running from front to back with staggered joints", "category": "wood" }, { "code": 2, - "name": "Horizontal Lay", - "pattern": "horizontal planks...", + "name": "横铺 Horizontal Lay", + "pattern": "horizontal planks running side to side across the width of the room", "category": "wood" }, { "code": 3, - "name": "Herringbone", - "pattern": "herringbone/zigzag pattern...", + "name": "人字拼 Herringbone", + "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape", "category": "wood" }, { "code": 4, - "name": "Chevron", - "pattern": "chevron pattern...", + "name": "鱼骨拼 Chevron", + "pattern": "chevron pattern with planks cut at an angle forming a continuous V shape", "category": "wood" } ], "tile_patterns": [ { "code": 5, - "name": "Straight Grid", - "pattern": "straight grid layout...", + "name": "直铺(网格对缝)Straight Grid", + "pattern": "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines", "category": "tile" }, { "code": 6, - "name": "Running Bond", - "pattern": "running bond/brick pattern...", + "name": "工字铺(1/2错缝)Running Bond", + "pattern": "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance", + "category": "tile" + }, + { + "code": 7, + "name": "三七错铺 1/3 Offset", + "pattern": "tiles offset by 1/3 of their length in each row, subtle stepped pattern", + "category": "tile" + }, + { + "code": 8, + "name": "六边形 Hexagonal", + "pattern": "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh", + "category": "tile" + }, + { + "code": 9, + "name": "斜铺(45度对角)Diagonal", + "pattern": "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation", "category": "tile" } ], "rooms": [ - { - "code": 0, - "name": "Auto Detect" - }, - { - "code": 1, - "name": "Living Room" - }, - { - "code": 2, - "name": "Bedroom" - } + { "code": 0, "name": "自动识别" }, + { "code": 1, "name": "客厅" }, + { "code": 2, "name": "卧室" }, + { "code": 3, "name": "厨房" }, + { "code": 4, "name": "餐厅" }, + { "code": 5, "name": "浴室" }, + { "code": 6, "name": "书房" }, + { "code": 7, "name": "走廊" } ] } } @@ -1909,8 +1978,8 @@ "tags": [ "地板更换" ], - "summary": "AI换地板(使用真实产品数据)", - "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度", + "summary": "AI换地板(使用真实产品数据,含尺寸/变体)", + "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。用 GET /floor/status?job_id=xxx 查询进度。\n\nfloor_id 支持有尺寸变体的产品——选择变体对应 SKU 即可,AI 会根据 width_in/length_in 生成对应物理尺寸的地板。", "requestBody": { "content": { "multipart/form-data": { @@ -1940,6 +2009,16 @@ "type": "integer", "description": "房间类型编码(0-7),见文档顶部数字编码对照表。0 或省略表示自动检测", "example": 1 + }, + "grout_lines": { + "type": "boolean", + "description": "Optional. Tile floors only. true adds realistic visible grout lines between tiles.", + "example": false + }, + "white_baseboard": { + "type": "boolean", + "description": "Optional. true adds or refreshes a realistic white baseboard/skirting board along visible wall-floor edges.", + "example": false } } } @@ -2204,8 +2283,8 @@ "tags": [ "地板更换" ], - "summary": "查询生成进度(HTTP 轮询)", - "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error", + "summary": "查询生成进度(HTTP 轮询,含耗时统计)", + "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error。\n\n返回 timings_ms 字段包含每步耗时:check/mask/inpaint/cleanup/total。elapsed_ms 为总运行时间。", "parameters": [ { "name": "job_id", @@ -2219,7 +2298,51 @@ ], "responses": { "200": { - "description": "SSE 事件流" + "description": "Job status with timing", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 200 }, + "data": { + "type": "object", + "properties": { + "job_id": { "type": "string" }, + "status": { "type": "string", "description": "pending | processing | done | error" }, + "step": { "type": "string", "description": "check | mask | inpaint | done | error" }, + "message": { "type": "string" }, + "result": { "type": "string", "description": "Generated image path, e.g. /outputs/result_xxx.png" }, + "error": { "type": "string" }, + "elapsed_ms": { "type": "integer", "description": "Total elapsed milliseconds since job start" }, + "timings_ms": { + "type": "object", + "description": "Per-step duration in milliseconds", + "properties": { + "check": { "type": "integer" }, + "mask": { "type": "integer" }, + "inpaint": { "type": "integer" }, + "total": { "type": "integer" } + } + } + } + } + } + }, + "example": { + "code": 200, + "data": { + "job_id": "1784530075874083600", + "status": "done", + "step": "done", + "message": "Done", + "result": "/outputs/result_1784530075874083600.png", + "elapsed_ms": 100340, + "timings_ms": { "check": 5148, "mask": 25971, "inpaint": 69218, "total": 100340 } + } + } + } + } } } } @@ -2328,4 +2451,4 @@ } } } -} \ No newline at end of file +} diff --git a/cmd/import/main.go b/cmd/import/main.go index a688ace..d2b5777 100644 --- a/cmd/import/main.go +++ b/cmd/import/main.go @@ -1,21 +1,52 @@ package main + import ( - "encoding/json"; "fmt"; "os" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "floorvisualizer/internal/model" "floorvisualizer/internal/repository" ) + func main() { - db, _ := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable") + dsn := repository.DefaultDSN + if v := os.Getenv("DATABASE_URL"); v != "" { + dsn = v + } + dataDir := "data/products" + if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" { + dataDir = v + } + + db, err := repository.Connect(dsn) + if err != nil { + panic(err) + } defer db.Close() db.Exec("DELETE FROM products") - files, _ := os.ReadDir("data/products") + files, err := os.ReadDir(dataDir) + if err != nil { + panic(err) + } total := 0 for _, f := range files { - if f.IsDir() { continue } - data, _ := os.ReadFile("data/products/" + f.Name()) + if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dataDir, f.Name())) + if err != nil { + panic(err) + } var prods []model.Product - json.Unmarshal(data, &prods) - repository.InsertProducts(db, prods) + if err := json.Unmarshal(data, &prods); err != nil { + panic(fmt.Errorf("parse %s: %w", f.Name(), err)) + } + if err := repository.InsertProducts(db, prods); err != nil { + panic(err) + } total += len(prods) fmt.Printf(" %s: %d\n", f.Name(), len(prods)) } diff --git a/go.mod b/go.mod index 07dc8eb..6fab5c8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.26.4 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/lib/pq v1.12.3 + github.com/redis/go-redis/v9 v9.21.0 golang.org/x/image v0.43.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.2 ) require ( @@ -16,11 +19,9 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/redis/go-redis/v9 v9.21.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect - gorm.io/driver/postgres v1.6.0 // indirect - gorm.io/gorm v1.31.2 // indirect ) diff --git a/go.sum b/go.sum index e413204..f2ac7f9 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,14 +21,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= @@ -31,11 +48,17 @@ golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go new file mode 100644 index 0000000..67df1db --- /dev/null +++ b/internal/api/index_handler.go @@ -0,0 +1,13 @@ +package api + +import "net/http" + +func Index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(indexHTML)) +} diff --git a/internal/api/index_html.go b/internal/api/index_html.go new file mode 100644 index 0000000..bc888e3 --- /dev/null +++ b/internal/api/index_html.go @@ -0,0 +1,6 @@ +package api + +import _ "embed" + +//go:embed index.html +var indexHTML string diff --git a/internal/api/router.go b/internal/api/router.go index 7526cfb..7d942f8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -63,6 +63,7 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a mux.HandleFunc("/articles/recommend", articleRecommend) mux.HandleFunc("/articles", articles) mux.HandleFunc("/articles/", articlesSub) + mux.HandleFunc("/", Index) // Static files mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index 9ada160..bc4f4ce 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -18,15 +18,19 @@ import ( // ── 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"` - ImageURL string `json:"image_url"` - SKU string `json:"sku"` + 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 { @@ -42,29 +46,29 @@ type RoomOption struct { } var woodPatterns = []PatternOption{ - {Code: 1, Name: "Straight Lay", Category: "wood", Pattern: "straight/parallel planks running from front to back with staggered joints"}, - {Code: 2, Name: "Horizontal Lay", Category: "wood", Pattern: "horizontal planks running side to side across the width of the room"}, - {Code: 3, Name: "Herringbone", Category: "wood", Pattern: "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape"}, - {Code: 4, Name: "Chevron", Category: "wood", Pattern: "chevron pattern with planks cut at an angle forming a continuous V shape"}, + {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: "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines"}, - {Code: 6, Name: "Running Bond", Category: "tile", Pattern: "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance"}, - {Code: 7, Name: "1/3 Offset", Category: "tile", Pattern: "tiles offset by 1/3 of their length in each row, subtle stepped pattern"}, - {Code: 8, Name: "Hexagonal", Category: "tile", Pattern: "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh"}, - {Code: 9, Name: "Diagonal (45deg)", Category: "tile", Pattern: "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation"}, + {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: "Auto Detect"}, - {Code: 1, Name: "Living Room"}, - {Code: 2, Name: "Bedroom"}, - {Code: 3, Name: "Kitchen"}, - {Code: 4, Name: "Dining Room"}, - {Code: 5, Name: "Bathroom"}, - {Code: 6, Name: "Study Room"}, - {Code: 7, Name: "Hallway"}, + {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 { @@ -88,43 +92,109 @@ func findRoomByCode(code int) *RoomOption { return nil } -// loadFloorOptions loads floor options from the database, one per style_name per brand. -func loadFloorOptions(db *sql.DB, category string) ([]FloorOption, error) { - prods, _, err := repository.QueryFilteredProducts(db, repository.FilterParams{ - Category: category, - Limit: 200, - }) +// 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 } - // Dedup by brand+style_name, take first as representative - seen := map[string]bool{} + 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 { - key := p.Brand + "|" + p.StyleName - if seen[key] { - continue + 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, + }) } - seen[key] = true - 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, ImageURL: p.MainImageURL, SKU: p.SKU, - }) } 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 } - return &FloorOption{ + f := &FloorOption{ ID: p.SKU, Name: p.StyleName, Category: p.Category, Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, - SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU, + 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 ──────────────────────────── @@ -145,6 +215,54 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st 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) { @@ -256,14 +374,18 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) { func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error - woodFloors2, _ := loadFloorOptions(db, "Engineered Wood") - tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") - vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") - laminateFloors, _ := loadFloorOptions(db, "Laminate") - - allWood := append(woodFloors, woodFloors2...) - allTile := append(tileFloors, laminateFloors...) + 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, @@ -279,10 +401,25 @@ func FloorOptions(db *sql.DB) http.HandlerFunc { // ── Prompts ──────────────────────────────────────────────── func buildMaskPrompt(room *RoomOption) string { - base := `Create a pure black-and-white mask image for this room photo. -WHITE (#FFFFFF) areas: ONLY the flat horizontal floor. -BLACK (#000000) areas — everything else: stairs, baseboards, walls, furniture, doors, windows, ceiling, decorations. -Output ONLY the mask image. No text.` + 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) } @@ -295,8 +432,7 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name) } matType := "wood plank" - isTile := floor.Category == "Wood-Look Tile" || floor.Category == "Laminate" - if isTile { + if isTileProduct(floor) || isLaminateProduct(floor) { matType = "tile" } @@ -305,12 +441,127 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption 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( - `Replace ONLY the white-masked floor area with %s (%s) %s flooring in a %s pattern.%s -Material details: %s | Layout: %s -Style: photorealistic, soft natural matte finish, minimal reflections, no mirror-like gloss, seamless blend with room lighting.`, - floor.Name, matType, materialDesc, pattern.Name, roomCtx, - materialDesc, pattern.Pattern, + `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, ) } diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go index 25a4a42..c351886 100644 --- a/internal/handler/product_handler.go +++ b/internal/handler/product_handler.go @@ -26,6 +26,7 @@ type ProductItem struct { MainImageURL string `json:"main_image_url"` SizeLabel string `json:"size_label"` CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + IsOfficialPrice bool `json:"is_official_price"` IsFavorited bool `json:"is_favorited"` SpecCount int `json:"spec_count"` } @@ -152,6 +153,7 @@ func Products(db *sql.DB) http.HandlerFunc { PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, CoverageSqftPerBox: p.CoverageSqftPerBox, + IsOfficialPrice: p.IsOfficialPrice, IsFavorited: favSKUs[p.SKU], SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], }) diff --git a/internal/model/product.go b/internal/model/product.go index c5d3e45..7968124 100644 --- a/internal/model/product.go +++ b/internal/model/product.go @@ -1,31 +1,32 @@ package model type Product struct { - Brand string `json:"brand"` - GroupName string `json:"group_name"` - SKU string `json:"sku"` - SeriesName string `json:"series_name"` - StyleName string `json:"style_name"` - Category string `json:"category"` - Material string `json:"material"` - ColorTone string `json:"color_tone"` - Finish string `json:"finish"` - PricePerSqft float64 `json:"price_per_sqft"` - PriceTier string `json:"price_tier"` - PriceSource string `json:"price_source"` - MainImageURL string `json:"main_image_url"` - RoomImageURL string `json:"room_image_url"` - SourceURL string `json:"source_url"` - Description string `json:"description"` - Status string `json:"status"` - CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` - WoodSpecies string `json:"wood_species"` - SizeLabel string `json:"size_label"` - WidthIn float64 `json:"width_in"` - LengthIn float64 `json:"length_in"` - Variants []Product `json:"variants,omitempty"` - Specs []ProductSpec `json:"specs,omitempty"` - IsFavorited bool `json:"is_favorited"` + Brand string `json:"brand"` + GroupName string `json:"group_name"` + SKU string `json:"sku"` + SeriesName string `json:"series_name"` + StyleName string `json:"style_name"` + Category string `json:"category"` + Material string `json:"material"` + ColorTone string `json:"color_tone"` + Finish string `json:"finish"` + PricePerSqft float64 `json:"price_per_sqft"` + PriceTier string `json:"price_tier"` + PriceSource string `json:"price_source"` + IsOfficialPrice bool `json:"is_official_price"` + MainImageURL string `json:"main_image_url"` + RoomImageURL string `json:"room_image_url"` + SourceURL string `json:"source_url"` + Description string `json:"description"` + Status string `json:"status"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + WoodSpecies string `json:"wood_species"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + Variants []Product `json:"variants,omitempty"` + Specs []ProductSpec `json:"specs,omitempty"` + IsFavorited bool `json:"is_favorited"` } // ProductSpec is a single size/price variant of a product style. diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go index f2e872e..ff92d99 100644 --- a/internal/openrouter/image.go +++ b/internal/openrouter/image.go @@ -24,6 +24,8 @@ const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview" const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged." +const referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material." + var supportedAspectRatios = []struct { value string width int @@ -51,6 +53,8 @@ type ImageGenerationRequest struct { InputMimeType string MaskImagePath string MaskMimeType string + ReferenceImagePath string // optional: reference image for floor material style + ReferenceMimeType string AspectRatio string ImageSize string TopP *float64 @@ -284,7 +288,7 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err return nil, stderrors.New("openrouter: mask image requires input image") } - content := make([]ContentPart, 0, 4) + content := make([]ContentPart, 0, 6) if req.InputImagePath != "" { dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType) if err != nil { @@ -306,6 +310,17 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err ImageURL: &ImageURL{URL: dataURI}, }) } + if req.ReferenceImagePath != "" { + dataURI, err := imageFileDataURI(req.ReferenceImagePath, req.ReferenceMimeType) + if err != nil { + return nil, err + } + content = append(content, ContentPart{ + Type: "image_url", + ImageURL: &ImageURL{URL: dataURI}, + }) + content = append(content, ContentPart{Type: "text", Text: referenceImageInstruction}) + } content = append(content, ContentPart{Type: "text", Text: req.Prompt}) return content, nil } @@ -464,3 +479,42 @@ func decodeDataURL(dataURL string) (string, []byte, error) { return mimeType, data, nil } + +// MaxImageDimension is the maximum width or height for images sent to the AI API. +const MaxImageDimension = 2048 + +// ResizeImageToFit reads an image, scales it so the longest side ≤ maxDim pixels, +// and writes the result as PNG. +func ResizeImageToFit(inputPath, outputPath string) error { + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("resize: read input: %w", err) + } + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("resize: decode: %w", err) + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + longest := w + if h > longest { + longest = h + } + dst := src + if longest > MaxImageDimension { + scale := float64(MaxImageDimension) / float64(longest) + newW := int(float64(w)*scale + 0.5) + newH := int(float64(h)*scale + 0.5) + resized := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil) + dst = resized + } + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return fmt.Errorf("resize: encode png: %w", err) + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + return os.WriteFile(outputPath, buf.Bytes(), 0644) +} diff --git a/internal/queue/redis.go b/internal/queue/redis.go index 4036547..f4ad27f 100644 --- a/internal/queue/redis.go +++ b/internal/queue/redis.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "time" "github.com/redis/go-redis/v9" @@ -17,12 +18,14 @@ const ( // JobStatus represents the state of a generation job. type JobStatus struct { - JobID string `json:"job_id"` - Status string `json:"status"` // pending | processing | done | error - Step string `json:"step"` - Message string `json:"message"` - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` + JobID string `json:"job_id"` + Status string `json:"status"` // pending | processing | done | error + Step string `json:"step"` + Message string `json:"message"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + ElapsedMs int64 `json:"elapsed_ms,omitempty"` + TimingsMs map[string]int64 `json:"timings_ms,omitempty"` } // NewRedisClient creates a Redis client and pings to verify connectivity. @@ -42,7 +45,7 @@ func NewRedisClient(addr, password string) (*redis.Client, error) { func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error { pipe := rdb.Pipeline() pipe.RPush(ctx, queueKey, jobID) - pipe.HSet(ctx, jobKey(jobID), "status", "pending") + pipe.HSet(ctx, jobKey(jobID), "status", "pending", "queued_at", nowMillis(), "updated_at", nowMillis()) pipe.Expire(ctx, jobKey(jobID), jobTTL) _, err := pipe.Exec(ctx) return err @@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) { // SetJobStatus updates the job status field. func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", status).Err() + fields := []any{"status", status, "updated_at", nowMillis()} + if status == "processing" { + fields = append(fields, "started_at", nowMillis()) + } + return rdb.HSet(ctx, jobKey(jobID), fields...).Err() } // SetJobProgress updates the step and message for a job. func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error { - return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg).Err() + return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg, "updated_at", nowMillis()).Err() +} + +// SetJobTiming stores a single step duration and the total elapsed time so far. +func SetJobTiming(ctx context.Context, rdb *redis.Client, jobID, step string, duration, elapsed time.Duration) error { + return rdb.HSet(ctx, jobKey(jobID), + step+"_ms", duration.Milliseconds(), + "elapsed_ms", elapsed.Milliseconds(), + "updated_at", nowMillis(), + ).Err() } // SetJobResult marks a job as done with the result image URL. func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url, "updated_at", nowMillis()).Err() } // SetJobError marks a job as errored with an error message. func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg, "updated_at", nowMillis()).Err() } // GetJobStatus retrieves the full status of a job. @@ -90,17 +106,27 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta return nil, fmt.Errorf("job not found") } js := &JobStatus{ - JobID: jobID, - Status: m["status"], - Step: m["step"], - Message: m["message"], - Result: m["result"], - Error: m["error"], + JobID: jobID, + Status: m["status"], + Step: m["step"], + Message: m["message"], + Result: m["result"], + Error: m["error"], + ElapsedMs: parseInt64(m["elapsed_ms"]), + TimingsMs: map[string]int64{}, + } + for _, step := range []string{"check", "mask", "inpaint", "cleanup", "total"} { + if value := parseInt64(m[step+"_ms"]); value > 0 { + js.TimingsMs[step] = value + } + } + if len(js.TimingsMs) == 0 { + js.TimingsMs = nil } return js, nil } -// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path). +// StoreJobPayload saves the job's input parameters and render options. func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error { data, _ := json.Marshal(payload) return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err() @@ -120,3 +146,12 @@ func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[st func jobKey(id string) string { return jobKeyPrefix + id } + +func nowMillis() int64 { + return time.Now().UnixMilli() +} + +func parseInt64(raw string) int64 { + value, _ := strconv.ParseInt(raw, 10, 64) + return value +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index a36e21d..719b5b3 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -1,12 +1,17 @@ package queue import ( + "bytes" "context" "database/sql" + "encoding/json" "floorvisualizer/internal/logger" "fmt" + "io" + "net/http" "os" "path/filepath" + "strings" "time" "github.com/redis/go-redis/v9" @@ -44,7 +49,9 @@ func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrou } func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) { + totalStart := time.Now() SetJobStatus(ctx, rdb, jobID, "processing") + logger.Info("[%s] TIMER job start", jobID) // Load payload payload, err := GetJobPayload(ctx, rdb, jobID) @@ -58,7 +65,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien patternCode := payload["pattern_code"] roomCode := payload["room_code"] - // Look up floor and pattern from DB (same logic as before) + // Look up floor and pattern from DB floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) @@ -67,17 +74,27 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien SetJobError(ctx, rdb, jobID, "Invalid floor or pattern") return } + logger.Info("[%s] TIMER options floor_id=%s pattern_code=%s room_code=%s elapsed=%s", + jobID, floorID, patternCode, roomCode, time.Since(totalStart).Round(time.Millisecond)) + + // Resize input to max 2048px to speed up AI processing + workPath := filepath.Join("uploads", fmt.Sprintf("input_%s_resized.png", jobID)) + if err := openrouter.ResizeImageToFit(inputPath, workPath); err != nil { + logger.Warn("[%s] Failed to resize input, using original: %v", jobID, err) + workPath = inputPath + } - maskPath := filepath.Join("outputs", fmt.Sprintf("mask_%s.png", jobID)) outputPath := filepath.Join("outputs", fmt.Sprintf("result_%s.png", jobID)) - jobCtx, cancel := context.WithTimeout(ctx, 180*time.Second) + jobCtx, cancel := context.WithTimeout(ctx, 600*time.Second) defer cancel() // Step 0: content check SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") - logger.Info("[%s] Content check", jobID) - passed, score := validateContent(client, jobCtx, inputPath) + stepStart := time.Now() + logger.Info("[%s] TIMER step=check start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) + passed, score := validateContent(client, jobCtx, workPath) + logStepDone(jobCtx, rdb, jobID, "check", stepStart, totalStart, "score=%d passed=%t", score, passed) if score == 0 { logger.Warn("[%s] Content check failed (score=0)", jobID) } @@ -86,55 +103,94 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien return } - // Step 1: mask - SetJobProgress(jobCtx, rdb, jobID, "mask", "Generating floor mask...") - logger.Info("[%s] Mask generation", jobID) - maskPrompt := handler.BuildMaskPromptPublic(room) - if _, err := client.RecognizeImage(jobCtx, openrouter.ImageRecognitionRequest{ - Prompt: maskPrompt, InputImagePath: inputPath, OutputPath: maskPath, - }); err != nil { - SetJobError(jobCtx, rdb, jobID, "Mask generation failed: "+err.Error()) - return - } - - // Step 2: inpaint + // Step 1+2 combined: identify floor + replace in one call (no separate mask) roomLabel := "" if room != nil { roomLabel = " · " + room.Name } - SetJobProgress(jobCtx, rdb, jobID, "inpaint", fmt.Sprintf("Applying %s %s%s...", floor.Name, pattern.Name, roomLabel)) - logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name) + SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel)) + stepStart = time.Now() + logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s", + jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond)) - floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room) + refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + + imageSize := floorImageSize() + logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s", + jobID, imageSize, workPath, outputPath, refImagePath) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ - Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath, - MaskImagePath: maskPath, ImageSize: "2K", + Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, + ImageSize: imageSize, ReferenceImagePath: refImagePath, }) if err != nil { + logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err) SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) return } + logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "") // Clean up intermediate files + stepStart = time.Now() + logger.Info("[%s] TIMER step=cleanup start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) if err := os.Remove(inputPath); err != nil { logger.Warn("[%s] Failed to remove input: %v", jobID, err) } - if err := os.Remove(maskPath); err != nil { - logger.Warn("[%s] Failed to remove mask: %v", jobID, err) + if workPath != inputPath { + if err := os.Remove(workPath); err != nil { + logger.Warn("[%s] Failed to remove resized input: %v", jobID, err) + } + } + if refImagePath != "" { + if err := os.Remove(refImagePath); err != nil { + logger.Warn("[%s] Failed to remove reference image: %v", jobID, err) + } } + totalDuration := time.Since(totalStart) + _ = SetJobTiming(jobCtx, rdb, jobID, "total", totalDuration, totalDuration) SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath)) - logger.Info("[%s] Done", jobID) + logger.Info("[%s] TIMER job done total=%s result=%s", jobID, totalDuration.Round(time.Millisecond), "/"+filepath.ToSlash(outputPath)) +} + +func logStepDone(ctx context.Context, rdb *redis.Client, jobID, step string, stepStart, totalStart time.Time, format string, args ...any) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + if format != "" { + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s %s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), fmt.Sprintf(format, args...)) + return + } + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond)) +} + +func logStepError(ctx context.Context, rdb *redis.Client, jobID, step string, totalStart, stepStart time.Time, err error) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + logger.Error("[%s] TIMER step=%s error duration=%s elapsed=%s err=%v", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), err) } // validateContent checks if the image is an indoor room photo. -func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { +// Uses local CLIP ViT-B/32 first, falls back to Gemini 2.5 Flash if CLIP is unavailable. +func validateContent(geminiClient *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { imgData, err := os.ReadFile(imagePath) if err != nil { return false, 0 } + // Try local CLIP server first + if score, ok := clipCheck(imgData); ok { + logger.Info("Content check via local CLIP: score=%d passed=%t", score, score >= 6) + return score >= 6, score + } + // Fallback to Gemini + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() dataURI := "data:image/png;base64," + base64enc(imgData) - resp, err := client.Chat(ctx, openrouter.ChatRequest{ + resp, err := geminiClient.Chat(checkCtx, openrouter.ChatRequest{ Model: "google/gemini-2.5-flash", Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{ {Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}}, @@ -150,6 +206,31 @@ Return ONLY a number (1-10).`}, return score >= 6, score } +// clipCheck sends image bytes to the local CLIP server and returns (score, ok). +func clipCheck(imgData []byte) (int, bool) { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:5100/check", bytes.NewReader(imgData)) + if err != nil { + return 0, false + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, false + } + var result struct { + Score int `json:"score"` + Passed bool `json:"passed"` + } + if json.NewDecoder(resp.Body).Decode(&result) != nil { + return 0, false + } + return result.Score, true +} + 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 { @@ -187,3 +268,47 @@ func base64enc(data []byte) string { } return string(b) } + +// downloadReferenceImage downloads the floor material image from the given URL +// and saves it to a local temp file. Returns the local file path, or empty string on failure. +func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string { + if imageURL == "" { + return "" + } + refPath := filepath.Join("outputs", fmt.Sprintf("ref_%s.png", jobID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + logger.Warn("[%s] Failed to create reference image request: %v", jobID, err) + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Warn("[%s] Failed to download reference image from %s: %v", jobID, imageURL, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logger.Warn("[%s] Reference image download returned HTTP %d from %s", jobID, resp.StatusCode, imageURL) + return "" + } + f, err := os.Create(refPath) + if err != nil { + logger.Warn("[%s] Failed to create reference image file: %v", jobID, err) + return "" + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + logger.Warn("[%s] Failed to save reference image: %v", jobID, err) + return "" + } + logger.Info("[%s] Downloaded reference image from %s", jobID, imageURL) + return refPath +} + +func floorImageSize() string { + value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE")) + if value == "" { + return "2K" + } + return value +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go index 4abfe53..da41f44 100644 --- a/internal/repository/product_repo.go +++ b/internal/repository/product_repo.go @@ -13,28 +13,27 @@ import ( const selectCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + 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)` const insertCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, coverage_sqft_per_box, wood_species, size_label, width_in, length_in` - // ── Import ────────────────────────────────────────────────── func InsertProducts(d *sql.DB, products []model.Product) error { for _, p := range products { _, err := d.Exec(` - INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) `, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, - p.PricePerSqft, p.PriceTier, p.PriceSource, + p.PricePerSqft, p.PriceTier, p.PriceSource, p.IsOfficialPrice, p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status, p.CoverageSqftPerBox, p.WoodSpecies, p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn)) @@ -46,7 +45,9 @@ func InsertProducts(d *sql.DB, products []model.Product) error { } func nf(v float64) interface{} { - if v == 0 { return nil } + if v == 0 { + return nil + } return v } @@ -54,12 +55,12 @@ func nf(v float64) interface{} { type FilterParams struct { SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string - Page, Limit int + Page, Limit int } type FilterOptions struct { - Categories []string `json:"categories"` - SeriesNames []string `json:"series_names"` + Categories []string `json:"categories"` + SeriesNames []string `json:"series_names"` Brands []model.BrandInfo `json:"brands"` } @@ -71,8 +72,12 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) { } func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { - if p.Limit <= 0 { p.Limit = 20 } - if p.Page <= 0 { p.Page = 1 } + if p.Limit <= 0 { + p.Limit = 20 + } + if p.Page <= 0 { + p.Page = 1 + } var conds []string var args []interface{} @@ -101,12 +106,16 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err fmt.Sprintf("brand ILIKE $%d", i+2), } conds = append(conds, "("+strings.Join(clauses, " OR ")+")") - for k := 0; k < 3; k++ { args = append(args, pat) } + for k := 0; k < 3; k++ { + args = append(args, pat) + } i += 3 } where := "" - if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") } + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } // Count distinct styles (dedup same brand+style_name across different sizes) var total int @@ -118,18 +127,29 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err args = append(args, p.Limit, offset) rows, err := d.Query(query, args...) - if err != nil { return nil, 0, err } + if err != nil { + return nil, 0, err + } defer rows.Close() - prods, err := scanProducts(rows) + prods, err := ScanProducts(rows) return prods, total, err } func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) { - var p model.Product - if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil { + rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku) + if err != nil { return nil, err } - return &p, nil + defer rows.Close() + + prods, err := ScanProducts(rows) + if err != nil { + return nil, err + } + if len(prods) == 0 { + return nil, sql.ErrNoRows + } + return &prods[0], nil } // GetVariantsByStyle returns all products with the same brand+style_name but different SKU. @@ -138,13 +158,14 @@ func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) { SELECT `+selectCols+` FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) - AND series_name = (SELECT series_name FROM products WHERE sku = $1) AND sku != $1 ORDER BY width_in, length_in `, sku) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() - return scanProducts(rows) + return ScanProducts(rows) } func VariantCount(d *sql.DB, sku string) int { @@ -190,13 +211,22 @@ func GetSpecCounts(d *sql.DB, skus []string) map[string]int { func GetFilterOptions(d *sql.DB) (*FilterOptions, error) { opts := &FilterOptions{} - for _, q := range []struct{ query string; out *[]string }{ + for _, q := range []struct { + query string + out *[]string + }{ {"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories}, {"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames}, } { rows, err := d.Query(q.query) - if err != nil { return nil, err } - for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) } + if err != nil { + return nil, err + } + for rows.Next() { + var v string + rows.Scan(&v) + *q.out = append(*q.out, v) + } rows.Close() } opts.Brands = GetBrandInfos(d) @@ -254,18 +284,20 @@ func GetBrandInfos(d *sql.DB) []model.BrandInfo { return out } -func scanProducts(rows *sql.Rows) ([]model.Product, error) { +func ScanProducts(rows *sql.Rows) ([]model.Product, error) { var out []model.Product for rows.Next() { var p model.Product if err := rows.Scan( &p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName, &p.Category, &p.Material, &p.ColorTone, &p.Finish, - &p.PricePerSqft, &p.PriceTier, &p.PriceSource, + &p.PricePerSqft, &p.PriceTier, &p.PriceSource, &p.IsOfficialPrice, &p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status, &p.CoverageSqftPerBox, &p.WoodSpecies, &p.SizeLabel, &p.WidthIn, &p.LengthIn, - ); err != nil { return nil, err } + ); err != nil { + return nil, err + } out = append(out, p) } return out, rows.Err() diff --git a/main.go b/main.go index a4d2a59..fa309e2 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ import ( "strconv" "time" + "github.com/joho/godotenv" + "floorvisualizer/internal/api" "floorvisualizer/internal/handler" "floorvisualizer/internal/logger" @@ -37,6 +39,8 @@ func init() { } func main() { + godotenv.Load() + logDir := "logs" if v := os.Getenv("LOG_DIR"); v != "" { logDir = v @@ -85,6 +89,7 @@ func main() { if v := os.Getenv("REDIS_ADDR"); v != "" { redisAddr = v } + var cancelWorkers context.CancelFunc rdb, err := queue.NewRedisClient(redisAddr, "") if err != nil { logger.Warn("Redis not available (%v), worker queue disabled", err) @@ -119,19 +124,11 @@ func main() { workerCount = n } } - workerCtx, cancelWorkers := context.WithCancel(context.Background()) + workerCtx, cancel := context.WithCancel(context.Background()) + cancelWorkers = cancel defer cancelWorkers() queue.StartWorkers(workerCtx, workerCount, rdb, client, db) logger.Info("Worker pool started: %d workers", workerCount) - - // Graceful shutdown - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt) - <-sig - logger.Info("Shutting down workers...") - cancelWorkers() - }() } // ── Hot product cache ──────────────────────────────── @@ -171,6 +168,31 @@ func main() { if p := os.Getenv("PORT"); p != "" { port = p } + + server := &http.Server{ + Addr: ":" + port, + Handler: middleware.Logging(mux), + } + + // Graceful shutdown: Ctrl+C stops HTTP server + worker pool + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + logger.Info("Shutting down...") + if cancelWorkers != nil { + cancelWorkers() + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Warn("HTTP server forced shutdown: %v", err) + } + }() + logger.Info("FloorVisualizer -> http://localhost:%s", port) - log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux))) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } + logger.Info("Server stopped gracefully") } diff --git a/schema.sql b/schema.sql index cd22e3f..bf44a97 100644 --- a/schema.sql +++ b/schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS products ( price_per_sqft NUMERIC(10,2) DEFAULT 0, price_tier TEXT DEFAULT '', price_source TEXT DEFAULT '', + is_official_price BOOLEAN DEFAULT false, main_image_url TEXT DEFAULT '', room_image_url TEXT DEFAULT '', source_url TEXT DEFAULT '', -- 2.52.0 From 33515992b6873d571187a01ec121f051a8a05586 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 14:39:45 +0800 Subject: [PATCH 03/10] v2 --- README.md | 4 + docker-compose.yml | 6 +- internal/handler/floor_handler.go | 447 +++++++++++++++--------------- internal/openrouter/image.go | 127 ++++++++- internal/queue/worker.go | 49 +++- main.go | 54 ++++ 6 files changed, 456 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index cba7e37..3efa71e 100644 --- a/README.md +++ b/README.md @@ -199,3 +199,7 @@ AI 图片生成使用 Redis List 做消息队列,Worker 池消费: - Worker 从 BRPOP 出队 → 调用 OpenRouter → 写入结果 - 并发数通过 `WORKER_COUNT` 控制,默认 5 - 进度查询: `GET /floor/status?job_id=xxx` + "description": "Light to medium golden oak-brown wood tone with warm +- yellow undertones and subtle tonal variation, featuring fine to moderately +- pronounced straight and gentle cathedral grain, a smooth lightly textured surface, +- low satin sheen, and a clean, natural, uniform visual character." diff --git a/docker-compose.yml b/docker-compose.yml index 5ff4426..b362d6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,16 +7,14 @@ services: - "8099:8099" volumes: - floor_outputs:/app/outputs - - floor_outputs:/app/outputs - - floor_logs: - floor_uploads:/app/uploads + - floor_uploads:/app/uploads - floor_logs:/app/logs environment: - PORT=8099 - DATABASE_URL=postgres://postgres:floorviz123@db:5432/floorvisualizer?sslmode=disable - REDIS_ADDR=redis:6379 - JWT_SECRET=change-this-to-a-strong-secret-in-production - - OPENROUTER_API_KEY=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 + - OPENROUTER_API_KEY=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 - WORKER_COUNT=5 - LOG_DIR=/app/logs - LOG_LEVEL=INFO diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index bc4f4ce..33a64b2 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/json" "fmt" "io" "net/http" @@ -15,22 +16,23 @@ import ( "floorvisualizer/internal/repository" ) -// ── Floor options ────────────────────────────────────────── +// 閳光偓閳光偓 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"` + 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 { @@ -48,8 +50,8 @@ type RoomOption struct { 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"}, + {Code: 3, Name: "人字铺 Herringbone", Category: "wood", Pattern: "ALL planks must be IDENTICAL width - no piece may appear narrower. Planks at 90 degrees 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 degree mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"}, } var tilePatterns = []PatternOption{ @@ -57,7 +59,7 @@ var tilePatterns = []PatternOption{ {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"}, + {Code: 9, Name: "斜铺(60度对角)Diagonal", Category: "tile", Pattern: "tiles rotated 60 degrees 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{ @@ -101,7 +103,7 @@ func loadFloorOptions(db *sql.DB) ([]FloorOption, error) { 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`) + FROM products ORDER BY brand, style_name, id LIMIT 5000`) if err != nil { return nil, err } @@ -111,7 +113,7 @@ func loadFloorOptions(db *sql.DB) ([]FloorOption, error) { return nil, err } type key struct{ brand, style string } - seen := map[key]int{} // key → index in out slice + seen := map[key]int{} // key 閳?index in out slice var out []FloorOption for _, p := range prods { k := key{p.Brand, p.StyleName} @@ -189,7 +191,7 @@ func findFloorBySKU(db *sql.DB, sku string) *FloorOption { 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`, + `尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`, canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description, ) } @@ -197,7 +199,7 @@ func findFloorBySKU(db *sql.DB, sku string) *FloorOption { return f } -// ── Public accessors for worker ──────────────────────────── +// 閳光偓閳光偓 Public accessors for worker 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) } func FindPatternByCodePublic(code string) *PatternOption { @@ -212,58 +214,42 @@ func FindRoomByCodePublic(code string) *RoomOption { } func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) } func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string { - return buildFloorPrompt(f, p, room) + 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) 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, - ) +// 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 `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.` + return "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。" 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.` + return "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。" 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.` + return "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。" 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.` + return "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。" 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".` + return "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 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".` + return "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。" 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.` + return "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。" 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.` + return "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。" 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".` + return "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。" default: return "" } } -// ── Floor Generate Handler (Redis queue) ────────────────── +// 閳光偓閳光偓 Floor Generate Handler (Redis queue) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) { generateQueue = enqueue @@ -334,10 +320,13 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { } // 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"), + "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") @@ -348,9 +337,9 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { } } -// ── Progress ─────────────────────────────────────────────── +// 閳光偓閳光偓 Progress 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 -// ── Floor Status Query ───────────────────────────────── +// 閳光偓閳光偓 Floor Status Query 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FloorStatus(w http.ResponseWriter, r *http.Request) { jobID := r.URL.Query().Get("job_id") @@ -370,7 +359,7 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, js) } -// ── Options ──────────────────────────────────────────────── +// 閳光偓閳光偓 Options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -387,185 +376,201 @@ func FloorOptions(db *sql.DB) http.HandlerFunc { } } + // 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, - "rooms": roomOptions, + "wood_floors": allWood, + "wood_patterns": woodPatterns, + "tile_floors": allTile, + "tile_patterns": tilePatterns, + "vinyl_floors": vinylFloors, + "special_floors": specialFloors, + "rooms": roomOptions, }) } } -// ── Prompts ──────────────────────────────────────────────── +// 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 := `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.` + base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" + + "严格定义:\n" + + "- 白色(#FFFFFF):只表示平整、水平、可行走的地面。包含所有可见地面,一直到墙面、踢脚线、柜体或楼梯边缘。\n" + + "- 黑色(#000000):所有非水平地面的内容,包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" + + "边缘规则:\n" + + "- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" + + "- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" + + "- 楼梯和地毯都标为黑色。\n\n" + + "只输出遮罩图片,不要文字说明。" if room != nil && room.Code != 0 { - base += fmt.Sprintf("\nContext: This is a %s.", room.Name) + base += fmt.Sprintf("\n房间类型:%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" +func buildMaterialRules(floor FloorOption) string { if isTileProduct(floor) || isLaminateProduct(floor) { - matType = "tile" + 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 } - 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) + var priority []string + if whiteBaseboard { + priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。") + } + if groutLines { + priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。") } - // Include product description if available for richer visual detail - var descriptionText string - if floor.Description != "" { - descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description) + priorityText := "" + if len(priority) > 0 { + priorityText = strings.Join(priority, "\n\n") + "\n\n" } - 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.`, - ) + editScope := "只替换地面" + if whiteBaseboard { + editScope = "只替换地面和白色踢脚线" } - // 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, + return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", + priorityText, + roomName, + editScope, + buildCompactFloorPrompt(f, p, room), ) } -// ── Content Check ───────────────────────────────────────── +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 floor.SizeLabel != "" || (floor.WidthIn > 0 && floor.LengthIn > 0) { + details = append(details, compactSizeInstruction(floor)) + } + 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) string { + if floor.SizeLabel != "" { + return "尺寸规则:使用所选尺寸 " + floor.SizeLabel + ";只调整板/砖大小和缝距,不改变材质颜色和纹理。" + } + if floor.WidthIn > 0 && floor.LengthIn > 0 { + return fmt.Sprintf("尺寸规则:每片 %.1f\" x %.1f\";只调整板/砖大小和缝距,不改变材质颜色和纹理。", floor.WidthIn, floor.LengthIn) + } + return "" +} + +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) @@ -573,13 +578,12 @@ func validateContent(client *openrouter.Client, ctx context.Context, imagePath s 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: `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).`}, + {Type: "text", Text: prompt}, }}}, }) if err != nil || len(resp.Choices) == 0 { @@ -588,7 +592,6 @@ Return ONLY a number (1-10).`}, 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 { @@ -626,3 +629,13 @@ func b64enc(data []byte) string { } 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" + } +} diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go index ff92d99..b696efe 100644 --- a/internal/openrouter/image.go +++ b/internal/openrouter/image.go @@ -7,6 +7,7 @@ import ( stderrors "errors" "fmt" "image" + "image/color" _ "image/gif" _ "image/jpeg" "image/png" @@ -22,9 +23,9 @@ import ( const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview" -const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged." +const maskImageInstruction = "下一张图片是遮罩图。非黑色像素表示允许编辑的区域;黑色像素表示必须保持不变的保护区域。" -const referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material." +const referenceImageInstruction = "下一张图片是地板材质参考图。生成新地面时必须严格匹配它的颜色、纹理、图案、光泽和整体材质外观;生成的地面应看起来就是这个材质。" var supportedAspectRatios = []struct { value string @@ -518,3 +519,125 @@ func ResizeImageToFit(inputPath, outputPath string) error { } return os.WriteFile(outputPath, buf.Bytes(), 0644) } + +// RepairNearBlackBorder removes accidental model-added black canvas edges. +// It only acts when an entire edge row/column is almost uniformly black. +func RepairNearBlackBorder(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("repair border: read image: %w", err) + } + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("repair border: decode image: %w", err) + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w < 8 || h < 8 { + return nil + } + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + + maxBorder := minInt(32, minInt(w, h)/20) + if maxBorder < 2 { + maxBorder = 2 + } + + top := 0 + for top < maxBorder && isNearBlackRow(img, top) { + top++ + } + bottom := 0 + for bottom < maxBorder && isNearBlackRow(img, h-1-bottom) { + bottom++ + } + left := 0 + for left < maxBorder && isNearBlackCol(img, left) { + left++ + } + right := 0 + for right < maxBorder && isNearBlackCol(img, w-1-right) { + right++ + } + if top == 0 && bottom == 0 && left == 0 && right == 0 { + return nil + } + if top+bottom >= h || left+right >= w { + return nil + } + + for y := 0; y < top; y++ { + copyRow(img, top, y) + } + for y := h - bottom; y < h; y++ { + copyRow(img, h-bottom-1, y) + } + for x := 0; x < left; x++ { + copyCol(img, left, x) + } + for x := w - right; x < w; x++ { + copyCol(img, w-right-1, x) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return fmt.Errorf("repair border: encode png: %w", err) + } + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("repair border: write image: %w", err) + } + return nil +} + +func isNearBlackRow(img *image.RGBA, y int) bool { + w := img.Bounds().Dx() + black := 0 + for x := 0; x < w; x++ { + if isNearBlack(img.RGBAAt(x, y)) { + black++ + } + } + return black*100 >= w*96 +} + +func isNearBlackCol(img *image.RGBA, x int) bool { + h := img.Bounds().Dy() + black := 0 + for y := 0; y < h; y++ { + if isNearBlack(img.RGBAAt(x, y)) { + black++ + } + } + return black*100 >= h*96 +} + +func isNearBlack(c color.RGBA) bool { + return c.A > 0 && c.R <= 18 && c.G <= 18 && c.B <= 18 +} + +func copyRow(img *image.RGBA, srcY, dstY int) { + w := img.Bounds().Dx() + for x := 0; x < w; x++ { + img.SetRGBA(x, dstY, img.RGBAAt(x, srcY)) + } +} + +func copyCol(img *image.RGBA, srcX, dstX int) { + h := img.Bounds().Dy() + for y := 0; y < h; y++ { + img.SetRGBA(dstX, y, img.RGBAAt(srcX, y)) + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index 719b5b3..203b7be 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -69,6 +69,12 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) + groutLines := payloadBool(payload, "grout_lines") + whiteBaseboard := payloadBool(payload, "white_baseboard") + materialSource := payload["material_source"] // "image", "json", or "both" + if materialSource == "" { + materialSource = "image" + } if floor == nil || pattern == nil { SetJobError(ctx, rdb, jobID, "Invalid floor or pattern") @@ -103,7 +109,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien return } - // Step 1+2 combined: identify floor + replace in one call (no separate mask) + // Step 1: generate floor (single combined prompt — AI identifies floor + replaces it) roomLabel := "" if room != nil { roomLabel = " · " + room.Name @@ -113,12 +119,25 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s", jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond)) - refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + // Material source: image / json / both + if materialSource == "json" || materialSource == "both" { + if desc := handler.LookupMaterialDesc(floor.SKU); desc != "" { + if floor.Description != "" { + floor.Description = desc + " | " + floor.Description + } else { + floor.Description = desc + } + } + } + refImagePath := "" + if materialSource != "json" { + refImagePath = downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + } imageSize := floorImageSize() - logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s", - jobID, imageSize, workPath, outputPath, refImagePath) - floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room) + logger.Info("[%s] TIMER generate request material=%s image_size=%s input=%s output=%s ref=%s", + jobID, materialSource, imageSize, workPath, outputPath, refImagePath) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutLines, whiteBaseboard) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, ImageSize: imageSize, ReferenceImagePath: refImagePath, @@ -128,6 +147,9 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) return } + if err := openrouter.RepairNearBlackBorder(outputPath); err != nil { + logger.Warn("[%s] Failed to repair output border: %v", jobID, err) + } logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "") // Clean up intermediate files @@ -194,9 +216,11 @@ func validateContent(geminiClient *openrouter.Client, ctx context.Context, image 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).`}, + {Type: "text", Text: `请按 1-10 分评估这张图片是否适合做室内地面替换。 +8-10 分:清晰室内房间,地面可见。 +5-7 分:室内图片,但角度或地面可见度一般。 +1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。 +只返回一个数字(1-10)。`}, }}}, }) if err != nil || len(resp.Choices) == 0 { @@ -305,6 +329,15 @@ func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string return refPath } +func payloadBool(payload map[string]string, key string) bool { + switch payload[key] { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + func floorImageSize() string { value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE")) if value == "" { diff --git a/main.go b/main.go index fa309e2..ccc629a 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "math/rand" "net/http" "os" + "os/exec" "os/signal" "strconv" "time" @@ -41,6 +42,19 @@ func init() { func main() { godotenv.Load() + // Load material description index + if err := handler.LoadMaterialIndex("outputs/material_index.json"); err != nil { + logger.Warn("Material index not loaded: %v", err) + } else { + logger.Info("Material index loaded") + } + + // Start CLIP server for content check + clipCmd := startCLIPServer() + if clipCmd != nil { + defer stopCLIPServer(clipCmd) + } + logDir := "logs" if v := os.Getenv("LOG_DIR"); v != "" { logDir = v @@ -183,6 +197,7 @@ func main() { if cancelWorkers != nil { cancelWorkers() } + stopCLIPServer(clipCmd) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { @@ -196,3 +211,42 @@ func main() { } logger.Info("Server stopped gracefully") } + +// startCLIPServer launches the Python CLIP server for content checking. +func startCLIPServer() *exec.Cmd { + // Check if CLIP is already running + if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil { + logger.Info("CLIP server already running on :5100") + return nil + } + pythonPath := `C:\Users\10794\AppData\Local\Programs\Python\Python312\python.exe` + cmd := exec.Command(pythonPath, "clip_server/server.py") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + logger.Warn("Failed to start CLIP server: %v (content check will use Gemini fallback)", err) + return nil + } + logger.Info("CLIP server starting on :5100...") + // Wait for it to be ready + for i := 0; i < 30; i++ { + time.Sleep(time.Second) + if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil { + logger.Info("CLIP server ready") + return cmd + } + } + logger.Warn("CLIP server did not become ready in time") + return cmd +} + +// stopCLIPServer stops the CLIP Python server. +func stopCLIPServer(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + logger.Info("Stopping CLIP server...") + if err := cmd.Process.Kill(); err != nil { + logger.Warn("Failed to stop CLIP server: %v", err) + } +} -- 2.52.0 From 202e36027a32c0f4a1ae2c39e9f7c723571f6d70 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 15:07:12 +0800 Subject: [PATCH 04/10] v2 --- internal/handler/floor_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index 33a64b2..ad8d8d0 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -496,7 +496,7 @@ func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomO editScope = "只替换地面和白色踢脚线" } - return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", + return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", priorityText, roomName, editScope, -- 2.52.0 From 1e1ffb23f33ba5b327cb6d98913826a33f9da393 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 15:34:32 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E9=97=AE=E5=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 25 + clip_server/server.py | 90 +++ internal/api/index.html | 440 +++++++++++++ internal/api/index_handler.go | 10 + internal/api/index_html.go | 3 + internal/api/questionnaire.html | 359 +++++++++++ internal/api/router.go | 5 + internal/handler/questionnaire_handler.go | 713 ++++++++++++++++++++++ internal/repository/product_repo.go | 9 + 9 files changed, 1654 insertions(+) create mode 100644 .env.example create mode 100644 clip_server/server.py create mode 100644 internal/api/index.html create mode 100644 internal/api/questionnaire.html create mode 100644 internal/handler/questionnaire_handler.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66470e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# ─── 服务器 ─── +PORT=8099 + +# ─── 数据库 ─── +DATABASE_URL=postgres://postgres:your_password@localhost:5432/floorvisualizer?sslmode=disable + +# ─── Redis ─── +REDIS_ADDR=localhost:6379 + +# ─── JWT ─── +JWT_SECRET=change-this-to-a-strong-secret + +# ─── AI ─── +OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here + +# ─── 代理(国内环境用,留空则直连) ─── +PROXY_URL=http://127.0.0.1:7897 +# DISABLE_PROXY=true + +# ─── 队列 ─── +WORKER_COUNT=5 + +# ─── 日志 ─── +LOG_DIR=logs +LOG_LEVEL=INFO diff --git a/clip_server/server.py b/clip_server/server.py new file mode 100644 index 0000000..57c3bdb --- /dev/null +++ b/clip_server/server.py @@ -0,0 +1,90 @@ +"""CLIP-based indoor room classifier. Runs ViT-B/32 on CPU, ~100ms per image.""" + +import io +import logging +import os + +import open_clip +import torch +from PIL import Image +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("clip-server") +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB max upload + +# Load once at startup +MODEL_NAME = os.getenv("CLIP_MODEL", "ViT-B-32") +PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s34b_b79k") +DEVICE = "cuda" if torch.cuda.is_available() else "cpu" + +log.info(f"Loading CLIP model: {MODEL_NAME} pretrained={PRETRAINED} device={DEVICE}") +model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED) +model = model.to(DEVICE).eval() +tokenizer = open_clip.get_tokenizer(MODEL_NAME) + +# Text prompts for indoor/outdoor classification +PROMPTS = [ + "a photo of an indoor room interior with floor visible", + "a photo of an outdoor scene, landscape, street, or non-room image", +] + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok", "model": MODEL_NAME, "device": DEVICE}) + + +@app.route("/check", methods=["POST"]) +def check(): + """Classify whether an image is an indoor room photo suitable for floor replacement. + Expects raw image bytes as the request body. Returns {score: 0-10, passed: bool}.""" + if not request.data: + return jsonify({"error": "no image data"}), 400 + + try: + img = Image.open(io.BytesIO(request.data)).convert("RGB") + except Exception as e: + return jsonify({"error": f"bad image: {e}"}), 400 + + # Multiple prompt pairs for robust ensemble (indoor vs outdoor) + prompt_pairs = [ + ("a photo of an indoor room interior with floor", "a photo of an outdoor scene"), + ("interior room photograph, real estate photo", "exterior photograph, outdoor photo"), + ("an indoor space with visible flooring", "an outdoor space, landscape, street view"), + ] + + image_input = preprocess(img).unsqueeze(0).to(DEVICE) + wins = 0 + gap_sum = 0.0 + for indoor_text, outdoor_text in prompt_pairs: + text_input = tokenizer([indoor_text, outdoor_text]).to(DEVICE) + with torch.no_grad(): + image_features = model.encode_image(image_input) + text_features = model.encode_text(text_input) + image_features /= image_features.norm(dim=-1, keepdim=True) + text_features /= text_features.norm(dim=-1, keepdim=True) + raw = (image_features @ text_features.T)[0] # cosine similarities + indoor_sim = float(raw[0].item()) + outdoor_sim = float(raw[1].item()) + if indoor_sim > outdoor_sim: + wins += 1 + gap_sum += indoor_sim - outdoor_sim # positive = indoor wins + + indoor_wins = wins >= 2 # majority vote (2 out of 3) + avg_gap = gap_sum / len(prompt_pairs) + # Map avg_gap to 1-10: gap ~0.05 = score 6, gap ~0.15+ = score 10 + score_1_10 = max(1, min(10, int(5 + avg_gap * 40))) + passed = indoor_wins + + log.info( + f"wins={wins}/3 avg_gap={avg_gap:.3f} score_1_10={score_1_10} passed={passed}" + ) + return jsonify({"score": score_1_10, "passed": passed}) + + +if __name__ == "__main__": + port = int(os.getenv("CLIP_PORT", "5100")) + log.info(f"CLIP server listening on :{port}") + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/internal/api/index.html b/internal/api/index.html new file mode 100644 index 0000000..bc93fe8 --- /dev/null +++ b/internal/api/index.html @@ -0,0 +1,440 @@ + + + + + +FloorVisualizer Demo + + + +
+

FloorVisualizer AI 换地板 Demo

+

上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览

+
+ +
+
+
+

第一步:上传房间照片

+
+ + + 点击或拖拽上传图片
建议使用能清楚看到地面的室内照片
+
+
+ +
+

房间类型

+
+
+ +
+

地板类型

+
+
木地板
Hardwood
+
瓷砖
Tile / Stone
+
乙烯基地板
Luxury Vinyl / SPC
+
特殊测试
7款 · 含AI描述
+
+
+ +
+

铺设方式

+
+
+ + + +
+

选择地板款式

+
+
+
+ +
+

材质参考方式

+
+
仅图片
参考图
+
仅JSON
材质描述
+
图片+JSON
两者都用
+
+
+ +
+

可选效果

+
+ + +
+
+ + +
+
+
+ +
+
+
+

生成失败原因

+

+    
+
+ +
+

生成结果

+
上传图片并选择地板选项后
点击生成预览图查看效果
+ 生成结果 +
+ 生成图 +
原图
+
+
原图生成图
+
+
+
+ + + + diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go index 67df1db..02d6e64 100644 --- a/internal/api/index_handler.go +++ b/internal/api/index_handler.go @@ -11,3 +11,13 @@ func Index(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(indexHTML)) } + +func Questionnaire(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/questionnaire" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(questionnaireHTML)) +} diff --git a/internal/api/index_html.go b/internal/api/index_html.go index bc888e3..f700edb 100644 --- a/internal/api/index_html.go +++ b/internal/api/index_html.go @@ -4,3 +4,6 @@ import _ "embed" //go:embed index.html var indexHTML string + +//go:embed questionnaire.html +var questionnaireHTML string diff --git a/internal/api/questionnaire.html b/internal/api/questionnaire.html new file mode 100644 index 0000000..fa8a64d --- /dev/null +++ b/internal/api/questionnaire.html @@ -0,0 +1,359 @@ + + + + + +地板偏好问卷 - FloorVisualizer + + + +
+ + +
+
+
+

问卷推荐结果

+

回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。

+
+
+
-候选 SKU
+
-去重款式
+
0%问卷完成度
+
+
+
+
请选择你的空间、风格、外观、颜色和预算。
+
+
+
+ + + + diff --git a/internal/api/router.go b/internal/api/router.go index 7d942f8..238ac88 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -32,6 +32,8 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a productOpts = handler.ProductOptions(db) filterOpts = handler.FilterOptions(db) recommend = handler.Recommend(db) + questionnaireRec = handler.QuestionnaireRecommend(db) + questionnaireOpt = handler.QuestionnaireOptions(db) floorGenerate = handler.FloorGenerate(client, db) floorOptions = handler.FloorOptions(db) articleRecommend = handler.ArticleRecommend(db) @@ -56,6 +58,9 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a mux.HandleFunc("/product-options", productOpts) mux.HandleFunc("/filter-options", filterOpts) mux.HandleFunc("/recommend/api", recommend) + mux.HandleFunc("/questionnaire", Questionnaire) + mux.HandleFunc("/questionnaire/options", questionnaireOpt) + mux.HandleFunc("/questionnaire/recommend", questionnaireRec) mux.HandleFunc("/calculator/calc", handler.Calc) mux.HandleFunc("/floor/generate", floorGenerate) mux.HandleFunc("/floor/status", handler.FloorStatus) diff --git a/internal/handler/questionnaire_handler.go b/internal/handler/questionnaire_handler.go new file mode 100644 index 0000000..8ac90db --- /dev/null +++ b/internal/handler/questionnaire_handler.go @@ -0,0 +1,713 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strings" + + "floorvisualizer/internal/model" + "floorvisualizer/internal/repository" +) + +type QuestionnaireBudget struct { + Min float64 `json:"min"` + Max float64 `json:"max"` +} + +type QuestionnaireRequest struct { + Rooms []string `json:"rooms"` + Styles []string `json:"styles"` + Looks []string `json:"looks"` + ColorTones []string `json:"color_tones"` + Budget QuestionnaireBudget `json:"budget"` + Brands []string `json:"brands"` + BrandMode string `json:"brand_mode"` + Performance []string `json:"performance"` + Finishes []string `json:"finishes"` + SizePreference string `json:"size_preference"` + Priority string `json:"priority"` + Answers map[string]any `json:"answers,omitempty"` + Limit int `json:"limit"` +} + +type QuestionnaireRecommendation struct { + Product model.Product `json:"product"` + Score float64 `json:"score"` + Reasons []string `json:"reasons"` + SpecCount int `json:"spec_count"` +} + +type QuestionnaireResponse struct { + Recommendations []QuestionnaireRecommendation `json:"recommendations"` + TotalCandidates int `json:"total_candidates"` + UniqueCandidates int `json:"unique_candidates"` + AnsweredWeights float64 `json:"answered_weights"` +} + +type questionnaireScoredProduct struct { + product model.Product + score float64 + reasons []string +} + +func QuestionnaireOptions(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if db == nil { + writeErr(w, http.StatusServiceUnavailable, "database is not available") + return + } + out := map[string]any{ + "brands": queryOptionCounts(db, "brand", 20), + "categories": queryOptionCounts(db, "category", 20), + "materials": queryOptionCounts(db, "material", 40), + "color_tones": queryOptionCounts(db, "color_tone", 30), + "finishes": queryOptionCounts(db, "finish", 20), + "price_stats": queryPriceStats(db), + } + writeJSON(w, http.StatusOK, out) + } +} + +func QuestionnaireRecommend(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if db == nil { + writeErr(w, http.StatusServiceUnavailable, "database is not available") + return + } + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req QuestionnaireRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid questionnaire payload") + return + } + normalizeQuestionnaireRequest(&req) + if req.Limit <= 0 || req.Limit > 30 { + req.Limit = 10 + } + + products, err := repository.QueryAllProductRows(db) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + + brandOnly := req.BrandMode == "only" && len(req.Brands) > 0 + brandSet := toSet(req.Brands) + scored := make([]questionnaireScoredProduct, 0, len(products)) + for _, p := range products { + if !recommendableProduct(p) { + continue + } + if brandOnly && !brandSet[p.Brand] { + continue + } + score, reasons, answeredWeight := scoreQuestionnaireProduct(req, p) + if answeredWeight == 0 { + score = neutralProductScore(p) + } + scored = append(scored, questionnaireScoredProduct{product: p, score: roundOne(score), reasons: reasons}) + } + + unique := dedupeQuestionnaireResults(scored) + sort.SliceStable(unique, func(i, j int) bool { + if unique[i].score != unique[j].score { + return unique[i].score > unique[j].score + } + if unique[i].product.IsOfficialPrice != unique[j].product.IsOfficialPrice { + return unique[i].product.IsOfficialPrice + } + if req.Priority == "budget" && unique[i].product.PricePerSqft != unique[j].product.PricePerSqft { + return unique[i].product.PricePerSqft < unique[j].product.PricePerSqft + } + return unique[i].product.MainImageURL != "" && unique[j].product.MainImageURL == "" + }) + + selected := diversifyQuestionnaireResults(unique, req.Limit, brandOnly) + skus := make([]string, 0, len(selected)) + for _, r := range selected { + skus = append(skus, r.product.SKU) + } + specCounts := repository.GetSpecCounts(db, skus) + + recs := make([]QuestionnaireRecommendation, 0, len(selected)) + for _, r := range selected { + key := r.product.Brand + "|" + r.product.StyleName + "|" + r.product.SeriesName + recs = append(recs, QuestionnaireRecommendation{ + Product: r.product, + Score: r.score, + Reasons: fallbackReasons(r.reasons, r.product), + SpecCount: specCounts[key], + }) + } + + _, _, answeredWeight := scoreQuestionnaireProduct(req, model.Product{}) + writeJSON(w, http.StatusOK, QuestionnaireResponse{ + Recommendations: recs, + TotalCandidates: len(scored), + UniqueCandidates: len(unique), + AnsweredWeights: roundOne(answeredWeight), + }) + } +} + +func normalizeQuestionnaireRequest(req *QuestionnaireRequest) { + req.Rooms = cleanList(req.Rooms) + req.Styles = cleanList(req.Styles) + req.Looks = cleanList(req.Looks) + req.ColorTones = cleanList(req.ColorTones) + req.Brands = cleanList(req.Brands) + req.Performance = cleanList(req.Performance) + req.Finishes = cleanList(req.Finishes) + req.SizePreference = strings.TrimSpace(req.SizePreference) + req.Priority = strings.TrimSpace(req.Priority) + req.BrandMode = strings.TrimSpace(req.BrandMode) +} + +func scoreQuestionnaireProduct(req QuestionnaireRequest, p model.Product) (float64, []string, float64) { + weights := map[string]float64{ + "color": 20, + "style": 20, + "look": 15, + "budget": 15, + "room": 10, + "performance": 10, + "brand": 7, + "finish": 5, + "size": 3, + } + adjustWeights(weights, req.Priority) + + totalWeight := 0.0 + weighted := 0.0 + reasons := []string{} + + add := func(name string, answered bool, score float64, reason string) { + if !answered { + return + } + w := weights[name] + totalWeight += w + weighted += clampScore(score) * w + if score >= 78 && reason != "" { + reasons = append(reasons, reason) + } + } + + add("color", len(req.ColorTones) > 0, scoreExactOrRelated(p.ColorTone, req.ColorTones, colorRelatedGroups()), fmt.Sprintf("颜色接近 %s", p.ColorTone)) + add("style", len(req.Styles) > 0, scoreStyles(p, req.Styles), "风格偏好匹配") + add("look", len(req.Looks) > 0, scoreLooks(p, req.Looks), "外观类型匹配") + add("budget", req.Budget.Min > 0 || req.Budget.Max > 0, scoreBudget(p.PricePerSqft, req.Budget), priceReason(p)) + add("room", len(req.Rooms) > 0, scoreRooms(p, req.Rooms), "适合选择的使用空间") + add("performance", len(req.Performance) > 0, scorePerformance(p, req.Performance), "性能偏好匹配") + add("brand", len(req.Brands) > 0 && req.BrandMode != "only", scoreExactOrRelated(p.Brand, req.Brands, nil), fmt.Sprintf("匹配品牌 %s", p.Brand)) + add("finish", len(req.Finishes) > 0, scoreExactOrRelated(p.Finish, req.Finishes, [][]string{{"Matte", "Textured"}, {"Distressed/Embossed", "Wire Brushed"}}), fmt.Sprintf("表面质感为 %s", p.Finish)) + add("size", req.SizePreference != "", scoreSize(p, req.SizePreference), "规格视觉匹配") + + if totalWeight == 0 { + return 0, reasons, 0 + } + + score := weighted / totalWeight + if p.MainImageURL != "" || p.RoomImageURL != "" { + score += 1.5 + } + if p.IsOfficialPrice { + score += 1 + } + return clampScore(score), compactReasons(reasons), totalWeight +} + +func adjustWeights(weights map[string]float64, priority string) { + switch priority { + case "style": + weights["style"] += 8 + weights["color"] += 4 + case "budget": + weights["budget"] += 12 + case "durability": + weights["performance"] += 10 + weights["room"] += 4 + case "brand": + weights["brand"] += 12 + case "color": + weights["color"] += 12 + } +} + +func recommendableProduct(p model.Product) bool { + status := strings.ToLower(strings.TrimSpace(p.Status)) + if strings.Contains(status, "discontinued") || strings.Contains(status, "inactive") || strings.Contains(status, "下架") { + return false + } + return p.SKU != "" && p.StyleName != "" +} + +func neutralProductScore(p model.Product) float64 { + score := 60.0 + if p.MainImageURL != "" || p.RoomImageURL != "" { + score += 8 + } + if p.PricePerSqft > 0 { + score += 5 + } + if p.IsOfficialPrice { + score += 3 + } + return score +} + +func scoreExactOrRelated(value string, selected []string, related [][]string) float64 { + if value == "" || len(selected) == 0 { + return 50 + } + for _, s := range selected { + if strings.EqualFold(value, s) { + return 100 + } + } + for _, group := range related { + if containsFold(group, value) { + for _, s := range selected { + if containsFold(group, s) { + return 70 + } + } + } + } + return 15 +} + +func colorRelatedGroups() [][]string { + return [][]string{ + {"Natural", "Warm Yellow", "Taupe"}, + {"Light Gray", "Off White", "Taupe"}, + {"Dark Brown", "Black Walnut", "Cherry"}, + {"Green", "Blue", "Light Gray"}, + } +} + +func scoreStyles(p model.Product, styles []string) float64 { + best := 0.0 + for _, style := range styles { + best = math.Max(best, scoreStyle(p, style)) + } + return best +} + +func scoreStyle(p model.Product, style string) float64 { + text := productText(p) + switch style { + case "modern": + return tagScore(p, []string{"Light Gray", "Off White", "Natural"}, []string{"Maple", "Ash", "Porcelain"}, []string{"modern", "clean", "minimal", "airy", "sophisticated"}, text) + case "warm_natural": + return tagScore(p, []string{"Natural", "Warm Yellow", "Taupe"}, []string{"Oak", "Hickory", "Pine", "Ash"}, []string{"warm", "natural", "wood", "honey"}, text) + case "classic": + return tagScore(p, []string{"Cherry", "Dark Brown", "Black Walnut", "Natural"}, []string{"Oak", "Walnut", "Maple", "Hickory"}, []string{"classic", "traditional", "timeless"}, text) + case "rustic": + score := tagScore(p, []string{"Natural", "Dark Brown", "Taupe"}, []string{"Hickory", "Oak", "Pine"}, []string{"rustic", "scraped", "weathered", "reclaimed", "vintage", "barn"}, text) + if p.Finish == "Distressed/Embossed" || p.Finish == "Wire Brushed" { + score += 20 + } + return clampScore(score) + case "luxury_stone": + score := tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Porcelain", "Stone Look"}, []string{"marble", "travertine", "polished", "stone", "luxe"}, text) + if p.Category == "Tile/Stone" { + score += 25 + } + if p.Finish == "Gloss" { + score += 10 + } + return clampScore(score) + case "industrial": + return tagScore(p, []string{"Light Gray", "Black Walnut", "Dark Brown"}, []string{"Porcelain", "Stone Look"}, []string{"concrete", "slate", "smoke", "graphite", "cement", "industrial"}, text) + case "coastal": + return tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Oak", "Maple", "Ash"}, []string{"beach", "coastal", "sand", "airy", "white"}, text) + } + return 50 +} + +func tagScore(p model.Product, colors, materials, keywords []string, text string) float64 { + score := 15.0 + if containsFold(colors, p.ColorTone) { + score += 30 + } + if containsFold(materials, p.Material) || materialFamilyHit(p.Material, materials) { + score += 25 + } + for _, kw := range keywords { + if strings.Contains(text, kw) { + score += 15 + break + } + } + if p.Finish == "Matte" { + score += 8 + } + return clampScore(score) +} + +func scoreLooks(p model.Product, looks []string) float64 { + best := 0.0 + text := productText(p) + for _, look := range looks { + score := 0.0 + switch look { + case "wood_look": + if isWoodLike(p, text) { + score = 100 + } else if p.Category == "SPC-LVP" || p.Category == "Laminate" { + score = 70 + } else { + score = 20 + } + case "stone_tile": + if p.Category == "Tile/Stone" || strings.Contains(text, "stone") || strings.Contains(text, "marble") || strings.Contains(text, "travertine") { + score = 100 + } else { + score = 15 + } + case "real_hardwood": + if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" { + score = 100 + } else if isWoodLike(p, text) { + score = 55 + } else { + score = 10 + } + case "light_clean": + score = scoreExactOrRelated(p.ColorTone, []string{"Off White", "Light Gray", "Natural"}, colorRelatedGroups()) + case "dark_rich": + score = scoreExactOrRelated(p.ColorTone, []string{"Dark Brown", "Black Walnut", "Cherry"}, colorRelatedGroups()) + } + best = math.Max(best, score) + } + return best +} + +func scoreBudget(price float64, budget QuestionnaireBudget) float64 { + if price <= 0 { + return 50 + } + min, max := budget.Min, budget.Max + if min > 0 && price < min { + return clampScore(100 - ((min-price)/math.Max(min, 1))*80) + } + if max > 0 && price > max { + return clampScore(100 - ((price-max)/math.Max(max, 1))*90) + } + return 100 +} + +func scoreRooms(p model.Product, rooms []string) float64 { + best := 0.0 + for _, room := range rooms { + score := 50.0 + switch room { + case "bathroom", "kitchen", "basement", "commercial": + score = scorePerformance(p, []string{"waterproof", "easy_clean"}) + case "living_room", "bedroom": + score = math.Max(scoreStyles(p, []string{"warm_natural", "modern"}), 60) + case "rental": + score = scorePerformance(p, []string{"scratch_resistant", "easy_clean"}) + } + best = math.Max(best, score) + } + return best +} + +func scorePerformance(p model.Product, performance []string) float64 { + best := 0.0 + text := productText(p) + for _, perf := range performance { + score := 20.0 + switch perf { + case "waterproof": + if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || containsAnyFold(p.Material, []string{"porcelain", "vinyl", "rigid core", "spc"}) || strings.Contains(text, "water") || strings.Contains(text, "moisture") { + score = 100 + } + case "scratch_resistant": + if p.Category == "Tile/Stone" || p.Category == "Laminate" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"scratch", "durable", "superguard", "wear"}) { + score = 95 + } + case "easy_clean": + if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || p.Finish == "Matte" || containsAnyFold(text, []string{"clean", "maintenance"}) { + score = 90 + } + case "pet_kid": + score = math.Max(scorePerformance(p, []string{"waterproof"}), scorePerformance(p, []string{"scratch_resistant"})) + case "comfort_quiet": + if p.Brand == "COREtec" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"quiet", "comfort", "soft", "underlayment"}) { + score = 90 + } + case "eco": + if containsAnyFold(text, []string{"recycled", "eco", "green", "low voc"}) { + score = 90 + } else { + score = 45 + } + } + best = math.Max(best, score) + } + return best +} + +func scoreSize(p model.Product, pref string) float64 { + switch pref { + case "wide_plank": + if p.WidthIn >= 7 && p.LengthIn >= 48 { + return 100 + } + if p.WidthIn >= 5 { + return 70 + } + case "standard_plank": + if p.WidthIn >= 4 && p.WidthIn < 7 && p.LengthIn >= 36 { + return 100 + } + return 55 + case "large_tile": + if p.Category == "Tile/Stone" && p.WidthIn >= 12 && p.LengthIn >= 24 { + return 100 + } + if p.Category == "Tile/Stone" { + return 65 + } + case "small_tile": + if p.Category == "Tile/Stone" && (p.WidthIn <= 6 || p.LengthIn <= 12) { + return 100 + } + if p.Category == "Tile/Stone" { + return 60 + } + } + return 20 +} + +func dedupeQuestionnaireResults(results []questionnaireScoredProduct) []questionnaireScoredProduct { + best := map[string]questionnaireScoredProduct{} + for _, r := range results { + key := strings.ToLower(r.product.Brand + "|" + r.product.SeriesName + "|" + r.product.StyleName + "|" + r.product.ColorTone) + if old, ok := best[key]; !ok || r.score > old.score || (r.score == old.score && r.product.MainImageURL != "" && old.product.MainImageURL == "") { + best[key] = r + } + } + out := make([]questionnaireScoredProduct, 0, len(best)) + for _, r := range best { + out = append(out, r) + } + return out +} + +func diversifyQuestionnaireResults(results []questionnaireScoredProduct, limit int, brandOnly bool) []questionnaireScoredProduct { + if len(results) <= limit { + return results + } + maxPerBrand := 4 + if brandOnly { + maxPerBrand = limit + } + brandCounts := map[string]int{} + selected := make([]questionnaireScoredProduct, 0, limit) + for _, r := range results { + if brandCounts[r.product.Brand] >= maxPerBrand { + continue + } + selected = append(selected, r) + brandCounts[r.product.Brand]++ + if len(selected) == limit { + return selected + } + } + for _, r := range results { + exists := false + for _, s := range selected { + if s.product.SKU == r.product.SKU { + exists = true + break + } + } + if !exists { + selected = append(selected, r) + } + if len(selected) == limit { + break + } + } + return selected +} + +func fallbackReasons(reasons []string, p model.Product) []string { + reasons = compactReasons(reasons) + if len(reasons) > 0 { + return reasons + } + out := []string{} + if p.ColorTone != "" { + out = append(out, "颜色为 "+p.ColorTone) + } + if p.Material != "" { + out = append(out, "材质为 "+p.Material) + } + if p.PricePerSqft > 0 { + out = append(out, priceReason(p)) + } + return compactReasons(out) +} + +func compactReasons(reasons []string) []string { + seen := map[string]bool{} + out := []string{} + for _, r := range reasons { + r = strings.TrimSpace(r) + if r == "" || seen[r] { + continue + } + seen[r] = true + out = append(out, r) + if len(out) >= 4 { + break + } + } + return out +} + +func priceReason(p model.Product) string { + if p.PricePerSqft <= 0 { + return "" + } + return fmt.Sprintf("价格约 $%.2f/sqft", p.PricePerSqft) +} + +func productText(p model.Product) string { + return strings.ToLower(strings.Join([]string{ + p.Brand, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.Description, + }, " ")) +} + +func isWoodLike(p model.Product, text string) bool { + if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" || p.Category == "Laminate" || p.Category == "SPC-LVP" { + if materialFamilyHit(p.Material, []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak", "Birch", "Luxury Vinyl", "Rigid Core"}) { + return true + } + } + return containsAnyFold(text, []string{"oak", "maple", "hickory", "walnut", "ash", "pine", "wood", "plank"}) +} + +func materialFamilyHit(material string, choices []string) bool { + m := strings.ToLower(material) + for _, choice := range choices { + c := strings.ToLower(choice) + if m == c || strings.Contains(m, c) { + return true + } + } + return false +} + +func queryOptionCounts(db *sql.DB, col string, limit int) []map[string]any { + rows, err := db.Query("SELECT "+col+", COUNT(*) FROM products WHERE "+col+" != '' GROUP BY "+col+" ORDER BY COUNT(*) DESC, "+col+" LIMIT $1", limit) + if err != nil { + return nil + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var value string + var count int + if err := rows.Scan(&value, &count); err == nil { + out = append(out, map[string]any{"value": value, "count": count}) + } + } + return out +} + +func queryPriceStats(db *sql.DB) map[string]float64 { + rows, err := db.Query(` + SELECT + COALESCE(MIN(price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(MAX(price_per_sqft), 0) + FROM products WHERE price_per_sqft > 0 + `) + if err != nil { + return map[string]float64{} + } + defer rows.Close() + stats := map[string]float64{} + if rows.Next() { + var min, p25, median, p75, max float64 + if err := rows.Scan(&min, &p25, &median, &p75, &max); err == nil { + stats["min"] = roundOne(min) + stats["p25"] = roundOne(p25) + stats["median"] = roundOne(median) + stats["p75"] = roundOne(p75) + stats["max"] = roundOne(max) + } + } + return stats +} + +func cleanList(values []string) []string { + out := []string{} + seen := map[string]bool{} + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} + +func toSet(values []string) map[string]bool { + out := map[string]bool{} + for _, v := range values { + out[v] = true + } + return out +} + +func containsFold(values []string, target string) bool { + for _, v := range values { + if strings.EqualFold(v, target) { + return true + } + } + return false +} + +func containsAnyFold(value string, needles []string) bool { + v := strings.ToLower(value) + for _, n := range needles { + if strings.Contains(v, strings.ToLower(n)) { + return true + } + } + return false +} + +func clampScore(v float64) float64 { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +} + +func roundOne(v float64) float64 { + return math.Round(v*10) / 10 +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go index da41f44..c0f22dc 100644 --- a/internal/repository/product_repo.go +++ b/internal/repository/product_repo.go @@ -71,6 +71,15 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) { return prods, err } +func QueryAllProductRows(d *sql.DB) ([]model.Product, error) { + rows, err := d.Query("SELECT " + selectCols + " FROM products ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + return ScanProducts(rows) +} + func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { if p.Limit <= 0 { p.Limit = 20 -- 2.52.0 From 1524a3215c85a23aa695e9bb0169c30aadfb5015 Mon Sep 17 00:00:00 2001 From: dindang Date: Wed, 22 Jul 2026 15:23:40 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(floor):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9C=B0=E6=9D=BF=E6=A0=B7=E5=BC=8F=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=92=8CAI=E6=8D=A2=E5=9C=B0=E6=9D=BF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 FloorOption 结构体字段支持尺寸、描述和变体信息 - 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体 - 区分木材、瓷砖、乙烯基和层压板产品,并分类返回 - 更新室内铺装纹理选项,完善图案描述和名称本地化 - 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段 - 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例 - 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求 - 改进地板识别蒙版生成逻辑,确保精准分割地板区域 - Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息 - 更新go.mod依赖,新增redis和gorm相关包支持 --- .gitignore | 3 + apifox-import.json | 207 ++++++++++++--- cmd/import/main.go | 45 +++- go.mod | 7 +- go.sum | 23 ++ internal/api/index_handler.go | 13 + internal/api/index_html.go | 6 + internal/api/router.go | 1 + internal/handler/floor_handler.go | 379 +++++++++++++++++++++++----- internal/handler/product_handler.go | 2 + internal/model/product.go | 51 ++-- internal/openrouter/image.go | 56 +++- internal/queue/redis.go | 71 ++++-- internal/queue/worker.go | 179 +++++++++++-- internal/repository/product_repo.go | 86 +++++-- main.go | 44 +++- schema.sql | 1 + 17 files changed, 949 insertions(+), 225 deletions(-) create mode 100644 internal/api/index_handler.go create mode 100644 internal/api/index_html.go diff --git a/.gitignore b/.gitignore index 2762182..80303d6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ __MACOSX/ # Claude .claude/ + +# Env +.env diff --git a/apifox-import.json b/apifox-import.json index ed8fb28..804db40 100644 --- a/apifox-import.json +++ b/apifox-import.json @@ -232,6 +232,10 @@ "spec_count": { "type": "integer", "description": "该花色的规格数量(同品牌同花色的SKU数,含自身)。>1 表示有多种尺寸可选" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -407,6 +411,10 @@ "is_favorited": { "type": "boolean", "description": "当前用户是否已收藏。未登录时始终为 false" + }, + "is_official_price": { + "type": "boolean", + "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到" } } }, @@ -616,7 +624,7 @@ }, "FloorStyleOption": { "type": "object", - "description": "Floor style loaded from product database", + "description": "Floor style loaded from product database. Each entry = one unique style (dedup by brand+style_name). Variants list different sizes.", "properties": { "id": { "type": "string", @@ -628,23 +636,31 @@ }, "name": { "type": "string", - "description": "Style name" + "description": "Style name (花色名称)" }, "material": { "type": "string", - "description": "Material (used in AI prompt)" + "description": "Material (材质,如 Oak/Porcelain/Luxury Vinyl)" }, "color_tone": { "type": "string", - "description": "Color tone (used in AI prompt)" + "description": "Color tone (色调,如 Light Gray/Natural Oak/Dark Brown)" }, "finish": { "type": "string", - "description": "Finish (used in AI prompt)" + "description": "Finish (表面处理,如 Matte/Gloss/Textured)" }, "size_label": { "type": "string", - "description": "Size label, e.g. 6 x 48 in" + "description": "Size label, e.g. 6 x 48 in. 多尺寸时为首个尺寸" + }, + "width_in": { + "type": "number", + "description": "Width in inches. Used in AI prompt for physical scale" + }, + "length_in": { + "type": "number", + "description": "Length in inches. Used in AI prompt for physical scale" }, "image_url": { "type": "string", @@ -652,7 +668,24 @@ }, "category": { "type": "string", - "description": "Product category" + "description": "Product category (Solid Hardwood / Engineered Hardwood / Tile/Stone / SPC-LVP / Laminate)" + }, + "description": { + "type": "string", + "description": "Product description. Used in AI prompt for richer visual detail" + }, + "variants": { + "type": "array", + "description": "Size variants of the same style (same brand+style_name, different SKU/size). Empty array if only one size.", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Variant SKU" }, + "size_label": { "type": "string", "description": "Variant size label" }, + "width_in": { "type": "number" }, + "length_in": { "type": "number" } + } + } } } }, @@ -664,21 +697,21 @@ "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Hardwood + Engineered Wood products" + "description": "Wood products (Oak/Ash/Hickory/Maple etc.) — classified by material field" }, "tile_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "Wood-Look Tile + Laminate products" + "description": "Tile/Stone (Porcelain/Stone Look/Glass) + Laminate products" }, "vinyl_floors": { "type": "array", "items": { "$ref": "#/components/schemas/FloorStyleOption" }, - "description": "SPC/LVP products" + "description": "Vinyl products (Luxury Vinyl/Rigid Core)" }, "wood_patterns": { "type": "array", @@ -1817,8 +1850,8 @@ "tags": [ "地板更换" ], - "summary": "获取地板样式选项(从产品库动态加载)", - "description": "从产品数据库(3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU,/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明:\n- wood_floors:实木+实木复合产品\n- tile_floors:瓷砖仿木纹+强化复合产品\n- vinyl_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型", + "summary": "获取地板样式选项(从产品库动态加载,含尺寸变体)", + "description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU,含 width_in/length_in/description/variants 等字段。\n\n材质分类(按 material 字段自动归类):\n- wood_floors:Oak/Ash/Hickory/Maple 等木材\n- tile_floors:Porcelain/Stone Look/Glass + Laminate\n- vinyl_floors:Luxury Vinyl/Rigid Core\n\n前端应使用 variants 渲染尺寸选择面板。", "responses": { "200": { "description": "成功", @@ -1842,59 +1875,95 @@ "wood_floors": [], "tile_floors": [], "vinyl_floors": [], + "wood_floors": [ + { + "id": "CB218LG", + "sku": "CB218LG", + "name": "Cherry", + "material": "Oak", + "color_tone": "Natural Oak", + "finish": "Matte", + "size_label": "2.25 x 84 in", + "width_in": 2.25, + "length_in": 84.0, + "image_url": "https://...", + "category": "Solid Hardwood", + "description": "Natural cherry oak with subtle grain", + "variants": [ + { "sku": "CB4218", "size_label": "4 x 84 in", "width_in": 4.0, "length_in": 84.0 }, + { "sku": "CB5218", "size_label": "5 x 84 in", "width_in": 5.0, "length_in": 84.0 } + ] + } + ], + "tile_floors": [], + "vinyl_floors": [], "wood_patterns": [ { "code": 1, - "name": "Straight Lay", - "pattern": "straight/parallel planks...", + "name": "直铺(工字拼)Straight Lay", + "pattern": "straight/parallel planks running from front to back with staggered joints", "category": "wood" }, { "code": 2, - "name": "Horizontal Lay", - "pattern": "horizontal planks...", + "name": "横铺 Horizontal Lay", + "pattern": "horizontal planks running side to side across the width of the room", "category": "wood" }, { "code": 3, - "name": "Herringbone", - "pattern": "herringbone/zigzag pattern...", + "name": "人字拼 Herringbone", + "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape", "category": "wood" }, { "code": 4, - "name": "Chevron", - "pattern": "chevron pattern...", + "name": "鱼骨拼 Chevron", + "pattern": "chevron pattern with planks cut at an angle forming a continuous V shape", "category": "wood" } ], "tile_patterns": [ { "code": 5, - "name": "Straight Grid", - "pattern": "straight grid layout...", + "name": "直铺(网格对缝)Straight Grid", + "pattern": "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines", "category": "tile" }, { "code": 6, - "name": "Running Bond", - "pattern": "running bond/brick pattern...", + "name": "工字铺(1/2错缝)Running Bond", + "pattern": "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance", + "category": "tile" + }, + { + "code": 7, + "name": "三七错铺 1/3 Offset", + "pattern": "tiles offset by 1/3 of their length in each row, subtle stepped pattern", + "category": "tile" + }, + { + "code": 8, + "name": "六边形 Hexagonal", + "pattern": "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh", + "category": "tile" + }, + { + "code": 9, + "name": "斜铺(45度对角)Diagonal", + "pattern": "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation", "category": "tile" } ], "rooms": [ - { - "code": 0, - "name": "Auto Detect" - }, - { - "code": 1, - "name": "Living Room" - }, - { - "code": 2, - "name": "Bedroom" - } + { "code": 0, "name": "自动识别" }, + { "code": 1, "name": "客厅" }, + { "code": 2, "name": "卧室" }, + { "code": 3, "name": "厨房" }, + { "code": 4, "name": "餐厅" }, + { "code": 5, "name": "浴室" }, + { "code": 6, "name": "书房" }, + { "code": 7, "name": "走廊" } ] } } @@ -1909,8 +1978,8 @@ "tags": [ "地板更换" ], - "summary": "AI换地板(使用真实产品数据)", - "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度", + "summary": "AI换地板(使用真实产品数据,含尺寸/变体)", + "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。用 GET /floor/status?job_id=xxx 查询进度。\n\nfloor_id 支持有尺寸变体的产品——选择变体对应 SKU 即可,AI 会根据 width_in/length_in 生成对应物理尺寸的地板。", "requestBody": { "content": { "multipart/form-data": { @@ -1940,6 +2009,16 @@ "type": "integer", "description": "房间类型编码(0-7),见文档顶部数字编码对照表。0 或省略表示自动检测", "example": 1 + }, + "grout_lines": { + "type": "boolean", + "description": "Optional. Tile floors only. true adds realistic visible grout lines between tiles.", + "example": false + }, + "white_baseboard": { + "type": "boolean", + "description": "Optional. true adds or refreshes a realistic white baseboard/skirting board along visible wall-floor edges.", + "example": false } } } @@ -2204,8 +2283,8 @@ "tags": [ "地板更换" ], - "summary": "查询生成进度(HTTP 轮询)", - "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error", + "summary": "查询生成进度(HTTP 轮询,含耗时统计)", + "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error。\n\n返回 timings_ms 字段包含每步耗时:check/mask/inpaint/cleanup/total。elapsed_ms 为总运行时间。", "parameters": [ { "name": "job_id", @@ -2219,7 +2298,51 @@ ], "responses": { "200": { - "description": "SSE 事件流" + "description": "Job status with timing", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { "type": "integer", "example": 200 }, + "data": { + "type": "object", + "properties": { + "job_id": { "type": "string" }, + "status": { "type": "string", "description": "pending | processing | done | error" }, + "step": { "type": "string", "description": "check | mask | inpaint | done | error" }, + "message": { "type": "string" }, + "result": { "type": "string", "description": "Generated image path, e.g. /outputs/result_xxx.png" }, + "error": { "type": "string" }, + "elapsed_ms": { "type": "integer", "description": "Total elapsed milliseconds since job start" }, + "timings_ms": { + "type": "object", + "description": "Per-step duration in milliseconds", + "properties": { + "check": { "type": "integer" }, + "mask": { "type": "integer" }, + "inpaint": { "type": "integer" }, + "total": { "type": "integer" } + } + } + } + } + } + }, + "example": { + "code": 200, + "data": { + "job_id": "1784530075874083600", + "status": "done", + "step": "done", + "message": "Done", + "result": "/outputs/result_1784530075874083600.png", + "elapsed_ms": 100340, + "timings_ms": { "check": 5148, "mask": 25971, "inpaint": 69218, "total": 100340 } + } + } + } + } } } } @@ -2328,4 +2451,4 @@ } } } -} \ No newline at end of file +} diff --git a/cmd/import/main.go b/cmd/import/main.go index a688ace..d2b5777 100644 --- a/cmd/import/main.go +++ b/cmd/import/main.go @@ -1,21 +1,52 @@ package main + import ( - "encoding/json"; "fmt"; "os" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "floorvisualizer/internal/model" "floorvisualizer/internal/repository" ) + func main() { - db, _ := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable") + dsn := repository.DefaultDSN + if v := os.Getenv("DATABASE_URL"); v != "" { + dsn = v + } + dataDir := "data/products" + if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" { + dataDir = v + } + + db, err := repository.Connect(dsn) + if err != nil { + panic(err) + } defer db.Close() db.Exec("DELETE FROM products") - files, _ := os.ReadDir("data/products") + files, err := os.ReadDir(dataDir) + if err != nil { + panic(err) + } total := 0 for _, f := range files { - if f.IsDir() { continue } - data, _ := os.ReadFile("data/products/" + f.Name()) + if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dataDir, f.Name())) + if err != nil { + panic(err) + } var prods []model.Product - json.Unmarshal(data, &prods) - repository.InsertProducts(db, prods) + if err := json.Unmarshal(data, &prods); err != nil { + panic(fmt.Errorf("parse %s: %w", f.Name(), err)) + } + if err := repository.InsertProducts(db, prods); err != nil { + panic(err) + } total += len(prods) fmt.Printf(" %s: %d\n", f.Name(), len(prods)) } diff --git a/go.mod b/go.mod index 07dc8eb..6fab5c8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.26.4 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/lib/pq v1.12.3 + github.com/redis/go-redis/v9 v9.21.0 golang.org/x/image v0.43.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.2 ) require ( @@ -16,11 +19,9 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/redis/go-redis/v9 v9.21.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/text v0.38.0 // indirect - gorm.io/driver/postgres v1.6.0 // indirect - gorm.io/gorm v1.31.2 // indirect ) diff --git a/go.sum b/go.sum index e413204..f2ac7f9 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -15,14 +21,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= @@ -31,11 +48,17 @@ golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go new file mode 100644 index 0000000..67df1db --- /dev/null +++ b/internal/api/index_handler.go @@ -0,0 +1,13 @@ +package api + +import "net/http" + +func Index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(indexHTML)) +} diff --git a/internal/api/index_html.go b/internal/api/index_html.go new file mode 100644 index 0000000..bc888e3 --- /dev/null +++ b/internal/api/index_html.go @@ -0,0 +1,6 @@ +package api + +import _ "embed" + +//go:embed index.html +var indexHTML string diff --git a/internal/api/router.go b/internal/api/router.go index 7526cfb..7d942f8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -63,6 +63,7 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a mux.HandleFunc("/articles/recommend", articleRecommend) mux.HandleFunc("/articles", articles) mux.HandleFunc("/articles/", articlesSub) + mux.HandleFunc("/", Index) // Static files mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index 9ada160..bc4f4ce 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -18,15 +18,19 @@ import ( // ── 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"` - ImageURL string `json:"image_url"` - SKU string `json:"sku"` + 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 { @@ -42,29 +46,29 @@ type RoomOption struct { } var woodPatterns = []PatternOption{ - {Code: 1, Name: "Straight Lay", Category: "wood", Pattern: "straight/parallel planks running from front to back with staggered joints"}, - {Code: 2, Name: "Horizontal Lay", Category: "wood", Pattern: "horizontal planks running side to side across the width of the room"}, - {Code: 3, Name: "Herringbone", Category: "wood", Pattern: "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape"}, - {Code: 4, Name: "Chevron", Category: "wood", Pattern: "chevron pattern with planks cut at an angle forming a continuous V shape"}, + {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: "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines"}, - {Code: 6, Name: "Running Bond", Category: "tile", Pattern: "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance"}, - {Code: 7, Name: "1/3 Offset", Category: "tile", Pattern: "tiles offset by 1/3 of their length in each row, subtle stepped pattern"}, - {Code: 8, Name: "Hexagonal", Category: "tile", Pattern: "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh"}, - {Code: 9, Name: "Diagonal (45deg)", Category: "tile", Pattern: "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation"}, + {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: "Auto Detect"}, - {Code: 1, Name: "Living Room"}, - {Code: 2, Name: "Bedroom"}, - {Code: 3, Name: "Kitchen"}, - {Code: 4, Name: "Dining Room"}, - {Code: 5, Name: "Bathroom"}, - {Code: 6, Name: "Study Room"}, - {Code: 7, Name: "Hallway"}, + {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 { @@ -88,43 +92,109 @@ func findRoomByCode(code int) *RoomOption { return nil } -// loadFloorOptions loads floor options from the database, one per style_name per brand. -func loadFloorOptions(db *sql.DB, category string) ([]FloorOption, error) { - prods, _, err := repository.QueryFilteredProducts(db, repository.FilterParams{ - Category: category, - Limit: 200, - }) +// 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 } - // Dedup by brand+style_name, take first as representative - seen := map[string]bool{} + 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 { - key := p.Brand + "|" + p.StyleName - if seen[key] { - continue + 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, + }) } - seen[key] = true - 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, ImageURL: p.MainImageURL, SKU: p.SKU, - }) } 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 } - return &FloorOption{ + f := &FloorOption{ ID: p.SKU, Name: p.StyleName, Category: p.Category, Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish, - SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU, + 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 ──────────────────────────── @@ -145,6 +215,54 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st 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) { @@ -256,14 +374,18 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) { func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error - woodFloors2, _ := loadFloorOptions(db, "Engineered Wood") - tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") - vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") - laminateFloors, _ := loadFloorOptions(db, "Laminate") - - allWood := append(woodFloors, woodFloors2...) - allTile := append(tileFloors, laminateFloors...) + 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, @@ -279,10 +401,25 @@ func FloorOptions(db *sql.DB) http.HandlerFunc { // ── Prompts ──────────────────────────────────────────────── func buildMaskPrompt(room *RoomOption) string { - base := `Create a pure black-and-white mask image for this room photo. -WHITE (#FFFFFF) areas: ONLY the flat horizontal floor. -BLACK (#000000) areas — everything else: stairs, baseboards, walls, furniture, doors, windows, ceiling, decorations. -Output ONLY the mask image. No text.` + 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) } @@ -295,8 +432,7 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name) } matType := "wood plank" - isTile := floor.Category == "Wood-Look Tile" || floor.Category == "Laminate" - if isTile { + if isTileProduct(floor) || isLaminateProduct(floor) { matType = "tile" } @@ -305,12 +441,127 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption 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( - `Replace ONLY the white-masked floor area with %s (%s) %s flooring in a %s pattern.%s -Material details: %s | Layout: %s -Style: photorealistic, soft natural matte finish, minimal reflections, no mirror-like gloss, seamless blend with room lighting.`, - floor.Name, matType, materialDesc, pattern.Name, roomCtx, - materialDesc, pattern.Pattern, + `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, ) } diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go index 25a4a42..c351886 100644 --- a/internal/handler/product_handler.go +++ b/internal/handler/product_handler.go @@ -26,6 +26,7 @@ type ProductItem struct { MainImageURL string `json:"main_image_url"` SizeLabel string `json:"size_label"` CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + IsOfficialPrice bool `json:"is_official_price"` IsFavorited bool `json:"is_favorited"` SpecCount int `json:"spec_count"` } @@ -152,6 +153,7 @@ func Products(db *sql.DB) http.HandlerFunc { PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, CoverageSqftPerBox: p.CoverageSqftPerBox, + IsOfficialPrice: p.IsOfficialPrice, IsFavorited: favSKUs[p.SKU], SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], }) diff --git a/internal/model/product.go b/internal/model/product.go index c5d3e45..7968124 100644 --- a/internal/model/product.go +++ b/internal/model/product.go @@ -1,31 +1,32 @@ package model type Product struct { - Brand string `json:"brand"` - GroupName string `json:"group_name"` - SKU string `json:"sku"` - SeriesName string `json:"series_name"` - StyleName string `json:"style_name"` - Category string `json:"category"` - Material string `json:"material"` - ColorTone string `json:"color_tone"` - Finish string `json:"finish"` - PricePerSqft float64 `json:"price_per_sqft"` - PriceTier string `json:"price_tier"` - PriceSource string `json:"price_source"` - MainImageURL string `json:"main_image_url"` - RoomImageURL string `json:"room_image_url"` - SourceURL string `json:"source_url"` - Description string `json:"description"` - Status string `json:"status"` - CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` - WoodSpecies string `json:"wood_species"` - SizeLabel string `json:"size_label"` - WidthIn float64 `json:"width_in"` - LengthIn float64 `json:"length_in"` - Variants []Product `json:"variants,omitempty"` - Specs []ProductSpec `json:"specs,omitempty"` - IsFavorited bool `json:"is_favorited"` + Brand string `json:"brand"` + GroupName string `json:"group_name"` + SKU string `json:"sku"` + SeriesName string `json:"series_name"` + StyleName string `json:"style_name"` + Category string `json:"category"` + Material string `json:"material"` + ColorTone string `json:"color_tone"` + Finish string `json:"finish"` + PricePerSqft float64 `json:"price_per_sqft"` + PriceTier string `json:"price_tier"` + PriceSource string `json:"price_source"` + IsOfficialPrice bool `json:"is_official_price"` + MainImageURL string `json:"main_image_url"` + RoomImageURL string `json:"room_image_url"` + SourceURL string `json:"source_url"` + Description string `json:"description"` + Status string `json:"status"` + CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"` + WoodSpecies string `json:"wood_species"` + SizeLabel string `json:"size_label"` + WidthIn float64 `json:"width_in"` + LengthIn float64 `json:"length_in"` + Variants []Product `json:"variants,omitempty"` + Specs []ProductSpec `json:"specs,omitempty"` + IsFavorited bool `json:"is_favorited"` } // ProductSpec is a single size/price variant of a product style. diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go index f2e872e..ff92d99 100644 --- a/internal/openrouter/image.go +++ b/internal/openrouter/image.go @@ -24,6 +24,8 @@ const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview" const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged." +const referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material." + var supportedAspectRatios = []struct { value string width int @@ -51,6 +53,8 @@ type ImageGenerationRequest struct { InputMimeType string MaskImagePath string MaskMimeType string + ReferenceImagePath string // optional: reference image for floor material style + ReferenceMimeType string AspectRatio string ImageSize string TopP *float64 @@ -284,7 +288,7 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err return nil, stderrors.New("openrouter: mask image requires input image") } - content := make([]ContentPart, 0, 4) + content := make([]ContentPart, 0, 6) if req.InputImagePath != "" { dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType) if err != nil { @@ -306,6 +310,17 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err ImageURL: &ImageURL{URL: dataURI}, }) } + if req.ReferenceImagePath != "" { + dataURI, err := imageFileDataURI(req.ReferenceImagePath, req.ReferenceMimeType) + if err != nil { + return nil, err + } + content = append(content, ContentPart{ + Type: "image_url", + ImageURL: &ImageURL{URL: dataURI}, + }) + content = append(content, ContentPart{Type: "text", Text: referenceImageInstruction}) + } content = append(content, ContentPart{Type: "text", Text: req.Prompt}) return content, nil } @@ -464,3 +479,42 @@ func decodeDataURL(dataURL string) (string, []byte, error) { return mimeType, data, nil } + +// MaxImageDimension is the maximum width or height for images sent to the AI API. +const MaxImageDimension = 2048 + +// ResizeImageToFit reads an image, scales it so the longest side ≤ maxDim pixels, +// and writes the result as PNG. +func ResizeImageToFit(inputPath, outputPath string) error { + data, err := os.ReadFile(inputPath) + if err != nil { + return fmt.Errorf("resize: read input: %w", err) + } + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("resize: decode: %w", err) + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + longest := w + if h > longest { + longest = h + } + dst := src + if longest > MaxImageDimension { + scale := float64(MaxImageDimension) / float64(longest) + newW := int(float64(w)*scale + 0.5) + newH := int(float64(h)*scale + 0.5) + resized := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil) + dst = resized + } + var buf bytes.Buffer + if err := png.Encode(&buf, dst); err != nil { + return fmt.Errorf("resize: encode png: %w", err) + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + return os.WriteFile(outputPath, buf.Bytes(), 0644) +} diff --git a/internal/queue/redis.go b/internal/queue/redis.go index 4036547..f4ad27f 100644 --- a/internal/queue/redis.go +++ b/internal/queue/redis.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "time" "github.com/redis/go-redis/v9" @@ -17,12 +18,14 @@ const ( // JobStatus represents the state of a generation job. type JobStatus struct { - JobID string `json:"job_id"` - Status string `json:"status"` // pending | processing | done | error - Step string `json:"step"` - Message string `json:"message"` - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` + JobID string `json:"job_id"` + Status string `json:"status"` // pending | processing | done | error + Step string `json:"step"` + Message string `json:"message"` + Result string `json:"result,omitempty"` + Error string `json:"error,omitempty"` + ElapsedMs int64 `json:"elapsed_ms,omitempty"` + TimingsMs map[string]int64 `json:"timings_ms,omitempty"` } // NewRedisClient creates a Redis client and pings to verify connectivity. @@ -42,7 +45,7 @@ func NewRedisClient(addr, password string) (*redis.Client, error) { func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error { pipe := rdb.Pipeline() pipe.RPush(ctx, queueKey, jobID) - pipe.HSet(ctx, jobKey(jobID), "status", "pending") + pipe.HSet(ctx, jobKey(jobID), "status", "pending", "queued_at", nowMillis(), "updated_at", nowMillis()) pipe.Expire(ctx, jobKey(jobID), jobTTL) _, err := pipe.Exec(ctx) return err @@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) { // SetJobStatus updates the job status field. func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", status).Err() + fields := []any{"status", status, "updated_at", nowMillis()} + if status == "processing" { + fields = append(fields, "started_at", nowMillis()) + } + return rdb.HSet(ctx, jobKey(jobID), fields...).Err() } // SetJobProgress updates the step and message for a job. func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error { - return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg).Err() + return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg, "updated_at", nowMillis()).Err() +} + +// SetJobTiming stores a single step duration and the total elapsed time so far. +func SetJobTiming(ctx context.Context, rdb *redis.Client, jobID, step string, duration, elapsed time.Duration) error { + return rdb.HSet(ctx, jobKey(jobID), + step+"_ms", duration.Milliseconds(), + "elapsed_ms", elapsed.Milliseconds(), + "updated_at", nowMillis(), + ).Err() } // SetJobResult marks a job as done with the result image URL. func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url, "updated_at", nowMillis()).Err() } // SetJobError marks a job as errored with an error message. func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error { - return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg).Err() + return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg, "updated_at", nowMillis()).Err() } // GetJobStatus retrieves the full status of a job. @@ -90,17 +106,27 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta return nil, fmt.Errorf("job not found") } js := &JobStatus{ - JobID: jobID, - Status: m["status"], - Step: m["step"], - Message: m["message"], - Result: m["result"], - Error: m["error"], + JobID: jobID, + Status: m["status"], + Step: m["step"], + Message: m["message"], + Result: m["result"], + Error: m["error"], + ElapsedMs: parseInt64(m["elapsed_ms"]), + TimingsMs: map[string]int64{}, + } + for _, step := range []string{"check", "mask", "inpaint", "cleanup", "total"} { + if value := parseInt64(m[step+"_ms"]); value > 0 { + js.TimingsMs[step] = value + } + } + if len(js.TimingsMs) == 0 { + js.TimingsMs = nil } return js, nil } -// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path). +// StoreJobPayload saves the job's input parameters and render options. func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error { data, _ := json.Marshal(payload) return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err() @@ -120,3 +146,12 @@ func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[st func jobKey(id string) string { return jobKeyPrefix + id } + +func nowMillis() int64 { + return time.Now().UnixMilli() +} + +func parseInt64(raw string) int64 { + value, _ := strconv.ParseInt(raw, 10, 64) + return value +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index a36e21d..719b5b3 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -1,12 +1,17 @@ package queue import ( + "bytes" "context" "database/sql" + "encoding/json" "floorvisualizer/internal/logger" "fmt" + "io" + "net/http" "os" "path/filepath" + "strings" "time" "github.com/redis/go-redis/v9" @@ -44,7 +49,9 @@ func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrou } func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) { + totalStart := time.Now() SetJobStatus(ctx, rdb, jobID, "processing") + logger.Info("[%s] TIMER job start", jobID) // Load payload payload, err := GetJobPayload(ctx, rdb, jobID) @@ -58,7 +65,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien patternCode := payload["pattern_code"] roomCode := payload["room_code"] - // Look up floor and pattern from DB (same logic as before) + // Look up floor and pattern from DB floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) @@ -67,17 +74,27 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien SetJobError(ctx, rdb, jobID, "Invalid floor or pattern") return } + logger.Info("[%s] TIMER options floor_id=%s pattern_code=%s room_code=%s elapsed=%s", + jobID, floorID, patternCode, roomCode, time.Since(totalStart).Round(time.Millisecond)) + + // Resize input to max 2048px to speed up AI processing + workPath := filepath.Join("uploads", fmt.Sprintf("input_%s_resized.png", jobID)) + if err := openrouter.ResizeImageToFit(inputPath, workPath); err != nil { + logger.Warn("[%s] Failed to resize input, using original: %v", jobID, err) + workPath = inputPath + } - maskPath := filepath.Join("outputs", fmt.Sprintf("mask_%s.png", jobID)) outputPath := filepath.Join("outputs", fmt.Sprintf("result_%s.png", jobID)) - jobCtx, cancel := context.WithTimeout(ctx, 180*time.Second) + jobCtx, cancel := context.WithTimeout(ctx, 600*time.Second) defer cancel() // Step 0: content check SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") - logger.Info("[%s] Content check", jobID) - passed, score := validateContent(client, jobCtx, inputPath) + stepStart := time.Now() + logger.Info("[%s] TIMER step=check start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) + passed, score := validateContent(client, jobCtx, workPath) + logStepDone(jobCtx, rdb, jobID, "check", stepStart, totalStart, "score=%d passed=%t", score, passed) if score == 0 { logger.Warn("[%s] Content check failed (score=0)", jobID) } @@ -86,55 +103,94 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien return } - // Step 1: mask - SetJobProgress(jobCtx, rdb, jobID, "mask", "Generating floor mask...") - logger.Info("[%s] Mask generation", jobID) - maskPrompt := handler.BuildMaskPromptPublic(room) - if _, err := client.RecognizeImage(jobCtx, openrouter.ImageRecognitionRequest{ - Prompt: maskPrompt, InputImagePath: inputPath, OutputPath: maskPath, - }); err != nil { - SetJobError(jobCtx, rdb, jobID, "Mask generation failed: "+err.Error()) - return - } - - // Step 2: inpaint + // Step 1+2 combined: identify floor + replace in one call (no separate mask) roomLabel := "" if room != nil { roomLabel = " · " + room.Name } - SetJobProgress(jobCtx, rdb, jobID, "inpaint", fmt.Sprintf("Applying %s %s%s...", floor.Name, pattern.Name, roomLabel)) - logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name) + SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel)) + stepStart = time.Now() + logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s", + jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond)) - floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room) + refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + + imageSize := floorImageSize() + logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s", + jobID, imageSize, workPath, outputPath, refImagePath) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ - Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath, - MaskImagePath: maskPath, ImageSize: "2K", + Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, + ImageSize: imageSize, ReferenceImagePath: refImagePath, }) if err != nil { + logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err) SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) return } + logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "") // Clean up intermediate files + stepStart = time.Now() + logger.Info("[%s] TIMER step=cleanup start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond)) if err := os.Remove(inputPath); err != nil { logger.Warn("[%s] Failed to remove input: %v", jobID, err) } - if err := os.Remove(maskPath); err != nil { - logger.Warn("[%s] Failed to remove mask: %v", jobID, err) + if workPath != inputPath { + if err := os.Remove(workPath); err != nil { + logger.Warn("[%s] Failed to remove resized input: %v", jobID, err) + } + } + if refImagePath != "" { + if err := os.Remove(refImagePath); err != nil { + logger.Warn("[%s] Failed to remove reference image: %v", jobID, err) + } } + totalDuration := time.Since(totalStart) + _ = SetJobTiming(jobCtx, rdb, jobID, "total", totalDuration, totalDuration) SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath)) - logger.Info("[%s] Done", jobID) + logger.Info("[%s] TIMER job done total=%s result=%s", jobID, totalDuration.Round(time.Millisecond), "/"+filepath.ToSlash(outputPath)) +} + +func logStepDone(ctx context.Context, rdb *redis.Client, jobID, step string, stepStart, totalStart time.Time, format string, args ...any) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + if format != "" { + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s %s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), fmt.Sprintf(format, args...)) + return + } + logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond)) +} + +func logStepError(ctx context.Context, rdb *redis.Client, jobID, step string, totalStart, stepStart time.Time, err error) { + duration := time.Since(stepStart) + elapsed := time.Since(totalStart) + _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed) + logger.Error("[%s] TIMER step=%s error duration=%s elapsed=%s err=%v", + jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), err) } // validateContent checks if the image is an indoor room photo. -func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { +// Uses local CLIP ViT-B/32 first, falls back to Gemini 2.5 Flash if CLIP is unavailable. +func validateContent(geminiClient *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { imgData, err := os.ReadFile(imagePath) if err != nil { return false, 0 } + // Try local CLIP server first + if score, ok := clipCheck(imgData); ok { + logger.Info("Content check via local CLIP: score=%d passed=%t", score, score >= 6) + return score >= 6, score + } + // Fallback to Gemini + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() dataURI := "data:image/png;base64," + base64enc(imgData) - resp, err := client.Chat(ctx, openrouter.ChatRequest{ + resp, err := geminiClient.Chat(checkCtx, openrouter.ChatRequest{ Model: "google/gemini-2.5-flash", Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{ {Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}}, @@ -150,6 +206,31 @@ Return ONLY a number (1-10).`}, return score >= 6, score } +// clipCheck sends image bytes to the local CLIP server and returns (score, ok). +func clipCheck(imgData []byte) (int, bool) { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:5100/check", bytes.NewReader(imgData)) + if err != nil { + return 0, false + } + req.Header.Set("Content-Type", "application/octet-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return 0, false + } + var result struct { + Score int `json:"score"` + Passed bool `json:"passed"` + } + if json.NewDecoder(resp.Body).Decode(&result) != nil { + return 0, false + } + return result.Score, true +} + 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 { @@ -187,3 +268,47 @@ func base64enc(data []byte) string { } return string(b) } + +// downloadReferenceImage downloads the floor material image from the given URL +// and saves it to a local temp file. Returns the local file path, or empty string on failure. +func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string { + if imageURL == "" { + return "" + } + refPath := filepath.Join("outputs", fmt.Sprintf("ref_%s.png", jobID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + logger.Warn("[%s] Failed to create reference image request: %v", jobID, err) + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Warn("[%s] Failed to download reference image from %s: %v", jobID, imageURL, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + logger.Warn("[%s] Reference image download returned HTTP %d from %s", jobID, resp.StatusCode, imageURL) + return "" + } + f, err := os.Create(refPath) + if err != nil { + logger.Warn("[%s] Failed to create reference image file: %v", jobID, err) + return "" + } + defer f.Close() + if _, err := io.Copy(f, resp.Body); err != nil { + logger.Warn("[%s] Failed to save reference image: %v", jobID, err) + return "" + } + logger.Info("[%s] Downloaded reference image from %s", jobID, imageURL) + return refPath +} + +func floorImageSize() string { + value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE")) + if value == "" { + return "2K" + } + return value +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go index 4abfe53..da41f44 100644 --- a/internal/repository/product_repo.go +++ b/internal/repository/product_repo.go @@ -13,28 +13,27 @@ import ( const selectCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + 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)` const insertCols = `brand, group_name, sku, series_name, style_name, category, material, color_tone, finish, - price_per_sqft, price_tier, price_source, + price_per_sqft, price_tier, price_source, is_official_price, main_image_url, room_image_url, source_url, description, status, coverage_sqft_per_box, wood_species, size_label, width_in, length_in` - // ── Import ────────────────────────────────────────────────── func InsertProducts(d *sql.DB, products []model.Product) error { for _, p := range products { _, err := d.Exec(` - INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) `, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, - p.PricePerSqft, p.PriceTier, p.PriceSource, + p.PricePerSqft, p.PriceTier, p.PriceSource, p.IsOfficialPrice, p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status, p.CoverageSqftPerBox, p.WoodSpecies, p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn)) @@ -46,7 +45,9 @@ func InsertProducts(d *sql.DB, products []model.Product) error { } func nf(v float64) interface{} { - if v == 0 { return nil } + if v == 0 { + return nil + } return v } @@ -54,12 +55,12 @@ func nf(v float64) interface{} { type FilterParams struct { SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string - Page, Limit int + Page, Limit int } type FilterOptions struct { - Categories []string `json:"categories"` - SeriesNames []string `json:"series_names"` + Categories []string `json:"categories"` + SeriesNames []string `json:"series_names"` Brands []model.BrandInfo `json:"brands"` } @@ -71,8 +72,12 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) { } func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { - if p.Limit <= 0 { p.Limit = 20 } - if p.Page <= 0 { p.Page = 1 } + if p.Limit <= 0 { + p.Limit = 20 + } + if p.Page <= 0 { + p.Page = 1 + } var conds []string var args []interface{} @@ -101,12 +106,16 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err fmt.Sprintf("brand ILIKE $%d", i+2), } conds = append(conds, "("+strings.Join(clauses, " OR ")+")") - for k := 0; k < 3; k++ { args = append(args, pat) } + for k := 0; k < 3; k++ { + args = append(args, pat) + } i += 3 } where := "" - if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") } + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } // Count distinct styles (dedup same brand+style_name across different sizes) var total int @@ -118,18 +127,29 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err args = append(args, p.Limit, offset) rows, err := d.Query(query, args...) - if err != nil { return nil, 0, err } + if err != nil { + return nil, 0, err + } defer rows.Close() - prods, err := scanProducts(rows) + prods, err := ScanProducts(rows) return prods, total, err } func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) { - var p model.Product - if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil { + rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku) + if err != nil { return nil, err } - return &p, nil + defer rows.Close() + + prods, err := ScanProducts(rows) + if err != nil { + return nil, err + } + if len(prods) == 0 { + return nil, sql.ErrNoRows + } + return &prods[0], nil } // GetVariantsByStyle returns all products with the same brand+style_name but different SKU. @@ -138,13 +158,14 @@ func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) { SELECT `+selectCols+` FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) - AND series_name = (SELECT series_name FROM products WHERE sku = $1) AND sku != $1 ORDER BY width_in, length_in `, sku) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer rows.Close() - return scanProducts(rows) + return ScanProducts(rows) } func VariantCount(d *sql.DB, sku string) int { @@ -190,13 +211,22 @@ func GetSpecCounts(d *sql.DB, skus []string) map[string]int { func GetFilterOptions(d *sql.DB) (*FilterOptions, error) { opts := &FilterOptions{} - for _, q := range []struct{ query string; out *[]string }{ + for _, q := range []struct { + query string + out *[]string + }{ {"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories}, {"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames}, } { rows, err := d.Query(q.query) - if err != nil { return nil, err } - for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) } + if err != nil { + return nil, err + } + for rows.Next() { + var v string + rows.Scan(&v) + *q.out = append(*q.out, v) + } rows.Close() } opts.Brands = GetBrandInfos(d) @@ -254,18 +284,20 @@ func GetBrandInfos(d *sql.DB) []model.BrandInfo { return out } -func scanProducts(rows *sql.Rows) ([]model.Product, error) { +func ScanProducts(rows *sql.Rows) ([]model.Product, error) { var out []model.Product for rows.Next() { var p model.Product if err := rows.Scan( &p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName, &p.Category, &p.Material, &p.ColorTone, &p.Finish, - &p.PricePerSqft, &p.PriceTier, &p.PriceSource, + &p.PricePerSqft, &p.PriceTier, &p.PriceSource, &p.IsOfficialPrice, &p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status, &p.CoverageSqftPerBox, &p.WoodSpecies, &p.SizeLabel, &p.WidthIn, &p.LengthIn, - ); err != nil { return nil, err } + ); err != nil { + return nil, err + } out = append(out, p) } return out, rows.Err() diff --git a/main.go b/main.go index a4d2a59..fa309e2 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,8 @@ import ( "strconv" "time" + "github.com/joho/godotenv" + "floorvisualizer/internal/api" "floorvisualizer/internal/handler" "floorvisualizer/internal/logger" @@ -37,6 +39,8 @@ func init() { } func main() { + godotenv.Load() + logDir := "logs" if v := os.Getenv("LOG_DIR"); v != "" { logDir = v @@ -85,6 +89,7 @@ func main() { if v := os.Getenv("REDIS_ADDR"); v != "" { redisAddr = v } + var cancelWorkers context.CancelFunc rdb, err := queue.NewRedisClient(redisAddr, "") if err != nil { logger.Warn("Redis not available (%v), worker queue disabled", err) @@ -119,19 +124,11 @@ func main() { workerCount = n } } - workerCtx, cancelWorkers := context.WithCancel(context.Background()) + workerCtx, cancel := context.WithCancel(context.Background()) + cancelWorkers = cancel defer cancelWorkers() queue.StartWorkers(workerCtx, workerCount, rdb, client, db) logger.Info("Worker pool started: %d workers", workerCount) - - // Graceful shutdown - go func() { - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt) - <-sig - logger.Info("Shutting down workers...") - cancelWorkers() - }() } // ── Hot product cache ──────────────────────────────── @@ -171,6 +168,31 @@ func main() { if p := os.Getenv("PORT"); p != "" { port = p } + + server := &http.Server{ + Addr: ":" + port, + Handler: middleware.Logging(mux), + } + + // Graceful shutdown: Ctrl+C stops HTTP server + worker pool + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + logger.Info("Shutting down...") + if cancelWorkers != nil { + cancelWorkers() + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Warn("HTTP server forced shutdown: %v", err) + } + }() + logger.Info("FloorVisualizer -> http://localhost:%s", port) - log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux))) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } + logger.Info("Server stopped gracefully") } diff --git a/schema.sql b/schema.sql index cd22e3f..bf44a97 100644 --- a/schema.sql +++ b/schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS products ( price_per_sqft NUMERIC(10,2) DEFAULT 0, price_tier TEXT DEFAULT '', price_source TEXT DEFAULT '', + is_official_price BOOLEAN DEFAULT false, main_image_url TEXT DEFAULT '', room_image_url TEXT DEFAULT '', source_url TEXT DEFAULT '', -- 2.52.0 From 4b5f5eb736b2313a6e94d11129e408839f85711f Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 14:39:45 +0800 Subject: [PATCH 07/10] v2 --- README.md | 4 + docker-compose.yml | 6 +- internal/handler/floor_handler.go | 447 +++++++++++++++--------------- internal/openrouter/image.go | 127 ++++++++- internal/queue/worker.go | 49 +++- main.go | 54 ++++ 6 files changed, 456 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index cba7e37..3efa71e 100644 --- a/README.md +++ b/README.md @@ -199,3 +199,7 @@ AI 图片生成使用 Redis List 做消息队列,Worker 池消费: - Worker 从 BRPOP 出队 → 调用 OpenRouter → 写入结果 - 并发数通过 `WORKER_COUNT` 控制,默认 5 - 进度查询: `GET /floor/status?job_id=xxx` + "description": "Light to medium golden oak-brown wood tone with warm +- yellow undertones and subtle tonal variation, featuring fine to moderately +- pronounced straight and gentle cathedral grain, a smooth lightly textured surface, +- low satin sheen, and a clean, natural, uniform visual character." diff --git a/docker-compose.yml b/docker-compose.yml index 5ff4426..b362d6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,16 +7,14 @@ services: - "8099:8099" volumes: - floor_outputs:/app/outputs - - floor_outputs:/app/outputs - - floor_logs: - floor_uploads:/app/uploads + - floor_uploads:/app/uploads - floor_logs:/app/logs environment: - PORT=8099 - DATABASE_URL=postgres://postgres:floorviz123@db:5432/floorvisualizer?sslmode=disable - REDIS_ADDR=redis:6379 - JWT_SECRET=change-this-to-a-strong-secret-in-production - - OPENROUTER_API_KEY=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 + - OPENROUTER_API_KEY=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 - WORKER_COUNT=5 - LOG_DIR=/app/logs - LOG_LEVEL=INFO diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index bc4f4ce..33a64b2 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "database/sql" + "encoding/json" "fmt" "io" "net/http" @@ -15,22 +16,23 @@ import ( "floorvisualizer/internal/repository" ) -// ── Floor options ────────────────────────────────────────── +// 閳光偓閳光偓 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"` + 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 { @@ -48,8 +50,8 @@ type RoomOption struct { 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"}, + {Code: 3, Name: "人字铺 Herringbone", Category: "wood", Pattern: "ALL planks must be IDENTICAL width - no piece may appear narrower. Planks at 90 degrees 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 degree mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"}, } var tilePatterns = []PatternOption{ @@ -57,7 +59,7 @@ var tilePatterns = []PatternOption{ {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"}, + {Code: 9, Name: "斜铺(60度对角)Diagonal", Category: "tile", Pattern: "tiles rotated 60 degrees 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{ @@ -101,7 +103,7 @@ func loadFloorOptions(db *sql.DB) ([]FloorOption, error) { 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`) + FROM products ORDER BY brand, style_name, id LIMIT 5000`) if err != nil { return nil, err } @@ -111,7 +113,7 @@ func loadFloorOptions(db *sql.DB) ([]FloorOption, error) { return nil, err } type key struct{ brand, style string } - seen := map[key]int{} // key → index in out slice + seen := map[key]int{} // key 閳?index in out slice var out []FloorOption for _, p := range prods { k := key{p.Brand, p.StyleName} @@ -189,7 +191,7 @@ func findFloorBySKU(db *sql.DB, sku string) *FloorOption { 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`, + `尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`, canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description, ) } @@ -197,7 +199,7 @@ func findFloorBySKU(db *sql.DB, sku string) *FloorOption { return f } -// ── Public accessors for worker ──────────────────────────── +// 閳光偓閳光偓 Public accessors for worker 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) } func FindPatternByCodePublic(code string) *PatternOption { @@ -212,58 +214,42 @@ func FindRoomByCodePublic(code string) *RoomOption { } func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) } func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string { - return buildFloorPrompt(f, p, room) + 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) 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, - ) +// 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 `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.` + return "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。" 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.` + return "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。" 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.` + return "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。" 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.` + return "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。" 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".` + return "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 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".` + return "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。" 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.` + return "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。" 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.` + return "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。" 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".` + return "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。" default: return "" } } -// ── Floor Generate Handler (Redis queue) ────────────────── +// 閳光偓閳光偓 Floor Generate Handler (Redis queue) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) { generateQueue = enqueue @@ -334,10 +320,13 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { } // 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"), + "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") @@ -348,9 +337,9 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { } } -// ── Progress ─────────────────────────────────────────────── +// 閳光偓閳光偓 Progress 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 -// ── Floor Status Query ───────────────────────────────── +// 閳光偓閳光偓 Floor Status Query 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FloorStatus(w http.ResponseWriter, r *http.Request) { jobID := r.URL.Query().Get("job_id") @@ -370,7 +359,7 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, js) } -// ── Options ──────────────────────────────────────────────── +// 閳光偓閳光偓 Options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓 func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -387,185 +376,201 @@ func FloorOptions(db *sql.DB) http.HandlerFunc { } } + // 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, - "rooms": roomOptions, + "wood_floors": allWood, + "wood_patterns": woodPatterns, + "tile_floors": allTile, + "tile_patterns": tilePatterns, + "vinyl_floors": vinylFloors, + "special_floors": specialFloors, + "rooms": roomOptions, }) } } -// ── Prompts ──────────────────────────────────────────────── +// 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 := `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.` + base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" + + "严格定义:\n" + + "- 白色(#FFFFFF):只表示平整、水平、可行走的地面。包含所有可见地面,一直到墙面、踢脚线、柜体或楼梯边缘。\n" + + "- 黑色(#000000):所有非水平地面的内容,包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" + + "边缘规则:\n" + + "- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" + + "- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" + + "- 楼梯和地毯都标为黑色。\n\n" + + "只输出遮罩图片,不要文字说明。" if room != nil && room.Code != 0 { - base += fmt.Sprintf("\nContext: This is a %s.", room.Name) + base += fmt.Sprintf("\n房间类型:%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" +func buildMaterialRules(floor FloorOption) string { if isTileProduct(floor) || isLaminateProduct(floor) { - matType = "tile" + 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 } - 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) + var priority []string + if whiteBaseboard { + priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。") + } + if groutLines { + priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。") } - // Include product description if available for richer visual detail - var descriptionText string - if floor.Description != "" { - descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description) + priorityText := "" + if len(priority) > 0 { + priorityText = strings.Join(priority, "\n\n") + "\n\n" } - 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.`, - ) + editScope := "只替换地面" + if whiteBaseboard { + editScope = "只替换地面和白色踢脚线" } - // 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, + return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", + priorityText, + roomName, + editScope, + buildCompactFloorPrompt(f, p, room), ) } -// ── Content Check ───────────────────────────────────────── +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 floor.SizeLabel != "" || (floor.WidthIn > 0 && floor.LengthIn > 0) { + details = append(details, compactSizeInstruction(floor)) + } + 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) string { + if floor.SizeLabel != "" { + return "尺寸规则:使用所选尺寸 " + floor.SizeLabel + ";只调整板/砖大小和缝距,不改变材质颜色和纹理。" + } + if floor.WidthIn > 0 && floor.LengthIn > 0 { + return fmt.Sprintf("尺寸规则:每片 %.1f\" x %.1f\";只调整板/砖大小和缝距,不改变材质颜色和纹理。", floor.WidthIn, floor.LengthIn) + } + return "" +} + +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) @@ -573,13 +578,12 @@ func validateContent(client *openrouter.Client, ctx context.Context, imagePath s 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: `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).`}, + {Type: "text", Text: prompt}, }}}, }) if err != nil || len(resp.Choices) == 0 { @@ -588,7 +592,6 @@ Return ONLY a number (1-10).`}, 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 { @@ -626,3 +629,13 @@ func b64enc(data []byte) string { } 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" + } +} diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go index ff92d99..b696efe 100644 --- a/internal/openrouter/image.go +++ b/internal/openrouter/image.go @@ -7,6 +7,7 @@ import ( stderrors "errors" "fmt" "image" + "image/color" _ "image/gif" _ "image/jpeg" "image/png" @@ -22,9 +23,9 @@ import ( const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview" -const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged." +const maskImageInstruction = "下一张图片是遮罩图。非黑色像素表示允许编辑的区域;黑色像素表示必须保持不变的保护区域。" -const referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material." +const referenceImageInstruction = "下一张图片是地板材质参考图。生成新地面时必须严格匹配它的颜色、纹理、图案、光泽和整体材质外观;生成的地面应看起来就是这个材质。" var supportedAspectRatios = []struct { value string @@ -518,3 +519,125 @@ func ResizeImageToFit(inputPath, outputPath string) error { } return os.WriteFile(outputPath, buf.Bytes(), 0644) } + +// RepairNearBlackBorder removes accidental model-added black canvas edges. +// It only acts when an entire edge row/column is almost uniformly black. +func RepairNearBlackBorder(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("repair border: read image: %w", err) + } + src, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("repair border: decode image: %w", err) + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + if w < 8 || h < 8 { + return nil + } + + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + + maxBorder := minInt(32, minInt(w, h)/20) + if maxBorder < 2 { + maxBorder = 2 + } + + top := 0 + for top < maxBorder && isNearBlackRow(img, top) { + top++ + } + bottom := 0 + for bottom < maxBorder && isNearBlackRow(img, h-1-bottom) { + bottom++ + } + left := 0 + for left < maxBorder && isNearBlackCol(img, left) { + left++ + } + right := 0 + for right < maxBorder && isNearBlackCol(img, w-1-right) { + right++ + } + if top == 0 && bottom == 0 && left == 0 && right == 0 { + return nil + } + if top+bottom >= h || left+right >= w { + return nil + } + + for y := 0; y < top; y++ { + copyRow(img, top, y) + } + for y := h - bottom; y < h; y++ { + copyRow(img, h-bottom-1, y) + } + for x := 0; x < left; x++ { + copyCol(img, left, x) + } + for x := w - right; x < w; x++ { + copyCol(img, w-right-1, x) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return fmt.Errorf("repair border: encode png: %w", err) + } + if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("repair border: write image: %w", err) + } + return nil +} + +func isNearBlackRow(img *image.RGBA, y int) bool { + w := img.Bounds().Dx() + black := 0 + for x := 0; x < w; x++ { + if isNearBlack(img.RGBAAt(x, y)) { + black++ + } + } + return black*100 >= w*96 +} + +func isNearBlackCol(img *image.RGBA, x int) bool { + h := img.Bounds().Dy() + black := 0 + for y := 0; y < h; y++ { + if isNearBlack(img.RGBAAt(x, y)) { + black++ + } + } + return black*100 >= h*96 +} + +func isNearBlack(c color.RGBA) bool { + return c.A > 0 && c.R <= 18 && c.G <= 18 && c.B <= 18 +} + +func copyRow(img *image.RGBA, srcY, dstY int) { + w := img.Bounds().Dx() + for x := 0; x < w; x++ { + img.SetRGBA(x, dstY, img.RGBAAt(x, srcY)) + } +} + +func copyCol(img *image.RGBA, srcX, dstX int) { + h := img.Bounds().Dy() + for y := 0; y < h; y++ { + img.SetRGBA(dstX, y, img.RGBAAt(srcX, y)) + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index 719b5b3..203b7be 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -69,6 +69,12 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien floor := handler.FindFloorBySKUPublic(db, floorID) pattern := handler.FindPatternByCodePublic(patternCode) room := handler.FindRoomByCodePublic(roomCode) + groutLines := payloadBool(payload, "grout_lines") + whiteBaseboard := payloadBool(payload, "white_baseboard") + materialSource := payload["material_source"] // "image", "json", or "both" + if materialSource == "" { + materialSource = "image" + } if floor == nil || pattern == nil { SetJobError(ctx, rdb, jobID, "Invalid floor or pattern") @@ -103,7 +109,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien return } - // Step 1+2 combined: identify floor + replace in one call (no separate mask) + // Step 1: generate floor (single combined prompt — AI identifies floor + replaces it) roomLabel := "" if room != nil { roomLabel = " · " + room.Name @@ -113,12 +119,25 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s", jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond)) - refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + // Material source: image / json / both + if materialSource == "json" || materialSource == "both" { + if desc := handler.LookupMaterialDesc(floor.SKU); desc != "" { + if floor.Description != "" { + floor.Description = desc + " | " + floor.Description + } else { + floor.Description = desc + } + } + } + refImagePath := "" + if materialSource != "json" { + refImagePath = downloadReferenceImage(jobCtx, floor.ImageURL, jobID) + } imageSize := floorImageSize() - logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s", - jobID, imageSize, workPath, outputPath, refImagePath) - floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room) + logger.Info("[%s] TIMER generate request material=%s image_size=%s input=%s output=%s ref=%s", + jobID, materialSource, imageSize, workPath, outputPath, refImagePath) + floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutLines, whiteBaseboard) _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath, ImageSize: imageSize, ReferenceImagePath: refImagePath, @@ -128,6 +147,9 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error()) return } + if err := openrouter.RepairNearBlackBorder(outputPath); err != nil { + logger.Warn("[%s] Failed to repair output border: %v", jobID, err) + } logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "") // Clean up intermediate files @@ -194,9 +216,11 @@ func validateContent(geminiClient *openrouter.Client, ctx context.Context, image 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).`}, + {Type: "text", Text: `请按 1-10 分评估这张图片是否适合做室内地面替换。 +8-10 分:清晰室内房间,地面可见。 +5-7 分:室内图片,但角度或地面可见度一般。 +1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。 +只返回一个数字(1-10)。`}, }}}, }) if err != nil || len(resp.Choices) == 0 { @@ -305,6 +329,15 @@ func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string return refPath } +func payloadBool(payload map[string]string, key string) bool { + switch payload[key] { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + func floorImageSize() string { value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE")) if value == "" { diff --git a/main.go b/main.go index fa309e2..ccc629a 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "math/rand" "net/http" "os" + "os/exec" "os/signal" "strconv" "time" @@ -41,6 +42,19 @@ func init() { func main() { godotenv.Load() + // Load material description index + if err := handler.LoadMaterialIndex("outputs/material_index.json"); err != nil { + logger.Warn("Material index not loaded: %v", err) + } else { + logger.Info("Material index loaded") + } + + // Start CLIP server for content check + clipCmd := startCLIPServer() + if clipCmd != nil { + defer stopCLIPServer(clipCmd) + } + logDir := "logs" if v := os.Getenv("LOG_DIR"); v != "" { logDir = v @@ -183,6 +197,7 @@ func main() { if cancelWorkers != nil { cancelWorkers() } + stopCLIPServer(clipCmd) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { @@ -196,3 +211,42 @@ func main() { } logger.Info("Server stopped gracefully") } + +// startCLIPServer launches the Python CLIP server for content checking. +func startCLIPServer() *exec.Cmd { + // Check if CLIP is already running + if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil { + logger.Info("CLIP server already running on :5100") + return nil + } + pythonPath := `C:\Users\10794\AppData\Local\Programs\Python\Python312\python.exe` + cmd := exec.Command(pythonPath, "clip_server/server.py") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + logger.Warn("Failed to start CLIP server: %v (content check will use Gemini fallback)", err) + return nil + } + logger.Info("CLIP server starting on :5100...") + // Wait for it to be ready + for i := 0; i < 30; i++ { + time.Sleep(time.Second) + if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil { + logger.Info("CLIP server ready") + return cmd + } + } + logger.Warn("CLIP server did not become ready in time") + return cmd +} + +// stopCLIPServer stops the CLIP Python server. +func stopCLIPServer(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + logger.Info("Stopping CLIP server...") + if err := cmd.Process.Kill(); err != nil { + logger.Warn("Failed to stop CLIP server: %v", err) + } +} -- 2.52.0 From 90fd37742fc9e1a9e09f1849bee6e215167ccfa3 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 15:07:12 +0800 Subject: [PATCH 08/10] v2 --- internal/handler/floor_handler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index 33a64b2..ad8d8d0 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -496,7 +496,7 @@ func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomO editScope = "只替换地面和白色踢脚线" } - return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", + return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。", priorityText, roomName, editScope, -- 2.52.0 From 3fa3561bb2311df143c84984e0a4bcfd4a80e498 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 15:34:32 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E9=97=AE=E5=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 25 + clip_server/server.py | 90 +++ internal/api/index.html | 440 +++++++++++++ internal/api/index_handler.go | 10 + internal/api/index_html.go | 3 + internal/api/questionnaire.html | 359 +++++++++++ internal/api/router.go | 5 + internal/handler/questionnaire_handler.go | 713 ++++++++++++++++++++++ internal/repository/product_repo.go | 9 + 9 files changed, 1654 insertions(+) create mode 100644 .env.example create mode 100644 clip_server/server.py create mode 100644 internal/api/index.html create mode 100644 internal/api/questionnaire.html create mode 100644 internal/handler/questionnaire_handler.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66470e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# ─── 服务器 ─── +PORT=8099 + +# ─── 数据库 ─── +DATABASE_URL=postgres://postgres:your_password@localhost:5432/floorvisualizer?sslmode=disable + +# ─── Redis ─── +REDIS_ADDR=localhost:6379 + +# ─── JWT ─── +JWT_SECRET=change-this-to-a-strong-secret + +# ─── AI ─── +OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here + +# ─── 代理(国内环境用,留空则直连) ─── +PROXY_URL=http://127.0.0.1:7897 +# DISABLE_PROXY=true + +# ─── 队列 ─── +WORKER_COUNT=5 + +# ─── 日志 ─── +LOG_DIR=logs +LOG_LEVEL=INFO diff --git a/clip_server/server.py b/clip_server/server.py new file mode 100644 index 0000000..57c3bdb --- /dev/null +++ b/clip_server/server.py @@ -0,0 +1,90 @@ +"""CLIP-based indoor room classifier. Runs ViT-B/32 on CPU, ~100ms per image.""" + +import io +import logging +import os + +import open_clip +import torch +from PIL import Image +from flask import Flask, jsonify, request + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("clip-server") +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB max upload + +# Load once at startup +MODEL_NAME = os.getenv("CLIP_MODEL", "ViT-B-32") +PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s34b_b79k") +DEVICE = "cuda" if torch.cuda.is_available() else "cpu" + +log.info(f"Loading CLIP model: {MODEL_NAME} pretrained={PRETRAINED} device={DEVICE}") +model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED) +model = model.to(DEVICE).eval() +tokenizer = open_clip.get_tokenizer(MODEL_NAME) + +# Text prompts for indoor/outdoor classification +PROMPTS = [ + "a photo of an indoor room interior with floor visible", + "a photo of an outdoor scene, landscape, street, or non-room image", +] + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok", "model": MODEL_NAME, "device": DEVICE}) + + +@app.route("/check", methods=["POST"]) +def check(): + """Classify whether an image is an indoor room photo suitable for floor replacement. + Expects raw image bytes as the request body. Returns {score: 0-10, passed: bool}.""" + if not request.data: + return jsonify({"error": "no image data"}), 400 + + try: + img = Image.open(io.BytesIO(request.data)).convert("RGB") + except Exception as e: + return jsonify({"error": f"bad image: {e}"}), 400 + + # Multiple prompt pairs for robust ensemble (indoor vs outdoor) + prompt_pairs = [ + ("a photo of an indoor room interior with floor", "a photo of an outdoor scene"), + ("interior room photograph, real estate photo", "exterior photograph, outdoor photo"), + ("an indoor space with visible flooring", "an outdoor space, landscape, street view"), + ] + + image_input = preprocess(img).unsqueeze(0).to(DEVICE) + wins = 0 + gap_sum = 0.0 + for indoor_text, outdoor_text in prompt_pairs: + text_input = tokenizer([indoor_text, outdoor_text]).to(DEVICE) + with torch.no_grad(): + image_features = model.encode_image(image_input) + text_features = model.encode_text(text_input) + image_features /= image_features.norm(dim=-1, keepdim=True) + text_features /= text_features.norm(dim=-1, keepdim=True) + raw = (image_features @ text_features.T)[0] # cosine similarities + indoor_sim = float(raw[0].item()) + outdoor_sim = float(raw[1].item()) + if indoor_sim > outdoor_sim: + wins += 1 + gap_sum += indoor_sim - outdoor_sim # positive = indoor wins + + indoor_wins = wins >= 2 # majority vote (2 out of 3) + avg_gap = gap_sum / len(prompt_pairs) + # Map avg_gap to 1-10: gap ~0.05 = score 6, gap ~0.15+ = score 10 + score_1_10 = max(1, min(10, int(5 + avg_gap * 40))) + passed = indoor_wins + + log.info( + f"wins={wins}/3 avg_gap={avg_gap:.3f} score_1_10={score_1_10} passed={passed}" + ) + return jsonify({"score": score_1_10, "passed": passed}) + + +if __name__ == "__main__": + port = int(os.getenv("CLIP_PORT", "5100")) + log.info(f"CLIP server listening on :{port}") + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/internal/api/index.html b/internal/api/index.html new file mode 100644 index 0000000..bc93fe8 --- /dev/null +++ b/internal/api/index.html @@ -0,0 +1,440 @@ + + + + + +FloorVisualizer Demo + + + +
+

FloorVisualizer AI 换地板 Demo

+

上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览

+
+ +
+
+
+

第一步:上传房间照片

+
+ + + 点击或拖拽上传图片
建议使用能清楚看到地面的室内照片
+
+
+ +
+

房间类型

+
+
+ +
+

地板类型

+
+
木地板
Hardwood
+
瓷砖
Tile / Stone
+
乙烯基地板
Luxury Vinyl / SPC
+
特殊测试
7款 · 含AI描述
+
+
+ +
+

铺设方式

+
+
+ + + +
+

选择地板款式

+
+
+
+ +
+

材质参考方式

+
+
仅图片
参考图
+
仅JSON
材质描述
+
图片+JSON
两者都用
+
+
+ +
+

可选效果

+
+ + +
+
+ + +
+
+
+ +
+
+
+

生成失败原因

+

+    
+
+ +
+

生成结果

+
上传图片并选择地板选项后
点击生成预览图查看效果
+ 生成结果 +
+ 生成图 +
原图
+
+
原图生成图
+
+
+
+ + + + diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go index 67df1db..02d6e64 100644 --- a/internal/api/index_handler.go +++ b/internal/api/index_handler.go @@ -11,3 +11,13 @@ func Index(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(indexHTML)) } + +func Questionnaire(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/questionnaire" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(questionnaireHTML)) +} diff --git a/internal/api/index_html.go b/internal/api/index_html.go index bc888e3..f700edb 100644 --- a/internal/api/index_html.go +++ b/internal/api/index_html.go @@ -4,3 +4,6 @@ import _ "embed" //go:embed index.html var indexHTML string + +//go:embed questionnaire.html +var questionnaireHTML string diff --git a/internal/api/questionnaire.html b/internal/api/questionnaire.html new file mode 100644 index 0000000..fa8a64d --- /dev/null +++ b/internal/api/questionnaire.html @@ -0,0 +1,359 @@ + + + + + +地板偏好问卷 - FloorVisualizer + + + +
+ + +
+
+
+

问卷推荐结果

+

回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。

+
+
+
-候选 SKU
+
-去重款式
+
0%问卷完成度
+
+
+
+
请选择你的空间、风格、外观、颜色和预算。
+
+
+
+ + + + diff --git a/internal/api/router.go b/internal/api/router.go index 7d942f8..238ac88 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -32,6 +32,8 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a productOpts = handler.ProductOptions(db) filterOpts = handler.FilterOptions(db) recommend = handler.Recommend(db) + questionnaireRec = handler.QuestionnaireRecommend(db) + questionnaireOpt = handler.QuestionnaireOptions(db) floorGenerate = handler.FloorGenerate(client, db) floorOptions = handler.FloorOptions(db) articleRecommend = handler.ArticleRecommend(db) @@ -56,6 +58,9 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a mux.HandleFunc("/product-options", productOpts) mux.HandleFunc("/filter-options", filterOpts) mux.HandleFunc("/recommend/api", recommend) + mux.HandleFunc("/questionnaire", Questionnaire) + mux.HandleFunc("/questionnaire/options", questionnaireOpt) + mux.HandleFunc("/questionnaire/recommend", questionnaireRec) mux.HandleFunc("/calculator/calc", handler.Calc) mux.HandleFunc("/floor/generate", floorGenerate) mux.HandleFunc("/floor/status", handler.FloorStatus) diff --git a/internal/handler/questionnaire_handler.go b/internal/handler/questionnaire_handler.go new file mode 100644 index 0000000..8ac90db --- /dev/null +++ b/internal/handler/questionnaire_handler.go @@ -0,0 +1,713 @@ +package handler + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strings" + + "floorvisualizer/internal/model" + "floorvisualizer/internal/repository" +) + +type QuestionnaireBudget struct { + Min float64 `json:"min"` + Max float64 `json:"max"` +} + +type QuestionnaireRequest struct { + Rooms []string `json:"rooms"` + Styles []string `json:"styles"` + Looks []string `json:"looks"` + ColorTones []string `json:"color_tones"` + Budget QuestionnaireBudget `json:"budget"` + Brands []string `json:"brands"` + BrandMode string `json:"brand_mode"` + Performance []string `json:"performance"` + Finishes []string `json:"finishes"` + SizePreference string `json:"size_preference"` + Priority string `json:"priority"` + Answers map[string]any `json:"answers,omitempty"` + Limit int `json:"limit"` +} + +type QuestionnaireRecommendation struct { + Product model.Product `json:"product"` + Score float64 `json:"score"` + Reasons []string `json:"reasons"` + SpecCount int `json:"spec_count"` +} + +type QuestionnaireResponse struct { + Recommendations []QuestionnaireRecommendation `json:"recommendations"` + TotalCandidates int `json:"total_candidates"` + UniqueCandidates int `json:"unique_candidates"` + AnsweredWeights float64 `json:"answered_weights"` +} + +type questionnaireScoredProduct struct { + product model.Product + score float64 + reasons []string +} + +func QuestionnaireOptions(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if db == nil { + writeErr(w, http.StatusServiceUnavailable, "database is not available") + return + } + out := map[string]any{ + "brands": queryOptionCounts(db, "brand", 20), + "categories": queryOptionCounts(db, "category", 20), + "materials": queryOptionCounts(db, "material", 40), + "color_tones": queryOptionCounts(db, "color_tone", 30), + "finishes": queryOptionCounts(db, "finish", 20), + "price_stats": queryPriceStats(db), + } + writeJSON(w, http.StatusOK, out) + } +} + +func QuestionnaireRecommend(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if db == nil { + writeErr(w, http.StatusServiceUnavailable, "database is not available") + return + } + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req QuestionnaireRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid questionnaire payload") + return + } + normalizeQuestionnaireRequest(&req) + if req.Limit <= 0 || req.Limit > 30 { + req.Limit = 10 + } + + products, err := repository.QueryAllProductRows(db) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + + brandOnly := req.BrandMode == "only" && len(req.Brands) > 0 + brandSet := toSet(req.Brands) + scored := make([]questionnaireScoredProduct, 0, len(products)) + for _, p := range products { + if !recommendableProduct(p) { + continue + } + if brandOnly && !brandSet[p.Brand] { + continue + } + score, reasons, answeredWeight := scoreQuestionnaireProduct(req, p) + if answeredWeight == 0 { + score = neutralProductScore(p) + } + scored = append(scored, questionnaireScoredProduct{product: p, score: roundOne(score), reasons: reasons}) + } + + unique := dedupeQuestionnaireResults(scored) + sort.SliceStable(unique, func(i, j int) bool { + if unique[i].score != unique[j].score { + return unique[i].score > unique[j].score + } + if unique[i].product.IsOfficialPrice != unique[j].product.IsOfficialPrice { + return unique[i].product.IsOfficialPrice + } + if req.Priority == "budget" && unique[i].product.PricePerSqft != unique[j].product.PricePerSqft { + return unique[i].product.PricePerSqft < unique[j].product.PricePerSqft + } + return unique[i].product.MainImageURL != "" && unique[j].product.MainImageURL == "" + }) + + selected := diversifyQuestionnaireResults(unique, req.Limit, brandOnly) + skus := make([]string, 0, len(selected)) + for _, r := range selected { + skus = append(skus, r.product.SKU) + } + specCounts := repository.GetSpecCounts(db, skus) + + recs := make([]QuestionnaireRecommendation, 0, len(selected)) + for _, r := range selected { + key := r.product.Brand + "|" + r.product.StyleName + "|" + r.product.SeriesName + recs = append(recs, QuestionnaireRecommendation{ + Product: r.product, + Score: r.score, + Reasons: fallbackReasons(r.reasons, r.product), + SpecCount: specCounts[key], + }) + } + + _, _, answeredWeight := scoreQuestionnaireProduct(req, model.Product{}) + writeJSON(w, http.StatusOK, QuestionnaireResponse{ + Recommendations: recs, + TotalCandidates: len(scored), + UniqueCandidates: len(unique), + AnsweredWeights: roundOne(answeredWeight), + }) + } +} + +func normalizeQuestionnaireRequest(req *QuestionnaireRequest) { + req.Rooms = cleanList(req.Rooms) + req.Styles = cleanList(req.Styles) + req.Looks = cleanList(req.Looks) + req.ColorTones = cleanList(req.ColorTones) + req.Brands = cleanList(req.Brands) + req.Performance = cleanList(req.Performance) + req.Finishes = cleanList(req.Finishes) + req.SizePreference = strings.TrimSpace(req.SizePreference) + req.Priority = strings.TrimSpace(req.Priority) + req.BrandMode = strings.TrimSpace(req.BrandMode) +} + +func scoreQuestionnaireProduct(req QuestionnaireRequest, p model.Product) (float64, []string, float64) { + weights := map[string]float64{ + "color": 20, + "style": 20, + "look": 15, + "budget": 15, + "room": 10, + "performance": 10, + "brand": 7, + "finish": 5, + "size": 3, + } + adjustWeights(weights, req.Priority) + + totalWeight := 0.0 + weighted := 0.0 + reasons := []string{} + + add := func(name string, answered bool, score float64, reason string) { + if !answered { + return + } + w := weights[name] + totalWeight += w + weighted += clampScore(score) * w + if score >= 78 && reason != "" { + reasons = append(reasons, reason) + } + } + + add("color", len(req.ColorTones) > 0, scoreExactOrRelated(p.ColorTone, req.ColorTones, colorRelatedGroups()), fmt.Sprintf("颜色接近 %s", p.ColorTone)) + add("style", len(req.Styles) > 0, scoreStyles(p, req.Styles), "风格偏好匹配") + add("look", len(req.Looks) > 0, scoreLooks(p, req.Looks), "外观类型匹配") + add("budget", req.Budget.Min > 0 || req.Budget.Max > 0, scoreBudget(p.PricePerSqft, req.Budget), priceReason(p)) + add("room", len(req.Rooms) > 0, scoreRooms(p, req.Rooms), "适合选择的使用空间") + add("performance", len(req.Performance) > 0, scorePerformance(p, req.Performance), "性能偏好匹配") + add("brand", len(req.Brands) > 0 && req.BrandMode != "only", scoreExactOrRelated(p.Brand, req.Brands, nil), fmt.Sprintf("匹配品牌 %s", p.Brand)) + add("finish", len(req.Finishes) > 0, scoreExactOrRelated(p.Finish, req.Finishes, [][]string{{"Matte", "Textured"}, {"Distressed/Embossed", "Wire Brushed"}}), fmt.Sprintf("表面质感为 %s", p.Finish)) + add("size", req.SizePreference != "", scoreSize(p, req.SizePreference), "规格视觉匹配") + + if totalWeight == 0 { + return 0, reasons, 0 + } + + score := weighted / totalWeight + if p.MainImageURL != "" || p.RoomImageURL != "" { + score += 1.5 + } + if p.IsOfficialPrice { + score += 1 + } + return clampScore(score), compactReasons(reasons), totalWeight +} + +func adjustWeights(weights map[string]float64, priority string) { + switch priority { + case "style": + weights["style"] += 8 + weights["color"] += 4 + case "budget": + weights["budget"] += 12 + case "durability": + weights["performance"] += 10 + weights["room"] += 4 + case "brand": + weights["brand"] += 12 + case "color": + weights["color"] += 12 + } +} + +func recommendableProduct(p model.Product) bool { + status := strings.ToLower(strings.TrimSpace(p.Status)) + if strings.Contains(status, "discontinued") || strings.Contains(status, "inactive") || strings.Contains(status, "下架") { + return false + } + return p.SKU != "" && p.StyleName != "" +} + +func neutralProductScore(p model.Product) float64 { + score := 60.0 + if p.MainImageURL != "" || p.RoomImageURL != "" { + score += 8 + } + if p.PricePerSqft > 0 { + score += 5 + } + if p.IsOfficialPrice { + score += 3 + } + return score +} + +func scoreExactOrRelated(value string, selected []string, related [][]string) float64 { + if value == "" || len(selected) == 0 { + return 50 + } + for _, s := range selected { + if strings.EqualFold(value, s) { + return 100 + } + } + for _, group := range related { + if containsFold(group, value) { + for _, s := range selected { + if containsFold(group, s) { + return 70 + } + } + } + } + return 15 +} + +func colorRelatedGroups() [][]string { + return [][]string{ + {"Natural", "Warm Yellow", "Taupe"}, + {"Light Gray", "Off White", "Taupe"}, + {"Dark Brown", "Black Walnut", "Cherry"}, + {"Green", "Blue", "Light Gray"}, + } +} + +func scoreStyles(p model.Product, styles []string) float64 { + best := 0.0 + for _, style := range styles { + best = math.Max(best, scoreStyle(p, style)) + } + return best +} + +func scoreStyle(p model.Product, style string) float64 { + text := productText(p) + switch style { + case "modern": + return tagScore(p, []string{"Light Gray", "Off White", "Natural"}, []string{"Maple", "Ash", "Porcelain"}, []string{"modern", "clean", "minimal", "airy", "sophisticated"}, text) + case "warm_natural": + return tagScore(p, []string{"Natural", "Warm Yellow", "Taupe"}, []string{"Oak", "Hickory", "Pine", "Ash"}, []string{"warm", "natural", "wood", "honey"}, text) + case "classic": + return tagScore(p, []string{"Cherry", "Dark Brown", "Black Walnut", "Natural"}, []string{"Oak", "Walnut", "Maple", "Hickory"}, []string{"classic", "traditional", "timeless"}, text) + case "rustic": + score := tagScore(p, []string{"Natural", "Dark Brown", "Taupe"}, []string{"Hickory", "Oak", "Pine"}, []string{"rustic", "scraped", "weathered", "reclaimed", "vintage", "barn"}, text) + if p.Finish == "Distressed/Embossed" || p.Finish == "Wire Brushed" { + score += 20 + } + return clampScore(score) + case "luxury_stone": + score := tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Porcelain", "Stone Look"}, []string{"marble", "travertine", "polished", "stone", "luxe"}, text) + if p.Category == "Tile/Stone" { + score += 25 + } + if p.Finish == "Gloss" { + score += 10 + } + return clampScore(score) + case "industrial": + return tagScore(p, []string{"Light Gray", "Black Walnut", "Dark Brown"}, []string{"Porcelain", "Stone Look"}, []string{"concrete", "slate", "smoke", "graphite", "cement", "industrial"}, text) + case "coastal": + return tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Oak", "Maple", "Ash"}, []string{"beach", "coastal", "sand", "airy", "white"}, text) + } + return 50 +} + +func tagScore(p model.Product, colors, materials, keywords []string, text string) float64 { + score := 15.0 + if containsFold(colors, p.ColorTone) { + score += 30 + } + if containsFold(materials, p.Material) || materialFamilyHit(p.Material, materials) { + score += 25 + } + for _, kw := range keywords { + if strings.Contains(text, kw) { + score += 15 + break + } + } + if p.Finish == "Matte" { + score += 8 + } + return clampScore(score) +} + +func scoreLooks(p model.Product, looks []string) float64 { + best := 0.0 + text := productText(p) + for _, look := range looks { + score := 0.0 + switch look { + case "wood_look": + if isWoodLike(p, text) { + score = 100 + } else if p.Category == "SPC-LVP" || p.Category == "Laminate" { + score = 70 + } else { + score = 20 + } + case "stone_tile": + if p.Category == "Tile/Stone" || strings.Contains(text, "stone") || strings.Contains(text, "marble") || strings.Contains(text, "travertine") { + score = 100 + } else { + score = 15 + } + case "real_hardwood": + if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" { + score = 100 + } else if isWoodLike(p, text) { + score = 55 + } else { + score = 10 + } + case "light_clean": + score = scoreExactOrRelated(p.ColorTone, []string{"Off White", "Light Gray", "Natural"}, colorRelatedGroups()) + case "dark_rich": + score = scoreExactOrRelated(p.ColorTone, []string{"Dark Brown", "Black Walnut", "Cherry"}, colorRelatedGroups()) + } + best = math.Max(best, score) + } + return best +} + +func scoreBudget(price float64, budget QuestionnaireBudget) float64 { + if price <= 0 { + return 50 + } + min, max := budget.Min, budget.Max + if min > 0 && price < min { + return clampScore(100 - ((min-price)/math.Max(min, 1))*80) + } + if max > 0 && price > max { + return clampScore(100 - ((price-max)/math.Max(max, 1))*90) + } + return 100 +} + +func scoreRooms(p model.Product, rooms []string) float64 { + best := 0.0 + for _, room := range rooms { + score := 50.0 + switch room { + case "bathroom", "kitchen", "basement", "commercial": + score = scorePerformance(p, []string{"waterproof", "easy_clean"}) + case "living_room", "bedroom": + score = math.Max(scoreStyles(p, []string{"warm_natural", "modern"}), 60) + case "rental": + score = scorePerformance(p, []string{"scratch_resistant", "easy_clean"}) + } + best = math.Max(best, score) + } + return best +} + +func scorePerformance(p model.Product, performance []string) float64 { + best := 0.0 + text := productText(p) + for _, perf := range performance { + score := 20.0 + switch perf { + case "waterproof": + if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || containsAnyFold(p.Material, []string{"porcelain", "vinyl", "rigid core", "spc"}) || strings.Contains(text, "water") || strings.Contains(text, "moisture") { + score = 100 + } + case "scratch_resistant": + if p.Category == "Tile/Stone" || p.Category == "Laminate" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"scratch", "durable", "superguard", "wear"}) { + score = 95 + } + case "easy_clean": + if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || p.Finish == "Matte" || containsAnyFold(text, []string{"clean", "maintenance"}) { + score = 90 + } + case "pet_kid": + score = math.Max(scorePerformance(p, []string{"waterproof"}), scorePerformance(p, []string{"scratch_resistant"})) + case "comfort_quiet": + if p.Brand == "COREtec" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"quiet", "comfort", "soft", "underlayment"}) { + score = 90 + } + case "eco": + if containsAnyFold(text, []string{"recycled", "eco", "green", "low voc"}) { + score = 90 + } else { + score = 45 + } + } + best = math.Max(best, score) + } + return best +} + +func scoreSize(p model.Product, pref string) float64 { + switch pref { + case "wide_plank": + if p.WidthIn >= 7 && p.LengthIn >= 48 { + return 100 + } + if p.WidthIn >= 5 { + return 70 + } + case "standard_plank": + if p.WidthIn >= 4 && p.WidthIn < 7 && p.LengthIn >= 36 { + return 100 + } + return 55 + case "large_tile": + if p.Category == "Tile/Stone" && p.WidthIn >= 12 && p.LengthIn >= 24 { + return 100 + } + if p.Category == "Tile/Stone" { + return 65 + } + case "small_tile": + if p.Category == "Tile/Stone" && (p.WidthIn <= 6 || p.LengthIn <= 12) { + return 100 + } + if p.Category == "Tile/Stone" { + return 60 + } + } + return 20 +} + +func dedupeQuestionnaireResults(results []questionnaireScoredProduct) []questionnaireScoredProduct { + best := map[string]questionnaireScoredProduct{} + for _, r := range results { + key := strings.ToLower(r.product.Brand + "|" + r.product.SeriesName + "|" + r.product.StyleName + "|" + r.product.ColorTone) + if old, ok := best[key]; !ok || r.score > old.score || (r.score == old.score && r.product.MainImageURL != "" && old.product.MainImageURL == "") { + best[key] = r + } + } + out := make([]questionnaireScoredProduct, 0, len(best)) + for _, r := range best { + out = append(out, r) + } + return out +} + +func diversifyQuestionnaireResults(results []questionnaireScoredProduct, limit int, brandOnly bool) []questionnaireScoredProduct { + if len(results) <= limit { + return results + } + maxPerBrand := 4 + if brandOnly { + maxPerBrand = limit + } + brandCounts := map[string]int{} + selected := make([]questionnaireScoredProduct, 0, limit) + for _, r := range results { + if brandCounts[r.product.Brand] >= maxPerBrand { + continue + } + selected = append(selected, r) + brandCounts[r.product.Brand]++ + if len(selected) == limit { + return selected + } + } + for _, r := range results { + exists := false + for _, s := range selected { + if s.product.SKU == r.product.SKU { + exists = true + break + } + } + if !exists { + selected = append(selected, r) + } + if len(selected) == limit { + break + } + } + return selected +} + +func fallbackReasons(reasons []string, p model.Product) []string { + reasons = compactReasons(reasons) + if len(reasons) > 0 { + return reasons + } + out := []string{} + if p.ColorTone != "" { + out = append(out, "颜色为 "+p.ColorTone) + } + if p.Material != "" { + out = append(out, "材质为 "+p.Material) + } + if p.PricePerSqft > 0 { + out = append(out, priceReason(p)) + } + return compactReasons(out) +} + +func compactReasons(reasons []string) []string { + seen := map[string]bool{} + out := []string{} + for _, r := range reasons { + r = strings.TrimSpace(r) + if r == "" || seen[r] { + continue + } + seen[r] = true + out = append(out, r) + if len(out) >= 4 { + break + } + } + return out +} + +func priceReason(p model.Product) string { + if p.PricePerSqft <= 0 { + return "" + } + return fmt.Sprintf("价格约 $%.2f/sqft", p.PricePerSqft) +} + +func productText(p model.Product) string { + return strings.ToLower(strings.Join([]string{ + p.Brand, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.Description, + }, " ")) +} + +func isWoodLike(p model.Product, text string) bool { + if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" || p.Category == "Laminate" || p.Category == "SPC-LVP" { + if materialFamilyHit(p.Material, []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak", "Birch", "Luxury Vinyl", "Rigid Core"}) { + return true + } + } + return containsAnyFold(text, []string{"oak", "maple", "hickory", "walnut", "ash", "pine", "wood", "plank"}) +} + +func materialFamilyHit(material string, choices []string) bool { + m := strings.ToLower(material) + for _, choice := range choices { + c := strings.ToLower(choice) + if m == c || strings.Contains(m, c) { + return true + } + } + return false +} + +func queryOptionCounts(db *sql.DB, col string, limit int) []map[string]any { + rows, err := db.Query("SELECT "+col+", COUNT(*) FROM products WHERE "+col+" != '' GROUP BY "+col+" ORDER BY COUNT(*) DESC, "+col+" LIMIT $1", limit) + if err != nil { + return nil + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var value string + var count int + if err := rows.Scan(&value, &count); err == nil { + out = append(out, map[string]any{"value": value, "count": count}) + } + } + return out +} + +func queryPriceStats(db *sql.DB) map[string]float64 { + rows, err := db.Query(` + SELECT + COALESCE(MIN(price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqft), 0), + COALESCE(MAX(price_per_sqft), 0) + FROM products WHERE price_per_sqft > 0 + `) + if err != nil { + return map[string]float64{} + } + defer rows.Close() + stats := map[string]float64{} + if rows.Next() { + var min, p25, median, p75, max float64 + if err := rows.Scan(&min, &p25, &median, &p75, &max); err == nil { + stats["min"] = roundOne(min) + stats["p25"] = roundOne(p25) + stats["median"] = roundOne(median) + stats["p75"] = roundOne(p75) + stats["max"] = roundOne(max) + } + } + return stats +} + +func cleanList(values []string) []string { + out := []string{} + seen := map[string]bool{} + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + return out +} + +func toSet(values []string) map[string]bool { + out := map[string]bool{} + for _, v := range values { + out[v] = true + } + return out +} + +func containsFold(values []string, target string) bool { + for _, v := range values { + if strings.EqualFold(v, target) { + return true + } + } + return false +} + +func containsAnyFold(value string, needles []string) bool { + v := strings.ToLower(value) + for _, n := range needles { + if strings.Contains(v, strings.ToLower(n)) { + return true + } + } + return false +} + +func clampScore(v float64) float64 { + if v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v +} + +func roundOne(v float64) float64 { + return math.Round(v*10) / 10 +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go index da41f44..c0f22dc 100644 --- a/internal/repository/product_repo.go +++ b/internal/repository/product_repo.go @@ -71,6 +71,15 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) { return prods, err } +func QueryAllProductRows(d *sql.DB) ([]model.Product, error) { + rows, err := d.Query("SELECT " + selectCols + " FROM products ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + return ScanProducts(rows) +} + func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) { if p.Limit <= 0 { p.Limit = 20 -- 2.52.0 From 1bd5fa9d4cea1e9b8fc4aecd6ec3db36052ebff9 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 16:12:02 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E5=86=B2=E7=AA=81=E8=A7=A3=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 3efa71e..cba7e37 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,3 @@ AI 图片生成使用 Redis List 做消息队列,Worker 池消费: - Worker 从 BRPOP 出队 → 调用 OpenRouter → 写入结果 - 并发数通过 `WORKER_COUNT` 控制,默认 5 - 进度查询: `GET /floor/status?job_id=xxx` - "description": "Light to medium golden oak-brown wood tone with warm -- yellow undertones and subtle tonal variation, featuring fine to moderately -- pronounced straight and gentle cathedral grain, a smooth lightly textured surface, -- low satin sheen, and a clean, natural, uniform visual character." -- 2.52.0