diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 69e47bc2a..ed76ddde8 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -8,6 +8,10 @@ import { updateBounds, validateRotation, getRotationFromAttributes, + hasSignificantRotation, + polygonWithinBounds, + polygonEqualsBounds, + clipPolygonToBounds, ROTATION_ATTRIBUTE_NAME, } from 'vue-media-annotator/utils'; import type AlignedViewStore from 'vue-media-annotator/alignedView/AlignedViewStore'; @@ -691,6 +695,34 @@ 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); + // 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 ?? ''); + } + } + }); + } track.setFeature({ frame: frameNum, @@ -698,10 +730,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/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 b916d002f..a80b2266e 100644 --- a/client/src/utils.spec.ts +++ b/client/src/utils.spec.ts @@ -17,6 +17,9 @@ import { ROTATION_THRESHOLD, ROTATION_ATTRIBUTE_NAME, featureHasSegmentationPolygon, + polygonWithinBounds, + polygonEqualsBounds, + clipPolygonToBounds, } from './utils'; import type { RectBounds } from './utils'; import type { Feature } from './track'; @@ -392,3 +395,117 @@ 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]], + ])); + }); +}); + +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 12e48c3d2..9b56621af 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -214,6 +214,133 @@ 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] + ))); +} + +/** + * 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[], + 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 +626,9 @@ export { reOrderBounds, reOrdergeoJSON, withinBounds, + polygonWithinBounds, + polygonEqualsBounds, + clipPolygonToBounds, frameToTimestamp, isAxisAligned, };