From 2b0b0cf3fb179b78aa82e98a580523622a933450 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 21 Aug 2026 17:06:45 -0400 Subject: [PATCH 1/7] Import RLE segmentation masks as outlines DIVE stores geometry rather than rasters, so a decoded mask becomes its contour. Both counts spellings are read, the list form and the LEB128 string pycocotools writes; an undecodable mask still warns. --- docs/DataFormats.md | 8 +- server/dive_utils/serializers/kwcoco.py | 119 +++++++++++++++++-- server/tests/test_deserialize_kwcoco_json.py | 86 +++++++++++++- 3 files changed, 199 insertions(+), 14 deletions(-) diff --git a/docs/DataFormats.md b/docs/DataFormats.md index 2113e367a..49b15cbcc 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -521,8 +521,12 @@ For COCO files not produced by DIVE: * Partially supported: * COCO has no direct equivalent for DIVE groups, so groups are not represented in COCO export. * Partially supported: - * Run-length encoded segmentations (RLE): bounding boxes and other fields import, - but masks are skipped and a warning is shown. + * Run-length encoded segmentations (RLE): the mask is decoded and imported as its + outline, since DIVE stores geometry rather than rasters. Both COCO counts + spellings are read: a list of run lengths, and the LEB128 string pycocotools + writes. Holes are not representable and are dropped, and a mask that cannot be + decoded is skipped with a warning, as before. Web import only; desktop import + still skips RLE. ### Example COCO Annotation with DIVE Extensions diff --git a/server/dive_utils/serializers/kwcoco.py b/server/dive_utils/serializers/kwcoco.py index dda69e0c7..ff1438ad9 100644 --- a/server/dive_utils/serializers/kwcoco.py +++ b/server/dive_utils/serializers/kwcoco.py @@ -15,10 +15,14 @@ from . import viame 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.' + 'The COCO file included run-length encoded segmentation masks that could not be decoded. ' + 'Bounding boxes and other annotation data were imported, but those masks were skipped.' ) +# A mask larger than this is refused rather than allocated; 8K x 8K is already +# far beyond anything DIVE displays. +_RLE_MAX_PIXELS = 64 * 1024 * 1024 + PROB_TOP_K = 10 PROB_EPSILON = 0.001 @@ -165,6 +169,88 @@ def _is_rle_segmentation(annotation: dict, segmentation=None) -> bool: return bool(annotation.get('iscrowd', False)) or isinstance(segmentation, dict) +def _decode_rle_counts(counts) -> Optional[List[int]]: + """Run lengths from either COCO counts spelling. + + Uncompressed COCO writes a list of integers; pycocotools writes the same + runs LEB128-encoded into a string. + """ + if isinstance(counts, (list, tuple)): + if all(isinstance(count, int) and not isinstance(count, bool) and count >= 0 + for count in counts): + return list(counts) + return None + if not isinstance(counts, (str, bytes)): + return None + + text = counts.decode('ascii') if isinstance(counts, bytes) else counts + runs: List[int] = [] + position = 0 + while position < len(text): + value = 0 + shift = 0 + more = True + while more: + if position >= len(text): + return None + char = ord(text[position]) - 48 + value |= (char & 0x1F) << shift + more = bool(char & 0x20) + position += 1 + shift += 5 + # The final chunk carries the sign in bit 0x10 (rleFrString). + if not more and char & 0x10: + value |= -1 << shift + # Runs past the first two are deltas against the run two places back. + if len(runs) > 2: + value += runs[-2] + runs.append(value) + return runs if all(run >= 0 for run in runs) else None + + +def _rle_polygon_coords(segmentation) -> List[List[Tuple[float, float]]]: + """Trace a COCO RLE mask into image-space polygon contours. + + DIVE stores geometry, not rasters, so an imported mask becomes its outline. + Holes are not representable and are dropped. + """ + if not isinstance(segmentation, dict): + return [] + size = segmentation.get('size') + if not (isinstance(size, (list, tuple)) and len(size) == 2): + return [] + height, width = size + if not (isinstance(height, int) and isinstance(width, int)): + return [] + if height <= 0 or width <= 0 or height * width > _RLE_MAX_PIXELS: + return [] + + runs = _decode_rle_counts(segmentation.get('counts')) + if runs is None or sum(runs) != height * width: + return [] + + import cv2 + import numpy as np + + flat = np.zeros(height * width, dtype=np.uint8) + position = 0 + for index, run in enumerate(runs): + if index % 2: # odd runs are foreground + flat[position:position + run] = 1 + position += run + # COCO run-length order is column-major. + mask = flat.reshape((height, width), order='F') + + found = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + coord_lists = [] + for contour in found[-2]: + points = contour.reshape(-1, 2) + if len(points) >= 3: + coord_lists.append([(float(x), float(y)) for x, y in points]) + coord_lists.sort(key=len, reverse=True) + return coord_lists + + 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): @@ -201,9 +287,10 @@ def _bbox_from_points(points: List[Tuple[float, float]]) -> List[float]: 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', []))) + segmentation = annotation.get('segmentation', []) + if _is_rle_segmentation(annotation, segmentation): + return bool(_rle_polygon_coords(segmentation)) + return bool(_extract_polygon_coords_lists(segmentation)) def _missing_bounds_error(annotation_ids: List) -> str: @@ -214,7 +301,7 @@ def _missing_bounds_error(annotation_ids: List) -> str: f'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.' + 'An RLE mask supplies bounds only when it can be decoded.' ) @@ -222,7 +309,11 @@ 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', [])) + segmentation = annotation.get('segmentation', []) + if _is_rle_segmentation(annotation, segmentation): + coord_lists = _rle_polygon_coords(segmentation) + else: + coord_lists = _extract_polygon_coords_lists(segmentation) all_points = [point for coords in coord_lists for point in coords] if all_points: return _bbox_from_points(all_points) @@ -333,10 +424,16 @@ def _parse_annotation( # parse polygons segmentation = annotation.get('segmentation', []) - rle_skipped = _is_rle_segmentation(annotation, segmentation) - - if segmentation and not rle_skipped: - coord_lists = _extract_polygon_coords_lists(segmentation) + rle_skipped = False + + if segmentation: + if _is_rle_segmentation(annotation, segmentation): + coord_lists = _rle_polygon_coords(segmentation) + # Only undecodable masks are reported; a traced one is not a loss + # worth warning about. + rle_skipped = not coord_lists + else: + coord_lists = _extract_polygon_coords_lists(segmentation) if coord_lists: viame.create_geoJSONFeature(features, 'Polygon', coord_lists[0]) diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 43321e0ca..c7079fe19 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -938,7 +938,7 @@ def test_import_missing_bbox_raises_descriptive_error(): 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 + assert "An RLE mask supplies bounds only when it can be decoded" in message def test_import_polygon_without_bbox_derives_bounds(): @@ -1239,3 +1239,87 @@ def test_frame_rate_absent_or_unusable(): assert kwcoco.frame_rate_from_coco( _fps_document([{'id': 1, 'annotation_fps': fps}]) ) is None + + +def _rle_to_string(cnts): + """pycocotools rleToString, so the decoder is tested against real output.""" + out = [] + for i, count in enumerate(cnts): + x = int(count) + if i > 2: + x -= int(cnts[i - 2]) + more = True + while more: + chunk = x & 0x1F + x >>= 5 + more = (x != -1) if (chunk & 0x10) else (x != 0) + if more: + chunk |= 0x20 + out.append(chr(chunk + 48)) + return ''.join(out) + + +def _square_mask_runs(): + """Column-major run lengths for a 6x6 square at (3, 2) in a 10x10 mask.""" + runs, current, length = [], 0, 0 + for column in range(10): + for row in range(10): + value = 1 if (2 <= row < 8 and 3 <= column < 9) else 0 + if value == current: + length += 1 + else: + runs.append(length) + current = value + length = 1 + runs.append(length) + return runs + + +def _rle_document(counts): + return { + 'images': [{'id': 1, 'file_name': 'frame_000000.png', 'frame_index': 0}], + 'annotations': [{ + 'id': 1, 'image_id': 1, 'category_id': 1, 'track_id': 1, + 'segmentation': {'counts': counts, 'size': [10, 10]}, 'iscrowd': 1, + }], + 'categories': [{'id': 1, 'name': 'fish'}], + } + + +@pytest.mark.parametrize('as_string', [False, True]) +def test_rle_masks_import_as_outlines(as_string): + """DIVE stores geometry, so a decoded mask arrives as its outline.""" + runs = _square_mask_runs() + counts = _rle_to_string(runs) if as_string else runs + tracks, _, warnings, _ = kwcoco.load_coco_as_tracks_and_attributes(_rle_document(counts)) + + feature = tracks['tracks']['1']['features'][0] + polygon = [ + geometry for geometry in feature['geometry']['features'] + if geometry['geometry']['type'] == 'Polygon' + ] + assert polygon, 'expected a polygon traced from the mask' + coords = polygon[0]['geometry']['coordinates'][0] + xs = [point[0] for point in coords] + ys = [point[1] for point in coords] + assert (min(xs), max(xs), min(ys), max(ys)) == (3, 8, 2, 7) + # The mask carried no bbox, so it supplied the bounds itself. + assert feature['bounds'] == [3, 2, 8, 7] + assert kwcoco.RLE_SEGMENTATION_WARNING not in warnings + + +def test_undecodable_rle_still_warns(): + """Run lengths that do not fill the mask are reported, not guessed at.""" + document = _rle_document([5]) + document['annotations'][0]['bbox'] = [0, 0, 4, 4] + tracks, _, warnings, _ = kwcoco.load_coco_as_tracks_and_attributes(document) + + assert kwcoco.RLE_SEGMENTATION_WARNING in warnings + assert tracks['tracks']['1']['features'][0]['bounds'] == [0, 0, 4, 4] + + +def test_decode_rle_counts_rejects_junk(): + assert kwcoco._decode_rle_counts([1, -2]) is None + assert kwcoco._decode_rle_counts([1, 'x']) is None + assert kwcoco._decode_rle_counts(None) is None + assert kwcoco._decode_rle_counts(_rle_to_string([4, 2, 4])) == [4, 2, 4] From 78d3cdffb16be0fa0744942538f848202b4c7f51 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 21 Aug 2026 17:13:28 -0400 Subject: [PATCH 2/7] Trace mask outlines with numpy, not opencv opencv is only a dev dependency, so the deployed server has numpy alone. Moore-neighbour tracing walks the boundary instead, which costs the perimeter rather than the area. --- docs/DataFormats.md | 3 +- server/dive_utils/serializers/kwcoco.py | 89 ++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/docs/DataFormats.md b/docs/DataFormats.md index 49b15cbcc..974c154e8 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -526,7 +526,8 @@ For COCO files not produced by DIVE: spellings are read: a list of run lengths, and the LEB128 string pycocotools writes. Holes are not representable and are dropped, and a mask that cannot be decoded is skipped with a warning, as before. Web import only; desktop import - still skips RLE. + still skips RLE. Decoding needs no extra dependency: the outline is traced with + numpy alone. ### Example COCO Annotation with DIVE Extensions diff --git a/server/dive_utils/serializers/kwcoco.py b/server/dive_utils/serializers/kwcoco.py index ff1438ad9..e1ff73f37 100644 --- a/server/dive_utils/serializers/kwcoco.py +++ b/server/dive_utils/serializers/kwcoco.py @@ -208,6 +208,64 @@ def _decode_rle_counts(counts) -> Optional[List[int]]: return runs if all(run >= 0 for run in runs) else None +# Clockwise Moore neighbourhood, as (dx, dy) starting from due east. +_MOORE_OFFSETS = ( + (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), +) + + +def _trace_contour(mask, start, visited) -> List[Tuple[float, float]]: + """Moore-neighbour trace of one component's outer boundary. + + Pure numpy: the server has numpy but not opencv, and walking the boundary + costs the perimeter rather than the area. + """ + height, width = mask.shape + contour = [start] + visited[start[1], start[0]] = True + # Entering the start pixel from the west, so begin the search north of it. + previous = (start[0] - 1, start[1]) + current = start + + while True: + back = (previous[0] - current[0], previous[1] - current[1]) + try: + index = _MOORE_OFFSETS.index(back) + except ValueError: + index = 0 + found = None + for step in range(1, 9): + offset = _MOORE_OFFSETS[(index + step) % 8] + candidate = (current[0] + offset[0], current[1] + offset[1]) + if not (0 <= candidate[0] < width and 0 <= candidate[1] < height): + continue + if mask[candidate[1], candidate[0]]: + found = candidate + break + previous = candidate + if found is None: # isolated pixel + break + if found == start and len(contour) > 1: + break + contour.append(found) + visited[found[1], found[0]] = True + previous = current + current = found + if len(contour) > 4 * height * width: # cannot happen; refuses to spin + break + + return [(float(x), float(y)) for x, y in contour] + + +def _polygon_area(points: List[Tuple[float, float]]) -> float: + """Shoelace area of a closed contour.""" + total = 0.0 + for index, (x, y) in enumerate(points): + next_x, next_y = points[(index + 1) % len(points)] + total += x * next_y - next_x * y + return abs(total) / 2.0 + + def _rle_polygon_coords(segmentation) -> List[List[Tuple[float, float]]]: """Trace a COCO RLE mask into image-space polygon contours. @@ -229,25 +287,38 @@ def _rle_polygon_coords(segmentation) -> List[List[Tuple[float, float]]]: if runs is None or sum(runs) != height * width: return [] - import cv2 import numpy as np - flat = np.zeros(height * width, dtype=np.uint8) + flat = np.zeros(height * width, dtype=bool) position = 0 for index, run in enumerate(runs): if index % 2: # odd runs are foreground - flat[position:position + run] = 1 + flat[position:position + run] = True position += run # COCO run-length order is column-major. mask = flat.reshape((height, width), order='F') + if not mask.any(): + return [] + + # A boundary pixel is foreground with at least one background 4-neighbour. + padded = np.zeros((height + 2, width + 2), dtype=bool) + padded[1:-1, 1:-1] = mask + interior = ( + padded[:-2, 1:-1] & padded[2:, 1:-1] & padded[1:-1, :-2] & padded[1:-1, 2:] + ) + boundary = mask & ~interior - found = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + visited = np.zeros_like(mask) coord_lists = [] - for contour in found[-2]: - points = contour.reshape(-1, 2) - if len(points) >= 3: - coord_lists.append([(float(x), float(y)) for x, y in points]) - coord_lists.sort(key=len, reverse=True) + for y, x in zip(*np.nonzero(boundary)): + if visited[y, x]: + continue + contour = _trace_contour(mask, (int(x), int(y)), visited) + if len(contour) >= 3: + coord_lists.append(contour) + # Largest by enclosed area, not by point count: a long thin outline can + # carry more points than a bigger blob, and callers take the first. + coord_lists.sort(key=_polygon_area, reverse=True) return coord_lists From c9454445e911b4f18b870b23015e82534fd5ae91 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Oct 2026 10:16:58 -0400 Subject: [PATCH 3/7] add to postprocess task the RLE mask generation --- docs/DataFormats.md | 7 +- server/dive_server/crud_rpc.py | 85 +++++++++++++++++++- server/dive_tasks/__init__.py | 1 + server/dive_tasks/import_coco.py | 50 ++++++++++++ server/dive_tasks/tasks.py | 4 +- server/dive_utils/serializers/kwcoco.py | 30 +++++-- server/tests/test_deserialize_kwcoco_json.py | 43 ++++++++-- 7 files changed, 201 insertions(+), 19 deletions(-) create mode 100644 server/dive_tasks/import_coco.py diff --git a/docs/DataFormats.md b/docs/DataFormats.md index f4b96f5c4..78178629d 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -594,9 +594,10 @@ For COCO files not produced by DIVE: outline, since DIVE stores geometry rather than rasters. Both COCO counts spellings are read: a list of run lengths, and the LEB128 string pycocotools writes. Holes are not representable and are dropped, and a mask that cannot be - decoded is skipped with a warning, as before. Web import only; desktop import - still skips RLE. Decoding needs no extra dependency: the outline is traced with - numpy alone. + decoded is skipped with a warning, as before. On the web, RLE conversion runs as + a postprocess convert job (same job tracking as video/image transcoding) so the + import request does not decode masks inline; desktop import still skips RLE. + Decoding needs no extra dependency: the outline is traced with numpy alone. ### Example COCO Annotation with DIVE Extensions diff --git a/server/dive_server/crud_rpc.py b/server/dive_server/crud_rpc.py index 676a0416e..b390ad2f8 100644 --- a/server/dive_server/crud_rpc.py +++ b/server/dive_server/crud_rpc.py @@ -884,6 +884,7 @@ def run_scoring( 'type': crud.FileType, 'hierarchy': NotRequired[Optional[Dict[str, str]]], 'species': NotRequired[List[str]], + 'has_rle': NotRequired[bool], }, ) @@ -1009,14 +1010,32 @@ def _get_data_by_type( 'species': kwcoco.species_list_from_categories(data_dict), }, species_warnings or warnings if as_type == crud.FileType.COCO_JSON: + hierarchy, hierarchy_warnings = kwcoco.type_hierarchy_from_categories(data_dict) + coco_fps = kwcoco.frame_rate_from_coco(data_dict) + has_rle = kwcoco.coco_contains_rle(data_dict) + # RLE decode is expensive; configuration staging only needs hierarchy/fps. + # Full track conversion is deferred to a convert-style job when skipJobs + # is false, or done below when importing synchronously. + if configuration_only and has_rle: + datasetInfo = (data_dict.get('info') or {}).get('dive_dataset_info') or {} + coco_meta = { + **({"datasetInfo": datasetInfo} if datasetInfo else {}), + **({'fps': coco_fps} if coco_fps is not None else {}), + } + return { + 'annotations': None, + 'meta': coco_meta or None, + 'attributes': None, + 'type': as_type, + 'hierarchy': hierarchy, + 'has_rle': True, + }, hierarchy_warnings or warnings ( converted, attributes, coco_warnings, datasetInfo, ) = kwcoco.load_coco_as_tracks_and_attributes(data_dict) - hierarchy, hierarchy_warnings = kwcoco.type_hierarchy_from_categories(data_dict) - coco_fps = kwcoco.frame_rate_from_coco(data_dict) coco_meta = { **({"datasetInfo": datasetInfo} if datasetInfo else {}), **({'fps': coco_fps} if coco_fps is not None else {}), @@ -1027,6 +1046,7 @@ def _get_data_by_type( 'attributes': attributes, 'type': as_type, 'hierarchy': hierarchy, + 'has_rle': has_rle, }, (coco_warnings + hierarchy_warnings) or warnings if as_type == crud.FileType.DIVE_CONF: return { @@ -1530,9 +1550,13 @@ def process_items( # Configuration staging already parsed and validated every JSON item; reuse that # result so an import is not parsed twice and cannot disagree with the plan. + # RLE-bearing COCO is staged without annotations so the request thread stays + # light; re-parse fully when this sync import path actually consumes the file. cached = parsed_json_items.get(str(item['_id'])) if cached is not None: _cached_file, results, warnings = cached + if results.get('has_rle') and results.get('annotations') is None: + results, warnings = _parse_data_item(item, file, image_map) else: results, warnings = _parse_data_item(item, file, image_map) if warnings: @@ -1628,9 +1652,11 @@ def _postprocess( Splitting of stitched stereo media (stitchedSide), in place of transcoding Conversion of KPF annotations into track JSON Extraction and upload of zip files + Import of COCO annotations that include RLE masks (decoded to outlines) In either case, the following may run synchronously: Conversion of CSV annotations into track JSON + Import of non-RLE COCO / DIVE JSON annotations Returns: dict: Contains 'folder' (the processed folder) and 'job_ids' (list of created job IDs) """ @@ -1837,6 +1863,61 @@ def _postprocess( dsFolder.setdefault('meta', {})[constants.DatasetMarker] = True Folder().save(dsFolder) + # COCO files with RLE masks decode off-thread like media convert jobs so the + # postprocess request stays responsive. Hierarchy/fps were already staged; + # the job re-enters postprocess with skipJobs=True to finish the import. + rle_item_ids = { + item_id + for item_id, (_file, results, _warnings) in configuration_plan[ + 'parsed_json_items' + ].items() + if results.get('has_rle') + } + if rle_item_ids: + rle_names = [ + item['name'] + for item in configuration_plan['unprocessed_items'] + if str(item['_id']) in rle_item_ids + ] + convert_params = { + 'user_id': str(user["_id"]), + 'user_login': str(user["login"]), + 'input_folder': str(dsFolder["_id"]), + 'rle_items': sorted(rle_item_ids), + } + newjob = tasks.import_coco_annotations.apply_async( + queue=_get_queue_name(user), + kwargs=dict( + folderId=str(dsFolder["_id"]), + user_id=str(user["_id"]), + user_login=str(user["login"]), + additive=additive, + additivePrepend=additivePrepend, + set=set, + girder_client_token=str(token["_id"]), + girder_job_title=( + f"Importing COCO RLE masks for {dsFolder['name']}" + + (f" ({', '.join(rle_names)})" if rle_names else '') + ), + girder_job_type="private" if job_is_private else "convert", + ), + ) + job = _persist_async_job_metadata( + newjob, + **{ + constants.JOBCONST_PRIVATE_QUEUE: job_is_private, + constants.JOBCONST_DATASET_ID: job_dataset_id, + constants.JOBCONST_PARAMS: convert_params, + constants.JOBCONST_CREATOR: str(user['_id']), + }, + ) + created_job_ids.append(job['_id']) + configuration_plan['unprocessed_items'] = [ + item + for item in configuration_plan['unprocessed_items'] + if str(item['_id']) not in rle_item_ids + ] + aggregate_warnings = process_items( dsFolder, user, diff --git a/server/dive_tasks/__init__.py b/server/dive_tasks/__init__.py index 4ab5c8af8..60fa449ef 100644 --- a/server/dive_tasks/__init__.py +++ b/server/dive_tasks/__init__.py @@ -21,6 +21,7 @@ def task_imports(self): 'dive_tasks.run_scoring', 'dive_tasks.convert_video', 'dive_tasks.convert_images', + 'dive_tasks.import_coco', 'dive_tasks.split_stitched', 'dive_tasks.finalize_multicam', 'dive_tasks.tasks', diff --git a/server/dive_tasks/import_coco.py b/server/dive_tasks/import_coco.py new file mode 100644 index 000000000..466c61ded --- /dev/null +++ b/server/dive_tasks/import_coco.py @@ -0,0 +1,50 @@ +"""Import COCO annotations that include RLE masks as a convert-style job.""" + +from contextlib import suppress + +from girder_client import GirderClient +from girder_worker.app import app +from girder_worker.task import Task +from girder_worker.utils import JobManager, JobStatus + +from dive_tasks import utils +from dive_tasks.manager import patch_manager + + +@app.task(bind=True, acks_late=True, ignore_result=True) +def import_coco_annotations( + self: Task, + folderId: str, + user_id: str, + user_login: str, + additive: bool = False, + additivePrepend: str = '', + set: str = '', +): + """Finish COCO import for files whose RLE masks were deferred from postprocess. + + Re-enters ``dive_rpc/postprocess`` with ``skipJobs=True`` so mask decode and + annotation save run on the worker rather than the original request thread. + """ + context: dict = {} + gc: GirderClient = self.girder_client + manager: JobManager = patch_manager(self.job_manager) + if utils.check_canceled(self, context): + manager.updateStatus(JobStatus.CANCELED) + return + + with suppress(utils.CanceledError): + manager.updateStatus(JobStatus.RUNNING) + manager.write(f'Importing COCO annotations with RLE masks for folder {folderId}...\n') + data = { + 'skipJobs': True, + 'additive': additive, + 'additivePrepend': additivePrepend, + } + if set: + data['set'] = set + result = gc.post(f'dive_rpc/postprocess/{folderId}', data=data) + warnings = (result or {}).get('warnings') or [] + for warning in warnings: + manager.write(f'Warning: {warning}\n') + manager.write('Finished COCO RLE annotation import.\n') diff --git a/server/dive_tasks/tasks.py b/server/dive_tasks/tasks.py index 88eb752fb..d35bdd6af 100644 --- a/server/dive_tasks/tasks.py +++ b/server/dive_tasks/tasks.py @@ -1,7 +1,7 @@ """Compatibility barrel re-exporting Celery tasks and helpers. Prefer importing from the focused modules directly in new code: -``convert_video``, ``convert_images``, ``run_pipeline``, ``run_scoring``, +``convert_video``, ``convert_images``, ``import_coco``, ``run_pipeline``, ``run_scoring``, ``run_training``, ``upgrade_pipelines``, and ``viame_config``. """ @@ -12,6 +12,7 @@ extract_zip, ) from dive_tasks.convert_video import convert_video, resolve_annotation_fps +from dive_tasks.import_coco import import_coco_annotations from dive_tasks.run_pipeline import ( _inject_dataset_metadata_file, filter_csv_by_frame_range, @@ -47,6 +48,7 @@ 'filter_csv_by_frame_range', 'filter_image_list_by_frame_range', 'get_gpu_environment', + 'import_coco_annotations', 'is_google_drive_addon_url', 'resolve_annotation_fps', 'run_pipeline', diff --git a/server/dive_utils/serializers/kwcoco.py b/server/dive_utils/serializers/kwcoco.py index 7959d5863..0312fb61e 100644 --- a/server/dive_utils/serializers/kwcoco.py +++ b/server/dive_utils/serializers/kwcoco.py @@ -237,6 +237,15 @@ def _is_rle_segmentation(annotation: dict, segmentation=None) -> bool: return bool(annotation.get('iscrowd', False)) or isinstance(segmentation, dict) +def coco_contains_rle(coco: Dict[str, Any]) -> bool: + """True when any annotation carries COCO RLE / crowd segmentation.""" + annotations = coco.get('annotations') or [] + return any( + isinstance(annotation, dict) and _is_rle_segmentation(annotation) + for annotation in annotations + ) + + def _decode_rle_counts(counts) -> Optional[List[int]]: """Run lengths from either COCO counts spelling. @@ -244,8 +253,10 @@ def _decode_rle_counts(counts) -> Optional[List[int]]: runs LEB128-encoded into a string. """ if isinstance(counts, (list, tuple)): - if all(isinstance(count, int) and not isinstance(count, bool) and count >= 0 - for count in counts): + if all( + isinstance(count, int) and not isinstance(count, bool) and count >= 0 + for count in counts + ): return list(counts) return None if not isinstance(counts, (str, bytes)): @@ -278,7 +289,14 @@ def _decode_rle_counts(counts) -> Optional[List[int]]: # Clockwise Moore neighbourhood, as (dx, dy) starting from due east. _MOORE_OFFSETS = ( - (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), + (1, 0), + (1, 1), + (0, 1), + (-1, 1), + (-1, 0), + (-1, -1), + (0, -1), + (1, -1), ) @@ -361,7 +379,7 @@ def _rle_polygon_coords(segmentation) -> List[List[Tuple[float, float]]]: position = 0 for index, run in enumerate(runs): if index % 2: # odd runs are foreground - flat[position:position + run] = True + flat[position : position + run] = True position += run # COCO run-length order is column-major. mask = flat.reshape((height, width), order='F') @@ -371,9 +389,7 @@ def _rle_polygon_coords(segmentation) -> List[List[Tuple[float, float]]]: # A boundary pixel is foreground with at least one background 4-neighbour. padded = np.zeros((height + 2, width + 2), dtype=bool) padded[1:-1, 1:-1] = mask - interior = ( - padded[:-2, 1:-1] & padded[2:, 1:-1] & padded[1:-1, :-2] & padded[1:-1, 2:] - ) + interior = padded[:-2, 1:-1] & padded[2:, 1:-1] & padded[1:-1, :-2] & padded[1:-1, 2:] boundary = mask & ~interior visited = np.zeros_like(mask) diff --git a/server/tests/test_deserialize_kwcoco_json.py b/server/tests/test_deserialize_kwcoco_json.py index 7cf8cc353..f71e4d15f 100644 --- a/server/tests/test_deserialize_kwcoco_json.py +++ b/server/tests/test_deserialize_kwcoco_json.py @@ -1446,7 +1446,7 @@ def test_centerlines_with_different_vertex_counts_share_coco_schema(): def _rle_to_string(cnts): - """pycocotools rleToString, so the decoder is tested against real output.""" + """Pycocotools rleToString, so the decoder is tested against real output.""" out = [] for i, count in enumerate(cnts): x = int(count) @@ -1482,10 +1482,16 @@ def _square_mask_runs(): def _rle_document(counts): return { 'images': [{'id': 1, 'file_name': 'frame_000000.png', 'frame_index': 0}], - 'annotations': [{ - 'id': 1, 'image_id': 1, 'category_id': 1, 'track_id': 1, - 'segmentation': {'counts': counts, 'size': [10, 10]}, 'iscrowd': 1, - }], + 'annotations': [ + { + 'id': 1, + 'image_id': 1, + 'category_id': 1, + 'track_id': 1, + 'segmentation': {'counts': counts, 'size': [10, 10]}, + 'iscrowd': 1, + } + ], 'categories': [{'id': 1, 'name': 'fish'}], } @@ -1499,7 +1505,8 @@ def test_rle_masks_import_as_outlines(as_string): feature = tracks['tracks']['1']['features'][0] polygon = [ - geometry for geometry in feature['geometry']['features'] + geometry + for geometry in feature['geometry']['features'] if geometry['geometry']['type'] == 'Polygon' ] assert polygon, 'expected a polygon traced from the mask' @@ -1527,3 +1534,27 @@ def test_decode_rle_counts_rejects_junk(): assert kwcoco._decode_rle_counts([1, 'x']) is None assert kwcoco._decode_rle_counts(None) is None assert kwcoco._decode_rle_counts(_rle_to_string([4, 2, 4])) == [4, 2, 4] + + +def test_coco_contains_rle_detects_masks(): + assert kwcoco.coco_contains_rle(_rle_document([4, 2, 4])) is True + assert ( + kwcoco.coco_contains_rle( + { + 'images': [{'id': 1, 'file_name': 'a.png'}], + 'annotations': [ + { + 'id': 1, + 'image_id': 1, + 'category_id': 1, + 'bbox': [0, 0, 1, 1], + 'segmentation': [[0, 0, 1, 0, 1, 1]], + } + ], + 'categories': [{'id': 1, 'name': 'fish'}], + } + ) + is False + ) + assert kwcoco.coco_contains_rle({'annotations': []}) is False + assert kwcoco.coco_contains_rle({}) is False From 4b61afe3c86e88f28a46f55e73a874acdf15383b Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Oct 2026 10:25:34 -0400 Subject: [PATCH 4/7] Desktop COCO RLE Mask support --- .../desktop/backend/native/common.spec.ts | 4 +- .../desktop/backend/serializers/coco.spec.ts | 96 +++++++- .../desktop/backend/serializers/coco.ts | 35 ++- .../desktop/backend/serializers/cocoRle.ts | 219 ++++++++++++++++++ docs/DataFormats.md | 5 +- 5 files changed, 341 insertions(+), 18 deletions(-) create mode 100644 client/platform/desktop/backend/serializers/cocoRle.ts diff --git a/client/platform/desktop/backend/native/common.spec.ts b/client/platform/desktop/backend/native/common.spec.ts index 1c3fbd193..896670d75 100644 --- a/client/platform/desktop/backend/native/common.spec.ts +++ b/client/platform/desktop/backend/native/common.spec.ts @@ -1209,8 +1209,8 @@ describe('native.common', () => { expect(result.processedFiles).toEqual([first, empty, third]); expect(result.warnings).toEqual([ - '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.', + 'The COCO file included run-length encoded segmentation masks that could not be decoded. ' + + 'Bounding boxes and other annotation data were imported, but those masks were skipped.', 'Ignored dataset_info entry: expected a JSON object but got number', ]); }); diff --git a/client/platform/desktop/backend/serializers/coco.spec.ts b/client/platform/desktop/backend/serializers/coco.spec.ts index 0ce42842f..204822a7b 100644 --- a/client/platform/desktop/backend/serializers/coco.spec.ts +++ b/client/platform/desktop/backend/serializers/coco.spec.ts @@ -18,6 +18,52 @@ import { speciesListFromCategories, typeHierarchyFromCategories, } from 'platform/desktop/backend/serializers/coco'; +import { decodeRleCounts } from 'platform/desktop/backend/serializers/cocoRle'; + +/** pycocotools rleToString, so the decoder is tested against real output. */ +function rleToString(cnts: number[]): string { + const out: string[] = []; + cnts.forEach((count, i) => { + let x = count; + if (i > 2) { + x -= cnts[i - 2]; + } + let more = true; + while (more) { + /* eslint-disable no-bitwise -- LEB128 digit packing from pycocotools */ + let chunk = x & 0x1f; + x >>= 5; + more = (chunk & 0x10) ? (x !== -1) : (x !== 0); + if (more) { + chunk |= 0x20; + } + /* eslint-enable no-bitwise */ + out.push(String.fromCharCode(chunk + 48)); + } + }); + return out.join(''); +} + +/** Column-major run lengths for a 6x6 square at (3, 2) in a 10x10 mask. */ +function squareMaskRuns(): number[] { + const runs: number[] = []; + let current = 0; + let length = 0; + for (let column = 0; column < 10; column += 1) { + for (let row = 0; row < 10; row += 1) { + const value = (row >= 2 && row < 8 && column >= 3 && column < 9) ? 1 : 0; + if (value === current) { + length += 1; + } else { + runs.push(length); + current = value; + length = 1; + } + } + } + runs.push(length); + return runs; +} const kwcocoProfile = fs.readJSONSync('../testutils/kwcoco/import-profile.json'); @@ -172,7 +218,7 @@ describe('COCO serializer', () => { }, }); 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/); + await expect(parseFile('/input/coco_no_bbox.json')).rejects.toThrow(/An RLE mask supplies bounds only when it can be decoded/); }); it('derives bbox from polygon when bbox is omitted', async () => { @@ -197,7 +243,7 @@ describe('COCO serializer', () => { expect(warnings).toEqual([]); }); - it('imports polygon segmentations and warns on RLE in the same file', async () => { + it('imports polygon segmentations and warns on undecodable RLE in the same file', async () => { mockfs({ '/input': { 'coco_mixed.json': JSON.stringify({ @@ -229,9 +275,10 @@ describe('COCO serializer', () => { expect(parsed.tracks[301].features[0].geometry?.features.length).toBe(1); expect(parsed.tracks[302].features[0].geometry).toBeUndefined(); expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('could not be decoded'); }); - it('imports bbox when RLE masks are present and returns a warning', async () => { + it('imports bbox when RLE masks cannot be decoded and returns a warning', async () => { mockfs({ '/input': { 'coco_rle.json': JSON.stringify({ @@ -253,7 +300,48 @@ describe('COCO serializer', () => { 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'); + expect(warnings[0]).toContain('could not be decoded'); + }); + + it.each([ + ['uncompressed list', false], + ['pycocotools string', true], + ])('imports RLE masks as outlines (%s)', async (_label, asString) => { + const runs = squareMaskRuns(); + const counts = asString ? rleToString(runs) : runs; + mockfs({ + '/input': { + 'coco_rle_ok.json': JSON.stringify({ + images: [{ id: 1, file_name: 'frame_000000.png', frame_index: 0 }], + annotations: [{ + id: 1, + image_id: 1, + category_id: 1, + track_id: 1, + iscrowd: 1, + segmentation: { counts, size: [10, 10] }, + }], + categories: [{ id: 1, name: 'fish' }], + }), + }, + }); + const [parsed, , warnings] = await parseFile('/input/coco_rle_ok.json'); + const feature = parsed.tracks[1].features[0]; + const polygon = feature.geometry?.features.find((g) => g.geometry.type === 'Polygon'); + expect(polygon).toBeTruthy(); + const coords = (polygon?.geometry as GeoJSON.Polygon).coordinates[0]; + const xs = coords.map(([x]) => x); + const ys = coords.map(([, y]) => y); + expect([Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]).toEqual([3, 8, 2, 7]); + expect(feature.bounds).toEqual([3, 2, 8, 7]); + expect(warnings).toEqual([]); + }); + + it('rejects junk RLE counts', () => { + expect(decodeRleCounts([1, -2])).toBeNull(); + expect(decodeRleCounts([1, 'x'])).toBeNull(); + expect(decodeRleCounts(null)).toBeNull(); + expect(decodeRleCounts(rleToString([4, 2, 4]))).toEqual([4, 2, 4]); }); it('serializes COCO with DIVE extension attributes', async () => { diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index 09437e3c0..1f47bbc04 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -6,6 +6,7 @@ import { JsonConfig } from 'platform/desktop/constants'; import processTrackAttributes from 'platform/desktop/backend/native/attributeProcessor'; import { strNumericCompare } from 'platform/desktop/sharedUtils'; import { TrackSupportedFeature } from 'vue-media-annotator/track'; +import { rlePolygonCoords } from 'platform/desktop/backend/serializers/cocoRle'; type CocoImage = { id: number; @@ -24,8 +25,8 @@ type CocoCategory = { }; 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.' + 'The COCO file included run-length encoded segmentation masks that could not be decoded. ' + + 'Bounding boxes and other annotation data were imported, but those masks were skipped.' ); const PROB_TOP_K = 25; @@ -170,7 +171,8 @@ function annotationHasImportableBounds(annotation: CocoAnnotation): boolean { return true; } if (hasRleSegmentation(annotation)) { - return false; + // An RLE mask supplies bounds only when it can be decoded to an outline. + return rlePolygonCoords(annotation.segmentation).length > 0; } return extractPolygonCoordsLists(annotation.segmentation).length > 0; } @@ -182,7 +184,7 @@ function missingBoundsError(annotationIds: Array): string { `${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.' + + 'An RLE mask supplies bounds only when it can be decoded.' ); } @@ -190,7 +192,10 @@ function resolveCocoBbox(annotation: CocoAnnotation): [number, number, number, n if (hasValidBbox(annotation)) { return annotation.bbox as [number, number, number, number]; } - const allPoints = extractPolygonCoordsLists(annotation.segmentation).flat(); + const coordLists = hasRleSegmentation(annotation) + ? rlePolygonCoords(annotation.segmentation) + : extractPolygonCoordsLists(annotation.segmentation); + const allPoints = coordLists.flat(); if (allPoints.length) { return bboxFromPoints(allPoints); } @@ -219,8 +224,8 @@ type CocoAnnotation = { * 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). + * Decodable RLE is traced to an outline polygon; undecodable masks keep bbox + * (when present) and skip geometry. */ iscrowd?: number; keypoints?: number[] | { xy: number[]; keypoint_category?: string; keypoint_category_id?: number; visible?: number }[]; @@ -270,7 +275,7 @@ function frameRateFromDocument(document: CocoDocument): number | undefined { return undefined; } -/** True when segmentation is COCO RLE (crowd / `iscrowd: 1`), which DIVE does not decode. */ +/** True when segmentation is COCO RLE / crowd (`iscrowd: 1` or a counts dict). */ function hasRleSegmentation(annotation: CocoAnnotation): boolean { if (annotation.iscrowd) { return true; @@ -284,10 +289,19 @@ function buildFeatureGeometry( category?: CocoCategory, keypointCategories: { id: number; name: string }[] = [], ): { geometry?: GeoJSON.FeatureCollection; rleSkipped: boolean } { - const rleSkipped = hasRleSegmentation(annotation); const geometryFeatures: GeoJSON.Feature[] = []; - const coordLists = rleSkipped ? [] : extractPolygonCoordsLists(annotation.segmentation); + let rleSkipped = false; + let coordLists: [number, number][][]; + if (hasRleSegmentation(annotation)) { + const rleCoords = rlePolygonCoords(annotation.segmentation); + // Only undecodable masks are reported; a traced outline is not a loss. + rleSkipped = !rleCoords.length; + // Largest outline only (server parity); holes / extra components are dropped. + coordLists = rleCoords.length ? [rleCoords[0]] : []; + } else { + coordLists = extractPolygonCoordsLists(annotation.segmentation); + } coordLists.forEach((coords) => { geometryFeatures.push({ type: 'Feature', @@ -712,6 +726,7 @@ export { PROB_DUPLICATE_CATEGORY_WARNING, PROB_LENGTH_MISMATCH_WARNING, PROB_TOP_K, + RLE_SEGMENTATION_WARNING, SUPERCATEGORY_DUPLICATE_CATEGORY_WARNING, SUPERCATEGORY_MULTI_PARENT_WARNING, invalidCocoHierarchyMessage, diff --git a/client/platform/desktop/backend/serializers/cocoRle.ts b/client/platform/desktop/backend/serializers/cocoRle.ts new file mode 100644 index 000000000..c45bd325e --- /dev/null +++ b/client/platform/desktop/backend/serializers/cocoRle.ts @@ -0,0 +1,219 @@ +/** + * Decode COCO RLE segmentations into outline polygons. + * + * Mirrors server/dive_utils/serializers/kwcoco.py: DIVE stores geometry, not + * rasters, so an imported mask becomes its outline. Holes are not representable + * and are dropped. A mask that cannot be decoded yields an empty result. + */ + +/** Refuse allocation past this; 8K×8K is already beyond DIVE display sizes. */ +const RLE_MAX_PIXELS = 64 * 1024 * 1024; + +/** Clockwise Moore neighbourhood as (dx, dy), starting due east. */ +const MOORE_OFFSETS: ReadonlyArray = [ + [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1], +]; + +/** + * Run lengths from either COCO counts spelling. + * + * Uncompressed COCO writes a list of integers; pycocotools writes the same + * runs LEB128-encoded into a string. + */ +function decodeRleCounts(counts: unknown): number[] | null { + if (Array.isArray(counts)) { + if (counts.every( + (count) => typeof count === 'number' + && Number.isInteger(count) + && count >= 0, + )) { + return counts as number[]; + } + return null; + } + if (typeof counts !== 'string') { + return null; + } + + const runs: number[] = []; + let position = 0; + while (position < counts.length) { + let value = 0; + let shift = 0; + let more = true; + while (more) { + if (position >= counts.length) { + return null; + } + const char = counts.charCodeAt(position) - 48; + // Use multiplicative power rather than << so shifts past 31 stay exact. + /* eslint-disable no-bitwise -- LEB128 digit packing from pycocotools */ + value += (char & 0x1f) * (2 ** shift); + more = Boolean(char & 0x20); + position += 1; + shift += 5; + // Final chunk carries the sign in bit 0x10 (pycocotools rleFrString). + if (!more && (char & 0x10)) { + value -= 2 ** shift; + } + /* eslint-enable no-bitwise */ + } + // Runs past the first two are deltas against the run two places back. + if (runs.length > 2) { + value += runs[runs.length - 2]; + } + runs.push(value); + } + return runs.every((run) => run >= 0) ? runs : null; +} + +function polygonArea(points: [number, number][]): number { + let total = 0; + for (let index = 0; index < points.length; index += 1) { + const [x, y] = points[index]; + const [nextX, nextY] = points[(index + 1) % points.length]; + total += x * nextY - nextX * y; + } + return Math.abs(total) / 2; +} + +/** + * Moore-neighbour trace of one component's outer boundary. + * + * Pure typed arrays: walking the boundary costs the perimeter rather than the area. + */ +function traceContour( + mask: Uint8Array, + width: number, + height: number, + start: [number, number], +): [number, number][] { + const contour: [number, number][] = [start]; + // Entering the start pixel from the west, so begin the search north of it. + let previous: [number, number] = [start[0] - 1, start[1]]; + let current: [number, number] = start; + let tracing = true; + + while (tracing) { + const back: [number, number] = [previous[0] - current[0], previous[1] - current[1]]; + let index = MOORE_OFFSETS.findIndex( + ([dx, dy]) => dx === back[0] && dy === back[1], + ); + if (index < 0) { + index = 0; + } + let found: [number, number] | null = null; + for (let step = 1; step < 9; step += 1) { + const [dx, dy] = MOORE_OFFSETS[(index + step) % 8]; + const candidate: [number, number] = [current[0] + dx, current[1] + dy]; + if (candidate[0] >= 0 && candidate[0] < width + && candidate[1] >= 0 && candidate[1] < height) { + if (mask[candidate[1] * width + candidate[0]]) { + found = candidate; + break; + } + previous = candidate; + } + } + if (found === null) { + tracing = false; // isolated pixel + } else if (found[0] === start[0] && found[1] === start[1] && contour.length > 1) { + tracing = false; + } else { + contour.push(found); + previous = current; + current = found; + if (contour.length > 4 * height * width) { + tracing = false; // cannot happen; refuses to spin + } + } + } + + return contour.map(([x, y]) => [x, y]); +} + +/** + * Trace a COCO RLE mask into image-space polygon contours. + * + * Contours are sorted largest-area first so callers that take the first get the + * primary outline. Holes are not representable and are dropped. + */ +function rlePolygonCoords(segmentation: unknown): [number, number][][] { + if (!segmentation || typeof segmentation !== 'object' || Array.isArray(segmentation)) { + return []; + } + const { size, counts } = segmentation as { size?: unknown; counts?: unknown }; + if (!Array.isArray(size) || size.length !== 2) { + return []; + } + const [height, width] = size; + if (!Number.isInteger(height) || !Number.isInteger(width)) { + return []; + } + if (height <= 0 || width <= 0 || height * width > RLE_MAX_PIXELS) { + return []; + } + + const runs = decodeRleCounts(counts); + if (runs === null || runs.reduce((sum, run) => sum + run, 0) !== height * width) { + return []; + } + + // Column-major flat buffer (COCO / Fortran order): index = x * height + y. + const flat = new Uint8Array(height * width); + let position = 0; + runs.forEach((run, index) => { + if (index % 2) { + flat.fill(1, position, position + run); + } + position += run; + }); + + // Row-major mask for neighbour walks: index = y * width + x. + const mask = new Uint8Array(height * width); + let any = false; + for (let x = 0; x < width; x += 1) { + for (let y = 0; y < height; y += 1) { + if (flat[x * height + y]) { + mask[y * width + x] = 1; + any = true; + } + } + } + if (!any) { + return []; + } + + // Boundary = foreground with at least one background 4-neighbour. + const visited = new Uint8Array(height * width); + const coordLists: [number, number][][] = []; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * width + x; + if (mask[index] && !visited[index]) { + const up = y > 0 && mask[(y - 1) * width + x]; + const down = y + 1 < height && mask[(y + 1) * width + x]; + const left = x > 0 && mask[y * width + (x - 1)]; + const right = x + 1 < width && mask[y * width + (x + 1)]; + if (!(up && down && left && right)) { + const contour = traceContour(mask, width, height, [x, y]); + contour.forEach(([cx, cy]) => { + visited[cy * width + cx] = 1; + }); + if (contour.length >= 3) { + coordLists.push(contour); + } + } + } + } + } + + coordLists.sort((a, b) => polygonArea(b) - polygonArea(a)); + return coordLists; +} + +export { + RLE_MAX_PIXELS, + decodeRleCounts, + rlePolygonCoords, +}; diff --git a/docs/DataFormats.md b/docs/DataFormats.md index 78178629d..3908ddfa4 100644 --- a/docs/DataFormats.md +++ b/docs/DataFormats.md @@ -596,8 +596,9 @@ For COCO files not produced by DIVE: writes. Holes are not representable and are dropped, and a mask that cannot be decoded is skipped with a warning, as before. On the web, RLE conversion runs as a postprocess convert job (same job tracking as video/image transcoding) so the - import request does not decode masks inline; desktop import still skips RLE. - Decoding needs no extra dependency: the outline is traced with numpy alone. + import request does not decode masks inline; desktop import decodes RLE the same + way during COCO parse. Decoding needs no extra dependency: the outline is traced + with typed arrays alone on desktop, and with numpy on the server. ### Example COCO Annotation with DIVE Extensions From a70c0c428831381694e5dbf028646833ca759b40 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Oct 2026 10:38:05 -0400 Subject: [PATCH 5/7] delay coco processing on web, more efficient decode for COCO --- .../desktop/backend/serializers/coco.ts | 89 +++++++---- server/dive_server/crud_rpc.py | 79 +++++----- server/dive_tasks/import_coco.py | 5 +- server/dive_utils/serializers/kwcoco.py | 138 ++++++++++++------ 4 files changed, 203 insertions(+), 108 deletions(-) diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index 1f47bbc04..f5ee38b86 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -166,15 +166,29 @@ function bboxFromPoints(points: [number, number][]): [number, number, number, nu return [xMin, yMin, Math.max(...xs) - xMin, Math.max(...ys) - yMin]; } -function annotationHasImportableBounds(annotation: CocoAnnotation): boolean { - if (hasValidBbox(annotation)) { - return true; +/** Decode segmentation once; reuse for bounds check, bbox, and geometry. */ +function segmentationCoordLists( + annotation: CocoAnnotation, +): { coordLists: [number, number][][]; rleSkipped: boolean } { + if (!annotation.segmentation) { + return { coordLists: [], rleSkipped: false }; } if (hasRleSegmentation(annotation)) { - // An RLE mask supplies bounds only when it can be decoded to an outline. - return rlePolygonCoords(annotation.segmentation).length > 0; + const coordLists = rlePolygonCoords(annotation.segmentation); + return { coordLists, rleSkipped: !coordLists.length }; + } + return { coordLists: extractPolygonCoordsLists(annotation.segmentation), rleSkipped: false }; +} + +function annotationHasImportableBounds( + annotation: CocoAnnotation, + coordLists?: [number, number][][], +): boolean { + if (hasValidBbox(annotation)) { + return true; } - return extractPolygonCoordsLists(annotation.segmentation).length > 0; + const lists = coordLists ?? segmentationCoordLists(annotation).coordLists; + return lists.length > 0; } function missingBoundsError(annotationIds: Array): string { @@ -188,23 +202,30 @@ function missingBoundsError(annotationIds: Array): string { ); } -function resolveCocoBbox(annotation: CocoAnnotation): [number, number, number, number] { +function resolveCocoBbox( + annotation: CocoAnnotation, + coordLists?: [number, number][][], +): [number, number, number, number] { if (hasValidBbox(annotation)) { return annotation.bbox as [number, number, number, number]; } - const coordLists = hasRleSegmentation(annotation) - ? rlePolygonCoords(annotation.segmentation) - : extractPolygonCoordsLists(annotation.segmentation); - const allPoints = coordLists.flat(); + const lists = coordLists ?? segmentationCoordLists(annotation).coordLists; + const allPoints = lists.flat(); if (allPoints.length) { return bboxFromPoints(allPoints); } throw new Error(missingBoundsError([annotation.id])); } -function validateAnnotationBounds(annotations: CocoAnnotation[]): void { +function validateAnnotationBounds( + annotations: CocoAnnotation[], + coordListsByAnnotation?: WeakMap, +): void { const missingIds = annotations - .filter((annotation) => !annotationHasImportableBounds(annotation)) + .filter((annotation) => !annotationHasImportableBounds( + annotation, + coordListsByAnnotation?.get(annotation), + )) .map((annotation) => annotation.id); if (missingIds.length) { throw new Error(missingBoundsError(missingIds)); @@ -288,20 +309,16 @@ function buildFeatureGeometry( annotation: CocoAnnotation, category?: CocoCategory, keypointCategories: { id: number; name: string }[] = [], + precomputed?: { coordLists: [number, number][][]; rleSkipped: boolean }, ): { geometry?: GeoJSON.FeatureCollection; rleSkipped: boolean } { const geometryFeatures: GeoJSON.Feature[] = []; - let rleSkipped = false; - let coordLists: [number, number][][]; - if (hasRleSegmentation(annotation)) { - const rleCoords = rlePolygonCoords(annotation.segmentation); - // Only undecodable masks are reported; a traced outline is not a loss. - rleSkipped = !rleCoords.length; - // Largest outline only (server parity); holes / extra components are dropped. - coordLists = rleCoords.length ? [rleCoords[0]] : []; - } else { - coordLists = extractPolygonCoordsLists(annotation.segmentation); - } + const resolved = precomputed ?? segmentationCoordLists(annotation); + const { rleSkipped } = resolved; + // Largest outline only for RLE (server parity); holes / extra components are dropped. + const coordLists = hasRleSegmentation(annotation) && resolved.coordLists.length + ? [resolved.coordLists[0]] + : resolved.coordLists; coordLists.forEach((coords) => { geometryFeatures.push({ type: 'Feature', @@ -471,12 +488,25 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record(); + const coordListsByAnnotation = new WeakMap(); + parsed.annotations.forEach((annotation) => { + const resolved = segmentationCoordLists(annotation); + segByAnnotation.set(annotation, resolved); + coordListsByAnnotation.set(annotation, resolved.coordLists); + }); + validateAnnotationBounds(parsed.annotations, coordListsByAnnotation); parsed.annotations.forEach((annotation) => { const frame = frameByImageId[annotation.image_id]; if (frame === undefined) return; - const [x, y, w, h] = resolveCocoBbox(annotation); + const precomputed = segByAnnotation.get(annotation) + ?? segmentationCoordLists(annotation); + const [x, y, w, h] = resolveCocoBbox(annotation, precomputed.coordLists); 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]; @@ -542,7 +572,12 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record List[float]: 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 +def _segmentation_coord_lists( + annotation: dict, +) -> Tuple[List[List[Tuple[float, float]]], bool]: + """Return ``(coord_lists, rle_skipped)`` for an annotation's segmentation. + + ``rle_skipped`` is True only when the annotation is RLE and decoding failed. + Callers that need bounds, bbox, and polygon geometry should invoke this once + and reuse the result so large masks are not decoded repeatedly. + """ segmentation = annotation.get('segmentation', []) + if not segmentation: + return [], False if _is_rle_segmentation(annotation, segmentation): - return bool(_rle_polygon_coords(segmentation)) - return bool(_extract_polygon_coords_lists(segmentation)) + coord_lists = _rle_polygon_coords(segmentation) + return coord_lists, not bool(coord_lists) + return _extract_polygon_coords_lists(segmentation), False + + +def _annotation_has_importable_bounds( + annotation: dict, + coord_lists: Optional[List[List[Tuple[float, float]]]] = None, +) -> bool: + if _has_valid_bbox(annotation): + return True + if coord_lists is None: + coord_lists, _ = _segmentation_coord_lists(annotation) + return bool(coord_lists) def _missing_bounds_error(annotation_ids: List) -> str: @@ -460,15 +480,15 @@ def _missing_bounds_error(annotation_ids: List) -> str: ) -def _resolve_coco_bbox(annotation: dict) -> List[float]: +def _resolve_coco_bbox( + annotation: dict, + coord_lists: Optional[List[List[Tuple[float, float]]]] = None, +) -> List[float]: if _has_valid_bbox(annotation): return list(annotation['bbox']) - segmentation = annotation.get('segmentation', []) - if _is_rle_segmentation(annotation, segmentation): - coord_lists = _rle_polygon_coords(segmentation) - else: - coord_lists = _extract_polygon_coords_lists(segmentation) + if coord_lists is None: + coord_lists, _ = _segmentation_coord_lists(annotation) all_points = [point for coords in coord_lists for point in coords] if all_points: return _bbox_from_points(all_points) @@ -476,12 +496,15 @@ def _resolve_coco_bbox(annotation: dict) -> List[float]: 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) - ] +def _validate_annotation_bounds( + annotations: List[dict], + coord_lists_by_id: Optional[Dict[int, List[List[Tuple[float, float]]]]] = None, +) -> None: + missing_ids = [] + for annotation in annotations: + coords = None if coord_lists_by_id is None else coord_lists_by_id.get(id(annotation)) + if not _annotation_has_importable_bounds(annotation, coords): + missing_ids.append(annotation.get('id', '?')) if missing_ids: raise ValueError(_missing_bounds_error(missing_ids)) @@ -511,7 +534,11 @@ def is_coco_json(coco: Dict[str, Any]): return all(key in coco for key in keys) -def annotation_info(annotation: dict, meta: CocoMetadata) -> Tuple[int, str, int, List[int]]: +def annotation_info( + annotation: dict, + meta: CocoMetadata, + coord_lists: Optional[List[List[Tuple[float, float]]]] = None, +) -> Tuple[int, str, int, List[int]]: # these fields will always exist annotation_id = annotation['id'] image_id = annotation['image_id'] @@ -523,7 +550,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 = _resolve_coco_bbox(annotation) + bounds = _resolve_coco_bbox(annotation, coord_lists) # 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] @@ -532,8 +559,11 @@ def annotation_info(annotation: dict, meta: CocoMetadata) -> Tuple[int, str, int def _parse_annotation( - annotation: dict, meta: CocoMetadata -) -> Tuple[dict, dict, dict, list, List[str], bool]: + annotation: dict, + meta: CocoMetadata, + coord_lists: Optional[List[List[Tuple[float, float]]]] = None, + rle_skipped: Optional[bool] = None, +) -> Tuple[dict, dict, dict, list, List[str], bool, List[List[Tuple[float, float]]]]: """ Parse a single KWCOCO annotation into its composite track and detection parts """ @@ -580,20 +610,13 @@ def _parse_annotation( line = [points[k][:2] for k in ['head', *spine, 'tail']] viame.create_geoJSONFeature(features, 'LineString', line, 'HeadTails') - # parse polygons - segmentation = annotation.get('segmentation', []) - rle_skipped = False - - if segmentation: - if _is_rle_segmentation(annotation, segmentation): - coord_lists = _rle_polygon_coords(segmentation) - # Only undecodable masks are reported; a traced one is not a loss - # worth warning about. - rle_skipped = not coord_lists - else: - coord_lists = _extract_polygon_coords_lists(segmentation) - if coord_lists: - viame.create_geoJSONFeature(features, 'Polygon', coord_lists[0]) + # parse polygons (reuse precomputed coords when the load path decoded once) + if coord_lists is None: + coord_lists, rle_skipped = _segmentation_coord_lists(annotation) + elif rle_skipped is None: + rle_skipped = _is_rle_segmentation(annotation) and not coord_lists + if coord_lists: + viame.create_geoJSONFeature(features, 'Polygon', coord_lists[0]) # DIVE extension fields for non-standard COCO attributes. detection_attributes = annotation.get( @@ -614,12 +637,23 @@ def _parse_annotation( elif isinstance(note_values, str) and note_values.strip(): notes.append(note_values.strip()) - return features, attributes, track_attributes, [confidence_pair], notes, rle_skipped + return ( + features, + attributes, + track_attributes, + [confidence_pair], + notes, + bool(rle_skipped), + coord_lists, + ) def _parse_annotation_for_tracks( - annotation: dict, meta: CocoMetadata -) -> Tuple[Feature, dict, dict, list, bool]: + annotation: dict, + meta: CocoMetadata, + coord_lists: Optional[List[List[Tuple[float, float]]]] = None, + rle_skipped: Optional[bool] = None, +) -> Tuple[Feature, dict, dict, list, bool, int, int]: ( features, attributes, @@ -627,8 +661,9 @@ def _parse_annotation_for_tracks( confidence_pairs, notes, rle_skipped, - ) = _parse_annotation(annotation, meta) - trackId, filename, frame, bounds = annotation_info(annotation, meta) + coord_lists, + ) = _parse_annotation(annotation, meta, coord_lists, rle_skipped) + trackId, _filename, frame, bounds = annotation_info(annotation, meta, coord_lists) feature = Feature( frame=frame, @@ -640,7 +675,7 @@ def _parse_annotation_for_tracks( ) # Pass the rest of the unchanged info through as well - return feature, attributes, track_attributes, confidence_pairs, rle_skipped + return feature, attributes, track_attributes, confidence_pairs, rle_skipped, trackId, frame def load_coco_metadata(coco: Dict[str, Any]) -> CocoMetadata: @@ -704,7 +739,14 @@ def load_coco_as_tracks_and_attributes( skipped_rle_masks = False meta = load_coco_metadata(coco) annotations = coco.get('annotations', []) - _validate_annotation_bounds(annotations) + # Decode each segmentation once; reuse for bounds check, bbox, and geometry. + seg_by_annotation_id: Dict[int, Tuple[List[List[Tuple[float, float]]], bool]] = { + id(annotation): _segmentation_coord_lists(annotation) for annotation in annotations + } + _validate_annotation_bounds( + annotations, + {key: coords for key, (coords, _rle_skipped) in seg_by_annotation_id.items()}, + ) ordered_names = meta.ordered_category_names duplicate_category_names = _has_duplicate_names(ordered_names) @@ -724,13 +766,21 @@ def load_coco_as_tracks_and_attributes( malformed_extension = False for annotation in annotations: + coord_lists, rle_skipped_pre = seg_by_annotation_id[id(annotation)] ( feature, attributes, track_attributes, confidence_pairs, rle_skipped, - ) = _parse_annotation_for_tracks(annotation, meta) + trackId, + frame, + ) = _parse_annotation_for_tracks( + annotation, + meta, + coord_lists=coord_lists, + rle_skipped=rle_skipped_pre, + ) skipped_rle_masks = skipped_rle_masks or rle_skipped extension_present = 'dive_confidence_pairs' in annotation @@ -750,8 +800,6 @@ def load_coco_as_tracks_and_attributes( elif prob_pairs: confidence_pairs = prob_pairs - trackId, _, frame, _ = annotation_info(annotation, meta) - if trackId not in tracks: tracks[trackId] = Track(begin=frame, end=frame, id=trackId) From b7211fb3a076d8a704d4cac21de359ff17115d22 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Oct 2026 10:46:00 -0400 Subject: [PATCH 6/7] prevent overwrites of other annotations --- server/dive_server/crud_rpc.py | 37 ++++++++++++++++ server/dive_tasks/import_coco.py | 8 ++-- server/tests/test_process_items.py | 68 +++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/server/dive_server/crud_rpc.py b/server/dive_server/crud_rpc.py index 022b11f3d..fede3918c 100644 --- a/server/dive_server/crud_rpc.py +++ b/server/dive_server/crud_rpc.py @@ -1125,6 +1125,37 @@ def _unprocessed_data_items(folder: types.GirderModel) -> list: ) +def _has_competing_annotation_imports( + configuration_plan: dict, + rle_item_ids: Set[str], + folder: types.GirderModel, + user: types.GirderUserModel, +) -> bool: + """True when the sync sweep would import annotations besides deferred RLE files. + + Deferring RLE always finishes after the sync ``process_items`` pass. With + ``additive=False``, that follow-up calls ``save_annotations(..., overwrite=True)`` + and would wipe tracks imported moments earlier from co-uploaded CSV/JSON/KPF. + Keep RLE on the sync path whenever another annotation source shares the batch so + creation-order overwrite semantics stay intact. + """ + _, is_declared_sidecar = _declared_sidecar_predicate(folder, user) + parsed_json_items = configuration_plan['parsed_json_items'] + for item in configuration_plan['unprocessed_items']: + item_id = str(item['_id']) + if item_id in rle_item_ids or is_declared_sidecar(item): + continue + name = item['name'] + if constants.csvRegex.search(name) or constants.ymlRegex.search(name): + return True + cached = parsed_json_items.get(item_id) + if cached is not None: + _file, results, _warnings = cached + if results.get('annotations'): + return True + return False + + def _declared_sidecar_predicate(folder: types.GirderModel, user: types.GirderUserModel): """Return the folder's attachment item id and a predicate identifying declared sidecars. @@ -1873,6 +1904,8 @@ def _postprocess( # COCO files with RLE masks decode off-thread like media convert jobs so the # postprocess request stays responsive. Hierarchy/fps were already staged; # the job re-enters postprocess with skipJobs=True to finish the import. + # Only defer when RLE is the sole annotation source in this batch: a later + # overwrite import would otherwise wipe sync CSV/JSON/KPF tracks. rle_item_ids = { item_id for item_id, (_file, results, _warnings) in configuration_plan[ @@ -1880,6 +1913,10 @@ def _postprocess( ].items() if results.get('has_rle') } + if rle_item_ids and _has_competing_annotation_imports( + configuration_plan, rle_item_ids, dsFolder, user + ): + rle_item_ids = set() if rle_item_ids: rle_names = [ item['name'] diff --git a/server/dive_tasks/import_coco.py b/server/dive_tasks/import_coco.py index cec4ac02a..63713e3cd 100644 --- a/server/dive_tasks/import_coco.py +++ b/server/dive_tasks/import_coco.py @@ -23,9 +23,11 @@ def import_coco_annotations( ): """Finish COCO import for files whose RLE masks were deferred from postprocess. - The originating postprocess call enqueues this job only after its synchronous - ``process_items`` sweep finishes, so co-uploaded CSV/JSON are already imported - before this worker re-enters ``dive_rpc/postprocess`` with ``skipJobs=True``. + Postprocess only enqueues this job when RLE COCO is the sole annotation source + in the batch (mixed CSV/JSON/KPF stays on the sync path to preserve overwrite + ordering). The job is started after the originating ``process_items`` sweep so + hierarchy/fps staging is already applied before this worker re-enters + ``dive_rpc/postprocess`` with ``skipJobs=True``. """ context: dict = {} gc: GirderClient = self.girder_client diff --git a/server/tests/test_process_items.py b/server/tests/test_process_items.py index 0f9bec7a7..2a741d680 100644 --- a/server/tests/test_process_items.py +++ b/server/tests/test_process_items.py @@ -3,7 +3,7 @@ from girder.exceptions import RestException import pytest -from dive_server.crud_rpc import process_items +from dive_server.crud_rpc import _has_competing_annotation_imports, process_items from dive_utils import constants, frame_metadata VIAME_HEADER = ( @@ -456,3 +456,69 @@ def test_undecodable_plain_csv_fails_loudly_with_rename_hint( assert 'frame-metadata.csv' in str(excinfo.value) item_cls.return_value.remove.assert_called_once_with(item) save_annotations.assert_not_called() + + +@patch('dive_server.crud_rpc.crud_dataset.resolve_metadata_attachment_item_id') +def test_competing_annotation_imports_detects_csv_beside_rle(resolve_attachment_item_id): + # RLE deferral must stay off when a CSV shares the batch, or the async overwrite + # import would wipe the CSV tracks. + resolve_attachment_item_id.return_value = None + plan = { + 'unprocessed_items': [ + {'_id': 'rle', 'name': 'masks.json', 'meta': {}}, + {'_id': 'csv', 'name': 'annotations.csv', 'meta': {}}, + ], + 'parsed_json_items': { + 'rle': ( + {'_id': 'f-rle'}, + {'has_rle': True, 'annotations': None}, + [], + ), + }, + } + assert ( + _has_competing_annotation_imports(plan, {'rle'}, {'_id': 'ds', 'meta': {}}, {'_id': 'u'}) + is True + ) + + +@patch('dive_server.crud_rpc.crud_dataset.resolve_metadata_attachment_item_id') +def test_competing_annotation_imports_ignores_solo_rle(resolve_attachment_item_id): + resolve_attachment_item_id.return_value = None + plan = { + 'unprocessed_items': [{'_id': 'rle', 'name': 'masks.json', 'meta': {}}], + 'parsed_json_items': { + 'rle': ( + {'_id': 'f-rle'}, + {'has_rle': True, 'annotations': None}, + [], + ), + }, + } + assert ( + _has_competing_annotation_imports(plan, {'rle'}, {'_id': 'ds', 'meta': {}}, {'_id': 'u'}) + is False + ) + + +@patch('dive_server.crud_rpc.crud_dataset.resolve_metadata_attachment_item_id') +def test_competing_annotation_imports_detects_dive_json_tracks(resolve_attachment_item_id): + resolve_attachment_item_id.return_value = None + plan = { + 'unprocessed_items': [ + {'_id': 'rle', 'name': 'masks.json', 'meta': {}}, + {'_id': 'dive', 'name': 'tracks.json', 'meta': {}}, + ], + 'parsed_json_items': { + 'rle': ({'_id': 'f-rle'}, {'has_rle': True, 'annotations': None}, []), + 'dive': ( + {'_id': 'f-dive'}, + {'annotations': {'tracks': {'1': {}}, 'groups': {}}}, + [], + ), + }, + } + assert ( + _has_competing_annotation_imports(plan, {'rle'}, {'_id': 'ds', 'meta': {}}, {'_id': 'u'}) + is True + ) From 92881cbc7adc757a79b1054f284c95acfb34d032 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Tue, 6 Oct 2026 11:00:47 -0400 Subject: [PATCH 7/7] linting --- client/platform/desktop/backend/serializers/coco.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/platform/desktop/backend/serializers/coco.ts b/client/platform/desktop/backend/serializers/coco.ts index f5ee38b86..a44fc67db 100644 --- a/client/platform/desktop/backend/serializers/coco.ts +++ b/client/platform/desktop/backend/serializers/coco.ts @@ -308,8 +308,8 @@ function hasRleSegmentation(annotation: CocoAnnotation): boolean { function buildFeatureGeometry( annotation: CocoAnnotation, category?: CocoCategory, - keypointCategories: { id: number; name: string }[] = [], precomputed?: { coordLists: [number, number][][]; rleSkipped: boolean }, + keypointCategories: { id: number; name: string }[] = [], ): { geometry?: GeoJSON.FeatureCollection; rleSkipped: boolean } { const geometryFeatures: GeoJSON.Feature[] = []; @@ -575,8 +575,8 @@ async function parseFile(path: string): Promise<[AnnotationSchema, Record