diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 64247dcca..8159e3389 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -170,6 +170,8 @@ const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112; const SCALE_ADJUST_FALLBACK_MASK_DISTANCE_THRESHOLD = 42; const SCALE_ADJUST_FALLBACK_MASK_LUMA_GAP = 18; const SCALE_ADJUST_FALLBACK_INPAINT_ITERATIONS = 180; +const SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MAX_RATIO = 0.72; +const SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS = 60; const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([ "gemini", @@ -815,6 +817,109 @@ function isScaleAdjustOutsidePixelChanged(params: { ); } +async function getScaleAdjustFootprintChangeStats(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 shrinkStats: Array<{ + generatedOutsidePixels: number; + outsideRatio: number; + passed: boolean; + regionIndex: number; + sourceOutsidePixels: number; + }> = []; + + for (const [index, region] of verification.regions.entries()) { + const targetRegion = + verification.targetRegions?.[index] || + (typeof verification.scalePercent === "number" + ? scaleRegionAroundCenter(region, verification.scalePercent) + : null); + if (!targetRegion) continue; + const widthFactor = region.width > 0 ? targetRegion.width / region.width : 1; + const heightFactor = region.height > 0 ? targetRegion.height / region.height : 1; + const isShrink = widthFactor < 0.92 || heightFactor < 0.92; + if (!isShrink) continue; + + const sourceBox = rectToPixelBox({ + height: sourceHeight, + region, + width: sourceWidth, + }); + const targetBox = rectToPixelBox({ + height: sourceHeight, + region: targetRegion, + width: sourceWidth, + }); + const sourceMask = buildScaleAdjustFallbackSubjectMask({ + box: sourceBox, + raw: sourceRaw, + width: sourceWidth, + }); + const generatedMask = buildScaleAdjustFallbackSubjectMask({ + box: sourceBox, + raw: generatedRaw, + width: sourceWidth, + }); + let sourceOutsidePixels = 0; + let generatedOutsidePixels = 0; + for (let localY = 0; localY < sourceBox.height; localY += 1) { + const y = sourceBox.top + localY; + for (let localX = 0; localX < sourceBox.width; localX += 1) { + const x = sourceBox.left + localX; + const insideTarget = + x >= targetBox.left && + x < targetBox.left + targetBox.width && + y >= targetBox.top && + y < targetBox.top + targetBox.height; + if (insideTarget) continue; + const maskIndex = localY * sourceBox.width + localX; + if (sourceMask[maskIndex] > 0) sourceOutsidePixels += 1; + if (generatedMask[maskIndex] > 0) generatedOutsidePixels += 1; + } + } + const outsideRatio = + sourceOutsidePixels > 0 ? generatedOutsidePixels / sourceOutsidePixels : 0; + const passed = + sourceOutsidePixels < SCALE_ADJUST_SHRINK_MIN_SOURCE_OUTSIDE_PIXELS || + outsideRatio <= SCALE_ADJUST_SHRINK_OUTSIDE_FOOTPRINT_MAX_RATIO; + shrinkStats.push({ + generatedOutsidePixels, + outsideRatio, + passed, + regionIndex: index, + sourceOutsidePixels, + }); + } + + return { + failedRegionCount: shrinkStats.filter((item) => !item.passed).length, + passed: shrinkStats.every((item) => item.passed), + shrinkRegionCount: shrinkStats.length, + shrinkStats, + }; +} + async function loadScaleAdjustSourceImage( verification: CanvasScaleAdjustVerificationConfig ) { @@ -1038,6 +1143,7 @@ async function applyScaleAdjustVerificationToImages(params: { changedInside: boolean; cleanedImage?: ParsedImage; diffScores: number[]; + footprintStats: Awaited>; image: ParsedImage; index: number; outsideChanged: boolean; @@ -1050,7 +1156,13 @@ async function applyScaleAdjustVerificationToImages(params: { const generatedUrl = image.originalUrl || image.thumbnailUrl; const generatedBuffer = await fetchImageBufferFromUrl(generatedUrl); const changeRegions = getScaleAdjustEffectiveChangeRegions(verification); - const [diffScores, changeDiffScores, outsideDiffScores, outsidePixelStats] = await Promise.all([ + const [ + diffScores, + changeDiffScores, + outsideDiffScores, + outsidePixelStats, + footprintStats, + ] = await Promise.all([ getScaleAdjustDiffScores({ generatedBuffer, regions: verification.regions, @@ -1079,10 +1191,18 @@ async function applyScaleAdjustVerificationToImages(params: { sourceWidth: sourceImage.sourceWidth, verification, }), + getScaleAdjustFootprintChangeStats({ + generatedBuffer, + sourceBuffer: sourceImage.sourceBuffer, + sourceHeight: sourceImage.sourceHeight, + sourceWidth: sourceImage.sourceWidth, + verification, + }), ]); - const changedInside = changeDiffScores.some( + const visuallyChangedInside = changeDiffScores.some( (score) => score >= SCALE_ADJUST_VISUAL_DIFF_THRESHOLD ); + const changedInside = visuallyChangedInside && footprintStats.passed; const outsideChanged = isScaleAdjustOutsideChanged(outsideDiffScores) || isScaleAdjustOutsidePixelChanged(outsidePixelStats); @@ -1090,6 +1210,18 @@ async function applyScaleAdjustVerificationToImages(params: { console.info("[Canvas Generate Image] Scale adjust result guard:", { debugLabel, changedInside, + footprintChanged: footprintStats.passed, + footprintStats: { + failedRegionCount: footprintStats.failedRegionCount, + shrinkRegionCount: footprintStats.shrinkRegionCount, + shrinkStats: footprintStats.shrinkStats.map((item) => ({ + generatedOutsidePixels: item.generatedOutsidePixels, + outsideRatio: Number(item.outsideRatio.toFixed(3)), + passed: item.passed, + regionIndex: item.regionIndex, + sourceOutsidePixels: item.sourceOutsidePixels, + })), + }, imageIndex: index, outsideChanged, outsideDiffScores: outsideDiffScores.map((score) => Number(score.toFixed(2))), @@ -1120,6 +1252,18 @@ async function applyScaleAdjustVerificationToImages(params: { imageIndex: index, reasons: guardWarnings, changedInside, + footprintChanged: footprintStats.passed, + footprintStats: { + failedRegionCount: footprintStats.failedRegionCount, + shrinkRegionCount: footprintStats.shrinkRegionCount, + shrinkStats: footprintStats.shrinkStats.map((item) => ({ + generatedOutsidePixels: item.generatedOutsidePixels, + outsideRatio: Number(item.outsideRatio.toFixed(3)), + passed: item.passed, + regionIndex: item.regionIndex, + sourceOutsidePixels: item.sourceOutsidePixels, + })), + }, outsideChanged, outsideDiffScores: outsideDiffScores.map((score) => Number(score.toFixed(2))), outsidePixelStats: { @@ -1155,6 +1299,7 @@ async function applyScaleAdjustVerificationToImages(params: { changedInside, cleanedImage, diffScores, + footprintStats, image, index, outsideChanged, @@ -1176,6 +1321,7 @@ async function applyScaleAdjustVerificationToImages(params: { changedInside, cleanedImage, diffScores, + footprintStats, image, index, outsideChanged,