diff --git a/client/dive-common/components/ImportAnnotations.vue b/client/dive-common/components/ImportAnnotations.vue index 70fcc365c..ad0d99fb2 100644 --- a/client/dive-common/components/ImportAnnotations.vue +++ b/client/dive-common/components/ImportAnnotations.vue @@ -4,7 +4,7 @@ import { } from 'vue'; import { useApi } from 'dive-common/apispec'; import { usePrompt } from 'dive-common/vue-utilities/prompt-service'; -import { clientSettings } from 'dive-common/store/settings'; +import { clientSettings, isStereoInteractiveModeEnabled } from 'dive-common/store/settings'; import clearLengthAttributes from 'dive-common/utils/clearLengthAttributes'; import warpAnnotationsAcrossCameras from 'dive-common/utils/warpAnnotationsAcrossCameras'; import { cloneDeep } from 'lodash'; @@ -47,7 +47,7 @@ export default defineComponent({ default: false, }, }, - emits: ['calibration-imported'], + emits: ['calibration-imported', 'stereo-warp-imported'], setup(props, { emit }) { const api = useApi(); const { openFromDisk, importAnnotationFile } = api; @@ -57,13 +57,35 @@ export default defineComponent({ 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 isStereoDataset = computed(() => props.subType === 'stereo'); + // Stereo warping goes through the interactive stereo service, which + // needs its features enabled and a camera calibration loaded. + const stereoWarpAvailable = computed( + () => isStereoDataset.value && isStereoInteractiveModeEnabled() + && !!props.calibrationFile, ); + // Multicam (non-stereo) warping instead requires the whole rig to be + // registered (a native->reference transform for every camera). + const canWarpToAllCameras = computed(() => { + if (!isMulticamDataset.value) { + return false; + } + if (isStereoDataset.value) { + return stereoWarpAvailable.value; + } + return alignedView.available.value; + }); const warpToAllCameras = ref(false); const warpToAllCamerasHint = computed(() => { + if (isStereoDataset.value) { + if (!isStereoInteractiveModeEnabled()) { + return 'Requires interactive stereo to be enabled'; + } + if (!props.calibrationFile) { + return 'Requires a camera calibration'; + } + return 'Warps imported detections to the other camera'; + } const progress = alignedView.registrationProgress.value; return progress ? `${progress.registered}/${progress.total} cameras registered` @@ -85,10 +107,12 @@ export default defineComponent({ () => !!api.importCalibrationFile && props.subType === 'stereo', ); // Camera registration import (per-camera transform files) is meaningful - // for any multicam dataset. + // for multicam datasets; stereo rigs use the calibration (camera file) + // import instead, so the registration section is hidden there. const cameraRegistration = useCameraRegistration(); const registrationSupported = computed( - () => !!api.importCameraRegistration && isMulticamDataset.value, + () => !!api.importCameraRegistration && isMulticamDataset.value + && props.subType !== 'stereo', ); // One import button per non-reference camera pair, labeled in the // direction of the mapping -- "Import ir → eo" registers ir onto the @@ -195,15 +219,20 @@ export default defineComponent({ warpToAllCameras.value && canWarpToAllCameras.value && activeCameraName.value - && alignedView.toReference.value ) { - const warped = warpAnnotationsAcrossCameras( - cameraStore, - alignedView.toReference.value, - activeCameraName.value, - ); - if (warped.tracks > 0) { - await save(); + if (isStereoDataset.value) { + // The platform (desktop) performs the warp through the + // interactive stereo service, one detection at a time. + emit('stereo-warp-imported', activeCameraName.value); + } else if (alignedView.toReference.value) { + const warped = warpAnnotationsAcrossCameras( + cameraStore, + alignedView.toReference.value, + activeCameraName.value, + ); + if (warped.tracks > 0) { + await save(); + } } } } @@ -500,22 +529,38 @@ export default defineComponent({ - + - + bottom + :disabled="!warpToAllCamerasHint" + open-delay="200" + > + + {{ warpToAllCamerasHint }} +
c !== sourceCamera); + const store = cameraStore.camMap.value.get(sourceCamera)?.trackStore; + if (!store || !otherCamera) return; + const jobs: StereoAnnotationCompleteParams[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store.annotationMap.forEach((track: any) => { + if (typeof track.getFeature !== 'function') return; + (track.featureIndex || []).forEach((frameNum: number) => { + const [feature] = track.getFeature(frameNum); + if (!feature || !feature.keyframe || !feature.bounds) return; + // Only fill in missing counterparts; never touch geometry the + // other camera already has for this frame. + const otherTrack = cameraStore.getPossibleTrack(track.id, otherCamera); + const [otherFeature] = otherTrack ? otherTrack.getFeature(frameNum) : [null]; + if (otherFeature) return; + const geoFeatures = feature.geometry?.features || []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const line = geoFeatures.find((g: any) => g.geometry?.type === 'LineString' + && g.geometry.coordinates?.length === 2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const poly = geoFeatures.find((g: any) => g.geometry?.type === 'Polygon'); + const base = { camera: sourceCamera, trackId: track.id, frameNum }; + if (line) { + jobs.push({ + ...base, + type: 'line', + line: line.geometry.coordinates as [[number, number], [number, number]], + key: line.properties?.key ?? '', + }); + } else if (poly) { + jobs.push({ + ...base, + type: 'polygon', + polygon: poly.geometry.coordinates[0] as [number, number][], + key: poly.properties?.key ?? '', + }); + } else { + jobs.push({ + ...base, + type: 'box', + bounds: feature.bounds as [number, number, number, number], + }); + } + }); + }); + for (let i = 0; i < jobs.length; i += 1) { + try { + // eslint-disable-next-line no-await-in-loop + await handleStereoAnnotationComplete(jobs[i], true); + } catch (err) { + console.warn('[Stereo] Import warp failed:', err); + } + } + if (jobs.length) { + await viewer.save(); + } + } + /** * Undo stereo side effects from interactive segmentation on reset. * Restores the other camera and clears saved undo state for the frame. @@ -1829,6 +1908,7 @@ export default defineComponent({ stereoLengthMessage, closeStereoLoadingDialog, handleStereoAnnotationComplete, + handleStereoWarpImported, handleStereoAnnotationReset, handleStereoSegmentationFinalize, handleStereoTrackLinked, @@ -1897,6 +1977,7 @@ export default defineComponent({ v-bind="{ buttonOptions, menuOptions, readOnlyMode }" block-on-unsaved @calibration-imported="onCalibrationImported" + @stereo-warp-imported="handleStereoWarpImported" />