From 5262b085c2d31cee59af6ded7ea7e5b2debfdd00 Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 5 Jul 2026 12:26:47 +0800 Subject: [PATCH] fix: suppress protected-side scale draft artifacts --- app/api/canvas/local-edit/route.ts | 170 +++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 11 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 32fd2bfad..153aedb69 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -1716,9 +1716,38 @@ function filterScaleAdjustStructureComponents(params: { protectedPersonOverlap > 0 && touchesProtectedPersonSide; const allowSurfaceFill = !protectedPersonSide; + const isProtectedPersonSafetySidePixel = ( + pixelX: number, + pixelY: number + ) => { + if (protectedPersonSide === "right") { + return ( + (pixelX >= width * 0.7 && pixelY >= height * 0.38) || + (pixelX >= width * 0.62 && pixelY >= height * 0.56) + ); + } + if (protectedPersonSide === "left") { + return ( + (pixelX <= width * 0.3 && pixelY >= height * 0.38) || + (pixelX <= width * 0.38 && pixelY >= height * 0.56) + ); + } + if (protectedPersonSide === "auto") { + return ( + (touchesRight && + pixelX >= width * 0.72 && + pixelY >= height * 0.42) || + (touchesLeft && + pixelX <= width * 0.28 && + pixelY >= height * 0.42) + ); + } + return false; + }; const isProtectedPersonPixel = (pixelX: number, pixelY: number) => { const pixelIndex = pixelY * width + pixelX; if (protectedPersonMask?.[pixelIndex]) return true; + if (isProtectedPersonSafetySidePixel(pixelX, pixelY)) return true; if (!shouldClipProtectedPersonSide) return false; if (protectedPersonSide === "right") return pixelX >= width * 0.78; if (protectedPersonSide === "left") return pixelX <= width * 0.22; @@ -2570,6 +2599,92 @@ async function buildScaleAdjustRmbgStructureLockedComposite(params: { } } +function validateScaleAdjustProtectedPersonMask(params: { + box: { width: number; height: number }; + mask: Uint8Array | null; +}) { + const { box, mask } = params; + if (!mask) return null; + const maskBox = getScaleAdjustMaskBoundingBox({ + height: box.height, + mask, + width: box.width, + }); + if (!maskBox) return null; + + const maskRatio = maskBox.count / Math.max(1, box.width * box.height); + const fullSideStripe = + maskBox.height >= box.height * 0.92 && maskBox.width >= box.width * 0.28; + if (maskRatio > 0.26 || fullSideStripe) { + console.warn( + "[Canvas Local Edit] Scale adjust protected person mask rejected as broad background:", + { + heightRatio: Number((maskBox.height / Math.max(1, box.height)).toFixed(3)), + maskRatio: Number(maskRatio.toFixed(3)), + widthRatio: Number((maskBox.width / Math.max(1, box.width)).toFixed(3)), + } + ); + return null; + } + + return mask; +} + +function suppressScaleAdjustProtectedFurnitureDarkAlpha(params: { + alphaMask: Buffer; + bbox: { left: number; top: number; width: number; height: number }; + protectedPersonSide: ScaleAdjustProtectedPersonSide | null; + rawRgb: Buffer; + sourceBox: { width: number; height: number }; +}) { + const { alphaMask, bbox, protectedPersonSide, rawRgb, sourceBox } = params; + if (!protectedPersonSide) return alphaMask; + + const output = Buffer.from(alphaMask); + const isProtectedSideArea = (sourceX: number, sourceY: number) => { + if (protectedPersonSide === "right") { + return ( + (sourceX >= sourceBox.width * 0.54 && sourceY >= sourceBox.height * 0.25) || + (sourceX >= sourceBox.width * 0.68 && sourceY >= sourceBox.height * 0.12) + ); + } + if (protectedPersonSide === "left") { + return ( + (sourceX <= sourceBox.width * 0.46 && sourceY >= sourceBox.height * 0.25) || + (sourceX <= sourceBox.width * 0.32 && sourceY >= sourceBox.height * 0.12) + ); + } + return sourceY >= sourceBox.height * 0.3; + }; + + for (let localY = 0; localY < bbox.height; localY += 1) { + for (let localX = 0; localX < bbox.width; localX += 1) { + const alphaIndex = localY * bbox.width + localX; + const alpha = output[alphaIndex] || 0; + if (alpha === 0) continue; + + const colorIndex = alphaIndex * 3; + const r = rawRgb[colorIndex] || 0; + const g = rawRgb[colorIndex + 1] || 0; + const b = rawRgb[colorIndex + 2] || 0; + const { luma, saturation } = getScaleAdjustPixelStats(r, g, b); + const veryDark = luma < 62; + const flatDark = luma < 95 && saturation < 0.25; + if (!veryDark && !flatDark) continue; + + const sourceX = bbox.left + localX; + const sourceY = bbox.top + localY; + if (isProtectedSideArea(sourceX, sourceY)) { + output[alphaIndex] = 0; + } else { + output[alphaIndex] = Math.round(alpha * 0.12); + } + } + } + + return output; +} + async function buildScaleAdjustStructureLockedComposite(params: { includePersonPixels?: boolean; placement: ReturnType; @@ -2619,11 +2734,14 @@ async function buildScaleAdjustStructureLockedComposite(params: { }); const protectedPersonMask = preferPixelStructure && protectedPersonSide - ? buildScaleAdjustProtectedPersonSeedMask({ + ? validateScaleAdjustProtectedPersonMask({ box: placement.sourceBox, - raw, - side: protectedPersonSide, - width: sourceWidth, + mask: buildScaleAdjustProtectedPersonSeedMask({ + box: placement.sourceBox, + raw, + side: protectedPersonSide, + width: sourceWidth, + }), }) : null; const mask = filterScaleAdjustStructureComponents({ @@ -2640,10 +2758,23 @@ async function buildScaleAdjustStructureLockedComposite(params: { if (!structureBbox) return null; const maskRatio = structureBbox.count / Math.max(1, placement.sourceBox.width * placement.sourceBox.height); if (maskRatio < SCALE_ADJUST_STRUCTURE_LOCK_MIN_MASK_RATIO) return null; - const subjectMask = solidifyScaleAdjustStructureMask({ - box: placement.sourceBox, - mask, - }); + const subjectMask = protectedPersonSide + ? dilateScaleAdjustStructureMask({ + height: placement.sourceBox.height, + mask, + radius: Math.max( + 1, + Math.round( + Math.min(placement.sourceBox.width, placement.sourceBox.height) * + 0.004 + ) + ), + width: placement.sourceBox.width, + }) + : solidifyScaleAdjustStructureMask({ + box: placement.sourceBox, + mask, + }); const bbox = getScaleAdjustMaskBoundingBox({ height: placement.sourceBox.height, mask: subjectMask, @@ -2699,7 +2830,23 @@ async function buildScaleAdjustStructureLockedComposite(params: { .removeAlpha() .png({ compressionLevel: 6 }) .toBuffer(); - const subjectAlpha = await sharp(sourceSubjectMask, { + const sourceSubjectRaw = await sharp(sourceBuffer, { failOn: "none" }) + .extract(sourceSubjectBox) + .removeAlpha() + .toColorspace("srgb") + .raw() + .toBuffer(); + const alphaMaskForSubject = + preferPixelStructure && protectedPersonSide + ? suppressScaleAdjustProtectedFurnitureDarkAlpha({ + alphaMask: Buffer.from(sourceSubjectMask), + bbox, + protectedPersonSide, + rawRgb: sourceSubjectRaw, + sourceBox: placement.sourceBox, + }) + : sourceSubjectMask; + const subjectAlpha = await sharp(alphaMaskForSubject, { raw: { channels: 1, height: bbox.height, width: bbox.width }, }) .blur(Math.max(0.7, Math.min(bbox.width, bbox.height) * 0.006)) @@ -4156,11 +4303,12 @@ function buildScaleAdjustFullFramePatchPrompt( "Input image 1 is a full-photo target draft. The selected furniture/product has already been moved and scaled with source-photo pixels to the required final footprint. Treat the size, position, silhouette, product structure, material, perspective, wheels, rods, panels, hooks, weave, and visible details in input image 1 as the hard target.", "Input image 2 is the original clean full photo. Use it only for clean background texture, lighting, perspective, non-target people, non-target objects, and untouched areas. Do not restore the selected furniture/product to its old size or old position from input image 2.", "AI work is limited to natural background completion, boundary blending, occlusion, contact shadows, and removing rough draft artifacts. Do not repaint, redesign, replace, stretch, duplicate, or distort the selected furniture/product interior.", + "The target draft can contain translucent pasted pixels, faint old-footprint washes, clipped dark clothing fragments, or partial mask silhouettes. These are draft artifacts only. Do not copy any dark blob, black hole, ghost fragment, transparent residue, or pasted silhouette as final photo content.", "If the target footprint intersects a non-target person, preserve that person as the foreground occluder exactly as in the original photo: do not move, cover, deform, redraw, or change their face, body, clothes, hands, legs, feet, or pose. Blend the furniture/product behind the person naturally where needed.", "Erase the old larger furniture/product footprint outside the final target footprint and rebuild newly visible wall, curtain, floor, rug, shadow, and background naturally. Do not leave ghost copies, old-size remnants, rectangular haze, pale wash blocks, patch edges, black holes, dirty speckles, or visible seams.", "Keep all non-target content stable: person identity and anatomy, sofa, window, curtain, cabinet, lamp, wall art, floor, rug, lighting, textural grain, and global color must remain consistent.", "Do not output boxes, masks, labels, coordinate marks, UI marks, arrows, guide overlays, crop borders, or any annotation artifacts.", - "中文硬约束:输入图1是完整画面的目标草稿,目标家具/商品已经在最终大小和最终位置;输入图2只是原始干净画面参考,绝对不能恢复旧尺寸。只做背景补全、遮挡融合、阴影和边缘清理。非目标人物必须保持原样,不能被目标家具盖住、变形、换脸、改姿势或改腿脚。最终必须是一张完整自然照片,不能有矩形雾块、贴片边、黑洞、残影、标注框。", + "中文硬约束:输入图1是完整画面的目标草稿,目标家具/商品已经在最终大小和最终位置;输入图2只是原始干净画面参考,绝对不能恢复旧尺寸。只做背景补全、遮挡融合、阴影和边缘清理。草稿里的半透明贴片、暗色残块、黑洞、旧尺寸残影都只是错误遮罩提示,最终必须删除。非目标人物必须保持原样,不能被目标家具盖住、变形、换脸、改姿势或改腿脚。最终必须是一张完整自然照片,不能有矩形雾块、贴片边、黑洞、残影、标注框。", "", "Region instructions:", regionLines, @@ -4786,7 +4934,7 @@ export async function POST(request: NextRequest) { scaleAdjustPromptStrategy: isScaleAdjustOperation ? scaleAdjustPatchReference ? useScaleAdjustFullFrameStructurePatch - ? "full-frame-structure-locked-draft-v3-no-protected-fill-one-shot" + ? "full-frame-structure-locked-draft-v4-protected-side-dark-suppressed-one-shot" : scaleAdjustPatchReference.structureLockedDraft ? "ai-patch-workspace-structure-locked-draft-v87-subject-alpha-clipped-one-shot" : "ai-patch-workspace-draft-v75-soft-wipe-erase-ghost-one-shot"