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:
2026-04-20 22:17:11 +03:00
parent 01321bf607
commit 3aafacab12
1068 changed files with 720366 additions and 0 deletions

View 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'