feat: add VIN photo OCR pipeline (refs #67)
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

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>
This commit is contained in:
Eric Gullickson
2026-02-01 19:31:36 -06:00
parent 004940b013
commit 54cbd49171
14 changed files with 1694 additions and 1 deletions

View File

@@ -0,0 +1,242 @@
"""Integration tests for VIN extraction endpoint."""
import io
from unittest.mock import patch, MagicMock
import pytest
from fastapi.testclient import TestClient
from PIL import Image, ImageDraw, ImageFont
from app.main import app
@pytest.fixture
def client() -> TestClient:
"""Create test client."""
return TestClient(app)
def create_vin_image(vin: str = "1HGBH41JXMN109186") -> bytes:
"""Create a test image with VIN text."""
# Create white image
image = Image.new("RGB", (400, 100), (255, 255, 255))
draw = ImageDraw.Draw(image)
# Draw VIN text (use default font)
draw.text((50, 40), vin, fill=(0, 0, 0))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def create_empty_image() -> bytes:
"""Create an empty test image."""
image = Image.new("RGB", (400, 100), (255, 255, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
class TestVinExtractionEndpoint:
"""Tests for POST /extract/vin endpoint."""
def test_endpoint_exists(self, client: TestClient) -> None:
"""Test VIN endpoint is registered."""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert any("vin" in endpoint.lower() for endpoint in data.get("endpoints", []))
def test_extract_vin_no_file(self, client: TestClient) -> None:
"""Test endpoint returns error when no file provided."""
response = client.post("/extract/vin")
assert response.status_code == 422 # Validation error
def test_extract_vin_empty_file(self, client: TestClient) -> None:
"""Test endpoint returns error for empty file."""
response = client.post(
"/extract/vin",
files={"file": ("empty.png", b"", "image/png")},
)
assert response.status_code == 400
assert "empty" in response.json()["detail"].lower()
def test_extract_vin_large_file(self, client: TestClient) -> None:
"""Test endpoint returns error for file too large."""
# Create file larger than 10MB
large_content = b"x" * (11 * 1024 * 1024)
response = client.post(
"/extract/vin",
files={"file": ("large.png", large_content, "image/png")},
)
assert response.status_code == 413
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_extract_vin_success(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test successful VIN extraction."""
from app.extractors.vin_extractor import VinExtractionResult
mock_extract.return_value = VinExtractionResult(
success=True,
vin="1HGBH41JXMN109186",
confidence=0.94,
bounding_box=None,
alternatives=[],
processing_time_ms=500,
)
image_bytes = create_vin_image()
response = client.post(
"/extract/vin",
files={"file": ("vin.png", image_bytes, "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["vin"] == "1HGBH41JXMN109186"
assert data["confidence"] == 0.94
assert "processingTimeMs" in data
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_extract_vin_not_found(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test VIN not found returns success=false."""
from app.extractors.vin_extractor import VinExtractionResult
mock_extract.return_value = VinExtractionResult(
success=False,
vin=None,
confidence=0.0,
error="No VIN pattern found in image",
processing_time_ms=300,
)
image_bytes = create_empty_image()
response = client.post(
"/extract/vin",
files={"file": ("empty.png", image_bytes, "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert data["vin"] is None
assert data["error"] == "No VIN pattern found in image"
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_extract_vin_with_alternatives(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test VIN extraction with alternatives."""
from app.extractors.vin_extractor import VinExtractionResult, VinAlternative
mock_extract.return_value = VinExtractionResult(
success=True,
vin="1HGBH41JXMN109186",
confidence=0.94,
bounding_box=None,
alternatives=[
VinAlternative(vin="1HGBH41JXMN109186", confidence=0.72),
],
processing_time_ms=600,
)
image_bytes = create_vin_image()
response = client.post(
"/extract/vin",
files={"file": ("vin.png", image_bytes, "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["alternatives"]) == 1
assert data["alternatives"][0]["confidence"] == 0.72
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_extract_vin_with_bounding_box(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test VIN extraction includes bounding box."""
from app.extractors.vin_extractor import VinExtractionResult
from app.preprocessors.vin_preprocessor import BoundingBox
mock_extract.return_value = VinExtractionResult(
success=True,
vin="1HGBH41JXMN109186",
confidence=0.94,
bounding_box=BoundingBox(x=50, y=40, width=300, height=20),
alternatives=[],
processing_time_ms=500,
)
image_bytes = create_vin_image()
response = client.post(
"/extract/vin",
files={"file": ("vin.png", image_bytes, "image/png")},
)
assert response.status_code == 200
data = response.json()
assert data["boundingBox"] is not None
assert data["boundingBox"]["x"] == 50
assert data["boundingBox"]["y"] == 40
assert data["boundingBox"]["width"] == 300
assert data["boundingBox"]["height"] == 20
class TestVinExtractionContentTypes:
"""Tests for different content types."""
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_accepts_jpeg(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test endpoint accepts JPEG images."""
from app.extractors.vin_extractor import VinExtractionResult
mock_extract.return_value = VinExtractionResult(
success=True,
vin="1HGBH41JXMN109186",
confidence=0.9,
processing_time_ms=400,
)
# Create JPEG image
image = Image.new("RGB", (400, 100), (255, 255, 255))
buffer = io.BytesIO()
image.save(buffer, format="JPEG")
response = client.post(
"/extract/vin",
files={"file": ("vin.jpg", buffer.getvalue(), "image/jpeg")},
)
assert response.status_code == 200
@patch("app.extractors.vin_extractor.vin_extractor.extract")
def test_accepts_png(
self, mock_extract: MagicMock, client: TestClient
) -> None:
"""Test endpoint accepts PNG images."""
from app.extractors.vin_extractor import VinExtractionResult
mock_extract.return_value = VinExtractionResult(
success=True,
vin="1HGBH41JXMN109186",
confidence=0.9,
processing_time_ms=400,
)
image_bytes = create_vin_image()
response = client.post(
"/extract/vin",
files={"file": ("vin.png", image_bytes, "image/png")},
)
assert response.status_code == 200

View File

@@ -0,0 +1,202 @@
"""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

View File

@@ -0,0 +1,211 @@
"""Unit tests for VIN validator."""
import pytest
from app.validators.vin_validator import VinValidator, vin_validator
class TestVinValidator:
"""Tests for VIN validation logic."""
def test_correct_ocr_errors_basic(self) -> None:
"""Test basic OCR error correction."""
validator = VinValidator()
# I -> 1
assert validator.correct_ocr_errors("IHGBH41JXMN109186") == "1HGBH41JXMN109186"
# O -> 0
assert validator.correct_ocr_errors("1HGBH41JXMN1O9186") == "1HGBH41JXMN109186"
# Q -> 0
assert validator.correct_ocr_errors("1HGBH41JXMN1Q9186") == "1HGBH41JXMN109186"
def test_correct_ocr_errors_lowercase(self) -> None:
"""Test OCR error correction handles lowercase."""
validator = VinValidator()
result = validator.correct_ocr_errors("1hgbh41jxmn109186")
assert result == "1HGBH41JXMN109186"
def test_correct_ocr_errors_strips_spaces(self) -> None:
"""Test OCR error correction removes spaces and dashes."""
validator = VinValidator()
assert validator.correct_ocr_errors("1HG BH41 JXMN 109186") == "1HGBH41JXMN109186"
assert validator.correct_ocr_errors("1HG-BH41-JXMN-109186") == "1HGBH41JXMN109186"
def test_calculate_check_digit(self) -> None:
"""Test check digit calculation."""
validator = VinValidator()
# Test with known valid VINs
# 1HGBH41JXMN109186 has check digit X at position 9
result = validator.calculate_check_digit("1HGBH41JXMN109186")
assert result == "X"
# 5YJSA1E28HF123456 has check digit 2 at position 9
result = validator.calculate_check_digit("5YJSA1E28HF123456")
assert result == "8" # Verify this is correct for this VIN
def test_validate_check_digit_valid(self) -> None:
"""Test check digit validation with valid VIN."""
validator = VinValidator()
# This VIN has a valid check digit
assert validator.validate_check_digit("1HGBH41JXMN109186") is True
def test_validate_check_digit_invalid(self) -> None:
"""Test check digit validation with invalid VIN."""
validator = VinValidator()
# Modify check digit to make it invalid
assert validator.validate_check_digit("1HGBH41J1MN109186") is False
def test_validate_modern_vin_valid(self) -> None:
"""Test validation of valid modern VIN."""
validator = VinValidator()
result = validator.validate("1HGBH41JXMN109186")
assert result.is_valid is True
assert result.vin == "1HGBH41JXMN109186"
assert result.confidence_adjustment > 0 # Check digit valid = boost
def test_validate_modern_vin_with_ocr_errors(self) -> None:
"""Test validation corrects OCR errors."""
validator = VinValidator()
# I at start should be corrected to 1
result = validator.validate("IHGBH41JXMN109186")
assert result.is_valid is True
assert result.vin == "1HGBH41JXMN109186"
def test_validate_short_vin(self) -> None:
"""Test validation rejects short VIN."""
validator = VinValidator()
result = validator.validate("1HGBH41JX")
assert result.is_valid is False
assert "length" in result.error.lower()
def test_validate_long_vin(self) -> None:
"""Test validation rejects long VIN."""
validator = VinValidator()
result = validator.validate("1HGBH41JXMN109186XX")
assert result.is_valid is False
assert "length" in result.error.lower()
def test_validate_empty_vin(self) -> None:
"""Test validation handles empty VIN."""
validator = VinValidator()
result = validator.validate("")
assert result.is_valid is False
assert "empty" in result.error.lower()
def test_validate_invalid_characters(self) -> None:
"""Test validation rejects invalid characters after correction."""
validator = VinValidator()
# Contains characters not in VIN alphabet
result = validator.validate("1HGBH41JXMN!@#186", correct_errors=False)
assert result.is_valid is False
assert "character" in result.error.lower()
def test_validate_legacy_vin_allowed(self) -> None:
"""Test validation allows legacy VINs when enabled."""
validator = VinValidator()
# 13-character VIN (pre-1981)
result = validator.validate("ABCD123456789", allow_legacy=True)
assert result.is_valid is True
assert result.confidence_adjustment < 0 # Reduced confidence for legacy
def test_validate_legacy_vin_rejected(self) -> None:
"""Test validation rejects legacy VINs by default."""
validator = VinValidator()
result = validator.validate("ABCD123456789", allow_legacy=False)
assert result.is_valid is False
def test_extract_candidates_finds_vin(self) -> None:
"""Test candidate extraction from text."""
validator = VinValidator()
text = "VIN: 1HGBH41JXMN109186 is shown here"
candidates = validator.extract_candidates(text)
assert len(candidates) >= 1
assert candidates[0][0] == "1HGBH41JXMN109186"
def test_extract_candidates_multiple_vins(self) -> None:
"""Test candidate extraction with multiple VINs."""
validator = VinValidator()
text = "First VIN: 1HGBH41JXMN109186 Second VIN: 5YJSA1E28HF123456"
candidates = validator.extract_candidates(text)
assert len(candidates) >= 2
vins = [c[0] for c in candidates]
assert "1HGBH41JXMN109186" in vins
assert "5YJSA1E28HF123456" in vins
def test_extract_candidates_with_ocr_errors(self) -> None:
"""Test candidate extraction corrects OCR errors."""
validator = VinValidator()
# Contains O instead of 0
text = "VIN: 1HGBH41JXMN1O9186"
candidates = validator.extract_candidates(text)
assert len(candidates) >= 1
assert candidates[0][0] == "1HGBH41JXMN109186"
def test_extract_candidates_no_vin(self) -> None:
"""Test candidate extraction with no VIN."""
validator = VinValidator()
text = "This text contains no VIN numbers"
candidates = validator.extract_candidates(text)
assert len(candidates) == 0
def test_singleton_instance(self) -> None:
"""Test singleton instance is available."""
assert vin_validator is not None
assert isinstance(vin_validator, VinValidator)
class TestVinValidatorEdgeCases:
"""Edge case tests for VIN validator."""
def test_all_zeros_vin(self) -> None:
"""Test VIN with all zeros (unlikely but valid format)."""
validator = VinValidator()
result = validator.validate("00000000000000000")
assert result.is_valid is True
assert len(result.vin) == 17
def test_mixed_case_vin(self) -> None:
"""Test VIN with mixed case."""
validator = VinValidator()
result = validator.validate("1hGbH41jXmN109186")
assert result.is_valid is True
assert result.vin == "1HGBH41JXMN109186"
def test_vin_with_leading_trailing_whitespace(self) -> None:
"""Test VIN with whitespace."""
validator = VinValidator()
result = validator.validate(" 1HGBH41JXMN109186 ")
assert result.is_valid is True
assert result.vin == "1HGBH41JXMN109186"
def test_check_digit_x(self) -> None:
"""Test VIN with X as check digit."""
validator = VinValidator()
# 1HGBH41JXMN109186 has X as check digit
assert validator.validate_check_digit("1HGBH41JXMN109186") is True