From 54de26875b1842e24a9a713183642a1569fad783 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Sat, 24 Jan 2026 22:46:55 -0500 Subject: [PATCH 01/12] WIP: Multi-polygon support with holes - Add CSV format extension for multiple polygons and holes per detection - Format: (poly[:key][:hole[:index]]) coordinates - Backward compatible with existing (poly) format - Add UI buttons for "Add Hole" and "Add Polygon" in polygon edit mode - Fix polygon key matching to properly handle empty string keys - Add selectedKeyRef to LayerManager watcher for proper layer updates - Add track methods: getPolygonFeatures, addHoleToPolygon, removeHoleFromPolygon - Add auto-hole detection using ray casting algorithm - Support clicking on individual polygons to select for editing Still needs testing for: - Polygon merge for overlapping polygons - Boolean difference for holes partially outside polygons Co-Authored-By: Claude Opus 4.5 --- .../dive-common/components/DeleteControls.vue | 122 +++++++++-- client/dive-common/components/Viewer.vue | 2 + client/dive-common/recipes/polygonbase.ts | 174 +++++++++++++++- client/dive-common/use/useModeManager.ts | 43 +++- .../desktop/backend/serializers/viame.ts | 67 +++++- client/src/components/LayerManager.vue | 5 + .../layers/AnnotationLayers/PolygonLayer.ts | 22 +- client/src/layers/EditAnnotationLayer.ts | 44 +++- client/src/track.ts | 123 ++++++++++- server/dive_utils/serializers/viame.py | 88 ++++++-- server/tests/test_serialize_viame_csv.py | 123 +++++++++++ testutils/viame.spec.json | 197 ++++++++++++++++++ 12 files changed, 948 insertions(+), 62 deletions(-) diff --git a/client/dive-common/components/DeleteControls.vue b/client/dive-common/components/DeleteControls.vue index 50ecdd08a..7ff5a0acc 100644 --- a/client/dive-common/components/DeleteControls.vue +++ b/client/dive-common/components/DeleteControls.vue @@ -26,6 +26,15 @@ export default Vue.extend({ } return false; }, + isPolygonMode(): boolean { + return this.editingMode === 'Polygon'; + }, + editModeIcon(): string { + if (this.editingMode === 'Polygon') return 'mdi-vector-polygon'; + if (this.editingMode === 'LineString') return 'mdi-vector-line'; + if (this.editingMode === 'rectangle') return 'mdi-vector-square'; + return 'mdi-shape'; + }, }, methods: { @@ -39,33 +48,104 @@ export default Vue.extend({ this.$emit('delete-annotation'); } }, + addHole() { + this.$emit('add-hole'); + }, + addPolygon() { + this.$emit('add-polygon'); + }, }, }); diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index b3da641be..0c7622ae8 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -1010,6 +1010,8 @@ export default defineComponent({ class="mr-2" @delete-point="handler.removePoint" @delete-annotation="handler.removeAnnotation" + @add-hole="handler.addHole" + @add-polygon="handler.addPolygon" /> - Add another polygon + Add hole to polygon diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 58be08e82..98dc39c35 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -570,11 +570,32 @@ export default function useModeManager({ const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); if (track) { const { frame } = aggregateController.value; + const frameNum = frame.value; recipes.forEach((r) => { if (r.active.value) { - r.delete(frame.value, track, selectedKey.value, annotationModes.editing); + r.delete(frameNum, track, selectedKey.value, annotationModes.editing); } }); + + // After deleting a polygon, recalculate bounds from remaining polygons + if (annotationModes.editing === 'Polygon') { + const remainingPolygons = track.getPolygonFeatures(frameNum); + if (remainingPolygons.length > 0) { + // Recalculate bounds from remaining polygons + const polygonGeometries = remainingPolygons.map((p) => p.geometry); + const newBounds = updateBounds(undefined, [], polygonGeometries); + + // Get current feature and update with new bounds + const [currentFeature] = track.getFeature(frameNum); + if (currentFeature && newBounds) { + track.setFeature({ + ...currentFeature, + bounds: newBounds, + }); + } + } + } + _nudgeEditingCanary(); } } From 13b4727ae06916fd3da16a83a2a437fc17847538 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 28 Jan 2026 23:59:01 -0500 Subject: [PATCH 11/12] Fix hole mode to behave identically to polygon mode Changes: - Return polygon data with hole included instead of modifying track directly, making the update flow consistent with add polygon mode - Use === undefined checks to distinguish "no key change" from "change to empty key" for proper mode transitions - Allow right-click to cancel creation and select polygons/holes - Add polygon-right-clicked-outside event for finalizing when clicking outside polygons - Add cancelCreation handler and update Handler interface This makes hole mode and polygon mode behave identically for: - Right-click finalization - Selection (click or right-click on polygons/holes) - Editing and deletion Co-Authored-By: Claude Opus 4.5 --- client/dive-common/recipes/polygonbase.ts | 23 +++++++++++++-- client/dive-common/use/useModeManager.ts | 18 ++++++++++-- client/src/components/LayerManager.vue | 28 ++++++++++++++++--- .../layers/AnnotationLayers/PolygonLayer.ts | 7 ++++- client/src/provides.ts | 9 ++++++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/client/dive-common/recipes/polygonbase.ts b/client/dive-common/recipes/polygonbase.ts index b46a40c81..8fe53cea3 100644 --- a/client/dive-common/recipes/polygonbase.ts +++ b/client/dive-common/recipes/polygonbase.ts @@ -77,6 +77,7 @@ export default class PolygonBoundsExpand implements Recipe { setAddingHole() { this.addingMode.value = 'hole'; // Emit activate event with special key to trigger creation mode + // The special key ensures no geometry matches, forcing creation mode this.bus.$emit('activate', { editing: 'Polygon' as EditAnnotationTypes, key: '__adding_hole__', @@ -120,13 +121,29 @@ export default class PolygonBoundsExpand implements Recipe { if (existingPolygons.length > 0) { // Add as hole to the first (default) polygon const targetPoly = existingPolygons[0]; - track.addHoleToPolygon(frameNum, targetPoly.key, newPolyCoords); + // Create updated polygon geometry with the hole added + const updatedCoordinates = [ + ...targetPoly.geometry.coordinates, + newPolyCoords, + ]; + const updatedPolygon: GeoJSON.Polygon = { + type: 'Polygon', + coordinates: updatedCoordinates, + }; + const updatedFeature: GeoJSON.Feature = { + type: 'Feature', + properties: { key: targetPoly.key }, + geometry: updatedPolygon, + }; + // Return data like add polygon mode so right-click behavior is consistent return { - data: {}, + data: { + [targetPoly.key]: [updatedFeature], + }, union: [], done: true, unionWithoutBounds: [], - newSelectedKey: targetPoly.key, // Reset to the polygon's key + newSelectedKey: targetPoly.key, // Set to target polygon's key for proper mode transition }; } // No existing polygon, treat as normal (create first polygon) diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 98dc39c35..45bd71df2 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -493,16 +493,18 @@ export default function useModeManager({ // If a drawable changed, but we aren't changing modes // prevent an interrupt within EditAnnotationLayer + // Use === undefined to distinguish "no key change" from "change to empty key" if ( somethingChanged - && !update.newSelectedKey + && update.newSelectedKey === undefined && !update.newType && preventInterrupt ) { preventInterrupt(); } else { // Otherwise, one of these state changes will trigger an interrupt. - if (update.newSelectedKey) { + // Use !== undefined to allow setting key to empty string + if (update.newSelectedKey !== undefined) { selectedKey.value = update.newSelectedKey; } if (update.newType) { @@ -857,6 +859,17 @@ export default function useModeManager({ } } + /** + * Cancel any in-progress creation mode (hole or polygon addition). + * This resets the recipe's adding mode so right-click selection can work. + */ + function handleCancelCreation() { + const polygonRecipe = recipes.find((r) => r.name === 'PolygonBase'); + if (polygonRecipe && 'resetAddingMode' in polygonRecipe) { + (polygonRecipe as { resetAddingMode: () => void }).resetAddingMode(); + } + } + /* Subscribe to recipe activation events */ recipes.forEach((r) => r.bus.$on('activate', handleSetAnnotationState)); /* Unsubscribe before unmount */ @@ -907,6 +920,7 @@ export default function useModeManager({ seekFrame, addHole: handleAddHole, addPolygon: handleAddPolygon, + cancelCreation: handleCancelCreation, }, }; } diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 77c69c317..eae318e38 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -457,9 +457,9 @@ export default defineComponent({ polyAnnotationLayer.bus.$on('annotation-right-clicked', Clicked); // Handle right-click polygon selection for multi-polygon support polyAnnotationLayer.bus.$on('polygon-right-clicked', (_trackId: number, polygonKey: string) => { - // Don't switch polygons when in creation mode (e.g., drawing a hole) + // If in creation mode, cancel it first so we can select the polygon if (editAnnotationLayer.getMode() === 'creation') { - return; + handler.cancelCreation(); } // Set the polygon key for the right-clicked polygon handler.selectFeatureHandle(-1, polygonKey); @@ -482,9 +482,9 @@ export default defineComponent({ polyAnnotationLayer.bus.$on('annotation-ctrl-clicked', Clicked); // Handle polygon selection for multi-polygon support polyAnnotationLayer.bus.$on('polygon-clicked', (_trackId: number, polygonKey: string) => { - // Don't switch polygons when in creation mode (e.g., drawing a hole) + // If in creation mode, cancel it first so we can select the polygon if (editAnnotationLayer.getMode() === 'creation') { - return; + handler.cancelCreation(); } handler.selectFeatureHandle(-1, polygonKey); // Force layer update to load the newly selected polygon @@ -502,6 +502,26 @@ export default defineComponent({ ); }, 0); }); + // Handle right-click outside polygons to finalize/cancel creation + polyAnnotationLayer.bus.$on('polygon-right-clicked-outside', () => { + if (editAnnotationLayer.getMode() === 'creation') { + // Cancel creation and go back to editing the default polygon + handler.cancelCreation(); + handler.selectFeatureHandle(-1, ''); + window.setTimeout(() => { + updateLayers( + frameNumberRef.value, + editingModeRef.value, + selectedTrackIdRef.value, + multiSeletListRef.value, + enabledTracksRef.value, + visibleModesRef.value, + selectedKeyRef.value, + props.colorBy, + ); + }, 0); + } + }); editAnnotationLayer.bus.$on('update:geojson', ( mode: 'in-progress' | 'editing', geometryCompleteEvent: boolean, diff --git a/client/src/layers/AnnotationLayers/PolygonLayer.ts b/client/src/layers/AnnotationLayers/PolygonLayer.ts index 27872f99d..79fe0043a 100644 --- a/client/src/layers/AnnotationLayers/PolygonLayer.ts +++ b/client/src/layers/AnnotationLayers/PolygonLayer.ts @@ -96,7 +96,12 @@ export default class PolygonLayer extends BaseLayer { this.featureLayer.geoOn(geo.event.mouseclick, (e: GeoEvent) => { // If we aren't clicking on an annotation we can deselect the current track if (this.featureLayer.pointSearch(e.geo).found.length === 0 && !this.drawingOther) { - this.bus.$emit('annotation-clicked', null, false); + if (e.mouse.buttonsDown.left) { + this.bus.$emit('annotation-clicked', null, false); + } else if (e.mouse.buttonsDown.right) { + // Right-click outside polygons - emit event to finalize/cancel creation + this.bus.$emit('polygon-right-clicked-outside'); + } } }); super.initialize(); diff --git a/client/src/provides.ts b/client/src/provides.ts index 4cf22ff13..8865bbd4b 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -184,6 +184,12 @@ export interface Handler { startLinking(camera: string): void; stopLinking(): void; setChange(set: string): void; + /* Add a hole to the current polygon */ + addHole(): void; + /* Add a new separate polygon */ + addPolygon(): void; + /* Cancel any in-progress creation mode (hole or polygon addition) */ + cancelCreation(): void; } const HandlerSymbol = Symbol('handler'); @@ -226,6 +232,9 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler startLinking(...args) { handle('startLinking', args); }, stopLinking(...args) { handle('stopLinking', args); }, setChange(...args) { handle('setChange', args); }, + addHole(...args) { handle('addHole', args); }, + addPolygon(...args) { handle('addPolygon', args); }, + cancelCreation(...args) { handle('cancelCreation', args); }, }; } From 91cbc661897895d94b065be9ebe64fcadc409ee0 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 29 Jan 2026 00:21:51 -0500 Subject: [PATCH 12/12] Fix hole drawing by not canceling creation mode on left-click Left-clicks in creation mode should be handled by the edit layer for placing polygon/hole vertices. Only right-click should cancel creation mode and allow polygon selection. Co-Authored-By: Claude Opus 4.5 --- client/src/components/LayerManager.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index eae318e38..6212f38b2 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -482,9 +482,10 @@ export default defineComponent({ polyAnnotationLayer.bus.$on('annotation-ctrl-clicked', Clicked); // Handle polygon selection for multi-polygon support polyAnnotationLayer.bus.$on('polygon-clicked', (_trackId: number, polygonKey: string) => { - // If in creation mode, cancel it first so we can select the polygon + // If in creation mode, don't interrupt - let the edit layer handle clicks for placing points + // This is important for hole drawing where left-clicks place hole vertices if (editAnnotationLayer.getMode() === 'creation') { - handler.cancelCreation(); + return; } handler.selectFeatureHandle(-1, polygonKey); // Force layer update to load the newly selected polygon