feat: P0-8 照片式AI炫图(7场景/纯前端合成/光照匹配)

This commit is contained in:
HHY
2026-09-25 03:49:49 -07:00
parent 63f498655f
commit 710b3521a2
10 changed files with 596 additions and 50 deletions
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG
- [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 14:50] FEAT: v2.25 P0-6 Excel 导入导出全套。①excel_service 扩展: read_rows 自动探测表头行(兼容首行标题合并格, 修复导入全跳过)/pick 中文列名取值/simple_table_workbook/statistics_workbook 双Sheet; ②客户: excel-template 模板/excel-in 批量导入(缺姓名行级报错)/excel-out 导出; ③合同 excel-out(含已收); ④库存 excel-out/excel-in(同名更新数量累加); ⑤reports/excel-statistics(订单汇总+收款统计); ⑥依赖补 python-multipart(UploadFile 必需, 缺失曾致后端启动失败); ⑦前端 apiUpload+四个页面导入导出按钮; 实测: 模板回传导入成功1条/库存导出→再导入更新1条; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/reports.py, frontend/src/api/index.ts, frontend/src/views/CustomersView.vue, frontend/src/views/WarehouseView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/ReportView.vue, doc/API.md)
+10
View File
@@ -455,3 +455,13 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
- **报价**:`offer-price`/BOM 自动计入格条算料(横条=区域宽×rows、竖条=区域高×cols,默认 12 元/米,`prices.dec_bar` 可覆盖,kind=`dec_bar`)
风格预设:现代轻奢(1×1黑25)/意式简约(1×1灰18)/奶油风(2×2白22)/中式(2×2棕28)/欧式古典(3×3金20)/法式(2×2金16)/地中海(2×3蓝24)/田园密格(4×4白14)。
### 12.22 照片式 AI 炫图(v2.20 升级,P0-照片炫图)
对齐目标站 `renderV2` 炫图 7 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。
纯前端像素合成(`components/photoCompose.ts`,无需服务端 AI):
- 流程:上传场景照片 → 拖拽框选窗洞(原始照片坐标)→ 选样板窗型 → `renderWindowOnly`(分割树重放渲染透明背景窗型:玻璃渐变/中梃/扇框开向/装饰格条)→ 光照匹配(采样洞口周边亮度调整窗户亮度)→ 边缘贴合 → 场景后处理(安装投影/擦除填充/暖色滤镜/石材基座/背景虚化/去饱和毛坯)
- 一键生成 4 方案画廊 + PNG 下载;原"风格速览"模式保留为第二 Tab
- 无新增后端接口(复用 `/api/windoor/templates` 与 `/api/rendering-info`)
+75
View File
@@ -0,0 +1,75 @@
// 照片式 AI 炫图端到端验证: 注入程序化测试照片 → 拖框 → 生成 → 截画廊
// 用法: node verify-photorender.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))
const opt = await page.$('.el-select-dropdown__item')
await opt.click()
await new Promise(r => setTimeout(r, 1200))
// 2) 程序化生成"室内照片"并注入 el-upload
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')
if (!stage) { console.log('✗ 照片舞台未出现'); process.exit(1) }
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, 400))
// 4) 生成
const genBtn = await page.evaluateHandle(() => [...document.querySelectorAll('button')].find(x => x.textContent.includes('生成炫图')))
await genBtn.asElement().click()
await new Promise(r => setTimeout(r, 6000))
const shots = await page.$$('.shot')
console.log(`方案数: ${shots.length}`)
if (!shots.length) { console.log('✗ 未生成方案'); process.exit(1) }
await page.evaluate(() => document.querySelector('.gallery')?.scrollIntoView({ block: 'center' }))
await new Promise(r => setTimeout(r, 500))
if (shots[0]) await shots[0].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-0.png' })
if (shots[1]) await shots[1].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-1.png' })
if (shots[2]) await shots[2].screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photorender-2.png' })
const stageEl = await page.$('.photo-cv')
if (stageEl) await stageEl.screenshot({ path: 'D:/Code/huamenchuang/shots/verify-photo-stage.png' })
console.log('✓ 照片合成炫图验证完成')
await browser.close()
+278
View File
@@ -0,0 +1,278 @@
/**
* 照片式 AI 炫图合成引擎(纯前端像素合成,无需服务端 AI)
*
* 流程: 用户照片 + 标注窗洞矩形 + 样板窗型渲染 → 光照匹配合成 + 场景后处理
* 场景语义对齐源站 renderV2 炫图 7 分类: 渲图/安装合成/门窗擦除/室内装修/别墅/去围栏/变毛坯
*/
import {
bboxOf, inferSashGlyphs, parseDrawing, regionPoints, replayShape,
type DecBar, type DrawingData, type FrameShape,
} from '../types/drawing'
export type PhotoScene = 'render' | 'install' | 'erase' | 'indoor' | 'villa' | 'fence' | 'blank'
export const PHOTO_SCENES: { key: PhotoScene; label: string; desc: string }[] = [
{ key: 'render', label: '渲图', desc: '窗户合成进照片,光照自动匹配' },
{ key: 'install', label: '安装合成', desc: '合成+墙面投影,接近实装效果' },
{ key: 'erase', label: '门窗擦除', desc: '擦除洞口原窗户后装入新窗' },
{ key: 'indoor', label: '室内装修', desc: '暖色家居氛围滤镜' },
{ key: 'villa', label: '别墅', desc: '石材外墙质感场景' },
{ key: 'fence', label: '去围栏', desc: '背景虚化聚焦门窗' },
{ key: 'blank', label: '变毛坯', desc: '水泥毛坯房效果' },
]
/**
* 纯窗户渲染(透明背景, 供照片合成): 分割树重放 → 玻璃渐变/格条/扇框开向/外框窗套
*/
export function renderWindowOnly(w: number, h: number, drawingRaw: string | object): HTMLCanvasElement {
const cv = document.createElement('canvas')
cv.width = Math.max(64, w)
cv.height = Math.max(48, h)
const ctx = cv.getContext('2d')!
let dd: DrawingData
try { dd = parseDrawing(drawingRaw) } catch { return cv }
if (!dd?.sm?.length) return cv
const pts = dd.sm.flatMap(s => replayShape(s).regions.flat().flatMap(seg => [seg.ps, seg.pe]))
if (!pts.length) return cv
const gb = bboxOf(pts)
const gw = gb.x1 - gb.x0, gh = gb.y1 - gb.y0
const pad = Math.max(10, cv.width * 0.03)
const scale = Math.min((cv.width - pad * 2) / gw, (cv.height - pad * 2) / gh)
const ox = (cv.width - gw * scale) / 2, oy = (cv.height - gh * scale) / 2
const tx = (x: number) => ox + (x - gb.x0) * scale
const ty = (y: number) => oy + (y - gb.y0) * scale
const drawShape = (shape: FrameShape) => {
const rp = replayShape(shape)
const regions = rp.regions.map(regionPoints).filter(p => p.length >= 3)
// 玻璃: 蓝绿渐变 + 斜向高光
for (const rpts of regions) {
const bb = bboxOf(rpts)
const gx = tx(bb.x0), gy = ty(bb.y0), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale
const grad = ctx.createLinearGradient(gx, gy, gx + gw2, gy + gh2)
grad.addColorStop(0, '#bfe0ea')
grad.addColorStop(0.45, '#9ec9d8')
grad.addColorStop(0.6, '#8fbccb')
grad.addColorStop(1, '#c8e2ec')
ctx.beginPath()
rpts.forEach((p, i) => (i ? ctx.lineTo(tx(p.x), ty(p.y)) : ctx.moveTo(tx(p.x), ty(p.y))))
ctx.closePath()
ctx.fillStyle = grad
ctx.fill()
ctx.save()
ctx.clip()
ctx.globalAlpha = 0.2
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.moveTo(gx + gw2 * 0.08, gy)
ctx.lineTo(gx + gw2 * 0.34, gy)
ctx.lineTo(gx + gw2 * 0.08, gy + gh2)
ctx.lineTo(gx - gw2 * 0.06, gy + gh2)
ctx.closePath()
ctx.fill()
ctx.restore()
ctx.strokeStyle = 'rgba(70,76,84,0.9)'
ctx.lineWidth = 2
ctx.stroke()
}
// 中梃: sp 在其作用区域 bbox 内的投影线
const regionBBs = regions.map(bboxOf)
for (const sp of (shape.mm?.sp ?? [])) {
const line = sp.line ?? {}
const n = line.norm ?? {}
const pt = line.pt ?? {}
if (typeof pt.x !== 'number' || typeof pt.y !== 'number') continue
const axis = Math.abs(n.x ?? 0) >= Math.abs(n.y ?? 0) ? 'v' : 'h'
const pos = axis === 'v' ? pt.x : pt.y
let best: { x0: number; y0: number; x1: number; y1: number } | null = null
for (const bb of regionBBs) {
const lo = axis === 'v' ? bb.x0 : bb.y0
const hi = axis === 'v' ? bb.x1 : bb.y1
if (lo - 1 <= pos && pos <= hi + 1) best = bb
}
if (!best) continue
ctx.strokeStyle = '#565b62'
ctx.lineWidth = Math.max(3, 50 * scale * 0.12)
ctx.beginPath()
if (axis === 'v') { ctx.moveTo(tx(pos), ty(best.y0)); ctx.lineTo(tx(pos), ty(best.y1)) }
else { ctx.moveTo(tx(best.x0), ty(pos)); ctx.lineTo(tx(best.x1), ty(pos)) }
ctx.stroke()
}
// 扇: 扇框 + 开向三角
const glyphs = inferSashGlyphs(shape, rp).sashes
for (const g of glyphs) {
const bx0 = tx(g.x0), by0 = ty(g.y0), bx1 = tx(g.x1), by1 = ty(g.y1)
if (bx1 - bx0 < 8 || by1 - by0 < 8) continue
ctx.strokeStyle = '#4d5257'
ctx.lineWidth = Math.max(2.5, 60 * scale * 0.1)
ctx.strokeRect(bx0, by0, bx1 - bx0, by1 - by0)
// 开向示意(虚线三角, 指向铰链对侧)
ctx.save()
ctx.setLineDash([6, 5])
ctx.strokeStyle = 'rgba(255,255,255,0.75)'
ctx.lineWidth = 1.6
const cx = (bx0 + bx1) / 2, cy = (by0 + by1) / 2
const dir = (g.openDirection ?? 'left') as string
ctx.beginPath()
if (dir.startsWith('up')) { ctx.moveTo(bx0 + 4, by1 - 4); ctx.lineTo(bx1 - 4, by1 - 4); ctx.lineTo(cx, by0 + 6) }
else if (dir.includes('right')) { ctx.moveTo(bx0 + 4, by0 + 4); ctx.lineTo(bx0 + 4, by1 - 4); ctx.lineTo(bx1 - 6, cy) }
else { ctx.moveTo(bx1 - 4, by0 + 4); ctx.lineTo(bx1 - 4, by1 - 4); ctx.lineTo(bx0 + 6, cy) }
ctx.closePath()
ctx.stroke()
ctx.restore()
}
// 装饰格条
for (const dec of (shape.decs ?? []) as DecBar[]) {
const segs = rp.regions[dec.ri]
if (!segs) continue
const rpts = regionPoints(segs)
if (rpts.length < 3) continue
const bb = bboxOf(rpts)
const gx = tx(bb.x0), gy = ty(bb.y0), gw2 = (bb.x1 - bb.x0) * scale, gh2 = (bb.y1 - bb.y0) * scale
const barPx = Math.max(1.5, dec.bw * scale)
ctx.save()
ctx.beginPath(); ctx.rect(gx, gy, gw2, gh2); ctx.clip()
ctx.fillStyle = dec.color
ctx.globalAlpha = 0.92
for (let i = 1; i <= dec.rows; i++) ctx.fillRect(gx, gy + (gh2 * i) / (dec.rows + 1) - barPx / 2, gw2, barPx)
for (let i = 1; i <= dec.cols; i++) ctx.fillRect(gx + (gw2 * i) / (dec.cols + 1) - barPx / 2, gy, barPx, gh2)
ctx.restore()
}
}
// 窗套(白色外框) + 各形状
ctx.fillStyle = '#f6f5f2'
ctx.fillRect(tx(gb.x0) - pad * 0.6, ty(gb.y0) - pad * 0.6, gw * scale + pad * 1.2, gh * scale + pad * 1.2)
ctx.strokeStyle = '#d9d6cf'
ctx.lineWidth = 2
ctx.strokeRect(tx(gb.x0) - pad * 0.6, ty(gb.y0) - pad * 0.6, gw * scale + pad * 1.2, gh * scale + pad * 1.2)
for (const shape of dd.sm) drawShape(shape)
// 整体外框
ctx.strokeStyle = '#43474c'
ctx.lineWidth = 6
ctx.strokeRect(tx(gb.x0) - 3, ty(gb.y0) - 3, gw * scale + 6, gh * scale + 6)
return cv
}
function avgLuma(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): number {
const d = ctx.getImageData(Math.max(0, x | 0), Math.max(0, y | 0), Math.max(1, w | 0), Math.max(1, h | 0)).data
let s = 0
const n = Math.min(d.length, 4 * 4000)
for (let i = 0; i < n; i += 4) s += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]
return s / (n / 4) / 255
}
/** 把窗户合成进照片矩形: 保持窗型宽高比居中 + 光照匹配 + 边缘羽化 */
function blendWindow(out: CanvasRenderingContext2D, win: HTMLCanvasElement,
rect: { x: number; y: number; w: number; h: number }, photoLuma: number) {
// 窗户保持宽高比放进矩形(居中)
const scale = Math.min(rect.w / win.width, rect.h / win.height)
const w = win.width * scale, h = win.height * scale
const x = rect.x + (rect.w - w) / 2, y = rect.y + (rect.h - h) / 2
// 光照匹配: 照片偏暗则压暗窗户
const bright = Math.max(0.55, Math.min(1.25, 0.35 + photoLuma))
out.save()
out.filter = `brightness(${bright.toFixed(2)})`
out.drawImage(win, x, y, w, h)
out.restore()
}
/** 场景后处理 */
function applyScene(ctx: CanvasRenderingContext2D, scene: PhotoScene,
rect: { x: number; y: number; w: number; h: number }) {
const { x, y, w, h } = rect
if (scene === 'install') {
// 墙面投影: 矩形右下侧柔和阴影
ctx.save()
const g = ctx.createLinearGradient(x + w, y, x + w + w * 0.12, y + h)
g.addColorStop(0, 'rgba(0,0,0,0.28)')
g.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = g
ctx.fillRect(x + w, y, w * 0.12, h)
ctx.fillStyle = 'rgba(0,0,0,0.18)'
ctx.fillRect(x, y + h, w, h * 0.05)
ctx.restore()
} else if (scene === 'indoor') {
ctx.save()
ctx.globalCompositeOperation = 'overlay'
ctx.fillStyle = 'rgba(255,190,120,0.16)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
} else if (scene === 'villa') {
// 石材基座: 底部 12% 灰色石带 + 分缝线
ctx.save()
const base = ctx.canvas.height * 0.88
ctx.fillStyle = 'rgba(160,158,150,0.55)'
ctx.fillRect(0, base, ctx.canvas.width, ctx.canvas.height - base)
ctx.strokeStyle = 'rgba(90,88,82,0.4)'
ctx.lineWidth = 1.5
for (let gx = 0; gx < ctx.canvas.width; gx += 90) {
ctx.beginPath(); ctx.moveTo(gx, base); ctx.lineTo(gx, ctx.canvas.height); ctx.stroke()
}
ctx.restore()
} else if (scene === 'fence') {
// 背景虚化: 矩形外区域模糊叠加
const cv = ctx.canvas
const snap = document.createElement('canvas')
snap.width = cv.width; snap.height = cv.height
snap.getContext('2d')!.drawImage(cv, 0, 0)
ctx.save()
ctx.filter = 'blur(5px)'
ctx.drawImage(snap, 0, 0)
ctx.restore()
ctx.save()
ctx.filter = 'none'
ctx.clearRect(x, y, w, h)
ctx.drawImage(snap, x, y, w, h, x, y, w, h)
ctx.restore()
} else if (scene === 'blank') {
// 变毛坯: 全图去饱和 + 水泥灰色调
ctx.save()
ctx.globalCompositeOperation = 'saturation'
ctx.fillStyle = 'hsl(0,12%,50%)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'soft-light'
ctx.fillStyle = 'rgba(140,138,132,0.5)'
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
ctx.restore()
}
}
/**
* 主合成入口: 照片 + 窗洞矩形 + 窗户渲染图 + 场景 → 合成结果画布
*/
export function composePhoto(photo: HTMLImageElement, rect: { x: number; y: number; w: number; h: number },
drawing: string | object, scene: PhotoScene,
outW = 1280, seed = 7): HTMLCanvasElement {
// 统一输出尺寸: 照片等比缩放到 outW
const scale = outW / photo.naturalWidth
const outH = Math.round(photo.naturalHeight * scale)
const cv = document.createElement('canvas')
cv.width = outW
cv.height = outH
const ctx = cv.getContext('2d')!
ctx.drawImage(photo, 0, 0, outW, outH)
const r = { x: rect.x * scale, y: rect.y * scale, w: rect.w * scale, h: rect.h * scale }
// 擦除模式: 洞口先用周边平均色+模糊填充(近似内容擦除)
if (scene === 'erase') {
const pad = 24
const lumaCtx = ctx
const sample = avgLuma(lumaCtx, r.x - pad, r.y - pad, r.w + pad * 2, r.h + pad * 2)
ctx.save()
ctx.filter = 'blur(14px)'
ctx.fillStyle = `hsl(0,0%,${Math.round(sample * 100)}%)`
ctx.fillRect(r.x, r.y, r.w, r.h)
ctx.restore()
}
// 渲染窗户并合成(透明背景纯窗型, 贴合洞口)
const win = renderWindowOnly(Math.max(320, Math.round(r.w)), Math.max(240, Math.round(r.h)), drawing)
const luma = avgLuma(ctx, Math.max(0, r.x - 30), Math.max(0, r.y - 30), r.w + 60, r.h + 60)
blendWindow(ctx, win, r, luma)
applyScene(ctx, scene, r)
return cv
}
+208 -27
View File
@@ -3,10 +3,56 @@
<template #header>
<div class="head">
<span class="brand">✦ AI 炫图</span>
<span class="sub">选择样板 → 设定风格 → 一键生成多方案效果图</span>
<el-radio-group v-model="tab" size="small">
<el-radio-button value="photo">照片合成</el-radio-button>
<el-radio-button value="quick">风格速览</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- ============ 照片合成(对齐源站 renderV2 炫图 7 场景) ============ -->
<template v-if="tab === 'photo'">
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板窗型" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<el-select v-model="scene" style="width:150px">
<el-option v-for="s in PHOTO_SCENES" :key="s.key" :value="s.key" :label="s.label" />
</el-select>
<span class="tip">{{ sceneDesc }}</span>
<div style="flex:1" />
<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>
</div>
<div v-if="!photo" class="drop-hint" @click="triggerUpload">
<el-icon size="40"><UploadFilled /></el-icon>
<p>上传室内/室外场景照片,在照片上<b>拖拽框选窗洞位置</b>,选择样板窗型,一键合成真实炫图</p>
<p class="sub">支持 渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯 七种场景</p>
</div>
<div v-if="photo" class="photo-stage">
<canvas ref="stageRef" class="photo-cv" @mousedown="onDown" @mousemove="onMove" @mouseup="onUp"
@mouseleave="onUp" />
<div class="stage-tip">在照片上拖拽框选窗洞位置(松开生效,可重新框选)</div>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="variants.length" class="gallery">
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
<div class="cap">{{ v.label }}</div>
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
</div>
</div>
</template>
<!-- ============ 风格速览(原程序化模式) ============ -->
<template v-else>
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
@@ -21,7 +67,7 @@
<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="generate">
<el-button type="primary" :disabled="!drawing" :loading="generating" @click="generateQuick">
{{ generating ? '生成中…' : '✦ 生成 AI 炫图' }}</el-button>
</div>
@@ -32,25 +78,23 @@
<AIRenderCanvas :drawing-data="drawing" :style="form.ai_style" :time="form.ai_time"
:scene="form.scene" :seed="form.seed" />
</div>
<div v-if="variants.length" class="gallery">
<div v-for="(v, i) in variants" :key="i" class="shot" :class="{ pick: pickIdx === i }" @click="pickIdx = i">
<canvas :ref="el => setVariantRef(el, i)" class="shot-cv" />
<div class="cap">{{ v.label }}</div>
<el-icon class="dl" @click.stop="download(i)"><Download /></el-icon>
</div>
</div>
<el-empty v-if="!drawing" description="请先选择样板" />
</template>
<div v-if="variants.length" class="gallery-foot">
<span class="tip">点击方案切换大图预览,图标下载 PNG</span>
</div>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
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'
const tab = ref<'photo' | 'quick'>('photo')
const templates = ref<any[]>([])
const templateId = ref<number | undefined>()
const drawing = ref('')
@@ -71,6 +115,15 @@ function setVariantRef(el: any, i: number) { variantCvs[i] = el as HTMLCanvasEle
const STAGES = ['解析窗型结构', '合成场景光照', '匹配材质纹理', '生成效果图']
const scene = ref<PhotoScene>('render')
const sceneDesc = computed(() => PHOTO_SCENES.find(s => s.key === scene.value)?.desc ?? '')
const photo = ref<HTMLImageElement | null>(null)
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 = []
@@ -79,10 +132,132 @@ async function loadTemplate(tid: number | undefined) {
drawing.value = t.drawing_data
}
// ---- 照片上传与框选 ----
function triggerUpload() { (document.querySelector('.el-upload input[type=file]') as HTMLInputElement)?.click() }
async function onUpload(opt: any) {
const file = opt.file as File
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = async () => {
photo.value = img
rect.value = null
variants.value = []
await nextTick() // v-if 的舞台 canvas 挂载后再绘制
drawStage()
URL.revokeObjectURL(url)
}
img.src = url
}
function stagePos(e: MouseEvent) {
const cv = stageRef.value!
const b = cv.getBoundingClientRect()
// CSS 像素 → 原始照片坐标(不依赖舞台缩放状态)
return { x: (e.clientX - b.left) * photo.value!.naturalWidth / b.width,
y: (e.clientY - b.top) * photo.value!.naturalHeight / b.height }
}
let photoScale = 1
function onDown(e: MouseEvent) {
if (!photo.value) return
dragStart = stagePos(e)
rect.value = null
}
function onMove(e: MouseEvent) {
if (!dragStart || !photo.value) return
const p = stagePos(e)
rect.value = {
x: Math.min(dragStart.x, p.x), y: Math.min(dragStart.y, p.y),
w: Math.abs(p.x - dragStart.x), h: Math.abs(p.y - dragStart.y),
}
drawStage()
}
function onUp(e: MouseEvent) {
if (!dragStart) return
const p = stagePos(e)
if (rect.value && (rect.value.w < 20 || rect.value.h < 20)) rect.value = null
dragStart = null
void p
drawStage()
}
/** 绘制照片舞台: 照片 + 框选矩形 + 遮罩(rect 为原始照片坐标) */
function drawStage() {
const cv = stageRef.value
if (!cv || !photo.value) return
const maxW = Math.min(920, window.innerWidth - 80)
photoScale = Math.min(1, maxW / photo.value.naturalWidth)
cv.width = Math.round(photo.value.naturalWidth * photoScale)
cv.height = Math.round(photo.value.naturalHeight * photoScale)
const ctx = cv.getContext('2d')!
ctx.drawImage(photo.value, 0, 0, cv.width, cv.height)
if (rect.value) {
const rx = rect.value.x * photoScale, ry = rect.value.y * photoScale
const rw = rect.value.w * photoScale, rh = rect.value.h * photoScale
ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.35)'
ctx.fillRect(0, 0, cv.width, cv.height)
ctx.clearRect(rx, ry, rw, rh)
ctx.drawImage(photo.value, rect.value.x, rect.value.y, rect.value.w, rect.value.h, rx, ry, rw, rh)
ctx.strokeStyle = '#5b3fd4'
ctx.lineWidth = 2.5
ctx.setLineDash([8, 5])
ctx.strokeRect(rx, ry, rw, rh)
ctx.restore()
}
}
// ---- 照片合成生成(4 方案: 当前场景 + 3 变化) ----
async function generate() {
if (!photo.value || !drawing.value || !rect.value) return
generating.value = true
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' ? '室内装修' : '渲图' },
]
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))
}
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 {
generating.value = false
progress.value = 0
}
}
// ---- 风格速览(原有程序化模式) ----
async function generateQuick() {
generating.value = true
variants.value = []
// 阶段化进度(本地程序化渲染,进度为演示动画)
for (let s = 0; s < STAGES.length; s++) {
stageText.value = STAGES[s]
for (let p = 0; p <= 25; p += 5) {
@@ -91,21 +266,19 @@ async function generate() {
}
}
progress.value = 100
// 生成 4 个方案:风格×时间组合 + seed 微差
variants.value = [
{ label: `${styleName(form.ai_style)} · ${timeName(form.ai_time)}`, style: form.ai_style, time: form.ai_time, seed: form.seed },
{ label: `${styleName(form.ai_style)} · 日景`, style: form.ai_style, time: 'day', seed: form.seed + 11 },
{ label: `${styleName(form.ai_style)} · 黄昏`, style: form.ai_style, time: 'dusk', seed: form.seed + 23 },
{ label: '新中式 · 夜景', style: 'chinese', time: 'night', seed: form.seed + 37 },
{ label: `现代简约 · 日景`, style: 'modern', time: 'day', seed: form.seed },
{ label: `新中式 · 日景`, style: 'chinese', time: 'day', seed: form.seed + 11 },
{ label: `欧式 · 黄昏`, style: 'european', time: 'dusk', seed: form.seed + 23 },
{ label: '北欧 · 夜景', style: 'nordic', time: 'night', seed: form.seed + 37 },
]
pickIdx.value = 0
generating.value = false
await nextFrames()
// 渲染画廊各画布(共享渲染引擎)
const { renderAI } = await import('../components/variantRenderer')
variants.value.forEach((v, i) => {
const cv = variantCvs[i]
if (cv) renderAI(cv, drawing.value, v.style, v.time, form.scene, v.seed)
if (cv) renderAI(cv, drawing.value, v.style as any, v.time as any, form.scene, v.seed)
})
ElMessage.success('已生成 4 套效果图')
}
@@ -114,9 +287,6 @@ function nextFrames(): Promise<void> {
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
}
function styleName(s: string) { return ({ modern: '现代简约', chinese: '新中式', european: '欧式', nordic: '北欧' } as Record<string, string>)[s] ?? s }
function timeName(t: string) { return ({ day: '日景', dusk: '黄昏', night: '夜景' } as Record<string, string>)[t] ?? t }
function download(i: number) {
const cv = variantCvs[i]
if (!cv) return
@@ -134,17 +304,28 @@ onMounted(async () => {
</script>
<style scoped>
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.brand { font-size: 17px; font-weight: 700; color: #5b3fd4; }
.sub { color: #909399; font-size: 12px; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.controls { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; align-items: center; }
.tip { color: #909399; font-size: 12px; }
.drop-hint { border: 2px dashed #c8cdd6; border-radius: 12px; padding: 56px 20px; text-align: center;
color: #606266; cursor: pointer; transition: border-color .2s, background .2s; }
.drop-hint:hover { border-color: #5b3fd4; background: #faf8ff; }
.drop-hint p { margin: 10px 0 0; }
.drop-hint .sub { font-size: 12px; }
.photo-stage { position: relative; display: flex; justify-content: center; }
.photo-cv { max-width: 100%; border-radius: 10px; cursor: crosshair; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.stage-tip { position: absolute; left: 12px; top: 12px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; }
.preview { height: 58vh; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
.gallery-foot { margin-top: 8px; }
.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); }
.shot.pick { border-color: #5b3fd4; }
.shot-cv { width: 100%; height: 100%; display: block; }
.shot-cv { width: 100%; height: 100%; display: block; object-fit: cover; }
.cap { position: absolute; left: 8px; bottom: 6px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.45); padding: 2px 8px; border-radius: 10px; }
.dl { position: absolute; right: 8px; top: 8px; color: #fff; background: rgba(0,0,0,0.45);
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB