fix: stabilize scale adjust subject resizing
# Conflicts: # app/api/canvas/local-edit/route.ts # lib/canvas/generate-image.ts
This commit is contained in:
commit
83e911f698
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,7 @@ const AOS_MAX_RESOLUTION = 4096;
|
||||
const AOS_CREATE_TIMEOUT_MS = 120_000;
|
||||
const AOS_POLL_TIMEOUT_MS = 60_000;
|
||||
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
const IMAGE_URL_FETCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer> {
|
||||
try {
|
||||
@ -499,14 +500,16 @@ function getModelId(provider: RmbgProvider) {
|
||||
|
||||
export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer> {
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new RmbgServiceError(
|
||||
`无法获取图片: ${imageResponse.status} ${imageResponse.statusText}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
return Buffer.from(await imageResponse.arrayBuffer());
|
||||
return await withTimeout(async (signal) => {
|
||||
const imageResponse = await fetch(imageUrl, { signal });
|
||||
if (!imageResponse.ok) {
|
||||
throw new RmbgServiceError(
|
||||
`无法获取图片: ${imageResponse.status} ${imageResponse.statusText}`,
|
||||
400
|
||||
);
|
||||
}
|
||||
return Buffer.from(await imageResponse.arrayBuffer());
|
||||
}, IMAGE_URL_FETCH_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
throw error;
|
||||
|
||||
@ -56,6 +56,11 @@ export interface CanvasLocalEditCompositeConfig {
|
||||
export interface CanvasScaleAdjustPatchCompositeConfig {
|
||||
sourceImageUrl: string;
|
||||
workspaceRegion: CanvasLocalEditCompositeRegion;
|
||||
regions?: CanvasLocalEditCompositeRegion[];
|
||||
maskMode?: "workspace" | "diff";
|
||||
diffLowThreshold?: number;
|
||||
diffHighThreshold?: number;
|
||||
diffCompareBlurPx?: number;
|
||||
featherMinPx?: number;
|
||||
featherMaxPx?: number;
|
||||
featherRatio?: number;
|
||||
@ -153,6 +158,8 @@ class ScaleAdjustOutputRejectedError extends Error {
|
||||
const message =
|
||||
params.reason === "outside-frame-changed"
|
||||
? "比例调整结果改变了框选区域外的画面,已拒绝该模型结果"
|
||||
: params.reason === "rectangular-seam-detected"
|
||||
? "比例调整结果出现明显矩形拼接硬边,已拒绝该模型结果"
|
||||
: "比例调整结果没有产生有效变化,已拒绝该模型结果";
|
||||
super(message);
|
||||
this.name = "ScaleAdjustOutputRejectedError";
|
||||
@ -182,6 +189,7 @@ const SCALE_ADJUST_MARKER_CLEANUP_MARGIN = 0.035;
|
||||
const SCALE_ADJUST_MARKER_DILATE_RADIUS = 3;
|
||||
const SCALE_ADJUST_MARKER_MIN_PIXEL_COUNT = 18;
|
||||
const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112;
|
||||
const SCALE_ADJUST_FOOTPRINT_FALLBACK_DIFF_THRESHOLD = 18;
|
||||
const SCALE_ADJUST_FALLBACK_MASK_DISTANCE_THRESHOLD = 42;
|
||||
const SCALE_ADJUST_FALLBACK_MASK_LUMA_GAP = 18;
|
||||
const SCALE_ADJUST_FALLBACK_INPAINT_ITERATIONS = 180;
|
||||
@ -190,6 +198,16 @@ const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_RELAXED_MAX_RATIO = 0.84;
|
||||
const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MIN_REMOVED_RATIO = 0.14;
|
||||
const SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS = 60;
|
||||
const SCALE_ADJUST_SHRINK_TARGET_FOOTPRINT_MARGIN = 0.018;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_CHANGE_THRESHOLD = 18;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_MIN_EDGE_DIFF = 5;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_MAX_EDGE_DIFF = 54;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_EDGE_INCREASE = 2.5;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO = 0.11;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO = 0.18;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO = 0.1;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO = 0.26;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO = 0.55;
|
||||
const SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO = 0.5;
|
||||
|
||||
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
"gemini",
|
||||
@ -374,10 +392,31 @@ function normalizeScaleAdjustPatchCompositeConfig(
|
||||
if (!/^https?:\/\//i.test(sourceImageUrl)) return null;
|
||||
const workspaceRegion = normalizeCanvasRegion(value.workspaceRegion);
|
||||
if (!workspaceRegion) return null;
|
||||
const regions = Array.isArray(value.regions)
|
||||
? value.regions
|
||||
.map((region) => normalizeCanvasRegion(region))
|
||||
.filter((region): region is CanvasLocalEditCompositeRegion =>
|
||||
Boolean(region)
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
sourceImageUrl,
|
||||
workspaceRegion,
|
||||
...(regions.length > 0 ? { regions } : {}),
|
||||
maskMode: value.maskMode === "diff" ? "diff" : "workspace",
|
||||
diffHighThreshold: normalizeOptionalNumber(value.diffHighThreshold, {
|
||||
max: 120,
|
||||
min: 1,
|
||||
}),
|
||||
diffLowThreshold: normalizeOptionalNumber(value.diffLowThreshold, {
|
||||
max: 80,
|
||||
min: 0,
|
||||
}),
|
||||
diffCompareBlurPx: normalizeOptionalNumber(value.diffCompareBlurPx, {
|
||||
max: 12,
|
||||
min: 0,
|
||||
}),
|
||||
featherMaxPx: normalizeOptionalNumber(value.featherMaxPx, {
|
||||
max: 180,
|
||||
min: 0,
|
||||
@ -869,6 +908,274 @@ function isScaleAdjustOutsidePixelChanged(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function getScaleAdjustRawRgbDiff(raw: Buffer, leftIndex: number, rightIndex: number) {
|
||||
return (
|
||||
Math.abs(raw[leftIndex] - raw[rightIndex]) * 0.299 +
|
||||
Math.abs(raw[leftIndex + 1] - raw[rightIndex + 1]) * 0.587 +
|
||||
Math.abs(raw[leftIndex + 2] - raw[rightIndex + 2]) * 0.114
|
||||
);
|
||||
}
|
||||
|
||||
function getScaleAdjustPixelChanged(params: {
|
||||
generatedRaw: Buffer;
|
||||
index: number;
|
||||
sourceRaw: Buffer;
|
||||
}) {
|
||||
return (
|
||||
Math.abs(params.generatedRaw[params.index] - params.sourceRaw[params.index]) *
|
||||
0.299 +
|
||||
Math.abs(
|
||||
params.generatedRaw[params.index + 1] -
|
||||
params.sourceRaw[params.index + 1]
|
||||
) *
|
||||
0.587 +
|
||||
Math.abs(
|
||||
params.generatedRaw[params.index + 2] -
|
||||
params.sourceRaw[params.index + 2]
|
||||
) *
|
||||
0.114
|
||||
) >= SCALE_ADJUST_RECTANGULAR_SEAM_CHANGE_THRESHOLD;
|
||||
}
|
||||
|
||||
function summarizeScaleAdjustSeamLine(params: {
|
||||
axis: "horizontal" | "vertical";
|
||||
boxIndex: number;
|
||||
changeMask: Uint8Array;
|
||||
generatedRaw: Buffer;
|
||||
height: number;
|
||||
position: number;
|
||||
rangeEnd: number;
|
||||
rangeStart: number;
|
||||
sourceRaw: Buffer;
|
||||
width: number;
|
||||
}) {
|
||||
const {
|
||||
axis,
|
||||
boxIndex,
|
||||
changeMask,
|
||||
generatedRaw,
|
||||
height,
|
||||
position,
|
||||
rangeEnd,
|
||||
rangeStart,
|
||||
sourceRaw,
|
||||
width,
|
||||
} = params;
|
||||
let transitionCount = 0;
|
||||
let currentRun = 0;
|
||||
let maxRun = 0;
|
||||
let edgeDiffSum = 0;
|
||||
const sampleCount = Math.max(1, rangeEnd - rangeStart);
|
||||
|
||||
for (let offset = rangeStart; offset < rangeEnd; offset += 1) {
|
||||
const leftX = axis === "vertical" ? position - 1 : offset;
|
||||
const leftY = axis === "vertical" ? offset : position - 1;
|
||||
const rightX = axis === "vertical" ? position : offset;
|
||||
const rightY = axis === "vertical" ? offset : position;
|
||||
if (
|
||||
leftX < 0 ||
|
||||
rightX < 0 ||
|
||||
leftY < 0 ||
|
||||
rightY < 0 ||
|
||||
leftX >= width ||
|
||||
rightX >= width ||
|
||||
leftY >= height ||
|
||||
rightY >= height
|
||||
) {
|
||||
currentRun = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftPixel = leftY * width + leftX;
|
||||
const rightPixel = rightY * width + rightX;
|
||||
const changedBoundary =
|
||||
changeMask[leftPixel] !== changeMask[rightPixel];
|
||||
const leftRawIndex = leftPixel * 3;
|
||||
const rightRawIndex = rightPixel * 3;
|
||||
const generatedEdgeDiff = getScaleAdjustRawRgbDiff(
|
||||
generatedRaw,
|
||||
leftRawIndex,
|
||||
rightRawIndex
|
||||
);
|
||||
const sourceEdgeDiff = getScaleAdjustRawRgbDiff(
|
||||
sourceRaw,
|
||||
leftRawIndex,
|
||||
rightRawIndex
|
||||
);
|
||||
const likelyBackgroundSeam =
|
||||
generatedEdgeDiff >= SCALE_ADJUST_RECTANGULAR_SEAM_MIN_EDGE_DIFF &&
|
||||
generatedEdgeDiff <= SCALE_ADJUST_RECTANGULAR_SEAM_MAX_EDGE_DIFF &&
|
||||
generatedEdgeDiff - sourceEdgeDiff >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_EDGE_INCREASE;
|
||||
|
||||
if (changedBoundary && likelyBackgroundSeam) {
|
||||
transitionCount += 1;
|
||||
currentRun += 1;
|
||||
maxRun = Math.max(maxRun, currentRun);
|
||||
edgeDiffSum += generatedEdgeDiff;
|
||||
} else {
|
||||
currentRun = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
axis,
|
||||
boxIndex,
|
||||
edgeDiffMean: transitionCount > 0 ? edgeDiffSum / transitionCount : 0,
|
||||
maxRun,
|
||||
maxRunRatio: maxRun / sampleCount,
|
||||
position,
|
||||
sampleCount,
|
||||
transitionCount,
|
||||
transitionRatio: transitionCount / sampleCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function getScaleAdjustRectangularSeamStats(params: {
|
||||
generatedBuffer: Buffer;
|
||||
sourceBuffer: Buffer;
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
verification: CanvasScaleAdjustVerificationConfig;
|
||||
}) {
|
||||
const { generatedBuffer, sourceBuffer, sourceHeight, sourceWidth, verification } =
|
||||
params;
|
||||
const normalizeToRaw = (buffer: Buffer) =>
|
||||
sharp(buffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(sourceWidth, sourceHeight, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.removeAlpha()
|
||||
.toColorspace("srgb")
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const [sourceRaw, generatedRaw] = await Promise.all([
|
||||
normalizeToRaw(sourceBuffer),
|
||||
normalizeToRaw(generatedBuffer),
|
||||
]);
|
||||
const changeMask = new Uint8Array(sourceWidth * sourceHeight);
|
||||
for (let pixel = 0; pixel < sourceWidth * sourceHeight; pixel += 1) {
|
||||
const rawIndex = pixel * 3;
|
||||
changeMask[pixel] = getScaleAdjustPixelChanged({
|
||||
generatedRaw,
|
||||
index: rawIndex,
|
||||
sourceRaw,
|
||||
})
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
const protectedBoxes = getScaleAdjustProtectedRegions(verification).map((region) =>
|
||||
rectToPixelBox({
|
||||
height: sourceHeight,
|
||||
region,
|
||||
width: sourceWidth,
|
||||
})
|
||||
);
|
||||
const lineStep = Math.max(
|
||||
1,
|
||||
Math.floor(Math.min(sourceWidth, sourceHeight) / 1400)
|
||||
);
|
||||
let bestVertical: ReturnType<typeof summarizeScaleAdjustSeamLine> | null = null;
|
||||
let bestHorizontal: ReturnType<typeof summarizeScaleAdjustSeamLine> | null = null;
|
||||
|
||||
for (const [boxIndex, box] of protectedBoxes.entries()) {
|
||||
const left = Math.max(2, box.left + 2);
|
||||
const top = Math.max(2, box.top + 2);
|
||||
const right = Math.min(sourceWidth - 2, box.left + box.width - 2);
|
||||
const bottom = Math.min(sourceHeight - 2, box.top + box.height - 2);
|
||||
if (right <= left || bottom <= top) continue;
|
||||
|
||||
for (let x = left; x <= right; x += lineStep) {
|
||||
const stats = summarizeScaleAdjustSeamLine({
|
||||
axis: "vertical",
|
||||
boxIndex,
|
||||
changeMask,
|
||||
generatedRaw,
|
||||
height: sourceHeight,
|
||||
position: x,
|
||||
rangeEnd: bottom,
|
||||
rangeStart: top,
|
||||
sourceRaw,
|
||||
width: sourceWidth,
|
||||
});
|
||||
if (!bestVertical || stats.maxRunRatio > bestVertical.maxRunRatio) {
|
||||
bestVertical = stats;
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = top; y <= bottom; y += lineStep) {
|
||||
const stats = summarizeScaleAdjustSeamLine({
|
||||
axis: "horizontal",
|
||||
boxIndex,
|
||||
changeMask,
|
||||
generatedRaw,
|
||||
height: sourceHeight,
|
||||
position: y,
|
||||
rangeEnd: right,
|
||||
rangeStart: left,
|
||||
sourceRaw,
|
||||
width: sourceWidth,
|
||||
});
|
||||
if (!bestHorizontal || stats.maxRunRatio > bestHorizontal.maxRunRatio) {
|
||||
bestHorizontal = stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verticalPair =
|
||||
bestVertical &&
|
||||
bestVertical.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO &&
|
||||
bestVertical.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO;
|
||||
const horizontalPair =
|
||||
bestHorizontal &&
|
||||
bestHorizontal.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_RUN_RATIO &&
|
||||
bestHorizontal.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_PAIR_TRANSITION_RATIO;
|
||||
const verticalStrong =
|
||||
bestVertical &&
|
||||
bestVertical.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO &&
|
||||
bestVertical.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO;
|
||||
const horizontalStrong =
|
||||
bestHorizontal &&
|
||||
bestHorizontal.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_RUN_RATIO &&
|
||||
bestHorizontal.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_STRONG_TRANSITION_RATIO;
|
||||
const verticalVeryStrong =
|
||||
bestVertical &&
|
||||
bestVertical.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO &&
|
||||
bestVertical.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO;
|
||||
const horizontalVeryStrong =
|
||||
bestHorizontal &&
|
||||
bestHorizontal.maxRunRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_RUN_RATIO &&
|
||||
bestHorizontal.transitionRatio >=
|
||||
SCALE_ADJUST_RECTANGULAR_SEAM_VERY_STRONG_TRANSITION_RATIO;
|
||||
|
||||
return {
|
||||
detected: Boolean(
|
||||
(verticalPair && horizontalPair) ||
|
||||
(verticalStrong && horizontalStrong) ||
|
||||
verticalVeryStrong ||
|
||||
horizontalVeryStrong
|
||||
),
|
||||
horizontal: bestHorizontal,
|
||||
lineStep,
|
||||
protectedBoxCount: protectedBoxes.length,
|
||||
vertical: bestVertical,
|
||||
};
|
||||
}
|
||||
|
||||
async function getScaleAdjustFootprintChangeStats(params: {
|
||||
generatedBuffer: Buffer;
|
||||
sourceBuffer: Buffer;
|
||||
@ -1232,6 +1539,10 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
outsidePixelStats: Awaited<
|
||||
ReturnType<typeof getScaleAdjustOutsidePixelChangeStats>
|
||||
>;
|
||||
rectangularSeamDetected: boolean;
|
||||
rectangularSeamStats: Awaited<
|
||||
ReturnType<typeof getScaleAdjustRectangularSeamStats>
|
||||
>;
|
||||
}> = [];
|
||||
for (const [index, image] of images.entries()) {
|
||||
const generatedUrl = image.originalUrl || image.thumbnailUrl;
|
||||
@ -1243,6 +1554,7 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
outsideDiffScores,
|
||||
outsidePixelStats,
|
||||
footprintStats,
|
||||
rectangularSeamStats,
|
||||
] = await Promise.all([
|
||||
getScaleAdjustDiffScores({
|
||||
generatedBuffer,
|
||||
@ -1279,18 +1591,35 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
sourceWidth: sourceImage.sourceWidth,
|
||||
verification,
|
||||
}),
|
||||
getScaleAdjustRectangularSeamStats({
|
||||
generatedBuffer,
|
||||
sourceBuffer: sourceImage.sourceBuffer,
|
||||
sourceHeight: sourceImage.sourceHeight,
|
||||
sourceWidth: sourceImage.sourceWidth,
|
||||
verification,
|
||||
}),
|
||||
]);
|
||||
const visuallyChangedInside = changeDiffScores.some(
|
||||
(score) => score >= SCALE_ADJUST_VISUAL_DIFF_THRESHOLD
|
||||
);
|
||||
const changedInside = visuallyChangedInside && footprintStats.passed;
|
||||
const footprintFallbackAccepted =
|
||||
visuallyChangedInside &&
|
||||
!footprintStats.passed &&
|
||||
changeDiffScores.some(
|
||||
(score) => score >= SCALE_ADJUST_FOOTPRINT_FALLBACK_DIFF_THRESHOLD
|
||||
);
|
||||
const changedInside =
|
||||
(visuallyChangedInside && footprintStats.passed) ||
|
||||
footprintFallbackAccepted;
|
||||
const outsideChanged =
|
||||
isScaleAdjustOutsideChanged(outsideDiffScores) ||
|
||||
isScaleAdjustOutsidePixelChanged(outsidePixelStats);
|
||||
const rectangularSeamDetected = rectangularSeamStats.detected;
|
||||
|
||||
console.info("[Canvas Generate Image] Scale adjust result guard:", {
|
||||
debugLabel,
|
||||
changedInside,
|
||||
footprintFallbackAccepted,
|
||||
footprintChanged: footprintStats.passed,
|
||||
footprintStats: {
|
||||
failedRegionCount: footprintStats.failedRegionCount,
|
||||
@ -1320,6 +1649,38 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
Number(score.toFixed(2))
|
||||
),
|
||||
protectedRegionCount: verification.protectedRegions?.length || 0,
|
||||
rectangularSeamDetected,
|
||||
rectangularSeamStats: {
|
||||
horizontal: rectangularSeamStats.horizontal
|
||||
? {
|
||||
edgeDiffMean: Number(
|
||||
rectangularSeamStats.horizontal.edgeDiffMean.toFixed(2)
|
||||
),
|
||||
maxRunRatio: Number(
|
||||
rectangularSeamStats.horizontal.maxRunRatio.toFixed(3)
|
||||
),
|
||||
position: rectangularSeamStats.horizontal.position,
|
||||
transitionRatio: Number(
|
||||
rectangularSeamStats.horizontal.transitionRatio.toFixed(3)
|
||||
),
|
||||
}
|
||||
: null,
|
||||
lineStep: rectangularSeamStats.lineStep,
|
||||
vertical: rectangularSeamStats.vertical
|
||||
? {
|
||||
edgeDiffMean: Number(
|
||||
rectangularSeamStats.vertical.edgeDiffMean.toFixed(2)
|
||||
),
|
||||
maxRunRatio: Number(
|
||||
rectangularSeamStats.vertical.maxRunRatio.toFixed(3)
|
||||
),
|
||||
position: rectangularSeamStats.vertical.position,
|
||||
transitionRatio: Number(
|
||||
rectangularSeamStats.vertical.transitionRatio.toFixed(3)
|
||||
),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
regionCount: verification.regions.length,
|
||||
regionDiffScores: diffScores.map((score) => Number(score.toFixed(2))),
|
||||
scalePercent: verification.scalePercent ?? null,
|
||||
@ -1329,6 +1690,7 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
const guardWarnings = [
|
||||
...(outsideChanged ? ["outside-frame-changed"] : []),
|
||||
...(!changedInside ? ["no-effective-scale-change"] : []),
|
||||
...(rectangularSeamDetected ? ["rectangular-seam-detected"] : []),
|
||||
];
|
||||
if (guardWarnings.length > 0) {
|
||||
console.warn("[Canvas Generate Image] Scale adjust result guard warning; keeping result for preview:", {
|
||||
@ -1336,6 +1698,7 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
imageIndex: index,
|
||||
reasons: guardWarnings,
|
||||
changedInside,
|
||||
footprintFallbackAccepted,
|
||||
footprintChanged: footprintStats.passed,
|
||||
footprintStats: {
|
||||
failedRegionCount: footprintStats.failedRegionCount,
|
||||
@ -1360,6 +1723,37 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
sampledPixelCount: outsidePixelStats.sampledPixelCount,
|
||||
step: outsidePixelStats.step,
|
||||
},
|
||||
rectangularSeamDetected,
|
||||
rectangularSeamStats: {
|
||||
horizontal: rectangularSeamStats.horizontal
|
||||
? {
|
||||
edgeDiffMean: Number(
|
||||
rectangularSeamStats.horizontal.edgeDiffMean.toFixed(2)
|
||||
),
|
||||
maxRunRatio: Number(
|
||||
rectangularSeamStats.horizontal.maxRunRatio.toFixed(3)
|
||||
),
|
||||
position: rectangularSeamStats.horizontal.position,
|
||||
transitionRatio: Number(
|
||||
rectangularSeamStats.horizontal.transitionRatio.toFixed(3)
|
||||
),
|
||||
}
|
||||
: null,
|
||||
vertical: rectangularSeamStats.vertical
|
||||
? {
|
||||
edgeDiffMean: Number(
|
||||
rectangularSeamStats.vertical.edgeDiffMean.toFixed(2)
|
||||
),
|
||||
maxRunRatio: Number(
|
||||
rectangularSeamStats.vertical.maxRunRatio.toFixed(3)
|
||||
),
|
||||
position: rectangularSeamStats.vertical.position,
|
||||
transitionRatio: Number(
|
||||
rectangularSeamStats.vertical.transitionRatio.toFixed(3)
|
||||
),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
changeRegionDiffScores: changeDiffScores.map((score) =>
|
||||
Number(score.toFixed(2))
|
||||
),
|
||||
@ -1392,6 +1786,8 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
outsideChanged,
|
||||
outsideDiffScores,
|
||||
outsidePixelStats,
|
||||
rectangularSeamDetected,
|
||||
rectangularSeamStats,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@ -1414,6 +1810,8 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
outsideChanged,
|
||||
outsideDiffScores,
|
||||
outsidePixelStats,
|
||||
rectangularSeamDetected,
|
||||
rectangularSeamStats,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1424,11 +1822,20 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
const outsideMean = item.outsidePixelStats.meanDiff;
|
||||
const outsidePenalty = item.outsideChanged ? 100 : 0;
|
||||
const noChangePenalty = item.changedInside ? 0 : 60;
|
||||
return insideScore - outsideMean * 0.35 - outsidePenalty - noChangePenalty;
|
||||
const seamPenalty = item.rectangularSeamDetected ? 140 : 0;
|
||||
return (
|
||||
insideScore -
|
||||
outsideMean * 0.35 -
|
||||
outsidePenalty -
|
||||
noChangePenalty -
|
||||
seamPenalty
|
||||
);
|
||||
};
|
||||
const rankedImages = [...evaluatedImages].sort((left, right) => {
|
||||
const leftPassed = left.changedInside && !left.outsideChanged;
|
||||
const rightPassed = right.changedInside && !right.outsideChanged;
|
||||
const leftPassed =
|
||||
left.changedInside && !left.outsideChanged && !left.rectangularSeamDetected;
|
||||
const rightPassed =
|
||||
right.changedInside && !right.outsideChanged && !right.rectangularSeamDetected;
|
||||
if (leftPassed !== rightPassed) return leftPassed ? -1 : 1;
|
||||
return scoreEvaluation(right) - scoreEvaluation(left);
|
||||
});
|
||||
@ -1438,9 +1845,13 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
debugLabel,
|
||||
candidateCount: evaluatedImages.length,
|
||||
imageIndex: selected.index,
|
||||
passed: selected.changedInside && !selected.outsideChanged,
|
||||
passed:
|
||||
selected.changedInside &&
|
||||
!selected.outsideChanged &&
|
||||
!selected.rectangularSeamDetected,
|
||||
changedInside: selected.changedInside,
|
||||
outsideChanged: selected.outsideChanged,
|
||||
rectangularSeamDetected: selected.rectangularSeamDetected,
|
||||
changeRegionDiffScores: selected.changeDiffScores.map((score) =>
|
||||
Number(score.toFixed(2))
|
||||
),
|
||||
@ -1473,12 +1884,16 @@ async function applyScaleAdjustVerificationToImages(params: {
|
||||
|
||||
if (
|
||||
verification.rejectFailedCandidates &&
|
||||
(!selected.changedInside || (selected.outsideChanged && !outsideChangeAccepted))
|
||||
(!selected.changedInside ||
|
||||
selected.rectangularSeamDetected ||
|
||||
(selected.outsideChanged && !outsideChangeAccepted))
|
||||
) {
|
||||
throw new ScaleAdjustOutputRejectedError({
|
||||
reason: selected.outsideChanged
|
||||
? "outside-frame-changed"
|
||||
: "no-effective-scale-change",
|
||||
reason: selected.rectangularSeamDetected
|
||||
? "rectangular-seam-detected"
|
||||
: selected.outsideChanged
|
||||
? "outside-frame-changed"
|
||||
: "no-effective-scale-change",
|
||||
diffScores: selected.changeDiffScores,
|
||||
images: [selected.cleanedImage || selected.image],
|
||||
outsideDiffScores: selected.outsideDiffScores,
|
||||
@ -1976,6 +2391,7 @@ function buildScaleAdjustPatchCompositeMaskSvg(params: {
|
||||
width: number;
|
||||
height: number;
|
||||
featherPx: number;
|
||||
regions?: Array<{ left: number; top: number; width: number; height: number }>;
|
||||
touchesLeft: boolean;
|
||||
touchesTop: boolean;
|
||||
touchesRight: boolean;
|
||||
@ -1983,6 +2399,46 @@ function buildScaleAdjustPatchCompositeMaskSvg(params: {
|
||||
}) {
|
||||
const { height, width } = params;
|
||||
const featherPx = Math.max(0, Math.round(params.featherPx));
|
||||
const regions = params.regions?.filter(
|
||||
(region) => region.width > 0 && region.height > 0
|
||||
);
|
||||
if (regions?.length) {
|
||||
const blur = Math.max(1, featherPx);
|
||||
const softPad = Math.max(2, Math.round(featherPx * 1.35));
|
||||
const corePad = Math.max(0, Math.round(featherPx * 0.22));
|
||||
const softRects = regions
|
||||
.map((region) => {
|
||||
const left = Math.max(0, region.left - softPad);
|
||||
const top = Math.max(0, region.top - softPad);
|
||||
const right = Math.min(width, region.left + region.width + softPad);
|
||||
const bottom = Math.min(height, region.top + region.height + softPad);
|
||||
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" fill="#ffffff" filter="url(#scale-adjust-patch-soft-mask)" />`;
|
||||
})
|
||||
.join("");
|
||||
const coreRects = regions
|
||||
.map((region) => {
|
||||
const left = Math.max(0, region.left - corePad);
|
||||
const top = Math.max(0, region.top - corePad);
|
||||
const right = Math.min(width, region.left + region.width + corePad);
|
||||
const bottom = Math.min(height, region.top + region.height + corePad);
|
||||
return `<rect x="${left}" y="${top}" width="${Math.max(1, right - left)}" height="${Math.max(1, bottom - top)}" fill="#ffffff" />`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return Buffer.from(`
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="scale-adjust-patch-soft-mask" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="${blur}" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="rgba(0,0,0,0)" />
|
||||
${softRects}
|
||||
${coreRects}
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
|
||||
if (featherPx <= 0) {
|
||||
return Buffer.from(`
|
||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
@ -2148,6 +2604,75 @@ async function buildLocalEditDiffCompositeMask(params: {
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function normalizeAlphaMaskToRaw(params: {
|
||||
maskBuffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const { height, maskBuffer, width } = params;
|
||||
return sharp(maskBuffer, { failOn: "none" })
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.removeAlpha()
|
||||
.extractChannel(0)
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function normalizeOpacityMaskToRaw(params: {
|
||||
maskBuffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const { height, maskBuffer, width } = params;
|
||||
return sharp(maskBuffer, { failOn: "none" })
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.ensureAlpha()
|
||||
.extractChannel(3)
|
||||
.raw()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
function multiplyAlphaMasks(primaryMask: Buffer, clipMask: Buffer) {
|
||||
if (primaryMask.length !== clipMask.length) return primaryMask;
|
||||
const multiplied = Buffer.allocUnsafe(primaryMask.length);
|
||||
for (let index = 0; index < primaryMask.length; index += 1) {
|
||||
multiplied[index] = Math.round((primaryMask[index] * clipMask[index]) / 255);
|
||||
}
|
||||
return multiplied;
|
||||
}
|
||||
|
||||
function boostAlphaMask(mask: Buffer, cutoff: number, multiplier: number) {
|
||||
const boosted = Buffer.allocUnsafe(mask.length);
|
||||
for (let index = 0; index < mask.length; index += 1) {
|
||||
const value = mask[index];
|
||||
boosted[index] =
|
||||
value < cutoff ? 0 : Math.min(255, Math.round(value * multiplier));
|
||||
}
|
||||
return boosted;
|
||||
}
|
||||
|
||||
async function applyRawAlphaToImage(params: {
|
||||
imageBuffer: Buffer;
|
||||
alphaMaskRaw: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
}) {
|
||||
const { alphaMaskRaw, height, imageBuffer, width } = params;
|
||||
const rgbRaw = await sharp(imageBuffer, { failOn: "none" })
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.removeAlpha()
|
||||
.toColorspace("srgb")
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
return sharp(rgbRaw, { raw: { channels: 3, height, width } })
|
||||
.joinChannel(alphaMaskRaw, {
|
||||
raw: { channels: 1, height, width },
|
||||
})
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function applyLocalEditCompositeToImages(params: {
|
||||
images: ParsedImage[];
|
||||
composite: CanvasLocalEditCompositeConfig | null;
|
||||
@ -2187,19 +2712,22 @@ async function applyLocalEditCompositeToImages(params: {
|
||||
.toBuffer();
|
||||
const overlayBuffer =
|
||||
composite.maskMode === "diff"
|
||||
? await sharp(resizedGeneratedBuffer, { failOn: "none" })
|
||||
.removeAlpha()
|
||||
.joinChannel(
|
||||
await buildLocalEditDiffCompositeMask({
|
||||
? await applyRawAlphaToImage({
|
||||
alphaMaskRaw: await normalizeAlphaMaskToRaw({
|
||||
height: sourceHeight,
|
||||
maskBuffer: await buildLocalEditDiffCompositeMask({
|
||||
composite,
|
||||
generatedBuffer: resizedGeneratedBuffer,
|
||||
height: sourceHeight,
|
||||
sourceBuffer,
|
||||
width: sourceWidth,
|
||||
})
|
||||
)
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer()
|
||||
}),
|
||||
width: sourceWidth,
|
||||
}),
|
||||
height: sourceHeight,
|
||||
imageBuffer: resizedGeneratedBuffer,
|
||||
width: sourceWidth,
|
||||
})
|
||||
: await sharp(resizedGeneratedBuffer, { failOn: "none" })
|
||||
.ensureAlpha()
|
||||
.composite([
|
||||
@ -2312,30 +2840,139 @@ async function applyScaleAdjustPatchCompositeToImages(params: {
|
||||
const touchesTop = workspaceBox.top <= 0;
|
||||
const touchesRight = workspaceBox.left + workspaceBox.width >= sourceWidth;
|
||||
const touchesBottom = workspaceBox.top + workspaceBox.height >= sourceHeight;
|
||||
const patchMask = buildScaleAdjustPatchCompositeMaskSvg({
|
||||
featherPx,
|
||||
height: workspaceBox.height,
|
||||
touchesBottom,
|
||||
touchesLeft,
|
||||
touchesRight,
|
||||
touchesTop,
|
||||
width: workspaceBox.width,
|
||||
});
|
||||
const maskRegionBoxes = composite.regions
|
||||
?.map((region) =>
|
||||
rectToPixelBox({
|
||||
height: sourceHeight,
|
||||
region,
|
||||
width: sourceWidth,
|
||||
})
|
||||
)
|
||||
.map((box) => {
|
||||
const left = Math.max(0, box.left - workspaceBox.left);
|
||||
const top = Math.max(0, box.top - workspaceBox.top);
|
||||
const right = Math.min(
|
||||
workspaceBox.width,
|
||||
box.left + box.width - workspaceBox.left
|
||||
);
|
||||
const bottom = Math.min(
|
||||
workspaceBox.height,
|
||||
box.top + box.height - workspaceBox.top
|
||||
);
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
})
|
||||
.filter((box) => box.width > 0 && box.height > 0);
|
||||
const staticPatchMask =
|
||||
composite.maskMode === "diff"
|
||||
? null
|
||||
: buildScaleAdjustPatchCompositeMaskSvg({
|
||||
featherPx,
|
||||
height: workspaceBox.height,
|
||||
regions: maskRegionBoxes,
|
||||
touchesBottom,
|
||||
touchesLeft,
|
||||
touchesRight,
|
||||
touchesTop,
|
||||
width: workspaceBox.width,
|
||||
});
|
||||
const regionClipPatchMask = maskRegionBoxes?.length
|
||||
? buildScaleAdjustPatchCompositeMaskSvg({
|
||||
featherPx,
|
||||
height: workspaceBox.height,
|
||||
regions: maskRegionBoxes,
|
||||
touchesBottom,
|
||||
touchesLeft,
|
||||
touchesRight,
|
||||
touchesTop,
|
||||
width: workspaceBox.width,
|
||||
})
|
||||
: null;
|
||||
const regionClipPatchMaskRaw = regionClipPatchMask
|
||||
? await normalizeOpacityMaskToRaw({
|
||||
height: workspaceBox.height,
|
||||
maskBuffer: regionClipPatchMask,
|
||||
width: workspaceBox.width,
|
||||
})
|
||||
: null;
|
||||
const sourcePatchBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||||
.extract(workspaceBox)
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const diffRegions = (maskRegionBoxes?.length
|
||||
? maskRegionBoxes
|
||||
: [{ left: 0, top: 0, width: workspaceBox.width, height: workspaceBox.height }]
|
||||
).map((box) => ({
|
||||
x: box.left / Math.max(1, workspaceBox.width),
|
||||
y: box.top / Math.max(1, workspaceBox.height),
|
||||
width: box.width / Math.max(1, workspaceBox.width),
|
||||
height: box.height / Math.max(1, workspaceBox.height),
|
||||
}));
|
||||
|
||||
const mergedImages: ParsedImage[] = [];
|
||||
for (const [index, image] of images.entries()) {
|
||||
const generatedUrl = image.originalUrl || image.thumbnailUrl;
|
||||
const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl);
|
||||
const patchOverlayBuffer = await sharp(generatedBuffer, { failOn: "none" })
|
||||
const resizedGeneratedBuffer = await sharp(generatedBuffer, { failOn: "none" })
|
||||
.rotate()
|
||||
.resize(workspaceBox.width, workspaceBox.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.ensureAlpha()
|
||||
.composite([{ input: patchMask, blend: "dest-in" }])
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const diffPatchMask =
|
||||
composite.maskMode === "diff"
|
||||
? await buildLocalEditDiffCompositeMask({
|
||||
composite: {
|
||||
diffCompareBlurPx: composite.diffCompareBlurPx,
|
||||
diffHighThreshold: composite.diffHighThreshold,
|
||||
diffLowThreshold: composite.diffLowThreshold,
|
||||
featherMaxPx: composite.featherMaxPx,
|
||||
featherMinPx: composite.featherMinPx,
|
||||
featherRatio: composite.featherRatio,
|
||||
maskMode: "diff",
|
||||
regions: diffRegions,
|
||||
sourceImageUrl: composite.sourceImageUrl,
|
||||
},
|
||||
generatedBuffer: resizedGeneratedBuffer,
|
||||
height: workspaceBox.height,
|
||||
sourceBuffer: sourcePatchBuffer,
|
||||
width: workspaceBox.width,
|
||||
})
|
||||
: null;
|
||||
const patchMask = diffPatchMask || staticPatchMask;
|
||||
if (!patchMask) {
|
||||
throw new Error("比例调整局部结果合成失败:无法生成合成蒙版");
|
||||
}
|
||||
const patchOverlayBuffer = diffPatchMask
|
||||
? await applyRawAlphaToImage({
|
||||
alphaMaskRaw: boostAlphaMask(
|
||||
multiplyAlphaMasks(
|
||||
await normalizeAlphaMaskToRaw({
|
||||
height: workspaceBox.height,
|
||||
maskBuffer: diffPatchMask,
|
||||
width: workspaceBox.width,
|
||||
}),
|
||||
regionClipPatchMaskRaw ||
|
||||
Buffer.alloc(workspaceBox.width * workspaceBox.height, 255)
|
||||
),
|
||||
8,
|
||||
3
|
||||
),
|
||||
height: workspaceBox.height,
|
||||
imageBuffer: resizedGeneratedBuffer,
|
||||
width: workspaceBox.width,
|
||||
})
|
||||
: await sharp(resizedGeneratedBuffer, { failOn: "none" })
|
||||
.ensureAlpha()
|
||||
.composite([{ input: patchMask, blend: "dest-in" }])
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
const mergedBuffer = await sharp(sourceBuffer, { failOn: "none" })
|
||||
.ensureAlpha()
|
||||
.composite([
|
||||
@ -2374,6 +3011,9 @@ async function applyScaleAdjustPatchCompositeToImages(params: {
|
||||
debugLabel,
|
||||
featherPx,
|
||||
imageCount: mergedImages.length,
|
||||
diffRegionClipApplied: Boolean(regionClipPatchMask),
|
||||
maskMode: composite.maskMode || "workspace",
|
||||
maskRegionCount: maskRegionBoxes?.length || 0,
|
||||
sourceHeight,
|
||||
sourceWidth,
|
||||
workspaceBox,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user