From 5a1e4cb64000d60cb3b4c1a3774ea303a12d6689 Mon Sep 17 00:00:00 2001 From: jinman Date: Tue, 18 Aug 2026 13:30:13 +0900 Subject: [PATCH 1/4] fix: update README with logo and badges for better visibility --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index b21c357..35b8590 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # Mobilint Vision Python + +
+

+ +Mobilint Logo + +

+
+ + Run pre-trained Mobilint Vision models from Python. `mblt-vision-python` provides model configuration, artifact loading, preprocessing, inference integration, and typed postprocessing results for image classification, depth estimation, face and @@ -10,6 +20,10 @@ Version `0.0.0` is the initial standalone release. ## Installation +[![PyPI - Version](https://img.shields.io/pypi/v/mblt-vision-python?logo=pypi&logoColor=white)](https://pypi.org/project/mblt-vision-python/) +[![PyPI Downloads](https://static.pepy.tech/badge/mblt-vision-python?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://clickpy.clickhouse.com/dashboard/mblt-vision-python) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mblt-vision-python?logo=python&logoColor=gold)](https://pypi.org/project/mblt-vision-python/) + ```bash pip install mblt-vision-python ``` From c59a7fe4dd9e3dba1d7b723ef4f158e5fadd8d9b Mon Sep 17 00:00:00 2001 From: jinman Date: Thu, 20 Aug 2026 14:46:51 +0900 Subject: [PATCH 2/4] fix: refine COCO dataset validation to accept official metadata nuances --- mblt_vision/utils/datasets/readiness.py | 51 +++---------------- tests/test_dataset_readiness.py | 41 +++++++-------- tests/test_eval_coco.py | 66 ++++++++++--------------- 3 files changed, 49 insertions(+), 109 deletions(-) diff --git a/mblt_vision/utils/datasets/readiness.py b/mblt_vision/utils/datasets/readiness.py index 3aeaa5d..e6e1682 100644 --- a/mblt_vision/utils/datasets/readiness.py +++ b/mblt_vision/utils/datasets/readiness.py @@ -406,8 +406,6 @@ def _coco_task_annotations_valid( or iscrowd not in {0, 1} ): return False - if task == "pose_estimation" and area > bbox[2] * bbox[3]: - return False if task == "instance_segmentation": segmentation = record.get("segmentation") if isinstance(segmentation, list): @@ -425,7 +423,6 @@ def _coco_task_annotations_valid( or not _polygon_has_positive_image_overlap( polygon, image_shapes.get(image_id) ) - or not _valid_coco_polygon(polygon, image_shapes.get(image_id)) for polygon in segmentation ): return False @@ -545,22 +542,6 @@ def _valid_coco_rle( return decoded.shape == tuple(size) and bool(np.any(decoded)) -def _valid_coco_polygon( - polygon: list[int | float], image_shape: tuple[int, int] | None -) -> bool: - """Require a COCO polygon to rasterize to foreground in its image.""" - - if image_shape is None: - return True - height, width = image_shape - try: - encoded = coco_mask.frPyObjects([polygon], height, width) - decoded = np.asarray(coco_mask.decode(encoded)) - except (RuntimeError, TypeError, ValueError): - return False - return bool(np.any(decoded)) - - def _coco_ready(root: Path, task: str) -> bool: """Check the complete COCO 2017 image split and task annotation metadata.""" @@ -630,12 +611,10 @@ def _dotav1_ready(root: Path) -> bool: if images is None or len(images) != DOTAV1_VALIDATION_SAMPLE_COUNT: return False - normalized_labels = _files_by_stem(root / "labels" / "val", {".txt"}) original_labels = _files_by_stem(root / "labels" / "val_original", {".txt"}) - if normalized_labels is None or original_labels is None: + if original_labels is None: return False - label_stems = normalized_labels.keys() | original_labels.keys() - return images.keys() == label_stems + return images.keys() == original_labels.keys() def _widerface_ready(root: Path) -> bool: @@ -727,31 +706,19 @@ def _widerface_difficulty_metadata_ready( face_array = np.asarray(face_entry[0]) except (IndexError, TypeError): return False - if ( + no_face_sentinel = face_array.shape == (1, 4) and not bool( + np.any(face_array) + ) + if not no_face_sentinel and ( face_array.ndim != 2 or face_array.shape[1] != 4 or len(face_array) == 0 or not np.issubdtype(face_array.dtype, np.number) or np.issubdtype(face_array.dtype, np.complexfloating) or not np.isfinite(face_array).all() - or (face_array[:, 2:] <= 0).any() - or len(np.unique(face_array, axis=0)) != len(face_array) ): return False - if image_shapes is not None: - height, width = image_shapes[event_index][image_index] - if ( - height <= 0 - or width <= 0 - or not ( - (face_array[:, 0] < width) - & (face_array[:, 0] + face_array[:, 2] > 0) - & (face_array[:, 1] < height) - & (face_array[:, 1] + face_array[:, 3] > 0) - ).all() - ): - return False - face_counts.append(len(face_array)) + face_counts.append(0 if no_face_sentinel else len(face_array)) for difficulty_index, table in enumerate(difficulties): try: event_indices = table[event_index][0] @@ -990,10 +957,6 @@ def dense_dataset_ready(data_path: str | Path, dataset: str) -> bool: and all( path.suffix.lower() in {".jpg", ".jpeg"} for path in images.values() ) - and all( - not (root / file_name).is_symlink() and (root / file_name).is_file() - for file_name in ADE20K_METADATA_FILES - ) ) if normalized == "cityscapes": city_counts: dict[str, int] = {} diff --git a/tests/test_dataset_readiness.py b/tests/test_dataset_readiness.py index bf2737f..f0ad6ef 100644 --- a/tests/test_dataset_readiness.py +++ b/tests/test_dataset_readiness.py @@ -685,10 +685,10 @@ def _loadmat(path: Path) -> dict[str, np.ndarray]: ) -def test_widerface_readiness_rejects_face_boxes_outside_images( +def test_widerface_readiness_accepts_official_out_of_image_boxes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Face ground truth must retain foreground in its decoded source image.""" + """Permit the slight out-of-image boxes present in official metadata.""" face_boxes = np.empty((1, 1), dtype=object) event_faces = np.empty((1, 1), dtype=object) @@ -706,7 +706,7 @@ def _loadmat(path: Path) -> dict[str, np.ndarray]: monkeypatch.setattr(readiness, "loadmat", _loadmat) - assert not readiness._widerface_difficulty_metadata_ready( + assert readiness._widerface_difficulty_metadata_ready( tmp_path, {"0--Parade": {"sample.jpg"}}, image_shapes=[[(10, 10)]], @@ -716,13 +716,17 @@ def _loadmat(path: Path) -> dict[str, np.ndarray]: def test_widerface_readiness_allows_empty_event_difficulty_contributions( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """A difficulty split may have no eligible faces in one event but not all.""" + """Accept the official no-face sentinel when no split indexes it.""" face_boxes = np.empty((2, 1), dtype=object) difficulty = np.empty((2, 1), dtype=object) for event_index in range(2): event_faces = np.empty((1, 1), dtype=object) - event_faces[0, 0] = np.array([[0, 0, 1, 1]], dtype=np.float64) + event_faces[0, 0] = ( + np.array([[0, 0, 0, 0]], dtype=np.float64) + if event_index == 0 + else np.array([[0, 0, 1, 1]], dtype=np.float64) + ) face_boxes[event_index, 0] = event_faces event_indices = np.empty((1, 1), dtype=object) event_indices[0, 0] = ( @@ -745,10 +749,10 @@ def _loadmat(path: Path) -> dict[str, np.ndarray]: ) -def test_widerface_readiness_rejects_duplicate_face_boxes( +def test_widerface_readiness_accepts_duplicate_official_face_boxes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Duplicate ground-truth rows cannot alter the WiderFace recall denominator.""" + """Permit duplicate rows present in the official WiderFace metadata.""" face_boxes = np.empty((1, 1), dtype=object) event_faces = np.empty((1, 1), dtype=object) @@ -766,7 +770,7 @@ def _loadmat(path: Path) -> dict[str, np.ndarray]: monkeypatch.setattr(readiness, "loadmat", _loadmat) - assert not readiness._widerface_difficulty_metadata_ready( + assert readiness._widerface_difficulty_metadata_ready( tmp_path, {"0--Parade": {"sample.jpg"}} ) @@ -777,7 +781,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( tmp_path: Path, relative_image_dir: str, ) -> None: - """Accept flat and legacy DOTA images only when every image has a label.""" + """Require an authoritative original label for every DOTA image.""" monkeypatch.setattr(readiness, "DOTAV1_VALIDATION_SAMPLE_COUNT", 2) for stem in ("P0001", "P0002"): @@ -786,7 +790,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( assert not readiness.dataset_ready(tmp_path, "obb", "dotav1") - _write_file(tmp_path / "labels" / "val" / "P0002.txt") + _write_file(tmp_path / "labels" / "val_original" / "P0002.txt") assert readiness.dataset_ready(tmp_path, "obb", "dotav1") @@ -795,7 +799,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( _write_file(external_image) _write_file(external_label) image_path = tmp_path / relative_image_dir / "P0001.png" - label_path = tmp_path / "labels" / "val" / "P0002.txt" + label_path = tmp_path / "labels" / "val_original" / "P0002.txt" image_path.unlink() label_path.unlink() image_path.symlink_to(external_image) @@ -870,11 +874,11 @@ def test_widerface_readiness_rejects_tree_not_named_by_metadata( assert not readiness.dataset_ready(tmp_path, "face_detection", "widerface") -def test_ade20k_readiness_requires_source_metadata( +def test_ade20k_readiness_does_not_require_unused_source_metadata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """Require both ADE20K metadata files before reusing an organized cache.""" + """Allow organized ADE20K evaluation data without unused source metadata.""" monkeypatch.setattr(readiness, "ADE20K_VALIDATION_SAMPLE_COUNT", 1) _write_image(tmp_path / "images" / "ADE_val_00000001.jpg", (1, 1)) @@ -882,12 +886,6 @@ def test_ade20k_readiness_requires_source_metadata( annotation_path.parent.mkdir(parents=True, exist_ok=True) Image.new("L", (1, 1), color=1).save(annotation_path) - assert not readiness.dataset_ready(tmp_path, "semantic_segmentation", "ade20k") - - _write_file(tmp_path / "objectInfo150.txt") - assert not readiness.dataset_ready(tmp_path, "semantic_segmentation", "ade20k") - - _write_file(tmp_path / "sceneCategories.txt") assert readiness.dataset_ready(tmp_path, "semantic_segmentation", "ade20k") @@ -899,7 +897,6 @@ def test_ade20k_readiness_requires_source_metadata( ("nyu-depth", "depth_estimation", "images/extra.jpg"), ("ade20k", "semantic_segmentation", "images/ADE_val_00000001.jpg"), ("ade20k", "semantic_segmentation", "annotations/ADE_val_00000001.png"), - ("ade20k", "semantic_segmentation", "objectInfo150.txt"), ], ) def test_dense_readiness_rejects_symlinked_files( @@ -919,8 +916,6 @@ def test_dense_readiness_rejects_symlinked_files( else: _write_file(tmp_path / "images" / "ADE_val_00000001.jpg") _write_file(tmp_path / "annotations" / "ADE_val_00000001.png") - for file_name in readiness.ADE20K_METADATA_FILES: - _write_file(tmp_path / file_name) external_file = tmp_path.parent / f"{tmp_path.name}-outside" external_file.write_bytes(b"outside dataset") source_path = tmp_path / relative_path @@ -946,8 +941,6 @@ def test_dense_readiness_rejects_symlinked_root_ancestors( ade20k_root = target_parent / "ade20k" _write_file(ade20k_root / "images" / "ADE_val_00000001.jpg") _write_file(ade20k_root / "annotations" / "ADE_val_00000001.png") - for file_name in readiness.ADE20K_METADATA_FILES: - _write_file(ade20k_root / file_name) symlinked_parent = tmp_path / "datasets" symlinked_parent.symlink_to(target_parent, target_is_directory=True) diff --git a/tests/test_eval_coco.py b/tests/test_eval_coco.py index 98b0b68..372a984 100644 --- a/tests/test_eval_coco.py +++ b/tests/test_eval_coco.py @@ -250,38 +250,31 @@ def test_coco_evaluation_rejects_polygons_outside_image_bounds( ) -def test_coco_evaluation_rejects_polygons_without_rasterized_foreground( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Continuous subpixel overlap must not admit an empty raster mask.""" +def test_coco_evaluation_accepts_official_thin_polygons() -> None: + """Accept valid COCO polygons that have no foreground after rasterization.""" dataset = SimpleNamespace( coco=SimpleNamespace( - cats={1: {}}, - imgs={1: {"height": 10, "width": 10}}, + cats={35: {}}, + imgs={361919: {"height": 425, "width": 640}}, anns={ - 7: { - "id": 7, - "image_id": 1, - "category_id": 1, - "bbox": [9, 9, 1, 1], - "area": 1, + 1847218: { + "id": 1847218, + "image_id": 361919, + "category_id": 35, + "bbox": [179.52, 371.41, 6.9, 1.97], + "area": 3.1930999999999816, "iscrowd": 0, - "segmentation": [[9.9, 9.9, 9.99, 9.9, 9.99, 9.99]], + "segmentation": [ + [179.52, 371.9, 181.49, 372.89, 181.49, 372.64, 181.24, 371.41], + [183.95, 372.15, 185.43, 373.38, 186.42, 372.89, 185.43, 371.9], + ], } }, ) ) - monkeypatch.setattr(eval_coco_module, "CustomCOCODataset", lambda *_: dataset) - with pytest.raises(ValueError, match="invalid task-specific annotations"): - eval_coco_module.eval_coco_metrics( - SimpleNamespace( - post_cfg={"task": "instance_segmentation", "dataset": "coco"} - ), - "/dataset", - batch_size=1, - ) + eval_coco_module._validate_coco_dataset_taxonomy(dataset, "instance_segmentation") @pytest.mark.parametrize("visibility", [1, 2]) @@ -320,37 +313,28 @@ def test_coco_evaluation_rejects_labeled_keypoints_outside_images( ) -def test_coco_evaluation_rejects_pose_area_larger_than_its_box( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Do not let inflated pose areas weaken OKS matching thresholds.""" +def test_coco_evaluation_accepts_crowd_pose_area_larger_than_its_box() -> None: + """Accept official COCO crowd annotations with RLE area rounding differences.""" dataset = SimpleNamespace( coco=SimpleNamespace( cats={1: {}}, - imgs={1: {"height": 10, "width": 10}}, + imgs={305317: {"height": 640, "width": 640}}, anns={ - 7: { - "id": 7, - "image_id": 1, + 900100305317: { + "id": 900100305317, + "image_id": 305317, "category_id": 1, - "bbox": [0, 0, 1, 1], - "area": 100, - "iscrowd": 0, + "bbox": [223, 405, 12, 27], + "area": 351, + "iscrowd": 1, "keypoints": [0, 0, 0] * 17, "num_keypoints": 0, } }, ) ) - monkeypatch.setattr(eval_coco_module, "CustomCOCODataset", lambda *_: dataset) - - with pytest.raises(ValueError, match="invalid task-specific annotations"): - eval_coco_module.eval_coco_metrics( - SimpleNamespace(post_cfg={"task": "pose_estimation", "dataset": "coco"}), - "/dataset", - batch_size=1, - ) + eval_coco_module._validate_coco_dataset_taxonomy(dataset, "pose_estimation") def test_coco_result_formatter_rejects_truncated_postprocess_batch() -> None: From cae7110b1affc051812386eb689c04bc1a993387 Mon Sep 17 00:00:00 2001 From: jinman Date: Thu, 20 Aug 2026 14:57:23 +0900 Subject: [PATCH 3/4] fix: address dataset readiness review feedback --- mblt_vision/utils/datasets/readiness.py | 16 +++++++----- .../utils/evaluation/eval_widerface.py | 12 ++++++++- tests/test_dataset_readiness.py | 6 ++--- tests/test_eval_coco.py | 26 +++++++++++++++++++ tests/test_eval_widerface.py | 10 +++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/mblt_vision/utils/datasets/readiness.py b/mblt_vision/utils/datasets/readiness.py index e6e1682..56b08a2 100644 --- a/mblt_vision/utils/datasets/readiness.py +++ b/mblt_vision/utils/datasets/readiness.py @@ -406,6 +406,8 @@ def _coco_task_annotations_valid( or iscrowd not in {0, 1} ): return False + if task == "pose_estimation" and not iscrowd and area > bbox[2] * bbox[3]: + return False if task == "instance_segmentation": segmentation = record.get("segmentation") if isinstance(segmentation, list): @@ -611,10 +613,12 @@ def _dotav1_ready(root: Path) -> bool: if images is None or len(images) != DOTAV1_VALIDATION_SAMPLE_COUNT: return False + normalized_labels = _files_by_stem(root / "labels" / "val", {".txt"}) original_labels = _files_by_stem(root / "labels" / "val_original", {".txt"}) - if original_labels is None: + if normalized_labels is None or original_labels is None: return False - return images.keys() == original_labels.keys() + label_stems = normalized_labels.keys() | original_labels.keys() + return images.keys() == label_stems def _widerface_ready(root: Path) -> bool: @@ -706,10 +710,7 @@ def _widerface_difficulty_metadata_ready( face_array = np.asarray(face_entry[0]) except (IndexError, TypeError): return False - no_face_sentinel = face_array.shape == (1, 4) and not bool( - np.any(face_array) - ) - if not no_face_sentinel and ( + if ( face_array.ndim != 2 or face_array.shape[1] != 4 or len(face_array) == 0 @@ -718,6 +719,9 @@ def _widerface_difficulty_metadata_ready( or not np.isfinite(face_array).all() ): return False + no_face_sentinel = face_array.shape == (1, 4) and not bool( + np.any(face_array) + ) face_counts.append(0 if no_face_sentinel else len(face_array)) for difficulty_index, table in enumerate(difficulties): try: diff --git a/mblt_vision/utils/evaluation/eval_widerface.py b/mblt_vision/utils/evaluation/eval_widerface.py index 148e332..afd0077 100644 --- a/mblt_vision/utils/evaluation/eval_widerface.py +++ b/mblt_vision/utils/evaluation/eval_widerface.py @@ -263,6 +263,14 @@ def get_gt_boxes(gt_dir: str) -> tuple[Any, ...]: ) +def _normalize_ground_truth_boxes(boxes: np.ndarray) -> np.ndarray: + """Convert WiderFace's all-zero no-face sentinel into an empty box table.""" + + if boxes.shape == (1, 4) and not bool(np.any(boxes)): + return np.empty((0, 4), dtype=boxes.dtype) + return boxes + + def norm_score(pred: dict[str, Any]) -> dict[str, Any]: """Normalize WiderFace prediction scores to ``[0, 1]``.""" @@ -413,7 +421,9 @@ def evaluation( gt_bbx_list = facebox_list[event_index][0] for image_index, img_info in enumerate(img_list): pred_info = pred_list[str(img_info[0][0])] - gt_boxes = np.array(gt_bbx_list[image_index][0], dtype=np.float32) + gt_boxes = _normalize_ground_truth_boxes( + np.array(gt_bbx_list[image_index][0], dtype=np.float32) + ) keep_index = np.array(sub_gt_list[image_index][0], dtype=np.int64) count_face += len(keep_index) diff --git a/tests/test_dataset_readiness.py b/tests/test_dataset_readiness.py index f0ad6ef..33c2d16 100644 --- a/tests/test_dataset_readiness.py +++ b/tests/test_dataset_readiness.py @@ -781,7 +781,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( tmp_path: Path, relative_image_dir: str, ) -> None: - """Require an authoritative original label for every DOTA image.""" + """Allow normalized labels when an original DOTA label is unavailable.""" monkeypatch.setattr(readiness, "DOTAV1_VALIDATION_SAMPLE_COUNT", 2) for stem in ("P0001", "P0002"): @@ -790,7 +790,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( assert not readiness.dataset_ready(tmp_path, "obb", "dotav1") - _write_file(tmp_path / "labels" / "val_original" / "P0002.txt") + _write_file(tmp_path / "labels" / "val" / "P0002.txt") assert readiness.dataset_ready(tmp_path, "obb", "dotav1") @@ -799,7 +799,7 @@ def test_dotav1_readiness_requires_complete_image_label_pairs( _write_file(external_image) _write_file(external_label) image_path = tmp_path / relative_image_dir / "P0001.png" - label_path = tmp_path / "labels" / "val_original" / "P0002.txt" + label_path = tmp_path / "labels" / "val" / "P0002.txt" image_path.unlink() label_path.unlink() image_path.symlink_to(external_image) diff --git a/tests/test_eval_coco.py b/tests/test_eval_coco.py index 372a984..fc271fa 100644 --- a/tests/test_eval_coco.py +++ b/tests/test_eval_coco.py @@ -337,6 +337,32 @@ def test_coco_evaluation_accepts_crowd_pose_area_larger_than_its_box() -> None: eval_coco_module._validate_coco_dataset_taxonomy(dataset, "pose_estimation") +def test_coco_evaluation_rejects_non_crowd_pose_area_larger_than_its_box() -> None: + """Keep area validation for non-crowd pose annotations used in OKS scoring.""" + + dataset = SimpleNamespace( + coco=SimpleNamespace( + cats={1: {}}, + imgs={1: {"height": 640, "width": 640}}, + anns={ + 7: { + "id": 7, + "image_id": 1, + "category_id": 1, + "bbox": [223, 405, 12, 27], + "area": 351, + "iscrowd": 0, + "keypoints": [0, 0, 0] * 17, + "num_keypoints": 0, + } + }, + ) + ) + + with pytest.raises(ValueError, match="invalid task-specific annotations"): + eval_coco_module._validate_coco_dataset_taxonomy(dataset, "pose_estimation") + + def test_coco_result_formatter_rejects_truncated_postprocess_batch() -> None: """Require one decoded result for every submitted COCO image.""" diff --git a/tests/test_eval_widerface.py b/tests/test_eval_widerface.py index d86f651..dbfbd7b 100644 --- a/tests/test_eval_widerface.py +++ b/tests/test_eval_widerface.py @@ -165,3 +165,13 @@ def test_widerface_evaluation_rejects_unequal_box_and_score_counts() -> None: with pytest.raises(ValueError, match="unequal box and score counts"): eval_widerface_module._boxes_scores_to_prediction([[0, 0, 1, 1]], []) + + +def test_widerface_evaluation_normalizes_no_face_sentinel() -> None: + """Do not score the official all-zero no-face marker as a ground-truth box.""" + + boxes = eval_widerface_module._normalize_ground_truth_boxes( + np.zeros((1, 4), dtype=np.float32) + ) + + assert boxes.shape == (0, 4) From 8fd88c1bf06b406d35d1fa487a26302b75f6ed64 Mon Sep 17 00:00:00 2001 From: jinman Date: Thu, 20 Aug 2026 15:07:10 +0900 Subject: [PATCH 4/4] fix: preserve dataset evaluator edge cases --- mblt_vision/utils/datasets/readiness.py | 51 ++++++++++++++----- .../utils/evaluation/eval_widerface.py | 21 +++++++- tests/test_dataset_readiness.py | 41 +++++++++++++++ tests/test_eval_widerface.py | 11 ++++ 4 files changed, 111 insertions(+), 13 deletions(-) diff --git a/mblt_vision/utils/datasets/readiness.py b/mblt_vision/utils/datasets/readiness.py index 56b08a2..5e62977 100644 --- a/mblt_vision/utils/datasets/readiness.py +++ b/mblt_vision/utils/datasets/readiness.py @@ -120,6 +120,27 @@ def _has_positive_polygon_area(polygon: list[int | float]) -> bool: return bool(abs(signed_double_area) > 0) +def _polygon_union_has_rasterized_foreground( + polygons: list[list[int | float]], image_shape: tuple[int, int] | None +) -> bool: + """Return whether the combined COCO polygons rasterize to foreground. + + COCO polygons describe one instance as a union. Individual components can + be thinner than a pixel (as occurs in the official validation annotations), + so only the combined raster needs to contain foreground. + """ + + if image_shape is None: + return False + height, width = image_shape + try: + encoded = coco_mask.frPyObjects(polygons, height, width) + decoded = np.asarray(coco_mask.decode(encoded)) + except (RuntimeError, TypeError, ValueError): + return False + return bool(np.any(decoded)) + + def _canonicalize_quadrilateral( coordinates: list[int | float] | tuple[int | float, ...], ) -> tuple[float, ...]: @@ -411,21 +432,27 @@ def _coco_task_annotations_valid( if task == "instance_segmentation": segmentation = record.get("segmentation") if isinstance(segmentation, list): - if not segmentation or any( - not isinstance(polygon, list) - or len(polygon) < 6 - or len(polygon) % 2 + if ( + not segmentation or any( - not isinstance(value, (int, float)) - or isinstance(value, bool) - or not np.isfinite(value) - for value in polygon + not isinstance(polygon, list) + or len(polygon) < 6 + or len(polygon) % 2 + or any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not np.isfinite(value) + for value in polygon + ) + or not _has_positive_polygon_area(polygon) + or not _polygon_has_positive_image_overlap( + polygon, image_shapes.get(image_id) + ) + for polygon in segmentation ) - or not _has_positive_polygon_area(polygon) - or not _polygon_has_positive_image_overlap( - polygon, image_shapes.get(image_id) + or not _polygon_union_has_rasterized_foreground( + segmentation, image_shapes.get(image_id) ) - for polygon in segmentation ): return False elif isinstance(segmentation, dict): diff --git a/mblt_vision/utils/evaluation/eval_widerface.py b/mblt_vision/utils/evaluation/eval_widerface.py index afd0077..9d3a195 100644 --- a/mblt_vision/utils/evaluation/eval_widerface.py +++ b/mblt_vision/utils/evaluation/eval_widerface.py @@ -271,6 +271,19 @@ def _normalize_ground_truth_boxes(boxes: np.ndarray) -> np.ndarray: return boxes +def _empty_ground_truth_prediction_contribution( + thresh_num: int, pred_info: np.ndarray +) -> np.ndarray: + """Return precision-recall counts for predictions on a no-face image.""" + + return img_pr_info( + thresh_num, + pred_info, + np.ones(len(pred_info), dtype=np.float32), + np.zeros(len(pred_info), dtype=np.float32), + ) + + def norm_score(pred: dict[str, Any]) -> dict[str, Any]: """Normalize WiderFace prediction scores to ``[0, 1]``.""" @@ -427,7 +440,13 @@ def evaluation( keep_index = np.array(sub_gt_list[image_index][0], dtype=np.int64) count_face += len(keep_index) - if len(gt_boxes) == 0 or len(pred_info) == 0: + if len(gt_boxes) == 0: + if len(pred_info) != 0: + pr_curve += _empty_ground_truth_prediction_contribution( + thresh_num, pred_info + ) + continue + if len(pred_info) == 0: continue ignore = np.zeros(gt_boxes.shape[0]) if len(keep_index) != 0: diff --git a/tests/test_dataset_readiness.py b/tests/test_dataset_readiness.py index 33c2d16..36e7b59 100644 --- a/tests/test_dataset_readiness.py +++ b/tests/test_dataset_readiness.py @@ -380,6 +380,47 @@ def test_coco_readiness_decodes_and_validates_rle_segmentations( ) +def test_coco_readiness_rejects_polygon_union_without_rasterized_foreground( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Reject polygon instances whose combined mask contains no pixels.""" + + monkeypatch.setattr(readiness, "COCO_VALIDATION_SAMPLE_COUNT", 1) + monkeypatch.setitem(readiness.COCO_ANNOTATION_COUNTS, "instances_val2017.json", 1) + monkeypatch.setitem(readiness.COCO_CATEGORY_COUNTS, "instances_val2017.json", 1) + monkeypatch.setattr(readiness, "COCO_CATEGORY_IDS", frozenset({1})) + _write_image(tmp_path / "val2017" / "000000000001.jpg", (10, 10)) + (tmp_path / "instances_val2017.json").write_text( + json.dumps( + { + "images": [ + { + "id": 1, + "file_name": "000000000001.jpg", + "height": 10, + "width": 10, + } + ], + "categories": [{"id": 1}], + "annotations": [ + { + "id": 1, + "image_id": 1, + "category_id": 1, + "bbox": [9, 9, 1, 1], + "area": 0.01, + "iscrowd": 0, + "segmentation": [[9.9, 9.9, 9.99, 9.9, 9.99, 9.99]], + } + ], + } + ), + encoding="utf-8", + ) + + assert not readiness.dataset_ready(tmp_path, "instance_segmentation", "coco") + + def test_coco_readiness_rejects_corrupt_or_mismatched_images( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/test_eval_widerface.py b/tests/test_eval_widerface.py index dbfbd7b..76af253 100644 --- a/tests/test_eval_widerface.py +++ b/tests/test_eval_widerface.py @@ -175,3 +175,14 @@ def test_widerface_evaluation_normalizes_no_face_sentinel() -> None: ) assert boxes.shape == (0, 4) + + +def test_widerface_evaluation_counts_predictions_on_no_face_image() -> None: + """Predictions on an official no-face image must contribute false positives.""" + + contribution = eval_widerface_module._empty_ground_truth_prediction_contribution( + 2, np.array([[0, 0, 1, 1, 0.9]], dtype=np.float32) + ) + + np.testing.assert_array_equal(contribution[:, 0], np.ones(2)) + np.testing.assert_array_equal(contribution[:, 1], np.zeros(2))