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 ``` diff --git a/mblt_vision/utils/datasets/readiness.py b/mblt_vision/utils/datasets/readiness.py index 3aeaa5d..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, ...]: @@ -406,27 +427,32 @@ def _coco_task_annotations_valid( or iscrowd not in {0, 1} ): return False - if task == "pose_estimation" and area > bbox[2] * bbox[3]: + 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): - 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) ) - or not _valid_coco_polygon(polygon, image_shapes.get(image_id)) - for polygon in segmentation ): return False elif isinstance(segmentation, dict): @@ -545,22 +571,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.""" @@ -734,24 +744,12 @@ def _widerface_difficulty_metadata_ready( 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)) + 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: event_indices = table[event_index][0] @@ -990,10 +988,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/mblt_vision/utils/evaluation/eval_widerface.py b/mblt_vision/utils/evaluation/eval_widerface.py index 148e332..9d3a195 100644 --- a/mblt_vision/utils/evaluation/eval_widerface.py +++ b/mblt_vision/utils/evaluation/eval_widerface.py @@ -263,6 +263,27 @@ 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 _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]``.""" @@ -413,11 +434,19 @@ 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) - 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 bf2737f..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: @@ -685,10 +726,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 +747,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 +757,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 +790,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 +811,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 +822,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.""" + """Allow normalized labels when an original DOTA label is unavailable.""" monkeypatch.setattr(readiness, "DOTAV1_VALIDATION_SAMPLE_COUNT", 2) for stem in ("P0001", "P0002"): @@ -870,11 +915,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 +927,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 +938,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 +957,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 +982,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..fc271fa 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,22 +313,44 @@ 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={ + 900100305317: { + "id": 900100305317, + "image_id": 305317, + "category_id": 1, + "bbox": [223, 405, 12, 27], + "area": 351, + "iscrowd": 1, + "keypoints": [0, 0, 0] * 17, + "num_keypoints": 0, + } + }, + ) + ) + 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": [0, 0, 1, 1], - "area": 100, + "bbox": [223, 405, 12, 27], + "area": 351, "iscrowd": 0, "keypoints": [0, 0, 0] * 17, "num_keypoints": 0, @@ -343,14 +358,9 @@ def test_coco_evaluation_rejects_pose_area_larger_than_its_box( }, ) ) - 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: diff --git a/tests/test_eval_widerface.py b/tests/test_eval_widerface.py index d86f651..76af253 100644 --- a/tests/test_eval_widerface.py +++ b/tests/test_eval_widerface.py @@ -165,3 +165,24 @@ 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) + + +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))