diff --git a/client/platform/desktop/backend/serializers/coco.spec.ts b/client/platform/desktop/backend/serializers/coco.spec.ts index f8db20442..1f1358670 100644 --- a/client/platform/desktop/backend/serializers/coco.spec.ts +++ b/client/platform/desktop/backend/serializers/coco.spec.ts @@ -257,15 +257,15 @@ describe('COCO serializer', () => { await serializeFile('/output/out.coco.json', annotationSchema, imageMeta); const out = await fs.readJSON('/output/out.coco.json'); expect(out.info.dive_extensions).toEqual([ - 'dive_detection_attributes', - 'dive_track_attributes', - 'dive_notes', - 'dive_confidence_pairs', + 'attributes', + 'track_attributes', + 'notes', + 'confidence_pairs', ]); expect(out.annotations).toHaveLength(1); - expect(out.annotations[0].dive_detection_attributes).toEqual({ visibility: 'poor' }); - expect(out.annotations[0].dive_track_attributes).toEqual({ reviewer: 'alice' }); - expect(out.annotations[0].dive_notes).toEqual(['exported note']); + expect(out.annotations[0].attributes).toEqual({ visibility: 'poor' }); + expect(out.annotations[0].track_attributes).toEqual({ reviewer: 'alice' }); + expect(out.annotations[0].notes).toEqual(['exported note']); }); // --- datasetInfo passthrough --- @@ -670,13 +670,13 @@ describe('COCO serializer', () => { .map(({ name, supercategory }: { name: string; supercategory: string }) => ( [name, supercategory] )))).toEqual(profile.expectedParents); - expect(out.info.dive_extensions).toContain('dive_confidence_pairs'); + expect(out.info.dive_extensions).toContain('confidence_pairs'); expect(out.annotations[0]).toMatchObject({ category_id: 2, track_id: profileTrack.id, score: 0.75, prob: profile.expectedProb, - dive_confidence_pairs: profile.expectedPairs, + confidence_pairs: profile.expectedPairs, }); expect(source.tracks[profileTrack.id].confidencePairs).toEqual(originalPairs); @@ -706,7 +706,7 @@ describe('COCO serializer', () => { }, new Set(['root'])); const out = await fs.readJSON('/output/filtered.json'); // Export filters raw stored names even though hierarchy display resolves this track to leaf. - expect(out.annotations[0].dive_confidence_pairs).toEqual([['root', 0.2]]); + expect(out.annotations[0].confidence_pairs).toEqual([['root', 0.2]]); expect(out.annotations[0].prob).toEqual([0.2, 0]); expect(source.tracks[4].confidencePairs).toEqual([['root', 0.2], ['leaf', 0.8]]); @@ -746,6 +746,45 @@ describe('COCO serializer', () => { const [, stringMeta] = await parseFile('/input/not-a-number.json'); expect(stringMeta.fps).toBeUndefined(); }); + + it('reads generic attribute, note and confidence keys, and the older prefixed ones', async () => { + const document = (extra: Record) => JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000000.png', frame_index: 0 }], + annotations: [{ + id: 1, image_id: 1, category_id: 1, bbox: [0, 0, 1, 1], track_id: 1, ...extra, + }], + categories: [{ id: 1, name: 'fish' }, { id: 2, name: 'shark' }], + }); + mockfs({ + '/input': { + 'generic.json': document({ + attributes: { occluded: true }, + track_attributes: { gear: 'trawl' }, + notes: ['a note'], + confidence_pairs: [['shark', 0.6], ['fish', 0.4]], + }), + 'prefixed.json': document({ + dive_detection_attributes: { occluded: true }, + dive_track_attributes: { gear: 'trawl' }, + dive_notes: ['a note'], + dive_confidence_pairs: [['shark', 0.6], ['fish', 0.4]], + }), + }, + }); + + const expectImported = async (name: string) => { + const [parsed] = await parseFile(`/input/${name}`); + expect(parsed.tracks[1].features[0].attributes).toEqual({ occluded: true }); + expect(parsed.tracks[1].attributes).toEqual({ gear: 'trawl' }); + expect(parsed.tracks[1].features[0].notes).toEqual(['a note']); + expect(parsed.tracks[1].confidencePairs).toEqual([['shark', 0.6], ['fish', 0.4]]); + }; + + // Attributes and notes are not DIVE concepts, so the plain names are read; + // files DIVE wrote before the rename keep importing. + await expectImported('generic.json'); + await expectImported('prefixed.json'); + }); }); afterEach(() => { diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index b9b63b67e..8d48c6acd 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -40,7 +40,7 @@ const PROB_DUPLICATE_CATEGORY_WARNING = ( + 'imported instead.' ); const DIVE_CONFIDENCE_PAIRS_INVALID_WARNING = ( - 'Some annotations had malformed "dive_confidence_pairs" values. Those values were ' + 'Some annotations had malformed "confidence_pairs" values. Those values were ' + 'ignored; a valid "prob" vector or the primary category and score were imported instead.' ); const SUPERCATEGORY_MULTI_PARENT_WARNING = ( @@ -212,6 +212,7 @@ type CocoAnnotation = { score?: number; track_id?: number; prob?: unknown; + confidence_pairs?: unknown; dive_confidence_pairs?: unknown; /** * COCO `iscrowd` flag (0 or 1). In the COCO spec, 0 means a single instance with @@ -413,15 +414,15 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record `${entry}`.trim()) @@ -597,10 +598,10 @@ async function serializeFile( bbox: [x1, y1, Math.max(0, x2 - x1), Math.max(0, y2 - y1)], score, prob, - dive_confidence_pairs: pairs.map(([name, confidence]) => [name, confidence]), - ...(feature.attributes ? { dive_detection_attributes: feature.attributes } : {}), - ...(track.attributes ? { dive_track_attributes: track.attributes } : {}), - ...(feature.notes && feature.notes.length > 0 ? { dive_notes: feature.notes } : {}), + confidence_pairs: pairs.map(([name, confidence]) => [name, confidence]), + ...(feature.attributes ? { attributes: feature.attributes } : {}), + ...(track.attributes ? { track_attributes: track.attributes } : {}), + ...(feature.notes && feature.notes.length > 0 ? { notes: feature.notes } : {}), }); annotationId += 1; }); @@ -617,10 +618,10 @@ async function serializeFile( const info: CocoDocument['info'] = { description: `DIVE export for ${meta.name}`, dive_extensions: [ - 'dive_detection_attributes', - 'dive_track_attributes', - 'dive_notes', - 'dive_confidence_pairs', + 'attributes', + 'track_attributes', + 'notes', + 'confidence_pairs', ...(datasetInfo ? ['dive_dataset_info'] : []), ], ...(datasetInfo ? { dive_dataset_info: datasetInfo } : {}), diff --git a/docs/DataFormats.md b/docs/DataFormats.md index eb7de720e..2b439d8e8 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -398,13 +398,14 @@ DIVE Web and Desktop use the same KWCOCO profile for hierarchy and complete conf exported pair, so readers that ignore KWCOCO extensions still receive a primary category. * Every annotation also has a dense `prob` array aligned by position with the document's complete `categories` array. -* `dive_confidence_pairs` stores the track's ordered sparse vector exactly. This preserves the +* `confidence_pairs` stores the track's ordered sparse vector exactly. This preserves the difference between a missing pair and a pair explicitly scored `0`, which a dense `prob` array cannot express. The extension is listed in `info.dive_extensions` and takes precedence when a DIVE-authored file is imported again. A present but malformed extension produces one import - warning and falls back to a valid `prob` vector or the primary category and score. + warning and falls back to a valid `prob` vector or the primary category and score. On import + the older `dive_confidence_pairs` spelling is still accepted. -For an external KWCOCO file without `dive_confidence_pairs`, DIVE maps `prob` by the original +For an external KWCOCO file without `confidence_pairs`, DIVE maps `prob` by the original category-array order, including unnamed positional slots. It accepts finite numeric values, clamps them to `[0, 1]`, keeps the ten highest entries above `0.001`, and falls back to `category_id` plus `score` when the vector length is wrong or duplicate category names make the mapping ambiguous. @@ -424,16 +425,19 @@ confidence vector. ### DIVE COCO Attribute Extensions COCO does not define standard fields for arbitrary track or detection attributes -or free-form notes. To preserve DIVE attributes and notes during COCO -export/import, DIVE uses extension fields on each COCO `annotation` object: +or free-form notes. Nothing about them is specific to DIVE, so DIVE writes them +under plain names on each COCO `annotation` object: -* `dive_detection_attributes`: Detection/frame-level attributes (maps to `Feature.attributes`) -* `dive_track_attributes`: Track-level attributes (maps to `Track.attributes`) -* `dive_notes`: Per-detection note (maps to `Feature.notes`) +* `attributes`: Detection/frame-level attributes (maps to `Feature.attributes`) +* `track_attributes`: Track-level attributes (maps to `Track.attributes`) +* `notes`: Per-detection note (maps to `Feature.notes`) -These extension keys are declared in the COCO `info` object as: +The keys in use are declared in the COCO `info` object as: -* `info.dive_extensions = ["dive_detection_attributes", "dive_track_attributes", "dive_notes", "dive_confidence_pairs"]` +* `info.dive_extensions = ["attributes", "track_attributes", "notes", "confidence_pairs"]` + +Files written before these were renamed used a `dive_` prefix on each of the four +keys. Import still reads those, so older exports keep working. ### Dataset-level metadata (`datasetInfo`) @@ -473,17 +477,19 @@ in the top-level `videos` table (the COCO counterpart of the VIAME CSV `# metada The DIVE extension fields are JSON objects with user-defined key/value pairs. Values are typically strings, numbers, or booleans. -* `annotation.dive_detection_attributes` +* `annotation.attributes` * Scope: one COCO annotation (one frame-level detection) * DIVE mapping: `Track.features[i].attributes` -* `annotation.dive_track_attributes` + * Legacy alias: on import, if absent, DIVE also reads `dive_detection_attributes` +* `annotation.track_attributes` * Scope: logical track identity across frames (`track_id`) * DIVE mapping: `Track.attributes` -* `annotation.dive_notes` + * Legacy alias: on import, if absent, DIVE also reads `dive_track_attributes` +* `annotation.notes` * Scope: one COCO annotation (one frame-level detection) * Type: `string[]` (typically one entry; a single non-empty string is also accepted on import) * DIVE mapping: `Track.features[i].notes` - * Legacy alias: on import, if `dive_notes` is absent, DIVE also reads `notes` + * Legacy alias: on import, if absent, DIVE also reads `dive_notes` When importing, DIVE merges any keys in the attribute objects into the target detection/track attribute dictionaries. If the same key appears in multiple @@ -495,11 +501,11 @@ that annotation only. For COCO files produced by DIVE: -* DIVE writes `info.dive_extensions` to advertise the extension keys used. -* DIVE writes `dive_detection_attributes` and `dive_track_attributes` on each - annotation when attributes are present. -* DIVE writes `dive_notes` on each annotation when that feature has a note. -* DIVE writes category-aligned `prob` plus exact `dive_confidence_pairs` on each annotation. +* DIVE writes `info.dive_extensions` to advertise the keys used. +* DIVE writes `attributes` and `track_attributes` on each annotation when + attributes are present. +* DIVE writes `notes` on each annotation when that feature has a note. +* DIVE writes category-aligned `prob` plus exact `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 @@ -530,7 +536,7 @@ For COCO files not produced by DIVE: { "info": { "description": "DIVE export for my-dataset", - "dive_extensions": ["dive_detection_attributes", "dive_track_attributes", "dive_notes", "dive_confidence_pairs"] + "dive_extensions": ["attributes", "track_attributes", "notes", "confidence_pairs"] }, "images": [ { "id": 1, "file_name": "frame_000000.jpg", "frame_index": 0 } @@ -548,17 +554,17 @@ For COCO files not produced by DIVE: "bbox": [100, 200, 50, 80], "score": 0.97, "prob": [0.03, 0.97, 0], - "dive_confidence_pairs": [["shark", 0.97], ["fish", 0.03]], + "confidence_pairs": [["shark", 0.97], ["fish", 0.03]], "track_id": 42, - "dive_detection_attributes": { + "attributes": { "visibility": "poor", "occluded": true }, - "dive_track_attributes": { + "track_attributes": { "reviewed": true, "source": "analyst" }, - "dive_notes": ["primary observation"] + "notes": ["primary observation"] }, { "id": 2, @@ -567,17 +573,17 @@ For COCO files not produced by DIVE: "bbox": [320, 140, 120, 90], "score": 0.91, "prob": [0, 0, 0.91], - "dive_confidence_pairs": [["crab", 0.91]], + "confidence_pairs": [["crab", 0.91]], "track_id": 77, "segmentation": [ [320, 140, 360, 130, 430, 170, 440, 220, 360, 230, 325, 200] ], "keypoints": [350, 150, 2, 410, 210, 2], "num_keypoints": 2, - "dive_detection_attributes": { + "attributes": { "visibility": "clear" }, - "dive_track_attributes": { + "track_attributes": { "species_confidence_note": "manual QA" } } @@ -607,14 +613,14 @@ parents (`fish`) and unused children (`ray`). The annotation below scores `great white shark` highest, keeps ancestor `shark` at an explicit `0`, and scores unrelated `rock`. Dense `prob` is aligned with `categories` order; missing pairs become `0` there. Sparse -`dive_confidence_pairs` is the source of truth: `shark` scored `0` is kept, while +`confidence_pairs` is the source of truth: `shark` scored `0` is kept, while `fish` and `ray` are absent rather than zero. ```json { "info": { "description": "DIVE export for my-dataset", - "dive_extensions": ["dive_confidence_pairs"] + "dive_extensions": ["confidence_pairs"] }, "images": [ { "id": 1, "file_name": "frame_000000.jpg", "frame_index": 0 } @@ -634,7 +640,7 @@ at an explicit `0`, and scores unrelated `rock`. Dense `prob` is aligned with "bbox": [100, 200, 50, 80], "score": 0.91, "prob": [0, 0.91, 0.22, 0, 0], - "dive_confidence_pairs": [ + "confidence_pairs": [ ["shark", 0], ["great white shark", 0.91], ["rock", 0.22] diff --git a/server/dive_utils/serializers/kwcoco.py b/server/dive_utils/serializers/kwcoco.py index d2e40877d..e4ce6494c 100644 --- a/server/dive_utils/serializers/kwcoco.py +++ b/server/dive_utils/serializers/kwcoco.py @@ -33,7 +33,7 @@ 'imported instead.' ) DIVE_CONFIDENCE_PAIRS_WARNING = ( - 'Some annotations had malformed "dive_confidence_pairs" values. Those values were ' + 'Some annotations had malformed "confidence_pairs" values. Those values were ' 'ignored; a valid "prob" vector or the primary category and score were imported instead.' ) SUPERCATEGORY_MULTI_PARENT_WARNING = ( @@ -342,18 +342,18 @@ def _parse_annotation( # DIVE extension fields for non-standard COCO attributes. detection_attributes = annotation.get( - 'dive_detection_attributes', annotation.get('attributes', {}) + 'attributes', annotation.get('dive_detection_attributes', {}) ) if isinstance(detection_attributes, dict): attributes.update(detection_attributes) track_attributes_value = annotation.get( - 'dive_track_attributes', - annotation.get('track_attributes', {}), + 'track_attributes', + annotation.get('dive_track_attributes', {}), ) if isinstance(track_attributes_value, dict): track_attributes.update(track_attributes_value) - note_values = annotation.get('dive_notes', annotation.get('notes', [])) + note_values = annotation.get('notes', annotation.get('dive_notes', [])) if isinstance(note_values, list): notes.extend([str(value).strip() for value in note_values if str(value).strip()]) elif isinstance(note_values, str) and note_values.strip(): @@ -478,8 +478,12 @@ def load_coco_as_tracks_and_attributes( ) = _parse_annotation_for_tracks(annotation, meta) skipped_rle_masks = skipped_rle_masks or rle_skipped - extension_present = 'dive_confidence_pairs' in annotation - extension_pairs = _confidence_pairs_from_extension(annotation.get('dive_confidence_pairs')) + extension_present = ( + 'confidence_pairs' in annotation or 'dive_confidence_pairs' in annotation + ) + extension_pairs = _confidence_pairs_from_extension( + annotation.get('confidence_pairs', annotation.get('dive_confidence_pairs')) + ) if extension_pairs is not None: confidence_pairs = extension_pairs else: @@ -668,16 +672,16 @@ def add_category_name(name: str) -> None: 'prob': [dict(track.confidencePairs).get(name, 0.0) for name in category_names], # Preserve sparse membership and explicit zero confidence without # requiring consumers to infer it from a dense probability vector. - 'dive_confidence_pairs': [list(pair) for pair in track.confidencePairs], + 'confidence_pairs': [list(pair) for pair in track.confidencePairs], } # Keep a stable object identity across frames when track data exists. annotation['track_id'] = track.id if feature.attributes: - annotation['dive_detection_attributes'] = feature.attributes + annotation['attributes'] = feature.attributes if track.attributes: - annotation['dive_track_attributes'] = track.attributes + annotation['track_attributes'] = track.attributes if feature.notes: - annotation['dive_notes'] = feature.notes + annotation['notes'] = feature.notes if segmentation: annotation['segmentation'] = segmentation if keypoints: @@ -699,10 +703,10 @@ def add_category_name(name: str) -> None: info: Dict[str, Any] = { 'description': f'DIVE export for {dataset_name}', 'dive_extensions': [ - 'dive_detection_attributes', - 'dive_track_attributes', - 'dive_notes', - 'dive_confidence_pairs', + 'attributes', + 'track_attributes', + 'notes', + 'confidence_pairs', ], } if datasetInfo: diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 57da260e0..fed503e25 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -726,10 +726,10 @@ def test_export_dive_as_coco_single_dataset(): assert len(coco["annotations"]) == 1 assert coco["annotations"][0]["track_id"] == 7 assert coco["annotations"][0]["bbox"] == [10, 20, 20, 40] - assert coco["annotations"][0]["dive_detection_attributes"] == {"occluded": True} - assert coco["annotations"][0]["dive_track_attributes"] == {"gear": "trawl"} - assert coco["annotations"][0]["dive_notes"] == ["net near reef"] - assert "dive_notes" in coco["info"]["dive_extensions"] + assert coco["annotations"][0]["attributes"] == {"occluded": True} + assert coco["annotations"][0]["track_attributes"] == {"gear": "trawl"} + assert coco["annotations"][0]["notes"] == ["net near reef"] + assert "notes" in coco["info"]["dive_extensions"] def test_export_dive_as_coco_preserves_pairs_and_category_hierarchy_roundtrip(): @@ -752,8 +752,8 @@ def test_export_dive_as_coco_preserves_pairs_and_category_hierarchy_roundtrip(): assert annotation['category_id'] == categories['leaf']['id'] assert annotation['score'] == 0.75 assert annotation['prob'] == profile['expectedProb'] - assert annotation['dive_confidence_pairs'] == profile['expectedPairs'] - assert 'dive_confidence_pairs' in exported['info']['dive_extensions'] + assert annotation['confidence_pairs'] == profile['expectedPairs'] + assert 'confidence_pairs' in exported['info']['dive_extensions'] converted, _, warnings, _ = kwcoco.load_coco_as_tracks_and_attributes(exported) track_id = str(profile['tracks'][0]['id']) @@ -1239,3 +1239,50 @@ def test_frame_rate_absent_or_unusable(): assert kwcoco.frame_rate_from_coco( _fps_document([{'id': 1, 'fps': fps}]) ) is None + + +def _generic_key_annotation(**extra): + return { + 'images': [{'id': 1, 'file_name': 'frame_000000.png', 'frame_index': 0}], + 'annotations': [ + dict( + {'id': 1, 'image_id': 1, 'category_id': 1, 'bbox': [0, 0, 1, 1], 'track_id': 1}, + **extra, + ) + ], + 'categories': [{'id': 1, 'name': 'fish'}, {'id': 2, 'name': 'shark'}], + } + + +def test_generic_keys_are_read(): + """Attributes and notes are not DIVE concepts, so the plain names are read.""" + tracks, _, _, _ = kwcoco.load_coco_as_tracks_and_attributes( + _generic_key_annotation( + attributes={'occluded': True}, + track_attributes={'gear': 'trawl'}, + notes=['a note'], + confidence_pairs=[['shark', 0.6], ['fish', 0.4]], + ) + ) + track = tracks['tracks']['1'] + assert track['features'][0]['attributes'] == {'occluded': True} + assert track['attributes'] == {'gear': 'trawl'} + assert track['features'][0]['notes'] == ['a note'] + assert track['confidencePairs'] == [('shark', 0.6), ('fish', 0.4)] + + +def test_prefixed_keys_still_read_for_older_files(): + """Files DIVE wrote before the rename keep importing.""" + tracks, _, _, _ = kwcoco.load_coco_as_tracks_and_attributes( + _generic_key_annotation( + dive_detection_attributes={'occluded': True}, + dive_track_attributes={'gear': 'trawl'}, + dive_notes=['a note'], + dive_confidence_pairs=[['shark', 0.6], ['fish', 0.4]], + ) + ) + track = tracks['tracks']['1'] + assert track['features'][0]['attributes'] == {'occluded': True} + assert track['attributes'] == {'gear': 'trawl'} + assert track['features'][0]['notes'] == ['a note'] + assert track['confidencePairs'] == [('shark', 0.6), ('fish', 0.4)]