Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
3554f97
Spread Auto Register frames evenly instead of proposing binned candid…
romleiaj Sep 29, 2026
86acb22
Create large-image tiles in a job instead of on every upload request
romleiaj Sep 29, 2026
a503170
Show file and image counts in the multicam import progress
romleiaj Sep 29, 2026
515aff3
Give Traefik's request read timeout room for slow uploads
romleiaj Sep 29, 2026
a378d45
List the context sidebar panels in a fixed order
romleiaj Sep 30, 2026
4468e99
Trim the Camera Registration panel and name Auto Register's pipeline
romleiaj Sep 30, 2026
f399f01
Drop the star-to-reference pair option from Auto Register
romleiaj Sep 30, 2026
1995313
Add a Delete registration button to the Camera Registration panel
romleiaj Sep 30, 2026
b7cb801
Save and delete camera registration one pair at a time
romleiaj Sep 30, 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
5 changes: 5 additions & 0 deletions .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ COMPOSE_PROFILES=gpu
#RABBITMQ_MANAGEMENT_URL="http://rabbit:15672/"
#RABBITMQ_MANAGEMENT_BROKER_URL_TEMPLATE="amqp://{}:{}@rabbit/default"

# Longest time Traefik waits to receive one whole request, body included.
# Traefik's own default is 60s, which cuts off any upload chunk (64MB by
# default) that a slow link cannot deliver within a minute.
#TRAEFIK_READ_TIMEOUT=600s

# Production data bind paths
#
#DIVE_PUBLIC_DATA=/var/local/public
Expand Down
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
86 changes: 35 additions & 51 deletions client/dive-common/autoRegisterSelection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,86 +3,70 @@ import { describe, expect, it } from 'vitest';
import proposeRegistrationFrames from './autoRegisterSelection';

describe('proposeRegistrationFrames', () => {
it('spreads candidates across every temporal bin (no single-scene bias)', () => {
const frames = proposeRegistrationFrames({
counts: [1200, 1200],
bins: 12,
perBin: 2,
});
expect(frames.length).toBe(24);
// Every 100-frame bin contributes exactly its share: a scene-rich
// stretch can never supply all the candidates.
for (let bin = 0; bin < 12; bin += 1) {
const inBin = frames.filter((f) => f >= bin * 100 && f < (bin + 1) * 100);
expect(inBin.length).toBe(2);
}
expect(frames).toEqual([...frames].sort((a, b) => a - b));
it('spreads frames evenly across the whole dataset', () => {
const frames = proposeRegistrationFrames({ counts: [1200, 1200], count: 12 });
expect(frames).toEqual([50, 150, 250, 350, 450, 550, 650, 750, 850, 950, 1050, 1150]);
});

it('spans only the shortest camera', () => {
const frames = proposeRegistrationFrames({
counts: [1000, 300],
bins: 10,
perBin: 1,
});
const frames = proposeRegistrationFrames({ counts: [1000, 300], count: 10 });
expect(Math.max(...frames)).toBeLessThan(300);
expect(frames.length).toBe(10);
});

it('ranks within a bin by inter-camera timestamp skew', () => {
// Two cameras, 10 frames, one bin: frame 6 is perfectly synced, frame 3
// is close, everything else is badly skewed.
it('spreads rather than clustering when every frame is perfectly synced', () => {
// Exact timestamps give every frame zero skew; the proposal must still
// spread across the dataset, not collapse onto the first frames.
const base = 1_700_000_000;
const camA = Array.from({ length: 10 }, (_, i) => base + i);
const camB = camA.map((t, i) => {
if (i === 6) return t;
if (i === 3) return t + 0.1;
return t + 5;
const stamps = Array.from({ length: 1200 }, (_, i) => base + i);
const frames = proposeRegistrationFrames({
counts: [1200, 1200],
timestamps: [stamps, [...stamps]],
count: 12,
});
expect(frames).toEqual([50, 150, 250, 350, 450, 550, 650, 750, 850, 950, 1050, 1150]);
});

it('excludes frames whose skew exceeds the threshold and spreads over the rest', () => {
// Frames 0-4 are hopelessly out of sync; 5-9 are synced.
const base = 1_700_000_000;
const camA = Array.from({ length: 10 }, (_, i) => base + i);
const camB = camA.map((t, i) => (i < 5 ? t + 5 : t));
const frames = proposeRegistrationFrames({
counts: [10, 10],
timestamps: [camA, camB],
bins: 1,
perBin: 2,
count: 5,
maxSkewSeconds: 0.5,
});
expect(frames).toEqual([3, 6]);
expect(frames).toEqual([5, 6, 7, 8, 9]);
});

it('drops candidates whose skew exceeds the threshold entirely', () => {
it('returns nothing when every frame is out of sync', () => {
const base = 1_700_000_000;
const camA = Array.from({ length: 4 }, (_, i) => base + i);
const camB = camA.map((t) => t + 10); // hopeless sync everywhere
const frames = proposeRegistrationFrames({
counts: [4, 4],
timestamps: [camA, camB],
bins: 1,
perBin: 2,
maxSkewSeconds: 0.5,
});
expect(frames).toEqual([]);
const camB = camA.map((t) => t + 10);
expect(proposeRegistrationFrames({
counts: [4, 4], timestamps: [camA, camB], count: 2, maxSkewSeconds: 0.5,
})).toEqual([]);
});

it('falls back to even spread for frames without timestamps', () => {
it('keeps frames whose skew is unknowable', () => {
const frames = proposeRegistrationFrames({
counts: [100, 100],
timestamps: [
Array.from({ length: 100 }, () => undefined),
Array.from({ length: 100 }, () => undefined),
],
bins: 2,
perBin: 2,
count: 4,
});
expect(frames.length).toBe(4);
expect(frames.filter((f) => f < 50).length).toBe(2);
expect(frames.filter((f) => f >= 50).length).toBe(2);
expect(frames).toEqual([12, 37, 62, 87]);
});

it('handles degenerate inputs', () => {
expect(proposeRegistrationFrames({ counts: [0, 10], bins: 5, perBin: 2 })).toEqual([]);
expect(proposeRegistrationFrames({ counts: [], bins: 5, perBin: 2 })).toEqual([]);
// More bins than frames: every frame proposed once.
const tiny = proposeRegistrationFrames({ counts: [3, 3], bins: 12, perBin: 2 });
expect(tiny).toEqual([0, 1, 2]);
expect(proposeRegistrationFrames({ counts: [0, 10], count: 5 })).toEqual([]);
expect(proposeRegistrationFrames({ counts: [], count: 5 })).toEqual([]);
expect(proposeRegistrationFrames({ counts: [10, 10], count: 0 })).toEqual([]);
// More frames requested than exist: every frame proposed once.
expect(proposeRegistrationFrames({ counts: [3, 3], count: 12 })).toEqual([0, 1, 2]);
});
});
Loading
Loading