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
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const EditorShortcutsModal: React.FC<EditorShortcutsModalProps> = ({ onCl
<Row label="Draw one — drag on the image" keys={['drag']} />
<Row label="Move or resize — click it first, then drag" keys={['click']} />
<Row label="Mark the object not visible here" keys={['Del']} />
<Row label="Copy the previous frame's box here" keys={['P']} />
<Row label="Deselect the box" keys={['Esc']} />
</Section>
<Section title="Move around the image">
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/localize/editor/LocalizeObjectEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
committedBox,
hasModelEvidence,
isCleared,
previousShownBox,
priorityPick,
type BoxCandidate,
} from '@/utils/annotation/objectBoxCandidates';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'));
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/utils/annotation/objectBoxCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
62 changes: 62 additions & 0 deletions frontend/tests/utils/objectBoxCandidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hasModelEvidence,
isCleared,
priorityPick,
previousShownBox,
candidateToBbox,
} from '@/utils/annotation/objectBoxCandidates';
import type { Detection, DetectionAnnotation } from '@/types/api';
Expand Down Expand Up @@ -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);
Expand Down
Loading