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
730 changes: 730 additions & 0 deletions client/dive-common/components/CameraRegistration/RegistrationTools.vue

Large diffs are not rendered by default.

46 changes: 37 additions & 9 deletions client/dive-common/components/ImportAnnotations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -268,26 +268,54 @@ export default defineComponent({
);
// Rehydrate the store from the freshly persisted meta so the Align
// View and mirroring pick up the new transforms immediately.
// Remember whether the Camera Registration panel had a pair active:
// hydrate() clears it, and the camera list doesn't change on import,
// so nothing else would re-establish the panel's state.
const priorPair = cameraRegistration.activePair.value;
const meta = await api.loadMetadata(parentDatasetId(props.datasetId));
cameraRegistration.hydrate(
meta.cameraHomographies,
meta.cameraCorrespondences,
meta.cameraTransformTypes,
meta.cameraRegistrationSource,
);
if (priorPair) {
// The panel is open: re-select the imported pair (falling back to
// the prior one) so the loaded state is immediately visible, in
// review posture -- picking stays off for a file-loaded transform.
const pairKeys = [
...Object.keys(cameraRegistration.homographies.value),
...Object.keys(cameraRegistration.correspondences.value),
];
// Pair bodies name their own cameras, so a pair can reference one
// missing from this dataset; only select a pair the panel can show.
const importedKey = pairKeys.find((key) => key.split('::').includes(camera)
&& key.split('::').every((name) => cameraStore.camMap.value.has(name)));
const [left, right] = importedKey
? importedKey.split('::')
: [priorPair.camA, priorPair.camB];
cameraRegistration.setActivePair(left, right);
cameraRegistration.pickingEnabled.value = cameraRegistration.pickingDefaultFor(
cameraRegistration.activePairKey(),
);
}
processing.value = false;
const unknown = result.cameras.filter((name) => !cameraStore.camMap.value.has(name));
const text = [
`Imported registration for camera "${camera}".`,
'Registration can be validated in the Camera Registration menu.',
];
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',
});
text.push(
`Warning: the file also names camera(s) not in this dataset: ${unknown.join(', ')}.`,
'Pair bodies name their own cameras, so those pairs will not resolve until matching cameras exist.',
);
}
await prompt({
title: 'Registration Imported',
text,
positiveButton: 'OK',
});
} catch (error) {
processing.value = false;
prompt({
Expand Down
122 changes: 116 additions & 6 deletions client/dive-common/components/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
LargeImageAnnotator,
LayerManager,
useMediaController,
useRegistrationNavigation,
useAlignedNavigation,
TrackList,
FilterList,
Expand Down Expand Up @@ -87,6 +88,7 @@ import context from 'dive-common/store/context';
import { MarkChangesPendingFilter } from 'vue-media-annotator/BaseFilterControls';
import GroupSidebarVue from './GroupSidebar.vue';
import MultiCamToolsVue from './MultiCamTools.vue';
import RegistrationToolsVue from './CameraRegistration/RegistrationTools.vue';
import MultiCamToolbar from './MultiCamToolbar.vue';
import PrimaryAttributeTrackFilter from './PrimaryAttributeTrackFilter.vue';
import UserSettingsDialog from './UserSettingsDialog.vue';
Expand Down Expand Up @@ -321,6 +323,29 @@ export default defineComponent({
});
const showUserSettingsDialog = ref(false);

// When the Camera Registration panel opens, minimize the workspace chrome to
// give the picking view more room: collapse the left type-filter sidebar and
// the bottom detections graph. This is a soft default -- the normal sidebar
// and timeline toggles still work while registering, so the user can bring
// either back -- and whatever layout they had before is restored on close.
const registrationActive = computed(() => context.state.active === RegistrationToolsVue.name);
let preRegistrationSidebarMode: 'left' | 'bottom' | 'collapsed' | null = null;
let preRegistrationControlsCollapsed = false;
watch(registrationActive, (active) => {
if (active) {
preRegistrationSidebarMode = sidebarMode.value;
preRegistrationControlsCollapsed = controlsCollapsed.value;
if (sidebarMode.value === 'left') {
sidebarMode.value = 'collapsed';
}
controlsCollapsed.value = true;
} else if (preRegistrationSidebarMode !== null) {
sidebarMode.value = preRegistrationSidebarMode;
controlsCollapsed.value = preRegistrationControlsCollapsed;
preRegistrationSidebarMode = null;
}
});

watch(sidebarMode, (mode) => {
if (mode === 'left' || mode === 'bottom') {
clientSettings.layoutSettings.sidebarPosition = mode;
Expand Down Expand Up @@ -409,8 +434,10 @@ export default defineComponent({
* review. Reference camera = the Reference Camera chosen at import
* (stored as defaultDisplay), falling back to the first camera in
* display order. Transforms come from the registration store's pair
* homographies (loaded from a registration file or the dataset's saved
* meta), composed through the pair graph.
* homographies (picked in-app via the Camera Registration panel, or loaded
* from a registration file or the dataset's saved meta), composed
* through the pair graph -- the single registration the panel edits and
* saves is exactly what the Align button applies.
*
* Store instances are always created (provideAnnotator runs before
* loadData resolves), but watches, aligned navigation, and metadata
Expand Down Expand Up @@ -446,6 +473,16 @@ export default defineComponent({
selectedCamera,
setResetZoomOverride,
});
// The Camera Registration pair link maps through the homography for
// UNWARPED panes, so it needs the aligned view state to stand down
// while displays are warped into reference space.
useRegistrationNavigation(aggregateController, cameraRegistration, alignedView);
// Registration point picking records raw native-space clicks and
// renders its own aligned preview; suspend the general warp while it
// is active so picks are never taken against a warped display.
watch(cameraRegistration.pickingEnabled, (picking) => {
alignedView.setSuspended(picking);
}, { immediate: true });
// Publish the reference even while unresolved so UI outside the viewer
// core (e.g. the import menu's per-pair buttons) can name it.
watch([alignedResolution, referenceCamera], ([resolution, reference]) => {
Expand Down Expand Up @@ -521,6 +558,34 @@ export default defineComponent({
}
return `Align View${mixedNote}`;
});
// The aligned view is suspended while picking, so the button reads as
// unavailable rather than accepting a toggle that has no visible effect.
const registrationPickingEnabled = computed(() => cameraRegistration.pickingEnabled.value);
/**
* Camera panes currently displayed. While the Camera Registration panel is
* open with an active pair on a 3+ camera dataset, only the pair's two
* panes show, so the left/right alignment flow reads without unrelated
* panes in between (regardless of whether Pick points is toggled on).
* Panes are hidden (v-show), not unmounted, so their viewers keep state.
*/
const displayedCameras = computed(() => {
const pair = cameraRegistration.activePair.value;
if (registrationActive.value && pair) {
const pairCameras = multiCamList.value.filter(
(camera) => camera === pair.camA || camera === pair.camB,
);
if (pairCameras.length === 2) {
return pairCameras;
}
}
return multiCamList.value;
});
watch(displayedCameras, async () => {
// Hidden/shown siblings change the remaining panes' sizes; resize the
// geojs maps once the DOM has settled.
await nextTick();
handleResize();
});
// This context for removal
const removeGroups = (id: AnnotationId) => {
cameraStore.removeGroups(id);
Expand Down Expand Up @@ -1120,15 +1185,42 @@ export default defineComponent({
});

// Navigation Guards used by parent component
/**
* Unsaved work the exit/navigation guards protect: pending annotation
* saves, plus Camera Registration panel edits, which track their own
* dirty state (they persist through dataset meta, outside the
* annotation save path that pendingSaveCount counts).
*/
const hasUnsavedChanges = computed(
() => pendingSaveCount.value > 0 || cameraRegistration.dirty.value,
);
/**
* Persist unsaved Camera Registration panel edits -- the same write as
* the panel's own Save button -- so the desktop close guard's "Save"
* choice covers them too. No-op while the registration is clean.
*/
async function saveRegistration() {
if (!cameraRegistration.dirty.value) {
return;
}
cameraRegistration.maybeFitActivePair();
await saveMetadata(datasetId.value, {
cameraHomographies: cameraRegistration.homographies.value,
cameraCorrespondences: cameraRegistration.correspondences.value,
cameraTransformTypes: cameraRegistration.transformTypes.value,
cameraRegistrationSource: cameraRegistration.source.value,
});
cameraRegistration.markSaved();
}
async function warnBrowserExit(event: BeforeUnloadEvent) {
if (pendingSaveCount.value === 0) return;
if (!hasUnsavedChanges.value) return;
event.preventDefault();
// eslint-disable-next-line no-param-reassign
event.returnValue = '';
}
async function navigateAwayGuard(): Promise<boolean> {
let result = true;
if (pendingSaveCount.value > 0) {
if (hasUnsavedChanges.value) {
result = await prompt({
title: 'Save Items',
text: 'There is unsaved data, would you like to continue or cancel and save?',
Expand Down Expand Up @@ -1547,11 +1639,19 @@ export default defineComponent({
component: MultiCamToolsVue,
description: 'Multi Camera Tools',
});
context.register({
component: RegistrationToolsVue,
description: 'Camera Registration',
});
} else {
context.unregister({
component: MultiCamToolsVue,
description: 'Multi Camera Tools',
});
context.unregister({
component: RegistrationToolsVue,
description: 'Camera Registration',
});
context.register({
description: 'Group Manager',
component: GroupSidebarVue,
Expand Down Expand Up @@ -1965,6 +2065,8 @@ export default defineComponent({
alignedViewAvailable,
alignedViewEnabled,
alignedViewTooltip,
registrationPickingEnabled,
displayedCameras,
toggleAlignedView,
seekToFrame,
resetAggregateZoom,
Expand All @@ -1985,6 +2087,8 @@ export default defineComponent({
// For Navigation Guarding
navigateAwayGuard,
warnBrowserExit,
hasUnsavedChanges,
saveRegistration,
reloadAnnotations,
// Annotation Sets,
sets,
Expand Down Expand Up @@ -2169,7 +2273,7 @@ export default defineComponent({
small
class="mx-1"
:color="alignedViewEnabled ? 'primary' : 'default'"
:disabled="!alignedViewAvailable"
:disabled="!alignedViewAvailable || registrationPickingEnabled"
@click="toggleAlignedView"
>
<v-icon>
Expand Down Expand Up @@ -2309,10 +2413,16 @@ export default defineComponent({
class="d-flex flex-column grow"
>
<div class="d-flex grow">
<!--
Hidden panes swap to Vuetify's d-none instead of using v-show:
d-flex is `display: flex !important`, which defeats v-show's
inline `display: none`. Panes stay mounted either way, so their
viewers keep state.
-->
<div
v-for="camera in multiCamList"
:key="camera"
class="d-flex flex-column grow"
:class="displayedCameras.includes(camera) ? 'd-flex flex-column grow' : 'd-none'"
:style="{ height: `calc(100% - ${controlsHeight}px)` }"
@mousedown.left="changeCamera(camera, $event)"
@mouseup.right="changeCamera(camera, $event)"
Expand Down
5 changes: 5 additions & 0 deletions client/dive-common/store/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ImageEnhancements from 'vue-media-annotator/components/ImageEnhancements.
import GroupSidebar from 'dive-common/components/GroupSidebar.vue';
import AttributesSideBar from 'dive-common/components/Attributes/AttributesSideBar.vue';
import MultiCamTools from 'dive-common/components/MultiCamTools.vue';
import RegistrationTools from 'dive-common/components/CameraRegistration/RegistrationTools.vue';
import AttributeTrackFilters from 'vue-media-annotator/components/AttributeTrackFilters.vue';
import DatasetInfo from 'dive-common/components/DatasetInfo.vue';

Expand Down Expand Up @@ -46,6 +47,10 @@ const componentMap: Record<string, ComponentMapItem> = {
description: 'Multi Camera Tools',
component: MultiCamTools,
},
[RegistrationTools.name]: {
description: 'Camera Registration',
component: RegistrationTools,
},
[AttributesSideBar.name]: {
description: 'Attribute Details',
component: AttributesSideBar,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function fromRegistrationPairs(
const transformTypes: CameraTransformTypes = {};
pairs.forEach((pair) => {
const key = `${pair.left}::${pair.right}`;
// Mirror the panel loader (CameraRegistrationStore.loadRegistrationText):
// Mirror the shared client parser (CameraRegistrationStore.loadRegistrationText):
// producer files may carry only one fitted direction, so derive the
// missing one by inversion. A singular matrix can't participate in the
// warp either way, so such pairs contribute points only.
Expand Down
10 changes: 7 additions & 3 deletions client/platform/desktop/frontend/components/ViewerLoader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1756,13 +1756,17 @@ export default defineComponent({
// choice (save / discard / cancel) rather than the two-button navigate-away
// prompt. Resolves true to allow the close, false to keep the app open.
async function desktopCloseGuard(): Promise<boolean> {
const count = viewerRef.value?.pendingSaveCount ?? 0;
if (!count) return true;
// Pending annotation saves OR unsaved Camera Registration panel edits.
const unsaved = viewerRef.value?.hasUnsavedChanges ?? false;
if (!unsaved) return true;
const choice = await window.diveDesktop.invoke('desktop:confirm-close-unsaved');
if (choice === 'cancel') return false;
if (choice === 'save') {
try {
await viewerRef.value.save();
if (viewerRef.value.pendingSaveCount > 0) {
await viewerRef.value.save();
}
await viewerRef.value.saveRegistration();
} catch {
// Save failed; the Viewer surfaces its own error, so keep the app open.
return false;
Expand Down
Loading
Loading