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
59 changes: 49 additions & 10 deletions client/platform/desktop/backend/serializers/coco.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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]]);

Expand Down Expand Up @@ -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<string, unknown>) => 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(() => {
Expand Down
35 changes: 18 additions & 17 deletions client/platform/desktop/backend/serializers/coco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -413,15 +414,15 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record<string
const category = categoriesById[annotation.category_id];
const categoryName = category?.name || 'unknown';
let confidencePairs: [string, number][] = [[categoryName, annotation.score ?? 1.0]];
const hasDiveConfidencePairs = Object.prototype.hasOwnProperty.call(
annotation,
'dive_confidence_pairs',
const hasExactPairs = Object.prototype.hasOwnProperty.call(annotation, 'confidence_pairs')
|| Object.prototype.hasOwnProperty.call(annotation, 'dive_confidence_pairs');
const exactPairs = confidencePairsFromDiveExtension(
annotation.confidence_pairs ?? annotation.dive_confidence_pairs,
);
const exactPairs = confidencePairsFromDiveExtension(annotation.dive_confidence_pairs);
if (exactPairs !== undefined) {
confidencePairs = exactPairs;
} else {
if (hasDiveConfidencePairs) {
if (hasExactPairs) {
diveConfidencePairsInvalid = true;
}
if (Array.isArray(annotation.prob)) {
Expand All @@ -448,7 +449,7 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record<string
};
}
const track = tracks[trackId];
const trackAttributes = annotation.dive_track_attributes || annotation.track_attributes;
const trackAttributes = annotation.track_attributes || annotation.dive_track_attributes;
if (trackAttributes && typeof trackAttributes === 'object') {
track.attributes = { ...track.attributes, ...trackAttributes };
}
Expand All @@ -458,11 +459,11 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record<string
frame,
bounds,
};
const featureAttributes = annotation.dive_detection_attributes || annotation.attributes;
const featureAttributes = annotation.attributes || annotation.dive_detection_attributes;
if (featureAttributes && typeof featureAttributes === 'object') {
feature.attributes = featureAttributes;
}
const noteField = annotation.dive_notes ?? annotation.notes;
const noteField = annotation.notes ?? annotation.dive_notes;
if (Array.isArray(noteField)) {
const normalized = noteField
.map((entry) => `${entry}`.trim())
Expand Down Expand Up @@ -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;
});
Expand All @@ -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 } : {}),
Expand Down
66 changes: 36 additions & 30 deletions docs/DataFormats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
Expand All @@ -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"
}
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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]
Expand Down
Loading
Loading