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..1b0c72e6c 100644
--- a/client/src/layers/AnnotationLayers/RectangleLayer.ts
+++ b/client/src/layers/AnnotationLayers/RectangleLayer.ts
@@ -2,7 +2,13 @@
import geo, { GeoEvent } from 'geojs';
import { cloneDeep } from 'lodash';
-import { boundToGeojson } from '../../utils';
+import {
+ boundToGeojson,
+ getRotationFromAttributes,
+ getRotationArrowLine,
+ hasSignificantRotation,
+ rotateGeoJSONCoordinates,
+} from '../../utils';
import BaseLayer, { LayerStyle, BaseLayerParams } from '../BaseLayer';
import { FrameDataTrack } from '../LayerTypes';
import LineLayer from './LineLayer';
@@ -16,6 +22,9 @@ interface RectGeoJSData{
hasPoly: boolean;
set?: string;
dashed?: boolean;
+ rotation?: number;
+ /** Small arrow on the right-edge midpoint when rotation is significant */
+ rotationArrow?: GeoJSON.LineString | null;
}
export default class RectangleLayer extends BaseLayer {
@@ -23,6 +32,9 @@ export default class RectangleLayer extends BaseLayer {
hoverOn: boolean; //to turn over annnotations on
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ arrowFeatureLayer: any;
+
constructor(params: BaseLayerParams) {
super(params);
this.drawingOther = false;
@@ -33,7 +45,7 @@ export default class RectangleLayer extends BaseLayer {
initialize() {
const layer = this.annotator.geoViewerRef.value.createLayer('feature', {
- features: ['polygon'],
+ features: ['polygon', 'line'],
});
this.featureLayer = layer
.createFeature('polygon', { selectionAPI: true })
@@ -66,7 +78,29 @@ export default class RectangleLayer extends BaseLayer {
this.bus.$emit('annotation-clicked', null, false);
}
});
+ this.arrowFeatureLayer = layer.createFeature('line');
super.initialize();
+ this.arrowFeatureLayer.style({
+ position: (p: [number, number]) => ({ x: p[0], y: p[1] }),
+ stroke: true,
+ fill: false,
+ strokeColor: (_p: [number, number], _i: number, data: RectGeoJSData) => {
+ if (data.selected) return this.stateStyling.selected.color;
+ if (data.styleType) return this.typeStyling.value.color(data.styleType[0]);
+ return this.typeStyling.value.color('');
+ },
+ strokeWidth: (_p: [number, number], _i: number, data: RectGeoJSData) => {
+ if (data.selected) return this.stateStyling.selected.strokeWidth;
+ if (data.styleType) return this.typeStyling.value.strokeWidth(data.styleType[0]);
+ return this.stateStyling.standard.strokeWidth;
+ },
+ strokeOpacity: (_p: [number, number], _i: number, data: RectGeoJSData) => {
+ if (data.selected) return this.stateStyling.selected.opacity;
+ if (data.styleType) return this.typeStyling.value.opacity(data.styleType[0]);
+ return this.stateStyling.standard.opacity;
+ },
+ strokeOffset: 0,
+ });
}
hoverAnnotations(e: GeoEvent) {
@@ -111,6 +145,16 @@ 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)) {
+ const updatedCoords = rotateGeoJSONCoordinates(polygon.coordinates[0], rotation ?? 0);
+ polygon.coordinates[0] = updatedCoords;
+ }
+
const dashed = !!(track.set && comparisonSets?.includes(track.set));
if (dashed) {
const temp = cloneDeep(polygon);
@@ -120,7 +164,6 @@ export default class RectangleLayer extends BaseLayer {
temp.coordinates[0] = LineLayer.dashLine(temp.coordinates[0], dashSize);
polygon = temp;
}
-
const annotation: RectGeoJSData = {
trackId: track.track.id,
selected: track.selected,
@@ -130,6 +173,8 @@ export default class RectangleLayer extends BaseLayer {
hasPoly,
set: track.set,
dashed,
+ rotation,
+ rotationArrow: getRotationArrowLine(track.features.bounds, rotation || 0),
};
arr.push(annotation);
}
@@ -142,12 +187,20 @@ export default class RectangleLayer extends BaseLayer {
.data(this.formattedData)
.polygon((d: RectGeoJSData) => d.polygon.coordinates[0])
.draw();
+ const arrowData = this.formattedData.filter((d) => d.rotationArrow);
+ this.arrowFeatureLayer
+ .data(arrowData)
+ .line((d: RectGeoJSData) => d.rotationArrow!.coordinates)
+ .draw();
}
disable() {
this.featureLayer
.data([])
.draw();
+ this.arrowFeatureLayer
+ .data([])
+ .draw();
}
createStyle(): LayerStyle {
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..8d5db14a9 100644
--- a/client/src/layers/EditAnnotationLayer.ts
+++ b/client/src/layers/EditAnnotationLayer.ts
@@ -1,6 +1,16 @@
/*eslint class-methods-use-this: "off"*/
import geo, { GeoEvent } from 'geojs';
-import { boundToGeojson, reOrdergeoJSON } from '../utils';
+import {
+ boundToGeojson,
+ reOrdergeoJSON,
+ getRotationFromAttributes,
+ getRotationArrowLine,
+ hasSignificantRotation,
+ ROTATION_ATTRIBUTE_NAME,
+ RectBounds,
+ getRotationBetweenCoordinateArrays,
+ rotateGeoJSONCoordinates,
+} from '../utils';
import { FrameDataTrack } from './LayerTypes';
import BaseLayer, { BaseLayerParams, LayerStyle } from './BaseLayer';
@@ -69,6 +79,11 @@ export default class EditAnnotationLayer extends BaseLayer {
/* in-progress events only emitted for lines and polygons */
shapeInProgress: GeoJSON.LineString | GeoJSON.Polygon | null;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ arrowFeatureLayer: any;
+
+ unrotatedGeoJSONCoords: GeoJSON.Position[] | null;
+
constructor(params: BaseLayerParams & EditAnnotationLayerParams) {
super(params);
this.skipNextExternalUpdate = false;
@@ -80,6 +95,7 @@ export default class EditAnnotationLayer extends BaseLayer {
this.shapeInProgress = null;
this.disableModeSync = false;
this.leftButtonCheckTimeout = -1;
+ this.unrotatedGeoJSONCoords = null;
//Only initialize once, prevents recreating Layer each edit
this.initialize();
@@ -143,6 +159,18 @@ export default class EditAnnotationLayer extends BaseLayer {
this.disableModeSync = false;
});
this.featureLayer.geoOn(geo.event.actiondown, (e: GeoEvent) => this.setShapeInProgress(e));
+
+ const arrowLayer = this.annotator.geoViewerRef.value.createLayer('feature', { features: ['line'] });
+ this.arrowFeatureLayer = arrowLayer.createFeature('line');
+ this.arrowFeatureLayer.style({
+ position: (p: [number, number]) => ({ x: p[0], y: p[1] }),
+ stroke: true,
+ fill: false,
+ strokeColor: () => (this.styleType ? this.typeStyling.value.color(this.styleType) : this.stateStyling.selected.color),
+ strokeWidth: () => (this.styleType ? this.typeStyling.value.strokeWidth(this.styleType) : this.stateStyling.selected.strokeWidth),
+ strokeOpacity: () => (this.styleType ? this.typeStyling.value.opacity(this.styleType) : this.stateStyling.selected.opacity),
+ strokeOffset: 0,
+ });
}
}
@@ -219,6 +247,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 +259,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');
@@ -310,6 +342,9 @@ export default class EditAnnotationLayer extends BaseLayer {
this.skipNextExternalUpdate = false;
this.setMode(null);
this.featureLayer.removeAllAnnotations(false);
+ if (this.arrowFeatureLayer) {
+ this.arrowFeatureLayer.data([]).draw();
+ }
this.shapeInProgress = null;
if (this.selectedHandleIndex !== -1) {
this.selectedHandleIndex = -1;
@@ -378,6 +413,15 @@ export default class EditAnnotationLayer extends BaseLayer {
let geoJSONData: GeoJSON.Point | GeoJSON.Polygon | GeoJSON.LineString | undefined;
if (this.type === 'rectangle') {
geoJSONData = boundToGeojson(track.features.bounds);
+ this.unrotatedGeoJSONCoords = geoJSONData.coordinates[0] as GeoJSON.Position[];
+
+ // Restore rotation if it exists
+ const rotation = getRotationFromAttributes(track.features.attributes);
+ if (hasSignificantRotation(rotation)) {
+ // Apply rotation to restore the rotated rectangle for editing
+ const updatedCoords = rotateGeoJSONCoordinates(geoJSONData.coordinates[0], rotation ?? 0);
+ geoJSONData.coordinates[0] = updatedCoords;
+ }
} else {
// TODO: this assumes only one polygon
geoJSONData = this.getGeoJSONData(track);
@@ -390,6 +434,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) }
+ : {}),
},
};
@@ -428,11 +476,8 @@ export default class EditAnnotationLayer extends BaseLayer {
// Only calls this once on completion of an annotation
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[],
- );
- }
+
+ this.unrotatedGeoJSONCoords = geoJSONData[0].geometry.coordinates[0] as GeoJSON.Position[];
this.formattedData = geoJSONData;
// The new annotation is in a state without styling, so apply local stypes
this.applyStylesToAnnotations();
@@ -464,33 +509,65 @@ export default class EditAnnotationLayer extends BaseLayer {
const newGeojson: GeoJSON.Feature = (
e.annotation.geojson()
);
+ const rotationBetween = getRotationBetweenCoordinateArrays(
+ this.unrotatedGeoJSONCoords || [],
+ newGeojson.geometry.coordinates[0] as GeoJSON.Position[],
+ );
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[];
+ // 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
+ // Convert to axis-aligned for storage, but keep rotation in properties
+ const axisAlignedCoords = rotateGeoJSONCoordinates(coords, 0 - rotationBetween);
+ newGeojson.properties = {
+ ...newGeojson.properties,
+ [ROTATION_ATTRIBUTE_NAME]: rotationBetween,
+ };
+ newGeojson.geometry.coordinates[0] = reOrdergeoJSON(axisAlignedCoords);
}
+
// 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[];
+
+ // Convert to axis-aligned for storage, but keep rotation in properties
+ newGeojson.properties = {
+ ...(newGeojson.properties || {}),
+ [ROTATION_ATTRIBUTE_NAME]: rotationBetween,
+ };
+ newGeojson.geometry.coordinates[0] = rotateGeoJSONCoordinates(coords, 0 - (rotationBetween ?? 0));
+ }
+
this.formattedData = [{
...newGeojson,
properties: {
annotationType: this.type,
+ ...(newGeojson.properties || {}),
},
type: 'Feature',
}];
@@ -517,6 +594,34 @@ export default class EditAnnotationLayer extends BaseLayer {
this.applyStylesToAnnotations();
this.featureLayer.draw();
+ if (this.arrowFeatureLayer) {
+ if (this.type === 'rectangle') {
+ const ann = this.featureLayer.annotations()[0];
+ if (ann) {
+ const g = ann.geojson();
+ if (g && g.geometry && g.geometry.type === 'Polygon') {
+ const coords = (g.geometry as GeoJSON.Polygon).coordinates[0];
+ const rotation = getRotationFromAttributes(g.properties as Record);
+ const unrotated = rotateGeoJSONCoordinates(coords, 0 - (rotation ?? 0));
+ // create RectBounds from unrotated coordinates
+ const bounds: RectBounds = [Math.min(unrotated[0][0], unrotated[2][0]), Math.min(unrotated[0][1], unrotated[2][1]), Math.max(unrotated[0][0], unrotated[2][0]), Math.max(unrotated[0][1], unrotated[2][1])];
+ const arrow = getRotationArrowLine(bounds, rotation ?? 0);
+ if (arrow) {
+ this.arrowFeatureLayer.data([{ c: arrow.coordinates }]).line((d: { c: GeoJSON.Position[] }) => d.c).draw();
+ } else {
+ this.arrowFeatureLayer.data([]).draw();
+ }
+ } else {
+ this.arrowFeatureLayer.data([]).draw();
+ }
+ } else {
+ this.arrowFeatureLayer.data([]).draw();
+ }
+ } else {
+ this.arrowFeatureLayer.data([]).draw();
+ }
+ }
+
return null;
}
@@ -552,7 +657,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..ab93f20b7 100644
--- a/client/src/utils.spec.ts
+++ b/client/src/utils.spec.ts
@@ -1,5 +1,18 @@
///
-import { updateSubset, reOrdergeoJSON, reOrderBounds } from './utils';
+import {
+ updateSubset,
+ reOrdergeoJSON,
+ reOrderBounds,
+ validateRotation,
+ isRotationValue,
+ getRotationFromAttributes,
+ hasSignificantRotation,
+ isReservedAttributeName,
+ isAxisAligned,
+ ROTATION_THRESHOLD,
+ ROTATION_ATTRIBUTE_NAME,
+} from './utils';
+import type { RectBounds } from './utils';
describe('updateSubset', () => {
it('should return null for identical sets', () => {
@@ -49,3 +62,155 @@ 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('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);
+ });
+ });
+});
diff --git a/client/src/utils.ts b/client/src/utils.ts
index d815f2a47..8ceb514d6 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,9 +197,220 @@ function frameToTimestamp(frame: number, frameRate: number): string | null {
}).format(date);
}
+/**
+ * Compute the centroid of a GeoJSON coordinate array.
+ * For closed polygons (first point equals last), the duplicate is excluded.
+ *
+ * @param coords - GeoJSON Position array
+ * @returns [x, y] centroid or null if empty
+ */
+function getCentroid(coords: GeoJSON.Position[]): [number, number] | null {
+ if (coords.length === 0) return null;
+ let pts = coords;
+ if (
+ coords.length > 1
+ && coords[0][0] === coords[coords.length - 1][0]
+ && coords[0][1] === coords[coords.length - 1][1]
+ ) {
+ pts = coords.slice(0, -1);
+ }
+ const [sx, sy] = pts.reduce(
+ (acc, p) => [acc[0] + p[0], acc[1] + p[1]],
+ [0, 0],
+ );
+ return [sx / pts.length, sy / pts.length];
+}
+
+/**
+ * Given two GeoJSON coordinate arrays, compute the centroid of each and the
+ * rotation (in radians) from the first array's orientation to the second's.
+ *
+ * The orientation of each array is taken as the angle from its centroid to
+ * its first point. The returned value is the angle you would rotate the first
+ * shape to align with the second (counter‑clockwise positive).
+ *
+ * @param coordsA - First coordinate array
+ * @param coordsB - Second coordinate array
+ * @returns Rotation in radians (coordsB angle minus coordsA angle), or 0 if
+ * either array is empty or centroids cannot be computed
+ */
+export function getRotationBetweenCoordinateArrays(
+ coordsA: GeoJSON.Position[],
+ coordsB: GeoJSON.Position[],
+): number {
+ const centerA = getCentroid(coordsA);
+ const centerB = getCentroid(coordsB);
+ if (!centerA || !centerB) return 0;
+
+ const dxA = coordsA[0][0] - centerA[0];
+ const dyA = coordsA[0][1] - centerA[1];
+ const dxB = coordsB[0][0] - centerB[0];
+ const dyB = coordsB[0][1] - centerB[1];
+
+ const angleA = Math.atan2(dyA, dxA);
+ const angleB = Math.atan2(dyB, dxB);
+ return angleB - angleA;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * Get a small arrow LineString at the midpoint of the right edge of a rotated bbox,
+ * pointing outward. Used to indicate rotation direction. Returns null if rotation
+ * is not significant.
+ *
+ * The "right" edge is the edge at +halfWidth in local bbox coords (the vertical
+ * edge at max X when rotation is 0), so the arrow stays on that side for any rotation.
+ *
+ * @param bounds - Axis-aligned bbox [x1, y1, x2, y2]
+ * @param rotation - Rotation in radians (counter-clockwise)
+ * @returns GeoJSON LineString (chevron) or null if rotation is not significant
+ */
+function getRotationArrowLine(
+ bounds: RectBounds,
+ rotation: number,
+): GeoJSON.LineString | null {
+ if (!hasSignificantRotation(rotation)) return null;
+
+ const centerX = bounds[0] + (bounds[2] - bounds[0]) / 2;
+ const centerY = bounds[1] + (bounds[3] - bounds[1]) / 2;
+ const center = [centerX, centerY];
+ const width = bounds[2] - bounds[0];
+ const height = bounds[3] - bounds[1];
+
+ const rightMidPoint = [bounds[2], center[1]];
+
+ // draw a small arrow on the right edge of the bounding box
+ // the arrow is pointing outward from the right edge
+ // the arrow is a triangle with a base and a tip
+ const arrowLength = Math.min(width, height) * 0.12;
+ const arrowTopBase = [
+ rightMidPoint[0] + 5,
+ rightMidPoint[1] + arrowLength * 0.5,
+ ];
+ const arrowTip = [
+ rightMidPoint[0] + arrowLength,
+ rightMidPoint[1],
+ ];
+ const arrowBottomBase = [
+ rightMidPoint[0] + 5,
+ rightMidPoint[1] - arrowLength * 0.5,
+ ];
+
+ const arrowRightCoordinates = [
+ arrowTopBase,
+ arrowTip,
+ arrowBottomBase,
+ ];
+ const rotatedArrowCoordinates = arrowRightCoordinates.map((pt) => rotatedPointAboutCenter(center as [number, number], pt as [number, number], rotation)) as GeoJSON.Position[];
+
+ return {
+ type: 'LineString',
+ coordinates: rotatedArrowCoordinates,
+ };
+}
+
+function rotatedPointAboutCenter(center: [number, number], point: [number, number], rotation: number): [number, number] {
+ const x = point[0] - center[0];
+ const y = point[1] - center[1];
+ const cos = Math.cos(rotation);
+ const sin = Math.sin(rotation);
+ return [x * cos - y * sin + center[0], x * sin + y * cos + center[1]];
+}
+
+function rotateGeoJSONCoordinates(coordinates: GeoJSON.Position[], rotation: number): GeoJSON.Position[] {
+ const center = getCentroid(coordinates);
+ if (!center) return coordinates;
+ return coordinates.map((coord) => rotatedPointAboutCenter(center, [coord[0], coord[1]], rotation)) as GeoJSON.Position[];
+}
+
export {
getResponseError,
boundToGeojson,
+ getRotationArrowLine,
+ rotatedPointAboutCenter,
+ rotateGeoJSONCoordinates,
// findBounds,
updateBounds,
geojsonToBound,
@@ -181,4 +420,5 @@ export {
reOrdergeoJSON,
withinBounds,
frameToTimestamp,
+ isAxisAligned,
};
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,