"""Unit tests for utils/image.py.""" from __future__ import annotations import base64 import cv2 import numpy as np import pytest from utils.image import ( BBox, bytes_to_numpy, base64_to_numpy, numpy_to_base64, image_hash, bytes_hash, crop_face, resize_with_aspect, draw_boxes, ) class TestImageRoundtrip: def test_bytes_to_numpy_valid(self, sample_image_bytes): img = bytes_to_numpy(sample_image_bytes) assert img is not None assert img.ndim == 3 assert img.shape[2] == 3 def test_bytes_to_numpy_invalid(self): with pytest.raises(ValueError): bytes_to_numpy(b"not an image") def test_base64_roundtrip(self, sample_image_bytes): b64 = base64.b64encode(sample_image_bytes).decode() img = base64_to_numpy(b64) assert img is not None assert img.shape[0] > 0 def test_base64_with_data_uri_prefix(self, sample_image_bytes): b64 = "data:image/jpeg;base64," + base64.b64encode(sample_image_bytes).decode() img = base64_to_numpy(b64) assert img is not None def test_numpy_to_base64(self, sample_image_bytes): img = bytes_to_numpy(sample_image_bytes) b64 = numpy_to_base64(img) assert isinstance(b64, str) # Should be decodable raw = base64.b64decode(b64) assert len(raw) > 0 class TestBBox: def test_bbox_to_dict(self): b = BBox(10, 20, 100, 200) d = b.to_dict() assert d == {"x": 10, "y": 20, "w": 100, "h": 200} def test_bbox_area(self): b = BBox(0, 0, 100, 50) assert b.area == 5000 def test_bbox_to_face_recognition_tuple(self): b = BBox(10, 20, 100, 200) assert b.to_face_recognition_tuple() == (20, 110, 220, 10) class TestCropFace: def test_crop_face_basic(self): img = np.zeros((300, 300, 3), dtype=np.uint8) img[100:200, 100:200] = 255 bbox = BBox(100, 100, 100, 100) crop = crop_face(img, bbox, margin=0.0) assert crop.shape[0] == 100 assert crop.shape[1] == 100 assert (crop == 255).all() def test_crop_face_with_margin(self): img = np.zeros((300, 300, 3), dtype=np.uint8) bbox = BBox(100, 100, 50, 50) crop = crop_face(img, bbox, margin=0.2) # 50 + 20% margin each side = 70 assert crop.shape[0] == 70 assert crop.shape[1] == 70 def test_crop_face_clamps_to_bounds(self): img = np.zeros((100, 100, 3), dtype=np.uint8) bbox = BBox(0, 0, 80, 80) crop = crop_face(img, bbox, margin=0.5) # Should not exceed image bounds assert crop.shape[0] <= 100 assert crop.shape[1] <= 100 class TestResize: def test_resize_with_aspect_no_resize_needed(self): img = np.zeros((100, 200, 3), dtype=np.uint8) resized = resize_with_aspect(img, max_dim=300) assert resized.shape == img.shape def test_resize_with_aspect_landscape(self): img = np.zeros((100, 400, 3), dtype=np.uint8) resized = resize_with_aspect(img, max_dim=200) assert resized.shape[1] == 200 assert resized.shape[0] == 50 # aspect preserved def test_resize_with_aspect_portrait(self): img = np.zeros((400, 100, 3), dtype=np.uint8) resized = resize_with_aspect(img, max_dim=200) assert resized.shape[0] == 200 assert resized.shape[1] == 50 class TestHashing: def test_image_hash_stable(self, sample_image_bytes): img = bytes_to_numpy(sample_image_bytes) h1 = image_hash(img) h2 = image_hash(img) assert h1 == h2 def test_image_hash_differs_for_different_images(self, sample_image_bytes, sample_face_image_bytes): img1 = bytes_to_numpy(sample_image_bytes) img2 = bytes_to_numpy(sample_face_image_bytes) assert image_hash(img1) != image_hash(img2) def test_bytes_hash_stable(self): assert bytes_hash(b"hello") == bytes_hash(b"hello") assert bytes_hash(b"hello") != bytes_hash(b"world") class TestDrawBoxes: def test_draw_boxes_with_dicts(self): img = np.zeros((300, 300, 3), dtype=np.uint8) boxes = [{"x": 50, "y": 50, "w": 100, "h": 100}] out = draw_boxes(img, boxes) # Output should differ (rectangles drawn) assert not np.array_equal(img, out) # Original should not be modified assert (img == 0).all() def test_draw_boxes_with_bbox_objects(self): img = np.zeros((300, 300, 3), dtype=np.uint8) boxes = [BBox(50, 50, 100, 100)] out = draw_boxes(img, boxes) assert not np.array_equal(img, out)