Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1b599cb
Add camera-calibration math and persistence foundation
romleiaj Jul 9, 2026
d368f0f
Load external camera calibrations into datasets
romleiaj Jul 9, 2026
1139ff6
Add the Align View: warped display, linked navigation, and mirroring
romleiaj Jul 9, 2026
a6e1e21
Warp imported annotations onto every calibrated camera on request
romleiaj Jul 9, 2026
64177bf
Store the calibration as one file per camera
romleiaj Jul 9, 2026
11a3303
Export the camera calibration and drop the all-pairs calibration.json
romleiaj Jul 10, 2026
d719e1d
Make calibration export per-camera on desktop and web, and add import
romleiaj Jul 10, 2026
64eb1f3
Polish the import/export menus around calibration
romleiaj Jul 10, 2026
c4675b0
Import the calibration per camera pair, confirming replacement
romleiaj Jul 10, 2026
42aa51c
Adopt image-registration naming across UI, API, files, and the wire f…
romleiaj Jul 10, 2026
a6d545d
Let the Reference Camera choice drive image registration
romleiaj Jul 10, 2026
2e38690
lazy loading camera alignment tools only when multicamera
BryonLewis Jul 11, 2026
72dc79a
refactor common to simplify into separate files
BryonLewis Jul 11, 2026
0f907fa
refactor the linked viewer and aligned navigation into their own folder
BryonLewis Jul 11, 2026
0a9a667
LayerManager.vue simplification
BryonLewis Jul 11, 2026
805b05d
folder for aligned camera utilites/tools
BryonLewis Jul 11, 2026
41be842
more type specification for camera alignment models in the server
BryonLewis Jul 11, 2026
7a898bc
client linting
BryonLewis Jul 11, 2026
ed4f8c1
minor fix
BryonLewis Jul 11, 2026
5147d97
Accept untyped per-camera registration files in parent-folder discovery
romleiaj Jul 11, 2026
f5426e9
Warn at desktop multicam import when registration names unknown cameras
romleiaj Jul 11, 2026
dab7e8f
Bring transform import at multicam creation to the web platform
romleiaj Jul 11, 2026
1267936
Discover per-collect registration files in batch multicam import
romleiaj Jul 11, 2026
2a833d9
Keep the Aligned View warp in step with image-enhancement changes
romleiaj Jul 11, 2026
19402f5
Deduplicate registration discovery and warning logic across platforms
romleiaj Jul 12, 2026
b0010bb
Drive the Aligned View warp from imageRevision instead of polling
romleiaj Jul 12, 2026
76a7bb2
Fix the imageRevision warp watch, which referenced a layer out of scope
romleiaj Jul 12, 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
36 changes: 34 additions & 2 deletions client/dive-common/apispec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { Attribute } from 'vue-media-annotator/use/AttributeTypes';
import { CustomStyle } from 'vue-media-annotator/StyleManager';
import { AttributeTrackFilter } from 'vue-media-annotator/AttributeTrackFilterControls';
import { ImageEnhancements } from 'vue-media-annotator/use/useImageEnhancements';
import {
CameraHomographies, CameraCorrespondences, CameraTransformTypes, RegistrationSource,
} from 'vue-media-annotator/alignedView/CameraRegistrationStore';
import type { PercentileStretch } from 'vue-media-annotator/use/useImageEnhancements';

type DatasetType = 'image-sequence' | 'video' | 'multi' | 'large-image';
Expand Down Expand Up @@ -136,6 +139,12 @@ export interface MultiCamImportFolderArgs {
sourceList: Record<string, {
sourcePath: string;
trackFile: string;
/**
* Optional alignment transform file for cameras after the first (desktop
* only): a DIVE registration .json, parsed at import time to seed the
* dataset's saved camera registration.
*/
transformFile?: string;
/** Per-camera media type when cameras differ (e.g. EO JPG + IR TIFF on web). */
type?: 'image-sequence' | 'video' | 'large-image';
}>; // path/track file per camera
Expand Down Expand Up @@ -186,9 +195,14 @@ interface DatasetMetaMutable {
attributes?: Readonly<Record<string, Attribute>>;
attributeTrackFilters?: Readonly<Record<string, AttributeTrackFilter>>;
datasetInfo?: Record<string, unknown>;
cameraHomographies?: CameraHomographies;
cameraCorrespondences?: CameraCorrespondences;
cameraTransformTypes?: CameraTransformTypes;
/** Producer provenance of the camera registration (see RegistrationSource). */
cameraRegistrationSource?: RegistrationSource | null;
error?: string;
}
const DatasetMetaMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo'];
const DatasetMetaMutableKeys = ['attributes', 'confidenceFilters', 'timeFilters', 'imageEnhancements', 'customTypeStyling', 'customGroupStyling', 'attributeTrackFilters', 'datasetInfo', 'cameraHomographies', 'cameraCorrespondences', 'cameraTransformTypes', 'cameraRegistrationSource'];

interface DatasetMeta extends DatasetMetaMutable {
id: Readonly<string>;
Expand Down Expand Up @@ -278,7 +292,7 @@ interface Api {
saveAttributeTrackFilters(datasetId: string,
args: SaveAttributeTrackFilterArgs): Promise<unknown>;
// Non-Endpoint shared functions
openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text' | 'zip', directory?: boolean):
openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text' | 'zip' | 'transform', directory?: boolean):
Promise<{canceled?: boolean; filePaths: string[]; fileList?: File[]; root?: string}>;
/** Desktop: immediate child directory names under a parent folder (multicam subfolder import). */
listImmediateSubfolders?(parentPath: string): Promise<string[]>;
Expand All @@ -294,10 +308,18 @@ interface Api {
): Promise<string>;
/** Desktop: stereoscopic calibration file in a parent folder root. */
findParentFolderCalibrationFile?(parentPath: string): Promise<string | null>;
/**
* Desktop: every DIVE camera-calibration .json (alignment transforms) in a
* parent folder root: per-camera *_registration.json files first, then
* other self-identified candidates.
*/
findParentFolderTransformFiles?(parentPath: string): Promise<string[]>;
/** True when the dataset folder has an attached stereoscopic calibration file. */
hasCalibrationFile?(datasetId: string): Promise<boolean>;
/** Web: stash a calibration File for multicam upload lookup. */
stashCalibrationFile?(key: string, file: File): void;
/** Web: stash a per-camera registration transform File for multicam upload lookup. */
stashTransformFile?(key: string, file: File): void;
getTiles?(itemId: string, projection?: string): Promise<StringKeyObject>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getTileURL?(itemId: string, x: number, y: number, level: number, query: Record<string, any>):
Expand All @@ -315,6 +337,16 @@ interface Api {
saveCalibration?(path: string): Promise<{ savedPath: string; updatedDatasetIds: string[] }>;
/** Desktop: set the stereo camera/calibration file for a single dataset. */
importCalibrationFile?(datasetId: string, path: string): Promise<{ calibration: string }>;
/**
* Merge a DIVE registration .json into an existing multicam dataset's
* saved camera registration. Web reads the provided File; desktop reads
* the path. options.camera keeps only the file's pairs naming that
* camera, replacing that camera's current pairs while other cameras'
* pairs are kept.
*/
importCameraRegistration?(datasetId: string, path: string, file?: File,
options?: { camera?: string }):
Promise<{ cameras: string[]; pairCount: number }>;
/** Desktop: copy the dataset's current camera/calibration file out to destPath. */
exportCalibrationFile?(datasetId: string, destPath: string): Promise<{ exportedPath: string }>;
/** Download/export the dataset's current calibration file (platform-specific). */
Expand Down
162 changes: 160 additions & 2 deletions client/dive-common/components/ImportAnnotations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import { useApi } from 'dive-common/apispec';
import { usePrompt } from 'dive-common/vue-utilities/prompt-service';
import { clientSettings } from 'dive-common/store/settings';
import clearLengthAttributes from 'dive-common/utils/clearLengthAttributes';
import warpAnnotationsAcrossCameras from 'dive-common/utils/warpAnnotationsAcrossCameras';
import { cloneDeep } from 'lodash';
import {
useAnnotationSets, useAnnotationSet, useHandler, useCameraStore, useSelectedCamera,
useAlignedView, useCameraRegistration,
} from 'vue-media-annotator/provides';
import { getResponseError } from 'vue-media-annotator/utils';
import { parentDatasetId } from 'dive-common/compositeDatasetId';

export default defineComponent({
name: 'ImportAnnotations',
Expand Down Expand Up @@ -52,7 +55,20 @@ export default defineComponent({
const { reloadAnnotations, save } = useHandler();
const cameraStore = useCameraStore();
const selectedCamera = useSelectedCamera();
const alignedView = useAlignedView();
const isMulticamDataset = computed(() => cameraStore.camMap.value.size > 1);
// Warping detections onto other cameras requires the whole rig to be
// registered (a native->reference transform for every camera).
const canWarpToAllCameras = computed(
() => isMulticamDataset.value && alignedView.available.value,
);
const warpToAllCameras = ref(false);
const warpToAllCamerasHint = computed(() => {
const progress = alignedView.registrationProgress.value;
return progress
? `${progress.registered}/${progress.total} cameras registered`
: '';
});
const activeCameraName = computed(() => {
if (!isMulticamDataset.value) {
return null;
Expand All @@ -68,6 +84,35 @@ export default defineComponent({
const cameraFileSupported = computed(
() => !!api.importCalibrationFile && props.subType === 'stereo',
);
// Camera registration import (per-camera transform files) is meaningful
// for any multicam dataset.
const cameraRegistration = useCameraRegistration();
const registrationSupported = computed(
() => !!api.importCameraRegistration && isMulticamDataset.value,
);
// One import button per non-reference camera pair, labeled in the
// direction of the mapping -- "Import ir → eo" registers ir onto the
// reference camera (the import dialog's Reference Camera choice,
// published by the viewer), matching the
// <camera>_to_<reference>_registration.json file names -- and colored by
// whether that camera already has a registration (importing onto an
// existing one replaces it, after confirmation).
const registrationImportTargets = computed(() => {
if (!registrationSupported.value) {
return [];
}
const pairKeys = [
...Object.keys(cameraRegistration.homographies.value),
...Object.keys(cameraRegistration.correspondences.value),
];
const cams = [...cameraStore.camMap.value.keys()];
const reference = alignedView.reference.value ?? cams[0];
return cams.filter((camera) => camera !== reference).map((camera) => ({
camera,
label: `Import ${camera} → ${reference}`,
registered: pairKeys.some((key) => key.split('::').includes(camera)),
}));
});
const currentCalibrationName = computed(() => {
if (!props.calibrationFile) return '';
return props.calibrationFile.replace(/^.*[\\/]/, '');
Expand Down Expand Up @@ -141,9 +186,24 @@ export default defineComponent({
}

if (importFile) {
processing.value = false;
await reloadAnnotations();
if (
warpToAllCameras.value
&& canWarpToAllCameras.value
&& activeCameraName.value
&& alignedView.toReference.value
) {
const warped = warpAnnotationsAcrossCameras(
cameraStore,
alignedView.toReference.value,
activeCameraName.value,
);
if (warped.tracks > 0) {
await save();
}
}
}
processing.value = false;
}
} catch (error) {
const text = [getResponseError(error)];
Expand Down Expand Up @@ -182,6 +242,61 @@ export default defineComponent({
});
}
};
const openRegistrationUpload = async (camera: string) => {
if (!api.importCameraRegistration) return;
const target = registrationImportTargets.value.find((entry) => entry.camera === camera);
if (target?.registered) {
const confirmed = await prompt({
title: 'Replace Registration?',
text: `Camera "${camera}" already has a registration. Importing will replace it.`,
positiveButton: 'Replace',
negativeButton: 'Cancel',
confirm: true,
});
if (!confirmed) return;
}
try {
const ret = await openFromDisk('transform');
if (ret.canceled || !ret.filePaths.length) return;
menuOpen.value = false;
processing.value = true;
const result = await api.importCameraRegistration(
props.datasetId,
ret.filePaths[0],
ret.fileList?.[0],
{ camera },
);
// Rehydrate the store from the freshly persisted meta so the Align
// View and mirroring pick up the new transforms immediately.
const meta = await api.loadMetadata(parentDatasetId(props.datasetId));
cameraRegistration.hydrate(
meta.cameraHomographies,
meta.cameraCorrespondences,
meta.cameraTransformTypes,
meta.cameraRegistrationSource,
);
processing.value = false;
const unknown = result.cameras.filter((name) => !cameraStore.camMap.value.has(name));
if (unknown.length) {
await prompt({
title: 'Registration Imported',
text: [
`Imported ${result.pairCount} pair(s), but the file names camera(s) not in this dataset:`,
unknown.join(', '),
'Pair bodies name their own cameras, so these pairs will not resolve until matching cameras exist.',
],
positiveButton: 'OK',
});
}
} catch (error) {
processing.value = false;
prompt({
title: 'Registration Import Failed',
text: [getResponseError(error)],
positiveButton: 'OK',
});
}
};
const applyLastCalibration = async () => {
if (!api.importCalibrationFile || !lastCalibrationPath.value) return;
try {
Expand Down Expand Up @@ -211,10 +326,13 @@ export default defineComponent({
return {
openUpload,
openCalibrationUpload,
openRegistrationUpload,
applyLastCalibration,
showLastCalibrationSuggestion,
lastCalibrationFileName,
cameraFileSupported,
registrationSupported,
registrationImportTargets,
currentCalibrationName,
processing,
menuOpen,
Expand All @@ -225,6 +343,9 @@ export default defineComponent({
currentSet,
isMulticamDataset,
activeCameraName,
canWarpToAllCameras,
warpToAllCameras,
warpToAllCamerasHint,
};
},
});
Expand Down Expand Up @@ -260,7 +381,7 @@ export default defineComponent({
</div>
</v-btn>
</template>
<span> Import Annotation Data </span>
<span> Import Supplementary Data </span>
</v-tooltip>
</template>
<template>
Expand Down Expand Up @@ -353,6 +474,15 @@ export default defineComponent({
label="Overwrite"
@change="additive = !$event"
/>
<v-checkbox
v-if="isMulticamDataset"
v-model="warpToAllCameras"
:disabled="!canWarpToAllCameras"
label="Warp to All"
:hint="warpToAllCamerasHint"
persistent-hint
class="ml-4"
/>
</v-row>
<div v-if="additive">
<div
Expand All @@ -372,6 +502,34 @@ export default defineComponent({
</div>
</v-col>
</v-container>
<template v-if="registrationSupported">
<v-divider />
<v-card-title class="pt-3">
Import Registration
</v-card-title>
<v-card-text class="pb-0">
Merge a per-camera registration .json into this dataset.
</v-card-text>
<v-container>
<v-col>
<v-row
v-for="target in registrationImportTargets"
:key="target.camera"
>
<v-btn
depressed
block
class="mb-2"
:color="target.registered ? 'success' : 'warning'"
:disabled="!datasetId || processing"
@click="openRegistrationUpload(target.camera)"
>
{{ target.label }}
</v-btn>
</v-row>
</v-col>
</v-container>
</template>
<template v-if="cameraFileSupported">
<v-divider />
<v-card-title class="pt-3">
Expand Down
29 changes: 24 additions & 5 deletions client/dive-common/components/ImportMultiCamBatchDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

interface CollectStatus {
state: 'ready' | 'blocked' | 'importing' | 'done' | 'failed';
/** Failure detail, or non-fatal warnings for a completed import. */
message?: string;
}

Expand Down Expand Up @@ -44,7 +45,10 @@ export default defineComponent({
required: true,
},
importCollect: {
type: Function as PropType<(collect: MultiCamBatchCollect, datasetName: string) => Promise<void>>,
type: Function as PropType<(
collect: MultiCamBatchCollect,
datasetName: string,
) => Promise<string[] | undefined>>,
required: true,
},
chooseFolderLabel: {
Expand Down Expand Up @@ -155,8 +159,14 @@ export default defineComponent({
const datasetName = (datasetNames.value[collect.name] || collect.name).trim();
try {
// eslint-disable-next-line no-await-in-loop
await props.importCollect(collect, datasetName);
statuses.value = { ...statuses.value, [collect.name]: { state: 'done' } };
const warnings = await props.importCollect(collect, datasetName);
statuses.value = {
...statuses.value,
[collect.name]: {
state: 'done',
...(warnings?.length ? { message: warnings.join(' ') } : {}),
},
};
} catch (err) {
statuses.value = {
...statuses.value,
Expand Down Expand Up @@ -229,7 +239,9 @@ export default defineComponent({
<p class="grey--text text--lighten-1">
Select a top-level folder whose subfolders are collects; each collect must
contain the same camera subfolders (for example EO, IR, UV) holding that
camera's images. One multicam dataset is created per collect.
camera's images. One multicam dataset is created per collect. DIVE
registration .json files found next to a collect's camera folders are
attached automatically and seed that dataset's camera registration.
</p>
<v-row
no-gutters
Expand Down Expand Up @@ -348,6 +360,12 @@ export default defineComponent({
v-else
class="grey--text"
>none</span>
<div
v-if="item.transformFiles.length"
class="grey--text text-caption"
>
Registration: {{ item.transformFiles.join(', ') }}
</div>
</template>
<template #item.issues="{ item }">
<div
Expand All @@ -366,7 +384,8 @@ export default defineComponent({
</div>
<div
v-if="statuses[item.name] && statuses[item.name].message"
class="error--text text-caption"
class="text-caption"
:class="statuses[item.name].state === 'done' ? 'warning--text' : 'error--text'"
>
{{ statuses[item.name].message }}
</div>
Expand Down
Loading