Skip to content
Open
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
36 changes: 35 additions & 1 deletion client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -691,17 +695,47 @@ 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<TrackSupportedFeature>[] = [];
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,
flick: flickNum,
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 {
Expand Down
76 changes: 76 additions & 0 deletions client/src/track.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
19 changes: 17 additions & 2 deletions client/src/track.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RectBounds } from './utils';
import { RectBounds, polygonEqualsBounds } from './utils';
import {
binarySearch,
listInsert,
Expand Down Expand Up @@ -662,10 +662,25 @@ export default class Track extends BaseAnnotation {
const sparseFeatures: Array<Feature> = [];
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);
Expand Down
117 changes: 117 additions & 0 deletions client/src/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading
Loading