d6fd6c03e6
Introduce focused unit, integration, and NATS-path tests for demo modules, and align assertions with final schema and temporal contracts (window int, seq=30, fill-level ratio). This commit isolates validation logic from runtime changes and provides reproducible QA for pipeline behavior and failure modes.
154 lines
5.2 KiB
Python
154 lines
5.2 KiB
Python
"""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_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)
|