fix: reduce scale adjust background distortion
This commit is contained in:
parent
2fbcd45035
commit
7959dd6b3a
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user