diff --git a/CHANGELOG b/CHANGELOG index c35c0e1..cfe8fcd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-10-07 10:30] FEAT: v2.48 大样图全链路优化(草图预处理工具链+结果对比)。蓝图 Tab 重构: ①旋转转正(↺/↻ 90°, 治理横拍旋转图方向塌陷——离屏 canvas 旋转绘制, ≤1536px) ②框选裁剪拆窗(拖拽框选窗区域, 裁剪后仅转换选中部分——根治一页多窗的窗数幻觉与高密度页塌陷, 窗数天然1:1) ③结果并排对比(原始草图|AI大样图 左右对照, 人工核对尺寸效率提升) ④「再生成一张」快捷按钮(应对轮间方差挑最佳) ⑤大样图模式独立结果区(不再与照片/速览共用); 端到端实测: 上传→旋转90°→框选→生成→并排对比全链路通(34s), 裁剪含两窗局部时 AI 仍正确生成两窗规范大样图(分格/开扇/尺寸全对); 移除测试依赖 pillow; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: frontend/src/views/AIRenderView.vue) - [2026-10-07 09:30] TEST+FIX: v2.47 大样图真实测试视觉审计(4 页手绘登记表)。批量实测 4 页全部生成成功(33~42s, 加固后零失败); 逐页视觉审计: p51 4/4 窗结构/开扇全对但 2 处手写数字误识(2200↔2020/1995↔1955); p49(旋转横拍+7窗高密度)窗合并丢失+尺寸多处无出处; p50 4窗生成 5 窗(1770x1200 重复)+1 处开扇反向; p52 4窗生成 5 窗(2810 重复); 提示词强化三铁律(数量/尺寸/开向)+窗编号+划掉数字以改后为准+无法辨认标?+旋转图先转正; 复测 p50 反证: 6 窗(幻觉更重)——**扩散类生图模型对对象数量精确控制是能力边界, 提示词无法根治**, 有效部分(窗编号/尺寸?标注/表格信息卡)保留; 产品对策对齐 autocad 项目审图定位: 蓝图 Tab 加审图警示(尺寸与窗数可能有误, 逐窗核对后再用于报价); 审计资产: shots/audit-{p49,p50,p51,p52}-{src,out}.png (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue) - [2026-10-07 08:00] FIX: v2.46 大样图生成失败加固(用户报告偶发失败)。诊断: 后端 API 复现两页手绘登记表均 38~43s 生成成功——失败为中转偶发超时/慢响应叠加手机原图未压缩(现场拍照 5~10MB, 测试图仅 194KB, base64 后请求体过大加剧超时); 三重加固: ①前端草图上传压缩至 ≤1536px JPEG(toDataURL 0.9, 复用照片合成模式同款口径, 治本) ②后端生图请求超时/5xx 自动重试一次(4xx 快速失败不重试), 请求段重构 _post_image() ③重试前 3s 等待; 实测第 53 页登记表原图 38s 成功(4 窗逐窗转换: 5860 大窗+700 上亮+730 标注/1680/1775/1760, 尺寸与手稿全一致, 开启扇符号保留); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue) - [2026-10-07 06:35] FEAT: v2.45 工程大样图支持上传手绘量尺登记表(用户核心场景)。blueprint 底图扩展: 上传手绘草图照片(现场量尺登记表)/2D 图纸截图二选一, 纯草图模式(无样板)不要求图纸数据; 提示词升级: 手绘登记表逐窗转换(保留各窗相对布局/分格/开启扇对角三角/手写尺寸规范誊写, 不保留表格框线, 模糊尺寸按可辨识程度誊写不编造); 前端蓝图 Tab 加草图上传(预览+移除, el-upload http-request 的 file 兼容 raw 包裹), 上传草图时脱离样板上下文(避免无关窗型特征误导); blueprint 尺寸固定 1536x1024 横版(量尺登记表整页比例, 对齐 autocad 口径); 真实实测: 用户手绘登记表(第52页 5 窗)78s 转规范大样图——分格/开启扇/尺寸(2780x2035/2810x2040/1850x2440/650/1500/700/1600/300)全保留规范誊写, 手写房间位置(27#南阳)识别保留, 表格框线去除, 标题栏/横版布局完整; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, doc/API.md) diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index fa11229..d8a9531 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -84,40 +84,74 @@ - + - -
+ +
下载 PNG @@ -301,43 +335,152 @@ async function generateCloud(mode: 'quick' | 'photo' | 'blueprint', sketch_b64?: } } -/** 工程大样图: 底图优先上传的手绘草图照片, 否则 2D 图纸画布截图; AI 重绘为黑白 CAD 大样图 */ -const sketchPhoto = ref('') +/** 工程大样图: 草图上传→旋转转正→框选裁剪(拆窗)→生成; 无草图时用 2D 图纸画布截图 */ +const sketchImg = ref(null) +const bpSketchRef = ref() +const bpRot = ref(0) // 旋转角 0/90/180/270 +const bpRect = ref<{ x: number; y: number; w: number; h: number } | null>(null) // 框选(显示坐标) +const bpCrop = ref('') // 裁剪结果 dataURL +const lastBpBase = ref('') // 本次生成底图(结果对比展示) +let bpDragStart: { x: number; y: number } | null = null +let bpRotCanvas: HTMLCanvasElement | null = null // 旋转后整图离屏(≤1536) function onSketchUpload(e: { file: unknown }) { // el-upload http-request: options.file 即原始 File(部分版本包 raw) const opt = e.file as { raw?: File } | File const file = (opt as { raw?: File }).raw ?? (opt as File) if (!(file instanceof File)) return - const reader = new FileReader() - reader.onload = () => { - // 手机原图过大(5~10MB)会导致生图中转超时/拒绝, 统一压至 ≤1536px JPEG - const img = new Image() - img.onload = () => { - const scale = Math.min(1, 1536 / Math.max(img.naturalWidth, img.naturalHeight)) - const cv = document.createElement('canvas') - cv.width = Math.max(1, Math.round(img.naturalWidth * scale)) - cv.height = Math.max(1, Math.round(img.naturalHeight * scale)) - cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height) - sketchPhoto.value = cv.toDataURL('image/jpeg', 0.9) - ElMessage.success('草图已上传') - } - img.onerror = () => ElMessage.error('图片读取失败,请重试') - img.src = String(reader.result) + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = async () => { + sketchImg.value = img + bpRot.value = 0 + bpRect.value = null + bpCrop.value = '' + URL.revokeObjectURL(url) + await nextTick() + buildBpRotCanvas() + drawBpStage() + ElMessage.success('草图已上传,可旋转转正或框选裁剪后生成') } - reader.readAsDataURL(file) + img.onerror = () => ElMessage.error('图片读取失败,请重试') + img.src = url } -async function generateBlueprint() { - // 上传的手绘草图优先(脱离样板上下文); 无上传时用 2D 图纸画布截图 - if (sketchPhoto.value) { - await generateCloud('blueprint', sketchPhoto.value.split(',')[1], true) - return +function removeSketch() { + sketchImg.value = null + bpRot.value = 0 + bpRect.value = null + bpCrop.value = '' + bpRotCanvas = null +} + +function rotBp(deg: number) { + bpRot.value = (bpRot.value + deg + 360) % 360 + bpRect.value = null + buildBpRotCanvas() + drawBpStage() +} + +function clearBpCrop() { + bpRect.value = null + bpCrop.value = '' + drawBpStage() +} + +/** 旋转后整图离屏 canvas(≤1536px):裁剪源 + 生成底图 */ +function buildBpRotCanvas() { + const img = sketchImg.value + if (!img) return + const rotated = bpRot.value % 180 !== 0 + const srcW = rotated ? img.naturalHeight : img.naturalWidth + const srcH = rotated ? img.naturalWidth : img.naturalHeight + const scale = Math.min(1, 1536 / Math.max(srcW, srcH)) + const cv = bpRotCanvas ?? document.createElement('canvas') + cv.width = Math.max(1, Math.round(srcW * scale)) + cv.height = Math.max(1, Math.round(srcH * scale)) + const ctx = cv.getContext('2d')! + ctx.save() + ctx.translate(cv.width / 2, cv.height / 2) + ctx.rotate((bpRot.value * Math.PI) / 180) + ctx.drawImage(img, (-img.naturalWidth * scale) / 2, (-img.naturalHeight * scale) / 2, + img.naturalWidth * scale, img.naturalHeight * scale) + ctx.restore() + bpRotCanvas = cv +} + +/** 草图舞台: 旋转后整图 + 框选遮罩(bpRect 为显示坐标) */ +function drawBpStage() { + const cv = bpSketchRef.value + const base = bpRotCanvas + if (!cv || !base) return + const fit = Math.min(1, Math.min(920, window.innerWidth - 140) / base.width) + cv.width = Math.round(base.width * fit) + cv.height = Math.round(base.height * fit) + const ctx = cv.getContext('2d')! + ctx.drawImage(base, 0, 0, cv.width, cv.height) + if (bpRect.value) { + const f = base.width / cv.width + const { x, y, w, h } = bpRect.value + ctx.save() + ctx.fillStyle = 'rgba(0,0,0,0.35)' + ctx.fillRect(0, 0, cv.width, cv.height) + ctx.drawImage(base, x * f, y * f, w * f, h * f, x, y, w, h) + ctx.strokeStyle = '#5b3fd4' + ctx.lineWidth = 2.5 + ctx.setLineDash([8, 5]) + ctx.strokeRect(x, y, w, h) + ctx.restore() } - await nextTick() - const dataUrl = bpCanvasRef.value?.exportImage?.() - if (!dataUrl) { ElMessage.warning('图纸截图失败,请稍后重试'); return } - await generateCloud('blueprint', dataUrl.split(',')[1]) +} + +function onBpDown(e: MouseEvent) { + const cv = bpSketchRef.value + if (!cv) return + const b = cv.getBoundingClientRect() + bpDragStart = { x: e.clientX - b.left, y: e.clientY - b.top } + bpRect.value = null +} +function onBpMove(e: MouseEvent) { + if (!bpDragStart || !bpSketchRef.value) return + const b = bpSketchRef.value.getBoundingClientRect() + const p = { x: e.clientX - b.left, y: e.clientY - b.top } + bpRect.value = { x: Math.min(bpDragStart.x, p.x), y: Math.min(bpDragStart.y, p.y), + w: Math.abs(p.x - bpDragStart.x), h: Math.abs(p.y - bpDragStart.y) } + drawBpStage() +} +function onBpUp() { + if (bpRect.value && (bpRect.value.w < 25 || bpRect.value.h < 25)) bpRect.value = null + bpDragStart = null + drawBpStage() +} + +async function generateBlueprint(regen = false) { + let base = '' + if (sketchImg.value) { + if (bpRect.value && bpRotCanvas && bpSketchRef.value) { + // 框选裁剪: 显示坐标 → 离屏图坐标 + const f = bpRotCanvas.width / bpSketchRef.value.width + const r = bpRect.value + const c = document.createElement('canvas') + c.width = Math.max(1, Math.round(r.w * f)) + c.height = Math.max(1, Math.round(r.h * f)) + c.getContext('2d')!.drawImage(bpRotCanvas, r.x * f, r.y * f, r.w * f, r.h * f, 0, 0, c.width, c.height) + base = c.toDataURL('image/jpeg', 0.9) + bpCrop.value = base + } else { + buildBpRotCanvas() + base = bpRotCanvas!.toDataURL('image/jpeg', 0.9) + } + } else { + await nextTick() + const dataUrl = bpCanvasRef.value?.exportImage?.() + if (!dataUrl) { ElMessage.warning('图纸截图失败,请稍后重试'); return } + base = dataUrl + } + lastBpBase.value = base + void regen + await generateCloud('blueprint', base.split(',')[1], !!sketchImg.value) } /** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */ @@ -438,8 +581,12 @@ onMounted(async () => { background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; } .preview { height: 58vh; } .bp-stage { height: 58vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; } -.bp-photo { display: flex; align-items: center; justify-content: center; background: #f5f6f8; } -.sketch-img { max-width: 100%; max-height: 100%; object-fit: contain; } +.bp-edit { position: relative; display: flex; justify-content: center; align-items: flex-start; background: #f5f6f8; } +.bp-cv { max-width: 100%; max-height: 100%; cursor: crosshair; } +.bp-compare { display: flex; gap: 12px; align-items: stretch; } +.bp-compare .cmp { flex: 1; min-width: 0; border: 1px solid #e4e7ed; border-radius: 8px; overflow: hidden; background: #fafbfc; } +.bp-compare .cap { padding: 6px 10px; font-size: 12px; font-weight: 600; color: #606266; background: #f2f3f5; } +.cmp-img { display: block; width: 100%; height: auto; } .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; } diff --git a/shots/bp-single-out.png b/shots/bp-single-out.png new file mode 100644 index 0000000..502d91f Binary files /dev/null and b/shots/bp-single-out.png differ diff --git a/shots/bp-single-src.jpg b/shots/bp-single-src.jpg new file mode 100644 index 0000000..3c8d1f9 Binary files /dev/null and b/shots/bp-single-src.jpg differ diff --git a/shots/opt-1-upload.png b/shots/opt-1-upload.png new file mode 100644 index 0000000..71833d5 Binary files /dev/null and b/shots/opt-1-upload.png differ diff --git a/shots/opt-2-rot.png b/shots/opt-2-rot.png new file mode 100644 index 0000000..2b2ad2e Binary files /dev/null and b/shots/opt-2-rot.png differ diff --git a/shots/opt-3-crop.png b/shots/opt-3-crop.png new file mode 100644 index 0000000..a0cd2ea Binary files /dev/null and b/shots/opt-3-crop.png differ diff --git a/shots/opt-4-result.png b/shots/opt-4-result.png new file mode 100644 index 0000000..62898c0 Binary files /dev/null and b/shots/opt-4-result.png differ