Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Mobilint Vision Python

<!-- markdownlint-disable MD033 -->
<div align="center">
<p>
<a href="https://www.mobilint.com/" target="_blank">
<img src="https://raw.githubusercontent.com/mobilint/.github/main/assets/Mobilint_Logo_Primary.png" alt="Mobilint Logo" width="60%">
</a>
</p>
</div>
<!-- markdownlint-enable MD033 -->

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
Expand All @@ -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
```
Expand Down
94 changes: 44 additions & 50 deletions mblt_vision/utils/datasets/readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]:
Expand Down Expand Up @@ -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":
Comment thread
parkjinman98 marked this conversation as resolved.
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):
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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))
Comment thread
parkjinman98 marked this conversation as resolved.
for difficulty_index, table in enumerate(difficulties):
try:
event_indices = table[event_index][0]
Expand Down Expand Up @@ -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] = {}
Expand Down
33 changes: 31 additions & 2 deletions mblt_vision/utils/evaluation/eval_widerface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]``."""

Expand Down Expand Up @@ -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)
)
Comment thread
parkjinman98 marked this conversation as resolved.
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:
Expand Down
78 changes: 56 additions & 22 deletions tests/test_dataset_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)]],
Expand All @@ -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] = (
Expand All @@ -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)
Expand All @@ -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"}}
)

Expand All @@ -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"):
Expand Down Expand Up @@ -870,24 +915,18 @@ 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))
annotation_path = tmp_path / "annotations" / "ADE_val_00000001.png"
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")


Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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)

Expand Down
Loading