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