fix: reduce scale adjust background distortion

This commit is contained in:
134 2026-06-21 21:31:04 +08:00
parent 2fbcd45035
commit 7959dd6b3a
2 changed files with 241 additions and 15 deletions

View File

@ -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,

View File

@ -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,