Skip to content
Open
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
82 changes: 31 additions & 51 deletions client/dive-common/autoRegisterSelection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,86 +3,66 @@ 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', () => {
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', () => {
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([]);
expect(proposeRegistrationFrames({ counts: [3, 3], count: 12 })).toEqual([0, 1, 2]);
});
});
89 changes: 14 additions & 75 deletions client/dive-common/autoRegisterSelection.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,12 @@
/**
* 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.
*
* 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.
*/
/** Even frame spread for auto-register; skew filter because RANSAC can't reject desync. */

export interface ProposalOptions {
/** Per-camera usable frame counts; the proposal spans [0, min(counts)). */
counts: number[];
/**
* Optional per-camera per-frame capture timestamps (epoch seconds;
* undefined entries = unknown). Skew ranking applies only when every
* camera has a timestamp for the frame under consideration.
*/
/** Per-camera per-frame epoch seconds; frames missing any camera's stamp skip the filter. */
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. */
count: number;
/** Seconds; frames with larger inter-camera skew are excluded. */
maxSkewSeconds?: number;
}

Expand All @@ -53,56 +26,22 @@ function frameSkew(
return Math.max(...stamps) - Math.min(...stamps);
}

/**
* Propose candidate frame indices, sorted ascending. Returns at most
* `bins * perBin` 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<number>();
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 so neither end is favoured.
const step = eligible.length / count;
return Array.from({ length: count }, (_, i) => eligible[Math.floor((i + 0.5) * step)]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,7 @@ import {
} from 'vue';
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
* whether a frame has usable dense features, and excluding a frame
* afterwards is a free client-side refit.
*/
/** Launch dialog for auto-register; frames are reviewed after matching, not before. */
export default defineComponent({
name: 'AutoRegisterDialog',
props: {
Expand All @@ -29,48 +22,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,
};
Expand All @@ -88,16 +62,16 @@ export default defineComponent({
<v-card-title>Auto Register Frames</v-card-title>
<v-card-text>
<p class="text-body-2">
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.
</p>
<v-text-field
v-model.number="maxFrames"
label="Registration frames (time bins)"
v-model.number="frames"
label="Registration frames"
type="number"
min="1"
max="50"
Expand All @@ -110,7 +84,7 @@ export default defineComponent({
class="mb-3"
/>
<v-alert
v-if="maxFrames < 5"
v-if="frames < 5"
dense
outlined
type="warning"
Expand All @@ -121,17 +95,6 @@ export default defineComponent({
rig of any setting, so the quality readouts will look their best
exactly when they are least trustworthy.
</v-alert>
<v-text-field
v-model.number="candidatesPerBin"
label="Candidates per bin"
type="number"
min="1"
max="5"
dense
outlined
hide-details
class="mb-3"
/>
<v-text-field
v-model.number="minInliers"
label="Min inliers per frame"
Expand All @@ -143,27 +106,6 @@ export default defineComponent({
hide-details
class="mb-3"
/>
<v-radio-group
v-if="isTriplet"
v-model="pairMode"
dense
hide-details
class="mt-0 mb-3"
>
<template #label>
<span class="text-caption">
Camera pairs ({{ matcherRuns }} matcher runs)
</span>
</template>
<v-radio
value="all"
label="All pairs (adds the triplet consistency check)"
/>
<v-radio
value="star"
label="Star to the reference camera (faster; minimum spanning set)"
/>
</v-radio-group>
<v-checkbox
v-model="replaceExisting"
label="Replace previous auto-registered frames"
Expand Down
Loading
Loading