"""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.demo.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)