Compare commits
No commits in common. "1e1ffb23f33ba5b327cb6d98913826a33f9da393" and "5e35532dbb98ea6f8b810bcdb076dee5761e3858" have entirely different histories.
1e1ffb23f3
...
5e35532dbb
25
.env.example
25
.env.example
@ -1,25 +0,0 @@
|
||||
# ─── 服务器 ───
|
||||
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
|
||||
@ -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."
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
"""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)
|
||||
@ -7,14 +7,16 @@ services:
|
||||
- "8099:8099"
|
||||
volumes:
|
||||
- floor_outputs:/app/outputs
|
||||
- floor_uploads:/app/uploads
|
||||
- floor_outputs:/app/outputs
|
||||
- floor_logs:
|
||||
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
|
||||
|
||||
@ -1,440 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>FloorVisualizer Demo</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#1a1a2e;color:#eee;min-height:100vh}
|
||||
header{background:#16213e;padding:20px;text-align:center;border-bottom:2px solid #e94560}
|
||||
header h1{font-size:24px;color:#e94560}
|
||||
header p{font-size:14px;color:#aaa;margin-top:6px}
|
||||
.container{max-width:1280px;margin:0 auto;padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
||||
.panel{background:#16213e;border-radius:12px;padding:22px;margin-bottom:16px}
|
||||
.panel h2{font-size:18px;margin-bottom:16px;color:#e94560}
|
||||
.upload-zone{border:2px dashed #444;border-radius:10px;padding:32px;min-height:220px;display:flex;align-items:center;justify-content:center;text-align:center;cursor:pointer;transition:.2s}
|
||||
.upload-zone:hover,.upload-zone.dragover{border-color:#e94560;background:rgba(233,68,96,.06)}
|
||||
.upload-zone img{max-width:100%;max-height:320px;border-radius:8px}
|
||||
.upload-zone span{color:#888;font-size:14px;line-height:1.7}
|
||||
.options{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||
.option{padding:12px;border:2px solid #333;border-radius:8px;cursor:pointer;text-align:center;transition:.2s;user-select:none;min-height:64px;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||
.option:hover{border-color:#e94560}
|
||||
.option.selected{border-color:#e94560;background:rgba(233,68,96,.15)}
|
||||
.option.disabled{opacity:.45;cursor:not-allowed}
|
||||
.option .name{font-size:14px;font-weight:700;line-height:1.35}
|
||||
.option .meta{font-size:12px;color:#aaa;margin-top:4px;line-height:1.35}
|
||||
.floor-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;max-height:430px;overflow:auto;padding-right:4px}
|
||||
.floor-card{border:2px solid #333;border-radius:8px;padding:10px;cursor:pointer;transition:.2s;background:#11172d}
|
||||
.floor-card:hover{border-color:#e94560}
|
||||
.floor-card.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
|
||||
.floor-card img{width:100%;height:70px;object-fit:cover;border-radius:6px;background:#222;margin-bottom:8px}
|
||||
.floor-card .title{font-size:13px;font-weight:700;line-height:1.3}
|
||||
.floor-card .desc{font-size:11px;color:#aaa;margin-top:4px;line-height:1.35}
|
||||
.toolbar{display:flex;gap:10px;margin-bottom:10px}
|
||||
.toolbar input{flex:1;background:#0f3460;border:1px solid #34456c;border-radius:8px;color:#eee;padding:10px}
|
||||
.toggle-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.toggle{border:2px dashed #444;border-radius:8px;padding:12px;cursor:pointer;background:#11172d}
|
||||
.toggle.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
|
||||
.toggle.disabled{opacity:.45;cursor:not-allowed}
|
||||
.toggle input{margin-right:8px}
|
||||
.btn{width:100%;padding:14px;margin-top:4px;border:0;border-radius:8px;background:#e94560;color:#fff;font-size:16px;font-weight:700;cursor:pointer}
|
||||
.btn:hover{background:#ff647b}
|
||||
.btn:disabled{background:#555;cursor:not-allowed}
|
||||
.spinner{display:none;width:18px;height:18px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:8px}
|
||||
.loading .spinner{display:inline-block}
|
||||
.loading .btn-text{display:none}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.result-panel{display:flex;flex-direction:column;min-height:720px}
|
||||
.placeholder{flex:1;min-height:360px;border:1px dashed #333;border-radius:8px;display:flex;align-items:center;justify-content:center;text-align:center;color:#777;line-height:1.8}
|
||||
.result-img{display:none;width:100%;border-radius:8px;margin-top:12px}
|
||||
.compare{position:relative;width:100%;overflow:hidden;border-radius:8px;margin-top:12px;display:none;user-select:none;background:#111;cursor:ew-resize}
|
||||
.compare img.base{display:block;width:100%;height:auto}
|
||||
.clip{position:absolute;left:0;top:0;width:50%;height:100%;overflow:hidden}
|
||||
.clip img{display:block;height:100%;object-fit:cover;object-position:left top;max-width:none}
|
||||
.handle{position:absolute;top:0;bottom:0;left:50%;transform:translateX(-50%);width:4px;background:#e94560;z-index:3;pointer-events:none}
|
||||
.knob{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:42px;height:42px;border-radius:50%;background:#e94560;display:grid;place-items:center;box-shadow:0 2px 16px rgba(0,0,0,.5)}
|
||||
.labels{position:absolute;left:0;right:0;bottom:14px;display:flex;justify-content:space-between;padding:0 16px;pointer-events:none;z-index:2}
|
||||
.labels span{background:rgba(0,0,0,.76);color:#fff;padding:4px 12px;border-radius:4px;font-size:12px;font-weight:700}
|
||||
.status-box{display:none;background:#0f3460;border-radius:8px;padding:16px;margin-top:12px}
|
||||
.step-label{font-size:13px;color:#e94560;font-weight:700;margin-bottom:6px}
|
||||
.step-msg{font-size:14px;color:#eee;line-height:1.5;white-space:pre-wrap}
|
||||
.progress{height:6px;background:#333;border-radius:3px;margin-top:12px;overflow:hidden}
|
||||
.fill{height:100%;width:0;background:linear-gradient(90deg,#e94560,#f0a500);transition:width .35s}
|
||||
.error-box{display:none;margin-top:12px;border:1px solid #90323d;background:#2b1220;color:#ffb4bd;border-radius:8px;padding:14px}
|
||||
.error-box h3{font-size:14px;margin-bottom:8px;color:#ff6b81}
|
||||
.error-box pre{white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;font-size:12px;line-height:1.5;color:#ffd6dc}
|
||||
.small{font-size:12px;color:#888;margin-top:8px}
|
||||
@media(max-width:900px){.container{grid-template-columns:1fr;padding:14px}.floor-grid{grid-template-columns:1fr}.options,.toggle-row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>FloorVisualizer AI 换地板 Demo</h1>
|
||||
<p>上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<h2>第一步:上传房间照片</h2>
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<input id="fileInput" type="file" accept="image/*" hidden>
|
||||
<img id="previewImg" alt="" style="display:none">
|
||||
<span id="uploadHint">点击或拖拽上传图片<br>建议使用能清楚看到地面的室内照片</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>房间类型</h2>
|
||||
<div class="options" id="room可选效果"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>地板类型</h2>
|
||||
<div class="options" id="type可选效果">
|
||||
<div class="option selected" data-type="wood"><div class="name">木地板</div><div class="meta">Hardwood</div></div>
|
||||
<div class="option" data-type="tile"><div class="name">瓷砖</div><div class="meta">Tile / Stone</div></div>
|
||||
<div class="option" data-type="vinyl"><div class="name">乙烯基地板</div><div class="meta">Luxury Vinyl / SPC</div></div>
|
||||
<div class="option" data-type="special"><div class="name">特殊测试</div><div class="meta">7款 · 含AI描述</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>铺设方式</h2>
|
||||
<div class="options" id="pattern可选效果"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="sizePanel" style="display:none">
|
||||
<h2>尺寸</h2>
|
||||
<div class="options" id="size可选效果"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2 id="floorTitle">选择地板款式</h2>
|
||||
<div class="toolbar"><input id="floorSearch" placeholder="搜索款式、颜色、材质、SKU"></div>
|
||||
<div class="floor-grid" id="floor可选效果"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>材质参考方式</h2>
|
||||
<div class="options" id="materialSourceOptions">
|
||||
<div class="option selected" data-source="image"><div class="name">仅图片</div><div class="meta">参考图</div></div>
|
||||
<div class="option" data-source="json"><div class="name">仅JSON</div><div class="meta">材质描述</div></div>
|
||||
<div class="option" data-source="both"><div class="name">图片+JSON</div><div class="meta">两者都用</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>可选效果</h2>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle" id="groutToggle"><input id="grout" type="checkbox">瓷砖勾缝线<div class="small">仅瓷砖可用</div></label>
|
||||
<label class="toggle" id="baseboardToggle"><input id="baseboard" type="checkbox">白色踢脚线<div class="small">沿墙地交界生成</div></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="generateBtn" disabled><span class="spinner"></span><span class="btn-text">生成预览图</span></button>
|
||||
<div class="status-box" id="statusBox">
|
||||
<div class="step-label" id="stepLabel"></div>
|
||||
<div class="step-msg" id="stepMsg"></div>
|
||||
<div class="live-timer" id="liveTimer" style="display:none;color:#f0a500;font-size:13px;margin-top:8px;font-weight:700"></div>
|
||||
<div class="progress"><div class="fill" id="progressFill"></div></div>
|
||||
</div>
|
||||
<div class="error-box" id="errorBox">
|
||||
<h3>生成失败原因</h3>
|
||||
<pre id="errorDetail"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel result-panel">
|
||||
<h2>生成结果</h2>
|
||||
<div class="placeholder" id="resultPlaceholder">上传图片并选择地板选项后<br>点击生成预览图查看效果</div>
|
||||
<img class="result-img" id="resultImg" alt="生成结果">
|
||||
<div class="compare" id="compare">
|
||||
<img class="base" id="afterImg" alt="生成图">
|
||||
<div class="clip" id="clip"><img id="beforeImg" alt="原图"></div>
|
||||
<div class="handle" id="handle"><div class="knob">↔</div></div>
|
||||
<div class="labels"><span>原图</span><span>生成图</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);
|
||||
const state={options:null,type:'wood',file:null,originalUrl:null,room:null,pattern:null,floor:null,timer:null,lastStatus:null};
|
||||
|
||||
function show错误(title, detail){
|
||||
$('errorBox').style.display='block';
|
||||
$('errorDetail').textContent=(title||'生成失败')+(detail?'\n\n'+detail:'');
|
||||
$('resultPlaceholder').style.display='flex';
|
||||
$('resultPlaceholder').innerHTML='生成失败';
|
||||
}
|
||||
function clear错误(){ $('errorBox').style.display='none'; $('errorDetail').textContent=''; }
|
||||
function setProgress(step,msg,width){
|
||||
$('statusBox').style.display='block';
|
||||
$('stepLabel').textContent=step;
|
||||
$('stepMsg').textContent=msg||'';
|
||||
$('progressFill').style.width=width;
|
||||
}
|
||||
function apiData(json){ return json && json.data ? json.data : json; }
|
||||
function floorList(){
|
||||
if(!state.options)return[];
|
||||
if(state.type==='tile')return state.options.tile_floors||[];
|
||||
if(state.type==='vinyl')return state.options.vinyl_floors||[];
|
||||
if(state.type==='special')return state.options.special_floors||[];
|
||||
return state.options.wood_floors||[];
|
||||
}
|
||||
function patternList(){
|
||||
if(!state.options)return[];
|
||||
return state.type==='tile' ? (state.options.tile_patterns||[]) : (state.options.wood_patterns||[]);
|
||||
}
|
||||
function selectIn(container,item,el,key){
|
||||
[...container.querySelectorAll(key)].forEach(x=>x.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
return item;
|
||||
}
|
||||
function checkReady(){
|
||||
$('generateBtn').disabled=!(state.file&&state.pattern&&state.floor);
|
||||
}
|
||||
// Material source selector
|
||||
state.materialSource='image';
|
||||
$('materialSourceOptions').onclick=e=>{
|
||||
const opt=e.target.closest('.option');
|
||||
if(!opt)return;
|
||||
[...$('materialSourceOptions').querySelectorAll('.option')].forEach(x=>x.classList.remove('selected'));
|
||||
opt.classList.add('selected');
|
||||
state.materialSource=opt.dataset.source;
|
||||
};
|
||||
function render尺寸s(floor){
|
||||
const el=$('size可选效果'); el.innerHTML=''; state.sizeVariant=null;
|
||||
const variants=floor.variants||[];
|
||||
$('sizePanel').style.display='block';
|
||||
// Collect all sizes: main entry first, then variants
|
||||
const mainLabel=floor.size_label||'默认尺寸';
|
||||
const all=[{sku:floor.sku,label:mainLabel},...variants.map(v=>({sku:v.sku,label:v.size_label||v.sku}))];
|
||||
all.forEach((v,i)=>{
|
||||
const div=document.createElement('div');
|
||||
div.className='option';
|
||||
div.innerHTML=`<div class="name">${v.label}</div><div class="meta">${v.sku}</div>`;
|
||||
div.onclick=()=>{state.sizeVariant=selectIn(el,v,div,'.option')};
|
||||
el.appendChild(div);
|
||||
if(i===0){div.click();}
|
||||
});
|
||||
}
|
||||
function cardText(f){
|
||||
return [f.name,f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' ');
|
||||
}
|
||||
function renderRooms(){
|
||||
const el=$('room可选效果'); el.innerHTML='';
|
||||
(state.options.rooms||[]).forEach((r,i)=>{
|
||||
const div=document.createElement('div');
|
||||
div.className='option';
|
||||
div.innerHTML=`<div class="name">${r.name}</div><div class="meta">code ${r.code}</div>`;
|
||||
div.onclick=()=>{state.room=selectIn(el,r,div,'.option')};
|
||||
el.appendChild(div);
|
||||
if(i===0)div.click();
|
||||
});
|
||||
}
|
||||
function render铺设方式s(){
|
||||
const el=$('pattern可选效果'); el.innerHTML=''; state.pattern=null;
|
||||
patternList().forEach((p,i)=>{
|
||||
const div=document.createElement('div');
|
||||
div.className='option';
|
||||
div.innerHTML=`<div class="name">${p.name}</div><div class="meta">code ${p.code}</div>`;
|
||||
div.onclick=()=>{state.pattern=selectIn(el,p,div,'.option');checkReady()};
|
||||
el.appendChild(div);
|
||||
if(i===0)div.click();
|
||||
});
|
||||
}
|
||||
function renderFloors(){
|
||||
const el=$('floor可选效果'); el.innerHTML=''; state.floor=null;
|
||||
const q=$('floorSearch').value.trim().toLowerCase();
|
||||
const list=floorList().filter(f=>!q||cardText(f).toLowerCase().includes(q)).slice(0,200);
|
||||
if(list.length===0){el.innerHTML='<div class="small">没有匹配的地板款式</div>';checkReady();return}
|
||||
list.forEach(f=>{
|
||||
const div=document.createElement('div');
|
||||
div.className='floor-card';
|
||||
const img=f.image_url ? `<img src="${f.image_url}" alt="">` : '<img alt="">';
|
||||
div.innerHTML=`${img}<div class="title">${f.name||f.sku}</div><div class="desc">${[f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' · ')}</div>`;
|
||||
div.onclick=()=>{state.floor=selectIn(el,f,div,'.floor-card');render尺寸s(f);checkReady()};
|
||||
el.appendChild(div);
|
||||
});
|
||||
el.querySelector('.floor-card')?.click();
|
||||
}
|
||||
function setType(type){
|
||||
state.type=type;
|
||||
[...$('type可选效果').querySelectorAll('.option')].forEach(x=>x.classList.toggle('selected',x.dataset.type===type));
|
||||
$('floorTitle').textContent=type==='tile'?'选择Tile / Stone款式':type==='vinyl'?'选择Luxury Vinyl款式':type==='special'?'AI Test地板 (含AI描述)':'选择Hardwood款式';
|
||||
const groutDisabled=type!=='tile'&&type!=='vinyl'&&type!=='special';
|
||||
$('grout').disabled=groutDisabled;
|
||||
$('groutToggle').classList.toggle('disabled',groutDisabled);
|
||||
if(groutDisabled){$('grout').checked=false;$('groutToggle').classList.remove('selected')}
|
||||
render铺设方式s(); renderFloors(); checkReady();
|
||||
}
|
||||
async function load可选效果(){
|
||||
setProgress('加载选项','正在读取数据库中的地板款式...', '8%');
|
||||
const res=await fetch('/floor/options');
|
||||
const text=await res.text();
|
||||
let json; try{json=JSON.parse(text)}catch{throw new 错误('选项接口返回非 JSON\n'+text)}
|
||||
if(!res.ok||json.code!==200)throw new 错误(json.error||text);
|
||||
state.options=apiData(json);
|
||||
renderRooms(); setType('wood');
|
||||
$('statusBox').style.display='none';
|
||||
}
|
||||
function handleFile(file){
|
||||
state.file=file; state.originalUrl=URL.createObjectURL(file);
|
||||
$('previewImg').src=state.originalUrl;
|
||||
$('previewImg').style.display='block';
|
||||
$('uploadHint').style.display='none';
|
||||
$('compare').style.display='none';
|
||||
$('resultImg').style.display='none';
|
||||
$('resultPlaceholder').style.display='flex';
|
||||
$('resultPlaceholder').innerHTML='已选择图片,等待生成';
|
||||
checkReady();
|
||||
}
|
||||
async function readJSON(res){
|
||||
const text=await res.text();
|
||||
try{return {json:JSON.parse(text),raw:text}}catch{return {json:null,raw:text}}
|
||||
}
|
||||
function progressInfo(data){
|
||||
if(data.step==='check')return ['第一步:内容审核','20%'];
|
||||
if(data.step==='mask')return ['第二步:生成地板蒙版','45%'];
|
||||
if(data.step==='inpaint')return ['第三步:铺设地板','75%'];
|
||||
if(data.step==='generate')return ['第二步:AI生成地板','75%'];
|
||||
if(data.step==='done')return ['完成','100%'];
|
||||
if(data.step==='error')return ['错误','100%'];
|
||||
return ['处理中','35%'];
|
||||
}
|
||||
function formatMs(ms){
|
||||
if(!ms)return '';
|
||||
return ms>=60000 ? (ms/60000).toFixed(0)+'m'+(ms%60000/1000).toFixed(0)+'s' : ms>=1000 ? (ms/1000).toFixed(1)+'s' : ms+'ms';
|
||||
}
|
||||
function formatTimings(data){
|
||||
const timings=data.timings_ms||{};
|
||||
const stepNames={check:'内容审核',mask:'蒙版生成',inpaint:'地板铺设',generate:'AI生成',cleanup:'清理',total:'总耗时'};
|
||||
const parts=[];
|
||||
for(const key of ['check','mask','inpaint','generate','total']){
|
||||
if(timings[key])parts.push(`${stepNames[key]||key}: ${formatMs(timings[key])}`);
|
||||
}
|
||||
return parts.length ? '\n⏱️ '+parts.join(' | ') : '';
|
||||
}
|
||||
// Live elapsed timer
|
||||
function startLiveTimer(){
|
||||
if(state.liveInterval)clearInterval(state.liveInterval);
|
||||
state.liveStart=Date.now();
|
||||
state.liveInterval=setInterval(()=>{
|
||||
const elapsed=Date.now()-state.liveStart;
|
||||
const el=$('liveTimer'); if(!el)return;
|
||||
el.textContent='⏱️ 已运行: '+formatMs(elapsed);
|
||||
el.style.display='block';
|
||||
},1000);
|
||||
}
|
||||
async function poll(jobId){
|
||||
const res=await fetch('/floor/status?job_id='+encodeURIComponent(jobId));
|
||||
const {json,raw}=await readJSON(res);
|
||||
const data=apiData(json);
|
||||
state.lastStatus=data;
|
||||
if(!res.ok||!json)throw new 错误(`状态接口错误 HTTP ${res.status}\n${raw}`);
|
||||
const [label,width]=progressInfo(data);
|
||||
setProgress(label,`job_id: ${jobId}\n状态: ${data.status||''}\n步骤: ${data.step||''}\n消息: ${data.message||''}${formatTimings(data)}`,width);
|
||||
if(data.status==='done'||data.status==='error'){
|
||||
if(state.liveInterval)clearInterval(state.liveInterval);
|
||||
}
|
||||
if(data.status==='error'||data.step==='error'){
|
||||
throw new 错误(`${data.error||data.message||'未知错误'}\n\n原始状态响应:\n${JSON.stringify(json,null,2)}`);
|
||||
}
|
||||
if(data.status==='done'&&data.result){
|
||||
clearInterval(state.timer); state.timer=null;
|
||||
$('generateBtn').classList.remove('loading');
|
||||
$('generateBtn').disabled=false;
|
||||
const url=data.result+'?t='+Date.now();
|
||||
$('resultPlaceholder').style.display='none';
|
||||
$('resultImg').src=url; $('resultImg').style.display='block';
|
||||
setupCompare(state.originalUrl,url);
|
||||
}
|
||||
}
|
||||
function setupCompare(before,after){
|
||||
$('beforeImg').src=before; $('afterImg').src=after;
|
||||
$('compare').style.display='block';
|
||||
requestAnimationFrame(()=>$('beforeImg').style.width=$('compare').offsetWidth+'px');
|
||||
}
|
||||
function updateCompare(clientX){
|
||||
const rect=$('compare').getBoundingClientRect();
|
||||
let pct=(clientX-rect.left)/rect.width*100;
|
||||
pct=Math.max(2,Math.min(98,pct));
|
||||
$('clip').style.width=pct+'%';
|
||||
$('handle').style.left=pct+'%';
|
||||
}
|
||||
|
||||
$('uploadZone').onclick=()=>$('fileInput').click();
|
||||
$('fileInput').onchange=()=>{if($('fileInput').files[0])handleFile($('fileInput').files[0])};
|
||||
$('uploadZone').ondragover=e=>{e.preventDefault();$('uploadZone').classList.add('dragover')};
|
||||
$('uploadZone').ondragleave=()=>$('uploadZone').classList.remove('dragover');
|
||||
$('uploadZone').ondrop=e=>{e.preventDefault();$('uploadZone').classList.remove('dragover');if(e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0])};
|
||||
$('type可选效果').onclick=e=>{const opt=e.target.closest('.option');if(opt)setType(opt.dataset.type)};
|
||||
$('floorSearch').oninput=renderFloors;
|
||||
$('grout').onchange=()=>$('groutToggle').classList.toggle('selected',$('grout').checked);
|
||||
$('baseboard').onchange=()=>$('baseboardToggle').classList.toggle('selected',$('baseboard').checked);
|
||||
$('groutToggle').onclick=e=>{if(e.target.tagName!=='INPUT'&&!$('grout').disabled){$('grout').checked=!$('grout').checked;$('grout').onchange()}};
|
||||
$('baseboardToggle').onclick=e=>{if(e.target.tagName!=='INPUT'){$('baseboard').checked=!$('baseboard').checked;$('baseboard').onchange()}};
|
||||
|
||||
$('generateBtn').onclick=async()=>{
|
||||
if(!state.file||!state.floor||!state.pattern)return;
|
||||
clearInterval(state.timer); clear错误();
|
||||
$('generateBtn').classList.add('loading');
|
||||
$('generateBtn').disabled=true;
|
||||
$('compare').style.display='none';
|
||||
$('resultImg').style.display='none';
|
||||
$('resultPlaceholder').style.display='flex';
|
||||
$('resultPlaceholder').innerHTML='正在提交任务...';
|
||||
setProgress('提交任务','正在上传图片...', '10%');
|
||||
startLiveTimer();
|
||||
try{
|
||||
const floorId=(state.sizeVariant&&state.sizeVariant.sku)||state.floor.id||state.floor.sku||'';
|
||||
const patternCode=state.pattern.code||'';
|
||||
const roomCode=state.room?state.room.code:0;
|
||||
const fd=new FormData();
|
||||
fd.append('image',state.file);
|
||||
fd.append('floor_id',floorId);
|
||||
fd.append('pattern_code',patternCode);
|
||||
fd.append('room_code',roomCode);
|
||||
fd.append('grout_lines',$('grout').checked?'true':'false');
|
||||
fd.append('white_baseboard',$('baseboard').checked?'true':'false');
|
||||
fd.append('material_source',state.materialSource||'image');
|
||||
const submitted=`提交参数:\nfloor_id: ${floorId}\npattern_code: ${patternCode}\nroom_code: ${roomCode}\ngrout_lines: ${$('grout').checked}\nwhite_baseboard: ${$('baseboard').checked}`;
|
||||
setProgress('提交任务',submitted, '10%');
|
||||
const res=await fetch('/floor/generate',{method:'POST',body:fd});
|
||||
const {json,raw}=await readJSON(res);
|
||||
if(!res.ok||!json||json.code!==200){
|
||||
throw new 错误(`${json?.error||'提交失败'}\n\n${submitted}\n\nHTTP ${res.status}\n${raw}`);
|
||||
}
|
||||
const jobId=json.data.job_id;
|
||||
await poll(jobId);
|
||||
state.timer=setInterval(()=>poll(jobId).catch(err=>{
|
||||
clearInterval(state.timer); state.timer=null;
|
||||
$('generateBtn').classList.remove('loading');
|
||||
$('generateBtn').disabled=false;
|
||||
show错误(err.message, state.lastStatus ? JSON.stringify(state.lastStatus,null,2) : '');
|
||||
}),2500);
|
||||
}catch(err){
|
||||
if(state.liveInterval){clearInterval(state.liveInterval);$('liveTimer').style.display='none'}
|
||||
$('generateBtn').classList.remove('loading');
|
||||
$('generateBtn').disabled=false;
|
||||
$('statusBox').style.display='none';
|
||||
show错误(err.message,'');
|
||||
}
|
||||
};
|
||||
|
||||
let dragging=false;
|
||||
$('compare').addEventListener('mousedown',e=>{dragging=true;updateCompare(e.clientX);e.preventDefault()});
|
||||
window.addEventListener('mousemove',e=>{if(dragging)updateCompare(e.clientX)});
|
||||
window.addEventListener('mouseup',()=>dragging=false);
|
||||
$('compare').addEventListener('touchstart',e=>{dragging=true;updateCompare(e.touches[0].clientX);e.preventDefault()},{passive:false});
|
||||
window.addEventListener('touchmove',e=>{if(dragging)updateCompare(e.touches[0].clientX)},{passive:false});
|
||||
window.addEventListener('touchend',()=>dragging=false);
|
||||
window.addEventListener('resize',()=>{if($('compare').style.display==='block')$('beforeImg').style.width=$('compare').offsetWidth+'px'});
|
||||
|
||||
load可选效果().catch(err=>show错误('选项加载失败',err.message));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -11,13 +11,3 @@ 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))
|
||||
}
|
||||
|
||||
@ -4,6 +4,3 @@ import _ "embed"
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML string
|
||||
|
||||
//go:embed questionnaire.html
|
||||
var questionnaireHTML string
|
||||
|
||||
@ -1,359 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>地板偏好问卷 - FloorVisualizer</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#f5f3ee;color:#24211d}
|
||||
button,input,select{font:inherit}
|
||||
.shell{min-height:100vh;display:grid;grid-template-columns:390px 1fr}
|
||||
.sidebar{background:#faf9f6;border-right:1px solid #ddd6c8;padding:24px;position:sticky;top:0;height:100vh;overflow:auto}
|
||||
.brand{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}
|
||||
.brand h1{font-size:20px;line-height:1.2;margin:0;color:#1e3832}
|
||||
.brand a{color:#51645f;text-decoration:none;font-size:13px}
|
||||
.progress{height:8px;background:#e4ded1;border-radius:999px;overflow:hidden;margin:10px 0 20px}
|
||||
.progress span{display:block;height:100%;width:0;background:#2f6f64;transition:width .2s}
|
||||
.group{padding:18px 0;border-top:1px solid #e3dccf}
|
||||
.group:first-of-type{border-top:0}
|
||||
.group h2{font-size:15px;margin:0 0 12px;color:#2d2a25}
|
||||
.group .optional{font-size:12px;color:#867d70;font-weight:400;margin-left:6px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.chip{border:1px solid #cfc5b4;background:#fff;padding:8px 10px;border-radius:8px;color:#39342d;cursor:pointer;min-height:38px}
|
||||
.chip:hover{border-color:#2f6f64}
|
||||
.chip.selected{border-color:#2f6f64;background:#dfeee9;color:#183b34}
|
||||
.chip.swatch{display:flex;align-items:center;gap:8px}
|
||||
.dot{width:15px;height:15px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:0 0 auto}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.field{display:flex;flex-direction:column;gap:6px}
|
||||
.field label{font-size:12px;color:#6f675d}
|
||||
.field input,.field select{width:100%;border:1px solid #cfc5b4;background:#fff;border-radius:8px;padding:10px;color:#24211d}
|
||||
.actions{display:flex;gap:10px;position:sticky;bottom:0;background:linear-gradient(180deg,rgba(250,249,246,0),#faf9f6 20%);padding-top:18px}
|
||||
.primary,.secondary{border:0;border-radius:8px;padding:12px 14px;cursor:pointer;font-weight:700}
|
||||
.primary{background:#2f6f64;color:white;flex:1}
|
||||
.primary:hover{background:#24594f}
|
||||
.primary:disabled{background:#9aa5a1;cursor:not-allowed}
|
||||
.secondary{background:#e9e2d6;color:#3f3931}
|
||||
.main{padding:28px;overflow:auto}
|
||||
.summary{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:end;margin-bottom:22px}
|
||||
.summary h2{font-size:24px;margin:0 0 8px;color:#1d332f}
|
||||
.summary p{margin:0;color:#6b6258;line-height:1.5}
|
||||
.stats{display:flex;gap:12px;flex-wrap:wrap;justify-content:flex-end}
|
||||
.stat{background:#fff;border:1px solid #ddd6c8;border-radius:8px;padding:10px 12px;min-width:110px}
|
||||
.stat strong{display:block;font-size:18px;color:#2f6f64}
|
||||
.stat span{font-size:12px;color:#777064}
|
||||
.empty{min-height:420px;border:1px dashed #cfc5b4;border-radius:8px;background:#fffaf2;display:flex;align-items:center;justify-content:center;text-align:center;color:#6b6258;line-height:1.7;padding:24px}
|
||||
.results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
|
||||
.card{background:#fff;border:1px solid #ddd6c8;border-radius:8px;display:grid;grid-template-columns:160px 1fr;min-height:176px;overflow:hidden}
|
||||
.media{background:#e6e0d4;position:relative}
|
||||
.media img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.rank{position:absolute;top:10px;left:10px;background:#1e3832;color:white;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:700}
|
||||
.score{position:absolute;right:10px;bottom:10px;background:#d99c35;color:#20170b;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:800}
|
||||
.content{padding:14px;display:flex;flex-direction:column;gap:8px}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.pill{background:#eee8dc;border-radius:999px;padding:4px 8px;font-size:12px;color:#5d5449}
|
||||
.title{font-size:16px;font-weight:800;line-height:1.3;color:#25211b}
|
||||
.sub{font-size:13px;color:#6f675d;line-height:1.35}
|
||||
.reasons{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto}
|
||||
.reason{border:1px solid #cbded8;background:#eef8f5;color:#24594f;border-radius:999px;padding:4px 8px;font-size:12px}
|
||||
.links{display:flex;gap:8px;margin-top:2px}
|
||||
.links a{color:#2f6f64;font-size:13px;text-decoration:none;font-weight:700}
|
||||
.loading{opacity:.7;pointer-events:none}
|
||||
.notice{display:none;margin-bottom:14px;padding:12px 14px;border:1px solid #d9b978;background:#fff4d8;border-radius:8px;color:#5c4518}
|
||||
@media(max-width:1100px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.summary{grid-template-columns:1fr}.stats{justify-content:flex-start}.results{grid-template-columns:1fr}}
|
||||
@media(max-width:620px){.sidebar,.main{padding:18px}.card{grid-template-columns:1fr}.media{height:210px}.row{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>地板偏好问卷</h1>
|
||||
<a href="/">返回 Demo</a>
|
||||
</div>
|
||||
<div class="progress"><span id="progress"></span></div>
|
||||
|
||||
<section class="group" data-required="rooms">
|
||||
<h2>铺设空间</h2>
|
||||
<div class="chips" data-key="rooms" data-multi="true">
|
||||
<button class="chip" data-value="living_room">客厅</button>
|
||||
<button class="chip" data-value="bedroom">卧室</button>
|
||||
<button class="chip" data-value="kitchen">厨房</button>
|
||||
<button class="chip" data-value="bathroom">卫生间</button>
|
||||
<button class="chip" data-value="basement">地下室</button>
|
||||
<button class="chip" data-value="rental">出租房/高频使用</button>
|
||||
<button class="chip" data-value="commercial">商业空间</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group" data-required="styles">
|
||||
<h2>整体风格</h2>
|
||||
<div class="chips" data-key="styles" data-multi="true" data-max="2">
|
||||
<button class="chip" data-value="modern">现代简约</button>
|
||||
<button class="chip" data-value="warm_natural">温暖自然</button>
|
||||
<button class="chip" data-value="classic">经典传统</button>
|
||||
<button class="chip" data-value="rustic">乡村/复古</button>
|
||||
<button class="chip" data-value="luxury_stone">轻奢石纹</button>
|
||||
<button class="chip" data-value="industrial">工业冷调</button>
|
||||
<button class="chip" data-value="coastal">明亮海岸风</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group" data-required="looks">
|
||||
<h2>外观类型</h2>
|
||||
<div class="chips" data-key="looks" data-multi="true">
|
||||
<button class="chip" data-value="wood_look">木纹</button>
|
||||
<button class="chip" data-value="stone_tile">石纹/瓷砖感</button>
|
||||
<button class="chip" data-value="real_hardwood">真实硬木</button>
|
||||
<button class="chip" data-value="light_clean">浅色干净</button>
|
||||
<button class="chip" data-value="dark_rich">深色稳重</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group" data-required="color_tones">
|
||||
<h2>颜色倾向</h2>
|
||||
<div class="chips" data-key="color_tones" data-multi="true">
|
||||
<button class="chip swatch" data-value="Natural"><span class="dot" style="background:#b98b58"></span>自然木色</button>
|
||||
<button class="chip swatch" data-value="Light Gray"><span class="dot" style="background:#b9bbb8"></span>浅灰</button>
|
||||
<button class="chip swatch" data-value="Off White"><span class="dot" style="background:#eee8da"></span>米白</button>
|
||||
<button class="chip swatch" data-value="Dark Brown"><span class="dot" style="background:#5a3928"></span>深棕</button>
|
||||
<button class="chip swatch" data-value="Taupe"><span class="dot" style="background:#9b8d7a"></span>灰褐</button>
|
||||
<button class="chip swatch" data-value="Cherry"><span class="dot" style="background:#8d3f31"></span>樱桃色</button>
|
||||
<button class="chip swatch" data-value="Black Walnut"><span class="dot" style="background:#31241d"></span>黑胡桃</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group" data-required="budget">
|
||||
<h2>预算范围</h2>
|
||||
<div class="chips" data-key="budgetPreset">
|
||||
<button class="chip" data-min="0" data-max="5">< $5/sqft</button>
|
||||
<button class="chip" data-min="5" data-max="8">$5-$8</button>
|
||||
<button class="chip" data-min="8" data-max="12">$8-$12</button>
|
||||
<button class="chip" data-min="12" data-max="20">$12-$20</button>
|
||||
<button class="chip" data-min="20" data-max="0">$20+</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<div class="field"><label>最低</label><input id="budgetMin" type="number" min="0" step="0.5" placeholder="0"></div>
|
||||
<div class="field"><label>最高</label><input id="budgetMax" type="number" min="0" step="0.5" placeholder="不限"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>性能偏好<span class="optional">可跳过</span></h2>
|
||||
<div class="chips" data-key="performance" data-multi="true">
|
||||
<button class="chip" data-value="waterproof">防水/防潮</button>
|
||||
<button class="chip" data-value="scratch_resistant">耐磨耐刮</button>
|
||||
<button class="chip" data-value="easy_clean">容易清洁</button>
|
||||
<button class="chip" data-value="pet_kid">适合宠物/孩子</button>
|
||||
<button class="chip" data-value="comfort_quiet">脚感舒适/安静</button>
|
||||
<button class="chip" data-value="eco">环保倾向</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>表面质感<span class="optional">可跳过</span></h2>
|
||||
<div class="chips" data-key="finishes" data-multi="true">
|
||||
<button class="chip" data-value="Matte">哑光</button>
|
||||
<button class="chip" data-value="Gloss">亮面</button>
|
||||
<button class="chip" data-value="Distressed/Embossed">复古做旧</button>
|
||||
<button class="chip" data-value="Wire Brushed">拉丝纹理</button>
|
||||
<button class="chip" data-value="Textured">触感纹理</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>规格视觉<span class="optional">可跳过</span></h2>
|
||||
<div class="chips" data-key="size_preference">
|
||||
<button class="chip" data-value="wide_plank">宽板木纹</button>
|
||||
<button class="chip" data-value="standard_plank">标准木板</button>
|
||||
<button class="chip" data-value="large_tile">大规格石材</button>
|
||||
<button class="chip" data-value="small_tile">小砖/马赛克</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>品牌<span class="optional">可跳过</span></h2>
|
||||
<div class="chips" id="brandChips" data-key="brands" data-multi="true"></div>
|
||||
<div class="field" style="margin-top:10px">
|
||||
<label>品牌处理方式</label>
|
||||
<select id="brandMode">
|
||||
<option value="boost">偏好加分</option>
|
||||
<option value="only">只看所选品牌</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>优先级<span class="optional">可跳过</span></h2>
|
||||
<div class="chips" data-key="priority">
|
||||
<button class="chip" data-value="style">风格最重要</button>
|
||||
<button class="chip" data-value="budget">价格最重要</button>
|
||||
<button class="chip" data-value="durability">耐用最重要</button>
|
||||
<button class="chip" data-value="brand">品牌最重要</button>
|
||||
<button class="chip" data-value="color">颜色最重要</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button class="secondary" id="resetBtn">重置</button>
|
||||
<button class="primary" id="submitBtn">推荐 Top 10</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="summary">
|
||||
<div>
|
||||
<h2>问卷推荐结果</h2>
|
||||
<p id="summaryText">回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><strong id="totalStat">-</strong><span>候选 SKU</span></div>
|
||||
<div class="stat"><strong id="uniqueStat">-</strong><span>去重款式</span></div>
|
||||
<div class="stat"><strong id="answeredStat">0%</strong><span>问卷完成度</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="empty" id="empty">请选择你的空间、风格、外观、颜色和预算。</div>
|
||||
<div class="results" id="results"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const state={rooms:[],styles:[],looks:[],color_tones:[],performance:[],finishes:[],brands:[],size_preference:'',priority:'',budget:{min:0,max:0}};
|
||||
const required=['rooms','styles','looks','color_tones','budget'];
|
||||
const $=id=>document.getElementById(id);
|
||||
|
||||
function selectedValues(key){return state[key]||[]}
|
||||
function setProgress(){
|
||||
let done=0;
|
||||
for(const key of required){
|
||||
if(key==='budget'){ if(state.budget.min>0||state.budget.max>0) done++; }
|
||||
else if((state[key]||[]).length) done++;
|
||||
}
|
||||
$('progress').style.width=(done/required.length*100)+'%';
|
||||
$('answeredStat').textContent=Math.round(done/required.length*100)+'%';
|
||||
}
|
||||
function bindChips(root=document){
|
||||
root.querySelectorAll('.chips').forEach(group=>{
|
||||
group.addEventListener('click',e=>{
|
||||
const chip=e.target.closest('.chip');
|
||||
if(!chip)return;
|
||||
const key=group.dataset.key;
|
||||
if(key==='budgetPreset'){
|
||||
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||
chip.classList.add('selected');
|
||||
state.budget={min:Number(chip.dataset.min||0),max:Number(chip.dataset.max||0)};
|
||||
$('budgetMin').value=state.budget.min||'';
|
||||
$('budgetMax').value=state.budget.max||'';
|
||||
setProgress();
|
||||
return;
|
||||
}
|
||||
const multi=group.dataset.multi==='true';
|
||||
const max=Number(group.dataset.max||0);
|
||||
const value=chip.dataset.value;
|
||||
if(multi){
|
||||
const list=selectedValues(key);
|
||||
const i=list.indexOf(value);
|
||||
if(i>=0){list.splice(i,1);chip.classList.remove('selected');}
|
||||
else{
|
||||
if(max&&list.length>=max){
|
||||
const removed=list.shift();
|
||||
group.querySelector(`.chip[data-value="${CSS.escape(removed)}"]`)?.classList.remove('selected');
|
||||
}
|
||||
list.push(value);chip.classList.add('selected');
|
||||
}
|
||||
}else{
|
||||
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||
if(state[key]===value){state[key]='';}
|
||||
else{state[key]=value;chip.classList.add('selected');}
|
||||
}
|
||||
setProgress();
|
||||
});
|
||||
});
|
||||
}
|
||||
function budgetInputs(){
|
||||
state.budget={min:Number($('budgetMin').value||0),max:Number($('budgetMax').value||0)};
|
||||
document.querySelectorAll('[data-key="budgetPreset"] .chip').forEach(c=>c.classList.remove('selected'));
|
||||
setProgress();
|
||||
}
|
||||
function resetAll(){
|
||||
for(const k of ['rooms','styles','looks','color_tones','performance','finishes','brands'])state[k]=[];
|
||||
state.size_preference='';state.priority='';state.budget={min:0,max:0};
|
||||
document.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
|
||||
$('budgetMin').value='';$('budgetMax').value='';$('brandMode').value='boost';
|
||||
$('results').innerHTML='';$('empty').style.display='flex';$('notice').style.display='none';
|
||||
$('totalStat').textContent='-';$('uniqueStat').textContent='-';setProgress();
|
||||
}
|
||||
async function loadOptions(){
|
||||
try{
|
||||
const res=await fetch('/questionnaire/options');
|
||||
const json=await res.json();
|
||||
const data=json.data||json;
|
||||
const brands=(data.brands||[]).slice(0,12);
|
||||
$('brandChips').innerHTML=brands.map(b=>`<button class="chip" data-value="${escapeHtml(b.value)}">${escapeHtml(b.value)} <span class="optional">${b.count}</span></button>`).join('');
|
||||
bindChips($('brandChips').parentElement);
|
||||
}catch(e){
|
||||
$('brandChips').innerHTML='<button class="chip" data-value="Shaw Floors">Shaw Floors</button><button class="chip" data-value="COREtec">COREtec</button><button class="chip" data-value="Bruce">Bruce</button>';
|
||||
bindChips($('brandChips').parentElement);
|
||||
}
|
||||
}
|
||||
async function recommend(){
|
||||
budgetInputs();
|
||||
const payload={...state,brand_mode:$('brandMode').value,limit:10};
|
||||
$('submitBtn').disabled=true;
|
||||
$('submitBtn').textContent='推荐中...';
|
||||
document.body.classList.add('loading');
|
||||
$('notice').style.display='none';
|
||||
try{
|
||||
const res=await fetch('/questionnaire/recommend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
const json=await res.json();
|
||||
if(!res.ok)throw new Error(json.error||'推荐失败');
|
||||
renderResults(json.data||json);
|
||||
}catch(e){
|
||||
$('notice').style.display='block';
|
||||
$('notice').textContent=e.message;
|
||||
}finally{
|
||||
$('submitBtn').disabled=false;
|
||||
$('submitBtn').textContent='推荐 Top 10';
|
||||
document.body.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function renderResults(data){
|
||||
const recs=data.recommendations||[];
|
||||
$('totalStat').textContent=data.total_candidates||0;
|
||||
$('uniqueStat').textContent=data.unique_candidates||0;
|
||||
$('empty').style.display=recs.length?'none':'flex';
|
||||
$('empty').textContent=recs.length?'':'没有找到匹配产品,可以放宽品牌或预算条件。';
|
||||
$('summaryText').textContent=recs.length?`已按你的偏好筛出 ${recs.length} 款推荐,分数来自风格、颜色、预算、材质、性能和品牌等维度。`:'没有找到匹配产品。';
|
||||
$('results').innerHTML=recs.map((r,i)=>cardHtml(r,i)).join('');
|
||||
}
|
||||
function cardHtml(r,i){
|
||||
const p=r.product||{};
|
||||
const img=p.room_image_url||p.main_image_url||'';
|
||||
const price=p.price_per_sqft?`$${Number(p.price_per_sqft).toFixed(2)}/sqft`:'价格待确认';
|
||||
const specs=r.spec_count>1?`${r.spec_count} 个规格`:(p.size_label||'规格待确认');
|
||||
const reasons=(r.reasons||[]).map(x=>`<span class="reason">${escapeHtml(x)}</span>`).join('');
|
||||
return `<article class="card">
|
||||
<div class="media">${img?`<img src="${escapeHtml(img)}" alt="">`:''}<span class="rank">#${i+1}</span><span class="score">${Math.round(r.score)}分</span></div>
|
||||
<div class="content">
|
||||
<div class="meta"><span class="pill">${escapeHtml(p.brand||'')}</span><span class="pill">${escapeHtml(p.category||'')}</span><span class="pill">${escapeHtml(price)}</span></div>
|
||||
<div class="title">${escapeHtml(p.style_name||p.series_name||p.sku)}</div>
|
||||
<div class="sub">${escapeHtml([p.series_name,p.color_tone,p.material,p.finish,specs].filter(Boolean).join(' · '))}</div>
|
||||
<div class="reasons">${reasons}</div>
|
||||
<div class="links">${p.sku?`<a href="/products/${encodeURIComponent(p.sku)}" target="_blank">查看详情</a>`:''}${p.source_url?`<a href="${escapeHtml(p.source_url)}" target="_blank">品牌页面</a>`:''}</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
function escapeHtml(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
||||
|
||||
bindChips();
|
||||
$('budgetMin').addEventListener('input',budgetInputs);
|
||||
$('budgetMax').addEventListener('input',budgetInputs);
|
||||
$('resetBtn').addEventListener('click',resetAll);
|
||||
$('submitBtn').addEventListener('click',recommend);
|
||||
loadOptions();
|
||||
setProgress();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -32,8 +32,6 @@ 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)
|
||||
@ -58,9 +56,6 @@ 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)
|
||||
|
||||
@ -3,7 +3,6 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -16,23 +15,22 @@ 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"`
|
||||
FloorDescription string `json:"floor_description,omitempty"`
|
||||
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"`
|
||||
Variants []FloorOption `json:"variants,omitempty"`
|
||||
}
|
||||
|
||||
type PatternOption struct {
|
||||
@ -50,8 +48,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: "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"},
|
||||
{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{
|
||||
@ -59,7 +57,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 degrees to the image frame, grout lines run diagonally at 60 and 150 degrees to the bottom edge, no lines parallel to image 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{
|
||||
@ -103,7 +101,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 5000`)
|
||||
FROM products ORDER BY brand, style_name, id LIMIT 3000`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -113,7 +111,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}
|
||||
@ -191,7 +189,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(
|
||||
`尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`,
|
||||
`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,
|
||||
)
|
||||
}
|
||||
@ -199,7 +197,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 {
|
||||
@ -214,42 +212,58 @@ func FindRoomByCodePublic(code string) *RoomOption {
|
||||
}
|
||||
func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) }
|
||||
func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string {
|
||||
return buildCompactFloorPrompt(f, p, room)
|
||||
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, groutLines, whiteBaseboard bool) string {
|
||||
return buildCompactCombinedFloorPrompt(f, p, room, groutLines, whiteBaseboard)
|
||||
// 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 "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。"
|
||||
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 "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。"
|
||||
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 "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。"
|
||||
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 "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。"
|
||||
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 "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 1/8 英寸。"
|
||||
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 "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。"
|
||||
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 "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。"
|
||||
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 "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。"
|
||||
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 "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。"
|
||||
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) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||||
// ── Floor Generate Handler (Redis queue) ──────────────────
|
||||
|
||||
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
|
||||
generateQueue = enqueue
|
||||
@ -320,13 +334,10 @@ 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"),
|
||||
"grout_lines": boolFormValue(r, "grout_lines"),
|
||||
"white_baseboard": boolFormValue(r, "white_baseboard"),
|
||||
"material_source": r.FormValue("material_source"),
|
||||
"input_path": inputPath,
|
||||
"floor_id": r.FormValue("floor_id"),
|
||||
"pattern_code": r.FormValue("pattern_code"),
|
||||
"room_code": r.FormValue("room_code"),
|
||||
}
|
||||
if err := generateQueue(r.Context(), jobID, payload); err != nil {
|
||||
writeErr(w, 500, "Failed to enqueue job")
|
||||
@ -337,9 +348,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")
|
||||
@ -359,7 +370,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) {
|
||||
@ -376,201 +387,185 @@ 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,
|
||||
"special_floors": specialFloors,
|
||||
"rooms": roomOptions,
|
||||
"wood_floors": allWood,
|
||||
"wood_patterns": woodPatterns,
|
||||
"tile_floors": allTile,
|
||||
"tile_patterns": tilePatterns,
|
||||
"vinyl_floors": vinylFloors,
|
||||
"rooms": roomOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// materialIndex caches SKU 閳?material description from MaterialAssets JSON files.
|
||||
var materialIndex map[string]string
|
||||
|
||||
// LoadMaterialIndex loads the material description index from a JSON file.
|
||||
func LoadMaterialIndex(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &materialIndex)
|
||||
}
|
||||
|
||||
// LookupMaterialDesc returns the material description for a given SKU from the loaded index.
|
||||
func LookupMaterialDesc(sku string) string {
|
||||
if materialIndex == nil {
|
||||
return ""
|
||||
}
|
||||
return materialIndex[sku]
|
||||
}
|
||||
|
||||
// loadSpecialFloors loads the 7 special test products with pre-generated floor descriptions.
|
||||
func loadSpecialFloors(db *sql.DB) []FloorOption {
|
||||
data, err := os.ReadFile("outputs/special_7_products.json")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var raw []struct {
|
||||
Brand string `json:"brand"`
|
||||
Style string `json:"style"`
|
||||
Material string `json:"material"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
if json.Unmarshal(data, &raw) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []FloorOption
|
||||
for _, r := range raw {
|
||||
// Find any real SKU for this style to get DB variants and sizes
|
||||
var sku string
|
||||
db.QueryRow(`SELECT sku FROM products WHERE style_name=$1 AND brand=$2 LIMIT 1`,
|
||||
r.Style, r.Brand).Scan(&sku)
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
f := findFloorBySKU(db, sku)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
f.FloorDescription = r.Description
|
||||
// Keep f.ImageURL from DB (canonical smallest) 閳?do NOT override with local path
|
||||
out = append(out, *f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 閳光偓閳光偓 Prompts 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
|
||||
// ── Prompts ────────────────────────────────────────────────
|
||||
|
||||
func buildMaskPrompt(room *RoomOption) string {
|
||||
base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" +
|
||||
"严格定义:\n" +
|
||||
"- 白色(#FFFFFF):只表示平整、水平、可行走的地面。包含所有可见地面,一直到墙面、踢脚线、柜体或楼梯边缘。\n" +
|
||||
"- 黑色(#000000):所有非水平地面的内容,包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" +
|
||||
"边缘规则:\n" +
|
||||
"- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" +
|
||||
"- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" +
|
||||
"- 楼梯和地毯都标为黑色。\n\n" +
|
||||
"只输出遮罩图片,不要文字说明。"
|
||||
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("\n房间类型:%s。", room.Name)
|
||||
base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func buildMaterialRules(floor FloorOption) string {
|
||||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||||
return "整片地面必须是同一个 SKU,基础颜色在全地面保持一致。允许原图阴影让局部变暗,但不能改变地砖色相或材质。瓷砖表面平整,缝隙连续、透视一致;不要生成局部偏绿、偏蓝、偏黄、偏青的色块。反光只能来自原图已有光照,不能新增窗户反光、亮斑或湿地板效果。"
|
||||
}
|
||||
return "地面必须保持同一个 SKU 的颜色、纹理、木纹或板纹和光泽。保留原图已有光照和阴影;不要新增反光、亮斑或阴影。板缝透视一致,纹理自然但不能明显重复。"
|
||||
}
|
||||
|
||||
func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
|
||||
roomName := "自动识别"
|
||||
if room != nil && room.Code != 0 {
|
||||
roomName = room.Name
|
||||
}
|
||||
|
||||
var priority []string
|
||||
if whiteBaseboard {
|
||||
priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。")
|
||||
}
|
||||
if groutLines {
|
||||
priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。")
|
||||
}
|
||||
|
||||
priorityText := ""
|
||||
if len(priority) > 0 {
|
||||
priorityText = strings.Join(priority, "\n\n") + "\n\n"
|
||||
}
|
||||
|
||||
editScope := "只替换地面"
|
||||
if whiteBaseboard {
|
||||
editScope = "只替换地面和白色踢脚线"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。",
|
||||
priorityText,
|
||||
roomName,
|
||||
editScope,
|
||||
buildCompactFloorPrompt(f, p, room),
|
||||
)
|
||||
}
|
||||
|
||||
func buildCompactFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
|
||||
matType := "木地板"
|
||||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||||
matType = "瓷砖"
|
||||
}
|
||||
|
||||
materialDesc := fmt.Sprintf("%s,%s色调,%s表面", floor.Material, floor.ColorTone, floor.Finish)
|
||||
if floor.SizeLabel != "" {
|
||||
materialDesc += ",尺寸 " + floor.SizeLabel
|
||||
}
|
||||
|
||||
var details []string
|
||||
if floor.Description != "" {
|
||||
details = append(details, "产品描述:"+floor.Description)
|
||||
}
|
||||
if floor.FloorDescription != "" {
|
||||
details = append(details, "目录材质描述:"+floor.FloorDescription)
|
||||
}
|
||||
if 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)
|
||||
var roomCtx string
|
||||
if room != nil && room.Code != 0 {
|
||||
roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name)
|
||||
}
|
||||
matType := "wood plank"
|
||||
if isTileProduct(floor) || isLaminateProduct(floor) {
|
||||
matType = "tile"
|
||||
}
|
||||
|
||||
materialDesc := fmt.Sprintf("%s material, %s tone, %s finish", floor.Material, floor.ColorTone, floor.Finish)
|
||||
if floor.SizeLabel != "" {
|
||||
materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel)
|
||||
}
|
||||
|
||||
// Include product description if available for richer visual detail
|
||||
var descriptionText string
|
||||
if floor.Description != "" {
|
||||
descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description)
|
||||
}
|
||||
|
||||
productLock := ""
|
||||
if floor.ImageURL != "" {
|
||||
productLock = fmt.Sprintf(
|
||||
`The reference image is the EXACT product material — not style inspiration, not a mood board.
|
||||
LOCK THESE PROPERTIES from the reference image: base color, color temperature, grain/vein pattern,
|
||||
knot density, surface texture, gloss level, material character.
|
||||
FORBIDDEN: color shift, different wood species or stone type, new grain pattern, different stain,
|
||||
gloss change, "luxury upgrade" or aesthetic reinterpretation.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Size instruction: only change board proportion, never the material itself
|
||||
sizeInstruction := ""
|
||||
if len(floor.Variants) > 0 {
|
||||
var variantList []string
|
||||
for _, v := range floor.Variants {
|
||||
marker := ""
|
||||
if v.SKU == floor.SKU {
|
||||
marker = " ← SELECTED"
|
||||
}
|
||||
if v.SizeLabel != "" {
|
||||
variantList = append(variantList, v.SizeLabel+marker)
|
||||
} else if v.WidthIn > 0 && v.LengthIn > 0 {
|
||||
variantList = append(variantList, fmt.Sprintf(`%.1f"×%.1f"`, v.WidthIn, v.LengthIn)+marker)
|
||||
}
|
||||
}
|
||||
if len(variantList) > 0 {
|
||||
sizeInstruction = fmt.Sprintf(
|
||||
"\nSIZE: This product comes in multiple sizes: %s.\nONLY change board width and seam spacing. The material texture, color, and grain are from the reference image — identical in every size. Wider boards use the SAME texture source, just with fewer seams. Do NOT reinterpret the wood to look \"more premium\" for wider planks.",
|
||||
strings.Join(variantList, ", "),
|
||||
)
|
||||
}
|
||||
} else if floor.SizeLabel != "" {
|
||||
sizeInstruction = fmt.Sprintf(
|
||||
"\nSIZE: Each piece is %s. Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.",
|
||||
floor.SizeLabel,
|
||||
)
|
||||
} else if floor.WidthIn > 0 && floor.LengthIn > 0 {
|
||||
sizeInstruction = fmt.Sprintf(
|
||||
`\nSIZE: Each piece is %.1f" × %.1f". Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.`,
|
||||
floor.WidthIn, floor.LengthIn,
|
||||
)
|
||||
}
|
||||
|
||||
patternInstruction := buildPatternConstraint(pattern.Code)
|
||||
|
||||
return fmt.Sprintf(
|
||||
`You are a flooring product visualization renderer for a commercial catalog. Your sole function is to replace the floor surface in interior photos with a specific flooring SKU. This is product-accurate catalog photography — NOT interior design or artistic interpretation.
|
||||
|
||||
STRICT OUTPUT RULES:
|
||||
1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper.
|
||||
2. The output must have the exact same pixel dimensions as the input image.
|
||||
Do not crop, pad, upscale, downscale, rotate, or alter the image geometry in any way.
|
||||
3. Do not apply global filtering, sharpening, blurring, tone-mapping, contrast adjustment,
|
||||
or style transfer. The only permitted change is the floor surface.
|
||||
|
||||
FLOOR SURFACE DEFINITION:
|
||||
- "Floor" — the flat horizontal walking surface. The visible ground plane inside the room.
|
||||
DO NOT bleed onto: walls, baseboards/skirting boards, stairs, stair risers, furniture,
|
||||
chair legs, table legs, cabinet bases, doors, door frames, window glass or frames,
|
||||
trim, pipes, conduit, rugs, carpets, ceiling, appliances, decorations.
|
||||
- Stairs are NOT floor — each tread and riser must remain completely unchanged.
|
||||
- If a surface is not the flat horizontal floor, PRESERVE IT EXACTLY.
|
||||
|
||||
FLOOR REPLACEMENT RULES:
|
||||
4. Apply the new floor ONLY to the floor surface. Every pixel outside must be bit-identical to input.
|
||||
5. Preserve the original room lighting, shadows, and highlights ON the new floor.
|
||||
6. ADD realistic contact shadows: chair wheels, table legs, cabinet bases MUST cast tight dark shadows
|
||||
directly at the contact point — sharpest there, softening outward. This shows physical weight.
|
||||
7. Maintain clean but physically installed-looking floor edges — not a Photoshop cutout.
|
||||
8. The new floor must look physically installed and photographed — not rendered, not composited.
|
||||
|
||||
FIDELITY RULES:
|
||||
9. The output must be a photorealistic edit of the input photograph.
|
||||
Do not generate new content, synthesise areas, invent objects, or alter composition.
|
||||
10. Preserve camera sensor noise, natural vignette, and original white balance on all non-floor surfaces.
|
||||
|
||||
AMBIGUITY RULE:
|
||||
11. If a pixel could be floor OR furniture/wall/baseboard — preserve the original unchanged.
|
||||
Precision over completeness.
|
||||
|
||||
FLOOR SPECIFICATION:
|
||||
- Product: %s (%s %s)
|
||||
- Pattern: %s — %s
|
||||
The pattern changes layout only. The material comes from the reference image — identical regardless of pattern.
|
||||
%s
|
||||
- Material details: %s%s%s%s
|
||||
|
||||
%s
|
||||
|
||||
TEXTURE FIDELITY:
|
||||
- Every plank/tile must have UNIQUE grain, vein, or tonal variation — no visible texture repetition.
|
||||
- Natural imperfections: minor scratches, micro-wear near chair wheels, colour variance between pieces.
|
||||
- Seams and joints: subtle dust in gaps, micro-shadow at edges, slight edge wear.
|
||||
- Polished surfaces: reflections UNEVEN and angle-dependent — not uniform glossy sheen.
|
||||
- Do NOT invent sunbeams, light rays, or window reflections not in the original.
|
||||
|
||||
NEGATIVE CONSTRAINTS — the floor must NOT contain:
|
||||
CGI look, 3D render, plastic texture, repeating patterns, uniform gloss, invented lights,
|
||||
HDR tone-mapping, "luxury showroom" aesthetic, sterile clean seams, floating furniture,
|
||||
color shift, different wood species, new grain, different stain, aesthetic reinterpretation.
|
||||
|
||||
REQUIREMENTS SUMMARY:
|
||||
✓ Only the flat horizontal floor changes — all other pixels preserved bit-identical.
|
||||
✓ Contact shadows under all furniture — sharp at contact, softening outward.
|
||||
✓ Original lighting and shadows match on new floor.
|
||||
✓ Output geometry identical to input — same dimensions, no cropping.
|
||||
✓ Scene composition unchanged — no new objects, no synthesised content.
|
||||
✓ Material color, grain, gloss, species match the reference image exactly.
|
||||
|
||||
Return ONLY the completed image. No text.`,
|
||||
floor.Name, matType, materialDesc,
|
||||
pattern.Name, pattern.Pattern, patternInstruction,
|
||||
materialDesc,
|
||||
roomCtx, descriptionText, sizeInstruction, productLock,
|
||||
)
|
||||
}
|
||||
|
||||
func buildProductNameGuard(floor FloorOption) string {
|
||||
return compactProductNameGuard(floor)
|
||||
}
|
||||
// ── Content Check ─────────────────────────────────────────
|
||||
|
||||
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
|
||||
imgData, err := os.ReadFile(imagePath)
|
||||
@ -578,12 +573,13 @@ 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: prompt},
|
||||
{Type: "text", Text: `Score this image 1-10 for indoor floor replacement suitability.
|
||||
8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
|
||||
Return ONLY a number (1-10).`},
|
||||
}}},
|
||||
})
|
||||
if err != nil || len(resp.Choices) == 0 {
|
||||
@ -592,6 +588,7 @@ func validateContent(client *openrouter.Client, ctx context.Context, imagePath s
|
||||
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 {
|
||||
@ -629,13 +626,3 @@ 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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,713 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -7,7 +7,6 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
@ -23,9 +22,9 @@ import (
|
||||
|
||||
const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview"
|
||||
|
||||
const maskImageInstruction = "下一张图片是遮罩图。非黑色像素表示允许编辑的区域;黑色像素表示必须保持不变的保护区域。"
|
||||
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 = "下一张图片是地板材质参考图。生成新地面时必须严格匹配它的颜色、纹理、图案、光泽和整体材质外观;生成的地面应看起来就是这个材质。"
|
||||
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
|
||||
@ -519,125 +518,3 @@ 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
|
||||
}
|
||||
|
||||
@ -69,12 +69,6 @@ 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")
|
||||
@ -109,7 +103,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: generate floor (single combined prompt — AI identifies floor + replaces it)
|
||||
// Step 1+2 combined: identify floor + replace in one call (no separate mask)
|
||||
roomLabel := ""
|
||||
if room != nil {
|
||||
roomLabel = " · " + room.Name
|
||||
@ -119,25 +113,12 @@ 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))
|
||||
|
||||
// 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)
|
||||
}
|
||||
refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID)
|
||||
|
||||
imageSize := floorImageSize()
|
||||
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)
|
||||
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: workPath,
|
||||
ImageSize: imageSize, ReferenceImagePath: refImagePath,
|
||||
@ -147,9 +128,6 @@ 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
|
||||
@ -216,11 +194,9 @@ 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: `请按 1-10 分评估这张图片是否适合做室内地面替换。
|
||||
8-10 分:清晰室内房间,地面可见。
|
||||
5-7 分:室内图片,但角度或地面可见度一般。
|
||||
1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。
|
||||
只返回一个数字(1-10)。`},
|
||||
{Type: "text", Text: `Score this image 1-10 for indoor floor replacement suitability.
|
||||
8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
|
||||
Return ONLY a number (1-10).`},
|
||||
}}},
|
||||
})
|
||||
if err != nil || len(resp.Choices) == 0 {
|
||||
@ -329,15 +305,6 @@ 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 == "" {
|
||||
|
||||
@ -71,15 +71,6 @@ 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
|
||||
|
||||
54
main.go
54
main.go
@ -7,7 +7,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"time"
|
||||
@ -42,19 +41,6 @@ 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
|
||||
@ -197,7 +183,6 @@ 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 {
|
||||
@ -211,42 +196,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user