Skip to content
Draft
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
34 changes: 32 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/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,6 +308,12 @@ 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. */
Expand All @@ -315,6 +335,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!--
Optional per-camera transform/registration file picker with clear and open
actions. Shown for cameras after the first; the desktop backend parses the
file at import time and seeds the dataset's saved camera registration (the
same registration the in-app panel edits and the Align button applies).
Accepts a DIVE registration .json.
-->
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
props: {
transformFile: String,
cameraName: String,
},
});
</script>

<template>
<v-row
class="align-center"
no-gutters
>
<v-text-field
label="Transform file (.json)"
:placeholder="`Transform mapping the first camera to ${cameraName}`"
hint="Optional: DIVE registration .json"
persistent-hint
outlined
dense
clearable
:value="transformFile"
class="mr-3"
@click:clear="$emit('clear')"
/>
<v-btn
color="primary"
@click="$emit('open')"
>
Open
<v-icon class="ml-2">
mdi-matrix
</v-icon>
</v-btn>
</v-row>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
/** Offer per-camera registration .json transform pickers (desktop multicam only). */
enableTransformImport: {
type: Boolean,
default: false,
},
registerSubfolderCameras: {
type: Function as PropType<(assignments: {
cameraName: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,13 @@ export default defineComponent({
dense
class="mb-3"
>
Choose which camera to use as the default display when viewing the dataset.
Choose which camera to use as the reference camera for image registration.
</v-alert>
<v-radio-group
v-if="showDefaultDisplay"
:key="displayKeysKey"
v-model="defaultDisplay"
label="Default Display"
label="Reference Camera"
>
<v-radio
v-for="cameraKey in displayKeys"
Expand Down
Loading
Loading