diff --git a/.env.default b/.env.default index 94cbf7921..105a57d57 100644 --- a/.env.default +++ b/.env.default @@ -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 diff --git a/client/dive-common/alignedTimeline.spec.ts b/client/dive-common/alignedTimeline.spec.ts index d0d0520fb..821c9b09e 100644 --- a/client/dive-common/alignedTimeline.spec.ts +++ b/client/dive-common/alignedTimeline.spec.ts @@ -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 { @@ -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 }); + }); +}); diff --git a/client/dive-common/alignedTimeline.ts b/client/dive-common/alignedTimeline.ts index 750566003..08f3d4934 100644 --- a/client/dive-common/alignedTimeline.ts +++ b/client/dive-common/alignedTimeline.ts @@ -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; + +/** + * 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, + 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 }; +} diff --git a/client/dive-common/apispec.ts b/client/dive-common/apispec.ts index 977006bdd..4b4b79b0c 100644 --- a/client/dive-common/apispec.ts +++ b/client/dive-common/apispec.ts @@ -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[]; @@ -332,9 +345,13 @@ interface DatasetConfigMutable { * role are absent. */ cameraRoles?: Record; + /** Per-camera start offset in its own frames, for recorders that started at different times. */ + cameraFrameOffsets?: Record; + /** The part of cameraFrameOffsets already applied to each camera's annotations. */ + cameraFrameOffsetsApplied?: Record; 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). @@ -517,6 +534,13 @@ interface Api { saveDetections(datasetId: string, args: SaveDetectionsArgs): Promise; saveConfig(datasetId: string, config: DatasetConfigMutable): Promise; + /** + * 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; saveAttributes(datasetId: string, args: SaveAttributeArgs): Promise; saveAttributeTrackFilters(datasetId: string, args: SaveAttributeTrackFilterArgs): Promise; @@ -931,6 +955,7 @@ export type { PipeMetadata, Pipelines, SaveDetectionsArgs, + CameraFrameOffsetResult, SaveAttributeArgs, SaveAttributeTrackFilterArgs, TrainingConfig, diff --git a/client/dive-common/autoRegisterSelection.spec.ts b/client/dive-common/autoRegisterSelection.spec.ts index d2f532e50..dea939144 100644 --- a/client/dive-common/autoRegisterSelection.spec.ts +++ b/client/dive-common/autoRegisterSelection.spec.ts @@ -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]); }); }); diff --git a/client/dive-common/autoRegisterSelection.ts b/client/dive-common/autoRegisterSelection.ts index 1d93124d7..4209be3e2 100644 --- a/client/dive-common/autoRegisterSelection.ts +++ b/client/dive-common/autoRegisterSelection.ts @@ -1,23 +1,15 @@ /** - * Candidate-frame proposal for the auto-register pipeline (stage 0 of the - * selection contract): DIVE picks for diversity and synchronization, VIAME - * picks for image quality. + * Frame selection for the auto-register pipeline: `count` frames spread + * evenly across the dataset. VIAME matches every frame it is sent, and the + * matcher's own per-frame gates (min matches, min inliers, inlier ratio) + * decide which frames contribute to each pair's pooled fit. * - * Stratified, not "evenly spread" and not "most featureful": the flight is - * divided into `bins` equal time bins and `perBin` candidates are proposed - * within each, so temporal spread is guaranteed structurally -- a single - * scene-rich stretch can never supply every frame, which would reconstitute - * exactly the single-scene bias the multi-pair restructure exists to remove. - * The pipeline then keeps the best candidate per bin by image quality - * (`max_frames` is the bin count), so oversampling here is deliberate. - * - * Within a bin, candidates rank by inter-camera timestamp skew when every - * camera carries per-frame timestamps: on a survey aircraft at ~100 kt, - * 100 ms of desync is ~5 m of ground motion baked straight into the "ground - * truth" points, and RANSAC cannot reject it because it is consistent - * within the frame. Without timestamps (positional alignment), candidates - * spread evenly within the bin and skew is unknowable -- no threshold - * applies. + * Inter-camera timestamp skew is the one filter applied here: on a survey + * aircraft at ~100 kt, 100 ms of desync is ~5 m of ground motion baked + * straight into the "ground truth" points, and RANSAC cannot reject it + * because it is consistent within the frame. Frames whose skew exceeds the + * threshold are excluded before spreading; frames whose skew is unknowable + * (a camera without a timestamp) stay eligible. */ export interface ProposalOptions { @@ -25,15 +17,13 @@ export interface ProposalOptions { counts: number[]; /** * Optional per-camera per-frame capture timestamps (epoch seconds; - * undefined entries = unknown). Skew ranking applies only when every + * undefined entries = unknown). The skew filter applies only when every * camera has a timestamp for the frame under consideration. */ timestamps?: (number | undefined)[][]; - /** Number of temporal bins (the pipeline's max_frames budget). */ - bins: number; - /** Candidates proposed per bin. */ - perBin: number; - /** Candidates with a larger inter-camera skew (seconds) are dropped. */ + /** Number of frames to propose. */ + count: number; + /** Frames with a larger inter-camera skew (seconds) are excluded. */ maxSkewSeconds?: number; } @@ -54,55 +44,26 @@ function frameSkew( } /** - * Propose candidate frame indices, sorted ascending. Returns at most - * `bins * perBin` frames; short datasets simply yield fewer. + * Propose frame indices, sorted ascending. Returns at most `count` frames; + * short datasets simply yield fewer. */ export default function proposeRegistrationFrames(options: ProposalOptions): number[] { const usable = Math.min(...options.counts); - if (!Number.isFinite(usable) || usable <= 0 || options.bins <= 0 || options.perBin <= 0) { + if (!Number.isFinite(usable) || usable <= 0 || options.count <= 0) { return []; } - const bins = Math.min(options.bins, usable); const { timestamps } = options; const maxSkew = options.maxSkewSeconds ?? 0.5; - const chosen = new Set(); - for (let bin = 0; bin < bins; bin += 1) { - const start = Math.floor((bin * usable) / bins); - const end = Math.floor(((bin + 1) * usable) / bins); // exclusive - // bins <= usable, so every bin spans at least one frame. - const size = end - start; - const perBin = Math.min(options.perBin, size); - if (timestamps && timestamps.length) { - // Rank the whole bin by skew; unknowable-skew frames rank after - // measured ones (evenly spread among themselves), over-threshold - // frames are dropped outright. - const measured: { frame: number; skew: number }[] = []; - const unknowable: number[] = []; - for (let frame = start; frame < end; frame += 1) { - const skew = frameSkew(timestamps, frame); - if (skew === null) { - unknowable.push(frame); - } else if (skew <= maxSkew) { - measured.push({ frame, skew }); - } - } - measured.sort((a, b) => a.skew - b.skew || a.frame - b.frame); - measured.slice(0, perBin).forEach(({ frame }) => chosen.add(frame)); - let still = perBin - Math.min(measured.length, perBin); - if (still > 0 && unknowable.length) { - const step = unknowable.length / still; - for (let i = 0; i < still; i += 1) { - chosen.add(unknowable[Math.floor(i * step)]); - } - still = 0; - } - } else { - // No timeline: spread evenly within the bin. - const step = size / perBin; - for (let i = 0; i < perBin; i += 1) { - chosen.add(start + Math.floor(i * step + step / 2)); - } + const eligible: number[] = []; + for (let frame = 0; frame < usable; frame += 1) { + const skew = timestamps?.length ? frameSkew(timestamps, frame) : null; + if (skew === null || skew <= maxSkew) { + eligible.push(frame); } } - return [...chosen].sort((a, b) => a - b); + const count = Math.min(options.count, eligible.length); + // Centre each pick in its share of the eligible frames, so neither end of + // the dataset is favoured. + const step = eligible.length / count; + return Array.from({ length: count }, (_, i) => eligible[Math.floor((i + 0.5) * step)]); } diff --git a/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue b/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue index 47f141e01..e0c95d159 100644 --- a/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue +++ b/client/dive-common/components/CameraRegistration/AutoRegisterDialog.vue @@ -6,9 +6,9 @@ import type { AutoRegisterRunOptions } from 'dive-common/use/useAutoRegisterJob' /** * Launch dialog for the auto-register pipeline: only the knobs worth - * changing. The candidate spread itself is proposed automatically - * (stratified time bins, ranked by camera sync where timestamps exist) and - * reviewed after the matching -- the matcher is the best measurement of + * changing. Frames are spread evenly across the dataset (skipping captures + * whose cameras are out of sync) and reviewed after the matching -- the + * matcher is the best measurement of * whether a frame has usable dense features, and excluding a frame * afterwards is a free client-side refit. */ @@ -29,48 +29,29 @@ export default defineComponent({ }, }, setup(props, { emit }) { - const maxFrames = ref(12); - const candidatesPerBin = ref(2); + const frames = ref(12); const minInliers = ref(30); - const pairMode = ref<'all' | 'star'>('all'); const replaceExisting = ref(false); const isTriplet = computed(() => props.cameraCount >= 3); - const candidateTotal = computed(() => maxFrames.value * candidatesPerBin.value); - const matcherRuns = computed(() => { - let pairCount = 1; - if (isTriplet.value) { - pairCount = pairMode.value === 'all' ? 3 : 2; - } - return maxFrames.value * pairCount; - }); function close() { emit('input', false); } function run() { const options: AutoRegisterRunOptions = { - maxFrames: maxFrames.value, - candidatesPerBin: candidatesPerBin.value, + frames: frames.value, minInliers: minInliers.value, replaceExisting: replaceExisting.value, - // A star to the reference (inputs 1-2, 1-3) is the minimum a - // spanning tree needs; all-pairs additionally buys the - // loop-closure consistency check. - ...(isTriplet.value && pairMode.value === 'star' ? { pairs: '1-2,1-3' } : {}), }; emit('run', options); close(); } return { - maxFrames, - candidatesPerBin, + frames, minInliers, - pairMode, replaceExisting, isTriplet, - candidateTotal, - matcherRuns, close, run, }; @@ -88,16 +69,16 @@ export default defineComponent({ Auto Register Frames

- Proposes {{ candidateTotal }} candidate frames spread across the - whole {{ isTriplet ? 'rig' : 'sequence' }} (ranked by camera sync - where timestamps exist), matches the best one per time bin, and - pools one transform per camera pair over every kept frame. Results + Matches {{ frames }} frames spread evenly across the whole + {{ isTriplet ? 'rig' : 'sequence' }} (skipping captures whose cameras + are out of sync) and pools one transform per camera pair over every + frame that passes the matcher's checks. Results merge with existing points frame by frame; review and exclude frames afterwards from the panel's frame list.

- - - - - - frameRows.value.filter((row) => row.skipped).length); const listedRows = computed(() => frameRows.value.filter((row) => !row.skipped)); @@ -308,8 +307,7 @@ export default defineComponent({ } function runQueuedFrames() { autoRegisterJob?.run({ - maxFrames: queuedSlots.value.length, - candidatesPerBin: 1, + frames: queuedSlots.value.length, slots: [...queuedSlots.value], }); queuedSlots.value = []; @@ -418,8 +416,6 @@ export default defineComponent({ const canClearLast = computed( () => registration.pendingPoint.value !== null || correspondences.value.length > 0, ); - /** How many more correspondence pairs are needed before the transform can be fit. */ - const remainingPoints = computed(() => Math.max(0, minPoints.value - correspondences.value.length)); /** The active pair has a usable transform: enough points to fit one, or one loaded from a file. */ const hasTransform = computed(() => canFit.value || Boolean(activeKey.value && registration.homographies.value[activeKey.value])); @@ -454,24 +450,30 @@ export default defineComponent({ return { icon: 'mdi-file-check', color: 'success', - text: 'Transform loaded from a registration file', + text: 'Transform loaded from file', + hint: 'Linked pan/zoom and the overlay warp use the loaded transform. Fitting ' + + `${minPoints.value} or more picked point pairs replaces it.`, }; } if (canFit.value) { const stats = pairStats.value; const frames = stats ? stats.frameCount : 0; - const rms = stats && stats.rmsPx !== null ? ` — rms ${stats.rmsPx.toFixed(1)} px` : ''; + const rms = stats && stats.rmsPx !== null ? ` · rms ${stats.rmsPx.toFixed(1)} px` : ''; return { icon: 'mdi-check-circle', color: fitQualityColor.value, - text: `Transform fit from ${frames} frame${frames === 1 ? '' : 's'} / ` - + `${correspondences.value.length} point pairs${rms}`, + text: `Fit: ${frames} frame${frames === 1 ? '' : 's'} · ` + + `${correspondences.value.length} pairs${rms}`, + hint: 'Green with 12 or more point pairs; yellow when the transform can be fit ' + + 'but has few points to support it.', }; } return { icon: 'mdi-progress-clock', color: 'grey', - text: 'No transform yet: pick points below, or import a registration (Import menu)', + text: 'No transform yet', + hint: 'Auto Register, pick points with Edit points, or import a registration ' + + '(Import menu).', }; }); /** @@ -594,26 +596,56 @@ export default defineComponent({ * didn't touch are rewritten byte-identical, which isn't an overwrite * worth warning about. */ + /** The rig reference the persistence layer groups per-camera files against. */ + const fileReference = computed( + () => alignedView.reference.value ?? cameras.value[0] ?? null, + ); + /** "rgb ↔ ir": the pair Save and Delete act on. */ + const pairLabel = computed(() => `${camLeft.value ?? 'A'} ↔ ${camRight.value ?? 'B'}`); + /** The active pair differs from what is saved (other pairs don't count). */ + const pairDirty = computed( + () => (activeKey.value ? registration.pairDirty(activeKey.value) : false), + ); + /** Some other pair (or a frame offset) has unsaved changes Save won't write. */ + const otherPairsDirty = computed( + () => (activeKey.value ? registration.dirtyOutsidePair(activeKey.value) : false), + ); + /** + * The per-camera registration file that holds the active pair -- what + * Save rewrites and Delete removes it from. Looked up in the state a save + * would write, then in the saved baseline (a deleted pair is only there). + */ + const pairFileName = computed(() => { + const key = activeKey.value; + if (!key) { + return null; + } + const [left, right] = key.split('::'); + const holdsPair = (file: ReturnType[number]) => ( + file.body.pairs.some((pair) => pair.left === left && pair.right === right)); + const reference = fileReference.value; + const next = buildPerCameraRegistrationFiles(registration.valuesSavingPair(key), reference); + const saved = buildPerCameraRegistrationFiles(registration.savedRegistrationValues(), reference); + return (next.find(holdsPair) ?? saved.find(holdsPair))?.name ?? null; + }); + async function save() { + const key = activeKey.value; + if (!key) { + return; + } // Fit before diffing so the comparison reflects what will be written. registration.maybeFitActivePair(); - // Group the saved baseline and the current state into per-camera files - // exactly the way the persistence layer writes them, against the same - // reference camera the backend uses (the dataset's Reference Camera - // choice, published by the viewer). - const reference = alignedView.reference.value ?? cameras.value[0] ?? null; + // Group the saved baseline and the state this save writes into + // per-camera files exactly the way the persistence layer does. Only + // the active pair changes, so only its file can differ. const savedFiles = buildPerCameraRegistrationFiles( registration.savedRegistrationValues(), - reference, + fileReference.value, ); const nextFiles = new Map(buildPerCameraRegistrationFiles( - { - homographies: registration.homographies.value, - observations: registration.observations.value, - transformTypes: registration.transformTypes.value, - source: registration.source.value, - }, - reference, + registration.valuesSavingPair(key), + fileReference.value, ).map((file) => [file.name, file])); // Existing files this save replaces with different content (or removes, // for a cleared pair) -- the actual overwrites. @@ -641,20 +673,79 @@ export default defineComponent({ return; } } + await persistPair(key); + } + + /** + * Write the saved registration with only this pair changed. The backend + * rewrites the per-camera files from what it is sent, so sending the + * saved baseline for every other pair leaves their files as they were + * and keeps their unsaved edits pending in the store. + */ + async function persistPair(key: string) { + const values = registration.valuesSavingPair(key); saving.value = true; try { await saveConfig(datasetId.value, { - cameraHomographies: registration.homographies.value, - cameraCorrespondences: registration.observations.value, - cameraTransformTypes: registration.transformTypes.value, - cameraRegistrationSource: registration.source.value, + cameraHomographies: values.homographies, + cameraCorrespondences: values.observations, + cameraTransformTypes: values.transformTypes, + cameraRegistrationSource: values.source, + cameraFrameOffsets: values.frameOffsets, + cameraFrameOffsetsApplied: values.appliedFrameOffsets, }); - registration.markSaved(); + registration.markPairSaved(key); } finally { saving.value = false; } } + /** The active pair has something to delete, saved or not. */ + const canDeletePair = computed(() => { + const key = activeKey.value; + if (!key) { + return false; + } + const saved = registration.savedRegistrationValues(); + return [ + registration.homographies.value, registration.observations.value, + registration.transformTypes.value, + saved.homographies, saved.observations, saved.transformTypes, + ].some((map) => key in map); + }); + + /** + * Delete the active pair's registration, saved and unsaved, and persist + * that right away so it leaves its per-camera file (and the file goes if + * nothing else is in it). Other pairs are untouched. When no transform is + * left anywhere the aligned view is unavailable; switch it off so it + * doesn't come back on by itself after a later registration. + */ + async function deletePair() { + const key = activeKey.value; + if (!key) { + return; + } + const file = pairFileName.value; + const confirmed = await prompt({ + title: `Delete ${pairLabel.value} Registration?`, + text: `Remove the ${pairLabel.value} points and transform` + + `${file ? `, and its entry in ${file}` : ''}? Other camera pairs are ` + + 'not affected. This cannot be undone.', + positiveButton: 'Delete', + negativeButton: 'Cancel', + confirm: true, + }); + if (!confirmed) { + return; + } + registration.deletePair(key); + if (!Object.keys(registration.homographies.value).length) { + alignedView.setEnabled(false); + } + await persistPair(key); + } + /** * Auto Register Frames: launch the align_cameras pipeline over a * stratified spread of candidate frames (one job registers the whole @@ -669,6 +760,14 @@ export default defineComponent({ const autoRegisterStatus = computed(() => autoRegisterJob?.status.value ?? null); const autoRegisterDialog = ref(false); + /** Hover text for the Auto Register button: what it will actually run. */ + const autoRegisterTooltip = computed(() => { + const pipe = autoRegisterJob?.pipe.value; + return pipe + ? `Auto Register: runs ${pipe.name} (${pipe.pipe})` + : 'Auto Register'; + }); + function openAutoRegisterDialog() { autoRegisterDialog.value = true; } @@ -797,10 +896,8 @@ export default defineComponent({ transformType, transformTypeItems: TRANSFORM_TYPES, minPoints, - remainingPoints, alignmentModeItems, hasTransform, - hasLoadedTransform, refinedFromSource, canClearPair, canClearLast, @@ -809,17 +906,23 @@ export default defineComponent({ transformStatus, setLinkedNav, linkedNav: registration.linkedNav, - dirty: registration.dirty, + pairLabel, + pairDirty, + otherPairsDirty, + pairFileName, saving, sourceReadout, setTransformType, setAlignmentMode, save, + canDeletePair, + deletePair, autoRegisterAvailable, autoRegistering, autoRegisterError, autoRegisterStatus, autoRegisterDialog, + autoRegisterTooltip, openAutoRegisterDialog, runAutoRegister, loopClosure, @@ -836,146 +939,169 @@ export default defineComponent({ ]" class="mx-4" > - - Register cameras by importing a registration file (Import menu) or by - picking corresponding points between two cameras. - - - - - Source: {{ sourceReadout }} - - - - This pair has been refined in-app since the source registration was - produced. - - - - +
-
- {{ alignmentSummary.icon }} + {{ cam.status === 'reference' ? 'mdi-star' + : (cam.status === 'resolved' ? 'mdi-check' : 'mdi-alert-outline') }} - {{ alignmentSummary.text }} -
-
- + {{ cam.name }} + + + + @@ -1355,55 +1422,80 @@ export default defineComponent({ -

Overlay Warp

- - - + Overlay + - {{ item.text }} - - - - Warp Opacity + + {{ item.text }} + + +
- - + + + Writes {{ pairFileName }} + + + Other camera pairs have unsaved changes; select a pair to save it. + - {{ dirty ? 'Save registration' : 'Registration saved' }} + Delete {{ pairLabel }}
diff --git a/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts b/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts index 76bedc8c5..93737a126 100644 --- a/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts +++ b/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts @@ -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: [] }), diff --git a/client/dive-common/components/ImportAnnotations.vue b/client/dive-common/components/ImportAnnotations.vue index 2b34a4082..4f8c9bf2f 100644 --- a/client/dive-common/components/ImportAnnotations.vue +++ b/client/dive-common/components/ImportAnnotations.vue @@ -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 diff --git a/client/dive-common/components/MultiCamTools.spec.ts b/client/dive-common/components/MultiCamTools.spec.ts new file mode 100644 index 000000000..a6ca9ccdc --- /dev/null +++ b/client/dive-common/components/MultiCamTools.spec.ts @@ -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'); +}); diff --git a/client/dive-common/components/MultiCamTools.vue b/client/dive-common/components/MultiCamTools.vue index dec6e513c..3e1e9a6ca 100644 --- a/client/dive-common/components/MultiCamTools.vue +++ b/client/dive-common/components/MultiCamTools.vue @@ -1,9 +1,14 @@