fix: guide scale adjustment target sizing

This commit is contained in:
134 2026-06-18 22:07:35 +08:00
parent 72e4b926d9
commit 43769c5b58

View File

@ -288,6 +288,26 @@ function rectToPixels(
return { left, top, width, height };
}
function scaleRectAroundCenter(
rect: NormalizedLocalEditRegion["rect"],
scalePercent: ScaleAdjustPercent
) {
const scale = Math.max(0.1, 1 + scalePercent / 100);
const targetWidth = Math.min(1, rect.width * scale);
const targetHeight = Math.min(1, rect.height * scale);
const centerX = rect.x + rect.width / 2;
const centerY = rect.y + rect.height / 2;
const x = clamp(centerX - targetWidth / 2, 0, Math.max(0, 1 - targetWidth));
const y = clamp(centerY - targetHeight / 2, 0, Math.max(0, 1 - targetHeight));
return {
x,
y,
width: targetWidth,
height: targetHeight,
};
}
function wrapText(text: string, maxChars: number, maxLines = 3) {
const compact = text.replace(/\s+/g, " ").trim();
if (!compact) return [];
@ -416,6 +436,99 @@ function buildAnnotationSvg(params: {
`);
}
function buildScaleAdjustAnnotationSvg(params: {
width: number;
height: number;
regions: NormalizedLocalEditRegion[];
scalePercent: ScaleAdjustPercent;
}) {
const { width, height, regions, scalePercent } = params;
const minSide = Math.max(1, Math.min(width, height));
const strokeWidth = Math.max(4, Math.round(minSide * 0.006));
const fontSize = Math.max(22, Math.round(minSide * 0.024));
const smallFontSize = Math.max(18, Math.round(fontSize * 0.78));
const targetColor = scalePercent > 0 ? "#00d4ff" : "#00c853";
const sourceColor = "#ff3b30";
const actionLabel = getScaleAdjustLabel(scalePercent);
const targetLabel = getScaleAdjustTargetLabel(scalePercent);
const dash = Math.max(12, Math.round(strokeWidth * 2.8));
const gap = Math.max(8, Math.round(strokeWidth * 1.8));
const regionSvg = regions
.map((region) => {
const source = region.pixelRect;
const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
const target = rectToPixels(targetRect, width, height);
const sourceCx = source.left + source.width / 2;
const sourceCy = source.top + source.height / 2;
const targetCx = target.left + target.width / 2;
const targetCy = target.top + target.height / 2;
const badgeSize = Math.max(32, Math.round(fontSize * 1.3));
const labelWidth = Math.min(
Math.max(260, Math.round(fontSize * 12.8)),
Math.max(180, width - 24)
);
const labelHeight = Math.round(fontSize * 2.55);
const labelX = clamp(
target.left,
12,
Math.max(12, width - labelWidth - 12)
);
const labelY = clamp(
target.top - labelHeight - Math.round(fontSize * 0.55),
12,
Math.max(12, height - labelHeight - 12)
);
const sourceCorners = [
[source.left, source.top],
[source.left + source.width, source.top],
[source.left + source.width, source.top + source.height],
[source.left, source.top + source.height],
];
const targetCorners = [
[target.left, target.top],
[target.left + target.width, target.top],
[target.left + target.width, target.top + target.height],
[target.left, target.top + target.height],
];
const arrows = sourceCorners
.map(([sx, sy], index) => {
const [tx, ty] = targetCorners[index];
const fromX = scalePercent > 0 ? sx : tx;
const fromY = scalePercent > 0 ? sy : ty;
const toX = scalePercent > 0 ? tx : sx;
const toY = scalePercent > 0 ? ty : sy;
return `<line x1="${fromX}" y1="${fromY}" x2="${toX}" y2="${toY}" stroke="${targetColor}" stroke-width="${Math.max(3, strokeWidth * 0.72)}" stroke-linecap="round" opacity="0.82" marker-end="url(#scale-adjust-arrow)" />`;
})
.join("");
return `
<rect x="${source.left}" y="${source.top}" width="${source.width}" height="${source.height}" rx="${Math.round(strokeWidth * 2)}" fill="${sourceColor}" opacity="0.07" stroke="${sourceColor}" stroke-width="${strokeWidth}" />
<rect x="${target.left}" y="${target.top}" width="${target.width}" height="${target.height}" rx="${Math.round(strokeWidth * 2.5)}" fill="${targetColor}" opacity="0.09" stroke="${targetColor}" stroke-width="${Math.max(4, Math.round(strokeWidth * 1.15))}" stroke-dasharray="${dash} ${gap}" />
${arrows}
<line x1="${sourceCx}" y1="${sourceCy}" x2="${targetCx}" y2="${targetCy}" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.45))}" opacity="0.5" />
<circle cx="${clamp(source.left, badgeSize / 2 + 6, width - badgeSize / 2 - 6)}" cy="${clamp(source.top, badgeSize / 2 + 6, height - badgeSize / 2 - 6)}" r="${badgeSize / 2}" fill="${sourceColor}" stroke="#ffffff" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.45))}" />
<text x="${clamp(source.left, badgeSize / 2 + 6, width - badgeSize / 2 - 6)}" y="${clamp(source.top, badgeSize / 2 + 6, height - badgeSize / 2 - 6) + smallFontSize * 0.38}" text-anchor="middle" fill="#ffffff" font-size="${smallFontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">${region.index}</text>
<rect x="${labelX}" y="${labelY}" width="${labelWidth}" height="${labelHeight}" rx="${Math.round(fontSize * 0.55)}" fill="#061017" opacity="0.86" stroke="${targetColor}" stroke-width="${Math.max(2, Math.round(strokeWidth * 0.5))}" />
<text x="${labelX + Math.round(fontSize * 0.7)}" y="${labelY + Math.round(fontSize * 1.03)}" fill="#ffffff" font-size="${fontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="800">目标:${escapeXml(actionLabel)},主体约 ${escapeXml(targetLabel)}</text>
<text x="${labelX + Math.round(fontSize * 0.7)}" y="${labelY + Math.round(fontSize * 2.03)}" fill="${targetColor}" font-size="${smallFontSize}" font-family="Arial, Helvetica, sans-serif" font-weight="700">让主体轮廓接近虚线框,不要保持原红框大小</text>
`;
})
.join("");
return Buffer.from(`
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="scale-adjust-arrow" markerWidth="12" markerHeight="12" refX="9" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M2,2 L10,6 L2,10 Z" fill="${targetColor}" />
</marker>
</defs>
${regionSvg}
</svg>
`);
}
function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
const regionLines = regions
.map((region) => {
@ -471,17 +584,23 @@ function buildScaleAdjustPrompt(
const top = Math.round(region.rect.y * 1000) / 10;
const width = Math.round(region.rect.width * 1000) / 10;
const height = Math.round(region.rect.height * 1000) / 10;
const targetRect = scaleRectAroundCenter(region.rect, scalePercent);
const targetLeft = Math.round(targetRect.x * 1000) / 10;
const targetTop = Math.round(targetRect.y * 1000) / 10;
const targetWidth = Math.round(targetRect.width * 1000) / 10;
const targetHeight = Math.round(targetRect.height * 1000) / 10;
const pixel = region.pixelRect;
const right = pixel.left + pixel.width;
const bottom = pixel.top + pixel.height;
const centerX = pixel.left + Math.round(pixel.width / 2);
const centerY = pixel.top + Math.round(pixel.height / 2);
return `区域 ${region.index}(相对位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸必须约为原主体的 ${targetLabel}。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
return `区域 ${region.index}(原始红色实线框:x=${left}%,y=${top}%,w=${width}%,h=${height}%;目标虚线框:x=${targetLeft}%,y=${targetTop}%,w=${targetWidth}%,h=${targetHeight}%;像素范围 left=${pixel.left}, top=${pixel.top}, right=${right}, bottom=${bottom}, center=(${centerX}, ${centerY})):红色实线框只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标主体最终外接轮廓必须接近第 1 张图中的青绿色/绿色虚线目标框,视觉尺寸约为原主体的 ${targetLabel}。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
})
.join("\n");
return [
"输入图片1是定位参考图:它只用于告诉你用户框选的目标主体位置。输入图片1里的彩色框、编号、标签、半透明遮罩、文字和 UI 只是定位标记,绝不能出现在最终画面中。",
"输入图片1中的红色实线框表示目标主体当前大致位置;青绿色或绿色虚线框表示比例调整后的目标视觉轮廓。最终画面不得出现这些框线,但目标主体大小必须接近虚线框所表达的新尺寸。",
"输入图片2是原始干净图,也是最终输出必须继承的唯一画面基底。如果系统或模型只能读取一张参考图,则必须使用输入图片1中的框选位置定位目标,同时主动去除所有定位标记并恢复干净画面。",
"系统已根据用户框选区域计算出下方每个区域的原图坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
"最终结果必须保持原始干净图的画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
@ -491,10 +610,10 @@ function buildScaleAdjustPrompt(
regionLines,
"",
"关键要求:",
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;请把主体轮廓宽度和高度都调整到约 ${targetLabel},而不是仅改变亮度、清晰度、颜色或画面裁切。`,
`1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放到约 ${targetLabel},不得保留原尺寸;请把主体轮廓宽度和高度都调整到约 ${targetLabel},并尽量贴近第 1 张图的虚线目标框,而不是仅改变亮度、清晰度、颜色或画面裁切。`,
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
`3. 缩小时,目标主体应围绕原位置视觉中心自然变小到约 ${targetLabel};主体缩小后,原来被遮挡的背景、桌面、墙面、阴影、纹理和透视必须被重新生成并无缝补全,选区内应出现明显新增留白或背景。`,
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。`,
`4. 放大时,目标主体应围绕原位置视觉中心自然变大到约 ${targetLabel};主体边缘应越过原始红框并接近虚线目标框,边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。`,
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
@ -587,12 +706,19 @@ export async function POST(request: NextRequest) {
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
const displayPrompt = buildLocalEditDisplayPrompt({ operation, scalePercent });
const annotationSvg = buildAnnotationSvg({
height: sourceHeight,
hidePromptText: isScaleAdjustOperation,
regions,
width: sourceWidth,
});
const annotationSvg = isScaleAdjustOperation
? buildScaleAdjustAnnotationSvg({
height: sourceHeight,
regions,
scalePercent: scalePercent as ScaleAdjustPercent,
width: sourceWidth,
})
: buildAnnotationSvg({
height: sourceHeight,
hidePromptText: false,
regions,
width: sourceWidth,
});
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([{ input: annotationSvg, left: 0, top: 0 }])
.png({ compressionLevel: 6 })