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
+
+
+
+
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
+[](https://pypi.org/project/mblt-vision-python/)
+[](https://clickpy.clickhouse.com/dashboard/mblt-vision-python)
+[](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))