feat(phase1): implement OpenCV image processing pipeline
- Create ImageProcessor service with EXIF orientation detection - Implement smart cropping via OpenCV contour detection (10% padding) - Add text orientation detection using Hough line transform - Resize and compress images to 1200px with 85% JPEG quality - Generate 200px square thumbnails with center crop - Fallback to Pillow if OpenCV fails - Comprehensive test suite: 28 tests all passing - File size validation (reject >10MB) - Graceful error handling for corrupted/invalid images - Update requirements.txt with opencv-python, piexif, python-magic
This commit is contained in:
393
backend/tests/test_image_processing.py
Normal file
393
backend/tests/test_image_processing.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
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'
|
||||
Reference in New Issue
Block a user