diff --git a/frontend/src/components/localize/editor/EditorShortcutsModal.tsx b/frontend/src/components/localize/editor/EditorShortcutsModal.tsx index 2e0f6c71..e5bc6d2c 100644 --- a/frontend/src/components/localize/editor/EditorShortcutsModal.tsx +++ b/frontend/src/components/localize/editor/EditorShortcutsModal.tsx @@ -77,6 +77,7 @@ export const EditorShortcutsModal: React.FC = ({ onCl +
diff --git a/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx b/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx index 6f164138..0b98aca0 100644 --- a/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx +++ b/frontend/src/components/localize/editor/LocalizeObjectEditor.tsx @@ -34,6 +34,7 @@ import { committedBox, hasModelEvidence, isCleared, + previousShownBox, priorityPick, type BoxCandidate, } from '@/utils/annotation/objectBoxCandidates'; @@ -392,6 +393,16 @@ export function LocalizeObjectEditor({ clearRef.current = clear; + // `P`: replace this frame's box with the one the previous frame shows — + // committed, or its winning pick. Copied geometry commits as `human`: no + // model proposed it HERE, a person placed it. Same ref trick as `clear`. + const copyPreviousRef = useRef<() => void>(() => undefined); + const copyPrevious = useCallback(() => { + const xyxyn = previousShownBox(detection.id, laneDetections, laneAnnotations); + if (xyxyn) commitCandidate({ source: 'manual', index: 0, xyxyn }); + }, [detection.id, laneDetections, laneAnnotations, commitCandidate]); + copyPreviousRef.current = copyPrevious; + // --- Navigation --------------------------------------------------------- const currentEntryIndex = peeked @@ -631,6 +642,19 @@ export function LocalizeObjectEditor({ if (!editable || e.repeat) return; clearRef.current(); break; + case 'p': + case 'P': + // Ctrl/Cmd+P is the browser's print — a held modifier means the + // press was never for us (the page-level handler draws the same + // line). Unlike the view toggles, this key WRITES, so it also + // stays inert behind the shortcuts sheet — which documents P and + // thereby invites the press — and the accept popover. Auto-repeat + // is dropped for the same async-save reason as Delete. + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (shortcutsOpen || acceptOpen) return; + if (!editable || e.repeat) return; + copyPreviousRef.current(); + break; case 'g': case 'G': setBoxVisibility(v => (v === 'pick' ? 'all' : v === 'all' ? 'none' : 'pick')); diff --git a/frontend/src/utils/annotation/objectBoxCandidates.ts b/frontend/src/utils/annotation/objectBoxCandidates.ts index 9687c14d..23acd8bd 100644 --- a/frontend/src/utils/annotation/objectBoxCandidates.ts +++ b/frontend/src/utils/annotation/objectBoxCandidates.ts @@ -152,6 +152,30 @@ export function priorityPick(candidates: BoxCandidate[]): BoxCandidate | null { return null; } +/** + * The box the nearest earlier lane frame SHOWS for this object — its committed + * box, or the winning pick it would commit if undecided. What `P` copies onto + * the current frame. Cleared frames show no box (the annotator said "not + * visible here") and are skipped, as are frames with nothing on offer. + */ +export function previousShownBox( + currentDetectionId: number, + laneDetections: Detection[], + laneAnnotations: DetectionAnnotation[] +): [number, number, number, number] | null { + const index = laneDetections.findIndex(d => d.id === currentDetectionId); + for (let i = index - 1; i >= 0; i--) { + const det = laneDetections[i]; + const annotation = laneAnnotations.find(a => a.detection_id === det.id) ?? null; + const committed = committedBox(annotation); + if (committed) return committed.xyxyn; + if (isCleared(annotation)) continue; + const pick = priorityPick(boxCandidates(det, annotation)); + if (pick) return pick.xyxyn; + } + return null; +} + /** Whether any model layer put a box on this frame. A frame without model * evidence exists in its lane only because a human boxed it (a materialized * gap frame, or any frame of an added-object lane), so clearing it removes diff --git a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx index a22ac4ee..a937ceb0 100644 --- a/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx +++ b/frontend/tests/components/localize/editor/LocalizeObjectEditor.test.tsx @@ -826,6 +826,84 @@ describe('LocalizeObjectEditor box selection', () => { expect(onCommit).not.toHaveBeenCalled(); }); + it("P commits the previous frame's committed box onto this frame as a human decision", () => { + const onCommit = vi.fn(); + const previous = { + ...committedAnnotation(firstDetection.id, 'auto'), + annotation: { + annotation: [ + { + xyxyn: [0.42, 0.42, 0.52, 0.52], + class_name: 'smoke', + smoke_type: 'wildfire', + origin: 'auto', + }, + ], + }, + } as unknown as DetectionAnnotation; + renderEditor({ detection: lastDetection, laneAnnotations: [previous], onCommit }); + fireEvent.keyDown(window, { key: 'p' }); + expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ id: lastDetection.id }), [ + { + xyxyn: [0.42, 0.42, 0.52, 0.52], + class_name: 'smoke', + smoke_type: 'wildfire', + origin: 'human', + }, + ]); + }); + + it("P falls back to the previous frame's winning pick when it is undecided", () => { + const onCommit = vi.fn(); + renderEditor({ detection: lastDetection, onCommit }); + fireEvent.keyDown(window, { key: 'P' }); + expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ id: lastDetection.id }), [ + expect.objectContaining({ xyxyn: [0.2, 0.2, 0.3, 0.3], origin: 'human' }), + ]); + }); + + it('P does nothing on the first frame — there is no earlier box to copy', () => { + const onCommit = vi.fn(); + renderEditor({ detection: firstDetection, onCommit }); + fireEvent.keyDown(window, { key: 'p' }); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('P ignores auto-repeat — the save is async and a held key would double-write', () => { + const onCommit = vi.fn(); + renderEditor({ detection: lastDetection, onCommit }); + fireEvent.keyDown(window, { key: 'p', repeat: true }); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('P does nothing on an out-of-range frame', () => { + // From the LAST frame, whose previous frame does offer a box — from the + // first, P is a no-op regardless and the guard would be untested. + const onCommit = vi.fn(); + const onCommitGapFrame = vi.fn(); + renderEditor({ detection: lastDetection, onCommit, onCommitGapFrame }); + fireEvent.keyDown(window, { key: 'ArrowRight' }); + fireEvent.keyDown(window, { key: 'p' }); + expect(onCommit).not.toHaveBeenCalled(); + expect(onCommitGapFrame).not.toHaveBeenCalled(); + }); + + it('P with a held modifier is the browser\'s shortcut, not ours', () => { + const onCommit = vi.fn(); + renderEditor({ detection: lastDetection, onCommit }); + fireEvent.keyDown(window, { key: 'p', ctrlKey: true }); + fireEvent.keyDown(window, { key: 'p', metaKey: true }); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('P does not write behind the shortcuts sheet that documents it', () => { + const onCommit = vi.fn(); + renderEditor({ detection: lastDetection, onCommit }); + fireEvent.keyDown(window, { key: '?' }); + fireEvent.keyDown(window, { key: 'p' }); + expect(onCommit).not.toHaveBeenCalled(); + }); + it('drops the selection when the frame changes', () => { const { rerender } = renderLoadedEditor({ existingAnnotation: committed() }); fireEvent.mouseDown(screen.getByTestId('drawn-box-committed')); diff --git a/frontend/tests/utils/objectBoxCandidates.test.ts b/frontend/tests/utils/objectBoxCandidates.test.ts index ad9eb332..0fa11f05 100644 --- a/frontend/tests/utils/objectBoxCandidates.test.ts +++ b/frontend/tests/utils/objectBoxCandidates.test.ts @@ -5,6 +5,7 @@ import { hasModelEvidence, isCleared, priorityPick, + previousShownBox, candidateToBbox, } from '@/utils/annotation/objectBoxCandidates'; import type { Detection, DetectionAnnotation } from '@/types/api'; @@ -251,6 +252,67 @@ describe('hasModelEvidence', () => { }); }); +describe('previousShownBox', () => { + const laneDetection = (id: number, autoBox?: [number, number, number, number]): Detection => + detection({ + id, + auto_predictions: { + predictions: autoBox ? [{ xyxyn: autoBox, confidence: 0.8, class_name: 'smoke' }] : [], + }, + }); + + const committedFor = ( + detectionId: number, + xyxyn: [number, number, number, number] + ): DetectionAnnotation => + ({ + id: detectionId * 10, + detection_id: detectionId, + annotation: { + annotation: [{ xyxyn, class_name: 'smoke', smoke_type: 'wildfire', origin: 'auto' }], + }, + }) as DetectionAnnotation; + + const clearedFor = (detectionId: number): DetectionAnnotation => + ({ + id: detectionId * 10, + detection_id: detectionId, + annotation: { annotation: [] }, + processing_stage: 'annotated', + }) as unknown as DetectionAnnotation; + + it("returns the previous frame's committed box", () => { + const lane = [laneDetection(1, [0.1, 0.1, 0.2, 0.2]), laneDetection(2, [0.5, 0.5, 0.6, 0.6])]; + const result = previousShownBox(2, lane, [committedFor(1, [0.3, 0.3, 0.4, 0.4])]); + expect(result).toEqual([0.3, 0.3, 0.4, 0.4]); + }); + + it("falls back to the previous frame's winning pick when it is undecided", () => { + const lane = [laneDetection(1, [0.1, 0.1, 0.2, 0.2]), laneDetection(2)]; + expect(previousShownBox(2, lane, [])).toEqual([0.1, 0.1, 0.2, 0.2]); + }); + + it('skips cleared and boxless frames to the nearest earlier shown box', () => { + const lane = [ + laneDetection(1, [0.1, 0.1, 0.2, 0.2]), + laneDetection(2), // boxless, undecided — shows nothing + laneDetection(3, [0.7, 0.7, 0.8, 0.8]), // cleared — shows nothing + laneDetection(4), + ]; + expect(previousShownBox(4, lane, [clearedFor(3)])).toEqual([0.1, 0.1, 0.2, 0.2]); + }); + + it('returns null when no earlier frame shows a box', () => { + const lane = [laneDetection(1), laneDetection(2)]; + expect(previousShownBox(2, lane, [])).toBeNull(); + }); + + it('returns null on the first frame of the lane', () => { + const lane = [laneDetection(1, [0.1, 0.1, 0.2, 0.2]), laneDetection(2)]; + expect(previousShownBox(1, lane, [])).toBeNull(); + }); +}); + describe('isCleared', () => { it('is true for a committed annotation holding no box — the annotator said "not visible here"', () => { expect(isCleared(annotated([]))).toBe(true);