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,