feat: extract opengait_studio monorepo module
Move demo implementation into opengait_studio, retire Sports2D runtime integration, and align packaging with root-level monorepo dependency management.
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
"""Unit tests for silhouette preprocessing functions."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
import pytest
|
||||
from beartype.roar import BeartypeCallHintParamViolation
|
||||
from jaxtyping import TypeCheckError
|
||||
|
||||
from opengait_studio.preprocess import mask_to_silhouette
|
||||
|
||||
|
||||
class TestMaskToSilhouette:
|
||||
"""Tests for mask_to_silhouette() function."""
|
||||
|
||||
def test_valid_mask_returns_correct_shape_dtype_and_range(self) -> None:
|
||||
"""Valid mask should return (64, 44) float32 array in [0, 1] range."""
|
||||
# Create a synthetic mask with sufficient area (person-shaped blob)
|
||||
h, w = 200, 150
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# Draw a filled ellipse to simulate a person
|
||||
center_y, center_x = h // 2, w // 2
|
||||
axes_y, axes_x = h // 3, w // 4
|
||||
y, x = np.ogrid[:h, :w]
|
||||
ellipse_mask = ((x - center_x) / axes_x) ** 2 + (
|
||||
(y - center_y) / axes_y
|
||||
) ** 2 <= 1
|
||||
mask[ellipse_mask] = 255
|
||||
|
||||
bbox = (w // 4, h // 6, 3 * w // 4, 5 * h // 6)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
assert result_arr.shape == (64, 44)
|
||||
assert result_arr.dtype == np.float32
|
||||
assert np.all(result_arr >= 0.0) and np.all(result_arr <= 1.0)
|
||||
|
||||
def test_tiny_mask_returns_none(self) -> None:
|
||||
"""Mask with area below MIN_MASK_AREA should return None."""
|
||||
# Create a tiny mask
|
||||
mask = np.zeros((50, 50), dtype=np.uint8)
|
||||
mask[20:22, 20:22] = 255 # Only 4 pixels, well below MIN_MASK_AREA (500)
|
||||
|
||||
bbox = (20, 20, 22, 22)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_empty_mask_returns_none(self) -> None:
|
||||
"""Completely empty mask should return None."""
|
||||
mask = np.zeros((100, 100), dtype=np.uint8)
|
||||
bbox = (10, 10, 90, 90)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_full_frame_mask_returns_valid_output(self) -> None:
|
||||
"""Full-frame mask (large bbox covering entire image) should work."""
|
||||
h, w = 300, 200
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# Create a large filled region
|
||||
mask[50:250, 30:170] = 255
|
||||
|
||||
# Full frame bbox
|
||||
bbox = (0, 0, w, h)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
assert result_arr.shape == (64, 44)
|
||||
assert result_arr.dtype == np.float32
|
||||
|
||||
def test_determinism_same_input_same_output(self) -> None:
|
||||
"""Same input should always produce same output."""
|
||||
h, w = 200, 150
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# Create a person-shaped region
|
||||
mask[50:150, 40:110] = 255
|
||||
|
||||
bbox = (40, 50, 110, 150)
|
||||
|
||||
result1 = mask_to_silhouette(mask, bbox)
|
||||
result2 = mask_to_silhouette(mask, bbox)
|
||||
result3 = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result1 is not None
|
||||
assert result2 is not None
|
||||
assert result3 is not None
|
||||
np.testing.assert_array_equal(result1, result2)
|
||||
np.testing.assert_array_equal(result2, result3)
|
||||
|
||||
def test_hole_inside_mask_is_filled(self) -> None:
|
||||
h, w = 200, 160
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
mask[30:170, 40:120] = 255
|
||||
mask[80:120, 70:90] = 0
|
||||
bbox = (40, 30, 120, 170)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
hole_patch = result_arr[26:38, 18:26]
|
||||
assert float(np.mean(hole_patch)) > 0.8
|
||||
|
||||
def test_hole_fill_works_when_mask_touches_corner(self) -> None:
|
||||
h, w = 220, 180
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
mask[0:180, 0:130] = 255
|
||||
mask[70:120, 55:95] = 0
|
||||
bbox = (0, 0, 130, 180)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
hole_patch = result_arr[24:40, 16:28]
|
||||
assert float(np.mean(hole_patch)) > 0.75
|
||||
|
||||
def test_tall_narrow_mask_valid_output(self) -> None:
|
||||
"""Tall narrow mask should produce valid silhouette."""
|
||||
h, w = 400, 50
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# Tall narrow person
|
||||
mask[50:350, 10:40] = 255
|
||||
|
||||
bbox = (10, 50, 40, 350)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
assert result_arr.shape == (64, 44)
|
||||
|
||||
def test_wide_short_mask_valid_output(self) -> None:
|
||||
"""Wide short mask should produce valid silhouette."""
|
||||
h, w = 100, 400
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
# Wide short person
|
||||
mask[20:80, 50:350] = 255
|
||||
|
||||
bbox = (50, 20, 350, 80)
|
||||
|
||||
result = mask_to_silhouette(mask, bbox)
|
||||
|
||||
assert result is not None
|
||||
result_arr = cast(NDArray[np.float32], result)
|
||||
assert result_arr.shape == (64, 44)
|
||||
|
||||
def test_beartype_rejects_wrong_dtype(self) -> None:
|
||||
"""Beartype should reject non-uint8 input."""
|
||||
# Float array instead of uint8
|
||||
mask = np.ones((100, 100), dtype=np.float32) * 255
|
||||
bbox = (10, 10, 90, 90)
|
||||
|
||||
with pytest.raises((BeartypeCallHintParamViolation, TypeCheckError)):
|
||||
_ = mask_to_silhouette(mask, bbox)
|
||||
|
||||
def test_beartype_rejects_wrong_ndim(self) -> None:
|
||||
"""Beartype should reject non-2D array."""
|
||||
# 3D array instead of 2D
|
||||
mask = np.ones((100, 100, 3), dtype=np.uint8) * 255
|
||||
bbox = (10, 10, 90, 90)
|
||||
|
||||
with pytest.raises((BeartypeCallHintParamViolation, TypeCheckError)):
|
||||
_ = mask_to_silhouette(mask, bbox)
|
||||
|
||||
def test_beartype_rejects_wrong_bbox_type(self) -> None:
|
||||
"""Beartype should reject non-tuple bbox."""
|
||||
mask = np.ones((100, 100), dtype=np.uint8) * 255
|
||||
# List instead of tuple
|
||||
bbox = [10, 10, 90, 90]
|
||||
|
||||
with pytest.raises((BeartypeCallHintParamViolation, TypeCheckError)):
|
||||
_ = mask_to_silhouette(mask, bbox)
|
||||
Reference in New Issue
Block a user