Files
motovaultpro/ocr/tests/test_vin_preprocessor.py
Eric Gullickson 54cbd49171
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 31s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 31s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: add VIN photo OCR pipeline (refs #67)
Implement VIN-specific OCR extraction with optimized preprocessing:

- Add POST /extract/vin endpoint for VIN extraction
- VIN preprocessor: CLAHE, deskew, denoise, adaptive threshold
- VIN validator: check digit validation, OCR error correction (I->1, O->0)
- VIN extractor: PSM modes 6/7/8, character whitelist, alternatives
- Response includes confidence, bounding box, and alternatives
- Unit tests for validator and preprocessor
- Integration tests for VIN extraction endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:31:36 -06:00

203 lines
6.6 KiB
Python

"""Unit tests for VIN preprocessor."""
import io
from unittest.mock import patch, MagicMock
import numpy as np
import pytest
from PIL import Image
from app.preprocessors.vin_preprocessor import VinPreprocessor, vin_preprocessor
def create_test_image(width: int = 400, height: int = 100, color: int = 128) -> bytes:
"""Create a simple test image."""
image = Image.new("RGB", (width, height), (color, color, color))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def create_grayscale_test_image(width: int = 400, height: int = 100) -> bytes:
"""Create a grayscale test image."""
image = Image.new("L", (width, height), 128)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
class TestVinPreprocessor:
"""Tests for VIN-optimized preprocessing."""
def test_preprocess_returns_result(self) -> None:
"""Test basic preprocessing returns a result."""
preprocessor = VinPreprocessor()
image_bytes = create_test_image()
result = preprocessor.preprocess(image_bytes)
assert result.image_bytes is not None
assert len(result.image_bytes) > 0
assert "grayscale" in result.preprocessing_applied
def test_preprocess_applies_all_steps(self) -> None:
"""Test preprocessing applies all requested steps."""
preprocessor = VinPreprocessor()
image_bytes = create_test_image()
result = preprocessor.preprocess(
image_bytes,
apply_clahe=True,
apply_deskew=True,
apply_denoise=True,
apply_threshold=True,
)
assert "grayscale" in result.preprocessing_applied
assert "clahe" in result.preprocessing_applied
assert "deskew" in result.preprocessing_applied
assert "denoise" in result.preprocessing_applied
assert "threshold" in result.preprocessing_applied
def test_preprocess_skips_disabled_steps(self) -> None:
"""Test preprocessing skips disabled steps."""
preprocessor = VinPreprocessor()
image_bytes = create_test_image()
result = preprocessor.preprocess(
image_bytes,
apply_clahe=False,
apply_deskew=False,
apply_denoise=False,
apply_threshold=False,
)
assert "clahe" not in result.preprocessing_applied
assert "deskew" not in result.preprocessing_applied
assert "denoise" not in result.preprocessing_applied
assert "threshold" not in result.preprocessing_applied
def test_preprocess_output_is_valid_image(self) -> None:
"""Test preprocessing output is a valid PNG image."""
preprocessor = VinPreprocessor()
image_bytes = create_test_image()
result = preprocessor.preprocess(image_bytes)
# Should be able to open as image
output_image = Image.open(io.BytesIO(result.image_bytes))
assert output_image is not None
assert output_image.format == "PNG"
def test_preprocess_handles_grayscale_input(self) -> None:
"""Test preprocessing handles grayscale input."""
preprocessor = VinPreprocessor()
image_bytes = create_grayscale_test_image()
result = preprocessor.preprocess(image_bytes)
assert result.image_bytes is not None
assert len(result.image_bytes) > 0
def test_preprocess_handles_rgba_input(self) -> None:
"""Test preprocessing handles RGBA input."""
preprocessor = VinPreprocessor()
# Create RGBA image
image = Image.new("RGBA", (400, 100), (128, 128, 128, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
result = preprocessor.preprocess(buffer.getvalue())
assert result.image_bytes is not None
assert "convert_rgb" in result.preprocessing_applied
def test_singleton_instance(self) -> None:
"""Test singleton instance is available."""
assert vin_preprocessor is not None
assert isinstance(vin_preprocessor, VinPreprocessor)
class TestVinPreprocessorDeskew:
"""Tests for deskew functionality."""
def test_deskew_no_change_for_straight_image(self) -> None:
"""Test deskew doesn't change a straight image significantly."""
preprocessor = VinPreprocessor()
# Create image with horizontal line (no skew)
image = np.zeros((100, 400), dtype=np.uint8)
image[50, 50:350] = 255 # Horizontal line
result = preprocessor._deskew(image)
# Shape should be similar (might change slightly due to processing)
assert result.shape[0] > 0
assert result.shape[1] > 0
class TestVinPreprocessorCLAHE:
"""Tests for CLAHE contrast enhancement."""
def test_clahe_improves_contrast(self) -> None:
"""Test CLAHE changes the image."""
preprocessor = VinPreprocessor()
# Create low contrast image
image = np.full((100, 400), 128, dtype=np.uint8)
result = preprocessor._apply_clahe(image)
# Result should be numpy array of same shape
assert result.shape == image.shape
class TestVinPreprocessorDenoise:
"""Tests for denoising functionality."""
def test_denoise_reduces_noise(self) -> None:
"""Test denoising works on noisy image."""
preprocessor = VinPreprocessor()
# Create noisy image
image = np.random.randint(0, 256, (100, 400), dtype=np.uint8)
result = preprocessor._denoise(image)
# Should return array of same shape
assert result.shape == image.shape
class TestVinPreprocessorThreshold:
"""Tests for adaptive thresholding."""
def test_threshold_creates_binary_image(self) -> None:
"""Test thresholding creates binary output."""
preprocessor = VinPreprocessor()
# Create grayscale image
image = np.full((100, 400), 128, dtype=np.uint8)
result = preprocessor._adaptive_threshold(image)
# Result should be binary (only 0 and 255)
unique_values = np.unique(result)
assert len(unique_values) <= 2
class TestVinRegionDetection:
"""Tests for VIN region detection."""
def test_detect_vin_region_returns_none_for_empty(self) -> None:
"""Test region detection returns None for empty image."""
preprocessor = VinPreprocessor()
# Solid color image - no regions to detect
image_bytes = create_test_image(color=128)
result = preprocessor.detect_vin_region(image_bytes)
# May return None for uniform image
# This is expected behavior
assert result is None or result.width > 0