Vehicle Document Verification Without Training Your Own ML Model
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