OCR_Vehicle_01 / src /validator /vin_validator.py
Esketch's picture
v18: ISO 3779 VIN validation, OCR correction, and owner name cleanup
88cd424
Raw
History Blame Contribute Delete
7.89 kB
# -*- coding: utf-8 -*-
"""
VIN (Vehicle Identification Number) Validator
Standard: ISO 3779 / SAE J853
Structure (17 characters):
Position 1-3: WMI (World Manufacturer Identifier)
Position 4-8: VDS (Vehicle Descriptor Section)
Position 9: Check digit (0-9 or X)
Position 10: Model year code
Position 11: Plant code
Position 12-17: Sequential production number (VIS tail)
"""
import logging
logger = logging.getLogger(__name__)
# Transliteration values for check digit calculation (ISO 3779)
_TRANSLITERATION = {
'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8,
'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9,
'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9,
}
# Position weights for check digit calculation
_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
# Model year codes (position 10)
_YEAR_CODES = {
'A': 2010, 'B': 2011, 'C': 2012, 'D': 2013, 'E': 2014,
'F': 2015, 'G': 2016, 'H': 2017, 'J': 2018, 'K': 2019,
'L': 2020, 'M': 2021, 'N': 2022, 'P': 2023, 'R': 2024,
'S': 2025, 'T': 2026, 'V': 2027, 'W': 2028, 'X': 2029, 'Y': 2030,
'1': 2001, '2': 2002, '3': 2003, '4': 2004, '5': 2005,
'6': 2006, '7': 2007, '8': 2008, '9': 2009,
}
# Valid characters in VIN (I, O, Q excluded)
_VALID_CHARS = set('ABCDEFGHJKLMNPRSTUVWXYZ0123456789')
# Korean manufacturer WMIs
KOREAN_WMIS = {
'KMH': 'Hyundai (passenger)',
'KMJ': 'Hyundai (bus/truck)',
'KME': 'Hyundai (EV/special)',
'KNA': 'Kia',
'KNC': 'Kia (commercial)',
'KNM': 'Renault Samsung',
'KND': 'Kia (SUV)',
'KPT': 'SsangYong',
'KPA': 'SsangYong',
'KL1': 'GM Korea (Chevrolet)',
'KLA': 'Daewoo/GM Korea',
'KLB': 'Daewoo/GM Korea',
'KMF': 'Hyundai (medium truck)',
'KMK': 'Hyundai (special)',
}
def compute_check_digit(vin):
"""
Compute the VIN check digit (position 9) per ISO 3779.
Args:
vin: 17-character VIN string (position 9 can be anything)
Returns:
Expected check digit character ('0'-'9' or 'X'), or None if invalid
"""
if not vin or len(vin) != 17:
return None
total = 0
for i, char in enumerate(vin.upper()):
if i == 8: # Skip position 9 (check digit itself)
continue
if char.isdigit():
value = int(char)
elif char in _TRANSLITERATION:
value = _TRANSLITERATION[char]
else:
return None # Invalid character
total += value * _WEIGHTS[i]
remainder = total % 11
return 'X' if remainder == 10 else str(remainder)
def decode_model_year(vin):
"""Decode model year from VIN position 10."""
if not vin or len(vin) < 10:
return None
year_char = vin[9].upper()
return _YEAR_CODES.get(year_char)
def decode_wmi(vin):
"""Decode World Manufacturer Identifier (positions 1-3)."""
if not vin or len(vin) < 3:
return None
wmi = vin[:3].upper()
return KOREAN_WMIS.get(wmi)
def correct_vin_ocr(vin):
"""
Fix common OCR misreads in VIN.
Strategy:
1. Replace universally invalid chars (I→1, O→0, Q→0)
2. If check digit fails, try single-char corrections to find valid VIN
"""
if not vin:
return vin
vin = vin.upper().strip()
# Step 1: Fix universally invalid VIN characters
vin = vin.replace('O', '0').replace('I', '1').replace('Q', '0')
# Step 2: If check digit passes, we're done
if len(vin) == 17:
expected = compute_check_digit(vin)
actual = vin[8]
if expected and actual == expected:
return vin
# Step 3: Try single-character corrections at each position
# Only try ambiguous OCR pairs (S↔5, B↔8, Z↔2, G↔6)
ambiguous_pairs = [
('5', 'S'), ('S', '5'),
('8', 'B'), ('B', '8'),
('2', 'Z'), ('Z', '2'),
('6', 'G'), ('G', '6'),
('0', 'D'), ('D', '0'),
]
for pos in range(17):
if pos == 8:
continue # Don't modify check digit position
original_char = vin[pos]
for from_char, to_char in ambiguous_pairs:
if original_char == from_char:
candidate = vin[:pos] + to_char + vin[pos + 1:]
chk = compute_check_digit(candidate)
if chk and candidate[8] == chk:
logger.info(f"VIN OCR correction: pos {pos + 1} '{from_char}'→'{to_char}' ({vin}{candidate})")
return candidate
return vin
def is_valid_structure(vin):
"""
Validate VIN structure per ISO 3779.
Returns:
(is_valid, message) tuple
"""
if not vin:
return False, "Empty VIN"
vin = vin.upper().strip()
# Length check
if len(vin) != 17:
return False, f"Invalid length: {len(vin)} (expected 17)"
# Character set check
invalid_chars = set(vin) - _VALID_CHARS
if invalid_chars:
return False, f"Invalid characters: {invalid_chars}"
# Position 12-17 should be numeric for Korean vehicles (most manufacturers)
serial = vin[11:17]
if vin[0] == 'K' and not serial.isdigit():
# Some Korean VINs have alphanumeric serial, so this is a warning not rejection
pass
return True, "Valid structure"
def validate_check_digit(vin):
"""
Validate VIN check digit (position 9).
Note: Check digit is mandatory for North American vehicles (FMVSS/CMVSS)
but optional for vehicles in other markets. Korean domestic vehicles
may not always follow check digit rules, but exported ones do.
Returns:
(is_valid, message) tuple
"""
if not vin or len(vin) != 17:
return False, "Cannot validate check digit"
expected = compute_check_digit(vin)
actual = vin[8]
if expected is None:
return False, "Cannot compute check digit (invalid characters)"
if actual == expected:
return True, f"Check digit valid ({actual})"
else:
return False, f"Check digit mismatch: expected '{expected}', got '{actual}'"
class VINValidator:
"""
Validates Vehicle Identification Numbers (VIN).
Standard: ISO 3779 / SAE J853
Validation levels:
1. Structure: length, character set
2. Check digit: position 9 verification
3. WMI: manufacturer identification
"""
@staticmethod
def validate(vin):
"""
Full VIN validation.
Returns:
(is_valid, message) tuple
"""
if not vin:
return False, "Empty VIN"
vin = vin.upper().replace(" ", "")
# Structure validation
valid, msg = is_valid_structure(vin)
if not valid:
return False, msg
# Check digit validation
chk_valid, chk_msg = validate_check_digit(vin)
# WMI info
manufacturer = decode_wmi(vin)
year = decode_model_year(vin)
info_parts = []
if manufacturer:
info_parts.append(manufacturer)
if year:
info_parts.append(f"MY{year}")
if chk_valid:
info = f"Valid ({', '.join(info_parts)})" if info_parts else "Valid"
return True, info
else:
# Korean domestic vehicles may not use check digit
# Accept with warning if structure is valid
info = f"Valid (structure OK, {chk_msg}"
if info_parts:
info += f", {', '.join(info_parts)}"
info += ")"
return True, info
@staticmethod
def calculate_similarity(vin1, vin2):
"""Simple character-level similarity between two VINs."""
if not vin1 or not vin2:
return 0.0
matches = sum(c1 == c2 for c1, c2 in zip(vin1, vin2))
return matches / max(len(vin1), len(vin2))