Files
motovaultpro/ocr/tests/test_vin_preprocessor.py
Eric Gullickson 6a4c2137f7
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
fix: resolve VIN OCR scanning failures on all images (refs #113)
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>
2026-02-06 15:57:14 -06:00

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