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,