diff --git a/client/dive-common/components/DatasetInfo.vue b/client/dive-common/components/DatasetInfo.vue deleted file mode 100644 index aa3f4e23a..000000000 --- a/client/dive-common/components/DatasetInfo.vue +++ /dev/null @@ -1,324 +0,0 @@ - - - - - diff --git a/client/dive-common/components/DatasetInfo/CustomDatasetInfoPanel.vue b/client/dive-common/components/DatasetInfo/CustomDatasetInfoPanel.vue new file mode 100644 index 000000000..c7db7d02a --- /dev/null +++ b/client/dive-common/components/DatasetInfo/CustomDatasetInfoPanel.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts b/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts new file mode 100644 index 000000000..e18a7c64c --- /dev/null +++ b/client/dive-common/components/DatasetInfo/DatasetInfo.spec.ts @@ -0,0 +1,498 @@ +// @vitest-environment jsdom +/* eslint-disable import/no-extraneous-dependencies */ +import { mount } from '@vue/test-utils'; +import Vue, { + CreateElement, defineComponent, nextTick, ref, +} from 'vue'; + +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; + +import { + DatasetMeta, + provideApi, +} from 'dive-common/apispec'; +import type { FrameMetadataSourcesResponse } from 'dive-common/apispec'; +import { resetFrameMetadataSessionCache } from 'dive-common/use/useFrameMetadata'; +import { + dummyHandler, + dummyState, + provideAnnotator, +} from 'vue-media-annotator/provides'; +import { useMediaController } from 'vue-media-annotator/components'; +import type { MediaControllerKind } from 'vue-media-annotator/components/annotators/mediaControllerType'; +import DatasetInfo from './DatasetInfo.vue'; + +Vue.config.ignoredElements = [/^v-/]; + +const VTooltipStub = { + render(this: Vue, h: CreateElement) { + return h('div', [this.$scopedSlots.activator?.({ on: {}, attrs: {} })]); + }, +}; + +async function settleOnce() { + await nextTick(); + await new Promise((resolve) => { window.setTimeout(resolve, 0); }); +} + +async function settle() { + await settleOnce(); + await settleOnce(); + await settleOnce(); + await settleOnce(); +} + +const defaultMetadata: DatasetMeta = { + id: 'dataset-id', + imageData: [], + videoUrl: undefined, + type: 'image-sequence', + fps: 5, + name: 'Mouss Set', + createdAt: '2024-01-02T03:04:05.000Z', + originalFps: 10, + subType: null, + multiCamMedia: null, + datasetInfo: { + cruise: '2403', + station: 'TXN-012', + }, +}; + +function apiWithMetadata({ + metadata, + frameMetadata, +}: { + metadata: DatasetMeta; + frameMetadata: FrameMetadataSourcesResponse; +}): Parameters[0] { + return { + getPipelineList: async () => ({}), + runPipeline: async () => undefined, + deleteTrainedPipeline: async () => undefined, + exportTrainedPipeline: async () => undefined, + getDatasetCalibration: async () => null, + getTrainingConfigurations: async () => ({ training: { configs: [], default: '' }, models: {} }), + runTraining: async () => undefined, + loadMetadata: vi.fn(async () => metadata), + loadDetections: async () => ({ + version: 2, + tracks: [], + groups: [], + sets: [], + }), + loadFrameMetadata: vi.fn(async () => frameMetadata), + saveDetections: async () => undefined, + saveMetadata: async () => undefined, + saveAttributes: async () => undefined, + saveAttributeTrackFilters: async () => undefined, + openFromDisk: async () => ({ canceled: true, filePaths: [] }), + importAnnotationFile: async () => false, + }; +} + +function mountDatasetInfo({ + frameMetadata = { cameras: {} }, + mediaNames = { port: ['port001.png'], starboard: ['starboard001.png'] }, + mediaKinds = {}, + readyCameras = {}, + hasFrameCameras = {}, + maxFrames = {}, + selectedCamera = 'port', + frame = 0, + readOnlyMode = true, + metadata = defaultMetadata, +}: { + frameMetadata?: FrameMetadataSourcesResponse; + mediaNames?: Record; + mediaKinds?: Record; + readyCameras?: Record; + hasFrameCameras?: Record; + maxFrames?: Record; + selectedCamera?: string; + frame?: number; + readOnlyMode?: boolean; + metadata?: DatasetMeta; +} = {}) { + const state = dummyState(); + state.datasetId = ref('dataset-id'); + state.selectedCamera = ref(selectedCamera); + state.time = { + ...state.time, + frame: ref(frame), + }; + state.readOnlyMode = ref(readOnlyMode); + + const api = apiWithMetadata({ metadata, frameMetadata }); + + const Root = defineComponent({ + components: { DatasetInfo }, + setup() { + provideApi(api); + const { initialize } = useMediaController(); + Object.entries(mediaNames).forEach(([camera, names]) => { + if (names === undefined) { + return; + } + const mediaKind = mediaKinds[camera] ?? 'image-sequence'; + const { state: cameraState } = initialize(camera, mediaKind, { + seek: () => undefined, + play: () => undefined, + pause: () => undefined, + setVolume: () => undefined, + setSpeed: () => undefined, + }); + cameraState.filenames = names; + cameraState.maxFrame = maxFrames[camera] ?? Math.max(0, names.length - 1); + cameraState.ready = readyCameras[camera] ?? true; + cameraState.hasFrame = hasFrameCameras[camera] ?? true; + }); + provideAnnotator( + state, + dummyHandler(() => undefined), + {} as Parameters[2], + ); + return {}; + }, + template: '', + }); + + return { + wrapper: mount(Root, { + stubs: { + DatasetInfoFieldDialog: true, + 'v-tooltip': VTooltipStub, + }, + }), + state, + loadFrameMetadata: api.loadFrameMetadata, + }; +} + +function frameMetadataRows(wrapper: ReturnType) { + return wrapper.find('.frame-metadata-section').findAll('.info-row').wrappers.map((row) => ({ + key: row.find('.info-key').text(), + value: row.find('.info-value').element.textContent, + })); +} + +describe('DatasetInfo', () => { + beforeEach(() => { + resetFrameMetadataSessionCache(); + }); + + it('renders the useful frame, dataset, and custom dataset info in order', async () => { + const { wrapper } = mountDatasetInfo({ + frameMetadata: { + cameras: { + port: { + name: 'frame_metadata.csv', + text: 'filename,latitude,depth_m,note\nport001.png,58.10,100,raw text\n', + }, + }, + }, + }); + + await settle(); + + const headers = wrapper.findAll('.dataset-info-panel-header').wrappers; + expect(headers.map((header) => header.text())).toEqual([ + expect.stringContaining('Frame Metadata'), + expect.stringContaining('Dataset Info'), + expect.stringContaining('Custom Dataset Info'), + ]); + expect(frameMetadataRows(wrapper)).toEqual([ + { key: 'filename', value: 'port001.png' }, + { key: 'latitude', value: '58.10' }, + { key: 'depth_m', value: '100' }, + { key: 'note', value: 'raw text' }, + ]); + + const text = wrapper.text(); + expect(text.indexOf('Frame Metadata')).toBeLessThan(text.indexOf('Dataset Info')); + expect(text.indexOf('Dataset Info')).toBeLessThan(text.indexOf('Custom Dataset Info')); + expect(wrapper.find('.frame-metadata-section .info-source-icon').attributes('aria-label')) + .toBe('Source: frame_metadata.csv'); + expect(wrapper.find('.dataset-info-section').text()).toContain('Mouss Set'); + expect(wrapper.find('.dataset-info-section').text()).toContain('image-sequence'); + expect(wrapper.find('.custom-dataset-info-section').text()).toContain('cruise'); + expect(wrapper.find('.custom-dataset-info-section').text()).toContain('2403'); + }); + + it('keeps frame metadata read-only when custom dataset info is editable', async () => { + const { wrapper } = mountDatasetInfo({ + readOnlyMode: false, + frameMetadata: { + cameras: { + port: { + name: 'frame_metadata.csv', + text: 'filename,latitude\nport001.png,58.10\n', + }, + }, + }, + }); + + await settle(); + + expect(wrapper.find('.frame-metadata-section').find('v-text-field').exists()).toBe(false); + expect(wrapper.find('.frame-metadata-section').find('v-btn').exists()).toBe(false); + expect(wrapper.find('.custom-dataset-info-section').find('v-text-field').exists()).toBe(true); + expect(wrapper.find('.custom-dataset-info-section').find('v-btn').exists()).toBe(true); + }); + + it.each([ + { + name: 'no attachment', + options: {}, + expected: 'No frame metadata source found. Add a TXT or CSV Metadata File when creating the dataset.', + }, + { + name: 'unsupported dataset type', + options: { + metadata: { ...defaultMetadata, type: 'large-image' }, + mediaKinds: { port: 'large-image' }, + }, + expected: 'Frame metadata is available for image-sequence and video datasets only.', + }, + { + name: 'unmatched attachment', + options: { + frameMetadata: { + cameras: { + port: { + name: 'frame_metadata.csv', + text: 'filename,depth\nother001.png,10\n', + }, + }, + }, + }, + expected: 'A metadata attachment (frame_metadata.csv) is present, but none of its rows matched this dataset\'s frames by filename, DIVE frame number, or source image counter.', + }, + { + name: 'opaque JSON attachment', + options: { + frameMetadata: { + shared: { name: 'pipeline-input.json' }, + cameras: {}, + }, + }, + expected: 'available to pipelines, but only TXT and CSV files can be read as frame metadata.', + }, + { + // The generic backend reason says no more than the panel's own sentence, so it is not + // appended; the case below shows the reason that does carry information surviving. + name: 'unreadable attachment', + options: { + frameMetadata: { + shared: { name: 'missing.csv', error: 'Metadata attachment is unavailable.' }, + cameras: {}, + }, + }, + expected: 'The metadata attachment (missing.csv) could not be read.', + }, + { + // The backend cannot name a single attachment here, so it sends a placeholder name and puts + // the whole story in the error: losing that text leaves the user with nothing to act on. + name: 'competing reserved-name attachments', + options: { + frameMetadata: { + shared: { + name: 'Metadata File', + error: 'More than one reserved-name metadata attachment is available.', + }, + cameras: {}, + }, + }, + expected: 'could not be read: More than one reserved-name metadata attachment is available.', + }, + { + name: 'invalid CSV attachment', + options: { + frameMetadata: { + shared: { name: 'broken.csv', text: '' }, + cameras: {}, + }, + }, + expected: 'could not be parsed as frame metadata.', + }, + { + name: 'no row for current frame', + options: { + frame: 1, + frameMetadata: { + cameras: { + port: { + name: 'frame_metadata.csv', + text: 'filename,depth\nport001.png,10\n', + }, + }, + }, + }, + expected: 'No frame metadata for the current frame.', + }, + ])('shows the $name empty state', async ({ options, expected }) => { + const { wrapper } = mountDatasetInfo(options); + + await settle(); + + expect(wrapper.find('.frame-metadata-section').text()).toContain(expected); + }); + + it('resolves literal DIVE frame rows for a ready video controller', async () => { + const { wrapper, state } = mountDatasetInfo({ + metadata: { ...defaultMetadata, type: 'video' }, + mediaNames: { singleCam: [] }, + mediaKinds: { singleCam: 'video' }, + maxFrames: { singleCam: 2 }, + selectedCamera: 'singleCam', + frameMetadata: { + shared: { + name: 'frame_metadata.csv', + text: 'frame,depth\n2,30\n0,10\n3,out-of-range\n', + }, + cameras: {}, + }, + }); + + await settle(); + expect(frameMetadataRows(wrapper)).toEqual([ + { key: 'frame', value: '0' }, + { key: 'depth', value: '10' }, + ]); + + state.time.frame.value = 2; + await settle(); + expect(frameMetadataRows(wrapper)).toEqual([ + { key: 'frame', value: '2' }, + { key: 'depth', value: '30' }, + ]); + }); + + it('waits for frame information before classifying readable rows', async () => { + const { wrapper } = mountDatasetInfo({ + metadata: { ...defaultMetadata, type: 'video' }, + mediaNames: { singleCam: [] }, + mediaKinds: { singleCam: 'video' }, + readyCameras: { singleCam: false }, + selectedCamera: 'singleCam', + frameMetadata: { + shared: { + name: 'frame_metadata.csv', + text: 'frame,depth\n0,10\n', + }, + cameras: {}, + }, + }); + + await settle(); + expect(wrapper.find('.frame-metadata-section').text()) + .toContain('Waiting for frame information.'); + }); + + it('waits rather than blaming the file while an image sequence has no media list yet', async () => { + const { wrapper } = mountDatasetInfo({ + mediaNames: { port: [] }, + frameMetadata: { + cameras: { + port: { name: 'frame_metadata.csv', text: 'filename,depth\nport001.png,10\n' }, + }, + }, + }); + + await settle(); + const section = wrapper.find('.frame-metadata-section'); + expect(section.text()).toContain('Waiting for frame information.'); + expect(section.text()).not.toContain('none of its rows matched'); + }); + + it('resolves an image sequence before its first image finishes decoding', async () => { + // ImageAnnotator publishes filenames in setup() but only flips `ready` once the first image + // has loaded: the panel must not sit in a waiting state for the whole download. + const { wrapper } = mountDatasetInfo({ + readyCameras: { port: false }, + frameMetadata: { + cameras: { + port: { name: 'frame_metadata.csv', text: 'filename,depth\nport001.png,10\n' }, + }, + }, + }); + + await settle(); + expect(frameMetadataRows(wrapper)).toEqual([ + { key: 'filename', value: 'port001.png' }, + { key: 'depth', value: '10' }, + ]); + }); + + it('shows no rows for a camera with no frame at the current aligned instant', async () => { + // Under the aligned multicam timeline `time.frame` keeps its previous local value while the + // camera's own pane renders "No frame at this instant"; that row is not this instant's. + const { wrapper } = mountDatasetInfo({ + hasFrameCameras: { port: false }, + frameMetadata: { + cameras: { + port: { name: 'frame_metadata.csv', text: 'filename,depth\nport001.png,10\n' }, + }, + }, + }); + + await settle(); + expect(frameMetadataRows(wrapper)).toEqual([]); + expect(wrapper.find('.frame-metadata-section').text()) + .toContain('This camera has no frame at the current time.'); + }); + + it('uses the video-specific unmatched message', async () => { + const { wrapper } = mountDatasetInfo({ + metadata: { ...defaultMetadata, type: 'video' }, + mediaNames: { singleCam: [] }, + mediaKinds: { singleCam: 'video' }, + maxFrames: { singleCam: 0 }, + selectedCamera: 'singleCam', + frameMetadata: { + shared: { + name: 'frame_metadata.csv', + text: 'filename,depth\nmovie.mp4,10\n', + }, + cameras: {}, + }, + }); + + await settle(); + expect(wrapper.find('.frame-metadata-section').text()).toContain( + 'A metadata attachment (frame_metadata.csv) is present, but none of its rows contained a valid DIVE frame number for this video.', + ); + }); + + it('follows the selected camera', async () => { + const { wrapper, state, loadFrameMetadata } = mountDatasetInfo({ + frameMetadata: { + cameras: { + port: { + name: 'frame_metadata.csv', + text: 'filename,latitude\nport001.png,58.10\n', + }, + starboard: { + name: 'frame-metadata.txt', + text: 'filename,latitude\nstarboard001.png,59.10\n', + }, + }, + }, + }); + + await settle(); + expect(wrapper.find('.frame-metadata-section').text()).toContain('58.10'); + + state.selectedCamera.value = 'starboard'; + await settle(); + + const section = wrapper.find('.frame-metadata-section'); + expect(section.text()).toContain('59.10'); + expect(section.text()).not.toContain('58.10'); + expect(wrapper.find('.frame-metadata-section .info-source-icon').attributes('aria-label')) + .toBe('Source: frame-metadata.txt'); + expect(loadFrameMetadata).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/dive-common/components/DatasetInfo/DatasetInfo.vue b/client/dive-common/components/DatasetInfo/DatasetInfo.vue new file mode 100644 index 000000000..4342c4777 --- /dev/null +++ b/client/dive-common/components/DatasetInfo/DatasetInfo.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/client/dive-common/components/DatasetMetaEditorDialog.vue b/client/dive-common/components/DatasetInfo/DatasetInfoFieldDialog.vue similarity index 98% rename from client/dive-common/components/DatasetMetaEditorDialog.vue rename to client/dive-common/components/DatasetInfo/DatasetInfoFieldDialog.vue index 3ccbaa75a..a2e506fb1 100644 --- a/client/dive-common/components/DatasetMetaEditorDialog.vue +++ b/client/dive-common/components/DatasetInfo/DatasetInfoFieldDialog.vue @@ -4,7 +4,7 @@ import { } from 'vue'; export default defineComponent({ - name: 'DatasetMetaEditorDialog', + name: 'DatasetInfoFieldDialog', props: { value: { diff --git a/client/dive-common/components/DatasetInfo/DatasetInfoSection.vue b/client/dive-common/components/DatasetInfo/DatasetInfoSection.vue new file mode 100644 index 000000000..fd2e2a93b --- /dev/null +++ b/client/dive-common/components/DatasetInfo/DatasetInfoSection.vue @@ -0,0 +1,99 @@ + + + diff --git a/client/dive-common/store/context.ts b/client/dive-common/store/context.ts index 3b5521cc4..0fbd24204 100644 --- a/client/dive-common/store/context.ts +++ b/client/dive-common/store/context.ts @@ -7,7 +7,7 @@ import AttributesSideBar from 'dive-common/components/Attributes/AttributesSideB 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'; +import DatasetInfo from 'dive-common/components/DatasetInfo/DatasetInfo.vue'; interface ContextState { last: string; @@ -20,8 +20,11 @@ interface ComponentMapItem { component: Component; } +// The pane the context sidebar opens on, here and after every dataset load via resetActive. +const DEFAULT_CONTEXT = 'DatasetInfo'; + const state: ContextState = reactive({ - last: 'TypeThreshold', + last: DEFAULT_CONTEXT, active: null, subCategory: null, }); @@ -72,7 +75,7 @@ function unregister(item: ComponentMapItem) { } function resetActive() { - state.last = 'TypeThreshold'; + state.last = DEFAULT_CONTEXT; state.active = null; } diff --git a/docs/DataFormats.md b/docs/DataFormats.md index 8b77c3925..539385d9b 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -5,7 +5,9 @@ hide: # Data Formats -DIVE Desktop and Web support a number of annotation and configuration formats. The following formats can be uploaded or imported alongside your media and will be automatically parsed. +DIVE Desktop and Web support a number of annotation, configuration, and +media-side metadata formats. The annotation and configuration formats below can +be uploaded or imported alongside your media and will be automatically parsed. * DIVE Annotation JSON (default annotation format) * DIVE Configuration JSON @@ -13,6 +15,10 @@ DIVE Desktop and Web support a number of annotation and configuration formats. * KPF (KWIVER Packet Format) * COCO and KWCOCO +Frame metadata sidecars are media files rather than annotation imports. See +[Frame Metadata Sidecars](Frame-Metadata.md) for their naming, placement, and +text-file format. + ## DIVE Annotation JSON !!! info diff --git a/docs/Dive-Desktop.md b/docs/Dive-Desktop.md index 19b161946..c6f4e2599 100644 --- a/docs/Dive-Desktop.md +++ b/docs/Dive-Desktop.md @@ -61,7 +61,7 @@ Click either ==Open Image Sequence :material-folder-open:== or ==Open Video :mat The import routine will look for `.csv` and `.json` files in the same directory as the source media, and you will be prompted to manually select an annotation file and a **Configuration File** (DIVE JSON). Neither is required. -Advanced options also include an optional **Metadata File** (`.json`, `.txt`, or `.csv`) — a pipeline sidecar such as a flight log. This is **not** the same as the Configuration File: DIVE does not parse it, and only pipelines that declare `# Metadata File:` receive it at run time. Metadata can be attached on single-camera, stereo, and multicam imports. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). +Advanced options also include an optional **Metadata File** (`.json`, `.txt`, or `.csv`) — a pipeline sidecar such as a flight log. This is **not** the same as the Configuration File. Pipelines that declare `# Metadata File:` receive it at run time; DIVE also shows matching CSV/TXT rows as [Frame Metadata](Frame-Metadata.md) on image-sequence and video datasets. Metadata can be attached on single-camera, stereo, and multicam imports. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). ### Launching from the command line @@ -79,7 +79,7 @@ DIVE-Desktop --import [--annotations ] [--metadata ] [--name | ---- | ----- | ----------- | | `--import` | `-i` | Media to open: an image-sequence directory, an image-list `.txt` file (one image path per line), or a video. Same inputs as the import wizard. | | `--annotations` | `-a` | Optional annotation file to load (VIAME CSV or DIVE JSON). | -| `--metadata` | | Optional pipeline metadata sidecar (`.json`, `.txt`, or `.csv`), e.g. a flight log. Same as the import wizard's Metadata File picker; only used by pipelines that declare `# Metadata File:`. | +| `--metadata` | | Optional metadata attachment (`.json`, `.txt`, or `.csv`), e.g. a flight log. Same as the import wizard's Metadata File picker; passed to pipelines that declare `# Metadata File:` and, for matching CSV/TXT rows, also shown as [Frame Metadata](Frame-Metadata.md). | | `--name` | `-n` | Optional display name; defaults to the media basename. | Example — review a detector CSV over an image list: diff --git a/docs/Frame-Metadata.md b/docs/Frame-Metadata.md new file mode 100644 index 000000000..9bb16b106 --- /dev/null +++ b/docs/Frame-Metadata.md @@ -0,0 +1,183 @@ +# Frame Metadata Sidecars + +DIVE can display read-only frame metadata for image-sequence and video datasets in the +[Dataset Info panel](UI-DatasetInfo.md#frame-metadata). Common examples include +timestamp, latitude, longitude, depth, altitude, and vehicle state. + +Frame metadata is not annotation data. DIVE derives it from a selected metadata +attachment when the dataset opens. The values are not edited in DIVE, imported +as annotations, or included in annotation exports. + +## Adding Frame Metadata + +Choose one metadata attachment when creating a dataset. A single-camera dataset +has one attachment. A multicamera dataset may have one shared attachment and one +attachment for each camera. + +TXT and CSV attachments are read for frame metadata values. JSON, TXT, and CSV +attachments remain independent pipeline inputs: a file with no matching rows is +still passed unchanged to a pipeline that requests it. + +For automated, archive, assetstore, or desktop-folder ingestion, use one of the +reserved names: + +* `frame-metadata.csv` +* `frame-metadata.json` +* `frame-metadata.txt` +* `frame_metadata.csv` +* `frame_metadata.json` +* `frame_metadata.txt` + +The reserved name identifies the attachment on ingestion paths where an upload +field is not available. Other filenames may be selected explicitly in a +creation-time metadata field. + +A reserved name is matched on the basename alone, ignoring case. The two JSON +names are reserved so a file named for frame metadata is never imported as DIVE +annotation JSON; like any JSON attachment it is passed to opt-in pipelines and +is not read for frame metadata values. + +When both a shared attachment and a camera-local attachment exist, the local +attachment replaces the shared attachment for that camera. DIVE does not merge +the files by column, row, or frame. + +## File Format + +Use a table with a header row and comma, tab, or whitespace-separated values. +Values remain strings and columns appear in source order. + +For image sequences, DIVE joins rows to the current camera's ordered images by +value, in this order: + +1. filename; +2. literal `frame`, containing a DIVE frame number; +3. source image counter. + +There is no row-order fallback. Rows may be sparse or out of order. + +For videos, only the literal `frame` join is available. Video filenames, +source counters, and other integer columns are not frame identity. + +### Filename Values + +A cell matches when its extension-stripped basename equals an image's +extension-stripped basename. For example, `images/img_0001.tif` can match +`img_0001`. + +A sidecar may contain one filename column per camera. DIVE resolves the same +shared file independently for each camera. If duplicate source rows name the +same image, the first row wins. + +The leftmost column that names at least two of a camera's images is the join +column; every other column is treated as data. A sibling camera's filename +column names none of this camera's images, so it is never chosen. If a single +dataset holds more than one camera's media, order the table so the intended +column comes first, or use the literal `frame` contract. + +Filename matching has highest priority. This preserves interchange tables that +contain both an image filename and a `frame` field with a different meaning. + +```text +image_file latitude longitude depth_m +img_0001.tif 46.575870 -124.603094 192.80 +img_0002.tif 46.575912 -124.603080 193.10 +``` + +### Literal `frame` + +The exact lowercase, case-sensitive header `frame` declares zero-based DIVE +frame numbers. `Frame`, `frame_index`, and `frame_id` are not aliases. + +Values must be non-negative base-10 integers in JavaScript's safe-integer range. +Leading zeros are accepted. A value must be within the current camera's usable +DIVE frame range. For an image sequence, that range is determined by its +ordered images. For a video, it is determined by the annotation/display frames +exposed at the dataset's effective FPS. A video `frame` value does not identify +an encoded container frame or packet. The `frame` column remains visible in +the panel, and the file must contain at least one other column. + +Invalid and out-of-range rows are skipped. For duplicate valid values, the +first row wins. One valid row produces a sparse result, but a declared `frame` +column with no valid in-range rows is unmatched and does not fall through to a +source-counter join. Rename a source column such as `frame` to `source_frame` +when it is not a DIVE frame number. + +```text +frame,timestamp,depth_m +3,2024-06-01T12:30:15Z,106.2 +0,2024-06-01T12:30:00Z,102.5 +``` + +### Source Image Counters + +For numbered imagery, DIVE can match a non-negative integer column to the +trailing digit run of each image filename stem. For example, `173` can match +`camera_00173.jpg`. Column names are not used as evidence; `frame_count` is a +conventional name, not a required one. + +Counter matching is deliberately strict: + +* a counter repeated by multiple media files is excluded; +* a matching counter repeated in the attachment disqualifies that column; +* matched DIVE frames must be strictly increasing or strictly decreasing in + source-row order, although gaps are allowed. + +The leftmost integer column passing every check is used. These checks prevent +constants, reset counters, and unrelated numeric fields from silently selecting +rows. If a source log reuses counters after a reset, slice it to one monotonic +segment or use the literal `frame` contract. + +## Placement and Multicamera Selection + +For a single-camera image sequence or video, place a reserved-name attachment +beside the media or select it during dataset creation. + +For a multicamera image-sequence or video dataset, place a shared reserved-name +attachment in the multicamera parent folder, or put a camera-local attachment +in a camera folder. Shared attachments are resolved separately against each +camera's media and usable frame range. A camera-local attachment replaces the +shared attachment for that camera; the two are never merged. + +Multicamera video follows DIVE's positional playback contract: global frame +`N` selects local DIVE frame `N` in every camera where that frame exists. +Frame metadata does not infer camera start offsets or correct dropped frames, +clock drift, or capture-time differences. + +## Export and Re-import + +A full dataset export carries the attachment inside the archive: the dataset's +own attachment at `metadata/`, and each camera's attachment at +`/metadata/`. Importing that archive restores it, on the web +version and on DIVE Desktop alike, so an export/import round-trip keeps the +attachment the panel displays. + +The archive layout is the whole contract — nothing in `meta.json` names the +attachment. An archive from an older DIVE that stored the file elsewhere imports +normally; its attachment arrives as an ordinary dataset file, and it is picked up +automatically only if it carries a reserved name. + +## Passing the Attachment to a Pipeline + +A pipeline opts in to the selected attachment with a KWIVER configuration key +in its header: + +```text +# Metadata File: stabilizer:flight_log +``` + +When the pipeline runs, DIVE passes the exact attachment as +`-s stabilizer:flight_log=`. This is independent of frame metadata parsing. +JSON attachments and unmatched TXT or CSV attachments therefore remain useful +pipeline inputs. + +## Limits + +Timestamp or other temporal alignment, embedded KLV, embedded EXIF, and changing +an attachment after dataset creation are not currently supported. Resolved rows +are read-only derived state; the selected attachment remains independently +available to pipelines that opt in to it. + +An attachment can be stored on a dataset of any media type and passed to opt-in +pipelines from any of them. Frame metadata values are resolved and displayed for +image-sequence and video datasets only; on a large-image dataset the attachment +is kept and handed to pipelines, but no rows are shown. diff --git a/docs/Multicamera-data.md b/docs/Multicamera-data.md index 7908352f7..b89a5ec9d 100644 --- a/docs/Multicamera-data.md +++ b/docs/Multicamera-data.md @@ -35,7 +35,7 @@ Multicam import is available from the standard upload dialog on [viame.kitware.c * ==:material-folder-multiple-image: MultiCam Batch== — import many multicam datasets at once from a folder of **collect** subfolders (image sequences only). See [Batch multicam import](#batch-multicam-import). 6. In the import dialog, assign a source folder or video file to each camera. All cameras must use the same media type (all image sequences or all videos). By default, every camera must have the same number of frames (or matching video duration). For **image-sequence** imports with capture timestamps in filenames, enable **Infer frame index from filename** to allow unequal per-camera counts — see [Infer frame index from filename](#infer-frame-index-from-filename). 7. Optionally attach a per-camera annotation file during import. -8. Optionally attach a **Metadata File** (`.json`, `.txt`, or `.csv`) — for example a flight log used by registration pipelines. This is **not** stereo-only and is independent of the calibration file. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). +8. Optionally attach a **Metadata File** (`.json`, `.txt`, or `.csv`) — for example a flight log used by registration pipelines. This is **not** stereo-only and is independent of the calibration file; matching CSV/TXT rows are also shown as [Frame Metadata](Frame-Metadata.md) for image-sequence and video cameras. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). 9. Enter a dataset name, choose the default display camera, and click ==Begin Import==. 10. When upload finishes, DIVE opens the new multicam dataset in the annotator. diff --git a/docs/Pipeline-Import-Export.md b/docs/Pipeline-Import-Export.md index 7d3860e28..343bcfad4 100644 --- a/docs/Pipeline-Import-Export.md +++ b/docs/Pipeline-Import-Export.md @@ -72,7 +72,7 @@ Example: | `# Description:` | Human-readable summary shown in the Run Pipeline UI. May continue on following `#` comment lines until the next named header. | | `# Input:` / `# Output:` | Declared media/annotation kinds for the pipe (used when discovering and categorizing static pipelines). | | `# Requires Calibration: True` | Restricts the pipe to stereo datasets that have a calibration file attached. Values `true`, `yes`, and `1` are accepted. | -| `# Metadata File: :` | Opt-in: when the dataset has an attached **Metadata File**, DIVE appends a KWIVER override `-s :=` at run time. Without this header, no metadata file is injected. | +| `# Metadata File: :` | Opt-in: when the dataset has an attached **Metadata File**, DIVE appends a KWIVER override `-s :=` at run time. The same CSV/TXT attachment is also considered for [Frame Metadata](Frame-Metadata.md). Without this header, no metadata file is injected. | | `# Image List Keys: [k…]` | Opt-in: binds the run's per-camera input image list(s) to each listed KWIVER key. Keys may be space- or comma-separated. A key containing `{cam}` is expanded once per camera (1-based), e.g. `stabilizer:image_list{cam}` → `image_list1`, `image_list2`, …. A key without `{cam}` receives camera 1's list only. | ### Metadata File vs Configuration File @@ -82,9 +82,17 @@ These are different files with different jobs: | Import field | What it is | Consumed by | | ------------ | ---------- | ----------- | | **Configuration File** | DIVE JSON (`meta` / attributes, styles, FPS, …) | DIVE itself | -| **Metadata File** | Opaque sidecar (`.json`, `.txt`, or `.csv`), e.g. a UAV flight log | Only pipelines that declare `# Metadata File:` | +| **Metadata File** | One dataset attachment (`.json`, `.txt`, or `.csv`), e.g. a UAV flight log | Pipelines that declare `# Metadata File:`; CSV/TXT files with matching rows also appear as [Frame Metadata](Frame-Metadata.md) | -DIVE does **not** auto-discover a flight log in the media folder — the user must pick it at import (UI or CLI `--metadata`). The file format is opaque to DIVE; the KWIVER process behind the pipe owns the schema. Metadata is available on **single-camera and multicamera** imports alike; it is not stereo-gated (unlike calibration). +Pick the file at import (UI or CLI `--metadata`), or give it one of the reserved +frame-metadata names so ingestion paths without an upload field — assetstore, zip +archive, desktop folder — find it on their own. Any other filename is used only when +it is selected explicitly. The KWIVER process behind the pipe owns the schema. + +DIVE additionally reads a selected CSV/TXT attachment as [Frame Metadata](Frame-Metadata.md) +for image-sequence and video datasets; if no rows match, it shows no frame values while +pipelines still receive the file unchanged. Metadata is available on **single-camera and +multicamera** imports alike; it is not stereo-gated (unlike calibration). !!! note diff --git a/docs/UI-DatasetInfo.md b/docs/UI-DatasetInfo.md index 38a9dfd85..0ab04b269 100644 --- a/docs/UI-DatasetInfo.md +++ b/docs/UI-DatasetInfo.md @@ -1,7 +1,7 @@ # Dataset Info -The **Dataset Info** panel shows properties of the whole dataset and lets you attach -custom metadata to it. It is one pane of the +The **Dataset Info** panel shows read-only frame metadata, properties of the +whole dataset, and custom metadata attached to it. It is one pane of the [context sidebar](UI-Navigation-Editing-Bar.md#context-sidebar-web). Metadata you add travels with the dataset: it is shown while annotating and written into @@ -13,14 +13,31 @@ re-link annotations to their source records. ![Dataset Info panel](images/General/DatasetInfo.png){ width=220px align=right } -**Standard information** (read-only): Name, Type, FPS, Original FPS and Subtype (when +The pane holds three collapsible sections: + +**Frame Metadata** (read-only): per-frame metadata for the active frame, such as +timestamp, latitude, longitude, depth, or altitude. It shows only the source +fields for that frame, in the order they appear in the source file. + +**Dataset Info** (read-only): Name, Type, FPS, Original FPS and Subtype (when set), Created date, and ID (the Girder folder id). -**Custom Metadata** — a free-form list of key/value pairs stored on the dataset, for -example a station id, cruise number, or dive number. +**Custom Dataset Info** — a free-form list of key/value pairs stored on the dataset, +for example a station id, cruise number, or dive number.
+## Frame Metadata + +The Frame Metadata section shows values for the active frame, updating as the +playhead moves and following the active camera on multicamera datasets. The +section header carries an information icon when an attachment supplied the +values; hover it to see which file they came from. When there are no values to +show, the section says why in place of the rows. + +See [Frame Metadata Sidecars](Frame-Metadata.md) for supported filenames, file +format, placement, and limits. + ## Where the data is stored Custom metadata lives on the dataset's folder metadata under the `datasetInfo` key — the diff --git a/docs/Web-Version.md b/docs/Web-Version.md index a173368f1..c92d10b7e 100644 --- a/docs/Web-Version.md +++ b/docs/Web-Version.md @@ -27,7 +27,7 @@ A user account is required to store data and run pipelines on viame.kitware.com. * Select a video or multi-select a group of image frames. * Use ++ctrl++ or ++shift++ to click every file you want to upload. * If you already have `annotations.csv` or an annotation or configuration JSON select that too. - * Optionally select a **Metadata File** (`.json`, `.txt`, or `.csv`) such as a flight log. This is separate from the DIVE configuration JSON; only pipelines that declare `# Metadata File:` use it. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). + * Optionally select a **Metadata File** (`.json`, `.txt`, or `.csv`) such as a flight log. This is separate from the DIVE configuration JSON; pipelines that declare `# Metadata File:` use it, and matching CSV/TXT rows also appear as [Frame Metadata](Frame-Metadata.md) on image-sequence and video datasets. See [Pipe file headers](Pipeline-Import-Export.md#pipe-file-headers). * Uploads that contain only `.tif` / `.tiff` files are classified as [large-image](Large-Image-Support.md) datasets rather than image sequences. * Choose a name for the dataset and enter the optional playback frame rate or select other optional files. * Press ==Start Upload== diff --git a/mkdocs.yml b/mkdocs.yml index 03ae6d52c..63b4f93bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - Keyboard Shortcut Reference: Mouse-Keyboard-Shortcuts.md - Advanced features: - Pipeline Import and Export: Pipeline-Import-Export.md + - Frame Metadata: Frame-Metadata.md - Command Line Tools: Command-Line-Tools.md - Large Image Support: Large-Image-Support.md - Multicamera and Stereo Data: Multicamera-data.md