diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 5df759862..7cf7a4c02 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -93,6 +93,9 @@ const LOCAL_EDIT_COMPOSITE_MAX_PADDING_RATIO = 0.06; const LOCAL_EDIT_COMPOSITE_FEATHER_MIN_PX = 10; const LOCAL_EDIT_COMPOSITE_FEATHER_MAX_PX = 44; const SCALE_ADJUST_VISUAL_DIFF_THRESHOLD = 14; +const SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD = 18; +const SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO = 0.45; +const SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN = 0.035; const SCALE_ADJUST_DIFF_SAMPLE_SIZE = 112; const SCALE_ADJUST_FALLBACK_FEATHER_MIN_PX = 8; const SCALE_ADJUST_FALLBACK_FEATHER_MAX_PX = 36; @@ -295,6 +298,75 @@ function scaleRegionAroundCenter( return { x, y, width, height }; } +function expandCanvasRegion( + region: CanvasLocalEditCompositeRegion, + margin: number +): CanvasLocalEditCompositeRegion { + const x = clampRatio(region.x - margin); + const y = clampRatio(region.y - margin); + const right = clampRatio(region.x + region.width + margin); + const bottom = clampRatio(region.y + region.height + margin); + return { + x, + y, + width: Math.max(0, right - x), + height: Math.max(0, bottom - y), + }; +} + +function canvasRegionsIntersect( + left: CanvasLocalEditCompositeRegion, + right: CanvasLocalEditCompositeRegion +) { + return !( + left.x + left.width <= right.x || + right.x + right.width <= left.x || + left.y + left.height <= right.y || + right.y + right.height <= left.y + ); +} + +function getScaleAdjustProtectedRegions( + verification: CanvasScaleAdjustVerificationConfig +) { + return verification.regions.flatMap((region) => [ + expandCanvasRegion(region, SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN), + expandCanvasRegion( + scaleRegionAroundCenter(region, verification.scalePercent), + SCALE_ADJUST_OUTSIDE_PROTECTED_MARGIN + ), + ]); +} + +function getScaleAdjustOutsideSampleRegions( + verification: CanvasScaleAdjustVerificationConfig +) { + const protectedRegions = getScaleAdjustProtectedRegions(verification); + const candidates: CanvasLocalEditCompositeRegion[] = [ + { x: 0.03, y: 0.03, width: 0.2, height: 0.16 }, + { x: 0.4, y: 0.03, width: 0.2, height: 0.16 }, + { x: 0.77, y: 0.03, width: 0.2, height: 0.16 }, + { x: 0.03, y: 0.28, width: 0.2, height: 0.16 }, + { x: 0.77, y: 0.28, width: 0.2, height: 0.16 }, + { x: 0.03, y: 0.55, width: 0.2, height: 0.16 }, + { x: 0.77, y: 0.55, width: 0.2, height: 0.16 }, + { x: 0.03, y: 0.81, width: 0.2, height: 0.16 }, + { x: 0.4, y: 0.81, width: 0.2, height: 0.16 }, + { x: 0.77, y: 0.81, width: 0.2, height: 0.16 }, + { x: 0.28, y: 0.28, width: 0.18, height: 0.16 }, + { x: 0.54, y: 0.28, width: 0.18, height: 0.16 }, + { x: 0.28, y: 0.55, width: 0.18, height: 0.16 }, + { x: 0.54, y: 0.55, width: 0.18, height: 0.16 }, + ]; + + return candidates.filter( + (candidate) => + !protectedRegions.some((protectedRegion) => + canvasRegionsIntersect(candidate, protectedRegion) + ) + ); +} + function rectToPixelBox(params: { region: CanvasLocalEditCompositeRegion; width: number; @@ -424,6 +496,49 @@ async function getScaleAdjustDiffScores(params: { return scores; } +async function getScaleAdjustOutsideDiffScores(params: { + generatedBuffer: Buffer; + sourceBuffer: Buffer; + sourceWidth: number; + sourceHeight: number; + verification: CanvasScaleAdjustVerificationConfig; +}) { + const sampleRegions = getScaleAdjustOutsideSampleRegions(params.verification); + const scores: number[] = []; + + for (const region of sampleRegions) { + const [sourceSample, generatedSample] = await Promise.all([ + getScaleAdjustRegionSample({ + buffer: params.sourceBuffer, + sourceWidth: params.sourceWidth, + sourceHeight: params.sourceHeight, + region, + }), + getScaleAdjustRegionSample({ + buffer: params.generatedBuffer, + sourceWidth: params.sourceWidth, + sourceHeight: params.sourceHeight, + region, + }), + ]); + scores.push(calculateMeanAbsoluteDiff(sourceSample, generatedSample)); + } + + return scores; +} + +function isScaleAdjustOutsideChanged(scores: number[]) { + if (scores.length < 2) return false; + const changedCount = scores.filter( + (score) => score >= SCALE_ADJUST_OUTSIDE_DIFF_THRESHOLD + ).length; + const requiredCount = Math.max( + 2, + Math.ceil(scores.length * SCALE_ADJUST_OUTSIDE_DIFF_MIN_CHANGED_RATIO) + ); + return changedCount >= requiredCount; +} + async function buildScaleAdjustFallbackImage(params: { sourceBuffer: Buffer; sourceWidth: number; @@ -547,6 +662,7 @@ async function createScaleAdjustFallbackUpload(params: { imageIndex?: number; reason: string; diffScores?: number[]; + outsideDiffScores?: number[]; }) { const fallbackBuffer = await buildScaleAdjustFallbackImage({ sourceBuffer: params.sourceBuffer, @@ -576,6 +692,9 @@ async function createScaleAdjustFallbackUpload(params: { debugLabel: params.debugLabel, diffScores: params.diffScores?.map((score) => Math.round(score * 10) / 10), imageIndex: params.imageIndex, + outsideDiffScores: params.outsideDiffScores?.map( + (score) => Math.round(score * 10) / 10 + ), reason: params.reason, regionCount: params.verification.regions.length, scalePercent: params.verification.scalePercent, @@ -615,15 +734,26 @@ async function applyScaleAdjustVerificationToImages(params: { sourceWidth, regions: verification.regions, }); + const outsideDiffScores = await getScaleAdjustOutsideDiffScores({ + generatedBuffer, + sourceBuffer, + sourceHeight, + sourceWidth, + verification, + }); const isVisuallyUnchanged = diffScores.length > 0 && diffScores.every((score) => score < SCALE_ADJUST_VISUAL_DIFF_THRESHOLD); + const outsideChanged = isScaleAdjustOutsideChanged(outsideDiffScores); - if (!isVisuallyUnchanged) { + if (!isVisuallyUnchanged && !outsideChanged) { console.info("[Canvas Generate Image] Scale adjust output accepted:", { debugLabel, diffScores: diffScores.map((score) => Math.round(score * 10) / 10), imageIndex: index + 1, + outsideDiffScores: outsideDiffScores.map( + (score) => Math.round(score * 10) / 10 + ), scalePercent: verification.scalePercent, }); checkedImages.push(image); @@ -634,7 +764,8 @@ async function applyScaleAdjustVerificationToImages(params: { debugLabel, diffScores, imageIndex: index + 1, - reason: "unchanged-output", + outsideDiffScores, + reason: outsideChanged ? "outside-frame-changed" : "unchanged-output", sourceBuffer, sourceHeight, sourceWidth,