Skip to content
Merged
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
8 changes: 6 additions & 2 deletions client/dive-common/recipes/segmentationpointclick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,15 +735,19 @@ export default class SegmentationPointClick implements Recipe {
/**
* Public method to reset (clear) all accumulated points and pending prediction.
* Called from UI Reset button. Clears all frames.
*
* @param byUser false when a selection change clears the points instead of
* the user, so a right-click that only entered edit mode cannot count as
* a reset to finalize.
*/
resetPoints(): void {
resetPoints(byUser = true): void {
// Emit reset event for all frames with data
const framesToReset = [this.currentFrame, ...this.frameData.keys()];
framesToReset.forEach((frameNum) => {
this.bus.$emit('prediction-reset', { frameNum });
});
this.reset();
this._wasReset = true;
this._wasReset = byUser;
this.icon.value = 'mdi-auto-fix';
}

Expand Down
67 changes: 67 additions & 0 deletions client/dive-common/use/useModeManager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
/**
* Functional tests for the Align View cross-camera mirror: drawing/editing a
* track on one camera while the aligned view is active re-projects the
Expand Down Expand Up @@ -618,3 +619,69 @@ describe('entering polygon editing', () => {
expect(manager.selectedKey.value).toBe('2');
});
});

describe('stereo copy of a point-segmented mask', () => {
it('runs once per click and not again when the mask is confirmed', () => {
const wasAutoCompute = clientSettings.stereoSettings.autoComputeOtherCamera;
clientSettings.stereoSettings.autoComputeOtherCamera = true;
try {
const recipe = new SegmentationPointClick();
const events: StereoAnnotationCompleteParams[] = [];
const { modeManager: manager } = makeHarness(undefined, [recipe], (params) => events.push(params));
manager.handler.trackAdd();
const result = {
polygon: [[0, 0], [10, 0], [10, 10]] as [number, number][],
bounds: null,
frameNum: 0,
controlPoints: { points: [[5, 5]] as [number, number][], labels: [1] },
};
recipe.bus.$emit('prediction-ready', result);
expect(events.map((e) => e.type)).toEqual(['segmentation']);
recipe.bus.$emit('prediction-confirmed', result);
recipe.bus.$emit('prediction-confirmed-multi', { frames: new Map([[0, result]]) });
expect(events.map((e) => e.type)).toEqual(['segmentation']);
} finally {
clientSettings.stereoSettings.autoComputeOtherCamera = wasAutoCompute;
}
});
});

describe('a right-click that enters point segmentation editing', () => {
const press = () => document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }));
it('is not finalized by the contextmenu that follows it, unlike a later right-click or a user reset', () => {
const recipe = new SegmentationPointClick();
const { modeManager: manager } = makeHarness(undefined, [recipe]);
recipe.activate();
const first = manager.handler.trackAdd();
manager.handler.updateRectBounds(0, 0, [0, 0, 10, 10]);
const second = manager.handler.trackAdd();
manager.handler.updateRectBounds(0, 0, [20, 20, 30, 30]);
expect(manager.selectedTrackId.value).toBe(second);
// Selecting another detection clears the recipe, but not as a user reset.
press();
manager.handler.trackEdit(first);
expect(manager.selectedTrackId.value).toBe(first);
expect(manager.editingTrack.value).toBe(true);
expect(recipe.wasReset).toBe(false);
// On Windows the contextmenu of that right-click arrives after edit mode began.
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBe(first);
expect(manager.editingTrack.value).toBe(true);
// A later right-click with no points placed finalizes the detection.
press();
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBeNull();
expect(manager.editingTrack.value).toBe(false);
// So does one after a reset by the user, even within the same press.
press();
manager.handler.trackEdit(second);
recipe.resetPoints();
expect(recipe.wasReset).toBe(true);
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBeNull();
// With nothing selected a right-click changes nothing.
press();
manager.handler.confirmRecipe();
expect(recipe.active.value).toBe(true);
});
});
38 changes: 28 additions & 10 deletions client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,30 @@ export default function useModeManager({
return false;
}

// The right mousedown that selects a detection for point segmentation is
// followed on Windows by its contextmenu only after mouseup, once edit mode
// has begun. That contextmenu must not finalize the fresh edit, while a
// later right-click with no points placed still does, so remember which
// press entered edit mode.
let mouseDownCount = 0;
let editEnteredOnMouseDown = -1;
const countMouseDown = () => { mouseDownCount += 1; };
if (typeof document !== 'undefined') document.addEventListener('mousedown', countMouseDown, true);

function selectTrack(trackId: AnnotationId | null, edit = false) {
// Reset segmentation recipe state when switching to a different track
// so stale points/mask from the previous detection don't interfere
// so stale points/mask from the previous detection don't interfere. This
// is not a user reset (see handleConfirmRecipe).
if (trackId !== selectedTrackId.value) {
recipes.forEach((r) => {
if (r instanceof SegmentationPointClick && r.active.value) {
r.resetPoints();
r.resetPoints(false);
}
});
}
if (trackId !== null && edit && (trackId !== selectedTrackId.value || !editingTrack.value)) {
editEnteredOnMouseDown = mouseDownCount;
}
// Clean up empty tracks when leaving edit mode (e.g., created a detection
// but never drew an annotation, then clicked away or right-clicked to deselect)
if (
Expand Down Expand Up @@ -1349,12 +1363,10 @@ export default function useModeManager({
* Called when right-click is used in Point mode to lock the annotation.
*/
function handleConfirmRecipe() {
// First check if any active segmentation recipe has a pending prediction
// or was explicitly reset by the user (Escape key).
// If neither, there's nothing to confirm - this happens when the contextmenu
// event from a right-click that entered Point edit mode triggers
// confirm-annotation before any points are placed. In that case, don't
// confirm/deactivate recipes or deselect - let the edit mode continue.
// A pending prediction is committed; with nothing placed (or after an
// Escape reset) the right-click just finalizes the detection, leaving
// edit mode. Neither applies to the contextmenu of the very press that
// entered edit mode, which Windows delivers after mouseup.
let hadPendingPredictionOrReset = false;
recipes.forEach((r) => {
if (r.active.value && r.confirm && r instanceof SegmentationPointClick) {
Expand All @@ -1364,7 +1376,10 @@ export default function useModeManager({
}
});
if (!hadPendingPredictionOrReset) {
return;
if (selectedTrackId.value === null || !editingTrack.value
|| mouseDownCount === editEnteredOnMouseDown) {
return;
}
}
const activeSegRecipes: SegmentationPointClick[] = [];
recipes.forEach((r) => {
Expand Down Expand Up @@ -1699,7 +1714,9 @@ export default function useModeManager({
* This is called when the user confirms the segmentation (right-click or Enter).
*/
function handleSegmentationPredictionConfirmed(result: SegmentationPredictionResult) {
handleSegmentationPredictionReady(result);
// Each click already ran the stereo copy and auto-populate for this mask;
// confirming only commits it.
handleSegmentationPredictionReady({ ...result, controlPoints: undefined });
}

/** Click-path variant: a fresh point click that should honor continuous mode. */
Expand Down Expand Up @@ -1884,6 +1901,7 @@ export default function useModeManager({

/* Unsubscribe before unmount */
onBeforeUnmount(() => {
if (typeof document !== 'undefined') document.removeEventListener('mousedown', countMouseDown, true);
recipes.forEach((r) => r.bus.$off('activate', handleSetAnnotationState));
recipes.forEach((r) => {
if (r instanceof SegmentationPointClick) {
Expand Down
Loading