diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index 203090c48..c2ef869dc 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -779,9 +779,10 @@ async function _ingestFilePath( merge(meta, pick(jsonObject, DatasetMetaMutableKeys)); metadataConfig = true; } else if (coco.isCocoJson(jsonObject)) { - const [parsedAnnotations, parsedMeta] = await coco.parseFile(path); + const [parsedAnnotations, parsedMeta, cocoWarnings] = await coco.parseFile(path); annotations = parsedAnnotations; merge(meta, parsedMeta); + warnings = warnings.concat(cocoWarnings); } else { // Regular dive json annotations = await loadAnnotationFile(path); diff --git a/client/platform/desktop/backend/serializers/coco.spec.ts b/client/platform/desktop/backend/serializers/coco.spec.ts index 53800ee76..3fb4e997d 100644 --- a/client/platform/desktop/backend/serializers/coco.spec.ts +++ b/client/platform/desktop/backend/serializers/coco.spec.ts @@ -84,7 +84,8 @@ describe('COCO serializer', () => { }); it('parses COCO with DIVE extension attributes', async () => { - const [parsed] = await parseFile('/input/coco.json'); + const [parsed, , warnings] = await parseFile('/input/coco.json'); + expect(warnings).toEqual([]); const track = parsed.tracks[7]; expect(track.id).toBe(7); expect(track.begin).toBe(1); @@ -97,6 +98,107 @@ describe('COCO serializer', () => { expect(geometryTypes).toEqual(expect.arrayContaining(['Polygon', 'Point', 'LineString'])); }); + it('throws a descriptive error when bbox and polygon are both missing', async () => { + mockfs({ + '/input': { + 'coco_no_bbox.json': JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000001.jpg', frame_index: 0 }], + annotations: [{ + id: 2, + image_id: 1, + category_id: 5, + iscrowd: 1, + segmentation: { size: [100, 100], counts: 'abc' }, + }], + categories: [{ id: 5, name: 'fish' }], + }), + }, + }); + await expect(parseFile('/input/coco_no_bbox.json')).rejects.toThrow(/no bbox and no usable polygon/); + await expect(parseFile('/input/coco_no_bbox.json')).rejects.toThrow(/RLE segmentation masks still require a bbox/); + }); + + it('derives bbox from polygon when bbox is omitted', async () => { + mockfs({ + '/input': { + 'coco_polygon_only.json': JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000001.jpg', frame_index: 0 }], + annotations: [{ + id: 3, + image_id: 1, + category_id: 5, + track_id: 401, + segmentation: [[120, 80, 200, 80, 200, 120, 120, 120]], + }], + categories: [{ id: 5, name: 'fish' }], + }), + }, + }); + const [parsed, , warnings] = await parseFile('/input/coco_polygon_only.json'); + expect(parsed.tracks[401].features[0].bounds).toEqual([120, 80, 200, 120]); + expect(parsed.tracks[401].features[0].geometry?.features.length).toBe(1); + expect(warnings).toEqual([]); + }); + + it('imports polygon segmentations and warns on RLE in the same file', async () => { + mockfs({ + '/input': { + 'coco_mixed.json': JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000001.jpg', frame_index: 0 }], + annotations: [ + { + id: 1, + image_id: 1, + category_id: 5, + bbox: [120, 80, 80, 40], + track_id: 301, + segmentation: [[120, 80, 200, 80, 200, 120, 120, 120]], + }, + { + id: 2, + image_id: 1, + category_id: 5, + bbox: [400, 200, 200, 60], + track_id: 302, + iscrowd: 1, + segmentation: { size: [1080, 1920], counts: 'abc' }, + }, + ], + categories: [{ id: 5, name: 'fish' }], + }), + }, + }); + const [parsed, , warnings] = await parseFile('/input/coco_mixed.json'); + expect(parsed.tracks[301].features[0].geometry?.features.length).toBe(1); + expect(parsed.tracks[302].features[0].geometry).toBeUndefined(); + expect(warnings).toHaveLength(1); + }); + + it('imports bbox when RLE masks are present and returns a warning', async () => { + mockfs({ + '/input': { + 'coco_rle.json': JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000001.jpg', frame_index: 1 }], + annotations: [{ + id: 2, + image_id: 1, + category_id: 5, + bbox: [10, 20, 30, 40], + track_id: 8, + iscrowd: 1, + segmentation: { size: [100, 100], counts: 'abc' }, + }], + categories: [{ id: 5, name: 'fish' }], + }), + }, + }); + const [parsed, , warnings] = await parseFile('/input/coco_rle.json'); + expect(parsed.tracks[8].features[0].bounds).toEqual([10, 20, 40, 60]); + expect(parsed.tracks[8].features[0].geometry).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('segmentation masks'); + }); + it('serializes COCO with DIVE extension attributes', async () => { await serializeFile('/output/out.coco.json', annotationSchema, imageMeta); const out = await fs.readJSON('/output/out.coco.json'); diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index ac79ac60f..2326d20fd 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -16,15 +16,108 @@ type CocoCategory = { keypoints?: string[]; }; +const RLE_SEGMENTATION_WARNING = ( + 'The COCO file included run-length encoded segmentation masks that are not supported. ' + + 'Bounding boxes and other annotation data were imported, but masks were skipped.' +); + +function hasValidBbox(annotation: CocoAnnotation): boolean { + const { bbox } = annotation; + return Array.isArray(bbox) && bbox.length === 4; +} + +function extractPolygonCoordsLists( + segmentation: CocoAnnotation['segmentation'], +): [number, number][][] { + if (!segmentation || !Array.isArray(segmentation)) { + return []; + } + const polygons = ( + segmentation.length > 0 && typeof segmentation[0] === 'number' + ? [segmentation as number[]] + : segmentation + ) as Array>; + const coordLists: [number, number][][] = []; + polygons.forEach((polygon) => { + if (Array.isArray(polygon)) { + const coords: [number, number][] = []; + for (let i = 0; i + 1 < polygon.length; i += 2) { + coords.push([polygon[i], polygon[i + 1]]); + } + if (coords.length) { + coordLists.push(coords); + } + } + }); + return coordLists; +} + +function bboxFromPoints(points: [number, number][]): [number, number, number, number] { + const xs = points.map(([x]) => x); + const ys = points.map(([, y]) => y); + const xMin = Math.min(...xs); + const yMin = Math.min(...ys); + return [xMin, yMin, Math.max(...xs) - xMin, Math.max(...ys) - yMin]; +} + +function annotationHasImportableBounds(annotation: CocoAnnotation): boolean { + if (hasValidBbox(annotation)) { + return true; + } + if (hasRleSegmentation(annotation)) { + return false; + } + return extractPolygonCoordsLists(annotation.segmentation).length > 0; +} + +function missingBoundsError(annotationIds: Array): string { + const shown = annotationIds.slice(0, 10).join(', '); + const extra = annotationIds.length > 10 ? ` (and ${annotationIds.length - 10} more)` : ''; + return ( + `${annotationIds.length} COCO annotation(s) cannot be imported because they have no bbox and ` + + `no usable polygon segmentation (ids: ${shown}${extra}). ` + + 'Provide bbox [x, y, width, height] or polygon segmentation as [[x1, y1, ...]]. ' + + 'Annotations with only RLE segmentation masks still require a bbox.' + ); +} + +function resolveCocoBbox(annotation: CocoAnnotation): [number, number, number, number] { + if (hasValidBbox(annotation)) { + return annotation.bbox as [number, number, number, number]; + } + const allPoints = extractPolygonCoordsLists(annotation.segmentation).flat(); + if (allPoints.length) { + return bboxFromPoints(allPoints); + } + throw new Error(missingBoundsError([annotation.id])); +} + +function validateAnnotationBounds(annotations: CocoAnnotation[]): void { + const missingIds = annotations + .filter((annotation) => !annotationHasImportableBounds(annotation)) + .map((annotation) => annotation.id); + if (missingIds.length) { + throw new Error(missingBoundsError(missingIds)); + } +} + type CocoAnnotation = { id: number; image_id: number; category_id: number; - bbox: [number, number, number, number]; + bbox?: [number, number, number, number]; score?: number; track_id?: number; + /** + * COCO `iscrowd` flag (0 or 1). In the COCO spec, 0 means a single instance with + * polygon `segmentation` ([[x1, y1, ...]]); 1 means a crowd region whose + * `segmentation` is run-length encoded (RLE) as an object (e.g. { counts, size }). + * DIVE does not import RLE masks: when `iscrowd` is truthy, or `segmentation` is + * a dict, polygon/mask geometry is skipped (bbox and other fields still import). + */ + iscrowd?: number; keypoints?: number[]; - segmentation?: number[][]; + segmentation?: number[][] | Record; dive_detection_attributes?: Record; dive_track_attributes?: Record; dive_notes?: string[]; @@ -40,39 +133,35 @@ type CocoDocument = { categories: CocoCategory[]; }; +/** True when segmentation is COCO RLE (crowd / `iscrowd: 1`), which DIVE does not decode. */ +function hasRleSegmentation(annotation: CocoAnnotation): boolean { + if (annotation.iscrowd) { + return true; + } + const { segmentation } = annotation; + return Boolean(segmentation) && !Array.isArray(segmentation); +} + function buildFeatureGeometry( annotation: CocoAnnotation, category?: CocoCategory, -): GeoJSON.FeatureCollection | undefined { +): { geometry?: GeoJSON.FeatureCollection; rleSkipped: boolean } { + if (hasRleSegmentation(annotation)) { + return { rleSkipped: true }; + } const geometryFeatures: GeoJSON.Feature[] = []; - const { segmentation } = annotation; - if (segmentation) { - if (!Array.isArray(segmentation)) { - throw new Error('Run-length encoded COCO segmentation is not supported'); - } - const polygons = ( - segmentation.length > 0 && typeof segmentation[0] === 'number' - ? [segmentation] - : segmentation - ) as number[][]; - polygons.forEach((polygon) => { - const coords: number[][] = []; - for (let i = 0; i + 1 < polygon.length; i += 2) { - coords.push([polygon[i], polygon[i + 1]]); - } - if (coords.length) { - geometryFeatures.push({ - type: 'Feature', - properties: { key: '' }, - geometry: { - type: 'Polygon', - coordinates: [coords], - }, - }); - } + const coordLists = extractPolygonCoordsLists(annotation.segmentation); + coordLists.forEach((coords) => { + geometryFeatures.push({ + type: 'Feature', + properties: { key: '' }, + geometry: { + type: 'Polygon', + coordinates: [coords], + }, }); - } + }); const keypoints = annotation.keypoints || []; if (Array.isArray(keypoints) && keypoints.length >= 3) { @@ -110,10 +199,15 @@ function buildFeatureGeometry( } } - if (!geometryFeatures.length) return undefined; + if (!geometryFeatures.length) { + return { rleSkipped: false }; + } return { - type: 'FeatureCollection' as const, - features: geometryFeatures, + geometry: { + type: 'FeatureCollection' as const, + features: geometryFeatures, + }, + rleSkipped: false, }; } @@ -134,7 +228,7 @@ function imageFrameMap(document: CocoDocument): Record { return map; } -async function parseFile(path: string): Promise<[AnnotationSchema, Record]> { +async function parseFile(path: string): Promise<[AnnotationSchema, Record, string[]]> { const parsed = await fs.readJSON(path); if (!isCocoJson(parsed)) { throw new Error('JSON does not match COCO format'); @@ -142,11 +236,14 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record [c.id, c])); const frameByImageId = imageFrameMap(parsed); const tracks: AnnotationSchema['tracks'] = {}; + let skippedRleMasks = false; + + validateAnnotationBounds(parsed.annotations); parsed.annotations.forEach((annotation) => { const frame = frameByImageId[annotation.image_id]; if (frame === undefined) return; - const [x, y, w, h] = annotation.bbox; + const [x, y, w, h] = resolveCocoBbox(annotation); const bounds: [number, number, number, number] = [x, y, x + w, y + h]; const trackId = annotation.track_id ?? annotation.id; const category = categoriesById[annotation.category_id]; @@ -187,7 +284,10 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record bool: + bbox = annotation.get('bbox') + return isinstance(bbox, list) and len(bbox) == 4 + + +def _is_rle_segmentation(annotation: dict, segmentation=None) -> bool: + """Return True if annotation uses COCO RLE / crowd segmentation. + + In COCO, ``iscrowd: 1`` marks a crowd region whose ``segmentation`` is RLE + (a dict with ``counts`` and ``size``), not a polygon list. ``iscrowd: 0`` is a + single instance with polygon segmentation. DIVE does not decode RLE masks; + bbox and other fields may still import, but mask geometry is skipped. + """ + if segmentation is None: + segmentation = annotation.get('segmentation', []) + return bool(annotation.get('iscrowd', False)) or isinstance(segmentation, dict) + + +def _extract_polygon_coords_lists(segmentation) -> List[List[Tuple[float, float]]]: + """Parse COCO / KWCOCO polygon segmentations into coordinate lists.""" + if not segmentation or isinstance(segmentation, dict): + return [] + + if len(segmentation) > 1 and isinstance(segmentation[0], (int, float)): + polygons = [segmentation] + elif len(segmentation) == 1: + polygons = [segmentation[0]] + else: + polygons = segmentation + + coord_lists: List[List[Tuple[float, float]]] = [] + for polygon in polygons: + if isinstance(polygon, dict): + coords = polygon.get('exterior', []) + elif isinstance(polygon, list): + coords = list(zip(polygon[::2], polygon[1::2])) + else: + continue + if coords: + coord_lists.append(coords) + return coord_lists + + +def _bbox_from_points(points: List[Tuple[float, float]]) -> List[float]: + xs = [point[0] for point in points] + ys = [point[1] for point in points] + x_min = min(xs) + y_min = min(ys) + return [x_min, y_min, max(xs) - x_min, max(ys) - y_min] + + +def _annotation_has_importable_bounds(annotation: dict) -> bool: + if _has_valid_bbox(annotation): + return True + if _is_rle_segmentation(annotation): + return False + return bool(_extract_polygon_coords_lists(annotation.get('segmentation', []))) + + +def _missing_bounds_error(annotation_ids: List) -> str: + shown = ', '.join(str(annotation_id) for annotation_id in annotation_ids[:10]) + extra = f' (and {len(annotation_ids) - 10} more)' if len(annotation_ids) > 10 else '' + return ( + f'{len(annotation_ids)} COCO annotation(s) cannot be imported because they have no bbox and ' + f'no usable polygon segmentation (ids: {shown}{extra}). ' + 'Provide bbox [x, y, width, height] or polygon segmentation as [[x1, y1, ...]]. ' + 'Annotations with only RLE segmentation masks still require a bbox.' + ) + + +def _resolve_coco_bbox(annotation: dict) -> List[float]: + if _has_valid_bbox(annotation): + return list(annotation['bbox']) + + coord_lists = _extract_polygon_coords_lists(annotation.get('segmentation', [])) + all_points = [point for coords in coord_lists for point in coords] + if all_points: + return _bbox_from_points(all_points) + + raise ValueError(_missing_bounds_error([annotation.get('id', '?')])) + + +def _validate_annotation_bounds(annotations: List[dict]) -> None: + missing_ids = [ + annotation.get('id', '?') + for annotation in annotations + if not _annotation_has_importable_bounds(annotation) + ] + if missing_ids: + raise ValueError(_missing_bounds_error(missing_ids)) + def is_coco_json(coco: Dict[str, Any]): # Minimal COCO fields according to https://cocodataset.org/#format-data. @@ -33,7 +130,7 @@ def annotation_info(annotation: dict, meta: CocoMetadata) -> Tuple[int, str, int # handle int and string types, throw error on UUID trackId = int(annotation.get('track_id', annotation_id)) - bounds = annotation['bbox'] + bounds = _resolve_coco_bbox(annotation) # update from [TL_x, TL_y, width, height] to [TL_x, TL_y, BR_x, BR_y] bounds[2] += bounds[0] bounds[3] += bounds[1] @@ -41,7 +138,9 @@ def annotation_info(annotation: dict, meta: CocoMetadata) -> Tuple[int, str, int return trackId, filename, frame, bounds -def _parse_annotation(annotation: dict, meta: CocoMetadata) -> Tuple[dict, dict, dict, list, List[str]]: +def _parse_annotation( + annotation: dict, meta: CocoMetadata +) -> Tuple[dict, dict, dict, list, List[str], bool]: """ Parse a single KWCOCO annotation into its composite track and detection parts """ @@ -86,35 +185,12 @@ def _parse_annotation(annotation: dict, meta: CocoMetadata) -> Tuple[dict, dict, # parse polygons segmentation = annotation.get('segmentation', []) - rle = bool(annotation.get('iscrowd', False)) or isinstance(segmentation, dict) - - if rle: # run-length encoding polygon - raise ValueError('Run-Length Encoding not supported') - - if segmentation: - if rle: # run-length encoding polygon - raise ValueError('Run-Length Encoding not supported') - else: # standard coordinates polygon - # expected [[x1, y1, ...], [x1, y1, ...], ...] standard format - - if len(segmentation) > 1: - if isinstance(segmentation[0], (int, float)): - # received [x1, y1, ...] format - polygon = segmentation - else: - polygon = segmentation[0] - else: - polygon = segmentation[0] # get first polygon only - - if isinstance(polygon, dict): # dictionary kwcoco format - coords = polygon.get('exterior', []) - elif isinstance(polygon, list): # list coco format - coords = list(zip(polygon[::2], polygon[1::2])) - else: - raise ValueError('Incorrect polygon segmentation') - - if coords: - viame.create_geoJSONFeature(features, 'Polygon', coords) + rle_skipped = _is_rle_segmentation(annotation, segmentation) + + if segmentation and not rle_skipped: + coord_lists = _extract_polygon_coords_lists(segmentation) + if coord_lists: + viame.create_geoJSONFeature(features, 'Polygon', coord_lists[0]) # DIVE extension fields for non-standard COCO attributes. detection_attributes = annotation.get('dive_detection_attributes', annotation.get('attributes', {})) @@ -133,18 +209,19 @@ def _parse_annotation(annotation: dict, meta: CocoMetadata) -> Tuple[dict, dict, elif isinstance(note_values, str) and note_values.strip(): notes.append(note_values.strip()) - return features, attributes, track_attributes, [confidence_pair], notes + return features, attributes, track_attributes, [confidence_pair], notes, rle_skipped def _parse_annotation_for_tracks( annotation: dict, meta: CocoMetadata -) -> Tuple[Feature, dict, dict, list]: +) -> Tuple[Feature, dict, dict, list, bool]: ( features, attributes, track_attributes, confidence_pairs, notes, + rle_skipped, ) = _parse_annotation(annotation, meta) trackId, filename, frame, bounds = annotation_info(annotation, meta) @@ -158,7 +235,7 @@ def _parse_annotation_for_tracks( ) # Pass the rest of the unchanged info through as well - return feature, attributes, track_attributes, confidence_pairs + return feature, attributes, track_attributes, confidence_pairs, rle_skipped def load_coco_metadata(coco: Dict[str, List[dict]]) -> CocoMetadata: @@ -206,15 +283,18 @@ def file_name_cmp(item1, item2): def load_coco_as_tracks_and_attributes( coco: Dict[str, List[dict]], -) -> Tuple[types.DIVEAnnotationSchema, dict]: +) -> Tuple[types.DIVEAnnotationSchema, dict, List[str]]: """ Convert KWCOCO json to DIVE json tracks. """ tracks: Dict[int, Track] = {} metadata_attributes: Dict[str, Dict[str, Any]] = {} test_vals: Dict[str, Dict[str, int]] = {} + warnings: List[str] = [] + skipped_rle_masks = False meta = load_coco_metadata(coco) annotations = coco.get('annotations', []) + _validate_annotation_bounds(annotations) for annotation in annotations: ( @@ -222,7 +302,9 @@ def load_coco_as_tracks_and_attributes( attributes, track_attributes, confidence_pairs, + rle_skipped, ) = _parse_annotation_for_tracks(annotation, meta) + skipped_rle_masks = skipped_rle_masks or rle_skipped trackId, _, frame, _ = annotation_info(annotation, meta) @@ -251,7 +333,9 @@ def load_coco_as_tracks_and_attributes( 'groups': {}, 'version': constants.AnnotationsCurrentVersion, } - return converted, metadata_attributes + if skipped_rle_masks: + warnings.append(RLE_SEGMENTATION_WARNING) + return converted, metadata_attributes, warnings def _feature_to_segmentation(feature: Feature) -> List[List[float]]: @@ -349,6 +433,7 @@ def export_dive_as_coco( 'category_id': category_id, 'bbox': [x1, y1, width, height], 'area': width * height, + # Single-instance polygon export; DIVE does not emit crowd RLE (iscrowd: 1). 'iscrowd': 0, 'score': score, } diff --git a/server/scripts/commands_main.py b/server/scripts/commands_main.py index eeba51a98..138103df9 100644 --- a/server/scripts/commands_main.py +++ b/server/scripts/commands_main.py @@ -48,11 +48,13 @@ def convert_kpf(inputs: List[BinaryIO], output: TextIO, output_attrs: TextIO): @click.option('--output-attrs', type=click.File('wt'), default='attributes.json') def convert_coco(input: TextIO, output: TextIO, output_attrs: TextIO): coco_json = json.load(input) - tracks, attributes = kwcoco.load_coco_as_tracks_and_attributes(coco_json) + tracks, attributes, warnings = kwcoco.load_coco_as_tracks_and_attributes(coco_json) json.dump(tracks, output) json.dump(attributes, output_attrs, indent=4) click.secho(f'wrote output {output.name}', fg='green') click.secho(f'wrote attrib {output_attrs.name}', fg='green') + for warning in warnings: + click.secho(warning, fg='yellow') @convert.command(name="viame2dive") diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 853b57ed7..cbf780542 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -680,7 +680,7 @@ def test_read_kwcoco_json( expected_tracks: Dict[str, dict], expected_attributes: Dict[str, dict], ): - (converted, attributes) = kwcoco.load_coco_as_tracks_and_attributes(input) + (converted, attributes, _) = kwcoco.load_coco_as_tracks_and_attributes(input) assert json.dumps(converted['tracks'], sort_keys=True) == json.dumps( expected_tracks, sort_keys=True ) @@ -744,9 +744,115 @@ def test_import_dive_attribute_extensions(): ], "categories": [{"id": 1, "name": "fish"}], } - converted, _ = kwcoco.load_coco_as_tracks_and_attributes(coco) + converted, _, _ = kwcoco.load_coco_as_tracks_and_attributes(coco) track = converted["tracks"]["5"] assert track["attributes"]["reviewed"] is True assert track["features"][0]["attributes"]["visibility"] == "poor" assert track["features"][0]["notes"] == ["first pass", "manual review"] + +def test_import_rle_segmentation_skips_masks_with_warning(): + coco = { + "images": [{"id": 1, "file_name": "img_1.jpg"}], + "annotations": [ + { + "id": 10, + "image_id": 1, + "category_id": 1, + "bbox": [10, 20, 30, 40], + "track_id": 5, + "iscrowd": 1, + "segmentation": { + "size": [480, 640], + "counts": "eNq...", + }, + } + ], + "categories": [{"id": 1, "name": "fish"}], + } + converted, _, warnings = kwcoco.load_coco_as_tracks_and_attributes(coco) + track = converted["tracks"]["5"] + assert track["features"][0]["bounds"] == [10, 20, 40, 60] + assert "geometry" not in track["features"][0] + assert len(warnings) == 1 + assert "segmentation masks" in warnings[0] + + +def test_import_missing_bbox_raises_descriptive_error(): + coco = { + "images": [{"id": 1, "file_name": "frame_000001.jpg", "frame_index": 0}], + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "track_id": 201, + "iscrowd": 1, + "segmentation": {"size": [1080, 1920], "counts": "abc"}, + } + ], + "categories": [{"id": 1, "name": "fish"}], + } + with pytest.raises(ValueError) as exc: + kwcoco.load_coco_as_tracks_and_attributes(coco) + message = str(exc.value) + assert "no bbox and no usable polygon" in message + assert "RLE segmentation masks still require a bbox" in message + + +def test_import_polygon_without_bbox_derives_bounds(): + coco = { + "images": [{"id": 1, "file_name": "frame_000001.jpg", "frame_index": 0}], + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "track_id": 401, + "segmentation": [[120, 80, 200, 80, 200, 120, 120, 120]], + } + ], + "categories": [{"id": 1, "name": "fish"}], + } + converted, _, warnings = kwcoco.load_coco_as_tracks_and_attributes(coco) + track = converted["tracks"]["401"] + assert track["features"][0]["bounds"] == [120, 80, 200, 120] + assert track["features"][0]["geometry"] is not None + assert warnings == [] + + +def test_import_polygon_and_rle_segmentation(): + coco = { + "images": [{"id": 1, "file_name": "frame_000001.jpg", "frame_index": 0}], + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "bbox": [120, 80, 80, 40], + "track_id": 301, + "segmentation": [[120, 80, 200, 80, 200, 120, 120, 120]], + }, + { + "id": 2, + "image_id": 1, + "category_id": 2, + "bbox": [400, 200, 200, 60], + "track_id": 302, + "iscrowd": 1, + "segmentation": {"size": [1080, 1920], "counts": "abc"}, + }, + ], + "categories": [ + {"id": 1, "name": "person"}, + {"id": 2, "name": "crowd"}, + ], + } + converted, _, warnings = kwcoco.load_coco_as_tracks_and_attributes(coco) + polygon_track = converted["tracks"]["301"] + assert polygon_track["features"][0]["geometry"] is not None + rle_track = converted["tracks"]["302"] + assert rle_track["features"][0]["bounds"] == [400, 200, 600, 260] + assert "geometry" not in rle_track["features"][0] + assert len(warnings) == 1 +