From 885c9e7935e66dc597fe4caf5fdaea95d7fc5cef Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 22:43:31 -0400 Subject: [PATCH 01/10] Multicam: draw the selected detection on other cameras without switching first Previously, after drawing a line or rectangle on one camera during manual creation, annotating the other camera required clicking it (switching the selected camera) and re-entering edit mode before drawing. Now, while in edit mode, any non-selected camera missing the selected track's geometry at the current frame keeps a live creation cursor, so the detection can be drawn on each camera in turn -- e.g. two more clicks to place the line on the other stereo camera -- committed under the same track id. Implementation: - LayerManager: new cameraAwaitingGeometry() complements the existing isCreatingNewDetection() (brand-new track) mechanism, both for enabling the creation cursor on non-selected cameras and for routing a landed draw. The routing creates the same-id track on the drawn-on camera BEFORE calling selectCamera, so selectCamera skips its trackEdit toggle (which would finalize and discard an in-progress 1-point line replica, breaking 2-click completion). - Viewer: changeCamera no longer consumes a left-mousedown on a camera awaiting geometry (that click starts the draw). Right-click switching and the camera dropdown are unaffected. Point/segmentation mode is excluded; segmentation has its own cross-camera machinery. --- client/dive-common/components/Viewer.vue | 25 ++++++++++ client/src/components/LayerManager.vue | 59 +++++++++++++++++++----- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index ea2580422..ff2782fbb 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -753,6 +753,25 @@ export default defineComponent({ } return track.getFeature(aggregateController.value.frame.value)[0] == null; }; + // While editing, the creation cursor is live on any camera still missing + // the selected track's geometry at this frame (see LayerManager), so the + // detection can be drawn on each camera in turn without switching first. + // A left-click on such a camera is the start of that draw -- don't steal + // it to switch cameras. Point mode is excluded (segmentation has its own + // cross-camera machinery), matching the cursor's own gating. + const isExtendingDetectionToCamera = (camera: string): boolean => { + if (selectedTrackId.value === null || !editingTrack.value) { + return false; + } + if (editingMode.value === 'Point') { + return false; + } + const track = cameraStore.getPossibleTrack(selectedTrackId.value, camera); + if (!track) { + return true; + } + return track.getFeature(aggregateController.value.frame.value)[0] == null; + }; // Handles changing camera using the dropdown or mouse clicks // When using mouse clicks and right button it will remain in edit mode for the selected track const changeCamera = (camera: string, event?: MouseEvent) => { @@ -764,6 +783,12 @@ export default defineComponent({ if (isCreatingNewDetection()) { return; } + // Likewise, a left-click on a camera still missing the selected track's + // geometry starts a draw there -- don't switch cameras. Right-click + // switching (mouseup.right) and the dropdown (no event) are unaffected. + if (event?.button === 0 && isExtendingDetectionToCamera(camera)) { + return; + } if (event) { event.preventDefault(); } diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 53654db0d..e0cac28b4 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -218,6 +218,25 @@ export default defineComponent({ return t.getFeature(frame)[0] == null; } + // True when, while editing, the selected track has no geometry on THIS + // camera at this frame (the track may not exist on this camera at all). + // Complements isCreatingNewDetection: after the detection is drawn on one + // camera, the creation cursor stays live on the cameras still missing it, + // so it can be drawn on each in turn (same track id) without selecting the + // camera first. Excludes Point mode -- segmentation has its own + // cross-camera machinery. + function cameraAwaitingGeometry( + frame: number, + trackId: AnnotationId | null, + editingTrack: false | EditAnnotationTypes, + ): boolean { + if (trackId === null || !editingTrack || editingTrack === 'Point') return false; + if (!cameraStore.getAnyPossibleTrack(trackId)) return false; + const t = cameraStore.getPossibleTrack(trackId, props.camera); + if (!t) return true; + return t.getFeature(frame)[0] == null; + } + function updateLayers( frame: number, editingTrack: false | EditAnnotationTypes, @@ -393,11 +412,14 @@ export default defineComponent({ editAnnotationLayer.changeData(editingTracks); } } else if (editingTrack && props.camera !== selectedCamera.value - && isCreatingNewDetection(frame, selectedTrackId)) { + && (isCreatingNewDetection(frame, selectedTrackId) + || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack))) { // Seamless multicam creation: keep the creation cursor live on every // camera (not just the selected one) so a brand-new detection can be - // drawn on whichever camera the user starts on. The draw is routed to - // the drawn-on camera in the update:geojson handler below. + // drawn on whichever camera the user starts on -- and, once drawn on + // one camera, immediately drawn on the others while still in edit + // mode. The draw is routed to the drawn-on camera in the + // update:geojson handler below. editAnnotationLayer.setType(editingTrack); editAnnotationLayer.setKey(selectedKey); editAnnotationLayer.changeData([]); @@ -658,14 +680,29 @@ export default defineComponent({ cb: () => void = () => (undefined), ) => { // Seamless multicam creation: a draw that lands on a camera that isn't the - // selected one must commit on THIS camera. Switch to it and start a fresh - // detection here (the empty origin track is auto-cleaned on camera switch) - // so the geometry is applied to the camera the user actually drew on. - if (props.camera !== selectedCamera.value - && isCreatingNewDetection(frameNumberRef.value, selectedTrackIdRef.value)) { - handler.selectCamera(props.camera, false); - if (selectedCamera.value === props.camera) { - handler.trackAdd(); + // selected one must commit on THIS camera. + if (props.camera !== selectedCamera.value) { + if (isCreatingNewDetection(frameNumberRef.value, selectedTrackIdRef.value)) { + // Brand-new detection: switch to this camera and start a fresh + // detection here (the empty origin track is auto-cleaned on camera + // switch) so the geometry is applied to the camera the user drew on. + handler.selectCamera(props.camera, false); + if (selectedCamera.value === props.camera) { + handler.trackAdd(); + } + } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value)) { + // Extending the selected detection to this camera (it already has + // geometry on another camera): create the same-id track on this + // camera BEFORE switching, so selectCamera keeps edit mode without + // toggling trackEdit (which would finalize/interrupt the in-progress + // draw). The update below then commits here under the same track id. + const trackId = selectedTrackIdRef.value as number; + if (!cameraStore.getPossibleTrack(trackId, props.camera)) { + const anyTrack = cameraStore.getAnyPossibleTrack(trackId); + const trackType = anyTrack?.confidencePairs?.[0]?.[0] || 'unknown'; + trackStore.add(frameNumberRef.value, trackType, undefined, trackId); + } + handler.selectCamera(props.camera, false); } } if (type === 'rectangle') { From 44ff630c341167fc05e62176ba1fa9d3bc5283c7 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 23:54:43 -0400 Subject: [PATCH 02/10] Extend cross-camera continuation to point-click segmentation Two fixes for point-click segmentation on stereo/multicam datasets: 1. Segmentation prompt points (the green/red click markers) were rendered by every camera's layer manager with no camera filter, so each click also drew its marker on the other camera at the same pixel location -- which looked like an unwarped point being auto-created there, regardless of the auto-compute setting or calibration. The markers now render only on the selected camera. 2. Point mode was excluded from cross-camera continuation drawing. Now a single click on the other camera starts a segmentation there for the same track id: 'awaiting geometry' for Point mode means the camera has no segmentation polygon yet (so a box-only detection still accepts a click), and a new segmentationFinalizePending handler -- invoked on any camera switch -- locks in the pending mask on the source camera and clears the recipe's accumulated prompt points, which belong to the source camera's image and must not leak into the other camera's prediction. Unlike the right-click confirm path it does not deselect, so the flow continues uninterrupted. The predict image path already resolves the selected camera at call time, so post-switch predictions run against the correct camera's image. Line, rectangle, and polygon continuation behavior is unchanged. --- client/dive-common/components/Viewer.vue | 33 ++++++++++---- client/dive-common/use/useModeManager.ts | 36 +++++++++++++++ client/src/components/LayerManager.vue | 46 ++++++++++++++----- client/src/provides.ts | 3 ++ client/src/utils.spec.ts | 57 ++++++++++++++++++++++++ client/src/utils.ts | 20 +++++++++ 6 files changed, 176 insertions(+), 19 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index ff2782fbb..5ccf38c93 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -32,7 +32,7 @@ import { FilterList, } from 'vue-media-annotator/components'; import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; -import { getResponseError } from 'vue-media-annotator/utils'; +import { getResponseError, featureHasSegmentationPolygon } from 'vue-media-annotator/utils'; /* DIVE COMMON */ import PolygonBase from 'dive-common/recipes/polygonbase'; @@ -714,6 +714,13 @@ export default defineComponent({ }); return; } + // Segmentation prompt points belong to the current camera's image: lock + // in any pending mask (committed to the still-selected camera) and clear + // the points before switching, so they cannot leak into a prediction on + // the new camera. No-op when nothing is pending. + if (selectedCamera.value !== camera) { + handler.segmentationFinalizePending(); + } // EditTrack is set false by the LayerMap before executing this if (selectedTrackId.value !== null) { // If we had a track selected and it still exists with @@ -754,23 +761,33 @@ export default defineComponent({ return track.getFeature(aggregateController.value.frame.value)[0] == null; }; // While editing, the creation cursor is live on any camera still missing - // the selected track's geometry at this frame (see LayerManager), so the - // detection can be drawn on each camera in turn without switching first. - // A left-click on such a camera is the start of that draw -- don't steal - // it to switch cameras. Point mode is excluded (segmentation has its own - // cross-camera machinery), matching the cursor's own gating. + // the selected track's geometry at this frame (see LayerManager's + // cameraAwaitingGeometry, which this must mirror), so the detection can + // be drawn on each camera in turn without switching first. A left-click + // on such a camera is the start of that draw -- don't steal it to switch + // cameras. For Point mode (point-click segmentation) "missing" means no + // segmentation polygon here yet, so a box-only detection still accepts a + // point click. const isExtendingDetectionToCamera = (camera: string): boolean => { if (selectedTrackId.value === null || !editingTrack.value) { return false; } - if (editingMode.value === 'Point') { + const editingType = editingMode.value; + if (!editingType) { return false; } const track = cameraStore.getPossibleTrack(selectedTrackId.value, camera); if (!track) { return true; } - return track.getFeature(aggregateController.value.frame.value)[0] == null; + const [feature] = track.getFeature(aggregateController.value.frame.value); + if (feature == null) { + return true; + } + if (editingType === 'Point') { + return !featureHasSegmentationPolygon(feature, selectedKey.value); + } + return false; }; // Handles changing camera using the dropdown or mouse clicks // When using mouse clicks and right button it will remain in edit mode for the selected track diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index c576dcebb..38f17ea68 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -1031,6 +1031,41 @@ export default function useModeManager({ activeSegRecipes.forEach((r) => r.activate()); } + /** + * Finalize any pending segmentation on the current camera WITHOUT + * deselecting the track, then re-arm the recipe. Used by the cross-camera + * continuation flow: when a segmentation point click lands on another + * camera, the current camera's pending mask is locked in first, and the + * recipe's accumulated prompt points are cleared so they cannot leak into + * the other camera's prediction (the points belong to this camera's image). + */ + function handleSegmentationFinalizePending() { + const activeSegRecipes = recipes.filter( + (r): r is SegmentationPointClick => r instanceof SegmentationPointClick && r.active.value, + ); + if (!activeSegRecipes.length) { + return; + } + if (!activeSegRecipes.some((r) => r.hasPendingPrediction() || r.hasPoints())) { + // Nothing in progress on this camera: the recipe is already fresh. + return; + } + // confirm() commits the pending mask (or just deactivates when there is + // none); the commit targets the still-selected camera's track. + activeSegRecipes.forEach((r) => r.confirm()); + // The confirmed polygons are now permanent. + preSegmentationFeatures.clear(); + onStereoSegmentationFinalize?.(); + // Re-activate so the next click (on the other camera) segments fresh. + // completeActivation re-emits Point editing mode and keeps the current + // selection (handleSetAnnotationState re-selects the same track). + activeSegRecipes.forEach((r) => { + if (!r.active.value) { + r.activate(); + } + }); + } + /** * Merge: Enabled whenever there are candidates in the merge list */ @@ -1542,6 +1577,7 @@ export default function useModeManager({ handler: { commitMerge: handleCommitMerge, confirmRecipe: handleConfirmRecipe, + segmentationFinalizePending: handleSegmentationFinalizePending, groupAdd: handleAddGroup, deleteSelectedTracks: handleDeleteSelectedTracks, groupEdit: handleGroupEdit, diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index e0cac28b4..7d3249c06 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -20,7 +20,9 @@ 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, isRotationValue, ROTATION_ATTRIBUTE_NAME } from '../utils'; +import { + geojsonToBound, isRotationValue, ROTATION_ATTRIBUTE_NAME, featureHasSegmentationPolygon, +} from '../utils'; import { VisibleAnnotationTypes } from '../layers'; import UILayer from '../layers/UILayers/UILayer'; import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue'; @@ -176,10 +178,14 @@ export default defineComponent({ const segmentationPointsRef = useSegmentationPoints(); const segmentationPointsLayer = new SegmentationPointsLayer(annotator); - // Watch for segmentation points updates - only show points for current frame - watch([segmentationPointsRef, frameNumberRef], ([newPoints, currentFrame]) => { - // Only display points if they belong to the current frame - if (newPoints.points.length > 0 && newPoints.frameNum === currentFrame) { + // Watch for segmentation points updates - only show points for the current + // frame, and only on the selected camera. The prompt points belong to the + // image being segmented; without the camera check every camera rendered + // them at the same pixel coordinates, which looked like an (unwarped) + // point being created on the other camera. + watch([segmentationPointsRef, frameNumberRef, selectedCamera], ([newPoints, currentFrame, currentCamera]) => { + if (newPoints.points.length > 0 && newPoints.frameNum === currentFrame + && props.camera === currentCamera) { segmentationPointsLayer.updatePoints(newPoints.points, newPoints.labels); } else { segmentationPointsLayer.clear(); @@ -220,21 +226,29 @@ export default defineComponent({ // True when, while editing, the selected track has no geometry on THIS // camera at this frame (the track may not exist on this camera at all). + // For Point mode (point-click segmentation) "no geometry" means no + // segmentation polygon here yet, so a detection that only has a box still + // accepts a point click. // Complements isCreatingNewDetection: after the detection is drawn on one // camera, the creation cursor stays live on the cameras still missing it, // so it can be drawn on each in turn (same track id) without selecting the - // camera first. Excludes Point mode -- segmentation has its own - // cross-camera machinery. + // camera first. function cameraAwaitingGeometry( frame: number, trackId: AnnotationId | null, editingTrack: false | EditAnnotationTypes, + selectedKey: string, ): boolean { - if (trackId === null || !editingTrack || editingTrack === 'Point') return false; + if (trackId === null || !editingTrack) return false; if (!cameraStore.getAnyPossibleTrack(trackId)) return false; const t = cameraStore.getPossibleTrack(trackId, props.camera); if (!t) return true; - return t.getFeature(frame)[0] == null; + const [feature] = t.getFeature(frame); + if (feature == null) return true; + if (editingTrack === 'Point') { + return !featureHasSegmentationPolygon(feature, selectedKey); + } + return false; } function updateLayers( @@ -413,7 +427,7 @@ export default defineComponent({ } } else if (editingTrack && props.camera !== selectedCamera.value && (isCreatingNewDetection(frame, selectedTrackId) - || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack))) { + || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack, selectedKey))) { // Seamless multicam creation: keep the creation cursor live on every // camera (not just the selected one) so a brand-new detection can be // drawn on whichever camera the user starts on -- and, once drawn on @@ -690,12 +704,16 @@ export default defineComponent({ if (selectedCamera.value === props.camera) { handler.trackAdd(); } - } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value)) { + } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value, selectedKeyRef.value)) { // Extending the selected detection to this camera (it already has // geometry on another camera): create the same-id track on this // camera BEFORE switching, so selectCamera keeps edit mode without // toggling trackEdit (which would finalize/interrupt the in-progress // draw). The update below then commits here under the same track id. + // For Point mode (point-click segmentation), selectCamera also + // finalizes the source camera's pending mask and clears the recipe's + // accumulated prompt points -- those points belong to the source + // camera's image and must not leak into this camera's prediction. const trackId = selectedTrackIdRef.value as number; if (!cameraStore.getPossibleTrack(trackId, props.camera)) { const anyTrack = cameraStore.getAnyPossibleTrack(trackId); @@ -703,6 +721,12 @@ export default defineComponent({ trackStore.add(frameNumberRef.value, trackType, undefined, trackId); } handler.selectCamera(props.camera, false); + if (editingModeRef.value === 'Point' && selectedCamera.value !== props.camera) { + // The switch was blocked (e.g. linking mode): a segmentation point + // clicked on this camera must never be added to the selected + // camera's prompt points, so drop the click. + return; + } } } if (type === 'rectangle') { diff --git a/client/src/provides.ts b/client/src/provides.ts index b59635968..ff1404bea 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -135,6 +135,8 @@ export interface Handler { trackEdit(AnnotationId: AnnotationId): void; /* Confirm/lock the current annotation for active recipes */ confirmRecipe(): void; + /* Finalize pending segmentation without deselecting (cross-camera handoff) */ + segmentationFinalizePending(): void; /* toggle selection mode for track */ trackSelect(AnnotationId: AnnotationId | null, edit: boolean, modifiers?: { ctrl: boolean }): void; /* select tracks enclosed by a lasso polygon */ @@ -234,6 +236,7 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler seekFrame(...args) { handle('seekFrame', args); }, trackEdit(...args) { handle('trackEdit', args); }, confirmRecipe(...args) { handle('confirmRecipe', args); }, + segmentationFinalizePending(...args) { handle('segmentationFinalizePending', args); }, trackSelect(...args) { handle('trackSelect', args); }, lassoSelect(...args) { handle('lassoSelect', args); }, trackSelectNext(...args) { handle('trackSelectNext', args); }, diff --git a/client/src/utils.spec.ts b/client/src/utils.spec.ts index 1805a1050..b916d002f 100644 --- a/client/src/utils.spec.ts +++ b/client/src/utils.spec.ts @@ -16,8 +16,10 @@ import { getRotationArrowLine, ROTATION_THRESHOLD, ROTATION_ATTRIBUTE_NAME, + featureHasSegmentationPolygon, } from './utils'; import type { RectBounds } from './utils'; +import type { Feature } from './track'; describe('updateSubset', () => { it('should return null for identical sets', () => { @@ -335,3 +337,58 @@ describe('Rotation utilities', () => { }); }); }); + +describe('featureHasSegmentationPolygon', () => { + // Matches the constant in dive-common/recipes/segmentationpointclick.ts + const SegmentationPolygonKey = 'SegmentationPolygon'; + const boxOnly: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + }; + const headPointOnly: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + geometry: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 1] }, + properties: { key: 'head' }, + }, + ], + }, + }; + const withSegmentationMask: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + geometry: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [5, 0], [5, 5], [0, 0]]] }, + properties: { key: SegmentationPolygonKey }, + }, + ], + }, + }; + + it('returns false for a null feature', () => { + expect(featureHasSegmentationPolygon(null, SegmentationPolygonKey)).toBe(false); + }); + + it('a camera already segmented (mask polygon present) has the polygon', () => { + expect(featureHasSegmentationPolygon(withSegmentationMask, SegmentationPolygonKey)).toBe(true); + // Wrong key does not match + expect(featureHasSegmentationPolygon(withSegmentationMask, 'other')).toBe(false); + }); + + it('a box-only detection still accepts a segmentation click', () => { + expect(featureHasSegmentationPolygon(boxOnly, SegmentationPolygonKey)).toBe(false); + }); + + it('a stray Point geometry (e.g. head marker) does not count as segmented', () => { + expect(featureHasSegmentationPolygon(headPointOnly, SegmentationPolygonKey)).toBe(false); + }); +}); diff --git a/client/src/utils.ts b/client/src/utils.ts index 7b4d7dfd7..12e48c3d2 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -1,9 +1,29 @@ import axios from 'axios'; import { difference } from 'lodash'; +// Type-only import (erased at runtime) so the utils <-> track cycle is safe. +import type { Feature } from './track'; // [x1, y1, x2, y2] as (left, top), (bottom, right) export type RectBounds = [number, number, number, number]; +/** + * True when a track feature already carries a segmentation mask: a Polygon + * under the selected key. Point-click segmentation commits its mask as that + * polygon (the prompt points themselves are never stored on the track), so + * cross-camera segmentation uses this to decide whether a camera still + * accepts a point click for the selected detection -- a detection that only + * has a box (e.g. from a pipeline) must still accept one. + */ +export function featureHasSegmentationPolygon( + feature: Feature | null, + selectedKey: string, +): boolean { + if (!feature) return false; + return (feature.geometry?.features || []).some( + (f) => f.geometry.type === 'Polygon' && (f.properties?.key ?? '') === selectedKey, + ); +} + // Rotation-related constants /** Threshold for considering rotation significant (radians) */ export const ROTATION_THRESHOLD = 0.001; From 30d87b50381725d98e89342c90dcad96583dd7a5 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 22:43:31 -0400 Subject: [PATCH 03/10] Multicam: draw the selected detection on other cameras without switching first Previously, after drawing a line or rectangle on one camera during manual creation, annotating the other camera required clicking it (switching the selected camera) and re-entering edit mode before drawing. Now, while in edit mode, any non-selected camera missing the selected track's geometry at the current frame keeps a live creation cursor, so the detection can be drawn on each camera in turn -- e.g. two more clicks to place the line on the other stereo camera -- committed under the same track id. Implementation: - LayerManager: new cameraAwaitingGeometry() complements the existing isCreatingNewDetection() (brand-new track) mechanism, both for enabling the creation cursor on non-selected cameras and for routing a landed draw. The routing creates the same-id track on the drawn-on camera BEFORE calling selectCamera, so selectCamera skips its trackEdit toggle (which would finalize and discard an in-progress 1-point line replica, breaking 2-click completion). - Viewer: changeCamera no longer consumes a left-mousedown on a camera awaiting geometry (that click starts the draw). Right-click switching and the camera dropdown are unaffected. Point/segmentation mode is excluded; segmentation has its own cross-camera machinery. --- client/dive-common/components/Viewer.vue | 25 ++++++++++ client/src/components/LayerManager.vue | 59 +++++++++++++++++++----- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index ea2580422..ff2782fbb 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -753,6 +753,25 @@ export default defineComponent({ } return track.getFeature(aggregateController.value.frame.value)[0] == null; }; + // While editing, the creation cursor is live on any camera still missing + // the selected track's geometry at this frame (see LayerManager), so the + // detection can be drawn on each camera in turn without switching first. + // A left-click on such a camera is the start of that draw -- don't steal + // it to switch cameras. Point mode is excluded (segmentation has its own + // cross-camera machinery), matching the cursor's own gating. + const isExtendingDetectionToCamera = (camera: string): boolean => { + if (selectedTrackId.value === null || !editingTrack.value) { + return false; + } + if (editingMode.value === 'Point') { + return false; + } + const track = cameraStore.getPossibleTrack(selectedTrackId.value, camera); + if (!track) { + return true; + } + return track.getFeature(aggregateController.value.frame.value)[0] == null; + }; // Handles changing camera using the dropdown or mouse clicks // When using mouse clicks and right button it will remain in edit mode for the selected track const changeCamera = (camera: string, event?: MouseEvent) => { @@ -764,6 +783,12 @@ export default defineComponent({ if (isCreatingNewDetection()) { return; } + // Likewise, a left-click on a camera still missing the selected track's + // geometry starts a draw there -- don't switch cameras. Right-click + // switching (mouseup.right) and the dropdown (no event) are unaffected. + if (event?.button === 0 && isExtendingDetectionToCamera(camera)) { + return; + } if (event) { event.preventDefault(); } diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 53654db0d..e0cac28b4 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -218,6 +218,25 @@ export default defineComponent({ return t.getFeature(frame)[0] == null; } + // True when, while editing, the selected track has no geometry on THIS + // camera at this frame (the track may not exist on this camera at all). + // Complements isCreatingNewDetection: after the detection is drawn on one + // camera, the creation cursor stays live on the cameras still missing it, + // so it can be drawn on each in turn (same track id) without selecting the + // camera first. Excludes Point mode -- segmentation has its own + // cross-camera machinery. + function cameraAwaitingGeometry( + frame: number, + trackId: AnnotationId | null, + editingTrack: false | EditAnnotationTypes, + ): boolean { + if (trackId === null || !editingTrack || editingTrack === 'Point') return false; + if (!cameraStore.getAnyPossibleTrack(trackId)) return false; + const t = cameraStore.getPossibleTrack(trackId, props.camera); + if (!t) return true; + return t.getFeature(frame)[0] == null; + } + function updateLayers( frame: number, editingTrack: false | EditAnnotationTypes, @@ -393,11 +412,14 @@ export default defineComponent({ editAnnotationLayer.changeData(editingTracks); } } else if (editingTrack && props.camera !== selectedCamera.value - && isCreatingNewDetection(frame, selectedTrackId)) { + && (isCreatingNewDetection(frame, selectedTrackId) + || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack))) { // Seamless multicam creation: keep the creation cursor live on every // camera (not just the selected one) so a brand-new detection can be - // drawn on whichever camera the user starts on. The draw is routed to - // the drawn-on camera in the update:geojson handler below. + // drawn on whichever camera the user starts on -- and, once drawn on + // one camera, immediately drawn on the others while still in edit + // mode. The draw is routed to the drawn-on camera in the + // update:geojson handler below. editAnnotationLayer.setType(editingTrack); editAnnotationLayer.setKey(selectedKey); editAnnotationLayer.changeData([]); @@ -658,14 +680,29 @@ export default defineComponent({ cb: () => void = () => (undefined), ) => { // Seamless multicam creation: a draw that lands on a camera that isn't the - // selected one must commit on THIS camera. Switch to it and start a fresh - // detection here (the empty origin track is auto-cleaned on camera switch) - // so the geometry is applied to the camera the user actually drew on. - if (props.camera !== selectedCamera.value - && isCreatingNewDetection(frameNumberRef.value, selectedTrackIdRef.value)) { - handler.selectCamera(props.camera, false); - if (selectedCamera.value === props.camera) { - handler.trackAdd(); + // selected one must commit on THIS camera. + if (props.camera !== selectedCamera.value) { + if (isCreatingNewDetection(frameNumberRef.value, selectedTrackIdRef.value)) { + // Brand-new detection: switch to this camera and start a fresh + // detection here (the empty origin track is auto-cleaned on camera + // switch) so the geometry is applied to the camera the user drew on. + handler.selectCamera(props.camera, false); + if (selectedCamera.value === props.camera) { + handler.trackAdd(); + } + } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value)) { + // Extending the selected detection to this camera (it already has + // geometry on another camera): create the same-id track on this + // camera BEFORE switching, so selectCamera keeps edit mode without + // toggling trackEdit (which would finalize/interrupt the in-progress + // draw). The update below then commits here under the same track id. + const trackId = selectedTrackIdRef.value as number; + if (!cameraStore.getPossibleTrack(trackId, props.camera)) { + const anyTrack = cameraStore.getAnyPossibleTrack(trackId); + const trackType = anyTrack?.confidencePairs?.[0]?.[0] || 'unknown'; + trackStore.add(frameNumberRef.value, trackType, undefined, trackId); + } + handler.selectCamera(props.camera, false); } } if (type === 'rectangle') { From 14ba24979208fe2fdd85e57ffa44f556e4ce87b4 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 23:54:43 -0400 Subject: [PATCH 04/10] Extend cross-camera continuation to point-click segmentation Two fixes for point-click segmentation on stereo/multicam datasets: 1. Segmentation prompt points (the green/red click markers) were rendered by every camera's layer manager with no camera filter, so each click also drew its marker on the other camera at the same pixel location -- which looked like an unwarped point being auto-created there, regardless of the auto-compute setting or calibration. The markers now render only on the selected camera. 2. Point mode was excluded from cross-camera continuation drawing. Now a single click on the other camera starts a segmentation there for the same track id: 'awaiting geometry' for Point mode means the camera has no segmentation polygon yet (so a box-only detection still accepts a click), and a new segmentationFinalizePending handler -- invoked on any camera switch -- locks in the pending mask on the source camera and clears the recipe's accumulated prompt points, which belong to the source camera's image and must not leak into the other camera's prediction. Unlike the right-click confirm path it does not deselect, so the flow continues uninterrupted. The predict image path already resolves the selected camera at call time, so post-switch predictions run against the correct camera's image. Line, rectangle, and polygon continuation behavior is unchanged. --- client/dive-common/components/Viewer.vue | 33 ++++++++++---- client/dive-common/use/useModeManager.ts | 36 +++++++++++++++ client/src/components/LayerManager.vue | 46 ++++++++++++++----- client/src/provides.ts | 3 ++ client/src/utils.spec.ts | 57 ++++++++++++++++++++++++ client/src/utils.ts | 20 +++++++++ 6 files changed, 176 insertions(+), 19 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index ff2782fbb..5ccf38c93 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -32,7 +32,7 @@ import { FilterList, } from 'vue-media-annotator/components'; import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; -import { getResponseError } from 'vue-media-annotator/utils'; +import { getResponseError, featureHasSegmentationPolygon } from 'vue-media-annotator/utils'; /* DIVE COMMON */ import PolygonBase from 'dive-common/recipes/polygonbase'; @@ -714,6 +714,13 @@ export default defineComponent({ }); return; } + // Segmentation prompt points belong to the current camera's image: lock + // in any pending mask (committed to the still-selected camera) and clear + // the points before switching, so they cannot leak into a prediction on + // the new camera. No-op when nothing is pending. + if (selectedCamera.value !== camera) { + handler.segmentationFinalizePending(); + } // EditTrack is set false by the LayerMap before executing this if (selectedTrackId.value !== null) { // If we had a track selected and it still exists with @@ -754,23 +761,33 @@ export default defineComponent({ return track.getFeature(aggregateController.value.frame.value)[0] == null; }; // While editing, the creation cursor is live on any camera still missing - // the selected track's geometry at this frame (see LayerManager), so the - // detection can be drawn on each camera in turn without switching first. - // A left-click on such a camera is the start of that draw -- don't steal - // it to switch cameras. Point mode is excluded (segmentation has its own - // cross-camera machinery), matching the cursor's own gating. + // the selected track's geometry at this frame (see LayerManager's + // cameraAwaitingGeometry, which this must mirror), so the detection can + // be drawn on each camera in turn without switching first. A left-click + // on such a camera is the start of that draw -- don't steal it to switch + // cameras. For Point mode (point-click segmentation) "missing" means no + // segmentation polygon here yet, so a box-only detection still accepts a + // point click. const isExtendingDetectionToCamera = (camera: string): boolean => { if (selectedTrackId.value === null || !editingTrack.value) { return false; } - if (editingMode.value === 'Point') { + const editingType = editingMode.value; + if (!editingType) { return false; } const track = cameraStore.getPossibleTrack(selectedTrackId.value, camera); if (!track) { return true; } - return track.getFeature(aggregateController.value.frame.value)[0] == null; + const [feature] = track.getFeature(aggregateController.value.frame.value); + if (feature == null) { + return true; + } + if (editingType === 'Point') { + return !featureHasSegmentationPolygon(feature, selectedKey.value); + } + return false; }; // Handles changing camera using the dropdown or mouse clicks // When using mouse clicks and right button it will remain in edit mode for the selected track diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index c576dcebb..38f17ea68 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -1031,6 +1031,41 @@ export default function useModeManager({ activeSegRecipes.forEach((r) => r.activate()); } + /** + * Finalize any pending segmentation on the current camera WITHOUT + * deselecting the track, then re-arm the recipe. Used by the cross-camera + * continuation flow: when a segmentation point click lands on another + * camera, the current camera's pending mask is locked in first, and the + * recipe's accumulated prompt points are cleared so they cannot leak into + * the other camera's prediction (the points belong to this camera's image). + */ + function handleSegmentationFinalizePending() { + const activeSegRecipes = recipes.filter( + (r): r is SegmentationPointClick => r instanceof SegmentationPointClick && r.active.value, + ); + if (!activeSegRecipes.length) { + return; + } + if (!activeSegRecipes.some((r) => r.hasPendingPrediction() || r.hasPoints())) { + // Nothing in progress on this camera: the recipe is already fresh. + return; + } + // confirm() commits the pending mask (or just deactivates when there is + // none); the commit targets the still-selected camera's track. + activeSegRecipes.forEach((r) => r.confirm()); + // The confirmed polygons are now permanent. + preSegmentationFeatures.clear(); + onStereoSegmentationFinalize?.(); + // Re-activate so the next click (on the other camera) segments fresh. + // completeActivation re-emits Point editing mode and keeps the current + // selection (handleSetAnnotationState re-selects the same track). + activeSegRecipes.forEach((r) => { + if (!r.active.value) { + r.activate(); + } + }); + } + /** * Merge: Enabled whenever there are candidates in the merge list */ @@ -1542,6 +1577,7 @@ export default function useModeManager({ handler: { commitMerge: handleCommitMerge, confirmRecipe: handleConfirmRecipe, + segmentationFinalizePending: handleSegmentationFinalizePending, groupAdd: handleAddGroup, deleteSelectedTracks: handleDeleteSelectedTracks, groupEdit: handleGroupEdit, diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index e0cac28b4..7d3249c06 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -20,7 +20,9 @@ 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, isRotationValue, ROTATION_ATTRIBUTE_NAME } from '../utils'; +import { + geojsonToBound, isRotationValue, ROTATION_ATTRIBUTE_NAME, featureHasSegmentationPolygon, +} from '../utils'; import { VisibleAnnotationTypes } from '../layers'; import UILayer from '../layers/UILayers/UILayer'; import ToolTipWidget from '../layers/UILayers/ToolTipWidget.vue'; @@ -176,10 +178,14 @@ export default defineComponent({ const segmentationPointsRef = useSegmentationPoints(); const segmentationPointsLayer = new SegmentationPointsLayer(annotator); - // Watch for segmentation points updates - only show points for current frame - watch([segmentationPointsRef, frameNumberRef], ([newPoints, currentFrame]) => { - // Only display points if they belong to the current frame - if (newPoints.points.length > 0 && newPoints.frameNum === currentFrame) { + // Watch for segmentation points updates - only show points for the current + // frame, and only on the selected camera. The prompt points belong to the + // image being segmented; without the camera check every camera rendered + // them at the same pixel coordinates, which looked like an (unwarped) + // point being created on the other camera. + watch([segmentationPointsRef, frameNumberRef, selectedCamera], ([newPoints, currentFrame, currentCamera]) => { + if (newPoints.points.length > 0 && newPoints.frameNum === currentFrame + && props.camera === currentCamera) { segmentationPointsLayer.updatePoints(newPoints.points, newPoints.labels); } else { segmentationPointsLayer.clear(); @@ -220,21 +226,29 @@ export default defineComponent({ // True when, while editing, the selected track has no geometry on THIS // camera at this frame (the track may not exist on this camera at all). + // For Point mode (point-click segmentation) "no geometry" means no + // segmentation polygon here yet, so a detection that only has a box still + // accepts a point click. // Complements isCreatingNewDetection: after the detection is drawn on one // camera, the creation cursor stays live on the cameras still missing it, // so it can be drawn on each in turn (same track id) without selecting the - // camera first. Excludes Point mode -- segmentation has its own - // cross-camera machinery. + // camera first. function cameraAwaitingGeometry( frame: number, trackId: AnnotationId | null, editingTrack: false | EditAnnotationTypes, + selectedKey: string, ): boolean { - if (trackId === null || !editingTrack || editingTrack === 'Point') return false; + if (trackId === null || !editingTrack) return false; if (!cameraStore.getAnyPossibleTrack(trackId)) return false; const t = cameraStore.getPossibleTrack(trackId, props.camera); if (!t) return true; - return t.getFeature(frame)[0] == null; + const [feature] = t.getFeature(frame); + if (feature == null) return true; + if (editingTrack === 'Point') { + return !featureHasSegmentationPolygon(feature, selectedKey); + } + return false; } function updateLayers( @@ -413,7 +427,7 @@ export default defineComponent({ } } else if (editingTrack && props.camera !== selectedCamera.value && (isCreatingNewDetection(frame, selectedTrackId) - || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack))) { + || cameraAwaitingGeometry(frame, selectedTrackId, editingTrack, selectedKey))) { // Seamless multicam creation: keep the creation cursor live on every // camera (not just the selected one) so a brand-new detection can be // drawn on whichever camera the user starts on -- and, once drawn on @@ -690,12 +704,16 @@ export default defineComponent({ if (selectedCamera.value === props.camera) { handler.trackAdd(); } - } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value)) { + } else if (cameraAwaitingGeometry(frameNumberRef.value, selectedTrackIdRef.value, editingModeRef.value, selectedKeyRef.value)) { // Extending the selected detection to this camera (it already has // geometry on another camera): create the same-id track on this // camera BEFORE switching, so selectCamera keeps edit mode without // toggling trackEdit (which would finalize/interrupt the in-progress // draw). The update below then commits here under the same track id. + // For Point mode (point-click segmentation), selectCamera also + // finalizes the source camera's pending mask and clears the recipe's + // accumulated prompt points -- those points belong to the source + // camera's image and must not leak into this camera's prediction. const trackId = selectedTrackIdRef.value as number; if (!cameraStore.getPossibleTrack(trackId, props.camera)) { const anyTrack = cameraStore.getAnyPossibleTrack(trackId); @@ -703,6 +721,12 @@ export default defineComponent({ trackStore.add(frameNumberRef.value, trackType, undefined, trackId); } handler.selectCamera(props.camera, false); + if (editingModeRef.value === 'Point' && selectedCamera.value !== props.camera) { + // The switch was blocked (e.g. linking mode): a segmentation point + // clicked on this camera must never be added to the selected + // camera's prompt points, so drop the click. + return; + } } } if (type === 'rectangle') { diff --git a/client/src/provides.ts b/client/src/provides.ts index b59635968..ff1404bea 100644 --- a/client/src/provides.ts +++ b/client/src/provides.ts @@ -135,6 +135,8 @@ export interface Handler { trackEdit(AnnotationId: AnnotationId): void; /* Confirm/lock the current annotation for active recipes */ confirmRecipe(): void; + /* Finalize pending segmentation without deselecting (cross-camera handoff) */ + segmentationFinalizePending(): void; /* toggle selection mode for track */ trackSelect(AnnotationId: AnnotationId | null, edit: boolean, modifiers?: { ctrl: boolean }): void; /* select tracks enclosed by a lasso polygon */ @@ -234,6 +236,7 @@ function dummyHandler(handle: (name: string, args: unknown[]) => void): Handler seekFrame(...args) { handle('seekFrame', args); }, trackEdit(...args) { handle('trackEdit', args); }, confirmRecipe(...args) { handle('confirmRecipe', args); }, + segmentationFinalizePending(...args) { handle('segmentationFinalizePending', args); }, trackSelect(...args) { handle('trackSelect', args); }, lassoSelect(...args) { handle('lassoSelect', args); }, trackSelectNext(...args) { handle('trackSelectNext', args); }, diff --git a/client/src/utils.spec.ts b/client/src/utils.spec.ts index 1805a1050..b916d002f 100644 --- a/client/src/utils.spec.ts +++ b/client/src/utils.spec.ts @@ -16,8 +16,10 @@ import { getRotationArrowLine, ROTATION_THRESHOLD, ROTATION_ATTRIBUTE_NAME, + featureHasSegmentationPolygon, } from './utils'; import type { RectBounds } from './utils'; +import type { Feature } from './track'; describe('updateSubset', () => { it('should return null for identical sets', () => { @@ -335,3 +337,58 @@ describe('Rotation utilities', () => { }); }); }); + +describe('featureHasSegmentationPolygon', () => { + // Matches the constant in dive-common/recipes/segmentationpointclick.ts + const SegmentationPolygonKey = 'SegmentationPolygon'; + const boxOnly: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + }; + const headPointOnly: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + geometry: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 1] }, + properties: { key: 'head' }, + }, + ], + }, + }; + const withSegmentationMask: Feature = { + frame: 0, + bounds: [0, 0, 10, 10] as RectBounds, + geometry: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[0, 0], [5, 0], [5, 5], [0, 0]]] }, + properties: { key: SegmentationPolygonKey }, + }, + ], + }, + }; + + it('returns false for a null feature', () => { + expect(featureHasSegmentationPolygon(null, SegmentationPolygonKey)).toBe(false); + }); + + it('a camera already segmented (mask polygon present) has the polygon', () => { + expect(featureHasSegmentationPolygon(withSegmentationMask, SegmentationPolygonKey)).toBe(true); + // Wrong key does not match + expect(featureHasSegmentationPolygon(withSegmentationMask, 'other')).toBe(false); + }); + + it('a box-only detection still accepts a segmentation click', () => { + expect(featureHasSegmentationPolygon(boxOnly, SegmentationPolygonKey)).toBe(false); + }); + + it('a stray Point geometry (e.g. head marker) does not count as segmented', () => { + expect(featureHasSegmentationPolygon(headPointOnly, SegmentationPolygonKey)).toBe(false); + }); +}); diff --git a/client/src/utils.ts b/client/src/utils.ts index 7b4d7dfd7..12e48c3d2 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -1,9 +1,29 @@ import axios from 'axios'; import { difference } from 'lodash'; +// Type-only import (erased at runtime) so the utils <-> track cycle is safe. +import type { Feature } from './track'; // [x1, y1, x2, y2] as (left, top), (bottom, right) export type RectBounds = [number, number, number, number]; +/** + * True when a track feature already carries a segmentation mask: a Polygon + * under the selected key. Point-click segmentation commits its mask as that + * polygon (the prompt points themselves are never stored on the track), so + * cross-camera segmentation uses this to decide whether a camera still + * accepts a point click for the selected detection -- a detection that only + * has a box (e.g. from a pipeline) must still accept one. + */ +export function featureHasSegmentationPolygon( + feature: Feature | null, + selectedKey: string, +): boolean { + if (!feature) return false; + return (feature.geometry?.features || []).some( + (f) => f.geometry.type === 'Polygon' && (f.properties?.key ?? '') === selectedKey, + ); +} + // Rotation-related constants /** Threshold for considering rotation significant (radians) */ export const ROTATION_THRESHOLD = 0.001; From 26663be952180a7cb3886766f574bf098ea2382b Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 2 Jul 2026 13:59:27 -0400 Subject: [PATCH 05/10] Multicam edit fixes: both-camera edit mode, single-press right-click deselect, stereo startup Three fixes for stereo/multicam annotation editing: - Edit mode is now active on every camera showing the selected track, not just the selected one, so a stereo detection can be adjusted on either camera without selecting it first. The mousedown that grabs an edit handle switches the selected camera (preserving edit mode since the track exists there) without preventDefault killing the drag, and the edit-layer reset is deferred while the button is held. A routing fallback commits edits from a non-selected camera to that camera's own track. - A right-click while editing now finalizes and deselects the detection in a single press, matching single-camera behavior, instead of merely switching cameras and leaving it selected until a second right-click. Also fixes finalizeInProgress emitting the garbage GeoJS trace for a LineString still in creation mode (the real feature lives in shapeInProgress), which could commit a bogus line. - Interactive stereo startup no longer silently fails to produce lengths. Entering a sequence with a stereo feature on shows the persistent, annotation-blocking loading dialog until the service is ready; enable failures are always surfaced instead of degrading silently; and if a measurement is requested while the service is wanted but not running, the handler kicks off the enable itself and waits, so drawing a line always forces the service up rather than producing no measurement. --- client/dive-common/components/Viewer.vue | 33 +++++- .../frontend/components/ViewerLoader.vue | 109 +++++++++++++----- client/src/components/LayerManager.vue | 23 +++- client/src/layers/EditAnnotationLayer.ts | 13 ++- 4 files changed, 143 insertions(+), 35 deletions(-) diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 5ccf38c93..7e3d01de6 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -800,13 +800,36 @@ export default defineComponent({ if (isCreatingNewDetection()) { return; } - // Likewise, a left-click on a camera still missing the selected track's - // geometry starts a draw there -- don't switch cameras. Right-click - // switching (mouseup.right) and the dropdown (no event) are unaffected. - if (event?.button === 0 && isExtendingDetectionToCamera(camera)) { + // Likewise, when a camera is still missing the selected track's + // geometry its creation cursor is live (see LayerManager): a left-click + // there starts a draw and a right-click cancels creation -- finalize + // what was committed and deselect, exactly like the single-camera + // behavior (the edit layer's own right-click handler does this). + // Neither must be stolen to switch cameras: switching mid-draw runs + // trackEdit -> finalizeInProgress, which interrupts the in-progress + // line instead of finalizing the detection. The dropdown (no event) is + // unaffected. + if (event && isExtendingDetectionToCamera(camera)) { return; } - if (event) { + // A right-click while editing must finalize and deselect the detection + // in a single press -- matching single-camera behavior -- not merely + // switch cameras (which used to leave the detection selected until a + // second right-click on the new camera). Right-clicks ON an annotation + // never reach here: the annotation layers' right-click handoff switches + // the selected camera synchronously first, so this handler returns at + // the top (same camera). + if (event?.button === 2 && editingTrack.value) { + handler.trackSelect(null, false); + return; + } + // While editing a track that exists on the target camera, its edit + // handles are live there too (see LayerManager): this mousedown may be + // the start of a handle drag, which preventDefault would kill. The + // switch still proceeds so the edit commits to the target camera. + const editingOnTarget = editingTrack.value && selectedTrackId.value !== null + && !!cameraStore.getPossibleTrack(selectedTrackId.value, camera); + if (event && !editingOnTarget) { event.preventDefault(); } // Left click should kick out of editing mode, unless the selected track diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index da5455fc4..3c765a68a 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -428,17 +428,16 @@ export default defineComponent({ } // Enable or disable the backend stereo service to match the desired state. - // userInitiated distinguishes an explicit toggle -- surface failures and - // revert the toggles -- from the load-time auto-enable of a remembered - // setting, which degrades quietly on benign failures (e.g. an uncalibrated - // dataset) so a later calibrated dataset still works. + // Failures are always surfaced in the (persistent) dialog; userInitiated + // additionally reverts the feature toggles, since the user just asked for + // something that cannot work, whereas a load-time auto-enable failure + // (e.g. an uncalibrated dataset) keeps the remembered toggles so a later + // calibrated dataset still works. async function applyStereoServiceState(enabled: boolean, userInitiated: boolean) { if (enabled) { // Already running (e.g. a user toggle raced the load-time auto-enable): // nothing to do. if (stereoEnabled.value) return; - stereoLoadingDialog.value = userInitiated; - stereoLoadingMessage.value = 'Loading stereo model...'; stereoLoadingError.value = ''; try { @@ -446,11 +445,19 @@ export default defineComponent({ if (!hasStereo) { // Single-camera dataset: nothing to enable. Do NOT spin up the // stereo service. Leave the toggles untouched so the defaults still - // apply when a stereo dataset is opened later. + // apply when a stereo dataset is opened later. Remember the + // determination so the per-annotation self-heal doesn't re-check. + stereoDatasetUnavailable = true; stereoEnabled.value = false; stereoLoadingDialog.value = false; return; } + // Show the (persistent, annotation-blocking) loading dialog while + // the service starts -- including the load-time auto-enable, so + // entering a sequence with a stereo feature on visibly blocks + // annotation until measurements will actually work. + stereoLoadingMessage.value = 'Loading stereo model...'; + stereoLoadingDialog.value = true; const result = await stereoEnable(undefined, stereoCalibrationFile); if (!result.success) { // launchFailed means the backend service couldn't even start (e.g. @@ -475,17 +482,16 @@ export default defineComponent({ const launchFailed = (err as Error & { launchFailed?: boolean }).launchFailed === true; if (userInitiated || launchFailed) { // The user explicitly enabled a feature, or the backend service - // failed to launch (an infrastructure error). Either way, revert the - // toggles and surface the failure in a dialog. + // failed to launch (an infrastructure error): revert the toggles. disableStereoFeatureToggles(); - stereoLoadingError.value = err instanceof Error ? err.message : String(err); - stereoLoadingDialog.value = true; - } else { - // Load-time auto-enable failed for a benign reason (e.g. an - // uncalibrated stereo dataset): degrade silently and keep the toggle - // states so a later calibrated dataset still works. - stereoLoadingDialog.value = false; } + // Always surface the failure -- a stereo feature is on, so lengths + // and transfers will NOT work; silently degrading here made that + // look like annotations simply weren't measuring. Load-time + // failures (e.g. an uncalibrated dataset) keep the toggles so a + // later calibrated dataset still works. + stereoLoadingError.value = err instanceof Error ? err.message : String(err); + stereoLoadingDialog.value = true; } } else { try { @@ -497,6 +503,43 @@ export default defineComponent({ } } + // In-flight enable/disable promise. Stereo work requested while the + // service is still starting (spawning the backend service on a fresh + // launch can take a while) waits for it via stereoServiceReady instead of + // being dropped -- e.g. a line drawn on both cameras right after launch + // must still get its length computed once the service comes up. + let stereoStatePromise: Promise | null = null; + // True once this dataset was determined to have no stereo pair, so the + // per-annotation self-heal below doesn't re-load metadata on every draw + // in single-camera datasets. + let stereoDatasetUnavailable = false; + + function requestStereoServiceState(enabled: boolean, userInitiated: boolean): Promise { + const p = applyStereoServiceState(enabled, userInitiated).finally(() => { + if (stereoStatePromise === p) stereoStatePromise = null; + }); + stereoStatePromise = p; + return p; + } + + // Wait out any in-flight enable/disable; returns whether the service is up. + async function stereoServiceReady(): Promise { + // Self-heal: a stereo feature is wanted but no enable is running and + // none succeeded (e.g. the load-time auto-enable never fired or failed + // transiently) -- kick one off now, driven by the annotation that needs + // it. This guarantees drawing a line always forces the stereo service + // up rather than silently producing no measurement. + if (!stereoEnabled.value && !stereoStatePromise + && !stereoDatasetUnavailable && stereoServiceWanted()) { + requestStereoServiceState(true, false); + } + while (stereoStatePromise) { + // eslint-disable-next-line no-await-in-loop + await stereoStatePromise; + } + return stereoEnabled.value; + } + // Runtime toggle changes are always user-initiated. This watcher is NOT // immediate: a remembered setting must NOT auto-enable here, because this // runs during setup() -- before the dataset/viewer has loaded. Enabling then @@ -504,7 +547,7 @@ export default defineComponent({ // degrades silently with nothing to retry, so the service would stay off // until the toggle was flipped off and on again. The load-time auto-enable // is deferred to the viewer-ready watcher below instead. - watch(stereoServiceWanted, (enabled) => applyStereoServiceState(enabled, true)); + watch(stereoServiceWanted, (enabled) => requestStereoServiceState(enabled, true)); // Load-time auto-enable of a remembered setting: run once the viewer has // actually finished loading the dataset (progress.loaded), so the metadata @@ -517,7 +560,7 @@ export default defineComponent({ if (!loaded) return; stopStereoAutoEnable(); if (stereoServiceWanted() && !stereoEnabled.value) { - applyStereoServiceState(true, false); + requestStereoServiceState(true, false); } }, { immediate: true }, @@ -926,7 +969,8 @@ export default defineComponent({ * right tracks now have a 2-point line. */ async function handleStereoTrackLinked(trackId: number) { - if (!stereoEnabled.value) return; + // Wait out a still-starting service rather than dropping the recompute. + if (!stereoEnabled.value && !(await stereoServiceReady())) return; // Linking a pair across cameras only (re)computes their stereo lengths. if (!clientSettings.stereoSettings.updateLengthsOnModify) return; const viewer = viewerRef.value; @@ -969,7 +1013,25 @@ export default defineComponent({ * Warps annotation from source camera to the other camera */ async function handleStereoAnnotationComplete(params: StereoAnnotationCompleteParams) { - if (!stereoEnabled.value) return; + // This handler only fires on human edits — the stereo warp writes + // geometry directly, bypassing the annotation-complete event — so the + // camera the user just drew/edited is now human-authored. Mark it + // BEFORE any waiting below (no service is needed for this): the wait + // can be long on a fresh launch, and a line drawn on the other camera + // in the meantime must see this one as human-authored, not + // machine-generated, so the warp can never overwrite it. + if (params.type === 'line') { + const sourceTrack = viewerRef.value?.cameraStore + ?.getPossibleTrack(params.trackId, params.camera); + sourceTrack?.setFeatureAttribute(params.frameNum, STEREO_USER_LINE_ATTR, true); + } + + // The service may still be starting (the load-time auto-enable spawns + // the backend service, which can take a while on a fresh launch). Wait + // for it rather than dropping the annotation's stereo work -- the + // other-camera state below is re-read after the wait, so lines drawn on + // both cameras in the meantime still get their length computed. + if (!stereoEnabled.value && !(await stereoServiceReady())) return; const viewer = viewerRef.value; if (!viewer) return; @@ -994,13 +1056,6 @@ export default defineComponent({ const autoCompute = clientSettings.stereoSettings.autoComputeOtherCamera; if (params.type === 'line') { - // This handler only fires on human edits — the stereo warp writes geometry - // directly, bypassing the annotation-complete event — so the camera the - // user just drew/edited is now human-authored. Mark it so the opposite - // side's warp can never overwrite it later. - const sourceTrack = cameraStore.getPossibleTrack(params.trackId, params.camera); - sourceTrack?.setFeatureAttribute(params.frameNum, STEREO_USER_LINE_ATTR, true); - const otherIsHuman = otherHasFeature && otherFeature?.attributes?.[STEREO_USER_LINE_ATTR] === true; if (otherIsHuman || !autoCompute) { diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 7d3249c06..74a69cae7 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -306,7 +306,15 @@ export default defineComponent({ }; frameData.push(trackFrame); if (trackFrame.selected) { - if (editingTrack && props.camera === selectedCamera.value) { + // While editing, show edit handles on EVERY camera where the + // selected track has geometry at this frame -- not just the + // selected camera -- so a stereo detection can be adjusted on + // either camera without selecting it first. The mousedown that + // grabs a handle switches the selected camera first + // (Viewer.changeCamera keeps edit mode when the track exists on + // the target camera), and the update:geojson routing below is + // the fallback for edits that land before the switch. + if (editingTrack) { editingTracks.push(trackFrame); } if (clientSettings.annotatorPreferences.lockedCamera.enabled) { @@ -727,6 +735,19 @@ export default defineComponent({ // camera's prompt points, so drop the click. return; } + } else if (editingModeRef.value && selectedTrackIdRef.value !== null + && cameraStore.getPossibleTrack(selectedTrackIdRef.value, props.camera)) { + // Editing the selected track's existing geometry on this camera + // (edit handles are live on every camera showing the track): switch + // so the edit commits to THIS camera's track. Normally the mousedown + // that grabbed the handle already switched via Viewer.changeCamera; + // this is the fallback when the update lands first. + handler.selectCamera(props.camera, false); + if (selectedCamera.value !== props.camera) { + // Switch blocked: never commit this camera's edit to the selected + // camera's track. + return; + } } } if (type === 'rectangle') { diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts index 7aaeb00e8..a64089337 100644 --- a/client/src/layers/EditAnnotationLayer.ts +++ b/client/src/layers/EditAnnotationLayer.ts @@ -583,6 +583,12 @@ export default class EditAnnotationLayer extends BaseLayer { // Skip Point mode — segmentation manages its own polygon via the recipe, // not through the edit layer's GeoJS annotation. if (this.featureLayer && this.type !== 'Point') { + // For LineString in creation mode, the native GeoJS annotation is a + // garbage trace: the real feature is managed in shapeInProgress (handled + // above). Emitting it would commit a bogus line to the track. + if (this.type === 'LineString' && this.getMode() === 'creation') { + return false; + } const annotations = this.featureLayer.annotations(); if (annotations.length > 0) { const annotation = annotations[0]; @@ -684,13 +690,16 @@ export default class EditAnnotationLayer extends BaseLayer { /** overrides default function to disable and clear anotations before drawing again */ async changeData(frameData: FrameDataTrack[]) { if (this.skipNextExternalUpdate === false) { - // disable resets things before we load a new/different shape or mode - this.disable(); //TODO: Find a better way to track mouse up after placing a point or completing geometry //For line drawings and the actions of any recipes we want if (this.annotator.geoViewerRef.value.interactor().mouse().buttons.left) { + // Defer the whole reset while the left button is held: this layer may + // be mid-drag (e.g. an edit handle grabbed on a camera the mousedown + // just selected) and disable() would kill the in-progress action. this.leftButtonCheckTimeout = window.setTimeout(() => this.changeData(frameData), 20); } else { + // disable resets things before we load a new/different shape or mode + this.disable(); clearTimeout(this.leftButtonCheckTimeout); this.formattedData = this.formatData(frameData); } From 827d73a29f6550132d915437572619d0eb186462 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 2 Jul 2026 15:00:18 -0400 Subject: [PATCH 06/10] Stereo: unify transfer-failure error into one dialog, soften to a warning A failed cross-camera transfer showed two popups in sequence: the 'Computing stereo correspondence...' loading dialog flashed and vanished on its own, immediately followed by a separate 'Stereo Transfer Error' prompt. Reuse the loading dialog's own error state instead -- the spinner morphs in place into the error (title + message + Close) rather than hiding and spawning a second dialog. A transfer that finds no stereo match is a common, recoverable event, so its alert is now a softer 'warning' (amber) rather than the hard red 'error' reserved for service-startup failures. --- .../frontend/components/ViewerLoader.vue | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 3c765a68a..5d22ecf5d 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -305,6 +305,14 @@ export default defineComponent({ const stereoLoadingDialog = ref(false); const stereoLoadingMessage = ref('Loading stereo model...'); const stereoLoadingError = ref(''); + // Title shown while the loading dialog is in its error state. Lets the same + // dialog surface both service-startup and per-transfer failures instead of + // spawning a second popup. + const stereoErrorTitle = ref('Stereo Service Error'); + // Alert severity for that error state. A per-transfer failure (no stereo + // match found) is a common, recoverable event -- shown as a softer + // 'warning' -- whereas a service-startup failure is a hard 'error' (red). + const stereoErrorSeverity = ref<'error' | 'warning'>('error'); const stereoEnabled = ref(false); // Transient notification reporting the latest computed stereo length const stereoLengthSnackbar = ref(false); @@ -490,6 +498,8 @@ export default defineComponent({ // look like annotations simply weren't measuring. Load-time // failures (e.g. an uncalibrated dataset) keep the toggles so a // later calibrated dataset still works. + stereoErrorTitle.value = 'Stereo Service Error'; + stereoErrorSeverity.value = 'error'; stereoLoadingError.value = err instanceof Error ? err.message : String(err); stereoLoadingDialog.value = true; } @@ -1332,15 +1342,15 @@ export default defineComponent({ // Success — hide loading dialog stereoLoadingDialog.value = false; } catch (err) { - stereoLoadingDialog.value = false; + // Surface the failure in the SAME dialog that was showing the + // "Computing stereo correspondence..." spinner: switch it to its error + // state (title + message + Close button) rather than hiding it and + // popping a separate prompt, which flashed two windows in sequence. const message = err instanceof Error ? err.message : String(err); - await prompt({ - title: 'Stereo Transfer Error', - text: [ - 'Failed to transfer annotation to the other camera.', - message, - ], - }); + stereoErrorTitle.value = 'Stereo Transfer Error'; + stereoErrorSeverity.value = 'warning'; + stereoLoadingError.value = `Failed to transfer annotation to the other camera. ${message}`; + stereoLoadingDialog.value = true; } } @@ -1469,6 +1479,8 @@ export default defineComponent({ stereoLoadingDialog, stereoLoadingMessage, stereoLoadingError, + stereoErrorTitle, + stereoErrorSeverity, stereoLengthSnackbar, stereoLengthMessage, closeStereoLoadingDialog, @@ -1567,7 +1579,7 @@ export default defineComponent({ max-width="560" > - {{ stereoLoadingError ? 'Stereo Service Error' : 'Stereo Service' }} + {{ stereoLoadingError ? stereoErrorTitle : 'Stereo Service' }}
From bb989b69956c622f2648c426ea4f1b11bb7bcb2c Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 2 Jul 2026 15:31:40 -0400 Subject: [PATCH 07/10] Fix right-click line-edit toggling off on skinny boxes hugging a line A right-click near the head/tail line of a tight (skinny) bounding box lands inside both the rectangle polygon and the line feature, so RectangleLayer and LineLayer both emit annotation-right-clicked for the one physical click. Both are wired to Clicked -> handler.trackEdit, which toggles edit mode, so the second emit immediately undid the first: the detection entered line edit mode and then dropped straight back to merely selected. Add a same-tick guard in Clicked (mirroring the existing justFinalizedCreation flag): the first emit for a physical click is handled and any further emits in the same synchronous tick are ignored, cleared on the next macrotask so distinct user clicks are unaffected. Both overlapping features belong to the same track, so first-wins is unambiguous. --- client/src/components/LayerManager.vue | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index 74a69cae7..7e74373f0 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -554,12 +554,27 @@ export default defineComponent({ // the click that completed the draw is suppressed. let justFinalizedCreation = false; + // Guards against a single physical click being handled more than once when + // it lands on overlapping features on different layers. A skinny box hugging + // its head/tail line is the common case: the click hits both the rectangle + // polygon and the line, so both layers emit annotation-(right-)clicked in + // the same tick. Because trackEdit toggles edit mode, the second emit would + // immediately undo the first (right-click enters edit, then leaves it, + // ending up merely selected). Cleared on the next macrotask, so distinct + // user clicks (separate ticks) are unaffected. + let clickHandledThisTick = false; + const Clicked = (trackId: number, editing: boolean, modifiers?: {ctrl: boolean}) => { // The click that just finalized a new detection should not also select an // existing detection underneath the final vertex. if (justFinalizedCreation) { return; } + if (clickHandledThisTick) { + return; + } + clickHandledThisTick = true; + window.setTimeout(() => { clickHandledThisTick = false; }, 0); // Clicking a detection in a camera that isn't selected yet: switch to that // camera AND act on the detection in the same click — left-click selects, // right-click edits — instead of requiring a separate click to switch first. From 966a21493be7ea217d3a96be7b493410c8b4c35b Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Fri, 3 Jul 2026 09:04:55 -0400 Subject: [PATCH 08/10] button alignment when edit tools are collapsed --- .../components/AnnotationVisibilityMenu.vue | 5 ++++- client/dive-common/components/EditorMenu.vue | 18 +++++++++++++----- .../dive-common/components/MultiCamToolbar.vue | 1 + .../dive-common/components/toolbarGroup.scss | 8 ++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/client/dive-common/components/AnnotationVisibilityMenu.vue b/client/dive-common/components/AnnotationVisibilityMenu.vue index ea11bc788..5a86f3cf3 100644 --- a/client/dive-common/components/AnnotationVisibilityMenu.vue +++ b/client/dive-common/components/AnnotationVisibilityMenu.vue @@ -159,7 +159,10 @@ export default defineComponent({