Skip to content
Closed
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
42 changes: 30 additions & 12 deletions client/platform/desktop/backend/serializers/coco.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,9 @@ describe('COCO serializer', () => {
expect(parsedMeta).not.toHaveProperty('datasetInfo');
});

// --- annotation fps on videos[] ---
// --- annotation fps in info.video_annotation_fps ---

it('writes videos[].fps for video datasets and restores it on re-import', async () => {
it('writes info.video_annotation_fps for video datasets and restores it on re-import', async () => {
const videoMeta = {
...imageMeta,
type: 'video' as const,
Expand All @@ -327,7 +327,9 @@ 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' }]);
expect(out.info.video_annotation_fps).toEqual({ 1: 5 });
expect(out.info.dive_extensions).toContain('video_annotation_fps');
expect(out.images.every((image: { video_id?: number }) => image.video_id === 1)).toBe(true);

mockfs({
Expand All @@ -342,6 +344,7 @@ describe('COCO serializer', () => {
await serializeFile('/output/seq.coco.json', annotationSchema, { ...imageMeta, fps: 5 });
const out = await fs.readJSON('/output/seq.coco.json');
expect(out).not.toHaveProperty('videos');
expect(out.info).not.toHaveProperty('video_annotation_fps');
expect(out.images.every((image: { video_id?: number }) => image.video_id === undefined)).toBe(true);
});

Expand All @@ -353,6 +356,7 @@ describe('COCO serializer', () => {
});
const out = await fs.readJSON('/output/zero.coco.json');
expect(out).not.toHaveProperty('videos');
expect(out.info).not.toHaveProperty('video_annotation_fps');
});

it('imports a pruned KWCOCO probability vector by raw category position', async () => {
Expand Down Expand Up @@ -715,26 +719,40 @@ describe('COCO serializer', () => {
expect(parsed.tracks[4].confidencePairs).toEqual([['root', 0.2]]);
});

it('imports the frame rate a video records, as the CSV header path does', async () => {
const document = (videos: unknown) => JSON.stringify({
it('imports the frame rate from info.video_annotation_fps only', async () => {
const document = (opts: {
videos?: unknown;
video_annotation_fps?: Record<string, unknown>;
}) => JSON.stringify({
...(opts.video_annotation_fps
? { info: { video_annotation_fps: opts.video_annotation_fps } }
: {}),
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,
}],
categories: [{ id: 1, name: 'fish' }],
...(videos === undefined ? {} : { videos }),
...(opts.videos === undefined ? {} : { videos: opts.videos }),
});
mockfs({
'/input': {
'video.json': document([{ id: 1, name: 'clip', 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' }]),
'info-map.json': document({
videos: [{ id: 1, name: 'clip' }],
video_annotation_fps: { 1: 5 },
}),
'videos-fps-only.json': document({ videos: [{ id: 1, name: 'clip', fps: 5 }] }),
'image-list.json': document({}),
'unusable.json': document({ video_annotation_fps: { 1: 0 } }),
'not-a-number.json': document({ video_annotation_fps: { 1: '5' } }),
},
});

const [, videoMeta] = await parseFile('/input/video.json');
expect(videoMeta.fps).toBe(5);
const [, infoMeta] = await parseFile('/input/info-map.json');
expect(infoMeta.fps).toBe(5);

// fps on videos[] is ignored.
const [, videosFpsMeta] = await parseFile('/input/videos-fps-only.json');
expect(videosFpsMeta.fps).toBeUndefined();

// An image sequence describes no video, so it carries no rate to import.
const [, listMeta] = await parseFile('/input/image-list.json');
Expand Down
42 changes: 31 additions & 11 deletions client/platform/desktop/backend/serializers/coco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ type CocoAnnotation = {
type CocoVideo = {
id: number;
name?: string;
fps?: unknown;
};

type CocoDocument = {
Expand All @@ -245,20 +244,38 @@ type CocoDocument = {
videos?: CocoVideo[];
};

function usableFpsRate(rate: unknown): number | undefined {
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) {
return rate;
}
return undefined;
}

/**
* Frame rate recorded on the video, the COCO counterpart of the VIAME CSV
* header's fps. Neither COCO nor kwcoco define one, so this reads the field
* VIAME writes on the video entry; an image sequence describes no video and
* carries none, which is not an error.
* Annotation FPS for a COCO/KWCOCO document, if usable.
* Reads `info.video_annotation_fps` (video_id → rate). Image sequences
* describe no video and carry none, which is not an error.
*/
function frameRateFromDocument(document: CocoDocument): number | undefined {
const { info } = document;
const fpsMap = info?.video_annotation_fps;
if (!fpsMap || typeof fpsMap !== 'object' || Array.isArray(fpsMap)) {
return undefined;
}
const map = fpsMap as Record<string, unknown>;
const videos = Array.isArray(document.videos) ? document.videos : [];
for (let i = 0; i < videos.length; i += 1) {
const rate = videos[i]?.fps;
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) {
return rate;
const videoId = videos[i]?.id;
if (videoId !== undefined) {
const rate = usableFpsRate(map[videoId]) ?? usableFpsRate(map[String(videoId)]);
if (rate !== undefined) return rate;
}
}
const values = Object.values(map);
for (let i = 0; i < values.length; i += 1) {
const rate = usableFpsRate(values[i]);
if (rate !== undefined) return rate;
}
return undefined;
}

Expand Down Expand Up @@ -560,8 +577,9 @@ async function serializeFile(
Array.from(new Set(Object.values(hierarchy))).sort().forEach(addCategoryName);
const categories = new Map(categoryNames.map((name, index) => [name, index + 1]));

// Video datasets record annotation FPS on a one-entry `videos` table (VIAME
// convention). Image sequences omit it so re-import does not treat them as video.
// Video datasets record annotation FPS under info.video_annotation_fps keyed
// by video_id, with a one-entry videos table. Image sequences omit videos so
// re-import does not treat them as video.
const emitVideo = (
meta.type === 'video'
&& typeof meta.fps === 'number'
Expand Down Expand Up @@ -622,16 +640,18 @@ async function serializeFile(
'dive_notes',
'dive_confidence_pairs',
...(datasetInfo ? ['dive_dataset_info'] : []),
...(emitVideo ? ['video_annotation_fps'] : []),
],
...(datasetInfo ? { dive_dataset_info: datasetInfo } : {}),
...(emitVideo ? { video_annotation_fps: { 1: meta.fps } } : {}),
};
const output: CocoDocument = {
info,
images: Array.from(images.values()),
annotations,
categories: categoryDocs,
...(emitVideo ? {
videos: [{ id: 1, name: meta.name, fps: meta.fps }],
videos: [{ id: 1, name: meta.name }],
} : {}),
};
await fs.writeJSON(path, output, { spaces: 2 });
Expand Down
2 changes: 1 addition & 1 deletion client/platform/desktop/backend/serializers/dive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `info.video_annotation_fps`.
*/
function frameRateFromDocument(data: unknown): number | undefined {
if (!data || typeof data !== 'object') {
Expand Down
42 changes: 26 additions & 16 deletions docs/DataFormats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `info.video_annotation_fps`.
*/
fps?: number;
}
Expand Down Expand Up @@ -120,7 +120,8 @@ 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 in
`info.video_annotation_fps`.

```json
{
Expand Down Expand Up @@ -208,7 +209,8 @@ 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-infovideo_annotation_fps) as
`info.video_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
Expand Down Expand Up @@ -443,17 +445,22 @@ advertised in `info.dive_extensions`:

* `info.dive_dataset_info = { "gfishsite_id": "2024TXN012", "year": "2024", ... }`

### Annotation frame rate (`videos[].fps`)
### Annotation frame rate (`info.video_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
in the top-level `videos` table (the COCO counterpart of the VIAME CSV `# metadata`
`fps` header). Image-sequence documents typically omit `videos` and carry no rate.
Neither MS-COCO nor KWCOCO define a frame-rate field. DIVE stores the annotation
FPS under `info.video_annotation_fps` as a map of KWCOCO `video_id` → rate (the
COCO counterpart of the VIAME CSV `# metadata` `fps` header). Image-sequence
documents typically omit `videos` and carry no rate.

```json
{
"info": {
"description": "DIVE export for clip",
"dive_extensions": ["video_annotation_fps"],
"video_annotation_fps": { "1": 5 }
},
"videos": [
{ "id": 1, "name": "clip", "fps": 5 }
{ "id": 1, "name": "clip" }
],
"images": [
{ "id": 1, "file_name": "frame_000000.jpg", "frame_index": 0, "video_id": 1 }
Expand All @@ -463,10 +470,12 @@ in the top-level `videos` table (the COCO counterpart of the VIAME CSV `# metada

* A usable value (finite number greater than zero) is restored into dataset metadata as
`fps`. Unusable values (`0`, negative, non-numeric, `inf`/`nan`) are ignored.
* When multiple video entries are present, the first usable `fps` wins.
* On export of a **video** dataset, DIVE writes a one-entry `videos` table with the
annotation FPS and sets `images[].video_id`. Image-sequence exports omit `videos`
so re-import does not treat them as video.
* Keys are stringified `video_id` values (JSON object keys are always strings).
* When multiple rates are present, the first usable value wins (videos-table order,
then map insertion order).
* On export of a **video** dataset, DIVE writes a one-entry `videos` table, sets
`images[].video_id`, and records the rate under `info.video_annotation_fps`.
Image-sequence exports omit `videos` so re-import does not treat them as video.

### Extension Field Details

Expand Down Expand Up @@ -502,9 +511,10 @@ 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
FPS round-trips. Image-sequence exports omit `videos`. See
[Annotation frame rate (`videos[].fps`)](#annotation-frame-rate-videosfps).
* For video datasets, DIVE also writes `info.video_annotation_fps` (with `videos[]`
and `images[].video_id`) so annotation FPS round-trips. Image-sequence exports
omit `videos`. See
[Annotation frame rate (`info.video_annotation_fps`)](#annotation-frame-rate-infovideo_annotation_fps).

For COCO files not produced by DIVE:

Expand Down
4 changes: 2 additions & 2 deletions server/dive_server/crud_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -829,8 +829,8 @@ 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
# omit the table so re-import does not treat them as video.
# Annotation FPS rides in info.video_annotation_fps for video datasets only;
# image sequences omit the videos table so re-import does not treat them as video.
export_fps = None
if dataset_type == constants.VideoType:
fps = fromMeta(dsFolder, constants.FPSMarker, None)
Expand Down
2 changes: 1 addition & 1 deletion server/dive_utils/serializers/dive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``info.video_annotation_fps``:
a finite number greater than zero. Absent or unusable values are not an error.
"""
if not isinstance(data, dict):
Expand Down
63 changes: 42 additions & 21 deletions server/dive_utils/serializers/kwcoco.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,42 @@ def _validate_annotation_bounds(annotations: List[dict]) -> None:
raise ValueError(_missing_bounds_error(missing_ids))


def _usable_fps_rate(rate: Any) -> Optional[float]:
"""Return rate when it is a finite number greater than zero; otherwise None."""
if isinstance(rate, bool) or not isinstance(rate, (int, float)):
return None
if math.isfinite(rate) and rate > 0:
return float(rate)
return 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.
"""Annotation FPS for a COCO/KWCOCO document, if usable.

Reads ``info.video_annotation_fps``, a map of ``video_id`` → rate (the DIVE
export shape). Image-sequence documents describe no video and carry none,
which is not an error.

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
carry none, which is not an error.
When multiple rates are present, the first usable value wins: videos-table
order for the map (looking up each ``video_id``), then map insertion order.
"""
info = coco.get('info') or {}
fps_map = info.get('video_annotation_fps') if isinstance(info, dict) else None
if not isinstance(fps_map, dict):
return None
for video in coco.get('videos') or []:
if not isinstance(video, dict):
continue
rate = video.get('fps')
if isinstance(rate, bool) or not isinstance(rate, (int, float)):
if not isinstance(video, dict) or 'id' not in video:
continue
if math.isfinite(rate) and rate > 0:
return float(rate)
video_id = video['id']
rate = _usable_fps_rate(fps_map.get(video_id))
if rate is None:
rate = _usable_fps_rate(fps_map.get(str(video_id)))
if rate is not None:
return rate
for rate in fps_map.values():
usable = _usable_fps_rate(rate)
if usable is not None:
return usable
return None


Expand Down Expand Up @@ -600,10 +621,11 @@ def export_dive_as_coco(
typeHierarchy: DIVE child-to-parent category hierarchy, emitted through
KWCOCO's ``categories[].supercategory`` field.
fps: Annotation frame rate for a video dataset. When usable (finite and
greater than zero), written on a one-entry ``videos`` table with
``images[].video_id`` set — the same convention VIAME uses and DIVE
imports. Callers should pass this only for video datasets; image
sequences omit ``videos`` so re-import does not treat them as video.
greater than zero), written under ``info.video_annotation_fps`` keyed
by ``video_id``, with a one-entry ``videos`` table and
``images[].video_id`` set. Callers should pass this only for video
datasets; image sequences omit ``videos`` so re-import does not treat
them as video.
"""
parsed_tracks = [Track(**track_doc) for track_doc in tracks]
category_names: List[str] = []
Expand All @@ -624,12 +646,7 @@ def add_category_name(name: str) -> None:
coco_annotations: List[dict] = []
images: Dict[int, dict] = {}
annotation_id = 1
emit_video = (
isinstance(fps, (int, float))
and not isinstance(fps, bool)
and math.isfinite(fps)
and fps > 0
)
emit_video = _usable_fps_rate(fps) is not None

for track in parsed_tracks:
for feature in track.features:
Expand Down Expand Up @@ -708,6 +725,10 @@ def add_category_name(name: str) -> None:
if datasetInfo:
info['dive_dataset_info'] = datasetInfo
info['dive_extensions'].append('dive_dataset_info')
if emit_video:
# JSON object keys are strings; video_id 1 → "1".
info['video_annotation_fps'] = {'1': float(fps)}
info['dive_extensions'].append('video_annotation_fps')

coco: Dict[str, Any] = {
'info': info,
Expand All @@ -716,5 +737,5 @@ 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}]
return coco
Loading
Loading