fix: guide scale adjust with visual target layout

This commit is contained in:
134 2026-07-02 14:48:51 +08:00
parent 08b439b7f6
commit 2241e0f27e
2 changed files with 155 additions and 40 deletions

View File

@ -100,6 +100,7 @@ const MAX_REFERENCE_IMAGES_PER_REGION = 3;
const MAX_SCALE_ADJUST_CROP_REFERENCES = 4;
const SCALE_ADJUST_CROP_PADDING_RATIO = 0.18;
const SCALE_ADJUST_CROP_MAX_EDGE = 1536;
const SCALE_ADJUST_LAYOUT_GUIDE_BACKGROUND = "#f5f7fb";
const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust";
type LocalEditOperation = "local-edit" | typeof SCALE_ADJUST_OPERATION;
@ -761,6 +762,123 @@ async function createScaleAdjustCropReferences(params: {
return references;
}
function getScaleAdjustVisualGuidePlacement(params: {
height: number;
region: NormalizedLocalEditRegion;
scalePercent?: ScaleAdjustPercent | null;
width: number;
}) {
const { height, region, scalePercent, width } = params;
const source = region.pixelRect;
const targetRect = getScaleAdjustTargetRect(region, scalePercent);
const target = region.targetPixelRect || rectToPixels(targetRect, width, height);
const sourceLeft = clamp(source.left, 0, Math.max(0, width - 1));
const sourceTop = clamp(source.top, 0, Math.max(0, height - 1));
const sourceRight = clamp(
source.left + source.width,
sourceLeft + 1,
width
);
const sourceBottom = clamp(
source.top + source.height,
sourceTop + 1,
height
);
const sourceWidth = Math.max(1, Math.round(sourceRight - sourceLeft));
const sourceHeight = Math.max(1, Math.round(sourceBottom - sourceTop));
const widthScale = target.width / Math.max(1, sourceWidth);
const heightScale = target.height / Math.max(1, sourceHeight);
const uniformScale = Math.max(
0.05,
Math.min(
Number.isFinite(widthScale) && widthScale > 0 ? widthScale : 1,
Number.isFinite(heightScale) && heightScale > 0 ? heightScale : 1
)
);
const placedWidth = clamp(
Math.round(sourceWidth * uniformScale),
1,
Math.max(1, width)
);
const placedHeight = clamp(
Math.round(sourceHeight * uniformScale),
1,
Math.max(1, height)
);
const targetCenterX = target.left + target.width / 2;
const targetCenterY = target.top + target.height / 2;
const placedLeft = clamp(
Math.round(targetCenterX - placedWidth / 2),
0,
Math.max(0, width - placedWidth)
);
const placedTop = clamp(
Math.round(targetCenterY - placedHeight / 2),
0,
Math.max(0, height - placedHeight)
);
return {
placedHeight: Math.max(1, Math.round(placedHeight)),
placedLeft: Math.max(0, Math.round(placedLeft)),
placedTop: Math.max(0, Math.round(placedTop)),
placedWidth: Math.max(1, Math.round(placedWidth)),
sourceBox: {
left: Math.round(sourceLeft),
top: Math.round(sourceTop),
width: sourceWidth,
height: sourceHeight,
},
targetBox: target,
uniformScale,
};
}
async function buildScaleAdjustVisualLayoutGuideBuffer(params: {
regions: NormalizedLocalEditRegion[];
scalePercent?: ScaleAdjustPercent | null;
sourceBuffer: Buffer;
sourceHeight: number;
sourceWidth: number;
}) {
const { regions, scalePercent, sourceBuffer, sourceHeight, sourceWidth } =
params;
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
for (const region of regions) {
const placement = getScaleAdjustVisualGuidePlacement({
height: sourceHeight,
region,
scalePercent,
width: sourceWidth,
});
const cropBuffer = await sharp(sourceBuffer, { failOn: "none" })
.extract(placement.sourceBox)
.resize(placement.placedWidth, placement.placedHeight, {
fit: "fill",
})
.png({ compressionLevel: 6 })
.toBuffer();
composites.push({
input: cropBuffer,
left: placement.placedLeft,
top: placement.placedTop,
});
}
return sharp({
create: {
background: SCALE_ADJUST_LAYOUT_GUIDE_BACKGROUND,
channels: 3,
height: sourceHeight,
width: sourceWidth,
},
})
.composite(composites)
.png({ compressionLevel: 6 })
.toBuffer();
}
function wrapText(text: string, maxChars: number, maxLines = 3) {
const compact = text.replace(/\s+/g, " ").trim();
if (!compact) return [];
@ -1067,7 +1185,7 @@ function buildScaleAdjustPrompt(
cropIndex >= 0
? `;输入图片${cropIndex + 3}是该区域的干净局部裁切参考图,只用于识别目标主体的身份、轮廓与原始比例`
: "";
return `区域 ${region.index}${cropNote}:请在输入图片1中找到源框范围内最主要、可独立调整的商品/物体主体。${sourcePixelLine};${targetPixelLine};${scaleLine}${targetPositionNote}源框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。目标框只是主体调整后的视觉占位参考,不要求主体填满矩形,更不能改变主体自身宽高比、透视或形体。请像真实图片编辑一样先保留锁定的原始画布,再只把这个主体整体等比变大/变小/移动到目标视觉占位;主体缩放后露出或遮挡的极小背景区域需要自然重建。严禁把输入图片1整张图、输入图片2坐标图、任意裁切图、源框矩形区域或目标框矩形区域当成图片块整体缩放、平移、复制、裁切或贴回。如果源框中只覆盖到主体的一部分,请根据局部裁切参考和可见结构推断该主体的完整可见轮廓,只调整主体,不调整矩形框内背景。`;
return `区域 ${region.index}${cropNote}:请在输入图片1中找到源框范围内最主要、可独立调整的商品/物体主体。${sourcePixelLine};${targetPixelLine};${scaleLine}${targetPositionNote}源框只是“寻找主体”的范围,不是最终边界,也不是要缩放或贴回的矩形补丁。目标框只是主体调整后的视觉占位参考,不要求主体填满矩形,更不能改变主体自身宽高比、透视或形体。请像真实图片编辑一样先保留锁定的原始画布,再只把这个主体整体等比变大/变小/移动到目标视觉占位;主体缩放后露出或遮挡的极小背景区域需要自然重建。严禁把输入图片1整张图、输入图片2目标布局参考图、任意裁切图、源框矩形区域或目标框矩形区域当成图片块整体缩放、平移、复制、裁切或贴回。如果源框中只覆盖到主体的一部分,请根据局部裁切参考和可见结构推断该主体的完整可见轮廓,只调整主体,不调整矩形框内背景。`;
})
.join("\n");
const cropLines = cropReferences
@ -1081,17 +1199,17 @@ function buildScaleAdjustPrompt(
"这是一项局部主体比例 inpaint/edit 任务,不是整图缩放、整图裁切或重新构图任务。",
sourceSizeLine,
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。除目标主体尺寸变化与必要背景补全外,输入图片1中的文字、Logo、装饰、排版、背景、光线、构图边界和所有未选区域必须保持不变。",
"输入图片2是一张与输入图片1尺寸完全相同的灰度坐标指令图,不是照片,也不是输出底图。它只用细线源框和目标框说明坐标关系;不得学习、复刻或输出其中的线框、灰底或 UI 标记。",
"输入图片2是一张与输入图片1尺寸完全相同的中性背景目标布局参考图,不是照片,也不是输出底图。它把源框里的目标主体粗略缩放并摆放到用户希望的最终位置和大小;请只读取其中主体的最终视觉占位,不得学习、复刻或输出中性背景、矩形裁切边缘或参考图痕迹。",
cropLines
? `输入图片3及之后是框选区域的干净局部裁切参考图,作用是帮助你确认“要缩放的是哪个主体”。这些裁切图不是输出底图,不得被整体放大、贴回或覆盖到原图上。\n${cropLines}`
: "",
"输入图片2中的源框表示输入图片1里目标主体当前大致位置;它只用于定位主体,不限制调整后的空间。目标框表示用户希望主体调整后的视觉占位和中心位置。最终画面不得出现这些线框或灰底;主体也不需要填满目标矩形,必须保持主体自身原始宽高比和透视形体。",
"输入图片2中的目标布局是强约束:它展示目标主体调整后的大致视觉占位和中心位置。最终输出必须回到输入图片1的真实照片环境中完成自然编辑,不得直接粘贴 input2 的矩形图块;主体不需要填满目标矩形,必须保持主体自身原始宽高比和透视形体。",
"如果系统或模型只能读取一张参考图,必须优先以输入图片1为输出基底,不得清理、改写或删除输入图片1中框选区域外的任何文字和图形。",
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片、蒙版边界,也不是放大后的空间上限,更不是要整体缩放的矩形图层。",
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度;严禁整张图整体缩放、裁切、平移、重新排版或重新生成。",
"Highest priority rule: do NOT zoom, crop, resize, reframe, pan, or regenerate the full canvas. Treat image 1 as a locked background plate; edit only the selected subject and the smallest necessary background around its old and new footprint.",
"请不要生成任何彩色框、矩形边框、箭头、连线、角点、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
"请同时使用下方每个区域的源框/目标框像素坐标和输入图片2的坐标图来理解目标占位。坐标只用于指导主体位置和大小,不是要求把某个矩形图块缩放后贴回。",
"请同时使用下方每个区域的源框/目标框像素坐标和输入图片2的目标布局参考来理解目标占位。坐标和参考图只用于指导主体位置和大小,不是要求把某个矩形图块缩放后贴回。",
"",
"任务:先在输入图片1中识别源框范围内最主要、可独立调整的主体物体,再只对这个“主体物体”做局部比例调整。调整后的主体大小和位置必须接近目标框表示的视觉占位;如果输出看起来和原图一样,就是失败。",
regionLines,
@ -1170,11 +1288,11 @@ function buildScaleAdjustCoordinatePrompt(
"中文硬约束:只调整 OLD 框中的主要家具主体。TARGET 框是最终占位;如果是缩小,OLD 框里超出 TARGET 的旧家具必须消失并补成自然背景;如果是放大,同一件家具必须明显变大。禁止整图缩放、整图裁切、整图重绘。",
"一次成功硬约束:不要保守返回原尺寸。目标家具的可见轮廓必须明显从 OLD 原始占位变到 TARGET 占位;如果最终看起来和原图一样,就是失败。",
`Return one seamless realistic photo with the same frame and pixel size as input image 1: ${sourceSize.width} x ${sourceSize.height} px.`,
"Input image 2 is a separate neutral coordinate instruction map, not a photo and not output content. The light gray OLD rectangle/label locates the current furniture in input image 1; the dark TARGET rectangle/label gives the required final size and center. The map may include arrows, scale-factor text, OLD OUTSIDE = BACKGROUND, and TARGET = FINAL FOOTPRINT labels: read them as instructions only, never as visual content.",
"Input image 2 is a separate neutral-background target layout reference, not a photo and not output content. It places a rough resized crop of the selected furniture at the required final position and size. Read only the final furniture footprint from input image 2; never copy its neutral background, rectangular crop edges, rough patch quality, or guide artifacts into the output.",
cropLines ? `${cropLines}` : "",
"",
"Task:",
"Find the main movable furniture subject inside each OLD box in input image 1. Resize and, if needed, move that subject only so its final visible footprint matches the TARGET box.",
"Find the main movable furniture subject inside each OLD box in input image 1. Resize and, if needed, move that subject only so its final visible footprint matches the target layout shown in input image 2 and the numeric TARGET coordinates below.",
"Do a real subject-scale edit, not a full image zoom. The camera, room layout, windows, wall decor, text, floor, rug, and all non-target furniture must remain stable.",
"If an OLD box contains more than one object, choose the dominant furniture/product inside that box, usually the largest or most central movable item. Keep adjacent lamps, plants, side tables, pillows, wall decor, windows, text, and background objects at their original size unless they are clearly part of the same furniture subject.",
"Keep the furniture identity, material, perspective, objects on top, and natural aspect ratio from input image 1. Do not create a second copy or extra parts. Do not preserve the original furniture size just because the scene looks realistic; the user explicitly wants a visible scale change.",
@ -1186,8 +1304,8 @@ function buildScaleAdjustCoordinatePrompt(
"Region requirements:",
regionLines,
"",
"Success check: compare the final furniture footprint with the OLD and TARGET boxes from input image 2. The selected furniture size must clearly change from the original and align much closer to TARGET than OLD, while the rest of the photo stays stable.",
"Do not paste rectangular image areas. Do not output coordinate-map lines, labels, boxes, seams, hard patch edges, haze, color mismatch, full-canvas zoom, crop, pan, or redesign.",
"Success check: compare the final furniture footprint with input image 1 and the target layout reference in input image 2. The selected furniture size must clearly change from the original and align much closer to TARGET than OLD, while the rest of the photo stays stable.",
"Do not paste rectangular image areas. Do not output layout-reference background, coordinate-map lines, labels, boxes, seams, hard patch edges, haze, color mismatch, full-canvas zoom, crop, pan, or redesign.",
"Return only the clean edited photo.",
].join("\n");
}
@ -1195,13 +1313,13 @@ function buildScaleAdjustCoordinatePrompt(
function buildScaleAdjustCoordinateSystemPrompt() {
return [
"You are an image editing model for local furniture scale adjustment.",
"Input image 1 is the only final photo base. Input image 2 is a coordinate instruction map, not a photo layer.",
"Input image 1 is the only final photo base. Input image 2 is a rough target layout reference, not a photo layer.",
"Follow both English and Chinese instructions. The Chinese lines are hard constraints for avoiding unchanged furniture and full-canvas zoom.",
"Generate a clean realistic final photo by editing the target furniture itself and naturally rebuilding only the tiny surrounding background that becomes visible.",
"Prefer a clear visible size change over a conservative unchanged result; unchanged furniture is a failed edit.",
"When shrinking, remove the old full-size furniture footprint outside the target footprint and heal it as natural background. Large furniture is editable and must not remain locked at original size.",
"Do not paste, composite, clone, crop, resize, or copy rectangular image patches from any input.",
"Never output guide artifacts from the coordinate reference: no lines, labels, blocks, seams, rectangular borders, arrows, masks, UI marks, or annotations.",
"Never output guide artifacts from the layout reference: no neutral background, rough crop rectangle, lines, labels, blocks, seams, rectangular borders, arrows, masks, UI marks, or annotations.",
"Keep the camera frame, lighting, wall, floor, rug, windows, background furniture, text, logos, and all non-target regions stable.",
].join("\n");
}
@ -1369,29 +1487,30 @@ export async function POST(request: NextRequest) {
const scaleAdjustModelFamily = getScaleAdjustModelFamily(
resolvedScaleAdjustModelEntry
);
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([
{
input: isScaleAdjustOperation
? buildScaleAdjustAnnotationSvg({
height: sourceHeight,
regions,
scalePercent,
width: sourceWidth,
})
: buildAnnotationSvg({
const annotatedBuffer = isScaleAdjustOperation
? await buildScaleAdjustVisualLayoutGuideBuffer({
regions,
scalePercent,
sourceBuffer,
sourceHeight,
sourceWidth,
})
: await sharp(sourceBuffer, { failOn: "none" })
.composite([
{
input: buildAnnotationSvg({
height: sourceHeight,
hidePromptText: false,
regions,
width: sourceWidth,
}),
left: 0,
top: 0,
},
])
.png({ compressionLevel: 6 })
.toBuffer();
const annotatedFilename = `${isScaleAdjustOperation ? "canvas-scale-adjust-guide" : "canvas-local-edit"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
left: 0,
top: 0,
},
])
.png({ compressionLevel: 6 })
.toBuffer();
const annotatedFilename = `${isScaleAdjustOperation ? "canvas-scale-adjust-layout-guide" : "canvas-local-edit"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer)
@ -1424,7 +1543,7 @@ export async function POST(request: NextRequest) {
)
: buildLocalEditPrompt(regions);
const scaleAdjustCoordinateNegativePrompt =
"visible annotation, guide overlay, translucent overlay, text label, arrows, boxes, rectangle border, UI marks, pasted patch, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture, duplicate furniture, second copy, extra legs, extra rails, side panels, shelves, cabinets, lattice panels";
"visible annotation, guide overlay, translucent overlay, neutral layout background, text label, arrows, boxes, rectangle border, UI marks, pasted patch, rough layout patch, hard crop edge, seam, hard rectangular edge, color mismatch, different-resolution area, whole image zoom, whole image crop, whole image pan, whole image redesign, unchanged target furniture, stretched furniture, squashed furniture, duplicate furniture, second copy, extra legs, extra rails, side panels, shelves, cabinets, lattice panels";
const manualRegionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
@ -1480,7 +1599,7 @@ export async function POST(request: NextRequest) {
annotatedReferenceUrl: annotatedUpload.url,
annotatedReferenceSentToModel: isScaleAdjustOperation,
annotatedReferenceType: isScaleAdjustOperation
? "scale-adjust-neutral-coordinate-guide-map"
? "scale-adjust-rough-visual-layout-guide"
: "source-overlay",
cleanSourceReferenceIndex: 1,
configuredDetailModelId: configuredDetailModel || null,
@ -1495,6 +1614,7 @@ export async function POST(request: NextRequest) {
resolvedScaleAdjustModelEntry?.provider?.type || null,
scaleAdjustModelFamily: scaleAdjustModelFamily || null,
scaleAdjustCoordinateGuideApplied: useScaleAdjustCoordinateGuide,
scaleAdjustVisualLayoutGuideApplied: isScaleAdjustOperation,
falScaleAdjustCoordinateGuideApplied:
useScaleAdjustCoordinateGuide &&
resolvedScaleAdjustModelEntry?.provider?.type === "fal-image",
@ -1504,7 +1624,7 @@ export async function POST(request: NextRequest) {
: "default-model-failover"
: undefined,
scaleAdjustQualityFailoverPolicy: isScaleAdjustOperation
? "disabled-return-first-ai-candidate"
? "disabled-reject-unchanged-ai-candidate"
: undefined,
scaleAdjustPreferSameNameProviderFailover:
isScaleAdjustOperation && Boolean(scaleAdjustModelFamily),
@ -1523,8 +1643,8 @@ export async function POST(request: NextRequest) {
.length,
scaleAdjustPromptStrategy: isScaleAdjustOperation
? useScaleAdjustCoordinateGuide
? "same-name-locked-source-neutral-coordinate-map-v31-one-shot"
: "locked-plate-neutral-coordinate-guide-map-pixel-anchors-v8"
? "same-name-locked-source-visual-layout-guide-v32-one-shot"
: "locked-plate-visual-layout-guide-v9"
: undefined,
scaleAdjustPostCompositeApplied: false,
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,

View File

@ -2671,7 +2671,7 @@ export async function generateCanvasImage(
}
if (scaleAdjustRejection?.images?.length) {
console.warn("[Canvas Generate Image] Returning rejected scale adjust candidate because no fallback remains:", {
console.warn("[Canvas Generate Image] Rejecting scale adjust candidate because no effective edit was detected:", {
debugLabel,
imageCount: scaleAdjustRejection.images.length,
reason: scaleAdjustRejection.reason,
@ -2679,12 +2679,7 @@ export async function generateCanvasImage(
selectedModelId: selectedModel.model.modelId,
...debugMeta,
});
return {
images: scaleAdjustRejection.images,
model: selectedModel.model.displayName,
modelRef: selectedModel.model.id,
prompt: resultPrompt,
};
throw caughtError;
}
throw caughtError;