From 6f03dbaaa6dde86515f1d74485a848a69e4cc93f Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Tue, 21 Jul 2026 16:46:07 -0400 Subject: [PATCH 1/2] Trim a detection's polygon when its bounding box is shrunk over it Resizing a bounding box previously left the detection's polygon untouched, so shrinking the box could leave polygon geometry sticking out beyond it. Box edits now clip any polygon on the detection to the new bounds (Sutherland-Hodgman against the axis-aligned box), dropping a polygon entirely when it falls completely outside. Holes are clipped with the same rule. Rotated boxes are exempt since their stored bounds are the unrotated envelope. Co-Authored-By: Claude Fable 5 --- client/dive-common/use/useModeManager.ts | 33 +++++++++- client/src/utils.spec.ts | 64 ++++++++++++++++++ client/src/utils.ts | 83 ++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 69e47bc2a..7b4604280 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -8,6 +8,9 @@ import { updateBounds, validateRotation, getRotationFromAttributes, + hasSignificantRotation, + polygonWithinBounds, + clipPolygonToBounds, ROTATION_ATTRIBUTE_NAME, } from 'vue-media-annotator/utils'; import type AlignedViewStore from 'vue-media-annotator/alignedView/AlignedViewStore'; @@ -691,6 +694,32 @@ export default function useModeManager({ const [real] = features; // If there's already a keyframe at this frame, we're editing an existing annotation const isEditingExisting = real !== null && real.keyframe; + const normalizedRotation = validateRotation(rotation); + + // Trim any polygon on this detection to fit the new box. setFeature + // rounds bounds, so clip against the rounded values to stay + // consistent with what gets stored. Rotated boxes are skipped: their + // stored bounds are the unrotated envelope, so an axis-aligned clip + // would cut the wrong region. + const clippedGeometry: GeoJSON.Feature[] = []; + const removedPolygonKeys: string[] = []; + if (!hasSignificantRotation(normalizedRotation)) { + const roundedBounds: RectBounds = [ + Math.round(bounds[0]), Math.round(bounds[1]), + Math.round(bounds[2]), Math.round(bounds[3]), + ]; + track.getFeatureGeometry(frameNum, { type: 'Polygon' }).forEach((polyFeature) => { + const polygon = polyFeature.geometry as GeoJSON.Polygon; + if (!polygonWithinBounds(polygon, roundedBounds)) { + const clipped = clipPolygonToBounds(polygon, roundedBounds); + if (clipped) { + clippedGeometry.push({ ...polyFeature, geometry: clipped }); + } else { + removedPolygonKeys.push(polyFeature.properties?.key ?? ''); + } + } + }); + } track.setFeature({ frame: frameNum, @@ -698,10 +727,12 @@ export default function useModeManager({ bounds, keyframe: true, interpolate: _shouldInterpolate(interpolate), + }, clippedGeometry); + removedPolygonKeys.forEach((key) => { + track.removeFeatureGeometry(frameNum, { key, type: 'Polygon' }); }); // Save rotation as detection attribute if provided - const normalizedRotation = validateRotation(rotation); if (normalizedRotation !== undefined) { track.setFeatureAttribute(frameNum, ROTATION_ATTRIBUTE_NAME, normalizedRotation); } else { diff --git a/client/src/utils.spec.ts b/client/src/utils.spec.ts index b916d002f..6a1666d1f 100644 --- a/client/src/utils.spec.ts +++ b/client/src/utils.spec.ts @@ -17,6 +17,8 @@ import { ROTATION_THRESHOLD, ROTATION_ATTRIBUTE_NAME, featureHasSegmentationPolygon, + polygonWithinBounds, + clipPolygonToBounds, } from './utils'; import type { RectBounds } from './utils'; import type { Feature } from './track'; @@ -392,3 +394,65 @@ describe('featureHasSegmentationPolygon', () => { expect(featureHasSegmentationPolygon(headPointOnly, SegmentationPolygonKey)).toBe(false); }); }); + +describe('polygonWithinBounds / clipPolygonToBounds', () => { + const poly = (rings: GeoJSON.Position[][]): GeoJSON.Polygon => ({ + type: 'Polygon', + coordinates: rings, + }); + const square: GeoJSON.Polygon = poly([[ + [10, 10], [30, 10], [30, 30], [10, 30], [10, 10], + ]]); + const bounds: RectBounds = [0, 0, 40, 40]; + + it('polygonWithinBounds is inclusive of the boundary', () => { + expect(polygonWithinBounds(square, bounds)).toBe(true); + expect(polygonWithinBounds(square, [10, 10, 30, 30])).toBe(true); + expect(polygonWithinBounds(square, [11, 10, 30, 30])).toBe(false); + }); + + it('a polygon fully inside is returned unchanged', () => { + expect(clipPolygonToBounds(square, bounds)).toEqual(square); + }); + + it('a polygon crossing the bounds is trimmed to fit', () => { + const clipped = clipPolygonToBounds(square, [0, 0, 20, 40]); + expect(clipped).toEqual(poly([[ + [10, 10], [20, 10], [20, 30], [10, 30], [10, 10], + ]])); + }); + + it('a polygon fully outside returns null', () => { + expect(clipPolygonToBounds(square, [50, 50, 80, 80])).toBeNull(); + }); + + it('clips against all four edges at once', () => { + const clipped = clipPolygonToBounds(square, [15, 15, 25, 25]); + expect(clipped).toEqual(poly([[ + [15, 25], [15, 15], [25, 15], [25, 25], [15, 25], + ]])); + }); + + it('computes intersection points on slanted edges', () => { + const triangle = poly([[ + [0, 0], [20, 0], [0, 20], [0, 0], + ]]); + const clipped = clipPolygonToBounds(triangle, [0, 0, 10, 20]); + expect(clipped).toEqual(poly([[ + [0, 0], [10, 0], [10, 10], [0, 20], [0, 0], + ]])); + }); + + it('clips holes and drops holes left entirely outside', () => { + const withHoles = poly([ + [[0, 0], [40, 0], [40, 40], [0, 40], [0, 0]], + [[5, 5], [8, 5], [8, 8], [5, 8], [5, 5]], + [[30, 30], [35, 30], [35, 35], [30, 35], [30, 30]], + ]); + const clipped = clipPolygonToBounds(withHoles, [0, 0, 20, 20]); + expect(clipped).toEqual(poly([ + [[0, 20], [0, 0], [20, 0], [20, 20], [0, 20]], + [[5, 5], [8, 5], [8, 8], [5, 8], [5, 5]], + ])); + }); +}); diff --git a/client/src/utils.ts b/client/src/utils.ts index 12e48c3d2..fd17355e2 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -214,6 +214,87 @@ function withinBounds(coord: [number, number], bounds: RectBounds) { return (x > bounds[0] && x < bounds[2] && y > bounds[1] && y < bounds[3]); } +/** + * Inclusive test that every vertex of a polygon lies within the given + * axis-aligned bounds [x1, y1, x2, y2]. + */ +function polygonWithinBounds(polygon: GeoJSON.Polygon, bounds: RectBounds): boolean { + return polygon.coordinates.every((ring) => ring.every((pos) => ( + pos[0] >= bounds[0] && pos[0] <= bounds[2] && pos[1] >= bounds[1] && pos[1] <= bounds[3] + ))); +} + +/* Clip one open linear ring against a single half-plane (Sutherland–Hodgman step) */ +function clipRingAgainstEdge( + points: GeoJSON.Position[], + inside: (pos: GeoJSON.Position) => boolean, + intersect: (a: GeoJSON.Position, b: GeoJSON.Position) => GeoJSON.Position, +): GeoJSON.Position[] { + const output: GeoJSON.Position[] = []; + for (let i = 0; i < points.length; i += 1) { + const current = points[i]; + const previous = points[(i + points.length - 1) % points.length]; + if (inside(current)) { + if (!inside(previous)) { + output.push(intersect(previous, current)); + } + output.push(current); + } else if (inside(previous)) { + output.push(intersect(previous, current)); + } + } + return output; +} + +/** + * Clip a polygon to axis-aligned bounds [x1, y1, x2, y2] using the + * Sutherland–Hodgman algorithm. Holes are clipped as well; a hole left + * with fewer than 3 points is dropped. Returns null when the exterior + * ring falls entirely outside the bounds. + */ +function clipPolygonToBounds( + polygon: GeoJSON.Polygon, + bounds: RectBounds, +): GeoJSON.Polygon | null { + const [x1, y1, x2, y2] = bounds; + const edges: [ + (pos: GeoJSON.Position) => boolean, + (a: GeoJSON.Position, b: GeoJSON.Position) => GeoJSON.Position, + ][] = [ + [ + (pos) => pos[0] >= x1, + (a, b) => [x1, a[1] + (((x1 - a[0]) / (b[0] - a[0])) * (b[1] - a[1]))], + ], + [ + (pos) => pos[0] <= x2, + (a, b) => [x2, a[1] + (((x2 - a[0]) / (b[0] - a[0])) * (b[1] - a[1]))], + ], + [ + (pos) => pos[1] >= y1, + (a, b) => [a[0] + (((y1 - a[1]) / (b[1] - a[1])) * (b[0] - a[0])), y1], + ], + [ + (pos) => pos[1] <= y2, + (a, b) => [a[0] + (((y2 - a[1]) / (b[1] - a[1])) * (b[0] - a[0])), y2], + ], + ]; + const rings: GeoJSON.Position[][] = []; + for (let i = 0; i < polygon.coordinates.length; i += 1) { + // Work on the open ring; GeoJSON rings repeat the first point at the end + let clipped = polygon.coordinates[i].slice(0, -1); + edges.forEach(([inside, intersect]) => { + clipped = clipRingAgainstEdge(clipped, inside, intersect); + }); + if (clipped.length >= 3) { + rings.push([...clipped, clipped[0]]); + } else if (i === 0) { + // Exterior ring is entirely outside: the polygon is gone + return null; + } + } + return { type: 'Polygon', coordinates: rings }; +} + function frameToTimestamp(frame: number, frameRate: number): string | null { const ms = (frame / frameRate) * 1000; const date = new Date(ms); @@ -499,6 +580,8 @@ export { reOrderBounds, reOrdergeoJSON, withinBounds, + polygonWithinBounds, + clipPolygonToBounds, frameToTimestamp, isAxisAligned, }; From 2d58d786e53e082ec8964875939b3a901aa0251c Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 22 Jul 2026 09:34:21 -0400 Subject: [PATCH 2/2] Drop polygons that are just the full detection box A polygon identical to the detection's bounding box carries no information beyond the box itself and makes editing harder, so: - when a box shrink clips a polygon down to exactly the new box, the polygon is removed instead of kept as a redundant rectangle; - on load, Track.fromJSON drops any polygon equal to its feature's bounds (extra collinear vertices and either winding still count as equal; polygons with holes or any interior structure are kept). The equality test (polygonEqualsBounds) requires a hole-free polygon whose extent matches the bounds and whose area fills the box - a simple polygon with the area of its bounding box must be that rectangle. Co-Authored-By: Claude Fable 5 --- client/dive-common/use/useModeManager.ts | 5 +- client/src/track.spec.ts | 76 ++++++++++++++++++++++++ client/src/track.ts | 19 +++++- client/src/utils.spec.ts | 53 +++++++++++++++++ client/src/utils.ts | 47 +++++++++++++++ 5 files changed, 197 insertions(+), 3 deletions(-) diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 7b4604280..ed76ddde8 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -10,6 +10,7 @@ import { getRotationFromAttributes, hasSignificantRotation, polygonWithinBounds, + polygonEqualsBounds, clipPolygonToBounds, ROTATION_ATTRIBUTE_NAME, } from 'vue-media-annotator/utils'; @@ -712,7 +713,9 @@ export default function useModeManager({ const polygon = polyFeature.geometry as GeoJSON.Polygon; if (!polygonWithinBounds(polygon, roundedBounds)) { const clipped = clipPolygonToBounds(polygon, roundedBounds); - if (clipped) { + // A clip that leaves the polygon identical to the box carries + // no information beyond the box itself, so drop it too. + if (clipped && !polygonEqualsBounds(clipped, roundedBounds)) { clippedGeometry.push({ ...polyFeature, geometry: clipped }); } else { removedPolygonKeys.push(polyFeature.properties?.key ?? ''); diff --git a/client/src/track.spec.ts b/client/src/track.spec.ts index e35e6839e..8165b6144 100644 --- a/client/src/track.spec.ts +++ b/client/src/track.spec.ts @@ -314,3 +314,79 @@ describe('exceedsThreshold', () => { expect(Track.exceedsThreshold([['foo', 0]], {})).toEqual([['foo', 0]]); }); }); + +describe('fromJSON full-box polygon cleanup', () => { + const base = { + attributes: {}, + begin: 0, + end: 0, + confidencePairs: [['seal', 0.9]] as ConfidencePair[], + meta: {}, + id: 7, + }; + const boxPoly: GeoJSON.Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]], + }, + properties: { key: '' }, + }; + const realPoly: GeoJSON.Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[[2, 2], [8, 2], [5, 8], [2, 2]]], + }, + properties: { key: '' }, + }; + + it('drops a polygon that is just the full detection box', () => { + const track = Track.fromJSON({ + ...base, + features: [{ + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + keyframe: true, + interpolate: false, + geometry: { type: 'FeatureCollection', features: [boxPoly] }, + }], + }); + expect(track.features[0].geometry).toBeUndefined(); + }); + + it('keeps polygons that differ from the box', () => { + const track = Track.fromJSON({ + ...base, + features: [{ + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + keyframe: true, + interpolate: false, + geometry: { type: 'FeatureCollection', features: [realPoly] }, + }], + }); + expect(track.features[0].geometry?.features).toHaveLength(1); + }); + + it('keeps other geometry while dropping the full-box polygon', () => { + const head: GeoJSON.Feature = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 1] }, + properties: { key: 'head' }, + }; + const track = Track.fromJSON({ + ...base, + features: [{ + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + keyframe: true, + interpolate: false, + geometry: { type: 'FeatureCollection', features: [boxPoly, head] }, + }], + }); + const kept = track.features[0].geometry?.features || []; + expect(kept).toHaveLength(1); + expect(kept[0].geometry.type).toBe('Point'); + }); +}); diff --git a/client/src/track.ts b/client/src/track.ts index 667d7548c..13f237d9b 100644 --- a/client/src/track.ts +++ b/client/src/track.ts @@ -1,4 +1,4 @@ -import { RectBounds } from './utils'; +import { RectBounds, polygonEqualsBounds } from './utils'; import { binarySearch, listInsert, @@ -662,10 +662,25 @@ export default class Track extends BaseAnnotation { const sparseFeatures: Array = []; json.features.forEach((f) => { if (f === null || f === undefined) return; - sparseFeatures[f.frame] = { + const feature: Feature = { keyframe: true, ...f, }; + // A polygon that is just the full detection box carries no information + // beyond the box itself and makes editing harder; drop such polygons + // when loading. + if (feature.bounds && feature.geometry?.features?.length) { + const kept = feature.geometry.features.filter((geo) => !( + geo.geometry?.type === 'Polygon' + && polygonEqualsBounds(geo.geometry, feature.bounds as RectBounds) + )); + if (kept.length !== feature.geometry.features.length) { + feature.geometry = kept.length + ? { ...feature.geometry, features: kept } + : undefined; + } + } + sparseFeatures[f.frame] = feature; }); // accept either number or string, convert to number const intTrackId = parseInt(json.id.toString(), 10); diff --git a/client/src/utils.spec.ts b/client/src/utils.spec.ts index 6a1666d1f..a80b2266e 100644 --- a/client/src/utils.spec.ts +++ b/client/src/utils.spec.ts @@ -18,6 +18,7 @@ import { ROTATION_ATTRIBUTE_NAME, featureHasSegmentationPolygon, polygonWithinBounds, + polygonEqualsBounds, clipPolygonToBounds, } from './utils'; import type { RectBounds } from './utils'; @@ -456,3 +457,55 @@ describe('polygonWithinBounds / clipPolygonToBounds', () => { ])); }); }); + +describe('polygonEqualsBounds', () => { + const poly = (rings: GeoJSON.Position[][]): GeoJSON.Polygon => ({ + type: 'Polygon', + coordinates: rings, + }); + const bounds: RectBounds = [10, 20, 50, 60]; + + it('matches the exact box rectangle', () => { + expect(polygonEqualsBounds(poly([[ + [10, 20], [50, 20], [50, 60], [10, 60], [10, 20], + ]]), bounds)).toBe(true); + // Any starting vertex / winding + expect(polygonEqualsBounds(poly([[ + [10, 60], [50, 60], [50, 20], [10, 20], [10, 60], + ]]), bounds)).toBe(true); + }); + + it('matches a box rectangle with extra collinear points (clip artifacts)', () => { + expect(polygonEqualsBounds(poly([[ + [10, 20], [30, 20], [50, 20], [50, 40], [50, 60], [10, 60], [10, 20], + ]]), bounds)).toBe(true); + }); + + it('rejects polygons that do not fill the box', () => { + // Triangle whose vertices sit on the box perimeter + expect(polygonEqualsBounds(poly([[ + [10, 20], [50, 20], [10, 60], [10, 20], + ]]), bounds)).toBe(false); + // Smaller rectangle + expect(polygonEqualsBounds(poly([[ + [15, 25], [45, 25], [45, 55], [15, 55], [15, 25], + ]]), bounds)).toBe(false); + // Same extent, notched corner + expect(polygonEqualsBounds(poly([[ + [10, 20], [50, 20], [50, 60], [30, 60], [30, 40], [10, 40], [10, 20], + ]]), bounds)).toBe(false); + }); + + it('rejects polygons with holes', () => { + expect(polygonEqualsBounds(poly([ + [[10, 20], [50, 20], [50, 60], [10, 60], [10, 20]], + [[20, 30], [30, 30], [30, 40], [20, 40], [20, 30]], + ]), bounds)).toBe(false); + }); + + it('rejects a box rectangle with a different extent', () => { + expect(polygonEqualsBounds(poly([[ + [0, 0], [40, 0], [40, 40], [0, 40], [0, 0], + ]]), bounds)).toBe(false); + }); +}); diff --git a/client/src/utils.ts b/client/src/utils.ts index fd17355e2..9b56621af 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -224,6 +224,52 @@ function polygonWithinBounds(polygon: GeoJSON.Polygon, bounds: RectBounds): bool ))); } +/** + * True when the polygon is just the full axis-aligned rectangle of `bounds`: + * no holes, its extent matches the bounds, and its area fills the whole box + * (a simple polygon whose area equals its bounding box's area must be that + * rectangle). Such a polygon carries no information beyond the box itself. + */ +function polygonEqualsBounds( + polygon: GeoJSON.Polygon, + bounds: RectBounds, + tolerance = 1e-3, +): boolean { + if (polygon.coordinates.length !== 1) { + return false; + } + const ring = polygon.coordinates[0]; + if (ring.length < 4) { + return false; + } + let xLow = Infinity; + let yLow = Infinity; + let xHigh = -Infinity; + let yHigh = -Infinity; + ring.forEach(([x, y]) => { + xLow = Math.min(xLow, x); + yLow = Math.min(yLow, y); + xHigh = Math.max(xHigh, x); + yHigh = Math.max(yHigh, y); + }); + if (Math.abs(xLow - bounds[0]) > tolerance || Math.abs(yLow - bounds[1]) > tolerance + || Math.abs(xHigh - bounds[2]) > tolerance || Math.abs(yHigh - bounds[3]) > tolerance) { + return false; + } + // Shoelace area over the open ring (GeoJSON rings repeat the first point) + const open = (ring[0][0] === ring[ring.length - 1][0] + && ring[0][1] === ring[ring.length - 1][1]) ? ring.slice(0, -1) : ring; + let area = 0; + for (let i = 0; i < open.length; i += 1) { + const [x1, y1] = open[i]; + const [x2, y2] = open[(i + 1) % open.length]; + area += (x1 * y2) - (x2 * y1); + } + area = Math.abs(area) / 2; + const boxArea = (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]); + return Math.abs(area - boxArea) <= tolerance * Math.max(1, boxArea); +} + /* Clip one open linear ring against a single half-plane (Sutherland–Hodgman step) */ function clipRingAgainstEdge( points: GeoJSON.Position[], @@ -581,6 +627,7 @@ export { reOrdergeoJSON, withinBounds, polygonWithinBounds, + polygonEqualsBounds, clipPolygonToBounds, frameToTimestamp, isAxisAligned,