diff --git a/CHANGELOG b/CHANGELOG index 5146487..e573134 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 18:30] FEAT: v2.28 AI 炫图接入 gpt-image-2.5 云端生图+提示词工程。①services/ai_prompt.py 结构化提示词构建器: 从 drawing_data 提取窗型特征(组合单元/宽高mm/格数/扇类型平开推拉内开内倒对开门/开向/纱扇/圆弧/格条)→专业英文提示词(主体先行/材质风格映射/摄影光照/负面约束"保持分格勿增删扇"/质量词)+按宽高比自动选尺寸(1024/1536x1024/1024x1536); ②api/ai_render.py 代理(Key 仅后端 .env HMC_AI_API_KEY, 已 gitignore): POST /ai-render/prompt 提示词预览 + /ai-render/generate(quick→generations, photo→edits 参考图合成, httpx 180s 超时, 错误分级); ③config .env 锚定项目根(修复 cwd 相对路径失效); ④前端 AIRenderView 双模式主按钮接云端(photo 模式参考图压缩≤1536px JPEG), 结果大图+提示词折叠面板+下载, 未配 Key 降级本地 4 方案; ⑤实测: quick 生图 28s 返回三格平开窗产品级渲染(把手/铰链/投影正确), photo 合成 55s 返回窗型融入照片; 端到端 puppeteer 验证通过(shots/verify-aicloud.png); vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/ai_prompt.py, src/huamenchuang/api/ai_render.py, src/huamenchuang/api/router.py, src/huamenchuang/core/config.py, frontend/src/views/AIRenderView.vue, frontend/src/api/index.ts, frontend/scripts/verify-aicloud.mjs, doc/API.md) + - [2026-09-25 16:50] FEAT: v2.27 P0-8 照片式 AI 炫图(7 场景)。①photoCompose.ts 纯前端合成引擎: renderWindowOnly 透明背景纯窗型渲染(分割树重放/玻璃渐变/中梃投影/扇开向虚线三角/装饰格条, 复用 types/drawing 纯函数)+composePhoto 光照匹配(洞口周边亮度→窗户亮度 0.55~1.25)+7 场景后处理(渲图/安装合成投影/门窗擦除内容填充/室内暖色/别墅石基座/去围栏背景虚化/变毛坯去饱和); ②AIRenderView 升级双 Tab(照片合成/风格速览): 上传照片→拖框窗洞→选样板→生成 4 方案画廊+下载; 修复三处: variants 赋值后未等 Vue 挂载渲染空画布(nextFrames)、rect 舞台坐标与照片原始坐标双重缩放(统一原始坐标+CSS↔原始宽换算)、v-if canvas 未挂载 drawStage 静默失败致 photoScale 滞留 1(nextTick+无状态换算); ③端到端验证: 程序化测试照片→拖框→4 方案生成, 窗户准确贴入洞口+投影正确(shots/verify-photorender-*.png); vue-tsc 0 错+构建通过 (Files: frontend/src/components/photoCompose.ts, frontend/src/views/AIRenderView.vue, frontend/scripts/verify-photorender.mjs, doc/API.md) - [2026-09-25 15:40] FEAT: v2.26 P0-7 玻璃装饰格条设计器。①types/drawing.ts 加 DecBar(decs 挂 FrameShape, ri=重放区域索引)+DEC_PRESETS 8 风格(对齐源站 decorationBar: 现代轻奢/意式简约/奶油风/中式/欧式古典/法式/地中海/田园密格); ②WindoorCanvas: 选格后 HUD"格条"按钮+设计对话框(预设缩略图/行列/条宽/6 色)+2D 渲染(区域 clip 内横竖条+描边, 应用/清除入撤销栈); ③WindoorThreed: 静态格+扇面板玻璃前表面格条体(barX/barY 实心腔); ④后端 takeoff 计 dec_bar 算料(横=区域宽×rows/竖=区域高×cols, 默认 12 元/米); ⑤端到端验证: 无头浏览器订单详情→编辑→选格→欧式古典 3×3 应用→截图渲染正确(shots/verify-decbar-2d.png); 254 套全量校验 0 失败(decs 可选字段无回归); vue-tsc 0 错+构建通过 (Files: frontend/src/types/drawing.ts, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue, src/huamenchuang/services/quote_service.py, frontend/scripts/verify-decbar.mjs, doc/API.md) diff --git a/doc/API.md b/doc/API.md index b4a95d8..1688753 100644 --- a/doc/API.md +++ b/doc/API.md @@ -465,3 +465,17 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 - 流程:上传场景照片 → 拖拽框选窗洞(原始照片坐标)→ 选样板窗型 → `renderWindowOnly`(分割树重放渲染透明背景窗型:玻璃渐变/中梃/扇框开向/装饰格条)→ 光照匹配(采样洞口周边亮度调整窗户亮度)→ 边缘贴合 → 场景后处理(安装投影/擦除填充/暖色滤镜/石材基座/背景虚化/去饱和毛坯) - 一键生成 4 方案画廊 + PNG 下载;原"风格速览"模式保留为第二 Tab - 无新增后端接口(复用 `/api/windoor/templates` 与 `/api/rendering-info`) + +### 12.23 云端 AI 生图 `/api/ai-render`(v2.28 新增,gpt-image-2.5) + +接入 OpenAI Images 兼容中转站(`geminiapi.ai`,模型 `gpt-image-2.5-1k`)。**Key 仅存后端**(`.env` 的 `HMC_AI_API_KEY`,已 gitignore;Docker 部署用环境变量注入)。 + +**提示词工程**(`services/ai_prompt.py`,核心资产):从 `drawing_data` 结构化提取窗型特征(组合单元数/每单元宽高 mm/玻璃格数/扇类型(平开、推拉、内开内倒、对开门)/开向内外/纱扇/圆弧复古/装饰格条)→ 生成专业英文提示词:主体先行(窗型几何量化描述)→ 材质风格(4 风格映射铝型材 finish)→ 摄影光照(3 时间)→ 负面约束("保持窗型分格不变,勿增删扇")→ 质量词。生成尺寸按窗型宽高比自动选择(1024x1024 / 1536x1024 / 1024x1536)。 + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 提示词预览 | POST | `/api/ai-render/prompt` | `{template_id\|drawing_data, style, time, scene}` | 返回 `{features(窗型特征), prompt(优化后完整提示词), size}` | +| **云端生成** | POST | `/api/ai-render/generate` | `{template_id\|drawing_data, mode: quick\|photo, style, time, scene, photo_b64?}` | quick→`images/generations`;photo→`images/edits`(用户照片为参考图,AI 将窗型合成进照片窗洞);返回 `{image_b64, prompt, size, features}`;耗时 30~90s(前端 timeout 200s) | + +- 未配置 Key 时接口返回 code=0,前端降级本地程序化渲染("本地 4 方案"按钮) +- 前端:AI 炫图页两个模式(照片合成/风格速览)主按钮均走云端生成,结果大图展示 + 提示词折叠面板可查看 + PNG 下载;照片模式参考图自动压缩 ≤1536px JPEG diff --git a/frontend/scripts/verify-aicloud.mjs b/frontend/scripts/verify-aicloud.mjs new file mode 100644 index 0000000..466f5e3 --- /dev/null +++ b/frontend/scripts/verify-aicloud.mjs @@ -0,0 +1,72 @@ +// 云端 AI 炫图(gpt-image)端到端验证: 注入测试照片 → 拖框 → AI 照片合成 → 截图 +// 用法: node verify-aicloud.mjs +import puppeteer from 'puppeteer-core' +const browser = await puppeteer.launch({ + executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + headless: 'new', + args: ['--window-size=1440,900', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, +}) +const page = await browser.newPage() +page.on('pageerror', e => console.log(`[JS异常] ${String(e).slice(0, 200)}`)) +const token = process.argv[2] +await page.goto(`http://localhost:8000/ai_render?token=${encodeURIComponent(token)}`, { waitUntil: 'networkidle2' }) +await new Promise(r => setTimeout(r, 2000)) + +// 1) 选第一个样板 +await page.click('.controls .el-select') +await new Promise(r => setTimeout(r, 800)) +await (await page.$('.el-select-dropdown__item')).click() +await new Promise(r => setTimeout(r, 1000)) + +// 2) 注入程序化"旧客厅"照片(带深色旧窗洞) +await page.evaluate(() => { + const cv = document.createElement('canvas') + cv.width = 1200; cv.height = 800 + const ctx = cv.getContext('2d') + const g = ctx.createLinearGradient(0, 0, 0, 800) + g.addColorStop(0, '#e8e0d2'); g.addColorStop(0.7, '#d8cdbb'); g.addColorStop(1, '#9c8b74') + ctx.fillStyle = g; ctx.fillRect(0, 0, 1200, 800) + ctx.fillStyle = 'rgba(120,100,80,0.25)' + ctx.fillRect(380, 260, 440, 340) + ctx.strokeStyle = 'rgba(90,70,50,0.5)'; ctx.lineWidth = 8 + ctx.strokeRect(380, 260, 440, 340) + return new Promise(resolve => { + cv.toBlob(blob => { + const dt = new DataTransfer() + dt.items.add(new File([blob], 'room.png', { type: 'image/png' })) + const input = document.querySelector('.el-upload input[type=file]') + input.files = dt.files + input.dispatchEvent(new Event('change')) + resolve() + }, 'image/png') + }) +}) +await new Promise(r => setTimeout(r, 1200)) + +// 3) 框选窗洞 +const stage = await page.$('.photo-cv') +const b = await stage.boundingBox() +await page.mouse.move(b.x + b.width * 0.3, b.y + b.height * 0.3) +await page.mouse.down() +await page.mouse.move(b.x + b.width * 0.7, b.y + b.height * 0.75, { steps: 8 }) +await page.mouse.up() +await new Promise(r => setTimeout(r, 300)) + +// 4) 切到"室外渲染"场景更像安装效果: 保持默认渲图, 点击 AI 照片合成 +const genBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(x => x.textContent.includes('AI 照片合成'))) +await genBtn.asElement().click() +console.log('已提交云端生成, 等待 AI 绘制(最长 150s)...') +// 轮询等 ai-img 出现 +let ok = false +for (let i = 0; i < 75; i++) { + await new Promise(r => setTimeout(r, 2000)) + ok = await page.evaluate(() => !!document.querySelector('.ai-img')) + if (ok) break + if (i % 5 === 4) console.log(` 等待中 ${(i + 1) * 2}s`) +} +if (!ok) { console.log('✗ 超时未返回'); await browser.close(); process.exit(1) } +const img = await page.$('.ai-result') +await img.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-aicloud.png' }) +console.log('✓ 云端 AI 照片合成验证完成') +await browser.close() diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c47245e..81e9d7a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -36,9 +36,9 @@ http.interceptors.response.use( }, ) -/** 提取 envelope 的 data */ -export async function api(method: string, url: string, body?: object): Promise { - const res = await http.request({ method, url, data: body }) +/** 提取 envelope 的 data(config 可传 timeout 等覆盖) */ +export async function api(method: string, url: string, body?: object, config?: object): Promise { + const res = await http.request({ method, url, data: body, ...config }) return res.data.data as T } diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index 9ad3057..01eb9de 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -24,8 +24,9 @@ {{ photo ? '更换照片' : '上传场景照片' }} - - {{ generating ? '合成中…' : '✦ 生成炫图' }} + + {{ generating ? 'AI 合成中…' : '✦ AI 照片合成' }}
@@ -67,8 +68,9 @@ - - {{ generating ? '生成中…' : '✦ 生成 AI 炫图' }} + + {{ generating ? 'AI 绘制中…' : '✦ AI 云端生成' }} + 本地 4 方案
+ + +
+ +
+ 下载 PNG +
+ + +
{{ aiPrompt }}
+
窗型特征:{{ aiFeatures }}
+
+
@@ -92,7 +108,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { api } from '../api' import AIRenderCanvas from '../components/AIRenderCanvas.vue' -import { PHOTO_SCENES, composePhoto, type PhotoScene } from '../components/photoCompose' +import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose' const tab = ref<'photo' | 'quick'>('photo') const templates = ref([]) @@ -122,8 +138,6 @@ const stageRef = ref() const rect = ref<{ x: number; y: number; w: number; h: number } | null>(null) let dragStart: { x: number; y: number } | null = null -const sceneLabel = (s: string) => PHOTO_SCENES.find(x => x.key === s)?.label ?? s - async function loadTemplate(tid: number | undefined) { drawing.value = '' variants.value = [] @@ -209,52 +223,65 @@ function drawStage() { } } -// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ---- -async function generate() { - if (!photo.value || !drawing.value || !rect.value) return +// ---- 云端 AI 生成(gpt-image-2.5, 提示词由后端从窗型数据结构化构建) ---- +const aiImage = ref('') +const aiPrompt = ref('') +const aiFeatures = ref('') + +async function generateCloud(mode: 'quick' | 'photo') { + if (!drawing.value) return + if (mode === 'photo' && (!photo.value || !rect.value)) return generating.value = true + aiImage.value = '' variants.value = [] - const plan: { key: PhotoScene; label: string }[] = [ - { key: scene.value, label: `${sceneLabel(scene.value)}` }, - { key: 'install', label: '安装合成' }, - { key: 'erase', label: '门窗擦除' }, - { key: scene.value === 'render' ? 'indoor' : 'render', label: scene.value === 'render' ? '室内装修' : '渲图' }, - ] + progress.value = 8 + stageText.value = mode === 'photo' ? '上传照片与窗型特征' : '解析窗型结构' + const timer = window.setInterval(() => { + progress.value = Math.min(92, progress.value + 2) + if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)' + }, 1500) try { - for (let s = 0; s < STAGES.length; s++) { - stageText.value = STAGES[s] - for (let p = 0; p <= 25; p += 5) { - progress.value = Math.min(100, s * 25 + p) - await new Promise(r => setTimeout(r, 18)) - } - await new Promise(r => setTimeout(r, 10)) + let photo_b64: string | undefined + if (mode === 'photo') { + photo_b64 = photoB64() } + const d = await api('post', '/api/ai-render/generate', { + template_id: templateId.value, mode, + style: form.ai_style, time: form.ai_time, + scene: mode === 'photo' ? scene.value : form.scene, photo_b64, + }, { timeout: 200000 }) + aiImage.value = `data:image/png;base64,${d.image_b64}` + aiPrompt.value = d.prompt + aiFeatures.value = d.features?.summary ?? '' progress.value = 100 - variants.value = plan.map((p, i) => ({ - label: p.label, style: String(p.key), time: String(i), seed: 7 + i * 13, - })) - pickIdx.value = 0 - // 等 Vue 挂载画廊画布后再合成输出 - await nextFrames() - // 渲染画廊: composePhoto 输出到各画布 - for (let i = 0; i < plan.length; i++) { - const cv = variantCvs[i] - if (!cv) continue - const out = composePhoto(photo.value, rect.value, drawing.value, plan[i].key, 1280, 7 + i * 13) - cv.width = out.width - cv.height = out.height - cv.getContext('2d')!.drawImage(out, 0, 0) - stageText.value = `合成方案 ${i + 1}/4` - await new Promise(r => setTimeout(r, 30)) - } - ElMessage.success('已生成 4 套炫图方案') - } finally { + ElMessage.success('AI 炫图已生成') + } catch { /* 拦截器已提示 */ } finally { + window.clearInterval(timer) generating.value = false progress.value = 0 } } -// ---- 风格速览(原有程序化模式) ---- +/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */ +function photoB64(): string { + const img = photo.value! + const scale = Math.min(1, 1536 / img.naturalWidth) + const cv = document.createElement('canvas') + cv.width = Math.round(img.naturalWidth * scale) + cv.height = Math.round(img.naturalHeight * scale) + cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height) + return cv.toDataURL('image/jpeg', 0.9).split(',')[1] +} + +function downloadAi() { + if (!aiImage.value) return + const a = document.createElement('a') + a.href = aiImage.value + a.download = 'AI炫图.png' + a.click() +} + +// ---- 风格速览(本地程序化 4 方案, 未配置云端 Key 时的降级模式) ---- async function generateQuick() { generating.value = true variants.value = [] @@ -321,6 +348,13 @@ onMounted(async () => { .preview { height: 58vh; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; } .gallery-foot { margin-top: 8px; } +.ai-result { margin-top: 14px; text-align: center; } +.ai-img { max-width: 100%; max-height: 72vh; border-radius: 10px; box-shadow: 0 2px 14px rgba(0,0,0,.14); } +.ai-ops { margin-top: 8px; } +.prompt-box { margin-top: 10px; text-align: left; } +.prompt-text { font-size: 12px; color: #606266; line-height: 1.7; font-family: Consolas, monospace; + background: #f7f8fa; padding: 10px 12px; border-radius: 6px; word-break: break-word; } +.prompt-feat { margin-top: 8px; font-size: 12px; color: #909399; } .shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer; height: 200px; transition: border-color .2s, transform .2s; } .shot:hover { transform: translateY(-2px); } diff --git a/pyproject.toml b/pyproject.toml index f6d36b4..5eb73fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyjwt>=2.8", "openpyxl>=3.1", "python-multipart>=0.0.32", + "httpx>=0.27", ] [project.scripts] diff --git a/shots/verify-aicloud.png b/shots/verify-aicloud.png new file mode 100644 index 0000000..e68a401 Binary files /dev/null and b/shots/verify-aicloud.png differ diff --git a/src/huamenchuang/api/ai_render.py b/src/huamenchuang/api/ai_render.py new file mode 100644 index 0000000..80be417 --- /dev/null +++ b/src/huamenchuang/api/ai_render.py @@ -0,0 +1,107 @@ +"""AI 炫图路由:gpt-image 生图代理(Key 仅存后端),提示词由 drawing_data 结构化构建。 + +- quick 模式: /v1/images/generations 纯提示词生成 +- photo 模式: /v1/images/edits 以用户照片为参考图,把窗型合成进照片 +""" +import base64 +import json + +import httpx +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from huamenchuang.core.config import settings +from huamenchuang.core.deps import CurrentUser, DbSession +from huamenchuang.core.response import fail, ok +from huamenchuang.services.ai_prompt import build_prompt, describe_window, pick_size + +router = APIRouter(prefix="/ai-render", tags=["AI 炫图"]) + + +class AiRenderBody(BaseModel): + template_id: int | None = None + drawing_data: str | dict | None = None + mode: str = Field(default="quick", pattern="^(quick|photo)$") + style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$") + time: str = Field(default="day", pattern="^(day|dusk|night)$") + # quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank + scene: str = "indoor" + photo_b64: str | None = Field(default=None, description="照片模式参考图(base64, 不带 dataURL 前缀)") + + +async def _resolve_drawing(db, body: AiRenderBody) -> str | dict | None: + if body.drawing_data: + return body.drawing_data + if body.template_id: + from sqlalchemy import select + from huamenchuang.models.windoor_template import WindoorTemplate + t = (await db.execute(select(WindoorTemplate).where( + WindoorTemplate.id == body.template_id))).scalar_one_or_none() + return t.drawing_data if t else None + return None + + +@router.post("/prompt") +async def preview_prompt(body: AiRenderBody, user: CurrentUser, db: DbSession): + """提示词预览(前端可展开查看优化后的完整提示词)。""" + drawing = await _resolve_drawing(db, body) + if drawing is None: + return fail("未找到图纸数据") + try: + info = describe_window(drawing) + prompt = build_prompt(drawing, body.style, body.time, body.scene) + except Exception: + return fail("图纸数据解析失败") + return ok(data={"features": info, "prompt": prompt, + "size": pick_size(info["width_mm"], info["height_mm"])}) + + +@router.post("/generate") +async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession): + """云端 AI 生图(gpt-image):quick=生成 / photo=参考图编辑。""" + if not settings.ai_api_key: + return fail("AI 生图服务未配置(HMC_AI_API_KEY)") + drawing = await _resolve_drawing(db, body) + if drawing is None: + return fail("未找到图纸数据") + if body.mode == "photo" and not body.photo_b64: + return fail("照片模式需要上传参考照片") + try: + info = describe_window(drawing) + prompt = build_prompt(drawing, body.style, body.time, body.scene) + size = pick_size(info["width_mm"], info["height_mm"]) + except Exception: + return fail("图纸数据解析失败") + + base = settings.ai_base_url.rstrip("/") + headers = {"Authorization": f"Bearer {settings.ai_api_key}"} + timeout = httpx.Timeout(settings.ai_timeout, connect=15) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + if body.mode == "photo": + img_bytes = base64.b64decode(body.photo_b64.split(",")[-1]) + resp = await client.post( + f"{base}/v1/images/edits", headers=headers, + data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, + files={"image[]": ("photo.png", img_bytes, "image/png")}) + else: + resp = await client.post( + f"{base}/v1/images/generations", headers=headers, + json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size}) + resp.raise_for_status() + data = resp.json() + item = (data.get("data") or [{}])[0] + b64 = item.get("b64_json") + if not b64: + return fail(f"生图服务返回异常: {str(data)[:200]}") + except httpx.TimeoutException: + return fail("AI 生图超时,请稍后重试") + except httpx.HTTPStatusError as e: + detail = e.response.text[:200] if e.response is not None else "" + return fail(f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}") + except Exception as e: + return fail(f"生图失败: {str(e)[:150]}") + return ok(data={"image_b64": b64, "prompt": prompt, "size": size, + "features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"], + "summary": info["summary"]}}, + message="生成成功") diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 087f5c6..04ca293 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from huamenchuang.api import ( + ai_render, auth, business, contracts, @@ -29,6 +30,7 @@ api_router.include_router(quote.router) api_router.include_router(script.router) api_router.include_router(order_format.router) api_router.include_router(finance.router) +api_router.include_router(ai_render.router) api_router.include_router(warehouse.router) api_router.include_router(business.router) api_router.include_router(platform.router) diff --git a/src/huamenchuang/core/config.py b/src/huamenchuang/core/config.py index 4e459df..8dcb523 100644 --- a/src/huamenchuang/core/config.py +++ b/src/huamenchuang/core/config.py @@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings _PROJECT_ROOT = Path(__file__).resolve().parents[3] _DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}" +_ENV_FILE = _PROJECT_ROOT / ".env" class Settings(BaseSettings): @@ -19,8 +20,15 @@ class Settings(BaseSettings): "template", "customer", "contract", "warehouse", "order", "quote", "optimize", "render", ] + # AI 炫图(OpenAI Images 兼容中转站;未配置时前端降级本地程序化渲染) + ai_base_url: str = "https://geminiapi.ai" + ai_api_key: str = "" + ai_model: str = "gpt-image-2.5-1k" + ai_timeout: int = 180 - model_config = {"env_prefix": "HMC_", "env_file": ".env"} + # .env 锚定项目根(与进程 cwd 无关); 不存在时不加载 + model_config = {"env_prefix": "HMC_", + "env_file": str(_ENV_FILE) if _ENV_FILE.exists() else None} settings = Settings() diff --git a/src/huamenchuang/services/ai_prompt.py b/src/huamenchuang/services/ai_prompt.py new file mode 100644 index 0000000..791674f --- /dev/null +++ b/src/huamenchuang/services/ai_prompt.py @@ -0,0 +1,169 @@ +"""AI 炫图提示词构建器:从 drawing_data 提取窗型结构特征,生成专业英文生图提示词。 + +设计原则(生图模型工程实践): +- 主体先行: 窗户的几何/材质/分格描述置前,场景氛围居后 +- 结构量化: 宽高 mm、分格数、扇数、开向全部显式给出,抑制模型自由发挥 +- 负面约束: 明确"保持窗型几何不变"(edits 模式关键) +- 光影专项: 时间(日/黄昏/夜)与室内外视角分别给出摄影级光照词 +""" +from huamenchuang.core.drawing import parse_drawing, replay_shape + +# 风格 → 型材材质/颜色描述 +STYLE_FINISH = { + "modern": ("matte anthracite grey aluminum frames, minimalist slim profiles", "modern minimalist"), + "chinese": ("dark bronze aluminum frames with wood-grain inner finish and subtle Chinese lattice grille", "new-Chinese style"), + "european": ("cream white aluminum frames with classic decorative architrave", "classic European"), + "nordic": ("white aluminum frames with light oak interior foil", "Scandinavian Nordic"), +} + +# 时间 → 摄影光照 +TIME_LIGHT = { + "day": "bright midday daylight, soft natural shadows, clear sky", + "dusk": "warm golden-hour sunset light, long soft shadows, amber sky", + "night": "night scene, warm interior glow shining through the glass, cool blue ambience", +} + +# 场景 → 场景描述(照片式 7 场景对齐源站 renderV2) +SCENE_DESC = { + "render": "the window installed on a clean rendered exterior wall, straight-on elevation view", + "install": "the brand-new window just mounted into the wall opening, installation foam and fixing screws still visible at the edges, home renovation context", + "erase": "renovation scene: the old worn window has been removed from the masonry opening and this new window is being fitted in its place, dusty sill", + "indoor": "interior view, the window as the focal point of a tastefully furnished {style_word} living room with sofa, wooden floor, curtains and green plants", + "villa": "exterior view of a {style_word} villa facade featuring this large window, landscaped garden with lawn below, blue sky", + "fence": "view from inside looking out through the window, clear unobstructed garden and distant hills beyond the glass, no railings or bars outside", + "blank": "unfurnished rough-condition room with bare grey cement walls, the window as the only fitted element, pre-renovation state", +} + +# 快速模式场景(无照片) +QUICK_SCENE = { + "indoor": "interior view in a tastefully furnished {style_word} room, the window as the light source", + "outdoor": "exterior facade view on a residential building, mild urban greenery around", + "studio": "studio product shot on a seamless neutral background, softbox lighting, slight floor reflection", +} + + +def _sash_kind(sub: dict) -> str | None: + t = str(sub.get("type", "")) + is_door = bool(sub.get("isDoor")) + if "Screen" in t: + return None # 纱扇不参与文字描述 + if "Kfc" in t: + return "tilt-and-turn " + ("door leaf" if is_door else "sash") + if "DoubleSash" in t: + return "pair of French " + ("door leaves" if is_door else "casement sashes opening from the center") + if "Slide" in t: + return "sliding " + ("door panel" if is_door else "sash") + if t in ("Sash",): + return "side-hung casement " + ("door leaf" if is_door else "sash") + return None + + +def _opening_words(sub: dict) -> str: + toward = str(sub.get("openToward") or "") + od = sub.get("od") + if "up" in str(sub.get("openDirection") or "").lower() or (isinstance(od, (int, float)) and 60 <= od <= 180 and "up" in str(sub.get("openDirection", "")).lower()): + return "top-hung" + return "inward-opening" if toward == "inward" else "outward-opening" + + +def describe_window(drawing: str | dict) -> dict: + """从 drawing_data 提取窗型结构特征(供提示词与前端展示)。""" + dd = parse_drawing(drawing) + shapes = [s for s in dd.get("sm", []) if isinstance(s, dict)] + if not shapes: + return {"count": 0, "summary": "", "width_mm": 0, "height_mm": 0} + + xs, ys = [], [] + parts: list[str] = [] + sash_total = 0 + has_arc = has_screen = has_deco = False + for shape in shapes: + regions = replay_shape(shape) + pts = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] for r in regions] + if not pts: + continue + flat = [p for r in pts for p in r] + x0, y0 = min(p[0] for p in flat), min(p[1] for p in flat) + x1, y1 = max(p[0] for p in flat), max(p[1] for p in flat) + xs += [x0, x1]; ys += [y0, y1] + w, h = round(x1 - x0), round(y1 - y0) + n_region = len(regions) + # 扇描述 + sashes = [sub for sub in (shape.get("sm") or []) if isinstance(sub, dict)] + kind_words: list[str] = [] + for sub in sashes: + k = _sash_kind(sub) + if k is None: + if "Screen" in str(sub.get("type", "")): + has_screen = True + continue + sash_total += 1 + kind_words.append(f"{_opening_words(sub)} {k}") + if shape.get("rt"): + has_arc = True + if shape.get("decs"): + has_deco = True + part = f"{w}×{h}mm unit with {n_region} glass panes" + if kind_words: + part += ", " + " and ".join(dict.fromkeys(kind_words)) + parts.append(part) + + if not xs: + return {"count": 0, "summary": "", "width_mm": 0, "height_mm": 0} + width_mm, height_mm = round(max(xs) - min(xs)), round(max(ys) - min(ys)) + multi = len(parts) > 1 + summary = "; ".join(parts) + if multi: + summary = f"a combination of {len(parts)} window units joined together: " + summary + else: + summary = parts[0] + if has_arc: + summary += ", arched/curved top (heritage style)" + if has_screen: + summary += ", with retractable fly screen" + if has_deco: + summary += ", with decorative gliding bars (grilles) between the glass" + return {"count": len(parts), "summary": summary, "width_mm": width_mm, + "height_mm": height_mm, "sashes": sash_total} + + +def build_prompt(drawing: str | dict, style: str = "modern", time: str = "day", + scene: str = "render", ratio: str = "landscape") -> str: + """构建优化后的完整英文提示词。 + + ratio: portrait/landscape/square → 依据窗型宽高比的构图引导。 + """ + info = describe_window(drawing) + finish, style_word = STYLE_FINISH.get(style, STYLE_FINISH["modern"]) + light = TIME_LIGHT.get(time, TIME_LIGHT["day"]) + scene_tpl = SCENE_DESC.get(scene) or QUICK_SCENE.get(scene) or SCENE_DESC["render"] + scene_text = scene_tpl.replace("{style_word}", style_word) + + window_desc = info["summary"] or "a modern aluminum window" + composition = { + "portrait": "vertical composition, window fills most of the frame height", + "landscape": "horizontal composition, generous wall context around the window", + "square": "balanced square composition, window centered", + }.get(ratio, "") + + prompt = ( + f"Photorealistic architectural photograph of {scene_text}. " + f"The window: {window_desc}; {finish}. " + f"Lighting: {light}. {composition} " + "Keep the window geometry, pane division and frame profiles exactly as described — do not add or remove sashes. " + "Realistic glass with soft sky reflection, physically correct shadows and contact shadows, " + "true-to-material aluminum texture, high dynamic range, ultra-detailed, 8k, professional architectural photography, no people, no text, no watermark." + ) + return prompt + + +def pick_size(width_mm: int, height_mm: int) -> str: + """按窗型宽高比选择生成尺寸(gpt-image 支持 1024x1024 / 1536x1024 / 1024x1536)。""" + if not width_mm or not height_mm: + return "1024x1024" + r = width_mm / height_mm + if r > 1.2: + return "1536x1024" + if r < 0.83: + return "1024x1536" + return "1024x1024" diff --git a/uv.lock b/uv.lock index 2733483..729cd70 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -124,6 +133,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -146,6 +168,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "huamenchuang" version = "0.1.0" @@ -153,6 +190,7 @@ source = { editable = "." } dependencies = [ { name = "aiosqlite" }, { name = "fastapi" }, + { name = "httpx" }, { name = "openpyxl" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -166,6 +204,7 @@ dependencies = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.3" },