Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
87475f3
Keep DIVE's venv out of the environment handed to VIAME
romleiaj Sep 8, 2026
cbcb337
Ask VIAME for the homography_json transform reader
romleiaj Sep 8, 2026
cd84af8
Cut multicam pipeline inputs to the shortest camera
romleiaj Sep 8, 2026
2d9dd03
Add an offset-derived aligned timeline for fixed-rig camera pairs
romleiaj Aug 21, 2026
dd4b0ef
Drive the aligned timeline from per-camera frame offsets
romleiaj Aug 21, 2026
aa3812c
Add a Time Offset slider to the registration panel
romleiaj Aug 21, 2026
7340188
Persist per-camera frame offsets with the dataset
romleiaj Aug 21, 2026
bd7bd6b
Carry the frame offset in registration files
romleiaj Aug 21, 2026
1be058c
Pair multicam pipeline inputs through the camera time offsets
romleiaj Aug 25, 2026
499ca3e
Bound multicam video inputs to the cameras' common span
romleiaj Aug 26, 2026
d3c0b85
Pair web multicam pipeline inputs through the camera time offsets
romleiaj Sep 8, 2026
74be74f
Keep the displayed instant when the aligned timeline is rebuilt
romleiaj Sep 8, 2026
909f9d6
Move the Time Offset to Multi Camera Tools and let it shift annotations
romleiaj Sep 10, 2026
6cfb9ff
Hold annotations at their instant while the time offset shifts the video
romleiaj Sep 10, 2026
1abce42
Play offset video rigs natively and render video panes mid-seek
romleiaj Sep 10, 2026
59e8612
Keep a zoomed pane's view when the layout resizes it
romleiaj Sep 10, 2026
7423a86
Revert "Keep a zoomed pane's view when the layout resizes it"
romleiaj Sep 10, 2026
bb75615
Restore geojs's delayed video render and coalesce scrub seeks
romleiaj Sep 10, 2026
4b31894
Clip warped boxes to the target camera's frame before upload
romleiaj Sep 10, 2026
cf2f795
Apply camera time offsets to annotations in persistence
romleiaj Sep 17, 2026
47adb61
Load cameras in parallel and mount panes before annotations insert
romleiaj Sep 17, 2026
3806845
Let a camera time offset override timestamp alignment
romleiaj Sep 17, 2026
a21e3a6
Reset annotation undo after a camera's annotations are reloaded
romleiaj Sep 28, 2026
ca49687
Fix server lint failures
romleiaj Sep 28, 2026
38e954e
Pause annotation editing while a camera's time offset is unapplied
romleiaj Sep 28, 2026
6451efe
Draw video annotations only once the seeked frame is on screen
romleiaj Sep 28, 2026
bf96b82
Draw timeline tracks at their aligned slots, not their stored frames
romleiaj Sep 28, 2026
f50d0b8
Show track list begin/end as aligned-timeline slots
romleiaj Sep 28, 2026
1fd5693
Draw image-sequence annotations only once the frame's image is drawn
romleiaj Sep 28, 2026
795ab69
Hold annotations steady while dragging the Time Offset slider
romleiaj Sep 28, 2026
3d276f5
Advance a video's annotation frame when the seeked frame is painted
romleiaj Sep 28, 2026
fea5f4f
Revert "Advance a video's annotation frame when the seeked frame is p…
romleiaj Sep 28, 2026
d314f88
Draw video annotations at the requested frame, as main does
romleiaj Sep 28, 2026
a84fe9b
Sync video annotations to the painted frame, behind a setting
romleiaj Sep 28, 2026
6692169
Revert "Sync video annotations to the painted frame, behind a setting"
romleiaj Sep 28, 2026
1b566af
Keep a zoomed pane's view when the layout resizes it
romleiaj Sep 29, 2026
4c94b4a
Keep a user's zoom when Align View's late re-fit fires
romleiaj Sep 29, 2026
456d24c
Restore main's video seeking, image timing and pane mounting
romleiaj Sep 29, 2026
3d6189c
Load camera annotations one at a time while configs load together
romleiaj Sep 29, 2026
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
64 changes: 63 additions & 1 deletion client/dive-common/alignedTimeline.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FrameImage } from './apispec';
import {
buildAlignedTimeline, buildInverseAlignedIndex, canAlign, computeGapGradient, computeGapSlots,
buildAlignedTimeline, buildInverseAlignedIndex, buildOffsetTimeline, canAlign,
computeGapGradient, computeGapSlots,
} from './alignedTimeline';

function frame(timestamp?: number): FrameImage {
Expand Down Expand Up @@ -209,3 +210,64 @@ describe('alignedTimeline', () => {
});
});
});

/**
* Fixed-rig start offsets: the case timestamps can't cover, because video
* frames carry none. EO/IR pairs from the same fixed rig are recorded by
* independent encoders, so one can start a fraction of a second after the
* other; that lag is constant for the whole recording.
*/
describe('buildOffsetTimeline', () => {
it('pairs a later-starting camera with the reference instant', () => {
// B starts 2 frames later: B's frame 2 is the same instant as A's 0.
const result = buildOffsetTimeline({ A: 5, B: 5 }, { A: 0, B: 2 });
if (!result.aligned) throw new Error('expected aligned');
// Slot 0 is the earliest instant ANY camera saw: B's frame 0, before A began.
expect(result.slots[0]).toEqual({ A: undefined, B: 0 });
expect(result.slots[2]).toEqual({ A: 0, B: 2 });
expect(result.slots[4]).toEqual({ A: 2, B: 4 });
});

it('keeps the union, blanking each camera outside its own coverage', () => {
const result = buildOffsetTimeline({ A: 3, B: 3 }, { A: 0, B: 2 });
if (!result.aligned) throw new Error('expected aligned');
// 2 leading slots before A starts + 3 shared + 0 trailing.
expect(result.slots).toHaveLength(5);
expect(computeGapSlots(result.slots)).toEqual([0, 1, 3, 4]);
// The overlap in the middle has both cameras.
expect(result.slots[2]).toEqual({ A: 0, B: 2 });
});

it('round-trips through the inverse index the resolver uses', () => {
const result = buildOffsetTimeline({ A: 4, B: 4 }, { A: 0, B: 1 });
if (!result.aligned) throw new Error('expected aligned');
const inverse = buildInverseAlignedIndex(result.slots);
// Whatever slot holds A's frame 2 must hold B's frame 3 -- the same instant.
const slotForA2 = inverse.A.get(2) as number;
expect(result.slots[slotForA2].B).toBe(3);
expect(inverse.B.get(3)).toBe(slotForA2);
});

it('handles a negative offset (reference is the later camera)', () => {
const result = buildOffsetTimeline({ A: 4, B: 4 }, { A: 0, B: -1 });
if (!result.aligned) throw new Error('expected aligned');
expect(result.slots[1]).toEqual({ A: 1, B: 0 });
});

it('declines when nothing needs correcting or there is no pair', () => {
// All-zero offsets: the positional path already does this, more cheaply.
expect(buildOffsetTimeline({ A: 5, B: 5 }, { A: 0, B: 0 })).toEqual({ aligned: false });
// A camera with no frames loaded can't be aligned against.
expect(buildOffsetTimeline({ A: 5, B: 0 }, { A: 0, B: 2 })).toEqual({ aligned: false });
expect(buildOffsetTimeline({ A: 5 }, { A: 3 })).toEqual({ aligned: false });
});

it('treats a missing camera entry as no offset', () => {
// A is absent from the offsets map, so it behaves as A: 0 -- identical to
// { A: 0, B: 1 }: one leading slot for B's frame 0, then the pairs.
const result = buildOffsetTimeline({ A: 3, B: 3 }, { B: 1 });
if (!result.aligned) throw new Error('expected aligned');
expect(result.slots[0]).toEqual({ A: undefined, B: 0 });
expect(result.slots[1]).toEqual({ A: 0, B: 1 });
});
});
68 changes: 68 additions & 0 deletions client/dive-common/alignedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,71 @@ export function computeGapSlots(slots: AlignedSlot[]): number[] {
});
return gaps;
}

/**
* A camera's constant start offset, in its own frames: local frame
* `slot + offset` shows the same instant as the reference camera's frame
* `slot`. A positive offset means this camera starts LATER -- its frame 0
* happens before the reference's frame 0, so it must be read further in.
*/
export type CameraFrameOffsets = Record<string, number>;

/**
* Build a timeline from fixed per-camera start offsets rather than per-frame
* timestamps.
*
* buildAlignedTimeline needs a timestamp on every frame, which only image
* sequences carry (parsed from filenames) -- video panes never qualify, so
* they scrub in raw-index lockstep and any recording start offset between
* two cameras is baked into the review. On a fixed rig that offset is a
* single constant, so one number per camera is enough to line them up, and
* emitting it as slots means everything downstream (pane seek, gap
* indication, cross-camera frame translation) behaves exactly as it does
* for a timestamp-aligned dataset.
*
* Slots span the UNION of the cameras' coverage: where one camera has run
* out (or has not started), its entry is undefined, which the existing gap
* handling already renders and blanks correctly, rather than silently
* trimming footage off the ends.
*
* Returns { aligned: false } when fewer than two cameras have frames, or
* when every offset is zero -- there is nothing to correct then, so the
* caller should stay on the cheaper positional path.
*/
export function buildOffsetTimeline(
cameraFrameCounts: Record<string, number>,
offsets: CameraFrameOffsets,
): TimelineResult {
const cameras = Object.keys(cameraFrameCounts)
.filter((camera) => cameraFrameCounts[camera] > 0);
if (cameras.length < 2) {
return { aligned: false };
}
if (cameras.every((camera) => (offsets[camera] ?? 0) === 0)) {
return { aligned: false };
}
// Slot s shows camera c's local frame s + offset[c]; that frame exists for
// s in [-offset[c], count[c] - offset[c]). Take the union across cameras,
// then rebase so the emitted slot array is 0-based.
const starts = cameras.map((camera) => -(offsets[camera] ?? 0));
const ends = cameras.map(
(camera) => cameraFrameCounts[camera] - (offsets[camera] ?? 0),
);
const base = Math.min(...starts);
const total = Math.max(...ends) - base;
if (total <= 0) {
return { aligned: false };
}
const slots: AlignedSlot[] = new Array(total);
for (let index = 0; index < total; index += 1) {
const slot: AlignedSlot = {};
cameras.forEach((camera) => {
const local = index + base + (offsets[camera] ?? 0);
slot[camera] = local >= 0 && local < cameraFrameCounts[camera]
? local
: undefined;
});
slots[index] = slot;
}
return { aligned: true, slots };
}
27 changes: 26 additions & 1 deletion client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ interface SaveDetectionsArgs {
set?: string;
}

/** Outcome of shifting one camera's stored annotations onto its time offset. */
interface CameraFrameOffsetResult {
camera: string;
/** The camera's start offset in its own frames, now both stored and applied. */
offset: number;
/** Frames the annotations actually moved: the offset minus what was already applied. */
delta: number;
tracks: number;
groups: number;
/** Annotations that had nothing left before frame 0 and were deleted. */
dropped: number;
}

interface SaveAttributeArgs {
delete: string[];
upsert: Attribute[];
Expand Down Expand Up @@ -332,9 +345,13 @@ interface DatasetConfigMutable {
* role are absent.
*/
cameraRoles?: Record<string, CameraRole>;
/** Per-camera start offset in its own frames, for recorders that started at different times. */
cameraFrameOffsets?: Record<string, number>;
/** The part of cameraFrameOffsets already applied to each camera's annotations. */
cameraFrameOffsetsApplied?: Record<string, number>;
error?: string;
}
const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource', 'typeHierarchy', 'taxonomySources', 'cameraRoles'];
const DatasetConfigMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource', 'cameraFrameOffsets', 'cameraFrameOffsetsApplied', 'typeHierarchy', 'taxonomySources', 'cameraRoles'];
/**
* Cross-dataset color/style overrides, reused across every dataset when the
* "shared" color scope is enabled (see clientSettings.typeSettings.colorScope).
Expand Down Expand Up @@ -517,6 +534,13 @@ interface Api {

saveDetections(datasetId: string, args: SaveDetectionsArgs): Promise<unknown>;
saveConfig(datasetId: string, config: DatasetConfigMutable): Promise<unknown>;
/**
* Shift one camera's stored annotations onto its time offset, in persistence.
* Only the part not yet applied moves; the caller reloads the camera afterwards.
*/
applyCameraFrameOffset(
datasetId: string, camera: string, offset: number,
): Promise<CameraFrameOffsetResult>;
saveAttributes(datasetId: string, args: SaveAttributeArgs): Promise<unknown>;
saveAttributeTrackFilters(datasetId: string,
args: SaveAttributeTrackFilterArgs): Promise<unknown>;
Expand Down Expand Up @@ -931,6 +955,7 @@ export type {
PipeMetadata,
Pipelines,
SaveDetectionsArgs,
CameraFrameOffsetResult,
SaveAttributeArgs,
SaveAttributeTrackFilterArgs,
TrainingConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,8 @@ export default defineComponent({
cameraCorrespondences: registration.observations.value,
cameraTransformTypes: registration.transformTypes.value,
cameraRegistrationSource: registration.source.value,
cameraFrameOffsets: registration.frameOffsets.value,
cameraFrameOffsetsApplied: registration.appliedFrameOffsets.value,
});
registration.markSaved();
} finally {
Expand Down
3 changes: 3 additions & 0 deletions client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function apiWithMetadata({
loadFrameMetadata: vi.fn(async () => frameMetadata),
saveDetections: async () => undefined,
saveConfig: async () => undefined,
applyCameraFrameOffset: async () => ({
camera: '', offset: 0, delta: 0, tracks: 0, groups: 0, dropped: 0,
}),
saveAttributes: async () => undefined,
saveAttributeTrackFilters: async () => undefined,
openFromDisk: async () => ({ canceled: true, filePaths: [] }),
Expand Down
1 change: 1 addition & 0 deletions client/dive-common/components/ImportAnnotations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export default defineComponent({
meta.cameraCorrespondences,
meta.cameraTransformTypes,
meta.cameraRegistrationSource,
meta.cameraFrameOffsets,
);
if (priorPair) {
// The panel is open: re-select the imported pair (falling back to
Expand Down
55 changes: 55 additions & 0 deletions client/dive-common/components/MultiCamTools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { defineComponent, h, ref } from 'vue';
import { shallowMount } from '@vue/test-utils';
import MultiCamTools from './MultiCamTools.vue';

const state = vi.hoisted(() => ({
readOnlyMode: false,
offsetEditLock: false,
}));

vi.mock('dive-common/apispec', () => ({
useApi: () => ({ applyCameraFrameOffset: vi.fn() }),
}));

vi.mock('vue-media-annotator/provides', () => ({
useSelectedCamera: () => ref('left'),
useEditingMode: () => ref(false),
useTrackFilters: () => ({ enabledAnnotations: ref([]) }),
useHandler: () => ({ save: vi.fn(), reloadCameraAnnotations: vi.fn() }),
useTime: () => ({ frame: ref(0), frameRate: ref(30) }),
useSelectedTrackId: () => ref(null),
useCameraStore: () => ({ orderedCameraNames: () => ['left', 'right'] }),
useCameraRegistration: () => ({
frameOffsets: ref({ right: 3 }),
appliedFrameOffsets: ref({}),
}),
useDatasetId: () => ref('dataset'),
usePendingSaveCount: () => ref(0),
useReadOnlyMode: () => ref(state.readOnlyMode),
useOffsetEditLock: () => ref(state.offsetEditLock),
}));

function applyButton() {
const Host = defineComponent({ setup: () => () => h(MultiCamTools) });
const wrapper = shallowMount(Host, { stubs: { MultiCamTools: false } });
const button = wrapper.findAll('v-btn').wrappers
.find((b) => b.text().includes('Apply to annotations'));
if (!button) throw new Error('Apply to annotations button not rendered');
return { wrapper, button };
}

it('keeps Apply enabled when editing is paused only by the pending offset', () => {
state.readOnlyMode = true;
state.offsetEditLock = true;
const { wrapper, button } = applyButton();
expect(button.attributes('disabled')).toBeUndefined();
expect(wrapper.text()).toContain('Annotation editing is paused until the offset is applied.');
});

it('disables Apply in a truly read-only view', () => {
state.readOnlyMode = true;
state.offsetEditLock = false;
const { wrapper, button } = applyButton();
expect(button.attributes('disabled')).toBeDefined();
expect(wrapper.text()).not.toContain('Annotation editing is paused');
});
Loading
Loading