Vehicle Document Verification Without Training Your Own ML Model
AI & Coding • 6 min read

Vehicle Document Verification Without Training Your Own ML Model

Paul Ekung

Author

Law enforcement agencies need a fast way to verify that a vehicle's license plate, driver's license, and registration are all current.

Law enforcement agencies need a fast way to verify that a vehicle's license plate, driver's license, and registration are all current. Doing this by hand doesn't scale, and it's easy for an expired document to slip through unnoticed.

The original approach to a project like this reaches for YOLOv4 for plate detection and Tesseract OCR for reading the characters off it — a solid design, but one that assumes you're ready to set up model training, TensorFlow, and a GPU-friendly environment.

This article walks through a version of the same idea that skips model training entirely: OpenCV's built-in Haar Cascade classifier (a pretrained, classical computer-vision detector) stands in for YOLOv4, Tesseract still handles OCR, and the whole thing is wired into a Django web app instead of a desktop GUI. It's lighter to set up, and — as you'll see in the “Common Problems” section — it comes with its own very real trade-offs worth knowing about before you rely on it.

How It Fits Together

The pipeline is a straight line from upload to verdict:

Image upload

     |

     v

Haar Cascade plate detection --(nothing found)--> Contour-based fallback

     |                                                    |

     v                                                    v

Crop + pad the plate region  <----------------------------

     |

     v

Tesseract OCR (character whitelist: A-Z, 0-9)

     |

     v

Look up plate text in VehicleRecord table

     |

     v

Compare license/registration expiry to today

     |

     v

VALID / EXPIRED_* / NOT_FOUND / NO_PLATE_DETECTED

 The Data Model

Detecting the Plate
Reading the Characters
Verifying Against the Database
Common Problems (and How They Were Solved)
1. OpenCV 5.0 removed CascadeClassifier from the main package
2. Tesseract installed without language data
3. False-positive detections on grilles and branding banners
4. Close-up plate shots going undetected
Result
Conclusion
This project shows that a pretrained Haar Cascade can stand in for a trained YOLO model, provided the candidate detections are filtered by aspect ratio and size to avoid false positives, and that a classical contour-based fallback meaningfully extends coverage to close-up shots the cascade alone tends to miss. Capping how far a crop gets upscaled before OCR prevents both garbled text and runaway processing times, while treating the "database" as a normal Django model rather than a flat file provides an admin panel, migrations, and a verification log at no extra cost. None of this requires training a model — every component here is either a pretrained classical detector or a rule-based lookup, making it a practical, lightweight alternative for a project like this.

Instead of a flat-file “dummy dataset,” the simulated DMV database is just a Django model — which also gives the admin panel for free, so test records can be added or edited without writing any extra tooling.

class VehicleRecord(models.Model):

    plate_number = models.CharField(max_length=20, unique=True, db_index=True)

    owner_name = models.CharField(max_length=120)

    license_number = models.CharField(max_length=30)

    vehicle_make = models.CharField(max_length=60, blank=True)

    vehicle_model = models.CharField(max_length=60, blank=True)

    registration_expiry = models.DateField()

    license_expiry = models.DateField()

 

 

class VerificationLog(models.Model):

    STATUS_CHOICES = [

        ("VALID", "Valid - all documents current"),

        ("EXPIRED_LICENSE", "License expired"),

        ("EXPIRED_REGISTRATION", "Registration expired"),

        ("EXPIRED_BOTH", "License and registration expired"),

        ("NOT_FOUND", "Plate not found in database"),

        ("NO_PLATE_DETECTED", "No plate detected in image"),

    ]

 

    uploaded_image = models.ImageField(upload_to="uploads/")

    detected_plate_text = models.CharField(max_length=20, blank=True)

    matched_record = models.ForeignKey(

        VehicleRecord, null=True, blank=True, on_delete=models.SET_NULL

    )

    status = models.CharField(max_length=30, choices=STATUS_CHOICES)

    processing_time_seconds = models.FloatField(default=0.0)

    created_at = models.DateTimeField(auto_now_add=True)

 

Every check — successful or not — gets logged. That log is what powers the “recent checks” table on the upload page, and it's a handy evaluation trail for a report: it captures exactly what was detected, what it matched, and how long it took, for every image tested.

 The core detector uses OpenCV's Haar Cascade — a pretrained classifier, so there's no training step. The catch: on its own, it fires on anything roughly plate-shaped, including grilles, bumpers, and branding banners. Filtering candidates by aspect ratio and relative size cuts that down substantially:

def detect_plate_region(image_path):

    img = cv2.imread(str(image_path))

    if img is None:

        return None

 

    img_h, img_w = img.shape[:2]

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    cascade = cv2.CascadeClassifier(str(settings.HAAR_CASCADE_PATH))

    plates = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5)

 

    candidates = []

    for (x, y, w, h) in plates:

        aspect_ratio = w / float(h)

        relative_width = w / float(img_w)

        # Real plates: roughly 2:1 to 5:1 width:height, and never

        # more than ~50% of the image width in a normal photo.

        if 2.0 <= aspect_ratio <= 5.0 and relative_width <= 0.5:

            candidates.append((x, y, w, h))

 

    if candidates:

        x, y, w, h = max(candidates, key=lambda box: box[2] * box[3])

        # Trim inward so border lines/bolts don't read as characters.

        pad_x, pad_y = int(w * 0.06), int(h * 0.12)

        x, y = x + pad_x, y + pad_y

        w, h = max(w - 2 * pad_x, 1), max(h - 2 * pad_y, 1)

        return gray[y : y + h, x : x + w]

 

    return _find_plate_via_contours(gray, img_w, img_h)

 When the cascade finds nothing — which happens more often than you'd expect on close-up or isolated plate shots, since it was trained on full vehicle photos with surrounding context — a classical contour-based fallback takes over. It thresholds the image and looks for the largest bright, plate-shaped rectangle instead:

def _find_plate_via_contours(gray, img_w, img_h):

    blur = cv2.GaussianBlur(gray, (5, 5), 0)

    _, thresh = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

 

    candidates = []

    for c in contours:

        x, y, w, h = cv2.boundingRect(c)

        if h == 0:

            continue

        aspect_ratio = w / float(h)

        relative_area = (w * h) / float(img_w * img_h)

        if 2.0 <= aspect_ratio <= 5.5 and 0.05 <= relative_area <= 0.9:

            candidates.append((x, y, w, h))

 

    if not candidates:

        return None

 

    x, y, w, h = max(candidates, key=lambda box: box[2] * box[3])

    pad_x, pad_y = int(w * 0.06), int(h * 0.12)

    x, y = x + pad_x, y + pad_y

    w, h = max(w - 2 * pad_x, 1), max(h - 2 * pad_y, 1)

    return gray[y : y + h, x : x + w]

Once a crop exists, Tesseract handles OCR. Scaling toward a fixed target width — rather than a blanket 2x upscale — matters more than it looks: it helps small crops without letting an oversized false-positive crop balloon into a multi-minute OCR call.

def extract_plate_text(plate_image):

    target_width = 300

    crop_h, crop_w = plate_image.shape[:2]

    scale = target_width / float(crop_w)

    scale = max(0.5, min(scale, 3.0))

 

    resized = cv2.resize(plate_image, None, fx=scale, fy=scale,

                          interpolation=cv2.INTER_CUBIC)

    _, thresh = cv2.threshold(resized, 0, 255,

                               cv2.THRESH_BINARY + cv2.THRESH_OTSU)

 

    raw_text = pytesseract.image_to_string(

        thresh,

        config="--psm 8 -c tessedit_char_whitelist="

               "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

    )

    return "".join(ch for ch in raw_text if ch.isalnum()).upper()

With clean plate text in hand, verification is a straightforward lookup and two date comparisons:

def verify_documents(plate_text):

    if not plate_text:

        return "NO_PLATE_DETECTED", None

 

    try:

        record = VehicleRecord.objects.get(plate_number=plate_text)

    except VehicleRecord.DoesNotExist:

        return "NOT_FOUND", None

 

    today = datetime.date.today()

    license_expired = record.license_expiry < today

    registration_expired = record.registration_expiry < today

 

    if license_expired and registration_expired:

        return "EXPIRED_BOTH", record

    if license_expired:

        return "EXPIRED_LICENSE", record

    if registration_expired:

        return "EXPIRED_REGISTRATION", record

    return "VALID", record

Building and testing this surfaced several real issues worth documenting — the kind of thing an evaluation section benefits from having on record.

A plain “pip install opencv-python” on a fresh machine can silently pull OpenCV 5.0, which moved CascadeClassifier and HOGDescriptor out of the core package and into opencv-contrib-python. The fix was pinning the dependency:

opencv-python>=4.8,<5.0

A Tesseract install can complete successfully while still shipping zero language files, producing:

TesseractError: Error opening data file ...tessdata/eng.traineddata

The fix: download eng.traineddata directly from the tesseract-ocr/tessdata GitHub repository and place it in the tessdata folder next to the Tesseract executable.

Without the aspect-ratio and relative-size filtering shown above, the cascade would happily lock onto a car's grille or a promotional banner across the bottom of a stock photo — producing garbled OCR output and, in one case, a 146-second processing time from upscaling an oversized false crop before OCR. Filtering candidates by shape before cropping fixed both the accuracy and the performance problem at once.

The Haar Cascade was trained on full vehicle photos where the plate is a small region surrounded by bumper/body context. It does not reliably fire on an isolated, close-up plate image — real or synthetic — even under generous parameter tuning. The contour-based fallback exists specifically to cover this case.

With detection, OCR, and verification wired together, a clean test image against a seeded database record correctly returns a verified match with the right owner, license, and expiry details — completing in a little over a second.

The full pipeline: detect → crop → OCR → verify → log, running end to end inside a standard Django request/response cycle.

Tags: Python, Django, OpenCV, Tesseract OCR, Computer Vision, Haar Cascade, License Plate Recognition, Document Verification, SQLite, Image Processing