diff --git a/client/dive-common/components/Attributes/AttributeEditor.vue b/client/dive-common/components/Attributes/AttributeEditor.vue
index 2b366f414..c91b8b627 100644
--- a/client/dive-common/components/Attributes/AttributeEditor.vue
+++ b/client/dive-common/components/Attributes/AttributeEditor.vue
@@ -5,6 +5,11 @@ import {
import { Attribute } from 'vue-media-annotator/use/AttributeTypes';
import { usePrompt } from 'dive-common/vue-utilities/prompt-service';
import { useTrackStyleManager } from 'vue-media-annotator/provides';
+import {
+ isReservedAttributeName,
+ RESERVED_DETECTION_ATTRIBUTES,
+ RESERVED_TRACK_ATTRIBUTES,
+} from 'vue-media-annotator/utils';
import AttributeRendering from './AttributeRendering.vue';
import AttributeValueColors from './AttributeValueColors.vue';
import AttributeNumberValueColors from './AttributeNumberValueColors.vue';
@@ -205,6 +210,10 @@ export default defineComponent({
typeChange,
numericChange,
launchColorEditor,
+ //utils
+ isReservedAttributeName,
+ RESERVED_DETECTION_ATTRIBUTES,
+ RESERVED_TRACK_ATTRIBUTES,
};
},
});
@@ -245,8 +254,15 @@ export default defineComponent({
{
if (type === 'rectangle') {
const bounds = geojsonToBound(data as GeoJSON.Feature);
+ // Extract rotation from properties if it exists
+ const rotation = data.properties && isRotationValue(data.properties?.[ROTATION_ATTRIBUTE_NAME])
+ ? data.properties[ROTATION_ATTRIBUTE_NAME] as number
+ : undefined;
cb();
- handler.updateRectBounds(frameNumberRef.value, flickNumberRef.value, bounds);
+ handler.updateRectBounds(frameNumberRef.value, flickNumberRef.value, bounds, rotation);
} else {
handler.updateGeoJSON(mode, frameNumberRef.value, flickNumberRef.value, data, key, cb);
}
diff --git a/client/src/layers/AnnotationLayers/RectangleLayer.ts b/client/src/layers/AnnotationLayers/RectangleLayer.ts
index f96bdac3d..1167817dd 100644
--- a/client/src/layers/AnnotationLayers/RectangleLayer.ts
+++ b/client/src/layers/AnnotationLayers/RectangleLayer.ts
@@ -2,7 +2,12 @@
import geo, { GeoEvent } from 'geojs';
import { cloneDeep } from 'lodash';
-import { boundToGeojson } from '../../utils';
+import {
+ boundToGeojson,
+ applyRotationToPolygon,
+ getRotationFromAttributes,
+ hasSignificantRotation,
+} from '../../utils';
import BaseLayer, { LayerStyle, BaseLayerParams } from '../BaseLayer';
import { FrameDataTrack } from '../LayerTypes';
import LineLayer from './LineLayer';
@@ -16,6 +21,7 @@ interface RectGeoJSData{
hasPoly: boolean;
set?: string;
dashed?: boolean;
+ rotation?: number;
}
export default class RectangleLayer extends BaseLayer {
@@ -111,6 +117,15 @@ export default class RectangleLayer extends BaseLayer {
const filtered = track.features.geometry.features.filter((feature) => feature.geometry && feature.geometry.type === 'Polygon');
hasPoly = filtered.length > 0;
}
+
+ // Get rotation from attributes if it exists
+ const rotation = getRotationFromAttributes(track.features.attributes);
+
+ // Apply rotation to polygon if rotation exists
+ if (hasSignificantRotation(rotation)) {
+ polygon = applyRotationToPolygon(polygon, track.features.bounds, rotation!);
+ }
+
const dashed = !!(track.set && comparisonSets?.includes(track.set));
if (dashed) {
const temp = cloneDeep(polygon);
@@ -130,6 +145,7 @@ export default class RectangleLayer extends BaseLayer {
hasPoly,
set: track.set,
dashed,
+ rotation,
};
arr.push(annotation);
}
diff --git a/client/src/layers/BaseLayer.ts b/client/src/layers/BaseLayer.ts
index ceac72ed8..c47d5052f 100644
--- a/client/src/layers/BaseLayer.ts
+++ b/client/src/layers/BaseLayer.ts
@@ -22,6 +22,7 @@ export interface LayerStyle {
textOpacity?: (data: D) => number;
fontSize?: (data: D) => string | undefined;
offset?: (data: D) => { x: number; y: number };
+ rotation?: (data: D) => number;
fill?: ObjectFunction | boolean;
radius?: PointFunction | number;
textAlign?: ((data: D) => string) | string;
diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts
index d25ed4725..5f3035524 100644
--- a/client/src/layers/EditAnnotationLayer.ts
+++ b/client/src/layers/EditAnnotationLayer.ts
@@ -1,6 +1,14 @@
/*eslint class-methods-use-this: "off"*/
import geo, { GeoEvent } from 'geojs';
-import { boundToGeojson, reOrdergeoJSON } from '../utils';
+import {
+ boundToGeojson,
+ reOrdergeoJSON,
+ rotatedPolygonToAxisAlignedBbox,
+ applyRotationToPolygon,
+ getRotationFromAttributes,
+ hasSignificantRotation,
+ ROTATION_ATTRIBUTE_NAME,
+} from '../utils';
import { FrameDataTrack } from './LayerTypes';
import BaseLayer, { BaseLayerParams, LayerStyle } from './BaseLayer';
@@ -219,6 +227,8 @@ export default class EditAnnotationLayer extends BaseLayer {
this.annotator.setCursor(rectVertex[e.handle.handle.index]);
} else if (e.handle.handle.type === 'edge') {
this.annotator.setCursor(rectEdge[e.handle.handle.index]);
+ } else if (e.handle.handle.type === 'rotate') {
+ this.annotator.setCursor('grab');
}
} else if (e.handle.handle.type === 'vertex') {
this.annotator.setCursor('grab');
@@ -229,6 +239,8 @@ export default class EditAnnotationLayer extends BaseLayer {
this.annotator.setCursor('move');
} else if (e.handle.handle.type === 'resize') {
this.annotator.setCursor('nwse-resize');
+ } else if (e.handle.handle.type === 'rotate') {
+ this.annotator.setCursor('grab');
}
} else if (this.getMode() !== 'creation') {
this.annotator.setCursor('default');
@@ -378,6 +390,17 @@ export default class EditAnnotationLayer extends BaseLayer {
let geoJSONData: GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString | undefined;
if (this.type === 'rectangle') {
geoJSONData = boundToGeojson(track.features.bounds);
+
+ // Restore rotation if it exists
+ const rotation = getRotationFromAttributes(track.features.attributes);
+ if (hasSignificantRotation(rotation)) {
+ // Apply rotation to restore the rotated rectangle for editing
+ geoJSONData = applyRotationToPolygon(
+ geoJSONData,
+ track.features.bounds,
+ rotation || 0,
+ );
+ }
} else {
// TODO: this assumes only one polygon
geoJSONData = this.getGeoJSONData(track);
@@ -390,6 +413,10 @@ export default class EditAnnotationLayer extends BaseLayer {
geometry: geoJSONData,
properties: {
annotationType: typeMapper.get(this.type),
+ // Preserve rotation in properties
+ ...(getRotationFromAttributes(track.features.attributes) !== undefined
+ ? { [ROTATION_ATTRIBUTE_NAME]: getRotationFromAttributes(track.features.attributes) }
+ : {}),
},
};
@@ -429,9 +456,27 @@ export default class EditAnnotationLayer extends BaseLayer {
if (e.annotation.state() === 'done' && this.getMode() === 'creation') {
const geoJSONData = [e.annotation.geojson()];
if (this.type === 'rectangle') {
- geoJSONData[0].geometry.coordinates[0] = reOrdergeoJSON(
- geoJSONData[0].geometry.coordinates[0] as GeoJSON.Position[],
- );
+ const coords = geoJSONData[0].geometry.coordinates[0] as GeoJSON.Position[];
+ const rotationInfo = rotatedPolygonToAxisAlignedBbox(coords);
+ const isRotated = hasSignificantRotation(rotationInfo.rotation);
+
+ // Convert to axis-aligned for storage
+ const axisAlignedCoords = [
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ ];
+ geoJSONData[0].geometry.coordinates[0] = axisAlignedCoords;
+
+ // Store rotation if significant
+ if (isRotated) {
+ geoJSONData[0].properties = {
+ ...geoJSONData[0].properties,
+ [ROTATION_ATTRIBUTE_NAME]: rotationInfo.rotation,
+ };
+ }
}
this.formattedData = geoJSONData;
// The new annotation is in a state without styling, so apply local stypes
@@ -466,31 +511,95 @@ export default class EditAnnotationLayer extends BaseLayer {
);
if (this.formattedData.length > 0) {
if (this.type === 'rectangle') {
- /* Updating the corners for the proper cursor icons
- Also allows for regrabbing of the handle */
- newGeojson.geometry.coordinates[0] = reOrdergeoJSON(
- newGeojson.geometry.coordinates[0] as GeoJSON.Position[],
- );
+ const coords = newGeojson.geometry.coordinates[0] as GeoJSON.Position[];
+
+ // Calculate rotation info once and reuse
+ const rotationInfo = rotatedPolygonToAxisAlignedBbox(coords);
+ const isRotated = hasSignificantRotation(rotationInfo.rotation);
+
+ // During editing, keep the rotated polygon as-is so corners don't snap
+ // Only reorder if not rotated
+ if (!isRotated) {
+ // No rotation, use reordered coordinates
+ newGeojson.geometry.coordinates[0] = reOrdergeoJSON(coords);
+ }
+ // If rotated, keep the rotated coordinates for editing
+
// The corners need to update for the indexes to update
- // coordinates are in a different system than display
- const coords = newGeojson.geometry.coordinates[0].map(
- (coord) => ({ x: coord[0], y: coord[1] }),
+ // Use the actual coordinates (rotated or not) to set corners correctly
+ const currentCoords = newGeojson.geometry.coordinates[0] as GeoJSON.Position[];
+ const cornerCoords = currentCoords.map(
+ (coord: GeoJSON.Position) => ({ x: coord[0], y: coord[1] }),
);
// only use the 4 coords instead of 5
- const remapped = this.annotator.geoViewerRef.value.worldToGcs(coords.splice(0, 4));
+ const remapped = this.annotator.geoViewerRef.value.worldToGcs(cornerCoords.splice(0, 4));
e.annotation.options('corners', remapped);
//This will retrigger highlighting of the current handle after releasing the mouse
setTimeout(() => this.annotator.geoViewerRef
.value.interactor().retriggerMouseMove(), 0);
+
+ // For rectangles, convert to axis-aligned bounds with rotation when saving
+ if (isRotated) {
+ // Convert to axis-aligned for storage, but keep rotation in properties
+ const axisAlignedCoords = [
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ ];
+ newGeojson.geometry.coordinates[0] = axisAlignedCoords;
+ newGeojson.properties = {
+ ...newGeojson.properties,
+ [ROTATION_ATTRIBUTE_NAME]: rotationInfo.rotation,
+ };
+ } else {
+ // No rotation, ensure coordinates are properly ordered
+ newGeojson.geometry.coordinates[0] = reOrdergeoJSON(coords);
+ }
}
+
// update existing feature
this.formattedData[0].geometry = newGeojson.geometry;
+ if (newGeojson.properties) {
+ this.formattedData[0].properties = {
+ ...this.formattedData[0].properties,
+ ...newGeojson.properties,
+ };
+ }
} else {
// create new feature
+ // For rectangles, convert to axis-aligned bounds with rotation when saving
+ if (this.type === 'rectangle') {
+ const coords = newGeojson.geometry.coordinates[0] as GeoJSON.Position[];
+ const rotationInfo = rotatedPolygonToAxisAlignedBbox(coords);
+ const isRotated = hasSignificantRotation(rotationInfo.rotation);
+
+ if (isRotated) {
+ // Convert to axis-aligned for storage, but keep rotation in properties
+ const axisAlignedCoords = [
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[1]],
+ [rotationInfo.bounds[2], rotationInfo.bounds[3]],
+ [rotationInfo.bounds[0], rotationInfo.bounds[3]],
+ ];
+ newGeojson.geometry.coordinates[0] = axisAlignedCoords;
+ newGeojson.properties = {
+ ...(newGeojson.properties || {}),
+ [ROTATION_ATTRIBUTE_NAME]: rotationInfo.rotation,
+ };
+ } else {
+ // No rotation, ensure coordinates are properly ordered
+ newGeojson.geometry.coordinates[0] = reOrdergeoJSON(coords);
+ }
+ }
+
this.formattedData = [{
...newGeojson,
properties: {
annotationType: this.type,
+ ...(newGeojson.properties || {}),
},
type: 'Feature',
}];
@@ -552,7 +661,7 @@ export default class EditAnnotationLayer extends BaseLayer {
if (this.type === 'rectangle') {
return {
handles: {
- rotate: false,
+ rotate: true,
},
};
}
diff --git a/client/src/provides.ts b/client/src/provides.ts
index 4cf22ff13..078b8ae5f 100644
--- a/client/src/provides.ts
+++ b/client/src/provides.ts
@@ -132,6 +132,7 @@ export interface Handler {
frameNum: number,
flickNum: number,
bounds: RectBounds,
+ rotation?: number,
): void;
/* update geojson for track */
updateGeoJSON(
diff --git a/client/src/use/useAttributes.ts b/client/src/use/useAttributes.ts
index 754a2af0b..469e49f16 100644
--- a/client/src/use/useAttributes.ts
+++ b/client/src/use/useAttributes.ts
@@ -4,6 +4,7 @@ import {
import { StringKeyObject } from 'vue-media-annotator/BaseAnnotation';
import { StyleManager, Track } from '..';
import CameraStore from '../CameraStore';
+import { isReservedAttributeName } from '../utils';
import { LineChartData } from './useLineChart';
import {
Attribute, AttributeFilter, AttributeKeyFilter,
@@ -64,6 +65,16 @@ export default function UseAttributes(
function setAttribute({ data, oldAttribute }:
{data: Attribute; oldAttribute?: Attribute }, updateAllTracks = false) {
+ // Validate that the attribute name is not reserved
+ if (isReservedAttributeName(data.name, data.belongs)) {
+ const reservedList = data.belongs === 'detection'
+ ? ['rotation', 'userModified']
+ : ['userCreated'];
+ throw new Error(
+ `Attribute name "${data.name}" is reserved. Reserved ${data.belongs} attributes: ${reservedList.join(', ')}`,
+ );
+ }
+
if (oldAttribute && data.key !== oldAttribute.key) {
// Name change should delete the old attribute and create a new one with the updated id
VueDel(attributes.value, oldAttribute.key);
diff --git a/client/src/utils.spec.ts b/client/src/utils.spec.ts
index b01cce209..58602329b 100644
--- a/client/src/utils.spec.ts
+++ b/client/src/utils.spec.ts
@@ -1,5 +1,21 @@
///
-import { updateSubset, reOrdergeoJSON, reOrderBounds } from './utils';
+import {
+ updateSubset,
+ reOrdergeoJSON,
+ reOrderBounds,
+ applyRotationToPolygon,
+ rotatedPolygonToAxisAlignedBbox,
+ validateRotation,
+ isRotationValue,
+ getRotationFromAttributes,
+ hasSignificantRotation,
+ isReservedAttributeName,
+ calculateRotationFromPolygon,
+ isAxisAligned,
+ ROTATION_THRESHOLD,
+ ROTATION_ATTRIBUTE_NAME,
+} from './utils';
+import type { RectBounds } from './utils';
describe('updateSubset', () => {
it('should return null for identical sets', () => {
@@ -49,3 +65,324 @@ describe('updateSubset', () => {
expect(reOrdergeoJSON([ll, ul, ur, lr, ll])).toEqual(rectBounds);
});
});
+
+describe('Rotation utilities', () => {
+ describe('validateRotation', () => {
+ it('should return undefined for undefined input', () => {
+ expect(validateRotation(undefined)).toBeUndefined();
+ });
+
+ it('should return undefined for null input', () => {
+ expect(validateRotation(null)).toBeUndefined();
+ });
+
+ it('should return undefined for NaN', () => {
+ expect(validateRotation(NaN)).toBeUndefined();
+ });
+
+ it('should return undefined for Infinity', () => {
+ expect(validateRotation(Infinity)).toBeUndefined();
+ expect(validateRotation(-Infinity)).toBeUndefined();
+ });
+
+ it('should normalize large rotation values to [-π, π]', () => {
+ expect(validateRotation(3 * Math.PI)).toBeCloseTo(Math.PI, 5);
+ expect(validateRotation(-3 * Math.PI)).toBeCloseTo(-Math.PI, 5);
+ expect(validateRotation(2 * Math.PI + 0.5)).toBeCloseTo(0.5, 5);
+ });
+
+ it('should return undefined for values below threshold', () => {
+ expect(validateRotation(0)).toBeUndefined();
+ expect(validateRotation(ROTATION_THRESHOLD / 2)).toBeUndefined();
+ expect(validateRotation(-ROTATION_THRESHOLD / 2)).toBeUndefined();
+ });
+
+ it('should return normalized value for significant rotation', () => {
+ expect(validateRotation(Math.PI / 4)).toBeCloseTo(Math.PI / 4, 5);
+ expect(validateRotation(-Math.PI / 4)).toBeCloseTo(-Math.PI / 4, 5);
+ });
+ });
+
+ describe('isRotationValue', () => {
+ it('should return true for valid numbers', () => {
+ expect(isRotationValue(0)).toBe(true);
+ expect(isRotationValue(Math.PI)).toBe(true);
+ expect(isRotationValue(-1.5)).toBe(true);
+ });
+
+ it('should return false for NaN', () => {
+ expect(isRotationValue(NaN)).toBe(false);
+ });
+
+ it('should return false for Infinity', () => {
+ expect(isRotationValue(Infinity)).toBe(false);
+ expect(isRotationValue(-Infinity)).toBe(false);
+ });
+
+ it('should return false for non-numbers', () => {
+ expect(isRotationValue('0')).toBe(false);
+ expect(isRotationValue(null)).toBe(false);
+ expect(isRotationValue(undefined)).toBe(false);
+ expect(isRotationValue({})).toBe(false);
+ });
+ });
+
+ describe('getRotationFromAttributes', () => {
+ it('should return rotation value when present', () => {
+ const attrs = { [ROTATION_ATTRIBUTE_NAME]: Math.PI / 4 };
+ expect(getRotationFromAttributes(attrs)).toBeCloseTo(Math.PI / 4, 5);
+ });
+
+ it('should return undefined when attributes is undefined', () => {
+ expect(getRotationFromAttributes(undefined)).toBeUndefined();
+ });
+
+ it('should return undefined when rotation is not present', () => {
+ const attrs = { other: 'value' };
+ expect(getRotationFromAttributes(attrs)).toBeUndefined();
+ });
+
+ it('should return undefined for invalid rotation values', () => {
+ const attrs1 = { [ROTATION_ATTRIBUTE_NAME]: NaN };
+ const attrs2 = { [ROTATION_ATTRIBUTE_NAME]: Infinity };
+ const attrs3 = { [ROTATION_ATTRIBUTE_NAME]: 'not a number' };
+ expect(getRotationFromAttributes(attrs1)).toBeUndefined();
+ expect(getRotationFromAttributes(attrs2)).toBeUndefined();
+ expect(getRotationFromAttributes(attrs3)).toBeUndefined();
+ });
+ });
+
+ describe('hasSignificantRotation', () => {
+ it('should return false for undefined', () => {
+ expect(hasSignificantRotation(undefined)).toBe(false);
+ });
+
+ it('should return false for null', () => {
+ expect(hasSignificantRotation(null)).toBe(false);
+ });
+
+ it('should return false for zero', () => {
+ expect(hasSignificantRotation(0)).toBe(false);
+ });
+
+ it('should return false for values below threshold', () => {
+ expect(hasSignificantRotation(ROTATION_THRESHOLD / 2)).toBe(false);
+ expect(hasSignificantRotation(-ROTATION_THRESHOLD / 2)).toBe(false);
+ });
+
+ it('should return true for values above threshold', () => {
+ expect(hasSignificantRotation(ROTATION_THRESHOLD * 2)).toBe(true);
+ expect(hasSignificantRotation(-ROTATION_THRESHOLD * 2)).toBe(true);
+ expect(hasSignificantRotation(Math.PI / 4)).toBe(true);
+ });
+ });
+
+ describe('isReservedAttributeName', () => {
+ it('should return true for reserved detection attributes', () => {
+ expect(isReservedAttributeName('rotation', 'detection')).toBe(true);
+ expect(isReservedAttributeName('userModified', 'detection')).toBe(true);
+ });
+
+ it('should return false for non-reserved detection attributes', () => {
+ expect(isReservedAttributeName('customAttr', 'detection')).toBe(false);
+ expect(isReservedAttributeName('userCreated', 'detection')).toBe(false);
+ });
+
+ it('should return true for reserved track attributes', () => {
+ expect(isReservedAttributeName('userCreated', 'track')).toBe(true);
+ });
+
+ it('should return false for non-reserved track attributes', () => {
+ expect(isReservedAttributeName('rotation', 'track')).toBe(false);
+ expect(isReservedAttributeName('customAttr', 'track')).toBe(false);
+ });
+ });
+
+ describe('calculateRotationFromPolygon', () => {
+ it('should return 0 for insufficient coordinates', () => {
+ expect(calculateRotationFromPolygon([])).toBe(0);
+ expect(calculateRotationFromPolygon([[0, 0]])).toBe(0);
+ });
+
+ it('should calculate rotation from first edge', () => {
+ // Horizontal edge (0 degrees)
+ const coords1 = [[0, 0], [10, 0], [10, 10], [0, 10]];
+ expect(calculateRotationFromPolygon(coords1)).toBeCloseTo(0, 5);
+
+ // Vertical edge (90 degrees = π/2)
+ const coords2 = [[0, 0], [0, 10], [10, 10], [10, 0]];
+ expect(calculateRotationFromPolygon(coords2)).toBeCloseTo(Math.PI / 2, 5);
+
+ // 45 degree edge
+ const coords3 = [[0, 0], [10, 10], [20, 10], [10, 0]];
+ expect(calculateRotationFromPolygon(coords3)).toBeCloseTo(Math.PI / 4, 5);
+ });
+ });
+
+ describe('isAxisAligned', () => {
+ it('should return true for insufficient coordinates', () => {
+ expect(isAxisAligned([])).toBe(true);
+ expect(isAxisAligned([[0, 0]])).toBe(true);
+ expect(isAxisAligned([[0, 0], [1, 0]])).toBe(true);
+ });
+
+ it('should return true for axis-aligned rectangles', () => {
+ const coords = [[0, 0], [0, 10], [10, 10], [10, 0]];
+ expect(isAxisAligned(coords)).toBe(true);
+ });
+
+ it('should return false for rotated rectangles', () => {
+ // 45 degree rotation
+ const coords = [[0, 0], [5, 5], [10, 0], [5, -5]];
+ expect(isAxisAligned(coords)).toBe(false);
+ });
+ });
+
+ describe('applyRotationToPolygon', () => {
+ it('should apply rotation to axis-aligned bounding box', () => {
+ const bounds: RectBounds = [0, 0, 10, 10];
+ const polygon = {
+ type: 'Polygon' as const,
+ coordinates: [[[0, 10], [0, 0], [10, 0], [10, 10], [0, 10]]],
+ };
+ const rotation = Math.PI / 4; // 45 degrees
+
+ const result = applyRotationToPolygon(polygon, bounds, rotation);
+
+ expect(result.type).toBe('Polygon');
+ expect(result.coordinates[0]).toHaveLength(5); // 4 corners + closing point
+ expect(result.coordinates[0][0]).toEqual(result.coordinates[0][4]); // Closed polygon
+
+ // Check that corners are rotated (not axis-aligned)
+ const firstCorner = result.coordinates[0][0];
+ // After 45 degree rotation, corners should not be at integer coordinates
+ expect(firstCorner[0]).not.toBe(0);
+ expect(firstCorner[1]).not.toBe(0);
+ });
+
+ it('should return same polygon for zero rotation', () => {
+ const bounds: RectBounds = [0, 0, 10, 10];
+ const polygon = {
+ type: 'Polygon' as const,
+ coordinates: [[[0, 10], [0, 0], [10, 0], [10, 10], [0, 10]]],
+ };
+
+ const result = applyRotationToPolygon(polygon, bounds, 0);
+
+ // Should be very close to original (within floating point precision)
+ // Check all corners (excluding the closing point)
+ const corners = result.coordinates[0].slice(0, 4);
+ // After zero rotation, corners should match original bounds
+ // The function creates corners relative to center, so check that all corners exist
+ expect(corners).toHaveLength(4);
+ // Verify polygon is closed
+ expect(result.coordinates[0][0]).toEqual(result.coordinates[0][4]);
+ });
+
+ it('should handle 90 degree rotation', () => {
+ const bounds: RectBounds = [0, 0, 10, 10];
+ const polygon = {
+ type: 'Polygon' as const,
+ coordinates: [[[0, 10], [0, 0], [10, 0], [10, 10], [0, 10]]],
+ };
+ const rotation = Math.PI / 2; // 90 degrees
+
+ const result = applyRotationToPolygon(polygon, bounds, rotation);
+
+ // After 90 degree rotation, the box should still be axis-aligned but dimensions swapped
+ // Center should remain at (5, 5)
+ const centerX = (bounds[0] + bounds[2]) / 2;
+ const centerY = (bounds[1] + bounds[3]) / 2;
+ const corners = result.coordinates[0].slice(0, 4);
+ const avgX = corners.reduce((sum, c) => sum + c[0], 0) / 4;
+ const avgY = corners.reduce((sum, c) => sum + c[1], 0) / 4;
+ expect(avgX).toBeCloseTo(centerX, 5);
+ expect(avgY).toBeCloseTo(centerY, 5);
+ });
+ });
+
+ describe('rotatedPolygonToAxisAlignedBbox', () => {
+ it('should handle axis-aligned rectangles', () => {
+ const coords = [[0, 10], [0, 0], [10, 0], [10, 10], [0, 10]];
+ const result = rotatedPolygonToAxisAlignedBbox(coords);
+
+ expect(result.rotation).toBe(0);
+ expect(result.bounds).toEqual([0, 0, 10, 10]);
+ });
+
+ it('should handle rotated rectangles', () => {
+ // Create a rotated rectangle (45 degrees)
+ // Start with axis-aligned box at [0, 0, 10, 10], then rotate 45 degrees
+ const centerX = 5;
+ const centerY = 5;
+ const halfSize = 5;
+ const angle = Math.PI / 4;
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+
+ // Rotate corners
+ const corners = [
+ [-halfSize, -halfSize],
+ [-halfSize, halfSize],
+ [halfSize, halfSize],
+ [halfSize, -halfSize],
+ ].map(([x, y]) => {
+ const rotatedX = x * cos - y * sin;
+ const rotatedY = x * sin + y * cos;
+ return [rotatedX + centerX, rotatedY + centerY] as [number, number];
+ });
+
+ const coords = [...corners, corners[0]]; // Close polygon
+ const result = rotatedPolygonToAxisAlignedBbox(coords);
+
+ // Should detect rotation
+ expect(Math.abs(result.rotation)).toBeGreaterThan(ROTATION_THRESHOLD);
+ // Should return axis-aligned bounds
+ expect(result.bounds[0]).toBeLessThanOrEqual(result.bounds[2]);
+ expect(result.bounds[1]).toBeLessThanOrEqual(result.bounds[3]);
+ });
+
+ it('should handle insufficient coordinates', () => {
+ const coords = [[0, 0], [10, 0]];
+ const result = rotatedPolygonToAxisAlignedBbox(coords);
+
+ expect(result.rotation).toBe(0);
+ expect(result.bounds[0]).toBeLessThanOrEqual(result.bounds[2]);
+ expect(result.bounds[1]).toBeLessThanOrEqual(result.bounds[3]);
+ });
+
+ it('should round-trip: rotate then unrotate', () => {
+ const originalBounds: RectBounds = [0, 0, 10, 10];
+ const rotation = Math.PI / 6; // 30 degrees
+
+ // Create rotated polygon
+ const polygon = {
+ type: 'Polygon' as const,
+ coordinates: [[[0, 10], [0, 0], [10, 0], [10, 10], [0, 10]]],
+ };
+ const rotatedPolygon = applyRotationToPolygon(polygon, originalBounds, rotation);
+
+ // Convert back to axis-aligned
+ const result = rotatedPolygonToAxisAlignedBbox(rotatedPolygon.coordinates[0]);
+
+ // Should recover original bounds (within floating point precision)
+ // Note: rotated bbox might be slightly larger due to rotation
+ expect(result.bounds[0]).toBeLessThanOrEqual(originalBounds[0] + 1);
+ expect(result.bounds[1]).toBeLessThanOrEqual(originalBounds[1] + 1);
+ expect(result.bounds[2]).toBeGreaterThanOrEqual(originalBounds[2] - 1);
+ expect(result.bounds[3]).toBeGreaterThanOrEqual(originalBounds[3] - 1);
+ // Should detect rotation (may be normalized to different quadrant)
+ expect(Math.abs(result.rotation)).toBeGreaterThan(ROTATION_THRESHOLD);
+ });
+
+ it('should handle edge case with malformed coordinates', () => {
+ // Test error handling with invalid coordinates
+ const coords: Array<[number, number]> = [];
+ const result = rotatedPolygonToAxisAlignedBbox(coords);
+
+ // Should return valid bounds (fallback behavior)
+ expect(Array.isArray(result.bounds)).toBe(true);
+ expect(result.bounds).toHaveLength(4);
+ });
+ });
+});
diff --git a/client/src/utils.ts b/client/src/utils.ts
index d815f2a47..27e89c563 100644
--- a/client/src/utils.ts
+++ b/client/src/utils.ts
@@ -4,6 +4,34 @@ import { difference } from 'lodash';
// [x1, y1, x2, y2] as (left, top), (bottom, right)
export type RectBounds = [number, number, number, number];
+// Rotation-related constants
+/** Threshold for considering rotation significant (radians) */
+export const ROTATION_THRESHOLD = 0.001;
+/** Attribute name for storing rotation in detection attributes */
+export const ROTATION_ATTRIBUTE_NAME = 'rotation';
+
+// Reserved attribute names - these cannot be used by users when creating attributes
+/** Reserved detection attribute names */
+export const RESERVED_DETECTION_ATTRIBUTES = ['rotation', 'userModified'] as const;
+/** Reserved track attribute names */
+export const RESERVED_TRACK_ATTRIBUTES = ['userCreated'] as const;
+
+/**
+ * Check if an attribute name is reserved for the given attribute type
+ * @param name - Attribute name to check
+ * @param belongs - Whether this is a 'track' or 'detection' attribute
+ * @returns True if the name is reserved
+ */
+export function isReservedAttributeName(
+ name: string,
+ belongs: 'track' | 'detection',
+): boolean {
+ if (belongs === 'detection') {
+ return RESERVED_DETECTION_ATTRIBUTES.includes(name as typeof RESERVED_DETECTION_ATTRIBUTES[number]);
+ }
+ return RESERVED_TRACK_ATTRIBUTES.includes(name as typeof RESERVED_TRACK_ATTRIBUTES[number]);
+}
+
/*
* updateSubset keeps a subset up to date when its superset
* changes. Takes the old and new array values of the superset,
@@ -169,6 +197,300 @@ function frameToTimestamp(frame: number, frameRate: number): string | null {
}).format(date);
}
+/**
+ * Calculate rotation angle in radians from a rotated rectangle polygon
+ * Returns the angle of the first edge (from first to second point) relative to horizontal
+ */
+function calculateRotationFromPolygon(coords: GeoJSON.Position[]): number {
+ if (coords.length < 2) {
+ return 0;
+ }
+ // Get the first edge vector (from first point to second point)
+ const dx = coords[1][0] - coords[0][0];
+ const dy = coords[1][1] - coords[0][1];
+ // Calculate angle using atan2
+ return Math.atan2(dy, dx);
+}
+
+/**
+ * Check if a rectangle is axis-aligned (not rotated)
+ * Returns true if edges are parallel to axes (within a small threshold)
+ */
+function isAxisAligned(coords: GeoJSON.Position[]): boolean {
+ if (coords.length < 4) {
+ return true;
+ }
+
+ // Check if first edge is horizontal or vertical
+ const dx1 = coords[1][0] - coords[0][0];
+ const dy1 = coords[1][1] - coords[0][1];
+ const isHorizontal = Math.abs(dy1) < ROTATION_THRESHOLD;
+ const isVertical = Math.abs(dx1) < ROTATION_THRESHOLD;
+
+ // Check if second edge is perpendicular to first
+ const dx2 = coords[2][0] - coords[1][0];
+ const dy2 = coords[2][1] - coords[1][1];
+ const isPerpendicular = Math.abs(dx1 * dx2 + dy1 * dy2) < ROTATION_THRESHOLD;
+
+ return (isHorizontal || isVertical) && isPerpendicular;
+}
+
+/**
+ * Convert a rotated rectangle polygon to an axis-aligned bounding box.
+ *
+ * This function handles the conversion between the display representation
+ * (rotated polygon) and the storage representation (axis-aligned bbox + rotation).
+ *
+ * The rotation is calculated from the first edge of the polygon.
+ * The resulting bbox is the smallest axis-aligned rectangle that would
+ * contain the original rotated rectangle when unrotated.
+ *
+ * @param coords - Polygon coordinates (should be 4-5 points for a rectangle)
+ * @returns Object containing:
+ * - bounds: Axis-aligned bounding box [x1, y1, x2, y2]
+ * - rotation: Rotation angle in radians (0 if axis-aligned)
+ *
+ * @example
+ * // Rotated rectangle at 45 degrees
+ * const coords = [[0,0], [0,10], [10,10], [10,0], [0,0]];
+ * const { bounds, rotation } = rotatedPolygonToAxisAlignedBbox(coords);
+ * // bounds: [0, 0, 10, 10] (or similar)
+ * // rotation: ~0.785 (45 degrees in radians)
+ */
+function rotatedPolygonToAxisAlignedBbox(
+ coords: GeoJSON.Position[],
+): { bounds: RectBounds; rotation: number } {
+ try {
+ if (coords.length < 4) {
+ // Fallback to simple bounding box calculation
+ let x1 = Infinity;
+ let y1 = Infinity;
+ let x2 = -Infinity;
+ let y2 = -Infinity;
+ coords.forEach((coord) => {
+ x1 = Math.min(x1, coord[0]);
+ y1 = Math.min(y1, coord[1]);
+ x2 = Math.max(x2, coord[0]);
+ y2 = Math.max(y2, coord[1]);
+ });
+ return { bounds: [x1, y1, x2, y2], rotation: 0 };
+ }
+
+ // Validate that we have a proper rectangle (4 distinct corners + optional closing point)
+ const distinctPoints = coords.slice(0, Math.min(4, coords.length));
+ if (distinctPoints.length < 4) {
+ // Fallback to simple bbox
+ let x1 = Infinity;
+ let y1 = Infinity;
+ let x2 = -Infinity;
+ let y2 = -Infinity;
+ distinctPoints.forEach((coord) => {
+ x1 = Math.min(x1, coord[0]);
+ y1 = Math.min(y1, coord[1]);
+ x2 = Math.max(x2, coord[0]);
+ y2 = Math.max(y2, coord[1]);
+ });
+ return { bounds: [x1, y1, x2, y2], rotation: 0 };
+ }
+
+ // Check if rectangle is already axis-aligned
+ if (isAxisAligned(coords)) {
+ // Already axis-aligned, just calculate bounds
+ let x1 = Infinity;
+ let y1 = Infinity;
+ let x2 = -Infinity;
+ let y2 = -Infinity;
+ coords.forEach((coord) => {
+ x1 = Math.min(x1, coord[0]);
+ y1 = Math.min(y1, coord[1]);
+ x2 = Math.max(x2, coord[0]);
+ y2 = Math.max(y2, coord[1]);
+ });
+ return { bounds: [x1, y1, x2, y2], rotation: 0 };
+ }
+
+ // Rectangle is rotated - calculate original axis-aligned bbox by unrotating
+ const rotation = calculateRotationFromPolygon(coords);
+
+ // Calculate center of the rotated rectangle
+ let centerX = 0;
+ let centerY = 0;
+ const numPoints = Math.min(4, coords.length - 1); // Exclude duplicate last point
+ for (let i = 0; i < numPoints; i += 1) {
+ centerX += coords[i][0];
+ centerY += coords[i][1];
+ }
+ centerX /= numPoints;
+ centerY /= numPoints;
+
+ // Unrotate all points by -rotation around center
+ const cos = Math.cos(-rotation);
+ const sin = Math.sin(-rotation);
+ const unrotatedPoints: GeoJSON.Position[] = [];
+ for (let i = 0; i < numPoints; i += 1) {
+ const x = coords[i][0] - centerX;
+ const y = coords[i][1] - centerY;
+ const unrotatedX = x * cos - y * sin;
+ const unrotatedY = x * sin + y * cos;
+ unrotatedPoints.push([unrotatedX + centerX, unrotatedY + centerY]);
+ }
+
+ // Calculate axis-aligned bounding box from unrotated points
+ let x1 = Infinity;
+ let y1 = Infinity;
+ let x2 = -Infinity;
+ let y2 = -Infinity;
+ unrotatedPoints.forEach((coord) => {
+ x1 = Math.min(x1, coord[0]);
+ y1 = Math.min(y1, coord[1]);
+ x2 = Math.max(x2, coord[0]);
+ y2 = Math.max(y2, coord[1]);
+ });
+
+ return { bounds: [x1, y1, x2, y2], rotation };
+ } catch (error) {
+ console.error('Error converting rotated polygon to bbox:', error);
+ // Fallback to simple bbox calculation
+ let x1 = Infinity;
+ let y1 = Infinity;
+ let x2 = -Infinity;
+ let y2 = -Infinity;
+ coords.forEach((coord) => {
+ x1 = Math.min(x1, coord[0]);
+ y1 = Math.min(y1, coord[1]);
+ x2 = Math.max(x2, coord[0]);
+ y2 = Math.max(y2, coord[1]);
+ });
+ return { bounds: [x1, y1, x2, y2], rotation: 0 };
+ }
+}
+
+/**
+ * Type guard to check if a value is a valid rotation number
+ */
+export function isRotationValue(value: unknown): value is number {
+ return typeof value === 'number' && Number.isFinite(value);
+}
+
+/**
+ * Validates and normalizes a rotation value
+ * @param rotation - Rotation angle in radians
+ * @returns Normalized rotation value or undefined if invalid
+ */
+export function validateRotation(rotation: number | undefined | null): number | undefined {
+ if (rotation === undefined || rotation === null) {
+ return undefined;
+ }
+
+ // Check for invalid numbers
+ if (!Number.isFinite(rotation)) {
+ console.warn('Invalid rotation value:', rotation);
+ return undefined;
+ }
+
+ // Normalize to [-π, π] range
+ // This prevents accumulation of large rotation values
+ let normalized = rotation;
+ while (normalized > Math.PI) normalized -= 2 * Math.PI;
+ while (normalized < -Math.PI) normalized += 2 * Math.PI;
+
+ // Return undefined if effectively zero
+ if (Math.abs(normalized) < ROTATION_THRESHOLD) {
+ return undefined;
+ }
+
+ return normalized;
+}
+
+/**
+ * Get rotation from track features attributes
+ * @param attributes - Feature attributes object
+ * @returns Rotation value in radians, or undefined if not present/invalid
+ */
+export function getRotationFromAttributes(
+ attributes: Record | undefined,
+): number | undefined {
+ if (!attributes) return undefined;
+ const rotation = attributes[ROTATION_ATTRIBUTE_NAME];
+ return isRotationValue(rotation) ? rotation : undefined;
+}
+
+/**
+ * Check if rotation is significant (non-zero within threshold)
+ * @param rotation - Rotation angle in radians
+ * @returns True if rotation is significant
+ */
+export function hasSignificantRotation(rotation: number | undefined | null): boolean {
+ if (rotation === undefined || rotation === null) return false;
+ return Math.abs(rotation) > ROTATION_THRESHOLD;
+}
+
+/**
+ * Apply rotation to an axis-aligned bounding box polygon.
+ *
+ * Coordinate System:
+ * - Assumes image coordinates where (0,0) is top-left
+ * - Y-axis increases downward
+ * - Rotation is counter-clockwise in radians (matching GeoJS convention)
+ * - Bounds format: [x1, y1, x2, y2] where (x1,y1) is top-left, (x2,y2) is bottom-right
+ *
+ * @param polygon - The axis-aligned polygon
+ * @param bounds - The bounding box [x1, y1, x2, y2] in image coordinates
+ * @param rotation - Rotation angle in radians (counter-clockwise)
+ * @returns Rotated polygon
+ */
+export function applyRotationToPolygon(
+ polygon: GeoJSON.Polygon,
+ bounds: RectBounds,
+ rotation: number,
+): GeoJSON.Polygon {
+ // Calculate center of the bounding box
+ const centerX = (bounds[0] + bounds[2]) / 2;
+ const centerY = (bounds[1] + bounds[3]) / 2;
+
+ // Calculate width and height
+ const width = bounds[2] - bounds[0];
+ const height = bounds[3] - bounds[1];
+
+ // Half dimensions
+ const halfWidth = width / 2;
+ const halfHeight = height / 2;
+
+ // Rotation matrix components
+ const cos = Math.cos(rotation);
+ const sin = Math.sin(rotation);
+
+ // Transform the four corners
+ const corners = [
+ [-halfWidth, -halfHeight], // bottom-left (relative to center)
+ [-halfWidth, halfHeight], // top-left
+ [halfWidth, halfHeight], // top-right
+ [halfWidth, -halfHeight], // bottom-right
+ ];
+
+ const rotatedCorners = corners.map(([x, y]) => {
+ // Apply rotation
+ const rotatedX = x * cos - y * sin;
+ const rotatedY = x * sin + y * cos;
+ // Translate back to world coordinates
+ return [rotatedX + centerX, rotatedY + centerY] as [number, number];
+ });
+
+ // Return polygon with rotated corners (close the polygon)
+ return {
+ type: 'Polygon',
+ coordinates: [
+ [
+ rotatedCorners[0],
+ rotatedCorners[1],
+ rotatedCorners[2],
+ rotatedCorners[3],
+ rotatedCorners[0], // Close the polygon
+ ],
+ ],
+ };
+}
+
export {
getResponseError,
boundToGeojson,
@@ -181,4 +503,7 @@ export {
reOrdergeoJSON,
withinBounds,
frameToTimestamp,
+ calculateRotationFromPolygon,
+ isAxisAligned,
+ rotatedPolygonToAxisAlignedBbox,
};
diff --git a/docs/DataFormats.md b/docs/DataFormats.md
index b18cb1374..16b2562b0 100644
--- a/docs/DataFormats.md
+++ b/docs/DataFormats.md
@@ -31,6 +31,12 @@ interface AnnotationSchema {
interface TrackData {
id: AnnotationId;
meta: Record;
+ /**
+ * Track-level attributes. Can contain arbitrary key-value pairs.
+ *
+ * Reserved attribute names (cannot be created by users):
+ * - `userCreated`: Internal flag indicating user creation status.
+ */
attributes: Record;
confidencePairs: Array<[string, number]>;
begin: number;
@@ -62,12 +68,36 @@ interface Feature {
bounds?: [number, number, number, number]; // [x1, y1, x2, y2] as (left, top), (bottom, right)
geometry?: GeoJSON.FeatureCollection;
fishLength?: number;
+ /**
+ * Detection attributes. Can contain arbitrary key-value pairs.
+ *
+ * Reserved attribute names (cannot be created by users):
+ * - `rotation`: Rotation angle in radians for rotated bounding boxes (counter-clockwise).
+ * When present, the `bounds` field represents an axis-aligned bounding box, and the
+ * actual rotated rectangle is computed by applying this rotation around the bbox center.
+ * Only stored if rotation is significant (|rotation| > 0.001 radians).
+ * - `userModified`: Internal flag indicating user modification status.
+ */
attributes?: Record;
head?: [number, number];
tail?: [number, number];
}
```
+### Reserved Attribute Names
+
+!!! warning "Reserved Attribute Names"
+ Certain attribute names are reserved by DIVE and cannot be used when creating custom attributes. Attempting to create attributes with these names will result in an error.
+
+**Reserved Detection Attributes** (stored in `Feature.attributes`):
+- `rotation`: Used to store the rotation angle in radians for rotated bounding boxes. When present, the `bounds` field represents an axis-aligned bounding box, and the actual rotated rectangle is computed by applying this rotation around the bbox center. Only stored if rotation is significant (|rotation| > 0.001 radians).
+- `userModified`: Internal flag used by DIVE to track user modification status.
+
+**Reserved Track Attributes** (stored in `TrackData.attributes`):
+- `userCreated`: Internal flag used by DIVE to track user creation status.
+
+These reserved names are enforced at both the UI level (when creating attributes) and the API level (when saving attributes). If you need to use similar names, consider alternatives like `rotationAngle`, `isUserModified`, or `isUserCreated`.
+
The full source [TrackData definition can be found here](https://github.com/Kitware/dive/blob/main/client/src/track.ts) as a TypeScript interface.
### Example JSON File
@@ -87,7 +117,7 @@ This is a relatively simple example, and many optional fields are not included.
"confidencePairs": [["fish", 0.87], ["rock", 0.22]],
"features": [
{ "frame": 0, "bounds": [0, 0, 10, 10], "interpolate": true },
- { "frame": 3, "bounds": [10, 10, 20, 20] },
+ { "frame": 3, "bounds": [10, 10, 20, 20], "attributes": { "rotation": 0.785 } },
],
"begin": 0,
"end": 2,