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
11 changes: 11 additions & 0 deletions client/dive-common/recipes/headtail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ export default class HeadTail implements Recipe {
data: GeoJSON.Feature<GeoJSON.LineString | GeoJSON.Polygon | GeoJSON.Point>[],
key?: string,
) {
// A lone head or tail dragged to a new spot (companion point editing).
const point = data.find((d) => d.geometry.type === 'Point') as GeoJSON.Feature<GeoJSON.Point> | undefined;
if (point && mode === 'editing' && key && isHeadTailPoint(key)) {
const bounds = track.getFeature(frameNum)[0]?.bounds;
return {
...EmptyResponse,
data: { [key]: [{ ...point, properties: {} }] },
union: bounds ? HeadTail.encloseVertices(bounds, [point.geometry.coordinates]) : [],
done: true,
};
}
const linestrings = data.filter((d) => d.geometry.type === 'LineString');
if (linestrings.length) {
const linestring = linestrings[0] as GeoJSON.Feature<GeoJSON.LineString>;
Expand Down
10 changes: 6 additions & 4 deletions client/src/components/LayerManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import useLayerRefresh from './layerManager/useLayerRefresh';
import useSegmentationPointsLayer from './layerManager/useSegmentationPointsLayer';
import useAnnotationClickHandling from './layerManager/useAnnotationClickHandling';
import { cameraAwaitingGeometry, isCreatingNewDetection } from './layerManager/multicamCreation';
import lineBoxCompanionTracks from './layerManager/lineBoxCompanion';
import lineCompanion from './layerManager/lineCompanion';

/** LayerManager is a component intended to be used as a child of an Annotator.
* It provides logic for switching which layers are visible, but more importantly
Expand Down Expand Up @@ -625,14 +625,16 @@ export default defineComponent({
editAnnotationLayer.disable();
}

const boxTracks = selectedTrackId === null ? [] : lineBoxCompanionTracks(
const companion = selectedTrackId === null ? null : lineCompanion(
editingTrack,
visibleModes.includes('rectangle'),
selectedKey,
editingTracks,
);
if (boxTracks.length) {
boxEditLayer.changeData(boxTracks.map((trackFrame) => ({
if (companion) {
boxEditLayer.setType(companion.type);
boxEditLayer.setKey(companion.key);
boxEditLayer.changeData(companion.tracks.map((trackFrame) => ({
...trackFrame,
features: featureToDisplay(trackFrame.features),
})));
Expand Down
35 changes: 0 additions & 35 deletions client/src/components/layerManager/lineBoxCompanion.spec.ts

This file was deleted.

20 changes: 0 additions & 20 deletions client/src/components/layerManager/lineBoxCompanion.ts

This file was deleted.

50 changes: 50 additions & 0 deletions client/src/components/layerManager/lineCompanion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import lineCompanion from './lineCompanion';
import type { FrameDataTrack } from '../../layers/LayerTypes';

function trackFrame(options: { bounds?: boolean; line?: number; key?: string; points?: string[] } = {}): FrameDataTrack {
const {
bounds = true, line = 2, key = 'HeadTails', points = [],
} = options;
return {
features: {
frame: 0,
bounds: bounds ? [0, 0, 10, 10] : undefined,
geometry: {
type: 'FeatureCollection',
features: [
...(line ? [{
type: 'Feature',
properties: { key },
geometry: { type: 'LineString', coordinates: Array.from({ length: line }, (_, i) => [i, i]) },
}] : []),
...points.map((name) => ({
type: 'Feature', properties: { key: name }, geometry: { type: 'Point', coordinates: [3, 4] },
})),
],
},
},
} as unknown as FrameDataTrack;
}

it('offers the box corners while an existing line is edited with boxes visible', () => {
const tracks = [trackFrame({ points: ['head', 'tail'] })];
expect(lineCompanion('LineString', true, 'HeadTails', tracks)).toEqual({ type: 'rectangle', key: '', tracks });
expect(lineCompanion('LineString', false, 'HeadTails', tracks)).toBeNull();
expect(lineCompanion('rectangle', true, 'HeadTails', tracks)).toBeNull();
expect(lineCompanion(false, true, 'HeadTails', tracks)).toBeNull();
expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ bounds: false })])).toBeNull();
});

it('offers a lone head or tail point so it can be moved instead of only extended', () => {
const head = [trackFrame({ line: 0, points: ['head'] })];
expect(lineCompanion('LineString', false, 'HeadTails', head)).toEqual({ type: 'Point', key: 'head', tracks: head });
const tail = [trackFrame({ line: 0, points: ['tail'], bounds: false })];
expect(lineCompanion('LineString', true, 'HeadTails', tail)).toEqual({ type: 'Point', key: 'tail', tracks: tail });
});

it('stays out of the way while a line is being drawn or has both points', () => {
expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 0 })])).toBeNull();
expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 1 })])).toBeNull();
expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 0, points: ['head', 'tail'] })])).toBeNull();
expect(lineCompanion('LineString', true, '', [trackFrame()])).toBeNull();
});
47 changes: 47 additions & 0 deletions client/src/components/layerManager/lineCompanion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { HeadPointKey, TailPointKey } from 'dive-common/recipes/headtail';
import type { FrameDataTrack } from '../../layers/LayerTypes';
import type { EditAnnotationTypes } from '../../layers/EditAnnotationLayer';

export interface LineCompanion {
type: 'rectangle' | 'Point';
key: string;
tracks: FrameDataTrack[];
}

function lineAt(track: FrameDataTrack, key: string): boolean {
return !!track.features?.geometry?.features.some((feature) => feature.geometry.type === 'LineString'
&& (feature.properties?.key ?? '') === key
&& feature.geometry.coordinates.length >= 2);
}

function pointKeys(track: FrameDataTrack): string[] {
return (track.features?.geometry?.features ?? [])
.filter((feature) => feature.geometry.type === 'Point'
&& [HeadPointKey, TailPointKey].includes(feature.properties?.key ?? ''))
.map((feature) => feature.properties?.key as string);
}

/**
* What the companion editor shows while the line tool is active: the box
* corners of a track whose line exists, or the lone head/tail point of a
* track that has one point and no line, so that point can be moved rather
* than only extended into a line. Nothing for a track still being drawn.
*/
export default function lineCompanion(
editingTrack: false | EditAnnotationTypes,
rectanglesVisible: boolean,
selectedKey: string,
editingTracks: FrameDataTrack[],
): LineCompanion | null {
if (editingTrack !== 'LineString') return null;
const withLine = editingTracks.filter((track) => lineAt(track, selectedKey));
if (withLine.length) {
if (!rectanglesVisible) return null;
const tracks = withLine.filter(({ features }) => !!features?.bounds);
return tracks.length ? { type: 'rectangle', key: '', tracks } : null;
}
const single = editingTracks.filter((track) => pointKeys(track).length === 1);
if (!single.length) return null;
const [key] = pointKeys(single[0]);
return { type: 'Point', key, tracks: single.filter((track) => pointKeys(track)[0] === key) };
}
40 changes: 40 additions & 0 deletions client/src/layers/EditAnnotationLayer.companion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,43 @@
expect(h.cursors.at(-1)).toBe('nw-resize');
expect(h.cursors).not.toContain('grab');
});

it('moves a lone head point while the line tool waits for its tail', async () => {
const node = document.createElement('div');
document.body.appendChild(node);
const map = geo.map({
node, width: 800, height: 600, ...geo.util.pixelCoordinateParams(node, 800, 600, 800, 600).map,
});
const params = {
annotator: { geoViewerRef: ref(map), setCursor: vi.fn(), setImageCursor: vi.fn() },
stateStyling: { standard: { color: '#f00' }, selected: { color: '#f00' } },
typeStyling: ref({ color: () => '#f00', strokeWidth: () => 1, opacity: () => 1 }),
} as any;
const line = new EditAnnotationLayer({ ...params, type: 'LineString' });
const point = new EditAnnotationLayer({ ...params, type: 'rectangle', companion: true });
liveLayers.push(line, point);
line.peer = point; point.peer = line;
line.setKey('HeadTails'); point.setType('Point'); point.setKey('head');
const track = new Track(1, { begin: 0, end: 0, meta: {} });
track.setFeature({ frame: 0, keyframe: true, bounds: [50, 50, 400, 400] }, [{
type: 'Feature', properties: { key: 'head' }, geometry: { type: 'Point', coordinates: [100, 100] },
}]);
const frameData = [{ features: track.features[0], track }] as any;
await line.changeData(frameData); await point.changeData(frameData);
expect(line.getMode()).toBe('creation');
expect(point.getMode()).toBe('editing');
const lineUpdate = vi.fn(); const pointUpdate = vi.fn();
line.bus.$on('update:geojson', lineUpdate); point.bus.$on('update:geojson', pointUpdate);
const mouse = (type: string, x: number, y: number) => map.interactor().simulateEvent(type, { map: { x, y }, button: 'left' });
mouse('mousemove', 100, 100); mouse('mousedown', 100, 100);
mouse('mousemove', 130, 110); mouse('mouseup', 130, 110);
expect(pointUpdate).toHaveBeenCalledTimes(1);
expect(pointUpdate.mock.calls[0][2].geometry).toEqual({ type: 'Point', coordinates: [130, 110] });
expect(pointUpdate.mock.calls[0][4]).toBe('head');
expect(lineUpdate).not.toHaveBeenCalled();
expect(line.shapeInProgress).toBeNull();
// Leaving the handle gives the line tool its click-to-place actions back.
mouse('mousemove', 300, 300);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(map.interactor().hasAction(undefined, undefined, geo.annotation.actionOwner)).toBeTruthy();

Check failure on line 138 in client/src/layers/EditAnnotationLayer.companion.spec.ts

View workflow job for this annotation

GitHub Actions / Client Tests (web)

[node] src/layers/EditAnnotationLayer.companion.spec.ts > moves a lone head point while the line tool waits for its tail

AssertionError: expected null to be truthy - Expected: true + Received: null ❯ src/layers/EditAnnotationLayer.companion.spec.ts:138:88
});
15 changes: 12 additions & 3 deletions client/src/layers/EditAnnotationLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
});
this.featureLayer.geoOn(geo.event.actiondown, (e: GeoEvent) => {
this.ownsDrag = !!this.featureLayer.currentAnnotation?._editHandle?.handle?.selected;
if (!this.companion) this.setShapeInProgress(e);
if (!this.companion && !this.peer?.handleSelected()) this.setShapeInProgress(e);
});

const arrowLayer = this.annotator.geoViewerRef.value.createLayer('feature', { features: ['line'] });
Expand Down Expand Up @@ -484,6 +484,8 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
hoverEditHandle(e: GeoEvent) {
// The map rebroadcasts this to every layer; only our own handles count.
if (e.annotation && e.annotation.layer() !== this.featureLayer) return;
// GeoJS strips the annotation actions right after this event fires.
if (this.companion && !e.enable) window.setTimeout(() => this.peer?.restoreHandleActions(), 0);
const divisor = 2; // Vertex/edge handles alternate for polygons and open lines.
if (e.enable && e.handle.handle.type === 'vertex') {
if (e.handle.handle.selected
Expand Down Expand Up @@ -543,6 +545,7 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
this.type = type;

// Add or remove Point mode listeners based on type change
if (this.companion) return;
if (!wasPoint && isPoint) {
this.addPointModeListeners();
} else if (wasPoint && !isPoint) {
Expand Down Expand Up @@ -705,6 +708,10 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
annotation.diveDragGuard = true;
}

handleSelected(): boolean {
return !!this.featureLayer.currentAnnotation?._editHandle?.handle?.selected;
}

/**
* A mode change on the peer layer strips every annotation action from the
* interactor, including the one for a handle still hovered on this layer
Expand Down Expand Up @@ -898,7 +905,7 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
// TODO: this assumes only one polygon
geoJSONData = this.getGeoJSONData(track);
}
if (!geoJSONData || this.type === 'Point') {
if (!geoJSONData || (this.type === 'Point' && !this.companion)) {
this.setMode(this.type);
} else {
const geojsonFeature: GeoJSON.Feature = {
Expand Down Expand Up @@ -1026,7 +1033,9 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {
);
const newCoords = newGeojson.geometry.coordinates[0] as GeoJSON.Position[];
let rotationBetween: number;
if (this.formattedData.length > 0 && this.type === 'rectangle') {
if (this.type === 'Point') {
rotationBetween = 0;
} else if (this.formattedData.length > 0 && this.type === 'rectangle') {
const existingRotation = getRotationFromAttributes(this.formattedData[0].properties as Record<string, unknown>) ?? 0;
const oldCoords = rotateGeoJSONCoordinates(
this.unrotatedGeoJSONCoords || [],
Expand Down
1 change: 1 addition & 0 deletions docs/Annotation-QuickStart.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ The demo below shows how to use AdvanceFrame mode to travel through the video wh
1. Or press ++t++ to create a tail point.
1. The mouse cursor will become a crosshair. Click in the annotator to place each point.
1. Once the first marker is placed it automatically transitions to the second marker. If you start with head, the second one will be the tail and vice versa.
1. A detection that has only one of the two points (for example a computed head with no tail) shows that point as a draggable handle in this mode, so it can be moved without redrawing; clicking elsewhere still places the missing point.

### Creating new annotations using Head/Tail points

Expand Down
Loading