feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)

This commit is contained in:
HHY
2026-09-25 04:15:45 -07:00
parent 710b3521a2
commit 6726a9fa3a
12 changed files with 496 additions and 48 deletions
+2
View File
@@ -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)
+14
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
// 云端 AI 炫图(gpt-image)端到端验证: 注入测试照片 → 拖框 → AI 照片合成 → 截图
// 用法: node verify-aicloud.mjs <token>
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()
+3 -3
View File
@@ -36,9 +36,9 @@ http.interceptors.response.use(
},
)
/** 提取 envelope 的 data */
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
const res = await http.request({ method, url, data: body })
/** 提取 envelope 的 data(config 可传 timeout 等覆盖) */
export async function api<T = any>(method: string, url: string, body?: object, config?: object): Promise<T> {
const res = await http.request({ method, url, data: body, ...config })
return res.data.data as T
}
+78 -44
View File
@@ -24,8 +24,9 @@
<el-upload :show-file-list="false" accept="image/*" :http-request="onUpload">
<el-button type="primary" plain>{{ photo ? '更换照片' : '上传场景照片' }}</el-button>
</el-upload>
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating" @click="generate">
{{ generating ? '合成中…' : '✦ 生成炫图' }}</el-button>
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating"
@click="generateCloud('photo')">
{{ generating ? 'AI 合成中…' : '✦ AI 照片合成' }}</el-button>
</div>
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
<el-icon size="40"><UploadFilled /></el-icon>
@@ -67,8 +68,9 @@
<el-select v-model="form.scene" style="width:110px">
<el-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
</el-select>
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateQuick">
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateCloud('quick')">
{{ generating ? 'AI 绘制中…' : '✦ AI 云端生成' }}</el-button>
<el-button :disabled="!drawing" :loading="generating" @click="generateQuick">本地 4 方案</el-button>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
@@ -82,7 +84,21 @@
</template>
<div v-if="variants.length" class="gallery-foot">
<span class="tip">点击方案切换大图预览,图标下载 PNG</span>
<span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span>
</div>
<!-- 云端 AI 结果(两个模式共用) -->
<div v-if="aiImage" class="ai-result">
<img :src="aiImage" class="ai-img" />
<div class="ai-ops">
<el-button type="primary" size="small" plain @click="downloadAi">下载 PNG</el-button>
</div>
<el-collapse class="prompt-box">
<el-collapse-item title="查看优化后的提示词(由窗型数据结构化生成)">
<div class="prompt-text">{{ aiPrompt }}</div>
<div class="prompt-feat" v-if="aiFeatures">窗型特征:{{ aiFeatures }}</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</template>
@@ -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<any[]>([])
@@ -122,8 +138,6 @@ const stageRef = ref<HTMLCanvasElement>()
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<any>('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); }
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
"pyjwt>=2.8",
"openpyxl>=3.1",
"python-multipart>=0.0.32",
"httpx>=0.27",
]
[project.scripts]
Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

+107
View File
@@ -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="生成成功")
+2
View File
@@ -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)
+9 -1
View File
@@ -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()
+169
View File
@@ -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"
Generated
+39
View File
@@ -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" },