From b6821a99578be95ab0e8e3ace5c3669bd33c168f Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 21 Aug 2026 14:10:06 -0400 Subject: [PATCH 1/2] Name the COCO frame rate annotation_fps The rate on the video entry is the downsampled rate detections were produced at, not the video's native one, so VIAME writes it as annotation_fps. Renames the key on both the import and export sides. --- .../desktop/backend/serializers/coco.spec.ts | 6 +++--- client/platform/desktop/backend/serializers/coco.ts | 6 +++--- client/platform/desktop/backend/serializers/dive.ts | 2 +- docs/DataFormats.md | 12 ++++++------ server/dive_server/crud_dataset.py | 2 +- server/dive_utils/serializers/dive.py | 2 +- server/dive_utils/serializers/kwcoco.py | 8 +++++--- server/tests/test_deserialize_kwcoco_json.py | 8 ++++---- 8 files changed, 24 insertions(+), 22 deletions(-) diff --git a/client/platform/desktop/backend/serializers/coco.spec.ts b/client/platform/desktop/backend/serializers/coco.spec.ts index f8db20442..43b363e66 100644 --- a/client/platform/desktop/backend/serializers/coco.spec.ts +++ b/client/platform/desktop/backend/serializers/coco.spec.ts @@ -318,7 +318,7 @@ describe('COCO serializer', () => { // --- annotation fps on videos[] --- - it('writes videos[].fps for video datasets and restores it on re-import', async () => { + it('writes videos[].annotation_fps for video datasets and restores it on re-import', async () => { const videoMeta = { ...imageMeta, type: 'video' as const, @@ -327,7 +327,7 @@ describe('COCO serializer', () => { }; await serializeFile('/output/video.coco.json', annotationSchema, videoMeta); const out = await fs.readJSON('/output/video.coco.json'); - expect(out.videos).toEqual([{ id: 1, name: 'clip', fps: 5 }]); + expect(out.videos).toEqual([{ id: 1, name: 'clip', annotation_fps: 5 }]); expect(out.images.every((image: { video_id?: number }) => image.video_id === 1)).toBe(true); mockfs({ @@ -726,7 +726,7 @@ describe('COCO serializer', () => { }); mockfs({ '/input': { - 'video.json': document([{ id: 1, name: 'clip', fps: 5 }]), + 'video.json': document([{ id: 1, name: 'clip', annotation_fps: 5 }]), 'image-list.json': document(undefined), 'unusable.json': document([{ id: 1, name: 'clip', fps: 0 }]), 'not-a-number.json': document([{ id: 1, name: 'clip', fps: '5' }]), diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index b9b63b67e..651eb8694 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -234,7 +234,7 @@ type CocoAnnotation = { type CocoVideo = { id: number; name?: string; - fps?: unknown; + annotation_fps?: unknown; }; type CocoDocument = { @@ -254,7 +254,7 @@ type CocoDocument = { function frameRateFromDocument(document: CocoDocument): number | undefined { const videos = Array.isArray(document.videos) ? document.videos : []; for (let i = 0; i < videos.length; i += 1) { - const rate = videos[i]?.fps; + const rate = videos[i]?.annotation_fps; if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) { return rate; } @@ -631,7 +631,7 @@ async function serializeFile( annotations, categories: categoryDocs, ...(emitVideo ? { - videos: [{ id: 1, name: meta.name, fps: meta.fps }], + videos: [{ id: 1, name: meta.name, annotation_fps: meta.fps }], } : {}), }; await fs.writeJSON(path, output, { spaces: 2 }); diff --git a/client/platform/desktop/backend/serializers/dive.ts b/client/platform/desktop/backend/serializers/dive.ts index f4b5729f0..13f144c19 100644 --- a/client/platform/desktop/backend/serializers/dive.ts +++ b/client/platform/desktop/backend/serializers/dive.ts @@ -14,7 +14,7 @@ function makeEmptyAnnotationFile(): AnnotationSchema { /** * Annotation FPS recorded on a DIVE JSON document, if usable. - * Same rules as the VIAME CSV `fps:` header and COCO `videos[].fps`. + * Same rules as the VIAME CSV `fps:` header and COCO `videos[].annotation_fps`. */ function frameRateFromDocument(data: unknown): number | undefined { if (!data || typeof data !== 'object') { diff --git a/docs/DataFormats.md b/docs/DataFormats.md index eb7de720e..2113e367a 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -34,7 +34,7 @@ interface AnnotationSchema { version: 2; /** * Annotation frame rate when present. Omitted when absent or unusable. - * Same role as the VIAME CSV `# metadata` `fps` field and COCO `videos[].fps`. + * Same role as the VIAME CSV `# metadata` `fps` field and COCO `videos[].annotation_fps`. */ fps?: number; } @@ -120,7 +120,7 @@ The full source [TrackData definition can be found here](https://github.com/Kitw ### Annotation frame rate (`fps`) Optional top-level `fps` carries the dataset annotation frame rate — the same value -VIAME CSV writes in the `# metadata` header and COCO/KWCOCO records on `videos[].fps`. +VIAME CSV writes in the `# metadata` header and COCO/KWCOCO records on `videos[].annotation_fps`. ```json { @@ -208,7 +208,7 @@ This information provides the specification for an individual dataset. It consi * Annotation frame rate is stored as dataset `fps`. * Included in [DIVE Annotation JSON](#annotation-frame-rate-fps) as top-level `fps`. * Included in [VIAME CSV](#dataset-metadata-in-the-header) as the `# metadata` `fps` field. - * Included in [COCO / KWCOCO](#annotation-frame-rate-videosfps) as `videos[].fps` for video datasets. + * Included in [COCO / KWCOCO](#annotation-frame-rate-videosannotation_fps) as `videos[].annotation_fps` for video datasets. * A track type hierarchy is stored in `typeHierarchy` as a child-type to immediate-parent-type map. For example, this configuration makes `fish` a heading-only parent (it does not need to be an @@ -443,7 +443,7 @@ advertised in `info.dive_extensions`: * `info.dive_dataset_info = { "gfishsite_id": "2024TXN012", "year": "2024", ... }` -### Annotation frame rate (`videos[].fps`) +### Annotation frame rate (`videos[].annotation_fps`) Neither MS-COCO nor KWCOCO define a frame-rate field. On import, DIVE reads the annotation FPS the same way VIAME writes it: a positive numeric `fps` on an entry @@ -502,9 +502,9 @@ For COCO files produced by DIVE: * DIVE writes category-aligned `prob` plus exact `dive_confidence_pairs` on each annotation. * Re-importing that file into DIVE preserves hierarchy edges, track IDs, complete confidence vectors, attributes, and notes. -* For video datasets, DIVE also writes `videos[].fps` (and `images[].video_id`) so annotation +* For video datasets, DIVE also writes `videos[].annotation_fps` (and `images[].video_id`) so annotation FPS round-trips. Image-sequence exports omit `videos`. See - [Annotation frame rate (`videos[].fps`)](#annotation-frame-rate-videosfps). + [Annotation frame rate (`videos[].annotation_fps`)](#annotation-frame-rate-videosannotation_fps). For COCO files not produced by DIVE: diff --git a/server/dive_server/crud_dataset.py b/server/dive_server/crud_dataset.py index d0bd8ba9f..6e9eb9dcf 100644 --- a/server/dive_server/crud_dataset.py +++ b/server/dive_server/crud_dataset.py @@ -829,7 +829,7 @@ def _coco_json_export_text( for feature in track_data.get('features', []): max_frame = max(max_frame, feature.get('frame', -1)) image_filenames = {i: f'frame_{i:06d}.jpg' for i in range(max_frame + 1)} - # Annotation FPS rides on videos[].fps for video datasets only; image sequences + # Annotation FPS rides on videos[].annotation_fps for video datasets only; image sequences # omit the table so re-import does not treat them as video. export_fps = None if dataset_type == constants.VideoType: diff --git a/server/dive_utils/serializers/dive.py b/server/dive_utils/serializers/dive.py index ac45cafd0..23a3281d9 100644 --- a/server/dive_utils/serializers/dive.py +++ b/server/dive_utils/serializers/dive.py @@ -7,7 +7,7 @@ def frame_rate_from_dive(data: Any) -> Optional[float]: """Annotation FPS recorded on a DIVE JSON document, if usable. - Same rules as the VIAME CSV ``fps:`` header and COCO ``videos[].fps``: + Same rules as the VIAME CSV ``fps:`` header and COCO ``videos[].annotation_fps``: a finite number greater than zero. Absent or unusable values are not an error. """ if not isinstance(data, dict): diff --git a/server/dive_utils/serializers/kwcoco.py b/server/dive_utils/serializers/kwcoco.py index d2e40877d..dda69e0c7 100644 --- a/server/dive_utils/serializers/kwcoco.py +++ b/server/dive_utils/serializers/kwcoco.py @@ -241,7 +241,7 @@ def _validate_annotation_bounds(annotations: List[dict]) -> None: def frame_rate_from_coco(coco: Dict[str, Any]) -> Optional[float]: - """Frame rate recorded on the video, the COCO counterpart of the CSV header's fps. + """Rate the annotations were produced at, the counterpart of the CSV header's fps. Neither MS-COCO nor KWCOCO define a frame rate, so this reads the field VIAME writes on the video entry. Image-sequence documents describe no video and @@ -250,7 +250,7 @@ def frame_rate_from_coco(coco: Dict[str, Any]) -> Optional[float]: for video in coco.get('videos') or []: if not isinstance(video, dict): continue - rate = video.get('fps') + rate = video.get('annotation_fps') if isinstance(rate, bool) or not isinstance(rate, (int, float)): continue if math.isfinite(rate) and rate > 0: @@ -716,5 +716,7 @@ def add_category_name(name: str) -> None: 'categories': categories_doc, } if emit_video: - coco['videos'] = [{'id': 1, 'name': dataset_name, 'fps': float(fps)}] + coco['videos'] = [ + {'id': 1, 'name': dataset_name, 'annotation_fps': float(fps)} + ] return coco diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 57da260e0..5c7f193e8 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -807,7 +807,7 @@ def test_export_dive_as_coco_omits_empty_dataset_info(datasetInfo): def test_export_dive_as_coco_writes_video_fps(): - """Video annotation FPS lands on videos[].fps with images linked by video_id.""" + """Video annotation FPS lands on videos[].annotation_fps, images linked by video_id.""" coco = kwcoco.export_dive_as_coco( _EXPORT_TRACKS, {0: "frame_000000.jpg"}, dataset_name="clip", fps=5 ) @@ -1224,10 +1224,10 @@ def _fps_document(videos=None): def test_frame_rate_read_from_video(): """The COCO counterpart of the VIAME CSV header's fps.""" assert kwcoco.frame_rate_from_coco( - _fps_document([{'id': 1, 'name': 'clip', 'fps': 5}]) + _fps_document([{'id': 1, 'name': 'clip', 'annotation_fps': 5}]) ) == 5.0 assert kwcoco.frame_rate_from_coco( - _fps_document([{'id': 1}, {'id': 2, 'name': 'clip', 'fps': 29.97}]) + _fps_document([{'id': 1}, {'id': 2, 'name': 'clip', 'annotation_fps': 29.97}]) ) == 29.97 @@ -1237,5 +1237,5 @@ def test_frame_rate_absent_or_unusable(): assert kwcoco.frame_rate_from_coco(_fps_document([])) is None for fps in [0, -5, '5', True, float('inf'), float('nan'), None]: assert kwcoco.frame_rate_from_coco( - _fps_document([{'id': 1, 'fps': fps}]) + _fps_document([{'id': 1, 'annotation_fps': fps}]) ) is None From 9461978a860d5d01f3a591a0fcf3f9243474555a Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 21 Aug 2026 14:16:33 -0400 Subject: [PATCH 2/2] Rename the export assertion too The export test still expected the old key. --- server/tests/test_deserialize_kwcoco_json.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 5c7f193e8..43321e0ca 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -811,7 +811,7 @@ def test_export_dive_as_coco_writes_video_fps(): coco = kwcoco.export_dive_as_coco( _EXPORT_TRACKS, {0: "frame_000000.jpg"}, dataset_name="clip", fps=5 ) - assert coco["videos"] == [{"id": 1, "name": "clip", "fps": 5.0}] + assert coco["videos"] == [{"id": 1, "name": "clip", "annotation_fps": 5.0}] assert all(image.get("video_id") == 1 for image in coco["images"]) assert kwcoco.frame_rate_from_coco(coco) == 5.0