SpeedRadar-ANPR

AI-powered traffic speed enforcement and automatic number plate recognition pipeline using YOLO11 vehicle tracking, pixel-displacement speed estimation, and PaddleOCR.

Computer VisionYOLOOCRPythonOpenCVTracking
GitHub
Aug 9, 2026
SpeedRadar-ANPR

Multi-Class Vehicle Detection & Persistent Tracking

THE PROBLEM

A single frame of detections has no notion of identity — the same car appears as an unrelated bounding box on every frame. Without a stable ID per vehicle, there is no way to accumulate motion over time, which rules out speed estimation entirely. The detector also needs to ignore pedestrians, signage, and other non-vehicle classes that YOLO11 recognizes by default.

WHAT I DID

Ran YOLO11 in tracking mode (model.track(..., persist=True, tracker='bytetrack.yaml')) so that ByteTrack assigns and persists a track_id across frames instead of re-detecting from scratch each time. Detections are filtered down to the ["car", "truck", "bus", "motorcycle"] classes by name, and a low IoU threshold (0.1) paired with a moderate confidence floor (0.30) was tuned to keep tracks alive through partial occlusion without flooding the pipeline with low-confidence boxes.

Tracking call and class filteringpython
results = self.model.track(frame, persist=True, iou=0.1, conf=0.30,
                            tracker="bytetrack.yaml", verbose=False)[0]

if results.boxes.id is not None:
    for box in results.boxes:
        cls_name = id_name_dict[int(box.cls.tolist()[0])]
        if cls_name in ["car", "truck", "bus", "motorcycle"]:
            car_list.append((box.xyxy.tolist()[0], int(box.id.tolist()[0])))
WHY THIS APPROACH

ByteTrack was chosen over a from-scratch centroid tracker because it recovers tracks through occlusion by also associating low-confidence detections in a second matching pass, rather than discarding any box below the primary confidence threshold. Writing a custom IoU/centroid tracker would have reproduced this logic with none of ByteTrack's occlusion recovery, and re-running detection independently on every frame without persist=True would issue a new track_id on every occlusion, silently breaking every downstream speed calculation.

THE IMPACT

Every vehicle gets one stable identity for as long as it stays in frame, which is the load-bearing assumption for every stage downstream — speed estimation, violation deduplication, and OCR-retry budgeting all key off track_id.

Pixel-Displacement Speed Estimation with Jitter Filtering

THE PROBLEM

There is no radar or LiDAR in this pipeline — speed has to be inferred purely from how a bounding box moves across pixels. Frame-to-frame centroid movement is extremely noisy: detector box jitter on a stationary vehicle can register as motion even when nothing actually moved, producing false speed readings on parked cars.

WHAT I DID

Kept a rolling 10-point history of each track's center point. Once a track has accumulated 10 points, the Euclidean distance between the oldest and newest point is computed and discarded outright if it is below a 25-pixel threshold, filtering out jitter before it ever reaches the speed formula. Surviving displacement is converted to km/h using a meter_per_pixel calibration constant and a fixed video FPS.

Windowed displacement to km/hpython
if len(track_history[track_id]) >= 10:
    px, py = track_history[track_id][0]
    dist = np.sqrt((cx - px) ** 2 + (cy - py) ** 2)
    if dist > 25:
        time_elapsed = 10 / VIDEO_FPS
        speed_kmh = int(((dist * meter_per_pixel) / time_elapsed) * 3.6)
WHY THIS APPROACH

Estimating speed from two consecutive frames would amplify every pixel of detector/tracker noise directly into the speed reading. Averaging displacement over a 10-frame window trades a small amount of latency for a much more stable estimate, and the minimum-distance gate specifically targets the failure mode of idle vehicles being flagged as moving. The meter_per_pixel constant is an honest limitation: it is hand-calibrated for the bundled sample video's camera distance and angle, and would need to be recalculated for any other camera placement.

THE IMPACT

Stationary and idling vehicles are reliably excluded from speed violations instead of generating false positives, at the cost of a fixed calibration constant that ties the current accuracy to the sample footage's specific camera geometry.

Proximity-Gated License Plate OCR

THE PROBLEM

Running plate detection and OCR on every violating vehicle regardless of distance wastes compute on frames that cannot possibly succeed — a plate that occupies a handful of pixels has no legible characters for OCR to extract, no matter how good the model is.

WHAT I DID

Added a proximity gate ahead of the OCR call: a vehicle only qualifies for plate extraction once its bounding box width and height both clear a minimum ratio of the frame dimensions (MIN_CAR_WIDTH_RATIO / MIN_CAR_HEIGHT_RATIO). Only vehicles that are both speeding and close enough to the camera trigger the license-plate model and PaddleOCR.

Distance gate before triggering OCRpython
box_width, box_height = x2 - x1, y2 - y1
is_close_enough = box_width >= min_car_width and box_height >= min_car_height

if speed_kmh > max_speed_limit and track_id not in logged_violators and is_close_enough:
    plate_text = plate_detector.get_plate_for_violator(car_crop)
WHY THIS APPROACH

The alternative — OCR on every detected violator immediately — would burn PaddleOCR calls on distant vehicles that are mathematically unlikely to yield a readable plate, and would generate a flood of UNREADABLE results early in a vehicle's approach. Gating on bounding-box size is a cheap geometric proxy for effective plate resolution, computed with no extra model inference, that filters out the frames least likely to succeed before the expensive OCR call ever runs.

THE IMPACT

Concentrates every OCR attempt on frames where the plate is actually likely to be legible, improving the overall plate-read success rate for the same compute budget.

Multi-Frame OCR Retry with Regex Plate Validation

THE PROBLEM

A single OCR pass on a single frame is a coin flip — motion blur, glare, or a bad crop angle can make even PaddleOCR return nothing or garbage text on any individual attempt. Accepting the first OCR result unconditionally would let noise through as a "valid" plate.

WHAT I DID

Gave each violating track a retry budget (MAX_PLATE_ATTEMPTS = 8): as long as the vehicle keeps being detected as a violator, plate extraction runs again on every subsequent frame until a result passes validation or the budget is exhausted. Each raw OCR string goes through _looks_like_plate(), which strips non-alphanumeric characters, enforces a minimum length, and requires at least one digit before the text is accepted.

Plate text validationpython
@staticmethod
def _looks_like_plate(text):
    if not text:
        return False
    cleaned = re.sub(r'[^A-Za-z0-9]', '', text)
    if len(cleaned) < MIN_PLATE_CHARS:
        return False
    return any(c.isdigit() for c in cleaned)
Retry budget per trackpython
plate_attempts[track_id] = plate_attempts.get(track_id, 0) + 1
attempts_used = plate_attempts[track_id]

if plate_text == "UNREADABLE" and attempts_used >= MAX_PLATE_ATTEMPTS:
    logged_violators.add(track_id)
WHY THIS APPROACH

A single-shot OCR call has a fixed, often low, per-frame success probability. Retrying across frames the vehicle is already being tracked through converts that into a much higher cumulative success rate, since a different frame's blur, angle, or lighting only needs to succeed once. The regex validation is deliberately conservative — it rejects noise before it can be logged as a plate, and the crop is upscaled 2x and grayscaled beforehand specifically to give each OCR attempt the best possible input.

THE IMPACT

Meaningfully raises the effective plate-read success rate over a single-attempt design, though it comes with a known trade-off: a track that exhausts its retry budget without a valid read is force-closed and silently dropped from the final report with no UNREADABLE placeholder — a case worth surfacing explicitly.

Tiered Automated Fine Calculation

THE PROBLEM

A single flat fine for every violation does not reflect how traffic law actually works, and a continuous formula (e.g. linear multiplier on excess speed) does not map cleanly onto how fine schedules are legally structured in bracketed tiers.

WHAT I DID

Implemented calculate_fine() as a bracketed lookup on excess speed (speed above the configured limit), with five escalating tiers from a $50 minor-excess fine up to $1000 for the most severe violations.

Bracketed fine schedulepython
def calculate_fine(excess_speed):
    if excess_speed <= 10:
        return 50
    elif excess_speed <= 20:
        return 150
    elif excess_speed <= 30:
        return 300
    elif excess_speed <= 50:
        return 600
    else:
        return 1000
WHY THIS APPROACH

A bracket function mirrors how real traffic fine schedules are published and is trivially auditable — each fine can be traced back to exactly which bracket a violation fell into. A continuous formula would be marginally more "precise" but harder to justify against any real regulatory schedule and harder to reason about at report-review time.

THE IMPACT

Every fine in the generated report is deterministic and independently verifiable from the recorded speed and limit alone, with no hidden interpolation.

Watermarked Evidence Capture with Plate-Named Storage

THE PROBLEM

A speed reading and a plate number are not evidence on their own — an enforcement system needs a traceable visual artifact per violation, and that artifact needs to be findable later without cross-referencing a separate database.

WHAT I DID

On a confirmed violation, cropped the vehicle region with a 15px padding margin, burned the speed, plate number, and fine directly onto the image with cv2.putText, and saved it under proof/{plate_text}.jpg — using the plate reading itself as the filename.

Watermarking and plate-named savepython
label = f"{speed_kmh} km/h | {plate_text} | ${fine}"
cv2.putText(proof_image, label, (10, 30),
            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

proof_path = f"proof/{plate_text}.jpg"
cv2.imwrite(proof_path, proof_image)
WHY THIS APPROACH

Naming evidence by track_id or timestamp would require a separate index (a database or the report file itself) to look up a specific vehicle's proof image. Naming directly by plate number makes every proof image self-describing and instantly searchable by license plate, which is the primary key an enforcement reviewer would actually search on — at the honest cost that two different violations misread to the same plate string will currently overwrite each other on disk.

THE IMPACT

Produces self-contained, human-readable evidence files with zero external lookup needed, while surfacing filename collision on misreads as a concrete area for a future track_id or timestamp suffix.

Automated Markdown Violation Reporting

THE PROBLEM

A list of violation dictionaries in memory is not an artifact anyone can review, share, or audit. Every run needs to end in a single, self-contained document summarizing what happened and how much revenue the fines represent.

WHAT I DID

Built build_markdown_table(), a small helper that computes column widths dynamically from the data itself and renders an aligned Markdown table, and write_report(), which sorts violations by speed descending and writes a full report with a generated timestamp, the configured speed limit, total violation count, and total fines collected.

Report header and sorted table generationpython
violations_sorted = sorted(violations, key=lambda v: v["speed"], reverse=True)
f.write(f"**Total Violations:** {len(violations)}  \n")
f.write(f"**Total Fines Issued:** ${total_fines}\n\n")
f.write(build_markdown_table(headers, rows) + "\n")
WHY THIS APPROACH

A CSV or JSON export would be more machine-parseable but far less reviewable at a glance, and would need an external viewer. Markdown renders natively on GitHub, is diff-friendly for version control, and doubles as documentation — appropriate for a project whose own README is itself written in the same format.

THE IMPACT

Every run of the pipeline produces one command-generated, audit-ready report — sorted by severity, with total revenue already computed — sitting alongside the annotated output video and the plate-named proof images.

Key Technical Achievements

Persistent Multi-Vehicle Tracking

YOLO11 combined with ByteTrack maintains stable per-vehicle identity across occlusion, which every downstream stage — speed estimation, violation dedup, OCR budgeting — depends on.

Jitter-Resistant Speed Estimation

A 10-frame rolling displacement window with a minimum-distance gate converts noisy pixel motion into a stable speed estimate while filtering out stationary-vehicle false positives.

Compute-Aware OCR Triggering

A proximity heuristic ensures plate detection and OCR only run on vehicles close enough to the camera to realistically produce a legible read.

Resilient Plate Recognition

An 8-attempt retry loop paired with regex-based plate validation converts a low single-shot OCR success rate into a much higher cumulative recognition rate.

Auditable Fine Logic

A five-tier bracket schedule ties every issued fine directly and traceably to how far a vehicle exceeded the configured speed limit.

Self-Contained Evidence Trail

Watermarked, plate-named proof images and a dynamically generated Markdown report give every run of the pipeline a complete, human-reviewable audit trail with no external database.