Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions ROTATION_FEATURE_PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Rotation Support for Bounding Box Annotations

## Overview
This PR adds rotation support for bounding box annotations, allowing users to rotate rectangles and have the rotation persist as a detection attribute. The implementation stores rotated rectangles as axis-aligned bounding boxes with a rotation attribute, ensuring data consistency while providing a smooth editing experience.

## Features

### 1. Rotate Handle with Visual Feedback
- Added rotate handle to the EditAnnotationLayer for rectangle annotations
- Implemented hover cursor icon (`grab`) when hovering over the rotate handle
- Rotate handle is enabled in the `editHandleStyle()` method for rectangles

### 2. Rotation Calculation and Conversion
- **New utility functions in `utils.ts`:**
- `calculateRotationFromPolygon()`: Calculates rotation angle in radians from polygon coordinates
- `isAxisAligned()`: Checks if a rectangle is axis-aligned (not rotated)
- `rotatedPolygonToAxisAlignedBbox()`: Converts rotated rectangles to axis-aligned bounding boxes by unrotating the polygon, preserving the original size

### 3. Rotation Storage
- Rotation is stored as a detection attribute named `rotation` (in radians)
- When a rectangle is rotated, it's converted to an axis-aligned bounding box with the rotation stored separately
- This approach ensures:
- Data consistency (bounds remain axis-aligned)
- No size changes when rotating (original bbox size is preserved)
- Rotation can be easily applied during rendering

### 4. Editing Experience
- **During editing:** Rotated rectangles remain rotated to prevent corners from snapping back
- **On save:** Rotated rectangles are converted to axis-aligned bounds with rotation attribute
- **On load:** Rotation is restored from the detection attribute and applied to the rectangle for editing

### 5. Rendering Support
- **RectangleLayer** reads the rotation attribute and applies it when rendering
- Rotated rectangles are displayed correctly using the `applyRotationToPolygon()` method
- The rotation transformation is applied to axis-aligned bounding boxes to create the visual rotated rectangle

## Technical Details

### Key Changes

#### `client/src/utils.ts`
- Added `calculateRotationFromPolygon()` function
- Added `isAxisAligned()` function to detect axis-aligned rectangles
- Added `rotatedPolygonToAxisAlignedBbox()` function that:
- Detects if rectangle is already axis-aligned
- If rotated, unrotates the polygon around its center to find the original axis-aligned bbox
- Returns both the axis-aligned bounds and rotation angle

#### `client/src/layers/EditAnnotationLayer.ts`
- Added rotate handle hover cursor support in `hoverEditHandle()`
- Updated `formatData()` to restore rotation when loading existing annotations
- Modified `handleEditAction()` to:
- Keep rotated polygons during editing (prevents corner snapping)
- Convert to axis-aligned bounds only when saving
- Store rotation in GeoJSON properties
- Added `applyRotationToPolygon()` helper method to apply rotation transformations

#### `client/src/layers/AnnotationLayers/RectangleLayer.ts`
- Added `rotation` field to `RectGeoJSData` interface
- Updated `formatData()` to:
- Read rotation from detection attributes
- Apply rotation transformation when rendering
- Added `applyRotationToPolygon()` method to transform axis-aligned bboxes to rotated polygons

#### `client/dive-common/use/useModeManager.ts`
- Updated `handleUpdateRectBounds()` to accept optional `rotation` parameter
- Saves rotation as detection attribute when provided
- Removes rotation attribute when rotation is 0 or undefined

#### `client/src/components/LayerManager.vue`
- Updated to extract rotation from GeoJSON properties and pass it to `updateRectBounds()`

#### `client/src/provides.ts`
- Updated `Handler` interface to include optional `rotation` parameter in `updateRectBounds()`

## Bug Fixes

### Size Preservation
- **Issue:** Rotating a bounding box was changing its size because min/max calculations created a larger axis-aligned bbox
- **Fix:** Implemented unrotation logic that finds the original axis-aligned bbox by rotating the polygon back, preserving the exact original size

### Corner Snapping
- **Issue:** After rotating, grab corners would snap back to axis-aligned positions when editing
- **Fix:** Modified editing logic to keep rotated polygons during active editing, only converting to axis-aligned when saving

### Ghost Outline
- **Issue:** After rotation, an outline of the previous angle would remain visible
- **Fix:** Properly update annotation geometry in GeoJS to reflect axis-aligned coordinates

## Data Model

### Storage Format
- **Bounds:** Always stored as axis-aligned `[x1, y1, x2, y2]`
- **Rotation:** Stored as detection attribute `rotation` in radians
- **Display:** Rotation is applied during rendering to show the rotated rectangle

### Example
```javascript
// Storage
bounds: [100, 100, 200, 200] // Axis-aligned
attributes: { rotation: 0.785 } // 45 degrees in radians

// Display
// Rectangle is rendered rotated 45 degrees around its center
```

## Testing Considerations

1. **Rotation Persistence:** Verify rotation is saved and restored correctly
2. **Size Preservation:** Ensure rotating doesn't change the bounding box size
3. **Editing Experience:** Confirm corners don't snap during editing
4. **Visual Rendering:** Verify rotated rectangles display correctly
5. **Edge Cases:** Test with 0 rotation, 90-degree rotations, and near-axis-aligned rectangles

## Future Enhancements

- Text rotation alignment (reverted in this PR, can be re-implemented if needed)
- Rotation snapping to common angles (0°, 45°, 90°, etc.)
- Visual rotation indicator/angle display
2 changes: 1 addition & 1 deletion client/dive-common/components/AnnotationVisibilityMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,10 @@ export default defineComponent({
>
<v-menu
v-if="button.id === 'text'"
:key="`${button.id}-view`"
open-on-hover
bottom
offset-y
:key="`${button.id}-view`"
:close-on-content-click="false"
>
<template #activator="{ on, attrs }">
Expand Down
16 changes: 14 additions & 2 deletions client/dive-common/components/Attributes/AttributeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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_ATTRIBUTES,
} from 'vue-media-annotator/utils';
import AttributeRendering from './AttributeRendering.vue';
import AttributeValueColors from './AttributeValueColors.vue';
import AttributeNumberValueColors from './AttributeNumberValueColors.vue';
Expand Down Expand Up @@ -205,6 +209,9 @@ export default defineComponent({
typeChange,
numericChange,
launchColorEditor,
//utils
isReservedAttributeName,
RESERVED_ATTRIBUTES,
};
},
});
Expand Down Expand Up @@ -245,8 +252,13 @@ export default defineComponent({
<v-text-field
v-model="baseSettings.name"
label="Name"
:rules="[v => !!v || 'Name is required', v => !v.includes(' ')
|| 'No spaces', v => v !== 'userAttributes' || 'Reserved Name']"
:rules="[
v => !!v || 'Name is required',
v => !v.includes(' ') || 'No spaces',
v => v !== 'userAttributes' || 'Reserved Name',
v => !isReservedAttributeName(v, baseSettings.belongs)
|| `Reserved name. ${RESERVED_ATTRIBUTES[baseSettings.belongs].join(', ')} are not allowed.`,
]"
required
/>
<v-select
Expand Down
22 changes: 20 additions & 2 deletions client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import {
} from 'vue';
import { uniq, flatMapDeep, flattenDeep } from 'lodash';
import Track, { TrackId } from 'vue-media-annotator/track';
import { RectBounds, updateBounds } from 'vue-media-annotator/utils';
import {
RectBounds,
updateBounds,
validateRotation,
ROTATION_ATTRIBUTE_NAME,
} from 'vue-media-annotator/utils';
import { EditAnnotationTypes, VisibleAnnotationTypes } from 'vue-media-annotator/layers';
import { AggregateMediaController } from 'vue-media-annotator/components/annotators/mediaControllerType';

Expand Down Expand Up @@ -395,7 +400,7 @@ export default function useModeManager({
creating = newCreatingValue;
}

function handleUpdateRectBounds(frameNum: number, flickNum: number, bounds: RectBounds) {
function handleUpdateRectBounds(frameNum: number, flickNum: number, bounds: RectBounds, rotation?: number) {
if (selectedTrackId.value !== null) {
const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value);
if (track) {
Expand All @@ -412,6 +417,19 @@ export default function useModeManager({
keyframe: true,
interpolate: _shouldInterpolate(interpolate),
});

// Save rotation as detection attribute if provided
const normalizedRotation = validateRotation(rotation);
if (normalizedRotation !== undefined) {
track.setFeatureAttribute(frameNum, ROTATION_ATTRIBUTE_NAME, normalizedRotation);
} else {
// Remove rotation attribute if rotation is 0 or undefined
const feature = track.features[frameNum];
if (feature && feature.attributes && ROTATION_ATTRIBUTE_NAME in feature.attributes) {
track.setFeatureAttribute(frameNum, ROTATION_ATTRIBUTE_NAME, undefined);
}
}

// Mark as user-modified if editing existing annotation (as detection attribute)
// Skip if track is userCreated (user-created tracks don't need userModified on every detection)
if (isEditingExisting && track.attributes?.userCreated !== true) {
Expand Down
8 changes: 6 additions & 2 deletions client/src/components/LayerManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import TextLayer, { FormatTextRow } from '../layers/AnnotationLayers/TextLayer';
import AttributeLayer from '../layers/AnnotationLayers/AttributeLayer';
import AttributeBoxLayer from '../layers/AnnotationLayers/AttributeBoxLayer';
import type { AnnotationId } from '../BaseAnnotation';
import { geojsonToBound } from '../utils';
import { geojsonToBound, isRotationValue, ROTATION_ATTRIBUTE_NAME } from '../utils';
import { VisibleAnnotationTypes } from '../layers';
import UILayer from '../layers/UILayers/UILayer';
import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue';
Expand Down Expand Up @@ -467,8 +467,12 @@ export default defineComponent({
) => {
if (type === 'rectangle') {
const bounds = geojsonToBound(data as GeoJSON.Feature<GeoJSON.Polygon>);
// 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);
}
Expand Down
59 changes: 56 additions & 3 deletions client/src/layers/AnnotationLayers/RectangleLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,13 +22,19 @@ 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<RectGeoJSData> {
drawingOther: boolean; //drawing another type of annotation at the same time?

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;
Expand All @@ -33,7 +45,7 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {

initialize() {
const layer = this.annotator.geoViewerRef.value.createLayer('feature', {
features: ['polygon'],
features: ['polygon', 'line'],
});
this.featureLayer = layer
.createFeature('polygon', { selectionAPI: true })
Expand Down Expand Up @@ -66,7 +78,29 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {
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) {
Expand Down Expand Up @@ -111,6 +145,16 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {
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);
Expand All @@ -120,7 +164,6 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {
temp.coordinates[0] = LineLayer.dashLine(temp.coordinates[0], dashSize);
polygon = temp;
}

const annotation: RectGeoJSData = {
trackId: track.track.id,
selected: track.selected,
Expand All @@ -130,6 +173,8 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {
hasPoly,
set: track.set,
dashed,
rotation,
rotationArrow: getRotationArrowLine(track.features.bounds, rotation || 0),
};
arr.push(annotation);
}
Expand All @@ -142,12 +187,20 @@ export default class RectangleLayer extends BaseLayer<RectGeoJSData> {
.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<RectGeoJSData> {
Expand Down
1 change: 1 addition & 0 deletions client/src/layers/BaseLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface LayerStyle<D> {
textOpacity?: (data: D) => number;
fontSize?: (data: D) => string | undefined;
offset?: (data: D) => { x: number; y: number };
rotation?: (data: D) => number;
fill?: ObjectFunction<boolean, D> | boolean;
radius?: PointFunction<number, D> | number;
textAlign?: ((data: D) => string) | string;
Expand Down
Loading
Loading