""" Test suite for OpenCV-based image processing pipeline. Tests cover: - EXIF orientation detection and rotation - OpenCV smart cropping - Text orientation detection - Resize and compression - Thumbnail generation - Fallback to Pillow - File size validation - Edge cases (corrupted images, no contours, etc.) """ import io import pytest from PIL import Image, PngImagePlugin import piexif import numpy as np from backend.services.image_processing import ImageProcessor @pytest.fixture def processor(): """Create ImageProcessor instance.""" return ImageProcessor() @pytest.fixture def sample_image_rgb(): """Create a simple RGB test image (100x100 red square).""" img = Image.new('RGB', (100, 100), color='red') output = io.BytesIO() img.save(output, format='JPEG', quality=85) return output.getvalue() @pytest.fixture def sample_image_with_object(): """Create test image with a distinct object (100x100, white bg, black square).""" img = Image.new('RGB', (100, 100), color='white') # Draw a black square in center pixels = img.load() for x in range(30, 70): for y in range(30, 70): pixels[x, y] = (0, 0, 0) output = io.BytesIO() img.save(output, format='JPEG', quality=85) return output.getvalue() @pytest.fixture def sample_image_with_exif(): """Create a test image with EXIF orientation tag.""" img = Image.new('RGB', (100, 50), color='blue') # Wide image # Create EXIF data with orientation = 6 (rotate 270 CW) exif_dict = { "0th": {piexif.ImageIFD.Orientation: 6} } exif_bytes = piexif.dump(exif_dict) output = io.BytesIO() img.save(output, format='JPEG', quality=85, exif=exif_bytes) return output.getvalue() @pytest.fixture def large_file(sample_image_rgb): """Create a file larger than 10MB limit.""" # Repeat image bytes to create large file return sample_image_rgb * 2_000_000 # ~12MB @pytest.fixture def corrupted_image(): """Create corrupted image data.""" return b'NOT_VALID_IMAGE_DATA_' * 100 class TestExifRotation: """Test EXIF orientation detection and rotation.""" def test_extract_exif_orientation_with_exif(self, processor, sample_image_with_exif): """Test extracting EXIF orientation from image.""" image = Image.open(io.BytesIO(sample_image_with_exif)) orientation = processor._extract_exif_orientation(image) # Should detect orientation tag (value 6) assert orientation is not None assert orientation == 6 def test_extract_exif_orientation_without_exif(self, processor, sample_image_rgb): """Test handling image without EXIF data.""" image = Image.open(io.BytesIO(sample_image_rgb)) orientation = processor._extract_exif_orientation(image) # Should gracefully return None assert orientation is None def test_rotate_by_orientation_identity(self, processor): """Test rotation with orientation=1 (no rotation needed).""" img = Image.new('RGB', (100, 50), color='red') rotated = processor._rotate_by_orientation(img, 1) assert rotated.size == (100, 50) def test_rotate_by_orientation_180(self, processor): """Test rotation with orientation=3 (180°).""" img = Image.new('RGB', (100, 50), color='red') rotated = processor._rotate_by_orientation(img, 3) assert rotated.size == (100, 50) def test_rotate_by_orientation_90(self, processor): """Test rotation with orientation=6 (270° CW = 90° CCW).""" img = Image.new('RGB', (100, 50), color='red') rotated = processor._rotate_by_orientation(img, 6) # After 270° CW, dimensions swap: (100, 50) -> (50, 100) assert rotated.size == (50, 100) class TestSmartCrop: """Test OpenCV-based smart cropping.""" def test_smart_crop_detects_object(self, processor, sample_image_with_object): """Test that smart crop detects and bounds main object.""" image = Image.open(io.BytesIO(sample_image_with_object)) result = processor._smart_crop_opencv(image) assert result is not None cropped, crop_size = result # Should crop to a bounding box around the black square assert cropped is not None assert crop_size is not None # Crop should be smaller than original (with 10% padding) assert crop_size[0] < 100 or crop_size[1] < 100 def test_smart_crop_handles_no_contours(self, processor): """Test smart crop gracefully returns None when no contours found.""" # Create a plain image with no edges img = Image.new('RGB', (100, 100), color='gray') result = processor._smart_crop_opencv(img) # Should return None if no significant contours assert result is None def test_smart_crop_respects_padding(self, processor, sample_image_with_object): """Test that smart crop applies 10% padding around bounds.""" image = Image.open(io.BytesIO(sample_image_with_object)) result = processor._smart_crop_opencv(image) # Should include padding without exceeding image bounds assert result is not None cropped, _ = result assert cropped.size[0] > 0 assert cropped.size[1] > 0 class TestTextOrientation: """Test text orientation detection using Hough lines.""" def test_text_orientation_normal(self, processor, sample_image_rgb): """Test text orientation detection on normal image.""" image = Image.open(io.BytesIO(sample_image_rgb)) angle, status = processor._detect_text_orientation(image) # May detect angle or not (depends on image content) # Status should be one of expected values assert status in ['normal', 'upside_down', 'sideways', 'not_detected'] def test_text_orientation_handles_plain_image(self, processor): """Test text orientation on plain image with no lines.""" img = Image.new('RGB', (100, 100), color='white') angle, status = processor._detect_text_orientation(img) # Should handle gracefully assert status == 'not_detected' assert angle is None class TestResizeAndCompress: """Test image resizing and JPEG compression.""" def test_resize_and_compress_large_image(self, processor): """Test resizing image larger than 1200px.""" # Create a 2400x2400 image img = Image.new('RGB', (2400, 2400), color='red') compressed = processor._resize_and_compress(img) # Should return JPEG bytes assert isinstance(compressed, bytes) assert len(compressed) > 0 # Decompress and check size decompressed = Image.open(io.BytesIO(compressed)) assert decompressed.size[0] <= 1200 assert decompressed.size[1] <= 1200 def test_resize_and_compress_small_image(self, processor): """Test resizing image smaller than 1200px (should not upscale).""" img = Image.new('RGB', (500, 500), color='red') compressed = processor._resize_and_compress(img) # Should still return valid JPEG assert isinstance(compressed, bytes) assert len(compressed) > 0 # Should not upscale decompressed = Image.open(io.BytesIO(compressed)) assert decompressed.size[0] <= 500 assert decompressed.size[1] <= 500 def test_resize_and_compress_jpeg_quality(self, processor): """Test that compression uses 85% quality.""" img = Image.new('RGB', (800, 600), color='red') compressed = processor._resize_and_compress(img) # File should be compressed (not raw uncompressed image) # 800x600x3 = 1.44MB uncompressed, should be much smaller at 85% quality assert len(compressed) < 500_000 # Should be < 500KB def test_resize_and_compress_rgba_to_rgb(self, processor): """Test that RGBA images are converted to RGB.""" # Create RGBA image img = Image.new('RGBA', (500, 500), color=(255, 0, 0, 255)) compressed = processor._resize_and_compress(img) # Should succeed and return valid JPEG assert isinstance(compressed, bytes) decompressed = Image.open(io.BytesIO(compressed)) assert decompressed.mode == 'RGB' class TestThumbnailGeneration: """Test thumbnail generation.""" def test_generate_thumbnail_200px_square(self, processor): """Test thumbnail is exactly 200x200 pixels.""" img = Image.new('RGB', (500, 500), color='red') thumbnail = processor._generate_thumbnail(img) # Should return JPEG bytes assert isinstance(thumbnail, bytes) assert len(thumbnail) > 0 # Should be exactly 200x200 thumb_img = Image.open(io.BytesIO(thumbnail)) assert thumb_img.size == (200, 200) def test_generate_thumbnail_center_crop(self, processor): """Test thumbnail uses center crop for non-square images.""" # Create wide image (400x200) img = Image.new('RGB', (400, 200), color='red') thumbnail = processor._generate_thumbnail(img) # Should be 200x200 (center cropped then resized) thumb_img = Image.open(io.BytesIO(thumbnail)) assert thumb_img.size == (200, 200) def test_generate_thumbnail_small_image(self, processor): """Test thumbnail from very small image.""" # Create small image (50x50) img = Image.new('RGB', (50, 50), color='red') thumbnail = processor._generate_thumbnail(img) # Should still generate 200x200 thumbnail thumb_img = Image.open(io.BytesIO(thumbnail)) assert thumb_img.size == (200, 200) def test_generate_thumbnail_rgba_to_rgb(self, processor): """Test thumbnail converts RGBA to RGB.""" img = Image.new('RGBA', (500, 500), color=(255, 0, 0, 255)) thumbnail = processor._generate_thumbnail(img) # Should convert to RGB thumb_img = Image.open(io.BytesIO(thumbnail)) assert thumb_img.mode == 'RGB' class TestProcessPhoto: """Test main process_photo method.""" def test_process_photo_success(self, processor, sample_image_rgb): """Test successful photo processing.""" result = processor.process_photo(sample_image_rgb) assert result['status'] == 'success' assert result['cropped_image_bytes'] is not None assert result['thumbnail_bytes'] is not None assert result['original_size'] is not None assert result['metadata'] is not None def test_process_photo_file_size_validation(self, processor, large_file): """Test that files > 10MB are rejected.""" result = processor.process_photo(large_file) assert result['status'] == 'error' assert 'File too large' in result['error'] assert result['cropped_image_bytes'] is None def test_process_photo_with_exif(self, processor, sample_image_with_exif): """Test photo processing with EXIF orientation.""" result = processor.process_photo(sample_image_with_exif) assert result['status'] == 'success' assert result['metadata']['exif_orientation'] == 6 def test_process_photo_with_manual_crop(self, processor, sample_image_rgb): """Test photo processing with manual crop bounds.""" crop_bounds = {'x': 10, 'y': 10, 'width': 50, 'height': 50} result = processor.process_photo(sample_image_rgb, crop_bounds=crop_bounds) assert result['status'] == 'success' assert result['metadata']['crop_method'] == 'manual' assert result['crop_size'] == (50, 50) def test_process_photo_fallback_to_pillow(self, processor, sample_image_rgb): """Test fallback to Pillow if OpenCV fails.""" result = processor.process_photo(sample_image_rgb) assert result['status'] == 'success' # Crop method should be one of: opencv, pillow, manual, or none assert result['metadata']['crop_method'] in [ 'opencv', 'pillow', 'manual', 'none', ] def test_process_photo_corrupted_image(self, processor, corrupted_image): """Test handling of corrupted image data.""" result = processor.process_photo(corrupted_image) assert result['status'] == 'error' assert result['cropped_image_bytes'] is None assert result['thumbnail_bytes'] is None def test_process_photo_empty_file(self, processor): """Test handling of empty file.""" result = processor.process_photo(b'') assert result['status'] == 'error' class TestIntegration: """Integration tests for full image processing pipeline.""" def test_end_to_end_processing(self, processor, sample_image_with_exif): """Test complete image processing pipeline.""" result = processor.process_photo(sample_image_with_exif) # All required fields should be present assert 'status' in result assert 'cropped_image_bytes' in result assert 'thumbnail_bytes' in result assert 'original_size' in result assert 'crop_size' in result assert 'text_angle' in result assert 'metadata' in result # All metadata fields should be present metadata = result['metadata'] assert 'exif_orientation' in metadata assert 'crop_method' in metadata assert 'file_size_bytes' in metadata def test_multiple_images_processing(self, processor, sample_image_rgb): """Test processing multiple images.""" for _ in range(3): result = processor.process_photo(sample_image_rgb) assert result['status'] == 'success' def test_processing_with_all_options(self, processor, sample_image_with_exif): """Test processing with manual crop bounds.""" crop_bounds = {'x': 5, 'y': 5, 'width': 40, 'height': 40} result = processor.process_photo(sample_image_with_exif, crop_bounds=crop_bounds) assert result['status'] == 'success' assert result['crop_size'] == (40, 40) assert result['metadata']['exif_orientation'] == 6 assert result['metadata']['crop_method'] == 'manual'