All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 51s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m31s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Root cause: Tesseract fragments VINs into multiple words but candidate extraction required continuous 17-char sequences, rejecting all results. Changes: - Fix candidate extraction to concatenate adjacent OCR fragments - Disable Tesseract dictionaries (VINs are not dictionary words) - Set OEM 1 (LSTM engine) for better accuracy - Add PSM 11 (sparse text) and PSM 13 (raw line) fallback modes - Add Otsu's thresholding as alternative preprocessing pipeline - Upscale small images to meet Tesseract's 300 DPI requirement - Remove incorrect B->8 and S->5 transliterations (valid VIN chars) - Fix pre-existing test bug in check digit expected value Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
8.3 KiB
Python
252 lines
8.3 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 "resolution_check" 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 TestVinPreprocessorOtsu:
|
|
"""Tests for Otsu's thresholding preprocessing."""
|
|
|
|
def test_otsu_threshold_creates_binary_image(self) -> None:
|
|
"""Test Otsu's thresholding creates binary output."""
|
|
preprocessor = VinPreprocessor()
|
|
image = np.full((100, 400), 128, dtype=np.uint8)
|
|
|
|
result = preprocessor._otsu_threshold(image)
|
|
|
|
unique_values = np.unique(result)
|
|
assert len(unique_values) <= 2
|
|
|
|
def test_preprocess_otsu_returns_result(self) -> None:
|
|
"""Test Otsu preprocessing pipeline returns valid result."""
|
|
preprocessor = VinPreprocessor()
|
|
image_bytes = create_test_image()
|
|
|
|
result = preprocessor.preprocess_otsu(image_bytes)
|
|
|
|
assert result.image_bytes is not None
|
|
assert len(result.image_bytes) > 0
|
|
assert "otsu_threshold" in result.preprocessing_applied
|
|
assert "grayscale" in result.preprocessing_applied
|
|
|
|
|
|
class TestVinPreprocessorResolution:
|
|
"""Tests for resolution upscaling."""
|
|
|
|
def test_upscale_small_image(self) -> None:
|
|
"""Test small images are upscaled."""
|
|
preprocessor = VinPreprocessor()
|
|
small_image = np.full((50, 200), 128, dtype=np.uint8)
|
|
|
|
result = preprocessor._ensure_minimum_resolution(small_image)
|
|
|
|
assert result.shape[1] >= preprocessor.MIN_WIDTH_FOR_VIN
|
|
|
|
def test_no_upscale_large_image(self) -> None:
|
|
"""Test large images are not upscaled."""
|
|
preprocessor = VinPreprocessor()
|
|
large_image = np.full((200, 800), 128, dtype=np.uint8)
|
|
|
|
result = preprocessor._ensure_minimum_resolution(large_image)
|
|
|
|
assert result.shape == large_image.shape
|
|
|
|
|
|
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
|