| import gradio as gr |
| import cv2 |
| import numpy as np |
| from PIL import Image |
| import time |
| import tempfile |
| import os |
| from ultralytics import YOLO |
| from paddleocr import PaddleOCR |
|
|
| |
| |
| |
| print("Loading models...") |
| model = YOLO("yolov8n.pt") |
| |
| ocr_engine = PaddleOCR(use_textline_orientation=True, lang="en") |
| print("Models ready.") |
|
|
|
|
| |
| |
| |
| def detect_plates(img_bgr, conf_threshold): |
| results = model(img_bgr, conf=conf_threshold, verbose=False) |
| boxes = [] |
| for r in results: |
| for box in r.boxes: |
| x1, y1, x2, y2 = map(int, box.xyxy[0]) |
| conf = float(box.conf[0]) |
| boxes.append((x1, y1, x2, y2, conf)) |
| return boxes |
|
|
|
|
| def read_plate(crop_bgr): |
| """PaddleOCR v3+ returns a list of OCRResult objects, not raw nested lists.""" |
| texts = [] |
| try: |
| results = ocr_engine.ocr(crop_bgr) |
| if not results: |
| return texts |
| for res in results: |
| |
| if hasattr(res, 'boxes'): |
| for box in res.boxes: |
| texts.append((box.text.strip().upper(), round(float(box.score), 3))) |
| else: |
| |
| for item in res: |
| if isinstance(item, (list, tuple)) and len(item) == 2: |
| text_conf = item[1] |
| if isinstance(text_conf, (list, tuple)) and len(text_conf) == 2: |
| text, confidence = text_conf |
| texts.append((str(text).strip().upper(), round(float(confidence), 3))) |
| except Exception as e: |
| print(f"OCR error: {e}") |
| return texts |
|
|
|
|
| def draw_annotations(img_bgr, detections, ocr_map): |
| img = img_bgr.copy() |
| for i, (x1, y1, x2, y2, conf) in enumerate(detections): |
| cv2.rectangle(img, (x1, y1), (x2, y2), (0, 229, 255), 2) |
| label_texts = ocr_map.get(i, []) |
| plate_str = " ".join([t for t, _ in label_texts]) if label_texts else "PLATE" |
| label = f"{plate_str} [{conf:.0%}]" |
| (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.65, 2) |
| cv2.rectangle(img, (x1, y1 - th - 12), (x1 + tw + 8, y1), (0, 229, 255), -1) |
| cv2.putText(img, label, (x1 + 4, y1 - 4), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 0), 2) |
| return img |
|
|
|
|
| def full_pipeline(pil_image, conf_threshold): |
| img_np = np.array(pil_image.convert("RGB")) |
| img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) |
|
|
| detections = detect_plates(img_bgr, conf_threshold) |
|
|
| ocr_map = {} |
| plate_rows = [] |
| for i, (x1, y1, x2, y2, det_conf) in enumerate(detections): |
| crop = img_bgr[y1:y2, x1:x2] |
| if crop.size == 0: |
| continue |
| texts = read_plate(crop) |
| ocr_map[i] = texts |
| plate_str = " | ".join([t for t, _ in texts]) if texts else "β" |
| ocr_conf = f"{texts[0][1]:.1%}" if texts else "β" |
| plate_rows.append([i + 1, plate_str, f"{det_conf:.1%}", ocr_conf]) |
|
|
| annotated_bgr = draw_annotations(img_bgr, detections, ocr_map) |
| annotated_rgb = cv2.cvtColor(annotated_bgr, cv2.COLOR_BGR2RGB) |
| annotated_pil = Image.fromarray(annotated_rgb) |
|
|
| summary = f"β
{len(detections)} plate(s) detected." if detections else "β οΈ No plates found. Try lowering the confidence threshold." |
| return annotated_pil, plate_rows, summary |
|
|
|
|
| |
| |
| |
| def process_image(image, conf_threshold): |
| if image is None: |
| return None, [], "β οΈ Please upload an image." |
| t0 = time.time() |
| annotated, rows, summary = full_pipeline(image, conf_threshold) |
| elapsed = time.time() - t0 |
| summary += f" | β± {elapsed:.2f}s" |
| return annotated, rows, summary |
|
|
|
|
| |
| |
| |
| def process_video(video_path, conf_threshold, frame_skip): |
| if video_path is None: |
| return None, [], "β οΈ Please upload a video." |
|
|
| cap = cv2.VideoCapture(video_path) |
| fps = cap.get(cv2.CAP_PROP_FPS) or 25 |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) |
|
|
| out_path = tempfile.mktemp(suffix="_anpr.mp4") |
| fourcc = cv2.VideoWriter_fourcc(*"mp4v") |
| writer = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) |
|
|
| all_plates = {} |
| frame_idx = 0 |
| last_ocr_map = {} |
| last_dets = [] |
|
|
| while True: |
| ret, frame = cap.read() |
| if not ret: |
| break |
|
|
| if frame_idx % int(frame_skip) == 0: |
| dets = detect_plates(frame, conf_threshold) |
| ocr_map = {} |
| for i, (x1, y1, x2, y2, _) in enumerate(dets): |
| crop = frame[y1:y2, x1:x2] |
| if crop.size == 0: |
| continue |
| texts = read_plate(crop) |
| ocr_map[i] = texts |
| for txt, conf in texts: |
| if txt not in all_plates or conf > all_plates[txt]: |
| all_plates[txt] = conf |
| last_dets = dets |
| last_ocr_map = ocr_map |
|
|
| annotated = draw_annotations(frame, last_dets, last_ocr_map) |
| writer.write(annotated) |
| frame_idx += 1 |
|
|
| cap.release() |
| writer.release() |
|
|
| rows = [[i + 1, plate, f"{conf:.1%}"] for i, (plate, conf) in enumerate(all_plates.items())] |
| summary = f"β
{len(all_plates)} unique plate(s) across {frame_idx} frames." |
| return out_path, rows, summary |
|
|
|
|
| |
| |
| |
| css = """ |
| @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap'); |
| |
| body, .gradio-container { |
| background: #0a0a0f !important; |
| font-family: 'Syne', sans-serif !important; |
| color: #e8e8f0 !important; |
| } |
| |
| .gradio-container { |
| max-width: 1100px !important; |
| margin: 0 auto !important; |
| } |
| |
| #hero { padding: 2.5rem 0 1rem 0; } |
| #hero h1 { |
| font-family: 'Syne', sans-serif; |
| font-size: 3rem; |
| font-weight: 800; |
| letter-spacing: -0.04em; |
| line-height: 1.1; |
| background: linear-gradient(135deg, #ffffff 0%, #00e5ff 100%); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| margin: 0; |
| } |
| #hero p { |
| font-family: 'Space Mono', monospace; |
| color: #6b6b80; |
| font-size: 0.85rem; |
| margin-top: 8px; |
| letter-spacing: 0.06em; |
| } |
| .tag { |
| display: inline-block; |
| background: rgba(0,229,255,0.08); |
| border: 1px solid rgba(0,229,255,0.25); |
| color: #00e5ff; |
| font-family: 'Space Mono', monospace; |
| font-size: 0.68rem; |
| padding: 4px 12px; |
| border-radius: 20px; |
| letter-spacing: 0.08em; |
| margin-right: 6px; |
| margin-top: 10px; |
| } |
| |
| .tab-nav button { |
| font-family: 'Space Mono', monospace !important; |
| font-size: 0.78rem !important; |
| color: #6b6b80 !important; |
| background: transparent !important; |
| border: none !important; |
| border-bottom: 2px solid transparent !important; |
| padding: 10px 18px !important; |
| } |
| .tab-nav button.selected { |
| color: #00e5ff !important; |
| border-bottom: 2px solid #00e5ff !important; |
| } |
| |
| .block, .panel, .wrap { |
| background: #111118 !important; |
| border: 1px solid #2a2a38 !important; |
| border-radius: 12px !important; |
| } |
| |
| button.primary { |
| background: #00e5ff !important; |
| color: #000 !important; |
| border: none !important; |
| font-family: 'Space Mono', monospace !important; |
| font-weight: 700 !important; |
| font-size: 0.82rem !important; |
| letter-spacing: 0.06em !important; |
| border-radius: 8px !important; |
| padding: 10px 28px !important; |
| transition: all 0.2s !important; |
| } |
| button.primary:hover { |
| background: #00b8d9 !important; |
| box-shadow: 0 4px 20px rgba(0,229,255,0.3) !important; |
| transform: translateY(-1px) !important; |
| } |
| |
| label span { |
| font-family: 'Syne', sans-serif !important; |
| font-size: 0.8rem !important; |
| color: #6b6b80 !important; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| } |
| |
| table { font-family: 'Space Mono', monospace !important; font-size: 0.8rem !important; } |
| th { color: #6b6b80 !important; text-transform: uppercase; letter-spacing: 0.08em; } |
| td { color: #e8e8f0 !important; } |
| |
| footer { display: none !important; } |
| """ |
|
|
| |
| |
| |
| with gr.Blocks(css=css, title="ANPR System") as demo: |
|
|
| gr.HTML(""" |
| <div id="hero"> |
| <h1>Number Plate<br>Recognition</h1> |
| <p>// detect Β· read Β· log</p> |
| <span class="tag">YOLOv8</span> |
| <span class="tag">PaddleOCR</span> |
| <span class="tag">Computer Vision</span> |
| <span class="tag">Gradio</span> |
| </div> |
| """) |
|
|
| with gr.Tabs(): |
|
|
| |
| with gr.Tab("π· Image Detection"): |
| with gr.Row(): |
| with gr.Column(scale=1): |
| img_input = gr.Image(type="pil", label="Upload Image") |
| conf_slider = gr.Slider(0.10, 0.95, value=0.30, step=0.05, |
| label="Confidence Threshold") |
| run_img_btn = gr.Button("βΆ Detect Plates", variant="primary") |
|
|
| with gr.Column(scale=1): |
| img_output = gr.Image(type="pil", label="Annotated Result") |
|
|
| status_img = gr.Textbox(label="Status", interactive=False) |
| plate_table = gr.Dataframe( |
| headers=["#", "Plate Text", "Detection Conf.", "OCR Conf."], |
| label="Detected Plates", |
| interactive=False, |
| ) |
|
|
| run_img_btn.click( |
| fn=process_image, |
| inputs=[img_input, conf_slider], |
| outputs=[img_output, plate_table, status_img], |
| ) |
|
|
| |
| with gr.Tab("π¬ Video Detection"): |
| with gr.Row(): |
| with gr.Column(scale=1): |
| vid_input = gr.Video(label="Upload Video") |
| conf_slider2 = gr.Slider(0.10, 0.95, value=0.30, step=0.05, |
| label="Confidence Threshold") |
| frame_skip = gr.Slider(1, 30, value=5, step=1, |
| label="Process Every N Frames (higher = faster)") |
| run_vid_btn = gr.Button("βΆ Process Video", variant="primary") |
|
|
| with gr.Column(scale=1): |
| vid_output = gr.Video(label="Annotated Video") |
|
|
| status_vid = gr.Textbox(label="Status", interactive=False) |
| vid_table = gr.Dataframe( |
| headers=["#", "Plate Text", "Best OCR Conf."], |
| label="Unique Plates Found", |
| interactive=False, |
| ) |
|
|
| run_vid_btn.click( |
| fn=process_video, |
| inputs=[vid_input, conf_slider2, frame_skip], |
| outputs=[vid_output, vid_table, status_vid], |
| ) |
|
|
| |
| with gr.Tab("π How It Works"): |
| gr.Markdown(""" |
| ## Pipeline |
| |
| This ANPR system runs a **two-stage deep learning pipeline**: |
| |
| --- |
| |
| ### Stage 1 β Plate Detection (YOLOv8) |
| YOLOv8 scans the full image in a single forward pass and outputs: |
| - Bounding box coordinates for each detected plate |
| - A confidence score per detection |
| |
| ### Stage 2 β Text Recognition (PaddleOCR) |
| Each detected plate region is cropped and passed to PaddleOCR which: |
| 1. Detects text regions inside the crop |
| 2. Classifies orientation (fixes rotated plates) |
| 3. Reads characters using a CRNN-based model |
| |
| ### Video Processing |
| For videos, every N-th frame is sampled (configurable). Each frame goes through |
| the same pipeline. Results are deduplicated to surface unique plates. |
| |
| --- |
| |
| ### Models Used |
| |
| | Model | Role | Source | |
| |-------|------|--------| |
| | YOLOv8n | Licence plate detection | Ultralytics | |
| | PaddleOCR | Text recognition | PaddlePaddle | |
| |
| --- |
| |
| ### Tip |
| Swap `yolov8n.pt` with a fine-tuned licence-plate weights file |
| (e.g. from Roboflow Universe) for significantly better plate-specific accuracy. |
| """) |
|
|
| gr.HTML(""" |
| <div style="text-align:center; padding: 1.5rem 0 0.5rem 0; |
| font-family: 'Space Mono', monospace; font-size: 0.7rem; color: #3a3a50;"> |
| Built with YOLOv8 Β· PaddleOCR Β· Gradio Β· Hosted on Hugging Face Spaces |
| </div> |
| """) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|