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

Multi-Class Vehicle Detection & Persistent Tracking
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.
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.
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])))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.
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
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.
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.
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)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.
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
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.
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.
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)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.
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
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.
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.
@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)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)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.
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
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.
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.
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 1000A 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.
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
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.
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.
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)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.
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
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.
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.
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")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.
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.