Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@vizzly-testing/honeydiff": "^0.10.0",
"@vizzly-testing/honeydiff": "^0.10.3",
"ansis": "^4.2.0",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dotenv": "^17.2.1",
"form-data": "^4.0.0",
"glob": "^13.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"zod": "^4.1.12"
},
"tsd": {
Expand Down Expand Up @@ -128,8 +130,6 @@
"babel-plugin-react-compiler": "^1.0.0",
"babel-plugin-transform-remove-console": "^6.9.4",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"rimraf": "^6.0.1",
"tailwindcss": "^4.1.13",
"tsd": "^0.33.0",
Expand Down
158 changes: 141 additions & 17 deletions src/tdd/core/region-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* 2D boxes that users have manually confirmed via the cloud UI.
*/

let REGION_CENTER_TOLERANCE = 10;

/**
* Check if a diff cluster intersects with a region (2D box intersection)
*
Expand Down Expand Up @@ -43,12 +45,102 @@ export function clusterIntersectsRegion(cluster, region) {
return !noOverlap;
}

function normalizeBoundingBox(cluster) {
let box = cluster?.boundingBox || cluster;
if (!box) {
return null;
}

if (
Number.isFinite(box.x) &&
Number.isFinite(box.y) &&
Number.isFinite(box.width) &&
Number.isFinite(box.height)
) {
return {
x1: box.x,
y1: box.y,
x2: box.x + box.width,
y2: box.y + box.height,
width: Math.abs(box.width),
height: Math.abs(box.height),
};
}

if (
Number.isFinite(box.x1) &&
Number.isFinite(box.y1) &&
Number.isFinite(box.x2) &&
Number.isFinite(box.y2)
) {
let width = Math.abs(box.x2 - box.x1);
let height = Math.abs(box.y2 - box.y1);
return {
x1: box.x1,
y1: box.y1,
x2: box.x2,
y2: box.y2,
width,
height,
};
}

return null;
}

function getBoundingBoxCenter(box) {
return {
x: (box.x1 + box.x2) / 2,
y: (box.y1 + box.y2) / 2,
};
}

function getRegionLabel(region) {
return region.id || region.label || null;
}

function clusterMatchesRegion(cluster, region) {
let clusterBox = normalizeBoundingBox(cluster);
let regionBox = normalizeBoundingBox(region);

if (!clusterBox || !regionBox) {
return false;
}

let clusterCenter = getBoundingBoxCenter(clusterBox);
let regionCenter = getBoundingBoxCenter(regionBox);
let distance = Math.sqrt(
(clusterCenter.x - regionCenter.x) ** 2 +
(clusterCenter.y - regionCenter.y) ** 2
);

return distance <= REGION_CENTER_TOLERANCE;
}

function estimateClusterPixels(cluster) {
if (Number.isFinite(cluster?.pixelCount)) {
return cluster.pixelCount;
}

let box = normalizeBoundingBox(cluster);
if (!box) {
return 0;
}

return box.width * box.height * 0.5;
}

/**
* Calculate what percentage of diff clusters fall within region boxes
* Calculate what percentage of changed pixels match confirmed regions.
*
* This mirrors cloud dynamic-region approval: clusters match confirmed regions
* by center proximity, then coverage is weighted by changed pixels. A huge
* unmatched change should not be hidden just because several tiny clusters
* match confirmed regions.
*
* @param {Array} diffClusters - Array of diff clusters from honeydiff
* @param {Array} regions - Array of confirmed regions { x1, y1, x2, y2 }
* @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[] }}
* @returns {{ coverage: number, clustersInRegions: number, totalClusters: number, matchedRegions: string[], pixelsInRegions: number, totalPixels: number }}
*/
export function calculateRegionCoverage(diffClusters, regions) {
if (!diffClusters || diffClusters.length === 0) {
Expand All @@ -57,6 +149,8 @@ export function calculateRegionCoverage(diffClusters, regions) {
clustersInRegions: 0,
totalClusters: 0,
matchedRegions: [],
pixelsInRegions: 0,
totalPixels: 0,
};
}

Expand All @@ -66,58 +160,88 @@ export function calculateRegionCoverage(diffClusters, regions) {
clustersInRegions: 0,
totalClusters: diffClusters.length,
matchedRegions: [],
pixelsInRegions: 0,
totalPixels: 0,
};
}

let clustersInRegions = 0;
let matchedRegionIds = new Set();
let pixelsInRegions = 0;
let totalPixels = 0;

for (let cluster of diffClusters) {
// Check if this cluster intersects any region
let intersectsAnyRegion = false;
let pixelCount = estimateClusterPixels(cluster);
totalPixels += pixelCount;

let matchedAnyRegion = false;

for (let region of regions) {
if (clusterIntersectsRegion(cluster, region)) {
intersectsAnyRegion = true;
// Track which regions were matched (for debugging/display)
if (region.id) {
matchedRegionIds.add(region.id);
} else if (region.label) {
matchedRegionIds.add(region.label);
if (clusterMatchesRegion(cluster, region)) {
matchedAnyRegion = true;

let regionLabel = getRegionLabel(region);
if (regionLabel) {
matchedRegionIds.add(regionLabel);
}
}
}

if (intersectsAnyRegion) {
if (matchedAnyRegion) {
clustersInRegions++;
pixelsInRegions += pixelCount;
}
}

let coverage = clustersInRegions / diffClusters.length;
let coverage = totalPixels > 0 ? pixelsInRegions / totalPixels : 0;

return {
coverage,
clustersInRegions,
totalClusters: diffClusters.length,
matchedRegions: [...matchedRegionIds],
pixelsInRegions,
totalPixels,
};
}

/**
* Determine if a comparison should auto-pass based on region coverage
*
* Unlike hotspots which require confidence scoring, user-defined regions
* are already confirmed by humans, so we only need the 80% threshold.
* are already confirmed by humans. Cloud still requires strong structural
* similarity so a layout shift cannot hide inside a confirmed box.
*
* @param {Array} regions - Confirmed regions (already filtered to confirmed status)
* @param {{ coverage: number }} coverageResult - Result from calculateRegionCoverage
* @param {Object} options - Approval thresholds
* @param {number|null} options.ssimScore - Honeydiff SSIM/perceptual score
* @param {number} options.coverageThreshold - Required region coverage
* @param {number} options.ssimThreshold - Required structural similarity
* @returns {boolean} True if diff should auto-pass as region-filtered
*/
export function shouldAutoApproveFromRegions(regions, coverageResult) {
export function shouldAutoApproveFromRegions(
regions,
coverageResult,
options = {}
) {
if (!regions || regions.length === 0 || !coverageResult) {
return false;
}

// Need at least 80% of diff clusters in confirmed regions
return coverageResult.coverage >= 0.8;
let {
ssimScore = null,
coverageThreshold = 0.9,
ssimThreshold = 0.95,
} = options;

if (coverageResult.coverage < coverageThreshold) {
return false;
}

if (ssimScore === null || ssimScore === undefined) {
return false;
}

return ssimScore >= ssimThreshold;
}
17 changes: 16 additions & 1 deletion src/tdd/services/comparison-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export async function compareImages(
diffPath,
overwrite: true,
includeClusters: true,
includeSSIM: true,
includeGMSD: true,
clusterMerge: true,
minClusterSize,
});
}
Expand Down Expand Up @@ -144,6 +147,11 @@ export function buildFailedComparison(params) {
} = params;

let diffClusters = honeydiffResult.diffClusters || [];
let ssimScore =
honeydiffResult.perceptualScore ??
honeydiffResult.ssimScore ??
honeydiffResult.ssim_score ??
null;
let isFiltered = false;
let filterReason = 'pixel-diff';

Expand All @@ -155,7 +163,11 @@ export function buildFailedComparison(params) {
if (confirmedRegions.length > 0 && diffClusters.length > 0) {
regionCoverage = calculateRegionCoverage(diffClusters, confirmedRegions);

if (shouldAutoApproveFromRegions(confirmedRegions, regionCoverage)) {
if (
shouldAutoApproveFromRegions(confirmedRegions, regionCoverage, {
ssimScore,
})
) {
isRegionFiltered = true;
isFiltered = true;
filterReason = 'region-filtered';
Expand Down Expand Up @@ -210,8 +222,11 @@ export function buildFailedComparison(params) {
coverage: regionCoverage.coverage,
clustersInRegions: regionCoverage.clustersInRegions,
totalClusters: regionCoverage.totalClusters,
pixelsInRegions: regionCoverage.pixelsInRegions,
totalPixels: regionCoverage.totalPixels,
matchedRegions: regionCoverage.matchedRegions,
confirmedCount: confirmedRegions.length,
ssimScore,
isFiltered: isRegionFiltered,
}
: null,
Expand Down
Loading