diff --git a/client/dive-common/components/DeleteControls.vue b/client/dive-common/components/DeleteControls.vue
index 50ecdd08a..0916c0757 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');
+ },
},
});
-
-
+
+
+
+
+
+ mdi-vector-polygon
+
+
+ mdi-plus-circle-outline
+
+
+
+ Add another polygon
+
+
+
+
+
+
+
+ mdi-vector-polygon
+
+
+ mdi-minus-circle-outline
+
+
+
+ Add hole to polygon
+
+
+
+
- del
-
- point {{ selectedFeatureHandle }}
-
-
- {{ editingMode }}
-
- unselected
-
- mdi-delete
-
-
+
+
+ DEL
+
+ pt{{ selectedFeatureHandle }}
+
+
+ {{ editModeIcon }}
+
+
+
+ Delete point {{ selectedFeatureHandle }}
+ Delete {{ editingMode }}
+
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"
/>
y) !== (yj > y))
+ && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
+
+ if (intersect) {
+ inside = !inside;
+ }
+ }
+
+ return inside;
+}
+
+/**
+ * Check if all vertices of polygon P are inside polygon E
+ * @param innerPolygon polygon to check if it's inside
+ * @param outerPolygon polygon to check if it contains the inner polygon
+ * @returns true if all vertices of innerPolygon are inside outerPolygon
+ */
+function isPolygonInsidePolygon(
+ innerPolygon: GeoJSON.Position[],
+ outerPolygon: GeoJSON.Position[],
+): boolean {
+ // Check if all vertices of the inner polygon are inside the outer polygon
+ return innerPolygon.every(
+ (vertex) => isPointInsidePolygon([vertex[0], vertex[1]], outerPolygon),
+ );
+}
+
export default class PolygonBoundsExpand implements Recipe {
active: Ref;
@@ -19,12 +62,41 @@ export default class PolygonBoundsExpand implements Recipe {
bus: Vue;
+ // Mode for adding polygons: 'normal', 'hole', or 'newPolygon'
+ addingMode: Ref<'normal' | 'hole' | 'newPolygon'>;
+
constructor() {
this.bus = new Vue();
this.active = ref(false);
this.name = 'PolygonBase';
this.toggleable = ref(true);
this.icon = ref('mdi-vector-polygon');
+ this.addingMode = ref('normal');
+ }
+
+ 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__',
+ recipeName: this.name,
+ });
+ }
+
+ setAddingPolygon(newKey: string) {
+ this.addingMode.value = 'newPolygon';
+ // Emit activate event with new key to trigger creation mode
+ this.bus.$emit('activate', {
+ editing: 'Polygon' as EditAnnotationTypes,
+ key: newKey,
+ recipeName: this.name,
+ });
+ }
+
+ resetAddingMode() {
+ this.addingMode.value = 'normal';
}
update(
@@ -37,12 +109,89 @@ export default class PolygonBoundsExpand implements Recipe {
if (data.length === 1 && mode === 'editing' && this.active.value) {
const poly = data[0].geometry;
if (poly.type === 'Polygon') {
+ const newPolyCoords = poly.coordinates[0] as GeoJSON.Position[];
+ const currentMode = this.addingMode.value;
+
+ // Reset adding mode after processing
+ this.resetAddingMode();
+
+ if (currentMode === 'hole' || key === '__adding_hole__') {
+ // Adding a hole - find the first polygon and add hole to it
+ const existingPolygons = track.getPolygonFeatures(frameNum);
+ if (existingPolygons.length > 0) {
+ // Add as hole to the first (default) polygon
+ const targetPoly = existingPolygons[0];
+ // 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: {
+ [targetPoly.key]: [updatedFeature],
+ },
+ union: [],
+ done: true,
+ unionWithoutBounds: [],
+ newSelectedKey: targetPoly.key, // Set to target polygon's key for proper mode transition
+ };
+ }
+ // No existing polygon, treat as normal (create first polygon)
+ return {
+ data: {
+ '': data,
+ },
+ union: [],
+ done: true,
+ unionWithoutBounds: [poly],
+ newSelectedKey: '',
+ };
+ }
+
+ if (currentMode === 'newPolygon') {
+ // Adding a new separate polygon - key should already be set to new value
+ const useKey = key || track.getNextPolygonKey(frameNum);
+ const newFeature: GeoJSON.Feature = {
+ type: 'Feature',
+ properties: { key: useKey },
+ geometry: poly,
+ };
+ return {
+ data: {
+ [useKey]: [newFeature],
+ },
+ union: [poly], // Use union to EXPAND bounds, not replace them
+ done: true,
+ unionWithoutBounds: [],
+ newSelectedKey: '', // Reset to default polygon for future edits
+ };
+ }
+
+ // Standard case: save polygon with the given key
+ // Calculate bounds from ALL polygons in the detection, not just the edited one
+ const allPolygons = track.getPolygonFeatures(frameNum);
+ const otherPolygons: GeoJSON.Polygon[] = allPolygons
+ .filter((p) => p.key !== (key || ''))
+ .map((p) => p.geometry);
+
return {
data: {
[key || '']: data,
},
- union: [],
+ // Use union with other polygons to ensure bounds encompass all
+ union: otherPolygons,
done: true,
+ // The edited polygon replaces the base bounds
unionWithoutBounds: [poly],
};
}
@@ -50,19 +199,61 @@ export default class PolygonBoundsExpand implements Recipe {
return EmptyResponse;
}
+ /**
+ * Add a polygon as a hole to an existing polygon, or as a new separate polygon.
+ * Call this method explicitly when auto-hole detection is desired.
+ */
+ // eslint-disable-next-line class-methods-use-this
+ addPolygonWithHoleDetection(
+ frameNum: number,
+ track: Track,
+ poly: GeoJSON.Polygon,
+ key?: string,
+ ) {
+ const newPolyCoords = poly.coordinates[0] as GeoJSON.Position[];
+
+ // Get existing polygons for this frame
+ const existingPolygons = track.getPolygonFeatures(frameNum);
+
+ // Check if this is an edit to an existing polygon (key matches)
+ const isExistingEdit = existingPolygons.some((p) => p.key === (key || ''));
+
+ if (!isExistingEdit && existingPolygons.length > 0) {
+ // This is a new polygon - check if it should be a hole in an existing polygon
+ const containingPoly = existingPolygons.find((existingPoly) => {
+ const outerRing = existingPoly.geometry.coordinates[0] as GeoJSON.Position[];
+ return isPolygonInsidePolygon(newPolyCoords, outerRing);
+ });
+
+ if (containingPoly) {
+ // New polygon is inside existing polygon - add as hole
+ track.addHoleToPolygon(frameNum, containingPoly.key, newPolyCoords);
+ return { isHole: true, key: containingPoly.key };
+ }
+
+ // Not inside any existing polygon - add as new separate polygon with auto-key
+ const newKey = track.getNextPolygonKey(frameNum);
+ return { isHole: false, key: newKey };
+ }
+
+ // Standard case: use provided key or default
+ return { isHole: false, key: key || '' };
+ }
+
// eslint-disable-next-line class-methods-use-this
delete(frame: number, track: Track, key: string, type: EditAnnotationTypes) {
- if (key === '' && type === 'Polygon') {
- track.removeFeatureGeometry(frame, { key: '', type: 'Polygon' });
+ if (type === 'Polygon') {
+ // Remove polygon with the specified key (supports multiple polygons)
+ track.removeFeatureGeometry(frame, { key, type: 'Polygon' });
}
}
// eslint-disable-next-line class-methods-use-this
deletePoint(frame: number, track: Track, idx: number, key: string, type: EditAnnotationTypes) {
- if (key === '' && type === 'Polygon') {
+ if (type === 'Polygon') {
const geoJsonFeatures = track.getFeatureGeometry(frame, {
type: 'Polygon',
- key: '',
+ key,
});
if (geoJsonFeatures.length === 0) return;
const clone = cloneDeep(geoJsonFeatures[0]);
diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts
index aaeb0db6a..45bd71df2 100644
--- a/client/dive-common/use/useModeManager.ts
+++ b/client/dive-common/use/useModeManager.ts
@@ -152,7 +152,12 @@ export default function useModeManager({
} if (annotationModes.editing === 'rectangle') {
return 'Editing';
}
- return (feature.geometry?.features.filter((item) => item.geometry.type === annotationModes.editing).length ? 'Editing' : 'Creating');
+ // Check if there's a geometry matching both the type AND the selectedKey
+ const matchingGeometry = feature.geometry?.features.filter(
+ (item) => item.geometry.type === annotationModes.editing
+ && item.properties?.key === selectedKey.value,
+ );
+ return (matchingGeometry?.length ? 'Editing' : 'Creating');
}
return 'Creating';
}
@@ -488,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) {
@@ -565,11 +572,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();
}
}
@@ -797,6 +825,51 @@ export default function useModeManager({
handleGroupEdit(previousOrNext);
}
+ /**
+ * Set up polygon recipe for adding a hole to an existing polygon.
+ * The recipe emits an activate event that triggers creation mode.
+ */
+ function handleAddHole() {
+ if (selectedTrackId.value === null) return;
+
+ const polygonRecipe = recipes.find((r) => r.name === 'PolygonBase');
+ if (polygonRecipe && 'setAddingHole' in polygonRecipe) {
+ // This will emit 'activate' event which triggers handleSetAnnotationState
+ (polygonRecipe as { setAddingHole: () => void }).setAddingHole();
+ }
+ }
+
+ /**
+ * Set up polygon recipe for adding a new separate polygon.
+ * The recipe emits an activate event that triggers creation mode.
+ */
+ function handleAddPolygon() {
+ if (selectedTrackId.value === null) return;
+
+ const polygonRecipe = recipes.find((r) => r.name === 'PolygonBase');
+ if (polygonRecipe && 'setAddingPolygon' in polygonRecipe) {
+ // Get next available key for the new polygon
+ const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value);
+ if (track) {
+ const { frame } = aggregateController.value;
+ const newKey = track.getNextPolygonKey(frame.value);
+ // This will emit 'activate' event which triggers handleSetAnnotationState
+ (polygonRecipe as { setAddingPolygon: (key: string) => void }).setAddingPolygon(newKey);
+ }
+ }
+ }
+
+ /**
+ * 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 */
@@ -845,6 +918,9 @@ export default function useModeManager({
startLinking: handleStartLinking,
stopLinking: handleStopLinking,
seekFrame,
+ addHole: handleAddHole,
+ addPolygon: handleAddPolygon,
+ cancelCreation: handleCancelCreation,
},
};
}
diff --git a/client/platform/desktop/backend/serializers/viame.ts b/client/platform/desktop/backend/serializers/viame.ts
index b9e55c2c9..afb9738c1 100644
--- a/client/platform/desktop/backend/serializers/viame.ts
+++ b/client/platform/desktop/backend/serializers/viame.ts
@@ -26,7 +26,10 @@ const HeadRegex = /^\(kp\) head (-?[0-9]+\.*-?[0-9]*) (-?[0-9]+\.*-?[0-9]*)/g;
const TailRegex = /^\(kp\) tail (-?[0-9]+\.*-?[0-9]*) (-?[0-9]+\.*-?[0-9]*)/g;
const AttrRegex = /^\(atr\) (.*?)\s(.+)/g;
const TrackAttrRegex = /^\(trk-atr\) (.*?)\s(.+)/g;
-const PolyRegex = /^(\(poly\)) ((?:-?[0-9]+\.*-?[0-9]*\s*)+)/g;
+// Polygon format: (poly) coordinates
+const PolyRegex = /^\(poly\)\s*((?:-?[0-9]+\.*-?[0-9]*\s*)+)/g;
+// Hole format: (hole) coordinates
+const HoleRegex = /^\(hole\)\s*((?:-?[0-9]+\.*-?[0-9]*\s*)+)/g;
const FpsRegex = /fps:\s*(\d+(\.\d+)?)/ig;
const ExecTimeRegEx = /exec_time:\s*(\d+(\.\d+)?)/ig;
const AtrToken = '(atr)';
@@ -120,6 +123,18 @@ function _deduceType(value: string): boolean | number | string {
return value;
}
+/**
+ * Get the next available polygon key for a feature collection.
+ */
+function _getNextPolygonKey(
+ geoFeatureCollection: GeoJSON.FeatureCollection,
+): string {
+ const polygonCount = geoFeatureCollection.features.filter(
+ (f) => f.geometry.type === 'Polygon',
+ ).length;
+ return polygonCount > 0 ? String(polygonCount) : '';
+}
+
/**
* Simplified from python variant. Does not handle duplicate type/key pairs
* within a single feature.
@@ -151,6 +166,28 @@ function _createGeoJsonFeature(
return geoFeature;
}
+/**
+ * Find an existing polygon feature by key and add a hole to it.
+ * @param geoFeatureCollection the feature collection to search
+ * @param coords hole coordinates
+ * @param key polygon key to find
+ */
+function _addHoleToPolygon(
+ geoFeatureCollection: GeoJSON.FeatureCollection,
+ coords: number[][],
+ key = '',
+) {
+ const matchingFeature = geoFeatureCollection.features.find(
+ (feature) => feature.geometry.type === 'Polygon' && feature.properties?.key === key,
+ );
+ if (matchingFeature) {
+ // Add hole as additional ring to the polygon coordinates
+ (matchingFeature.geometry.coordinates as number[][][]).push(coords);
+ return true;
+ }
+ return false;
+}
+
function _parseRow(row: string[]) {
// Create empty feature collection
const geoFeatureCollection:
@@ -206,11 +243,13 @@ function _parseRow(row: string[]) {
trackAttributes[trackattr[1]] = _deduceType(trackattr[2]);
}
- /* Polygon */
+ /* Polygon - format: (poly) coordinates
+ * Multiple (poly) entries create separate polygons with auto-generated keys */
const poly = getCaptureGroups(PolyRegex, value);
if (poly !== null) {
+ const coordString = poly[1];
const coords: number[][] = [];
- const polyList = poly[2].split(' ');
+ const polyList = coordString.split(' ');
polyList.forEach((coord, j) => {
if (j % 2 === 0) {
// Filter out ODDs
@@ -219,7 +258,32 @@ function _parseRow(row: string[]) {
}
}
});
- geoFeatureCollection.features.push(_createGeoJsonFeature('Polygon', coords));
+ // Create new polygon with auto-generated key
+ const newKey = _getNextPolygonKey(geoFeatureCollection);
+ geoFeatureCollection.features.push(_createGeoJsonFeature('Polygon', coords, newKey));
+ }
+
+ /* Hole - format: (hole) coordinates
+ * Added to the most recent polygon */
+ const hole = getCaptureGroups(HoleRegex, value);
+ if (hole !== null) {
+ const coordString = hole[1];
+ const coords: number[][] = [];
+ const polyList = coordString.split(' ');
+ polyList.forEach((coord, j) => {
+ if (j % 2 === 0) {
+ // Filter out ODDs
+ if (polyList[j + 1]) {
+ coords.push([parseFloat(coord), parseFloat(polyList[j + 1])]);
+ }
+ }
+ });
+ // Add as hole to the most recent polygon
+ const polygons = geoFeatureCollection.features.filter((f) => f.geometry.type === 'Polygon');
+ if (polygons.length > 0) {
+ const lastPolyKey = polygons[polygons.length - 1].properties?.key || '';
+ _addHoleToPolygon(geoFeatureCollection, coords, lastPolyKey);
+ }
}
});
@@ -603,8 +667,19 @@ async function serialize(
if (feature.geometry && feature.geometry.type === 'FeatureCollection') {
feature.geometry.features.forEach((geoJSONFeature) => {
if (geoJSONFeature.geometry.type === 'Polygon') {
- const coordinates = flattenDeep(geoJSONFeature.geometry.coordinates[0]);
- row.push(`${PolyToken} ${coordinates.map(Math.round).join(' ')}`);
+ const allRings = geoJSONFeature.geometry.coordinates as number[][][];
+
+ // Write outer ring (first ring)
+ if (allRings.length > 0) {
+ const outerCoords = flattenDeep(allRings[0]);
+ row.push(`${PolyToken} ${outerCoords.map(Math.round).join(' ')}`);
+
+ // Write holes (additional rings)
+ for (let holeIdx = 0; holeIdx < allRings.length - 1; holeIdx += 1) {
+ const holeCoords = flattenDeep(allRings[holeIdx + 1]);
+ row.push(`(hole) ${holeCoords.map(Math.round).join(' ')}`);
+ }
+ }
} else if (geoJSONFeature.geometry.type === 'Point') {
if (geoJSONFeature.properties) {
const kpname = geoJSONFeature.properties.key;
@@ -614,7 +689,6 @@ async function serialize(
);
}
}
- /* TODO support for multiple GeoJSON Objects of the same type */
});
}
stringify.write(row);
diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue
index 8b88d363b..6212f38b2 100644
--- a/client/src/components/LayerManager.vue
+++ b/client/src/components/LayerManager.vue
@@ -386,6 +386,7 @@ export default defineComponent({
typeStylingRef,
toRef(props, 'colorBy'),
selectedCamera,
+ selectedKeyRef,
],
() => {
updateLayers(
@@ -454,7 +455,74 @@ export default defineComponent({
rectAnnotationLayer.bus.$on('annotation-ctrl-clicked', Clicked);
polyAnnotationLayer.bus.$on('annotation-clicked', Clicked);
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) => {
+ // If in creation mode, cancel it first so we can select the polygon
+ if (editAnnotationLayer.getMode() === 'creation') {
+ handler.cancelCreation();
+ }
+ // Set the polygon key for the right-clicked polygon
+ handler.selectFeatureHandle(-1, polygonKey);
+ // Force layer update to load the selected polygon
+ // This is especially important when already editing the same track
+ // since annotation-right-clicked won't be emitted in that case
+ window.setTimeout(() => {
+ updateLayers(
+ frameNumberRef.value,
+ editingModeRef.value,
+ selectedTrackIdRef.value,
+ multiSeletListRef.value,
+ enabledTracksRef.value,
+ visibleModesRef.value,
+ selectedKeyRef.value,
+ props.colorBy,
+ );
+ }, 0);
+ });
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, 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') {
+ return;
+ }
+ handler.selectFeatureHandle(-1, polygonKey);
+ // Force layer update to load the newly selected polygon
+ // Use nextTick to ensure the selectedKey ref has been updated
+ window.setTimeout(() => {
+ updateLayers(
+ frameNumberRef.value,
+ editingModeRef.value,
+ selectedTrackIdRef.value,
+ multiSeletListRef.value,
+ enabledTracksRef.value,
+ visibleModesRef.value,
+ selectedKeyRef.value,
+ props.colorBy,
+ );
+ }, 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,
@@ -486,8 +554,59 @@ export default defineComponent({
});
editAnnotationLayer.bus.$on(
'update:selectedIndex',
- (index: number, _type: EditAnnotationTypes, key = '') => handler.selectFeatureHandle(index, key),
+ (index: number, _type: EditAnnotationTypes, key?: string) => {
+ // When deselecting (index -1), don't change the key - it may have been
+ // set by polygon-right-clicked/polygon-clicked for multi-polygon selection
+ if (index >= 0 && key !== undefined) {
+ handler.selectFeatureHandle(index, key);
+ } else {
+ // Just update the handle index, preserve the current key
+ handler.selectFeatureHandle(index, selectedKeyRef.value);
+ }
+ },
);
+ // Handle clicks outside the edit polygon to allow selecting other polygons
+ editAnnotationLayer.bus.$on('click-outside-edit', (geo: { x: number; y: number }) => {
+ // Check which polygon was clicked by iterating through formatted data
+ const point: [number, number] = [geo.x, geo.y];
+ const polygonData = polyAnnotationLayer.formattedData;
+
+ // Find the polygon that contains the click point
+ const clickedPolygon = polygonData.find((data) => {
+ const coords = data.polygon.coordinates[0] as [number, number][];
+ // Ray casting algorithm
+ let inside = false;
+ for (let i = 0, j = coords.length - 1; i < coords.length; j = i, i += 1) {
+ const xi = coords[i][0];
+ const yi = coords[i][1];
+ const xj = coords[j][0];
+ const yj = coords[j][1];
+ const intersect = ((yi > point[1]) !== (yj > point[1]))
+ && (point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+ });
+
+ if (clickedPolygon) {
+ const polygonKey = clickedPolygon.polygonKey || '';
+ // Select the clicked polygon
+ handler.selectFeatureHandle(-1, polygonKey);
+ // Force layer update to load the newly selected polygon
+ window.setTimeout(() => {
+ updateLayers(
+ frameNumberRef.value,
+ editingModeRef.value,
+ selectedTrackIdRef.value,
+ multiSeletListRef.value,
+ enabledTracksRef.value,
+ visibleModesRef.value,
+ selectedKeyRef.value,
+ props.colorBy,
+ );
+ }, 0);
+ }
+ });
const annotationHoverTooltip = (
found: {
styleType: [string, number];
diff --git a/client/src/layers/AnnotationLayers/PolygonLayer.ts b/client/src/layers/AnnotationLayers/PolygonLayer.ts
index 302c030b7..79fe0043a 100644
--- a/client/src/layers/AnnotationLayers/PolygonLayer.ts
+++ b/client/src/layers/AnnotationLayers/PolygonLayer.ts
@@ -10,7 +10,28 @@ interface PolyGeoJSData{
editing: boolean | string;
styleType: [string, number] | null;
polygon: GeoJSON.Polygon;
+ polygonKey: string;
set?: string;
+ isHole?: boolean; // True if this is a hole polygon (for styling)
+}
+
+/**
+ * Darken a hex color by a given factor (0-1, where 0 = black, 1 = original)
+ */
+function darkenColor(color: string, factor: number): string {
+ // Handle hex colors
+ if (color.startsWith('#')) {
+ const hex = color.slice(1);
+ const r = parseInt(hex.slice(0, 2), 16);
+ const g = parseInt(hex.slice(2, 4), 16);
+ const b = parseInt(hex.slice(4, 6), 16);
+ const newR = Math.round(r * factor);
+ const newG = Math.round(g * factor);
+ const newB = Math.round(b * factor);
+ return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
+ }
+ // For non-hex colors, return as-is (could extend to support rgb(), etc.)
+ return color;
}
export default class PolygonLayer extends BaseLayer {
@@ -35,19 +56,36 @@ export default class PolygonLayer extends BaseLayer {
.geoOn(geo.event.feature.mouseclick, (e: GeoEvent) => {
/**
* Handle clicking on individual annotations, if DrawingOther is true we use the
- * Rectangle type if only the polygon is visible we use the polygon bounds
+ * Rectangle type for track selection. However, polygon key events are always
+ * emitted so that multi-polygon selection works regardless of drawingOther.
* */
- if (e.mouse.buttonsDown.left && !this.drawingOther) {
- if (!e.data.editing || (e.data.editing && !e.data.selected)) {
- if (e.mouse.modifiers.ctrl) {
- this.bus.$emit('annotation-ctrl-clicked', e.data.trackId, false, { ctrl: true });
- } else {
- this.bus.$emit('annotation-clicked', e.data.trackId, false);
+ if (e.mouse.buttonsDown.left) {
+ // Always emit polygon-clicked for multi-polygon support, regardless of drawingOther
+ const polygonKey = e.data.polygonKey || '';
+ if (e.data.selected) {
+ // Already selected track - user may be selecting a different polygon
+ this.bus.$emit('polygon-clicked', e.data.trackId, polygonKey);
+ }
+ // Track-level events only when not drawingOther (rectangle layer handles those)
+ if (!this.drawingOther) {
+ if (!e.data.editing || (e.data.editing && !e.data.selected)) {
+ if (e.mouse.modifiers.ctrl) {
+ this.bus.$emit('annotation-ctrl-clicked', e.data.trackId, false, { ctrl: true });
+ } else {
+ this.bus.$emit('polygon-clicked', e.data.trackId, polygonKey);
+ this.bus.$emit('annotation-clicked', e.data.trackId, false);
+ }
}
}
- } else if (e.mouse.buttonsDown.right && !this.drawingOther) {
- if (!e.data.editing || (e.data.editing && !e.data.selected)) {
- this.bus.$emit('annotation-right-clicked', e.data.trackId, true);
+ } else if (e.mouse.buttonsDown.right) {
+ // Always emit polygon key for right-click so the correct polygon can be selected
+ const polygonKey = e.data.polygonKey || '';
+ this.bus.$emit('polygon-right-clicked', e.data.trackId, polygonKey);
+ // Track-level events only when not drawingOther
+ if (!this.drawingOther) {
+ if (!e.data.editing || (e.data.editing && !e.data.selected)) {
+ this.bus.$emit('annotation-right-clicked', e.data.trackId, true);
+ }
}
}
});
@@ -58,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();
@@ -106,15 +149,41 @@ export default class PolygonLayer extends BaseLayer {
frameData.features.geometry.features.forEach((feature) => {
if (feature.geometry && feature.geometry.type === 'Polygon') {
const polygon = feature.geometry;
+ const polygonKey = feature.properties?.key || '';
const annotation: PolyGeoJSData = {
trackId: frameData.track.id,
selected: frameData.selected,
editing: frameData.editing,
styleType: frameData.styleType,
polygon,
+ polygonKey,
set: frameData.set,
+ isHole: false,
};
arr.push(annotation);
+
+ // Also add holes as separate polygon entries for distinct styling
+ const coords = polygon.coordinates as GeoJSON.Position[][];
+ if (coords.length > 1) {
+ // coords[0] is outer ring, coords[1..n] are holes
+ for (let i = 1; i < coords.length; i += 1) {
+ const holePolygon: GeoJSON.Polygon = {
+ type: 'Polygon',
+ coordinates: [coords[i]], // Hole as its own polygon
+ };
+ const holeAnnotation: PolyGeoJSData = {
+ trackId: frameData.track.id,
+ selected: frameData.selected,
+ editing: frameData.editing,
+ styleType: frameData.styleType,
+ polygon: holePolygon,
+ polygonKey, // Same key as parent polygon
+ set: frameData.set,
+ isHole: true,
+ };
+ arr.push(holeAnnotation);
+ }
+ }
}
});
}
@@ -126,7 +195,17 @@ export default class PolygonLayer extends BaseLayer {
redraw() {
this.featureLayer
.data(this.formattedData)
- .polygon((d: PolyGeoJSData) => d.polygon.coordinates[0])
+ .polygon((d: PolyGeoJSData) => {
+ // GeoJS expects outer ring as array of points for simple polygons
+ // For polygons with holes, return object with outer/inner properties
+ if (d.polygon.coordinates.length > 1) {
+ return {
+ outer: d.polygon.coordinates[0],
+ inner: d.polygon.coordinates.slice(1),
+ };
+ }
+ return d.polygon.coordinates[0];
+ })
.draw();
}
@@ -142,15 +221,22 @@ export default class PolygonLayer extends BaseLayer {
// Style conversion to get array objects to work in geoJS
position: (point) => ({ x: point[0], y: point[1] }),
strokeColor: (_point, _index, data) => {
+ let color: string;
if (data.selected) {
- return this.stateStyling.selected.color;
- }
- if (data.styleType) {
- return this.typeStyling.value.color(data.styleType[0]);
+ color = this.stateStyling.selected.color;
+ } else if (data.styleType) {
+ color = this.typeStyling.value.color(data.styleType[0]);
+ } else {
+ color = this.typeStyling.value.color('');
}
- return this.typeStyling.value.color('');
+ // Darken color for holes
+ return data.isHole ? darkenColor(color, 0.5) : color;
},
fill: (data) => {
+ // Holes should always be filled to show the darker color
+ if (data.isHole) {
+ return true;
+ }
if (data.set) {
return this.typeStyling.value.fill(data.set);
}
@@ -160,12 +246,20 @@ export default class PolygonLayer extends BaseLayer {
return this.stateStyling.standard.fill;
},
fillColor: (_point, _index, data) => {
+ let color: string;
if (data.styleType) {
- return this.typeStyling.value.color(data.styleType[0]);
+ color = this.typeStyling.value.color(data.styleType[0]);
+ } else {
+ color = this.typeStyling.value.color('');
}
- return this.typeStyling.value.color('');
+ // Darken color for holes
+ return data.isHole ? darkenColor(color, 0.5) : color;
},
fillOpacity: (_point, _index, data) => {
+ // Holes get higher opacity to stand out
+ if (data.isHole) {
+ return 0.5;
+ }
if (data.set) {
return this.typeStyling.value.opacity(data.set);
}
diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts
index d25ed4725..6bb6e961c 100644
--- a/client/src/layers/EditAnnotationLayer.ts
+++ b/client/src/layers/EditAnnotationLayer.ts
@@ -58,6 +58,8 @@ export default class EditAnnotationLayer extends BaseLayer {
selectedKey?: string;
+ selectedPolygonIndex: number;
+
selectedHandleIndex: number;
hoverHandleIndex: number;
@@ -74,6 +76,7 @@ export default class EditAnnotationLayer extends BaseLayer {
this.skipNextExternalUpdate = false;
this._mode = 'editing';
this.selectedKey = '';
+ this.selectedPolygonIndex = 0;
this.type = params.type;
this.selectedHandleIndex = -1;
this.hoverHandleIndex = -1;
@@ -120,6 +123,35 @@ export default class EditAnnotationLayer extends BaseLayer {
this.bus.$emit('editing-annotation-sync', false);
} else if (e.buttonsDown.left) {
const newIndex = this.hoverHandleIndex;
+ // If not hovering over an edit handle and not on the edit polygon,
+ // emit event so other layers can handle the click (e.g., selecting different polygon)
+ if (newIndex < 0 && this.type === 'Polygon') {
+ const annotations = this.featureLayer.annotations();
+ if (annotations.length > 0) {
+ const annotation = annotations[0];
+ const geojson = annotation.geojson();
+ if (geojson && geojson.geometry && geojson.geometry.type === 'Polygon') {
+ const coords = geojson.geometry.coordinates[0] as [number, number][];
+ const point: [number, number] = [e.geo.x, e.geo.y];
+ // Ray casting algorithm to check if point is inside polygon
+ let inside = false;
+ for (let i = 0, j = coords.length - 1; i < coords.length; j = i, i += 1) {
+ const xi = coords[i][0];
+ const yi = coords[i][1];
+ const xj = coords[j][0];
+ const yj = coords[j][1];
+ const intersect = ((yi > point[1]) !== (yj > point[1]))
+ && (point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi);
+ if (intersect) inside = !inside;
+ }
+ if (!inside) {
+ // Click is outside the current edit polygon - emit passthrough event
+ this.bus.$emit('click-outside-edit', e.geo);
+ return;
+ }
+ }
+ }
+ }
// Click features like a toggle: unselect if it's clicked twice.
if (newIndex === this.selectedHandleIndex) {
this.selectedHandleIndex = -1;
@@ -323,25 +355,52 @@ export default class EditAnnotationLayer extends BaseLayer {
/**
* retrieves geoJSON data based on the key and type
- * @param frameData
+ * @param track
+ * @param polygonIndex optional index to get a specific polygon when multiple exist
*/
- getGeoJSONData(track: FrameDataTrack) {
- let geoJSONData;
+ getGeoJSONData(
+ track: FrameDataTrack,
+ polygonIndex?: number,
+ ): GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString | undefined {
+ let geoJSONData: GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString | undefined;
if (track && track.features && track.features.geometry) {
+ const matchingFeatures: (GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString)[] = [];
track.features.geometry.features.forEach((feature) => {
if (feature.geometry
&& feature.geometry.type.toLowerCase() === this.type.toLowerCase()) {
- if (feature.properties && feature.properties.key !== 'undefined') {
- if (feature.properties.key === this.selectedKey) {
- geoJSONData = feature.geometry;
- }
+ // Get the feature key, defaulting to '' for undefined/null keys
+ const featureKey = feature.properties?.key ?? '';
+ if (featureKey === this.selectedKey) {
+ matchingFeatures.push(
+ feature.geometry as GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString,
+ );
}
}
});
+ // If polygonIndex is specified and valid, use it; otherwise use first match
+ if (polygonIndex !== undefined && polygonIndex >= 0 && polygonIndex < matchingFeatures.length) {
+ geoJSONData = matchingFeatures[polygonIndex];
+ } else if (matchingFeatures.length > 0) {
+ [geoJSONData] = matchingFeatures;
+ }
}
return geoJSONData;
}
+ /**
+ * Set which polygon index to edit when multiple polygons exist
+ */
+ setPolygonIndex(index: number) {
+ this.selectedPolygonIndex = index;
+ }
+
+ /**
+ * Get the currently selected polygon index
+ */
+ getPolygonIndex() {
+ return this.selectedPolygonIndex;
+ }
+
/** overrides default function to disable and clear anotations before drawing again */
async changeData(frameData: FrameDataTrack[]) {
if (this.skipNextExternalUpdate === false) {
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); },
};
}
diff --git a/client/src/track.ts b/client/src/track.ts
index c23750978..e59b2d506 100644
--- a/client/src/track.ts
+++ b/client/src/track.ts
@@ -310,7 +310,10 @@ export default class Track extends BaseAnnotation {
geometry.forEach((geo) => {
const i = fg.features
.findIndex((item) => {
- const keyMatch = !geo.properties?.key || item.properties?.key === geo.properties?.key;
+ // Compare keys directly, treating undefined/null as empty string
+ const geoKey = geo.properties?.key ?? '';
+ const itemKey = item.properties?.key ?? '';
+ const keyMatch = geoKey === itemKey;
const typeMatch = item.geometry.type === geo.geometry.type;
return keyMatch && typeMatch;
});
@@ -348,7 +351,9 @@ export default class Track extends BaseAnnotation {
return [];
}
return feature.geometry.features.filter((item) => {
- const matchesKey = !key || item.properties?.key === key;
+ // Check key match: undefined means match all, otherwise compare (treating undefined/null as '')
+ const matchesKey = key === undefined
+ || (item.properties?.key ?? '') === key;
const matchesType = !type || item.geometry.type === type;
return matchesKey && matchesType;
});
@@ -361,7 +366,9 @@ export default class Track extends BaseAnnotation {
return false;
}
const index = feature.geometry.features.findIndex((item) => {
- const matchesKey = !key || item.properties?.key === key;
+ // Check key match: undefined means match all, otherwise compare (treating undefined/null as '')
+ const matchesKey = key === undefined
+ || (item.properties?.key ?? '') === key;
const matchesType = !type || item.geometry.type === type;
return matchesKey && matchesType;
});
@@ -373,6 +380,116 @@ export default class Track extends BaseAnnotation {
return false;
}
+ /**
+ * Get all polygon features for a frame
+ * @returns Array of polygon GeoJSON features with their keys
+ */
+ getPolygonFeatures(frame: number): Array<{
+ key: string;
+ geometry: GeoJSON.Polygon;
+ hasHoles: boolean;
+ holeCount: number;
+ }> {
+ const feature = this.features[frame];
+ if (!feature?.geometry) {
+ return [];
+ }
+ const polygons: Array<{
+ key: string;
+ geometry: GeoJSON.Polygon;
+ hasHoles: boolean;
+ holeCount: number;
+ }> = [];
+ feature.geometry.features.forEach((item) => {
+ if (item.geometry.type === 'Polygon') {
+ const coords = item.geometry.coordinates as GeoJSON.Position[][];
+ polygons.push({
+ key: item.properties?.key || '',
+ geometry: item.geometry,
+ hasHoles: coords.length > 1,
+ holeCount: Math.max(0, coords.length - 1),
+ });
+ }
+ });
+ return polygons;
+ }
+
+ /**
+ * Add a hole to an existing polygon
+ * @param frame frame number
+ * @param key polygon key to add hole to
+ * @param holeCoords coordinates of the hole (array of [x,y] positions)
+ * @returns true if hole was added successfully
+ */
+ addHoleToPolygon(frame: number, key: string, holeCoords: GeoJSON.Position[]): boolean {
+ const feature = this.features[frame];
+ if (!feature?.geometry) {
+ return false;
+ }
+ const polygonFeature = feature.geometry.features.find(
+ (item) => item.geometry.type === 'Polygon' && item.properties?.key === key,
+ );
+ if (polygonFeature && polygonFeature.geometry.type === 'Polygon') {
+ (polygonFeature.geometry.coordinates as GeoJSON.Position[][]).push(holeCoords);
+ this.notify('feature', feature);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Remove a hole from a polygon
+ * @param frame frame number
+ * @param key polygon key
+ * @param holeIndex index of the hole to remove (0 = first hole, which is coordinates[1])
+ * @returns true if hole was removed successfully
+ */
+ removeHoleFromPolygon(frame: number, key: string, holeIndex: number): boolean {
+ const feature = this.features[frame];
+ if (!feature?.geometry) {
+ return false;
+ }
+ const polygonFeature = feature.geometry.features.find(
+ (item) => item.geometry.type === 'Polygon' && item.properties?.key === key,
+ );
+ if (polygonFeature && polygonFeature.geometry.type === 'Polygon') {
+ const coords = polygonFeature.geometry.coordinates as GeoJSON.Position[][];
+ // holeIndex 0 corresponds to coords[1], holeIndex 1 to coords[2], etc.
+ const actualIndex = holeIndex + 1;
+ if (actualIndex > 0 && actualIndex < coords.length) {
+ coords.splice(actualIndex, 1);
+ this.notify('feature', feature);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the next available polygon key for this frame
+ * @param frame frame number
+ * @returns next available key (e.g., "1", "2", etc.)
+ */
+ getNextPolygonKey(frame: number): string {
+ const polygons = this.getPolygonFeatures(frame);
+ if (polygons.length === 0) {
+ return '';
+ }
+ // Find the highest numeric key and increment
+ let maxKey = 0;
+ polygons.forEach((p) => {
+ if (p.key === '') {
+ maxKey = Math.max(maxKey, 0);
+ } else {
+ const numKey = parseInt(p.key, 10);
+ if (!Number.isNaN(numKey)) {
+ maxKey = Math.max(maxKey, numKey);
+ }
+ }
+ });
+ return String(maxKey + 1);
+ }
+
setFeatureAttribute(frame: number, name: string, value: unknown, user: null | string = null) {
if (this.features[frame]) {
if (user !== null) {
diff --git a/server/dive_utils/models.py b/server/dive_utils/models.py
index 9c18c0e27..9520ada95 100644
--- a/server/dive_utils/models.py
+++ b/server/dive_utils/models.py
@@ -29,7 +29,8 @@ class GeoJSONGeometry(BaseModel):
class GeoJSONFeature(BaseModel):
type: str
geometry: GeoJSONGeometry
- properties: Dict[str, Union[bool, float, str]]
+ # str first in Union to prevent numeric strings like "1" being coerced to bool/float
+ properties: Dict[str, Union[str, float, bool]]
class GeoJSONFeatureCollection(BaseModel):
diff --git a/server/dive_utils/serializers/viame.py b/server/dive_utils/serializers/viame.py
index f57930bce..72fcfecf1 100644
--- a/server/dive_utils/serializers/viame.py
+++ b/server/dive_utils/serializers/viame.py
@@ -80,11 +80,28 @@ def _deduceType(value: Any) -> Union[bool, float, str, None]:
return value
-def create_geoJSONFeature(features: Dict[str, Any], type: str, coords: List[Any], key=''):
+def get_next_polygon_key(features: Dict[str, Any]) -> str:
+ """Get the next available polygon key for a feature."""
+ if "geometry" not in features or not features["geometry"]["features"]:
+ return ''
+ # Count existing polygons to determine the next key
+ polygon_count = sum(
+ 1 for f in features["geometry"]["features"]
+ if f["geometry"]["type"] == "Polygon"
+ )
+ return str(polygon_count) if polygon_count > 0 else ''
+
+
+def create_geoJSONFeature(features: Dict[str, Any], type: str, coords: List[Any], key='', auto_key=False):
feature = {}
if "geometry" not in features:
features["geometry"] = {"type": "FeatureCollection", "features": []}
- else: # check for existing type/key pairs
+
+ # For polygons with auto_key, always create a new feature with a unique key
+ if type == 'Polygon' and auto_key:
+ key = get_next_polygon_key(features)
+ elif not auto_key:
+ # Check for existing type/key pairs (for non-polygon or explicit key)
if features["geometry"]["features"]:
for subfeature in features["geometry"]["features"]:
if (
@@ -93,18 +110,34 @@ def create_geoJSONFeature(features: Dict[str, Any], type: str, coords: List[Any]
):
feature = subfeature
break
+
if "geometry" not in feature:
feature = {
"type": "Feature",
"properties": {"key": key},
"geometry": {"type": type},
}
+ features['geometry']['features'].append(feature)
if type == 'Polygon':
feature["geometry"]['coordinates'] = [coords]
elif type in ["LineString", "Point"]:
feature['geometry']['coordinates'] = coords
- features['geometry']['features'].append(feature)
+ return key # Return the key used (useful for auto-generated keys)
+
+
+def add_hole_to_polygon(features: Dict[str, Any], coords: List[Any], key=''):
+ """Add a hole to an existing polygon feature with the given key."""
+ if "geometry" not in features or not features["geometry"]["features"]:
+ return
+ for subfeature in features["geometry"]["features"]:
+ if (
+ subfeature["geometry"]["type"] == 'Polygon'
+ and subfeature["properties"]["key"] == key
+ ):
+ # Add hole as additional ring to the polygon coordinates
+ subfeature["geometry"]["coordinates"].append(coords)
+ break
def _parse_row(row: List[str]) -> Tuple[Dict, Dict, Dict, List]:
@@ -148,12 +181,32 @@ def _parse_row(row: List[str]) -> Tuple[Dict, Dict, Dict, List]:
if trk_regex:
track_attributes[trk_regex[1]] = _deduceType(trk_regex[2])
- # (poly) x1 y1 x2 y2 ...
- poly_regex = re.match(r"^(\(poly\)) ((?:-?[0-9]+\.*-?[0-9]*\s*)+)", row[j])
+ # (poly) x1 y1 x2 y2 ... - polygon (multiple allowed, auto-keyed internally)
+ # (hole) x1 y1 x2 y2 ... - hole in the most recent polygon
+ poly_regex = re.match(
+ r"^\(poly\)\s*((?:-?[0-9]+\.*-?[0-9]*\s*)+)",
+ row[j]
+ )
if poly_regex:
- temp = [float(x) for x in poly_regex[2].split()]
- coords = list(zip(temp[::2], temp[1::2]))
- create_geoJSONFeature(features, 'Polygon', coords)
+ temp = [float(x) for x in poly_regex.group(1).split()]
+ coords = [[temp[i], temp[i + 1]] for i in range(0, len(temp), 2)]
+ # Create new polygon with auto-generated key
+ create_geoJSONFeature(features, 'Polygon', coords, auto_key=True)
+
+ # (hole) x1 y1 x2 y2 ... - hole in the most recent polygon
+ hole_regex = re.match(
+ r"^\(hole\)\s*((?:-?[0-9]+\.*-?[0-9]*\s*)+)",
+ row[j]
+ )
+ if hole_regex:
+ temp = [float(x) for x in hole_regex.group(1).split()]
+ coords = [[temp[i], temp[i + 1]] for i in range(0, len(temp), 2)]
+ # Add hole to the most recent polygon (last one added)
+ if "geometry" in features and features["geometry"]["features"]:
+ polygons = [f for f in features["geometry"]["features"] if f["geometry"]["type"] == "Polygon"]
+ if polygons:
+ last_poly_key = polygons[-1]["properties"]["key"]
+ add_hole_to_polygon(features, coords, last_poly_key)
if len(head_tail) == 2:
create_geoJSONFeature(features, 'LineString', head_tail, 'HeadTails')
@@ -558,25 +611,35 @@ def export_tracks_as_csv(
if feature.geometry and "FeatureCollection" == feature.geometry.type:
for geoJSONFeature in feature.geometry.features:
if 'Polygon' == geoJSONFeature.geometry.type:
- # Coordinates need to be flattened out from their list of tuples
- coordinates = [
- item
- for sublist in geoJSONFeature.geometry.coordinates[
- 0
- ] # type: ignore
- for item in sublist # type: ignore
- ]
- columns.append(
- f"(poly) {' '.join(map(lambda x: str(round(x)), coordinates))}"
- )
+ all_rings = geoJSONFeature.geometry.coordinates # type: ignore
+
+ # Write outer ring (first ring)
+ if len(all_rings) > 0:
+ outer_coords = [
+ item
+ for sublist in all_rings[0]
+ for item in sublist # type: ignore
+ ]
+ columns.append(
+ f"(poly) {' '.join(map(lambda x: str(round(x)), outer_coords))}"
+ )
+
+ # Write holes (additional rings)
+ for hole_ring in all_rings[1:]:
+ hole_coords = [
+ item
+ for sublist in hole_ring
+ for item in sublist # type: ignore
+ ]
+ columns.append(
+ f"(hole) {' '.join(map(lambda x: str(round(x)), hole_coords))}"
+ )
if 'Point' == geoJSONFeature.geometry.type:
coordinates = geoJSONFeature.geometry.coordinates # type: ignore
columns.append(
f"(kp) {geoJSONFeature.properties['key']} "
f"{round(coordinates[0])} {round(coordinates[1])}"
)
- # TODO: support for multiple GeoJSON Objects of the same type
- # once the CSV supports it
writer.writerow(columns)
yield csvFile.getvalue()
diff --git a/server/tests/test_serialize_viame_csv.py b/server/tests/test_serialize_viame_csv.py
index 107e5c4b7..cd99a78a0 100644
--- a/server/tests/test_serialize_viame_csv.py
+++ b/server/tests/test_serialize_viame_csv.py
@@ -192,6 +192,129 @@
],
[],
),
+ # Testing multi-polygon with different keys
+ (
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [["fish", 1.0]],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [100, 100, 500, 500],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {"key": ""},
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [[[100, 100], [200, 100], [200, 200], [100, 200]]],
+ },
+ },
+ {
+ "type": "Feature",
+ "properties": {"key": "1"},
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [[[300, 300], [400, 300], [400, 400], [300, 400]]],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ "begin": 0,
+ "end": 0,
+ },
+ },
+ [
+ "0,1.png,0,100,100,500,500,1.0,-1,fish,1.0,(poly) 100 100 200 100 200 200 100 200,(poly) 300 300 400 300 400 400 300 400",
+ "",
+ ],
+ [],
+ ),
+ # Testing polygon with hole
+ (
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [["object", 1.0]],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [100, 100, 500, 500],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {"key": ""},
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [[100, 100], [500, 100], [500, 500], [100, 500]],
+ [[200, 200], [400, 200], [400, 400], [200, 400]],
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ "begin": 0,
+ "end": 0,
+ },
+ },
+ [
+ "0,1.png,0,100,100,500,500,1.0,-1,object,1.0,(poly) 100 100 500 100 500 500 100 500,(hole) 200 200 400 200 400 400 200 400",
+ "",
+ ],
+ [],
+ ),
+ # Testing keyed polygon with hole
+ (
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [["region", 1.0]],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [0, 0, 1000, 1000],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {"key": "2"},
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [[0, 0], [1000, 0], [1000, 1000], [0, 1000]],
+ [[100, 100], [200, 100], [200, 200], [100, 200]],
+ [[300, 300], [400, 300], [400, 400], [300, 400]],
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ "begin": 0,
+ "end": 0,
+ },
+ },
+ [
+ "0,1.png,0,0,0,1000,1000,1.0,-1,region,1.0,(poly) 0 0 1000 0 1000 1000 0 1000,(hole) 100 100 200 100 200 200 100 200,(hole) 300 300 400 300 400 400 300 400",
+ "",
+ ],
+ [],
+ ),
# Testing type filter
(
{
diff --git a/testutils/viame.spec.json b/testutils/viame.spec.json
index 040e88d2b..81eca2f3d 100644
--- a/testutils/viame.spec.json
+++ b/testutils/viame.spec.json
@@ -673,5 +673,202 @@
}
},
{}
+ ],
+ [
+ [
+ "0,1.png,0,100,100,500,500,1.0,-1,fish,1.0,(poly) 100 100 200 100 200 200 100 200,(poly) 300 300 400 300 400 400 300 400",
+ ""
+ ],
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [
+ [
+ "fish",
+ 1.0
+ ]
+ ],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [
+ 100,
+ 100,
+ 500,
+ 500
+ ],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {
+ "key": ""
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [100.0, 100.0],
+ [200.0, 100.0],
+ [200.0, 200.0],
+ [100.0, 200.0]
+ ]
+ ]
+ }
+ },
+ {
+ "type": "Feature",
+ "properties": {
+ "key": "1"
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [300.0, 300.0],
+ [400.0, 300.0],
+ [400.0, 400.0],
+ [300.0, 400.0]
+ ]
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "begin": 0,
+ "end": 0
+ }
+ },
+ {}
+ ],
+ [
+ [
+ "0,1.png,0,100,100,500,500,1.0,-1,object,1.0,(poly) 100 100 500 100 500 500 100 500,(hole) 200 200 400 200 400 400 200 400",
+ ""
+ ],
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [
+ [
+ "object",
+ 1.0
+ ]
+ ],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [
+ 100,
+ 100,
+ 500,
+ 500
+ ],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {
+ "key": ""
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [100.0, 100.0],
+ [500.0, 100.0],
+ [500.0, 500.0],
+ [100.0, 500.0]
+ ],
+ [
+ [200.0, 200.0],
+ [400.0, 200.0],
+ [400.0, 400.0],
+ [200.0, 400.0]
+ ]
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "begin": 0,
+ "end": 0
+ }
+ },
+ {}
+ ],
+ [
+ [
+ "0,1.png,0,0,0,1000,1000,1.0,-1,region,1.0,(poly) 0 0 1000 0 1000 1000 0 1000,(hole) 100 100 200 100 200 200 100 200,(hole) 300 300 400 300 400 400 300 400",
+ ""
+ ],
+ {
+ "0": {
+ "id": 0,
+ "attributes": {},
+ "confidencePairs": [
+ [
+ "region",
+ 1.0
+ ]
+ ],
+ "features": [
+ {
+ "frame": 0,
+ "bounds": [
+ 0,
+ 0,
+ 1000,
+ 1000
+ ],
+ "geometry": {
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "properties": {
+ "key": ""
+ },
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [0.0, 0.0],
+ [1000.0, 0.0],
+ [1000.0, 1000.0],
+ [0.0, 1000.0]
+ ],
+ [
+ [100.0, 100.0],
+ [200.0, 100.0],
+ [200.0, 200.0],
+ [100.0, 200.0]
+ ],
+ [
+ [300.0, 300.0],
+ [400.0, 300.0],
+ [400.0, 400.0],
+ [300.0, 400.0]
+ ]
+ ]
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "begin": 0,
+ "end": 0
+ }
+ },
+ {}
]
]