From 7959dd6b3a6fe086c32a2e5ea456895898636c73 Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 21 Jun 2026 21:31:04 +0800 Subject: [PATCH] fix: reduce scale adjust background distortion --- app/api/canvas/local-edit/route.ts | 12 +- lib/canvas/generate-image.ts | 244 +++++++++++++++++++++++++++-- 2 files changed, 241 insertions(+), 15 deletions(-) diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index bfcc61267..ffa842b27 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -730,6 +730,8 @@ function buildScaleAdjustPrompt( "5. 如果主体靠近画布边界导致目标尺寸空间不足,请用能完整放入画面的最大统一倍率整体缩放;绝不能为了塞进画面而只压缩宽度或只拉伸高度。", "6. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。", "6.1 比例调整后的主体边缘必须和周围环境自然融合,选区边界不能被肉眼看出;不得出现一整块矩形区域清晰度、颜色、光照、纹理或透视突然变化。", + "6.2 框选区域通常会包含主体周围的背景留白,背景留白不是目标主体。请锁定这些非主体背景的窗框、文字、地毯纹理、墙面、桌面边缘和透视线,只重建主体缩放后被遮挡或露出的极小必要区域;不要把框选矩形当作整块背景重新生成。", + "6.3 如果框选范围内没有清晰、完整、可独立缩放的主体物体,请保持该区域背景结构稳定,不要为了制造变化而重绘窗框、文字、墙面、地面或其它非主体元素。", "7. 未受比例调整影响的其它画面区域必须保持原图不变,尤其是框选区域外的文字、Logo、标题、尺寸标注、装饰条和页面排版不得被清理、改写、模糊或重绘。", "8. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。", "9. 输出前自检:如果目标主体大小与原图相比没有明显变化,或者主体宽高比发生非等比变形,必须重新生成;禁止返回看起来和原图相同或被拉伸的结果。", @@ -928,7 +930,7 @@ export async function POST(request: NextRequest) { model: resolvedModel || undefined, negativePrompt: isScaleAdjustOperation - ? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、主体未缩放、整块框选区域缩放" + ? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、整张图缩放、全图放大、全图裁切、全图平移、整体重绘、删除文字、清理文字、改写文字、文字模糊、Logo消失、主体拉伸、主体压扁、主体变窄、主体变高但变窄、横向挤压、纵向拉伸、透视变形、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、背景错位、窗框错位、文字错位、地毯纹理错位、非主体背景重绘、主体未缩放、整块框选区域缩放" : "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、动物截断、缺头、缺尾巴、缺四肢、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", prompt, promptExtend: false, @@ -947,6 +949,14 @@ export async function POST(request: NextRequest) { regions, scalePercent as ScaleAdjustPercent ), + expandRegions: false, + maskMode: "diff", + featherMinPx: 4, + featherMaxPx: 18, + featherRatio: 0.006, + diffLowThreshold: 18, + diffHighThreshold: 54, + diffCompareBlurPx: 2.4, } : { sourceImageUrl, diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 9e26919bc..90a4fa4e5 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -40,6 +40,14 @@ export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeConfig { sourceImageUrl: string; regions: CanvasLocalEditCompositeRegion[]; + expandRegions?: boolean; + maskMode?: "region" | "diff"; + featherMinPx?: number; + featherMaxPx?: number; + featherRatio?: number; + diffLowThreshold?: number; + diffHighThreshold?: number; + diffCompareBlurPx?: number; } export interface CanvasScaleAdjustVerificationConfig { @@ -114,6 +122,9 @@ const LOCAL_EDIT_COMPOSITE_MIN_PADDING_RATIO = 0.012; const LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO = 0.06; const LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX = 18; const LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX = 72; +const LOCAL_EDIT_COMPOSITE_FEATHER_RATIO = 0.018; +const LOCAL_EDIT_COMPOSITE_DIFF_LOW_THRESHOLD = 10; +const LOCAL_EDIT_COMPOSITE_DIFF_HIGH_THRESHOLD = 34; const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14; const SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD = 18; const SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO = 0.28; @@ -225,6 +236,15 @@ function expandLocalEditCompositeRegion(region: CanvasLocalEditCompositeRegion) }; } +function normalizeOptionalNumber( + value: unknown, + options: { min: number; max: number } +) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return undefined; + return Math.max(options.min, Math.min(options.max, numeric)); +} + function normalizeLocalEditCompositeConfig( value: CanvasGenerateImageRequest["localEditComposite"] ): CanvasLocalEditCompositeConfig | null { @@ -232,6 +252,8 @@ function normalizeLocalEditCompositeConfig( const sourceImageUrl = String(value.sourceImageUrl || "").trim(); if (!/^https?:\/\//i.test(sourceImageUrl)) return null; if (!Array.isArray(value.regions) || value.regions.length === 0) return null; + const expandRegions = value.expandRegions !== false; + const maskMode = value.maskMode === "diff" ? "diff" : "region"; const regions = value.regions .map((region) => { @@ -246,10 +268,41 @@ function normalizeLocalEditCompositeConfig( height: Math.min(height, Math.max(0, 1 - y)), }; }) - .map(expandLocalEditCompositeRegion) + .map((region) => expandRegions ? expandLocalEditCompositeRegion(region) : region) .filter((region) => region.width > 0 && region.height > 0); - return regions.length > 0 ? { sourceImageUrl, regions } : null; + return regions.length > 0 + ? { + sourceImageUrl, + regions, + expandRegions, + maskMode, + diffHighThreshold: normalizeOptionalNumber(value.diffHighThreshold, { + max: 128, + min: 1, + }), + diffLowThreshold: normalizeOptionalNumber(value.diffLowThreshold, { + max: 127, + min: 0, + }), + diffCompareBlurPx: normalizeOptionalNumber(value.diffCompareBlurPx, { + max: 12, + min: 0, + }), + featherMaxPx: normalizeOptionalNumber(value.featherMaxPx, { + max: 160, + min: 0, + }), + featherMinPx: normalizeOptionalNumber(value.featherMinPx, { + max: 80, + min: 0, + }), + featherRatio: normalizeOptionalNumber(value.featherRatio, { + max: 0.08, + min: 0, + }), + } + : null; } function normalizeCanvasRegion(region: unknown): CanvasLocalEditCompositeRegion | null { @@ -672,15 +725,19 @@ function buildLocalEditCompositeMaskSvg(params: { width: number; height: number; regions: CanvasLocalEditCompositeRegion[]; + featherPx?: number; }) { const { width, height, regions } = params; - const blur = Math.max( - LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX, - Math.min( - LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX, - Math.round(Math.min(width, height) * 0.018) - ) - ); + const blur = + typeof params.featherPx === "number" + ? Math.max(0, Math.round(params.featherPx)) + : Math.max( + LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX, + Math.min( + LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX, + Math.round(Math.min(width, height) * LOCAL_EDIT_COMPOSITE_FEATHER_RATIO) + ) + ); const rectEntries = regions .map((region) => { const left = Math.max(0, Math.round(region.x * width)); @@ -739,6 +796,134 @@ function buildLocalEditCompositeMaskSvg(params: { `); } +function resolveLocalEditCompositeFeatherPx(params: { + composite: CanvasLocalEditCompositeConfig; + width: number; + height: number; +}) { + const { composite, width, height } = params; + const minPx = + typeof composite.featherMinPx === "number" + ? composite.featherMinPx + : LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX; + const maxPx = + typeof composite.featherMaxPx === "number" + ? Math.max(minPx, composite.featherMaxPx) + : LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX; + const ratio = + typeof composite.featherRatio === "number" + ? composite.featherRatio + : LOCAL_EDIT_COMPOSITE_FEATHER_RATIO; + + return Math.max( + minPx, + Math.min(maxPx, Math.round(Math.min(width, height) * ratio)) + ); +} + +function getLocalEditCompositeRectEntries(params: { + width: number; + height: number; + regions: CanvasLocalEditCompositeRegion[]; +}) { + const { width, height, regions } = params; + return regions + .map((region) => { + const left = Math.max(0, Math.round(region.x * width)); + const top = Math.max(0, Math.round(region.y * height)); + const rectWidth = Math.min( + width - left, + Math.max(1, Math.round(region.width * width)) + ); + const rectHeight = Math.min( + height - top, + Math.max(1, Math.round(region.height * height)) + ); + return { left, top, rectHeight, rectWidth }; + }) + .filter(({ rectHeight, rectWidth }) => rectWidth > 0 && rectHeight > 0); +} + +function getSmoothDiffAlpha(diff: number, lowThreshold: number, highThreshold: number) { + if (diff <= lowThreshold) return 0; + if (diff >= highThreshold) return 255; + const progress = (diff - lowThreshold) / Math.max(1, highThreshold - lowThreshold); + return Math.round(255 * progress * progress * (3 - 2 * progress)); +} + +async function buildLocalEditDiffCompositeMask(params: { + composite: CanvasLocalEditCompositeConfig; + generatedBuffer: Buffer; + sourceBuffer: Buffer; + width: number; + height: number; +}) { + const { composite, generatedBuffer, height, sourceBuffer, width } = params; + const compareBlurPx = + typeof composite.diffCompareBlurPx === "number" + ? composite.diffCompareBlurPx + : 0; + const buildDiffRaw = (buffer: Buffer) => { + const pipeline = sharp(buffer, { failOn: "none" }) + .resize(width, height, { fit: "cover", position: "center" }) + .removeAlpha() + .toColorspace("srgb"); + return (compareBlurPx >= 0.3 ? pipeline.blur(compareBlurPx) : pipeline) + .raw() + .toBuffer(); + }; + const [sourceRaw, generatedRaw] = await Promise.all([ + buildDiffRaw(sourceBuffer), + buildDiffRaw(generatedBuffer), + ]); + const lowThreshold = + typeof composite.diffLowThreshold === "number" + ? composite.diffLowThreshold + : LOCAL_EDIT_COMPOSITE_DIFF_LOW_THRESHOLD; + const highThreshold = Math.max( + lowThreshold + 1, + typeof composite.diffHighThreshold === "number" + ? composite.diffHighThreshold + : LOCAL_EDIT_COMPOSITE_DIFF_HIGH_THRESHOLD + ); + const alpha = Buffer.alloc(width * height, 0); + const rectEntries = getLocalEditCompositeRectEntries({ + height, + regions: composite.regions, + width, + }); + + for (const { left, top, rectHeight, rectWidth } of rectEntries) { + const right = Math.min(width, left + rectWidth); + const bottom = Math.min(height, top + rectHeight); + for (let y = top; y < bottom; y += 1) { + for (let x = left; x < right; x += 1) { + const pixelIndex = y * width + x; + const rawIndex = pixelIndex * 3; + const diff = + Math.abs(generatedRaw[rawIndex] - sourceRaw[rawIndex]) * 0.299 + + Math.abs(generatedRaw[rawIndex + 1] - sourceRaw[rawIndex + 1]) * 0.587 + + Math.abs(generatedRaw[rawIndex + 2] - sourceRaw[rawIndex + 2]) * 0.114; + const nextAlpha = getSmoothDiffAlpha(diff, lowThreshold, highThreshold); + if (nextAlpha > alpha[pixelIndex]) { + alpha[pixelIndex] = nextAlpha; + } + } + } + } + + const featherPx = resolveLocalEditCompositeFeatherPx({ + composite, + height, + width, + }); + + return sharp(alpha, { raw: { channels: 1, height, width } }) + .blur(Math.max(0.3, featherPx)) + .png({ compressionLevel: 6 }) + .toBuffer(); +} + async function applyLocalEditCompositeToImages(params: { images: ParsedImage[]; composite: CanvasLocalEditCompositeConfig | null; @@ -761,9 +946,9 @@ async function applyLocalEditCompositeToImages(params: { throw new Error("局部编辑结果合成失败:无法识别原图尺寸"); } - const maskSvg = buildLocalEditCompositeMaskSvg({ + const featherPx = resolveLocalEditCompositeFeatherPx({ + composite, height: sourceHeight, - regions: composite.regions, width: sourceWidth, }); @@ -771,13 +956,41 @@ async function applyLocalEditCompositeToImages(params: { for (const [index, image] of images.entries()) { const generatedUrl = image.originalUrl || image.thumbnailUrl; const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl); - const overlayBuffer = await sharp(generatedBuffer, { failOn: "none" }) + const resizedGeneratedBuffer = await sharp(generatedBuffer, { failOn: "none" }) .rotate() .resize(sourceWidth, sourceHeight, { fit: "cover", position: "center" }) - .ensureAlpha() - .composite([{ input: maskSvg, blend: "dest-in" }]) .png({ compressionLevel: 6 }) .toBuffer(); + const overlayBuffer = + composite.maskMode === "diff" + ? await sharp(resizedGeneratedBuffer, { failOn: "none" }) + .removeAlpha() + .joinChannel( + await buildLocalEditDiffCompositeMask({ + composite, + generatedBuffer: resizedGeneratedBuffer, + height: sourceHeight, + sourceBuffer, + width: sourceWidth, + }) + ) + .png({ compressionLevel: 6 }) + .toBuffer() + : await sharp(resizedGeneratedBuffer, { failOn: "none" }) + .ensureAlpha() + .composite([ + { + input: buildLocalEditCompositeMaskSvg({ + featherPx, + height: sourceHeight, + regions: composite.regions, + width: sourceWidth, + }), + blend: "dest-in", + }, + ]) + .png({ compressionLevel: 6 }) + .toBuffer(); const mergedBuffer = await sharp(sourceBuffer, { failOn: "none" }) .ensureAlpha() .composite([{ input: overlayBuffer, left: 0, top: 0 }]) @@ -806,7 +1019,10 @@ async function applyLocalEditCompositeToImages(params: { console.info("[Canvas Generate Image] Local edit composite applied:", { debugLabel, + expandRegions: composite.expandRegions !== false, + featherPx, imageCount: mergedImages.length, + maskMode: composite.maskMode || "region", regionCount: composite.regions.length, sourceHeight, sourceWidth,