feat: AI炫图接入gpt-image-2.5云端生图(结构化提示词工程+照片参考合成)
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# CHANGELOG
|
# 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 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)
|
- [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
@@ -465,3 +465,17 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
|
|||||||
- 流程:上传场景照片 → 拖拽框选窗洞(原始照片坐标)→ 选样板窗型 → `renderWindowOnly`(分割树重放渲染透明背景窗型:玻璃渐变/中梃/扇框开向/装饰格条)→ 光照匹配(采样洞口周边亮度调整窗户亮度)→ 边缘贴合 → 场景后处理(安装投影/擦除填充/暖色滤镜/石材基座/背景虚化/去饱和毛坯)
|
- 流程:上传场景照片 → 拖拽框选窗洞(原始照片坐标)→ 选样板窗型 → `renderWindowOnly`(分割树重放渲染透明背景窗型:玻璃渐变/中梃/扇框开向/装饰格条)→ 光照匹配(采样洞口周边亮度调整窗户亮度)→ 边缘贴合 → 场景后处理(安装投影/擦除填充/暖色滤镜/石材基座/背景虚化/去饱和毛坯)
|
||||||
- 一键生成 4 方案画廊 + PNG 下载;原"风格速览"模式保留为第二 Tab
|
- 一键生成 4 方案画廊 + PNG 下载;原"风格速览"模式保留为第二 Tab
|
||||||
- 无新增后端接口(复用 `/api/windoor/templates` 与 `/api/rendering-info`)
|
- 无新增后端接口(复用 `/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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -36,9 +36,9 @@ http.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 提取 envelope 的 data */
|
/** 提取 envelope 的 data(config 可传 timeout 等覆盖) */
|
||||||
export async function api<T = any>(method: string, url: string, body?: object): Promise<T> {
|
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 })
|
const res = await http.request({ method, url, data: body, ...config })
|
||||||
return res.data.data as T
|
return res.data.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,9 @@
|
|||||||
<el-upload :show-file-list="false" accept="image/*" :http-request="onUpload">
|
<el-upload :show-file-list="false" accept="image/*" :http-request="onUpload">
|
||||||
<el-button type="primary" plain>{{ photo ? '更换照片' : '上传场景照片' }}</el-button>
|
<el-button type="primary" plain>{{ photo ? '更换照片' : '上传场景照片' }}</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating" @click="generate">
|
<el-button type="primary" :disabled="!photo || !drawing || !rect" :loading="generating"
|
||||||
{{ generating ? '合成中…' : '✦ 生成炫图' }}</el-button>
|
@click="generateCloud('photo')">
|
||||||
|
{{ generating ? 'AI 合成中…' : '✦ AI 照片合成' }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
|
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
|
||||||
<el-icon size="40"><UploadFilled /></el-icon>
|
<el-icon size="40"><UploadFilled /></el-icon>
|
||||||
@@ -67,8 +68,9 @@
|
|||||||
<el-select v-model="form.scene" style="width:110px">
|
<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-option value="indoor" label="室内" /><el-option value="outdoor" label="室外" /><el-option value="studio" label="影棚" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateQuick">
|
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateCloud('quick')">
|
||||||
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
|
{{ generating ? 'AI 绘制中…' : '✦ AI 云端生成' }}</el-button>
|
||||||
|
<el-button :disabled="!drawing" :loading="generating" @click="generateQuick">本地 4 方案</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
|
||||||
@@ -82,7 +84,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="variants.length" class="gallery-foot">
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -92,7 +108,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import AIRenderCanvas from '../components/AIRenderCanvas.vue'
|
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 tab = ref<'photo' | 'quick'>('photo')
|
||||||
const templates = ref<any[]>([])
|
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)
|
const rect = ref<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||||
let dragStart: { x: number; y: 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) {
|
async function loadTemplate(tid: number | undefined) {
|
||||||
drawing.value = ''
|
drawing.value = ''
|
||||||
variants.value = []
|
variants.value = []
|
||||||
@@ -209,52 +223,65 @@ function drawStage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ----
|
// ---- 云端 AI 生成(gpt-image-2.5, 提示词由后端从窗型数据结构化构建) ----
|
||||||
async function generate() {
|
const aiImage = ref('')
|
||||||
if (!photo.value || !drawing.value || !rect.value) return
|
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
|
generating.value = true
|
||||||
|
aiImage.value = ''
|
||||||
variants.value = []
|
variants.value = []
|
||||||
const plan: { key: PhotoScene; label: string }[] = [
|
progress.value = 8
|
||||||
{ key: scene.value, label: `${sceneLabel(scene.value)}` },
|
stageText.value = mode === 'photo' ? '上传照片与窗型特征' : '解析窗型结构'
|
||||||
{ key: 'install', label: '安装合成' },
|
const timer = window.setInterval(() => {
|
||||||
{ key: 'erase', label: '门窗擦除' },
|
progress.value = Math.min(92, progress.value + 2)
|
||||||
{ key: scene.value === 'render' ? 'indoor' : 'render', label: scene.value === 'render' ? '室内装修' : '渲图' },
|
if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)'
|
||||||
]
|
}, 1500)
|
||||||
try {
|
try {
|
||||||
for (let s = 0; s < STAGES.length; s++) {
|
let photo_b64: string | undefined
|
||||||
stageText.value = STAGES[s]
|
if (mode === 'photo') {
|
||||||
for (let p = 0; p <= 25; p += 5) {
|
photo_b64 = photoB64()
|
||||||
progress.value = Math.min(100, s * 25 + p)
|
|
||||||
await new Promise(r => setTimeout(r, 18))
|
|
||||||
}
|
|
||||||
await new Promise(r => setTimeout(r, 10))
|
|
||||||
}
|
}
|
||||||
|
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
|
progress.value = 100
|
||||||
variants.value = plan.map((p, i) => ({
|
ElMessage.success('AI 炫图已生成')
|
||||||
label: p.label, style: String(p.key), time: String(i), seed: 7 + i * 13,
|
} catch { /* 拦截器已提示 */ } finally {
|
||||||
}))
|
window.clearInterval(timer)
|
||||||
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 {
|
|
||||||
generating.value = false
|
generating.value = false
|
||||||
progress.value = 0
|
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() {
|
async function generateQuick() {
|
||||||
generating.value = true
|
generating.value = true
|
||||||
variants.value = []
|
variants.value = []
|
||||||
@@ -321,6 +348,13 @@ onMounted(async () => {
|
|||||||
.preview { height: 58vh; }
|
.preview { height: 58vh; }
|
||||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
|
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||||
.gallery-foot { margin-top: 8px; }
|
.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;
|
.shot { position: relative; border: 2px solid transparent; border-radius: 10px; overflow: hidden; cursor: pointer;
|
||||||
height: 200px; transition: border-color .2s, transform .2s; }
|
height: 200px; transition: border-color .2s, transform .2s; }
|
||||||
.shot:hover { transform: translateY(-2px); }
|
.shot:hover { transform: translateY(-2px); }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pyjwt>=2.8",
|
"pyjwt>=2.8",
|
||||||
"openpyxl>=3.1",
|
"openpyxl>=3.1",
|
||||||
"python-multipart>=0.0.32",
|
"python-multipart>=0.0.32",
|
||||||
|
"httpx>=0.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
@@ -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,6 +2,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from huamenchuang.api import (
|
from huamenchuang.api import (
|
||||||
|
ai_render,
|
||||||
auth,
|
auth,
|
||||||
business,
|
business,
|
||||||
contracts,
|
contracts,
|
||||||
@@ -29,6 +30,7 @@ api_router.include_router(quote.router)
|
|||||||
api_router.include_router(script.router)
|
api_router.include_router(script.router)
|
||||||
api_router.include_router(order_format.router)
|
api_router.include_router(order_format.router)
|
||||||
api_router.include_router(finance.router)
|
api_router.include_router(finance.router)
|
||||||
|
api_router.include_router(ai_render.router)
|
||||||
api_router.include_router(warehouse.router)
|
api_router.include_router(warehouse.router)
|
||||||
api_router.include_router(business.router)
|
api_router.include_router(business.router)
|
||||||
api_router.include_router(platform.router)
|
api_router.include_router(platform.router)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pydantic_settings import BaseSettings
|
|||||||
|
|
||||||
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||||
_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}"
|
_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}"
|
||||||
|
_ENV_FILE = _PROJECT_ROOT / ".env"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -19,8 +20,15 @@ class Settings(BaseSettings):
|
|||||||
"template", "customer", "contract", "warehouse", "order",
|
"template", "customer", "contract", "warehouse", "order",
|
||||||
"quote", "optimize", "render",
|
"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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "huamenchuang"
|
name = "huamenchuang"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -153,6 +190,7 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "openpyxl" },
|
{ name = "openpyxl" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -166,6 +204,7 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1" },
|
{ name = "openpyxl", specifier = ">=3.1" },
|
||||||
{ name = "pydantic", specifier = ">=2.7" },
|
{ name = "pydantic", specifier = ">=2.7" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.3" },
|
{ name = "pydantic-settings", specifier = ">=2.3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user