Upload 48 files
#1
by BasselNashaat - opened
- .gitattributes +1 -35
- .gitignore +0 -0
- app/__init__.py +0 -0
- app/config.py +130 -0
- app/database/__pycache__/connection.cpython-314.pyc +0 -0
- app/database/__pycache__/logger.cpython-314.pyc +0 -0
- app/database/connection.py +83 -0
- app/database/logger.py +113 -0
- app/database/supabase/config.toml +1 -0
- app/database/supabase/migrations/20260508175106_d61705db-2487-451d-ad0c-3940fa1fb5b0.sql +61 -0
- app/main.py +113 -0
- app/pipeline/__init__.py +0 -0
- app/pipeline/__pycache__/classifier.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/coordinator.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/detector.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/ocr.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/price_lookup.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/scorer.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/special_plate.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/video_capture.cpython-314.pyc +0 -0
- app/pipeline/__pycache__/video_capturer.cpython-314.pyc +0 -0
- app/pipeline/classifier.py +156 -0
- app/pipeline/coordinator.py +228 -0
- app/pipeline/corrector.py +129 -0
- app/pipeline/detector.py +106 -0
- app/pipeline/enhancer.py +68 -0
- app/pipeline/ocr.py +110 -0
- app/pipeline/scorer.py +83 -0
- app/pipeline/special_plate.py +188 -0
- app/pipeline/video_capture.py +289 -0
- app_gradio.py +298 -0
- requirements-space.txt +11 -0
- requirements.backend.txt +12 -0
- requirements.txt +11 -0
- server.py +11 -0
- src/capture/Diagnose.py +106 -0
- src/capture/__pycache__/capture.cpython-314.pyc +0 -0
- src/capture/__pycache__/config.cpython-314.pyc +0 -0
- src/capture/capture.py +119 -0
- src/capture/config.py +91 -0
- src/capture/register_camera.py +96 -0
- src/capture/test_capture.py +313 -0
- src/capture/test_detect_api.py +81 -0
- src/capture/yolov8n.pt +3 -0
- src/car_recognition/car_scraper.py +216 -0
- src/car_recognition/exterior_filterer.py +90 -0
- thisishowtorun.md +11 -0
- yolo11n.pt +3 -0
.gitattributes
CHANGED
|
@@ -1,35 +1 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
Binary file (820 Bytes). View file
|
|
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Project configuration file.
|
| 3 |
+
All hardcoded values are extracted from the notebook and centralized here.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from dotenv import load_dotenv, find_dotenv
|
| 9 |
+
|
| 10 |
+
# Load environment variables from .env file (skipped on HF Spaces demo)
|
| 11 |
+
GRADIO_SPACE = os.getenv("GRADIO_SPACE", "").lower() in ("1", "true", "yes")
|
| 12 |
+
if not GRADIO_SPACE:
|
| 13 |
+
ENV_FILE = find_dotenv(filename=".env", usecwd=True)
|
| 14 |
+
if ENV_FILE:
|
| 15 |
+
load_dotenv(ENV_FILE, override=False, interpolate=True)
|
| 16 |
+
|
| 17 |
+
# ======================
|
| 18 |
+
# PROJECT PATHS
|
| 19 |
+
# ======================
|
| 20 |
+
DEFAULT_PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 21 |
+
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", DEFAULT_PROJECT_ROOT)).resolve()
|
| 22 |
+
BASE_DIR = str(PROJECT_ROOT)
|
| 23 |
+
APP_DIR = Path(__file__).parent.resolve()
|
| 24 |
+
|
| 25 |
+
# ======================
|
| 26 |
+
# MODEL PATHS & HUGGING FACE CONFIGURATION
|
| 27 |
+
# ======================
|
| 28 |
+
HF_TOKEN = os.getenv("HF_TOKEN", "")
|
| 29 |
+
USE_HUGGING_FACE = (
|
| 30 |
+
os.getenv("USE_HUGGING_FACE", "").lower() in ("1", "true", "yes")
|
| 31 |
+
or bool(HF_TOKEN)
|
| 32 |
+
or bool(
|
| 33 |
+
os.getenv("HF_PLATE_DETECTOR_REPO")
|
| 34 |
+
and os.getenv("HF_CHAR_OCR_REPO")
|
| 35 |
+
)
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Hugging Face model IDs
|
| 39 |
+
HF_PLATE_DETECTOR_REPO = os.getenv("HF_PLATE_DETECTOR_REPO", "")
|
| 40 |
+
HF_CHAR_OCR_REPO = os.getenv("HF_CHAR_OCR_REPO", "")
|
| 41 |
+
HF_CAR_CLASSIFIER_REPO = os.getenv("HF_CAR_CLASSIFIER_REPO", "")
|
| 42 |
+
|
| 43 |
+
# Local fallback paths (used if HF token not provided)
|
| 44 |
+
MODELS_DIR = PROJECT_ROOT / "models"
|
| 45 |
+
PLATE_DETECTOR_MODEL_PATH = str(os.getenv("PLATE_DETECTOR_MODEL_PATH",
|
| 46 |
+
MODELS_DIR / "plate_detector" / "best.pt"))
|
| 47 |
+
CHAR_OCR_MODEL_PATH = str(os.getenv("CHAR_OCR_MODEL_PATH",
|
| 48 |
+
MODELS_DIR / "char_ocr" / "best.pt"))
|
| 49 |
+
CAR_CLASSIFIER_MODEL_PATH = str(os.getenv("CAR_CLASSIFIER_MODEL_PATH",
|
| 50 |
+
MODELS_DIR / "car_classifier" / "best.pt"))
|
| 51 |
+
|
| 52 |
+
# Debug: Print resolved paths
|
| 53 |
+
import logging
|
| 54 |
+
logger = logging.getLogger(__name__)
|
| 55 |
+
logger.info(f"Config - PROJECT_ROOT: {PROJECT_ROOT}")
|
| 56 |
+
logger.info(f"Config - MODELS_DIR: {MODELS_DIR}")
|
| 57 |
+
logger.info(f"Config - PLATE_DETECTOR_MODEL_PATH: {PLATE_DETECTOR_MODEL_PATH}")
|
| 58 |
+
|
| 59 |
+
# ======================
|
| 60 |
+
# MODEL INFERENCE SETTINGS
|
| 61 |
+
# ======================
|
| 62 |
+
CONF_THRESHOLD = 0.25 # Confidence threshold for YOLO detections
|
| 63 |
+
VARIANCE_THRESHOLD = 100.0 # Minimum variance to consider frame usable
|
| 64 |
+
|
| 65 |
+
# ======================
|
| 66 |
+
# IMAGE PREPROCESSING SETTINGS
|
| 67 |
+
# ======================
|
| 68 |
+
# Enhancer removed: EDSR settings deprecated
|
| 69 |
+
|
| 70 |
+
# ======================
|
| 71 |
+
# SCORING WEIGHTS
|
| 72 |
+
# ======================
|
| 73 |
+
SEGMENT_SCORE_WEIGHT = 0.65 # Weight for segment score in final calculation
|
| 74 |
+
PLATE_SCORE_WEIGHT = 0.35 # Weight for plate score in final calculation
|
| 75 |
+
|
| 76 |
+
# ======================
|
| 77 |
+
# DATABASE CONFIGURATION (Supabase)
|
| 78 |
+
# ======================
|
| 79 |
+
|
| 80 |
+
# Supabase configuration (NEW)
|
| 81 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "https://yoenbdxxdwknizqthkfp.supabase.co")
|
| 82 |
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
|
| 83 |
+
'''
|
| 84 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 85 |
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
|
| 86 |
+
'''
|
| 87 |
+
|
| 88 |
+
if not SUPABASE_SERVICE_KEY and not GRADIO_SPACE:
|
| 89 |
+
raise ValueError("SUPABASE_SERVICE_KEY not found in environment variables")
|
| 90 |
+
|
| 91 |
+
# ======================
|
| 92 |
+
# PLATE SCORING RULES
|
| 93 |
+
# ======================
|
| 94 |
+
PLATE_SCORING_RULES = {
|
| 95 |
+
"digit_count_weight": 0.3, # Lower digit count → higher score
|
| 96 |
+
"repeating_weight": 0.2, # Bonus for repeating patterns
|
| 97 |
+
"sequential_weight": 0.2, # Bonus for sequential patterns
|
| 98 |
+
"low_number_weight": 0.3, # Bonus for numbers < 100
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
# ======================
|
| 102 |
+
# VIDEO CAPTURE (pipeline VideoCapturer)
|
| 103 |
+
# ======================
|
| 104 |
+
CAPTURE_OUTPUT_DIR = PROJECT_ROOT / "src" / "capture" / "captured_frames"
|
| 105 |
+
CAPTURE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 106 |
+
|
| 107 |
+
CAPTURE_PROCESS_EVERY_N_FRAMES = int(os.getenv("CAPTURE_PROCESS_EVERY_N_FRAMES", "3"))
|
| 108 |
+
CAPTURE_FRAME_BUFFER_SIZE = int(os.getenv("CAPTURE_FRAME_BUFFER_SIZE", "20"))
|
| 109 |
+
CAPTURE_MOTION_THRESHOLD = int(os.getenv("CAPTURE_MOTION_THRESHOLD", "1500"))
|
| 110 |
+
CAPTURE_MOG2_HISTORY = int(os.getenv("CAPTURE_MOG2_HISTORY", "500"))
|
| 111 |
+
CAPTURE_MOG2_VAR_THRESHOLD = int(os.getenv("CAPTURE_MOG2_VAR_THRESHOLD", "50"))
|
| 112 |
+
CAPTURE_COOLDOWN_SECONDS = float(os.getenv("CAPTURE_COOLDOWN_SECONDS", "2"))
|
| 113 |
+
CAPTURE_TRIGGER_MODE = os.getenv("CAPTURE_TRIGGER_MODE", "yolo") # yolo | motion
|
| 114 |
+
CAPTURE_VEHICLE_MODEL = os.getenv("CAPTURE_VEHICLE_MODEL", "yolo11n.pt")
|
| 115 |
+
CAPTURE_VEHICLE_CONF = float(os.getenv("CAPTURE_VEHICLE_CONF", "0.40"))
|
| 116 |
+
CAPTURE_VEHICLE_MIN_BOX_AREA = int(os.getenv("CAPTURE_VEHICLE_MIN_BOX_AREA", "3000"))
|
| 117 |
+
CAPTURE_VEHICLE_CROP_PADDING = int(os.getenv("CAPTURE_VEHICLE_CROP_PADDING", "50"))
|
| 118 |
+
CAPTURE_VEHICLE_FILTER_ENABLED = os.getenv("CAPTURE_VEHICLE_FILTER_ENABLED", "true").lower() == "true"
|
| 119 |
+
CAPTURE_MAX_VEHICLES_PER_TRIGGER = int(os.getenv("CAPTURE_MAX_VEHICLES_PER_TRIGGER", "1"))
|
| 120 |
+
CAPTURE_MIN_SHARPNESS_SCORE = float(os.getenv("CAPTURE_MIN_SHARPNESS_SCORE", "5.0"))
|
| 121 |
+
CAPTURE_MIN_BRIGHTNESS = int(os.getenv("CAPTURE_MIN_BRIGHTNESS", "30"))
|
| 122 |
+
CAPTURE_MAX_BRIGHTNESS = int(os.getenv("CAPTURE_MAX_BRIGHTNESS", "220"))
|
| 123 |
+
CAPTURE_DEDUP_IOU = float(os.getenv("CAPTURE_DEDUP_IOU", "0.45"))
|
| 124 |
+
# Safety cap for POST /api/detect video uploads (None = no cap)
|
| 125 |
+
CAPTURE_MAX_TRIGGERS = int(os.getenv("CAPTURE_MAX_TRIGGERS", "30")) or None
|
| 126 |
+
|
| 127 |
+
# ======================
|
| 128 |
+
# ENSURE REQUIRED PATHS
|
| 129 |
+
# ======================
|
| 130 |
+
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
app/database/__pycache__/connection.cpython-314.pyc
ADDED
|
Binary file (4.51 kB). View file
|
|
|
app/database/__pycache__/logger.cpython-314.pyc
ADDED
|
Binary file (6.35 kB). View file
|
|
|
app/database/connection.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Supabase Database Connection Module
|
| 3 |
+
|
| 4 |
+
Handles connection to Supabase PostgreSQL database using the Supabase REST API.
|
| 5 |
+
Credentials are read from config.py environment variables.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
from ..config import SUPABASE_URL, SUPABASE_SERVICE_KEY
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Database:
|
| 13 |
+
"""
|
| 14 |
+
Supabase database connection manager.
|
| 15 |
+
|
| 16 |
+
Uses the Supabase REST API directly via requests.
|
| 17 |
+
Service key provides admin-level access from backend.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self, url: str = SUPABASE_URL, key: str = SUPABASE_SERVICE_KEY):
|
| 21 |
+
"""
|
| 22 |
+
Initialize Supabase connection.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
url: Supabase project URL.
|
| 26 |
+
key: Supabase service key (for server-side admin access).
|
| 27 |
+
|
| 28 |
+
Raises:
|
| 29 |
+
ValueError: If credentials are missing.
|
| 30 |
+
"""
|
| 31 |
+
if not url or not key:
|
| 32 |
+
raise ValueError("SUPABASE_URL and SUPABASE_SERVICE_KEY are required")
|
| 33 |
+
|
| 34 |
+
self.base_url = url.rstrip("/")
|
| 35 |
+
self.headers = {
|
| 36 |
+
"apikey": key,
|
| 37 |
+
"Authorization": f"Bearer {key}",
|
| 38 |
+
"Content-Type": "application/json"
|
| 39 |
+
}
|
| 40 |
+
self.session = requests.Session()
|
| 41 |
+
|
| 42 |
+
print(f"Supabase connection configured: {self.base_url}")
|
| 43 |
+
|
| 44 |
+
def close(self):
|
| 45 |
+
"""
|
| 46 |
+
Close the underlying HTTP session.
|
| 47 |
+
"""
|
| 48 |
+
self.session.close()
|
| 49 |
+
|
| 50 |
+
def close_all(self):
|
| 51 |
+
"""
|
| 52 |
+
Alias for backward compatibility with shutdown cleanup.
|
| 53 |
+
"""
|
| 54 |
+
self.close()
|
| 55 |
+
|
| 56 |
+
def insert(self, table: str, data: dict) -> list:
|
| 57 |
+
"""
|
| 58 |
+
Insert a record into a Supabase table and return the inserted row.
|
| 59 |
+
"""
|
| 60 |
+
url = f"{self.base_url}/rest/v1/{table}"
|
| 61 |
+
headers = self.headers.copy()
|
| 62 |
+
headers["Prefer"] = "return=representation"
|
| 63 |
+
|
| 64 |
+
response = self.session.post(url, json=data, headers=headers)
|
| 65 |
+
if response.status_code not in (200, 201):
|
| 66 |
+
raise RuntimeError(f"Supabase insert failed ({response.status_code}): {response.text}")
|
| 67 |
+
|
| 68 |
+
return response.json()
|
| 69 |
+
|
| 70 |
+
def query(self, table: str, select: str = "*", params: dict = None) -> list:
|
| 71 |
+
"""
|
| 72 |
+
Run a simple select query against a Supabase table.
|
| 73 |
+
"""
|
| 74 |
+
url = f"{self.base_url}/rest/v1/{table}"
|
| 75 |
+
query_params = {"select": select}
|
| 76 |
+
if params:
|
| 77 |
+
query_params.update(params)
|
| 78 |
+
|
| 79 |
+
response = self.session.get(url, headers=self.headers, params=query_params)
|
| 80 |
+
if response.status_code != 200:
|
| 81 |
+
raise RuntimeError(f"Supabase query failed ({response.status_code}): {response.text}")
|
| 82 |
+
|
| 83 |
+
return response.json()
|
app/database/logger.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Visit Logger Module
|
| 3 |
+
|
| 4 |
+
Logs detection results and scores to the database.
|
| 5 |
+
Records include: plate detection, character recognition, and scoring information.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import hashlib
|
| 10 |
+
|
| 11 |
+
from .connection import Database
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class VisitLogger:
|
| 15 |
+
"""
|
| 16 |
+
Logs visit records to the database.
|
| 17 |
+
|
| 18 |
+
Records are written to multiple tables:
|
| 19 |
+
- visits: Main visit record
|
| 20 |
+
- plate_scores: License plate prestige score
|
| 21 |
+
- segment_scores: Vehicle segment/class score
|
| 22 |
+
- purchasing_power: Final purchasing power metric
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, database: Database):
|
| 26 |
+
self.db = database
|
| 27 |
+
|
| 28 |
+
def log(self, plate_digits: str, plate_letters: str, plate_score: float,
|
| 29 |
+
segment: str, segment_score: float, purchasing_power: float,
|
| 30 |
+
car_brand: str = None, car_model: str = None,
|
| 31 |
+
timestamp: datetime = None,
|
| 32 |
+
camera_id: str = None) -> str:
|
| 33 |
+
|
| 34 |
+
if not plate_digits:
|
| 35 |
+
raise ValueError("plate_digits cannot be empty")
|
| 36 |
+
if not camera_id:
|
| 37 |
+
raise ValueError(
|
| 38 |
+
"camera_id is required — pass a valid camera UUID from the cameras table."
|
| 39 |
+
)
|
| 40 |
+
if not (0.0 <= plate_score <= 1.0):
|
| 41 |
+
raise ValueError(f"plate_score must be in [0.0, 1.0], got {plate_score}")
|
| 42 |
+
if not (0.0 <= segment_score <= 1.0):
|
| 43 |
+
raise ValueError(f"segment_score must be in [0.0, 1.0], got {segment_score}")
|
| 44 |
+
if not (0.0 <= purchasing_power <= 1.0):
|
| 45 |
+
raise ValueError(f"purchasing_power must be in [0.0, 1.0], got {purchasing_power}")
|
| 46 |
+
|
| 47 |
+
timestamp = timestamp or datetime.now()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
visit_id = self._insert_visit(timestamp, plate_digits, plate_letters, camera_id)
|
| 51 |
+
self._insert_plate_score(visit_id, plate_score)
|
| 52 |
+
self._insert_segment_score(visit_id, segment, segment_score)
|
| 53 |
+
self._insert_purchasing_power_score(visit_id, purchasing_power)
|
| 54 |
+
return visit_id
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise RuntimeError(f"Failed to log visit: {e}")
|
| 57 |
+
|
| 58 |
+
def _insert_visit(self, timestamp: datetime, plate_digits: str,
|
| 59 |
+
plate_letters: str, camera_id: str) -> str:
|
| 60 |
+
|
| 61 |
+
plate_hash = hashlib.sha256(
|
| 62 |
+
f"{plate_letters}{plate_digits}".encode("utf-8")
|
| 63 |
+
).hexdigest()
|
| 64 |
+
|
| 65 |
+
data = {
|
| 66 |
+
"camera_id": camera_id,
|
| 67 |
+
"plate_hash": plate_hash,
|
| 68 |
+
"plate_digits": plate_digits,
|
| 69 |
+
"plate_letters": plate_letters,
|
| 70 |
+
"visited_at": timestamp.isoformat()
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
response = self.db.insert("visits", data)
|
| 74 |
+
if not response or len(response) == 0:
|
| 75 |
+
raise RuntimeError("Failed to insert visit: no data returned")
|
| 76 |
+
|
| 77 |
+
return response[0]["id"]
|
| 78 |
+
|
| 79 |
+
def _insert_plate_score(self, visit_id: str, score: float) -> None:
|
| 80 |
+
data = {
|
| 81 |
+
"visit_id": visit_id,
|
| 82 |
+
"digit_length_score": score,
|
| 83 |
+
"repeat_score": 0,
|
| 84 |
+
"sequential_score": 0,
|
| 85 |
+
"low_number_score": 0,
|
| 86 |
+
"total_score": score
|
| 87 |
+
}
|
| 88 |
+
response = self.db.insert("plate_scores", data)
|
| 89 |
+
if not response or len(response) == 0:
|
| 90 |
+
raise RuntimeError("Failed to insert plate score")
|
| 91 |
+
|
| 92 |
+
def _insert_segment_score(self, visit_id: str, segment: str,
|
| 93 |
+
score: float) -> None:
|
| 94 |
+
data = {
|
| 95 |
+
"visit_id": visit_id,
|
| 96 |
+
"segment": segment,
|
| 97 |
+
"confidence": score,
|
| 98 |
+
"segment_score": score
|
| 99 |
+
}
|
| 100 |
+
response = self.db.insert("segment_scores", data)
|
| 101 |
+
if not response or len(response) == 0:
|
| 102 |
+
raise RuntimeError("Failed to insert segment score")
|
| 103 |
+
|
| 104 |
+
def _insert_purchasing_power_score(self, visit_id: str, score: float) -> None:
|
| 105 |
+
data = {
|
| 106 |
+
"visit_id": visit_id,
|
| 107 |
+
"plate_weight": 0,
|
| 108 |
+
"segment_weight": 0,
|
| 109 |
+
"final_score": score
|
| 110 |
+
}
|
| 111 |
+
response = self.db.insert("purchasing_power", data)
|
| 112 |
+
if not response or len(response) == 0:
|
| 113 |
+
raise RuntimeError("Failed to insert purchasing power score")
|
app/database/supabase/config.toml
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
project_id = "yoenbdxxdwknizqthkfp"
|
app/database/supabase/migrations/20260508175106_d61705db-2487-451d-ad0c-3940fa1fb5b0.sql
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
CREATE TABLE public.cameras (
|
| 3 |
+
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 4 |
+
location_name TEXT NOT NULL,
|
| 5 |
+
location_type TEXT NOT NULL,
|
| 6 |
+
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
| 7 |
+
);
|
| 8 |
+
|
| 9 |
+
CREATE TABLE public.visits (
|
| 10 |
+
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 11 |
+
camera_id UUID NOT NULL REFERENCES public.cameras(id) ON DELETE CASCADE,
|
| 12 |
+
plate_hash TEXT NOT NULL,
|
| 13 |
+
plate_digits TEXT NOT NULL,
|
| 14 |
+
plate_letters TEXT NOT NULL,
|
| 15 |
+
visited_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
| 16 |
+
);
|
| 17 |
+
CREATE INDEX idx_visits_camera ON public.visits(camera_id);
|
| 18 |
+
CREATE INDEX idx_visits_visited_at ON public.visits(visited_at DESC);
|
| 19 |
+
CREATE INDEX idx_visits_plate_hash ON public.visits(plate_hash);
|
| 20 |
+
|
| 21 |
+
CREATE TABLE public.plate_scores (
|
| 22 |
+
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 23 |
+
visit_id UUID NOT NULL REFERENCES public.visits(id) ON DELETE CASCADE,
|
| 24 |
+
digit_length_score NUMERIC NOT NULL DEFAULT 0,
|
| 25 |
+
repeat_score NUMERIC NOT NULL DEFAULT 0,
|
| 26 |
+
sequential_score NUMERIC NOT NULL DEFAULT 0,
|
| 27 |
+
low_number_score NUMERIC NOT NULL DEFAULT 0,
|
| 28 |
+
total_score NUMERIC NOT NULL DEFAULT 0
|
| 29 |
+
);
|
| 30 |
+
CREATE INDEX idx_plate_scores_visit ON public.plate_scores(visit_id);
|
| 31 |
+
|
| 32 |
+
CREATE TABLE public.segment_scores (
|
| 33 |
+
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 34 |
+
visit_id UUID NOT NULL REFERENCES public.visits(id) ON DELETE CASCADE,
|
| 35 |
+
segment TEXT NOT NULL,
|
| 36 |
+
confidence NUMERIC NOT NULL DEFAULT 0,
|
| 37 |
+
segment_score NUMERIC NOT NULL DEFAULT 0
|
| 38 |
+
);
|
| 39 |
+
CREATE INDEX idx_segment_scores_visit ON public.segment_scores(visit_id);
|
| 40 |
+
|
| 41 |
+
CREATE TABLE public.purchasing_power (
|
| 42 |
+
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
| 43 |
+
visit_id UUID NOT NULL REFERENCES public.visits(id) ON DELETE CASCADE,
|
| 44 |
+
plate_weight NUMERIC NOT NULL DEFAULT 0,
|
| 45 |
+
segment_weight NUMERIC NOT NULL DEFAULT 0,
|
| 46 |
+
final_score NUMERIC NOT NULL DEFAULT 0
|
| 47 |
+
);
|
| 48 |
+
CREATE INDEX idx_pp_visit ON public.purchasing_power(visit_id);
|
| 49 |
+
CREATE INDEX idx_pp_final_score ON public.purchasing_power(final_score DESC);
|
| 50 |
+
|
| 51 |
+
ALTER TABLE public.cameras ENABLE ROW LEVEL SECURITY;
|
| 52 |
+
ALTER TABLE public.visits ENABLE ROW LEVEL SECURITY;
|
| 53 |
+
ALTER TABLE public.plate_scores ENABLE ROW LEVEL SECURITY;
|
| 54 |
+
ALTER TABLE public.segment_scores ENABLE ROW LEVEL SECURITY;
|
| 55 |
+
ALTER TABLE public.purchasing_power ENABLE ROW LEVEL SECURITY;
|
| 56 |
+
|
| 57 |
+
CREATE POLICY "Public read cameras" ON public.cameras FOR SELECT USING (true);
|
| 58 |
+
CREATE POLICY "Public read visits" ON public.visits FOR SELECT USING (true);
|
| 59 |
+
CREATE POLICY "Public read plate_scores" ON public.plate_scores FOR SELECT USING (true);
|
| 60 |
+
CREATE POLICY "Public read segment_scores" ON public.segment_scores FOR SELECT USING (true);
|
| 61 |
+
CREATE POLICY "Public read purchasing_power" ON public.purchasing_power FOR SELECT USING (true);
|
app/main.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application entry point.
|
| 3 |
+
|
| 4 |
+
Start with:
|
| 5 |
+
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from contextlib import asynccontextmanager
|
| 10 |
+
|
| 11 |
+
from fastapi import FastAPI
|
| 12 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
+
|
| 14 |
+
logging.basicConfig(
|
| 15 |
+
level=logging.INFO,
|
| 16 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 17 |
+
)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(app: FastAPI):
|
| 23 |
+
"""Initialize resources on startup, clean up on shutdown."""
|
| 24 |
+
|
| 25 |
+
# ── Startup ───────────────────────────────────────────────────────────────
|
| 26 |
+
logger.info("Starting up...")
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from .pipeline.coordinator import LPRPipeline
|
| 30 |
+
app.state.pipeline = LPRPipeline()
|
| 31 |
+
logger.info("✓ Pipeline initialized")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Pipeline failed to initialize: {e}")
|
| 34 |
+
app.state.pipeline = None
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
from .database.connection import Database
|
| 38 |
+
from .database.logger import VisitLogger
|
| 39 |
+
db = Database()
|
| 40 |
+
app.state.db = db
|
| 41 |
+
app.state.visit_logger = VisitLogger(db)
|
| 42 |
+
logger.info("✓ Supabase connected")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Database failed to connect: {e}")
|
| 45 |
+
app.state.db = None
|
| 46 |
+
app.state.visit_logger = None
|
| 47 |
+
|
| 48 |
+
yield # app runs here
|
| 49 |
+
|
| 50 |
+
# ── Shutdown ──────────────────────────────────────────────────────────────
|
| 51 |
+
logger.info("Shutting down...")
|
| 52 |
+
if hasattr(app.state, "db") and app.state.db:
|
| 53 |
+
app.state.db.close()
|
| 54 |
+
if hasattr(app.state, "pipeline") and app.state.pipeline:
|
| 55 |
+
del app.state.pipeline
|
| 56 |
+
logger.info("Shutdown complete.")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── App ───────────────────────────────────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
app = FastAPI(
|
| 62 |
+
title="Vehicle Intelligence Dashboard — LPR API",
|
| 63 |
+
description="License plate detection, OCR, vehicle classification, and purchasing power scoring.",
|
| 64 |
+
version="1.0.0",
|
| 65 |
+
lifespan=lifespan,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# ── Middleware ────────────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
app.add_middleware(
|
| 71 |
+
CORSMiddleware,
|
| 72 |
+
allow_origins=[
|
| 73 |
+
"http://localhost:3000", # React CRA
|
| 74 |
+
"http://localhost:5173", # Vite
|
| 75 |
+
"http://localhost:8080", # your dashboard
|
| 76 |
+
],
|
| 77 |
+
allow_credentials=True,
|
| 78 |
+
allow_methods=["*"],
|
| 79 |
+
allow_headers=["*"],
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# ── Routes ────────────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
from .api.routes import detect, entries, stats, analytics, leaderboard, cameras
|
| 85 |
+
|
| 86 |
+
app.include_router(detect.router, prefix="/api")
|
| 87 |
+
app.include_router(entries.router, prefix="/api")
|
| 88 |
+
app.include_router(stats.router, prefix="/api")
|
| 89 |
+
app.include_router(analytics.router, prefix="/api")
|
| 90 |
+
app.include_router(leaderboard.router, prefix="/api")
|
| 91 |
+
app.include_router(cameras.router, prefix="/api")
|
| 92 |
+
|
| 93 |
+
# ── Health Check ──────────────────────────────────────────────────────────────
|
| 94 |
+
|
| 95 |
+
@app.get("/")
|
| 96 |
+
def root():
|
| 97 |
+
return {
|
| 98 |
+
"status": "ok",
|
| 99 |
+
"message": "Vehicle Intelligence Dashboard LPR API",
|
| 100 |
+
"docs": "/docs",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@app.get("/health")
|
| 104 |
+
def health():
|
| 105 |
+
return {
|
| 106 |
+
"pipeline": app.state.pipeline is not None,
|
| 107 |
+
"database": app.state.db is not None,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
import uvicorn
|
| 113 |
+
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
app/pipeline/__init__.py
ADDED
|
File without changes
|
app/pipeline/__pycache__/classifier.cpython-314.pyc
ADDED
|
Binary file (9.6 kB). View file
|
|
|
app/pipeline/__pycache__/coordinator.cpython-314.pyc
ADDED
|
Binary file (11.1 kB). View file
|
|
|
app/pipeline/__pycache__/detector.cpython-314.pyc
ADDED
|
Binary file (4.88 kB). View file
|
|
|
app/pipeline/__pycache__/ocr.cpython-314.pyc
ADDED
|
Binary file (5.79 kB). View file
|
|
|
app/pipeline/__pycache__/price_lookup.cpython-314.pyc
ADDED
|
Binary file (9.44 kB). View file
|
|
|
app/pipeline/__pycache__/scorer.cpython-314.pyc
ADDED
|
Binary file (5.04 kB). View file
|
|
|
app/pipeline/__pycache__/special_plate.cpython-314.pyc
ADDED
|
Binary file (11.8 kB). View file
|
|
|
app/pipeline/__pycache__/video_capture.cpython-314.pyc
ADDED
|
Binary file (16.4 kB). View file
|
|
|
app/pipeline/__pycache__/video_capturer.cpython-314.pyc
ADDED
|
Binary file (16.1 kB). View file
|
|
|
app/pipeline/classifier.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vehicle Classifier
|
| 3 |
+
|
| 4 |
+
Loads the EfficientNetV2-S car generation classifier and returns vehicle
|
| 5 |
+
segment predictions in the same style as app/predict.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import csv
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Optional
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
from PIL import Image
|
| 19 |
+
from torchvision import transforms
|
| 20 |
+
from torchvision.models import efficientnet_v2_s
|
| 21 |
+
|
| 22 |
+
from ..config import PROJECT_ROOT
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
IMG_SIZE = 300
|
| 27 |
+
IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
| 28 |
+
IMAGENET_STD = [0.229, 0.224, 0.225]
|
| 29 |
+
DEFAULT_PRICE_CSV = PROJECT_ROOT / "datasets" / "car_recognition" / "hatla2ee_cars_unique.csv"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _load_hf_asset(repo_id: str, filename: str, token: Optional[str]) -> Path:
|
| 33 |
+
try:
|
| 34 |
+
from huggingface_hub import hf_hub_download
|
| 35 |
+
except ImportError as exc:
|
| 36 |
+
raise RuntimeError(
|
| 37 |
+
"huggingface_hub is required for HF model downloads. "
|
| 38 |
+
"Install it with `pip install huggingface_hub`."
|
| 39 |
+
) from exc
|
| 40 |
+
|
| 41 |
+
return Path(hf_hub_download(repo_id=repo_id, filename=filename, token=token))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class Classifier:
|
| 45 |
+
"""
|
| 46 |
+
Classifies a vehicle image into one of 1240 classes using EfficientNetV2-S.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
model_path: Optional[str] = None,
|
| 52 |
+
class_names_path: Optional[str] = None,
|
| 53 |
+
price_csv_path: Path = DEFAULT_PRICE_CSV,
|
| 54 |
+
hf_repo: Optional[str] = None,
|
| 55 |
+
token: Optional[str] = None,
|
| 56 |
+
):
|
| 57 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 58 |
+
logger.info(f"Classifier initializing on device: {self.device}")
|
| 59 |
+
|
| 60 |
+
if hf_repo:
|
| 61 |
+
logger.info(f"Downloading classifier assets from HF Hub: {hf_repo}")
|
| 62 |
+
model_path = str(_load_hf_asset(hf_repo, "best.pt", token))
|
| 63 |
+
class_names_path = str(_load_hf_asset(hf_repo, "class_names.json", token))
|
| 64 |
+
|
| 65 |
+
if not model_path or not os.path.exists(model_path):
|
| 66 |
+
raise FileNotFoundError(f"Classifier model weights not found at: {model_path}")
|
| 67 |
+
if not class_names_path or not os.path.exists(class_names_path):
|
| 68 |
+
raise FileNotFoundError(f"Class names JSON file not found at: {class_names_path}")
|
| 69 |
+
|
| 70 |
+
with open(class_names_path, "r", encoding="utf-8") as f:
|
| 71 |
+
self.class_names = json.load(f)
|
| 72 |
+
|
| 73 |
+
num_classes = len(self.class_names)
|
| 74 |
+
|
| 75 |
+
self.model = efficientnet_v2_s()
|
| 76 |
+
self.model.classifier[1] = nn.Linear(self.model.classifier[1].in_features, num_classes)
|
| 77 |
+
|
| 78 |
+
state_dict = torch.load(model_path, map_location=self.device)
|
| 79 |
+
|
| 80 |
+
# Check if the state_dict is nested inside a metadata dictionary wrapper
|
| 81 |
+
if isinstance(state_dict, dict) and "model_state" in state_dict:
|
| 82 |
+
logger.info("Unpacking nested 'model_state' from model weights file wrapper.")
|
| 83 |
+
state_dict = state_dict["model_state"]
|
| 84 |
+
|
| 85 |
+
self.model.load_state_dict(state_dict)
|
| 86 |
+
self.model.to(self.device)
|
| 87 |
+
self.model.eval()
|
| 88 |
+
|
| 89 |
+
self.transform = transforms.Compose([
|
| 90 |
+
transforms.Resize((IMG_SIZE, IMG_SIZE)),
|
| 91 |
+
transforms.ToTensor(),
|
| 92 |
+
transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
|
| 93 |
+
])
|
| 94 |
+
|
| 95 |
+
self.price_lookup = {}
|
| 96 |
+
if price_csv_path.exists():
|
| 97 |
+
with open(price_csv_path, mode="r", encoding="utf-8-sig") as f:
|
| 98 |
+
reader = csv.DictReader(f)
|
| 99 |
+
for row in reader:
|
| 100 |
+
c_name = row.get("class_name", "").strip()
|
| 101 |
+
try:
|
| 102 |
+
price_val = float(row.get("price", 0))
|
| 103 |
+
except ValueError:
|
| 104 |
+
price_val = 0.0
|
| 105 |
+
if c_name:
|
| 106 |
+
self.price_lookup[c_name] = price_val
|
| 107 |
+
logger.info(f"Loaded price lookup for {len(self.price_lookup)} classes")
|
| 108 |
+
else:
|
| 109 |
+
logger.warning(f"Price lookup table CSV not found at: {price_csv_path}")
|
| 110 |
+
|
| 111 |
+
@staticmethod
|
| 112 |
+
def _parse_class_name(class_name: str) -> tuple[str, str]:
|
| 113 |
+
parts = class_name.split("_")
|
| 114 |
+
if len(parts) >= 4:
|
| 115 |
+
brand = parts[0]
|
| 116 |
+
model_name = "_".join(parts[1:-2])
|
| 117 |
+
elif len(parts) == 3:
|
| 118 |
+
brand, model_name = parts[0], parts[1]
|
| 119 |
+
else:
|
| 120 |
+
brand, model_name = class_name, "unknown"
|
| 121 |
+
return brand, model_name
|
| 122 |
+
|
| 123 |
+
def classify(self, frame: Any) -> dict[str, Any]:
|
| 124 |
+
"""
|
| 125 |
+
Expects a numpy ndarray image in standard RGB format.
|
| 126 |
+
Returns segment name, confidence score, car brand, and car model.
|
| 127 |
+
"""
|
| 128 |
+
if frame is None:
|
| 129 |
+
raise ValueError("Frame must not be None")
|
| 130 |
+
if self.model is None:
|
| 131 |
+
raise RuntimeError("Classifier model is not loaded")
|
| 132 |
+
if not isinstance(frame, np.ndarray):
|
| 133 |
+
raise ValueError("Frame must be a numpy ndarray")
|
| 134 |
+
|
| 135 |
+
# Core Fix: Accept explicit RGB matrix frame arrays directly
|
| 136 |
+
pil = Image.fromarray(frame)
|
| 137 |
+
tensor = self.transform(pil).unsqueeze(0).to(self.device)
|
| 138 |
+
|
| 139 |
+
with torch.no_grad():
|
| 140 |
+
logits = self.model(tensor)
|
| 141 |
+
probs = torch.softmax(logits, dim=1)
|
| 142 |
+
top_prob, top_idx = probs.topk(1, dim=1)
|
| 143 |
+
|
| 144 |
+
idx = int(top_idx[0, 0].item())
|
| 145 |
+
confidence = float(top_prob[0, 0].item())
|
| 146 |
+
segment = self.class_names[idx]
|
| 147 |
+
brand, model_name = self._parse_class_name(segment)
|
| 148 |
+
estimated_price = self.price_lookup.get(segment, None)
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"segment": segment,
|
| 152 |
+
"confidence": confidence,
|
| 153 |
+
"car_brand": brand,
|
| 154 |
+
"car_model": model_name,
|
| 155 |
+
"estimated_price": estimated_price,
|
| 156 |
+
}
|
app/pipeline/coordinator.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LPR Pipeline Coordinator
|
| 3 |
+
"""
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import cv2 as cv
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .detector import PlateDetector
|
| 12 |
+
from .ocr import OCR
|
| 13 |
+
from .classifier import Classifier
|
| 14 |
+
from .scorer import Scorer
|
| 15 |
+
from .special_plate import SpecialPlateAnalyzer
|
| 16 |
+
from .video_capture import VideoCapturer, CaptureSettings
|
| 17 |
+
from ..config import (
|
| 18 |
+
PLATE_DETECTOR_MODEL_PATH,
|
| 19 |
+
CHAR_OCR_MODEL_PATH,
|
| 20 |
+
CONF_THRESHOLD,
|
| 21 |
+
HF_TOKEN,
|
| 22 |
+
USE_HUGGING_FACE,
|
| 23 |
+
HF_PLATE_DETECTOR_REPO,
|
| 24 |
+
HF_CHAR_OCR_REPO,
|
| 25 |
+
HF_CAR_CLASSIFIER_REPO,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
class LPRPipeline:
|
| 31 |
+
def __init__(self, use_hugging_face: bool = True):
|
| 32 |
+
logger.info("Initializing LPR pipeline...")
|
| 33 |
+
self.detector = None
|
| 34 |
+
self.ocr = None
|
| 35 |
+
self.classifier = None
|
| 36 |
+
self.scorer = Scorer()
|
| 37 |
+
self.special_plate = SpecialPlateAnalyzer()
|
| 38 |
+
self.capturer = VideoCapturer()
|
| 39 |
+
use_hf = USE_HUGGING_FACE and use_hugging_face
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
self.detector = PlateDetector(
|
| 43 |
+
hf_repo=HF_PLATE_DETECTOR_REPO if use_hf else None,
|
| 44 |
+
model_path=None if use_hf else PLATE_DETECTOR_MODEL_PATH,
|
| 45 |
+
conf_threshold=CONF_THRESHOLD,
|
| 46 |
+
)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.exception(f"Failed to load PlateDetector: {e}")
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
self.ocr = OCR(
|
| 52 |
+
hf_repo=HF_CHAR_OCR_REPO if use_hf else None,
|
| 53 |
+
model_path=None if use_hf else CHAR_OCR_MODEL_PATH,
|
| 54 |
+
conf_threshold=CONF_THRESHOLD,
|
| 55 |
+
)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.exception(f"Failed to load OCR model: {e}")
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
self.classifier = Classifier(
|
| 61 |
+
hf_repo=HF_CAR_CLASSIFIER_REPO,
|
| 62 |
+
token=HF_TOKEN, # Fixed kwarg name based on your classifier class
|
| 63 |
+
)
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.exception(f"Failed to load Classifier: {e}")
|
| 66 |
+
self.classifier = None
|
| 67 |
+
|
| 68 |
+
if not self.detector or not self.ocr:
|
| 69 |
+
raise RuntimeError("Required pipeline components failed to load")
|
| 70 |
+
|
| 71 |
+
logger.info("✓ Pipeline ready")
|
| 72 |
+
|
| 73 |
+
def process(self, frame: np.ndarray) -> dict[str, Any]:
|
| 74 |
+
if frame is None or frame.size == 0:
|
| 75 |
+
return self._error("Invalid frame — None or empty.")
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
bboxes = self.detector.detect(frame)
|
| 79 |
+
|
| 80 |
+
if not bboxes:
|
| 81 |
+
return {"success": True, "num_plates": 0, "detections": []}
|
| 82 |
+
|
| 83 |
+
class_info = self._classify_vehicle(frame)
|
| 84 |
+
segment = class_info["segment"]
|
| 85 |
+
segment_score = self.scorer.score_segment(segment)
|
| 86 |
+
visit_time = datetime.now()
|
| 87 |
+
|
| 88 |
+
detections = []
|
| 89 |
+
|
| 90 |
+
for idx, bbox in enumerate(bboxes):
|
| 91 |
+
try:
|
| 92 |
+
x1, y1, x2, y2 = map(int, bbox)
|
| 93 |
+
cropped = frame[y1:y2, x1:x2]
|
| 94 |
+
|
| 95 |
+
if cropped is None or cropped.size == 0:
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
# OCR (Check if using read_with_details or get_text)
|
| 99 |
+
ocr_details = self.ocr.get_text(cropped) if hasattr(self.ocr, 'get_text') else self.ocr.read_with_details(cropped)
|
| 100 |
+
plate_string = ocr_details["plate_string"] or "UNKNOWN"
|
| 101 |
+
|
| 102 |
+
digits = ocr_details.get("numbers", [])
|
| 103 |
+
digit_str = "".join(digits)
|
| 104 |
+
plate_score = self.scorer.score_plate(digit_str) if digit_str else 0.0
|
| 105 |
+
|
| 106 |
+
# Special plate analysis
|
| 107 |
+
special_info = self.special_plate.analyze(plate_string, digits)
|
| 108 |
+
|
| 109 |
+
power_data = self.scorer.calculate_purchasing_power(
|
| 110 |
+
plate_score=plate_score,
|
| 111 |
+
segment_score=segment_score,
|
| 112 |
+
visit_time=visit_time,
|
| 113 |
+
estimated_price=class_info.get("estimated_price", 0.0),
|
| 114 |
+
is_special_plate=special_info["is_special_plate"],
|
| 115 |
+
segment_name=segment
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# ── ADD THIS OVERRIDE ──
|
| 119 |
+
# Update the dictionary so the Gradio UI receives the fallback price
|
| 120 |
+
class_info["estimated_price"] = power_data["final_price"]
|
| 121 |
+
|
| 122 |
+
detections.append({
|
| 123 |
+
"special_plate_score": special_info.get("special_plate_score", 0.0),
|
| 124 |
+
"special_plate_level": special_info.get("special_plate_level", "normal"),
|
| 125 |
+
"is_special_plate": special_info.get("is_special_plate", False),
|
| 126 |
+
"plate_string": plate_string,
|
| 127 |
+
"digits": digit_str,
|
| 128 |
+
"letters": "".join(ocr_details.get("letters", [])),
|
| 129 |
+
"plate_score": plate_score,
|
| 130 |
+
"segment": segment,
|
| 131 |
+
"segment_score": segment_score,
|
| 132 |
+
"car_brand": class_info.get("car_brand", "unknown"),
|
| 133 |
+
"car_model": class_info.get("car_model", "unknown"),
|
| 134 |
+
"estimated_price": class_info.get("estimated_price", 0.0),
|
| 135 |
+
"purchasing_power": power_data["pp_score"], # The numeric score
|
| 136 |
+
"pp_tier": power_data["pp_tier"], # The Elite/High tier
|
| 137 |
+
"bbox": bbox,
|
| 138 |
+
"visit_time": visit_time.isoformat(),
|
| 139 |
+
"visit_id": None,
|
| 140 |
+
"num_detections": ocr_details.get("num_detections", 1),
|
| 141 |
+
})
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
logger.error(f"Error processing plate {idx}: {e}")
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
"success": True,
|
| 149 |
+
"num_plates": len(detections),
|
| 150 |
+
"detections": detections,
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.exception(f"Pipeline error: {e}")
|
| 155 |
+
return self._error(str(e))
|
| 156 |
+
|
| 157 |
+
def process_video(
|
| 158 |
+
self,
|
| 159 |
+
source: str | int,
|
| 160 |
+
capture_settings: CaptureSettings | None = None,
|
| 161 |
+
) -> dict[str, Any]:
|
| 162 |
+
|
| 163 |
+
# Native safe fallback since VideoCapturer method signature changed previously
|
| 164 |
+
try:
|
| 165 |
+
logger.info(f"Opening video source natively: {source}")
|
| 166 |
+
cap = cv.VideoCapture(source)
|
| 167 |
+
if not cap.isOpened():
|
| 168 |
+
return self._error(f"Failed to open video file: {source}")
|
| 169 |
+
|
| 170 |
+
all_detections = []
|
| 171 |
+
frame_count = 0
|
| 172 |
+
processed_count = 0
|
| 173 |
+
step_frames = 12
|
| 174 |
+
|
| 175 |
+
while True:
|
| 176 |
+
ret, frame = cap.read()
|
| 177 |
+
if not ret: break
|
| 178 |
+
|
| 179 |
+
if frame_count % step_frames == 0:
|
| 180 |
+
frame_result = self.process(frame)
|
| 181 |
+
plates = frame_result.get("detections", [])
|
| 182 |
+
|
| 183 |
+
for p in plates:
|
| 184 |
+
p.update({
|
| 185 |
+
"visit_time": datetime.now().isoformat(),
|
| 186 |
+
"vehicle_index": processed_count,
|
| 187 |
+
"frame_index": frame_count,
|
| 188 |
+
})
|
| 189 |
+
all_detections.append(p)
|
| 190 |
+
processed_count += 1
|
| 191 |
+
frame_count += 1
|
| 192 |
+
cap.release()
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"success": True,
|
| 196 |
+
"num_vehicles_processed": processed_count,
|
| 197 |
+
"num_plates": len(all_detections),
|
| 198 |
+
"detections": all_detections,
|
| 199 |
+
"vehicle_events": [],
|
| 200 |
+
}
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.exception(f"process_video native error: {e}")
|
| 203 |
+
return self._error(str(e))
|
| 204 |
+
|
| 205 |
+
def _classify_vehicle(self, frame: np.ndarray) -> dict:
|
| 206 |
+
"""
|
| 207 |
+
Run car classifier safely. Ensures estimated_price is 0.0 rather than missing.
|
| 208 |
+
"""
|
| 209 |
+
fallback = {"segment": "unknown", "car_brand": "unknown", "car_model": "unknown", "estimated_price": 0.0}
|
| 210 |
+
|
| 211 |
+
if self.classifier is None:
|
| 212 |
+
return fallback
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
# Color conversion applied for accurate model representation
|
| 216 |
+
frame_rgb = cv.cvtColor(frame, cv.COLOR_BGR2RGB)
|
| 217 |
+
result = self.classifier.classify(frame_rgb)
|
| 218 |
+
# Ensure price key exists
|
| 219 |
+
if "estimated_price" not in result or result["estimated_price"] is None:
|
| 220 |
+
result["estimated_price"] = 0.0
|
| 221 |
+
return result
|
| 222 |
+
except Exception as e:
|
| 223 |
+
logger.warning(f"Classifier failed, defaulting to unknown: {e}")
|
| 224 |
+
return fallback
|
| 225 |
+
|
| 226 |
+
@staticmethod
|
| 227 |
+
def _error(message: str) -> dict:
|
| 228 |
+
return {"success": False, "num_plates": 0, "detections": [], "error": message}
|
app/pipeline/corrector.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2 as cv
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def order_points(pts: np.ndarray) -> np.ndarray:
|
| 6 |
+
"""Order four points as top-left, top-right, bottom-right, bottom-left."""
|
| 7 |
+
rect = np.zeros((4, 2), dtype="float32")
|
| 8 |
+
s = pts.sum(axis=1)
|
| 9 |
+
diff = np.diff(pts, axis=1)
|
| 10 |
+
|
| 11 |
+
rect[0] = pts[np.argmin(s)] # top-left — smallest sum
|
| 12 |
+
rect[2] = pts[np.argmax(s)] # bottom-right — largest sum
|
| 13 |
+
rect[1] = pts[np.argmin(diff)] # top-right — smallest difference
|
| 14 |
+
rect[3] = pts[np.argmax(diff)] # bottom-left — largest difference
|
| 15 |
+
|
| 16 |
+
return rect
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def correct_perspective(image: np.ndarray, box_xyxy: list | np.ndarray) -> np.ndarray:
|
| 20 |
+
"""
|
| 21 |
+
Apply perspective correction to a detected plate region.
|
| 22 |
+
|
| 23 |
+
Takes the bounding box from model.predict() in xyxy format and returns
|
| 24 |
+
a flat, front-facing crop of the plate. If the plate appears axis-aligned
|
| 25 |
+
(no meaningful tilt), returns a simple rectangular crop instead.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
image: Full BGR image as a numpy array.
|
| 29 |
+
box_xyxy: [x1, y1, x2, y2] pixel coordinates from YOLO detection.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Corrected plate crop as a BGR numpy array.
|
| 33 |
+
"""
|
| 34 |
+
x1, y1, x2, y2 = [int(v) for v in box_xyxy]
|
| 35 |
+
|
| 36 |
+
# Clamp to image bounds
|
| 37 |
+
h, w = image.shape[:2]
|
| 38 |
+
x1 = max(0, x1)
|
| 39 |
+
y1 = max(0, y1)
|
| 40 |
+
x2 = min(w, x2)
|
| 41 |
+
y2 = min(h, y2)
|
| 42 |
+
|
| 43 |
+
plate_crop = image[y1:y2, x1:x2]
|
| 44 |
+
|
| 45 |
+
if plate_crop.size == 0:
|
| 46 |
+
return plate_crop
|
| 47 |
+
|
| 48 |
+
# Try to find a quadrilateral contour in the crop for perspective warping
|
| 49 |
+
quad = _find_plate_quadrilateral(plate_crop)
|
| 50 |
+
|
| 51 |
+
if quad is None:
|
| 52 |
+
# No tilt detected — return the rectangular crop as-is
|
| 53 |
+
return plate_crop
|
| 54 |
+
|
| 55 |
+
return _warp_to_rect(plate_crop, quad)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _find_plate_quadrilateral(plate_crop: np.ndarray) -> np.ndarray | None:
|
| 59 |
+
"""
|
| 60 |
+
Detect the four corners of the plate within the crop.
|
| 61 |
+
|
| 62 |
+
Returns a (4, 2) float32 array of corner points if a clear quadrilateral
|
| 63 |
+
is found, or None if the plate appears flat and needs no correction.
|
| 64 |
+
"""
|
| 65 |
+
gray = cv.cvtColor(plate_crop, cv.COLOR_BGR2GRAY)
|
| 66 |
+
blurred = cv.GaussianBlur(gray, (5, 5), 0)
|
| 67 |
+
edges = cv.Canny(blurred, threshold1=50, threshold2=150)
|
| 68 |
+
|
| 69 |
+
# Dilate edges slightly to close small gaps
|
| 70 |
+
kernel = cv.getStructuringElement(cv.MORPH_RECT, (3, 3))
|
| 71 |
+
edges = cv.dilate(edges, kernel, iterations=1)
|
| 72 |
+
|
| 73 |
+
contours, _ = cv.findContours(edges, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)
|
| 74 |
+
|
| 75 |
+
if not contours:
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
# Take the largest contour by area
|
| 79 |
+
largest = max(contours, key=cv.contourArea)
|
| 80 |
+
peri = cv.arcLength(largest, closed=True)
|
| 81 |
+
approx = cv.approxPolyDP(largest, epsilon=0.02 * peri, closed=True)
|
| 82 |
+
|
| 83 |
+
plate_h, plate_w = plate_crop.shape[:2]
|
| 84 |
+
plate_area = plate_h * plate_w
|
| 85 |
+
|
| 86 |
+
# Only proceed if we found a quadrilateral covering most of the crop
|
| 87 |
+
if len(approx) != 4:
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
if cv.contourArea(approx) < 0.3 * plate_area:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
return approx.reshape(4, 2).astype("float32")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _warp_to_rect(image: np.ndarray, pts: np.ndarray) -> np.ndarray:
|
| 97 |
+
"""
|
| 98 |
+
Warp the detected quadrilateral to a flat rectangle.
|
| 99 |
+
|
| 100 |
+
Computes output dimensions from the detected corner points so the
|
| 101 |
+
aspect ratio of the corrected plate is preserved.
|
| 102 |
+
"""
|
| 103 |
+
rect = order_points(pts)
|
| 104 |
+
tl, tr, br, bl = rect
|
| 105 |
+
|
| 106 |
+
# Output width — max of top and bottom edge lengths
|
| 107 |
+
width_top = np.linalg.norm(tr - tl)
|
| 108 |
+
width_bottom = np.linalg.norm(br - bl)
|
| 109 |
+
out_w = int(max(width_top, width_bottom))
|
| 110 |
+
|
| 111 |
+
# Output height — max of left and right edge lengths
|
| 112 |
+
height_left = np.linalg.norm(bl - tl)
|
| 113 |
+
height_right = np.linalg.norm(br - tr)
|
| 114 |
+
out_h = int(max(height_left, height_right))
|
| 115 |
+
|
| 116 |
+
if out_w == 0 or out_h == 0:
|
| 117 |
+
return image
|
| 118 |
+
|
| 119 |
+
dst = np.array([
|
| 120 |
+
[0, 0 ],
|
| 121 |
+
[out_w - 1, 0 ],
|
| 122 |
+
[out_w - 1, out_h - 1],
|
| 123 |
+
[0, out_h - 1],
|
| 124 |
+
], dtype="float32")
|
| 125 |
+
|
| 126 |
+
M = cv.getPerspectiveTransform(rect, dst)
|
| 127 |
+
warped = cv.warpPerspective(image, M, (out_w, out_h))
|
| 128 |
+
|
| 129 |
+
return warped
|
app/pipeline/detector.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Plate Region Detection Module
|
| 3 |
+
|
| 4 |
+
Detects license plate regions in video frames using a fine-tuned YOLOv11n model.
|
| 5 |
+
Loads the model once at initialization and reuses it for all subsequent frames.
|
| 6 |
+
Cropping and perspective correction are handled downstream by corrector.py.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from ultralytics import YOLO
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from huggingface_hub import hf_hub_download
|
| 13 |
+
|
| 14 |
+
from ..config import PLATE_DETECTOR_MODEL_PATH, CONF_THRESHOLD
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PlateDetector:
|
| 18 |
+
"""
|
| 19 |
+
Detects license plate bounding boxes in a frame using a YOLO model.
|
| 20 |
+
|
| 21 |
+
Attributes:
|
| 22 |
+
model: Loaded YOLO model, initialized once.
|
| 23 |
+
conf_threshold: Minimum confidence score to accept a detection.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
model_path: str = None,
|
| 29 |
+
hf_repo: str = None,
|
| 30 |
+
conf_threshold: float = CONF_THRESHOLD,
|
| 31 |
+
):
|
| 32 |
+
"""
|
| 33 |
+
Initialize the PlateDetector.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
model_path: Path to local YOLO model file.
|
| 37 |
+
hf_repo: Hugging Face repo ID (e.g., 'username/model_name').
|
| 38 |
+
conf_threshold: Confidence threshold for detections (0.0 to 1.0).
|
| 39 |
+
|
| 40 |
+
Raises:
|
| 41 |
+
FileNotFoundError: If the model file does not exist at model_path.
|
| 42 |
+
"""
|
| 43 |
+
if hf_repo:
|
| 44 |
+
# Download from Hugging Face
|
| 45 |
+
model_path = hf_hub_download(
|
| 46 |
+
repo_id=hf_repo,
|
| 47 |
+
filename="best.pt",
|
| 48 |
+
token=True, # Uses HF_TOKEN from environment
|
| 49 |
+
)
|
| 50 |
+
elif model_path is None:
|
| 51 |
+
model_path = PLATE_DETECTOR_MODEL_PATH
|
| 52 |
+
|
| 53 |
+
if not Path(model_path).exists():
|
| 54 |
+
raise FileNotFoundError(f"Plate detector model not found: {model_path}")
|
| 55 |
+
|
| 56 |
+
self.model = YOLO(model_path)
|
| 57 |
+
self.conf_threshold = conf_threshold
|
| 58 |
+
|
| 59 |
+
def detect(self, frame: np.ndarray) -> list[list[float]]:
|
| 60 |
+
"""
|
| 61 |
+
Detect license plate regions in a frame.
|
| 62 |
+
|
| 63 |
+
Does not crop or correct perspective — returns raw bounding boxes only.
|
| 64 |
+
Pass each box to corrector.correct_perspective() in the pipeline.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
frame: BGR image as a numpy array.
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
List of bounding boxes, each as [x1, y1, x2, y2] in pixel coordinates.
|
| 71 |
+
Empty list if no plates are detected or frame fails quality check.
|
| 72 |
+
|
| 73 |
+
Raises:
|
| 74 |
+
ValueError: If the frame is None or has zero size.
|
| 75 |
+
"""
|
| 76 |
+
if frame is None or frame.size == 0:
|
| 77 |
+
raise ValueError("Invalid input frame — frame is None or empty.")
|
| 78 |
+
|
| 79 |
+
if not self._is_frame_usable(frame):
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
results = self.model.predict(frame, conf=self.conf_threshold, verbose=False)
|
| 83 |
+
boxes_xyxy = results[0].boxes.xyxy
|
| 84 |
+
|
| 85 |
+
return [box.tolist() for box in boxes_xyxy]
|
| 86 |
+
|
| 87 |
+
@staticmethod
|
| 88 |
+
def _is_frame_usable(
|
| 89 |
+
frame: np.ndarray,
|
| 90 |
+
variance_threshold: float = 100.0,
|
| 91 |
+
) -> bool:
|
| 92 |
+
"""
|
| 93 |
+
Return True if the frame has enough contrast and detail to process.
|
| 94 |
+
|
| 95 |
+
Rejects frames that are too dark, too bright, or heavily occluded.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
frame: BGR image as a numpy array.
|
| 99 |
+
variance_threshold: Minimum pixel variance to accept the frame.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
True if the frame is usable, False otherwise.
|
| 103 |
+
"""
|
| 104 |
+
import cv2 as cv
|
| 105 |
+
gray = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
|
| 106 |
+
return float(np.var(gray)) >= variance_threshold
|
app/pipeline/enhancer.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Enhancement Module
|
| 3 |
+
|
| 4 |
+
Applies preprocessing to improve OCR accuracy:
|
| 5 |
+
- Noise reduction (bilateral filter)
|
| 6 |
+
- Contrast enhancement (CLAHE)
|
| 7 |
+
- Optional upscaling for small plates
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import cv2 as cv
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def enhance(plate_img: np.ndarray) -> np.ndarray:
|
| 15 |
+
"""
|
| 16 |
+
Enhance a plate image to improve OCR accuracy.
|
| 17 |
+
|
| 18 |
+
Applies:
|
| 19 |
+
1. Bilateral filtering for noise reduction while preserving edges
|
| 20 |
+
2. CLAHE (Contrast Limited Adaptive Histogram Equalization)
|
| 21 |
+
3. Morphological operations if needed
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
plate_img: BGR plate image as a numpy array.
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Enhanced plate image as a numpy array.
|
| 28 |
+
|
| 29 |
+
Raises:
|
| 30 |
+
ValueError: If the input image is None or empty.
|
| 31 |
+
"""
|
| 32 |
+
if plate_img is None or plate_img.size == 0:
|
| 33 |
+
raise ValueError("Invalid input plate image — None or empty.")
|
| 34 |
+
|
| 35 |
+
# Convert to grayscale for processing
|
| 36 |
+
if len(plate_img.shape) == 3 and plate_img.shape[2] == 3:
|
| 37 |
+
gray = cv.cvtColor(plate_img, cv.COLOR_BGR2GRAY)
|
| 38 |
+
else:
|
| 39 |
+
gray = plate_img
|
| 40 |
+
|
| 41 |
+
# 1. Bilateral filtering — reduces noise while preserving edges
|
| 42 |
+
filtered = cv.bilateralFilter(gray, d=7, sigmaColor=75, sigmaSpace=75)
|
| 43 |
+
|
| 44 |
+
# 2. CLAHE — adaptive histogram equalization for better contrast
|
| 45 |
+
clahe = cv.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 46 |
+
enhanced = clahe.apply(filtered)
|
| 47 |
+
|
| 48 |
+
# 3. Optional morphological operations for small or degraded plates
|
| 49 |
+
# Apply a slight morphological closing to fill small holes
|
| 50 |
+
kernel = cv.getStructuringElement(cv.MORPH_RECT, (2, 2))
|
| 51 |
+
enhanced = cv.morphologyEx(enhanced, cv.MORPH_CLOSE, kernel, iterations=1)
|
| 52 |
+
|
| 53 |
+
# 4. Upscale if the plate is too small (less than 100 pixels wide)
|
| 54 |
+
if enhanced.shape[1] < 100:
|
| 55 |
+
scale_factor = max(1.5, 100 / enhanced.shape[1])
|
| 56 |
+
new_width = int(enhanced.shape[1] * scale_factor)
|
| 57 |
+
new_height = int(enhanced.shape[0] * scale_factor)
|
| 58 |
+
enhanced = cv.resize(enhanced, (new_width, new_height), interpolation=cv.INTER_CUBIC)
|
| 59 |
+
|
| 60 |
+
# 5. Thresholding for better character separation (optional, can be aggressive)
|
| 61 |
+
# Using Otsu's thresholding for automatic threshold selection
|
| 62 |
+
_, enhanced = cv.threshold(enhanced, 0, 255, cv.THRESH_BINARY + cv.THRESH_OTSU)
|
| 63 |
+
|
| 64 |
+
# Convert back to BGR to maintain consistency with pipeline
|
| 65 |
+
if len(plate_img.shape) == 3:
|
| 66 |
+
enhanced = cv.cvtColor(enhanced, cv.COLOR_GRAY2BGR)
|
| 67 |
+
|
| 68 |
+
return enhanced
|
app/pipeline/ocr.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
_ARABIC_INDIC_TO_ASCII = str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789")
|
| 12 |
+
|
| 13 |
+
def _load_hf_asset(repo_id: str, filename: str, token: Optional[str]) -> Path:
|
| 14 |
+
try:
|
| 15 |
+
from huggingface_hub import hf_hub_download
|
| 16 |
+
except ImportError as exc:
|
| 17 |
+
raise RuntimeError(
|
| 18 |
+
"huggingface_hub is required for HF model downloads. "
|
| 19 |
+
"Install it with `pip install huggingface_hub`."
|
| 20 |
+
) from exc
|
| 21 |
+
return Path(hf_hub_download(repo_id=repo_id, filename=filename, token=token))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class OCR:
|
| 25 |
+
"""
|
| 26 |
+
Handles Egyptian license plate character recognition (OCR) using YOLO.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
model_path: Optional[str] = None,
|
| 32 |
+
hf_repo: Optional[str] = None,
|
| 33 |
+
conf_threshold: float = 0.25,
|
| 34 |
+
):
|
| 35 |
+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 36 |
+
self.conf_threshold = conf_threshold
|
| 37 |
+
|
| 38 |
+
logger.info(f"OCR Engine initializing on device: {self.device}")
|
| 39 |
+
|
| 40 |
+
# 1. Handle Hugging Face download if configured
|
| 41 |
+
if hf_repo:
|
| 42 |
+
logger.info(f"Downloading OCR model weights from HF Hub: {hf_repo}")
|
| 43 |
+
try:
|
| 44 |
+
model_path = str(_load_hf_asset(hf_repo, "best.pt", None))
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.warning(f"Could not download from HF Hub, falling back to local weights: {e}")
|
| 47 |
+
|
| 48 |
+
# 2. Fall back to local path if needed
|
| 49 |
+
if not model_path or not os.path.exists(model_path):
|
| 50 |
+
from ..config import CHAR_OCR_MODEL_PATH
|
| 51 |
+
model_path = CHAR_OCR_MODEL_PATH
|
| 52 |
+
|
| 53 |
+
logger.info(f"Loading OCR YOLO weights from: {model_path}")
|
| 54 |
+
|
| 55 |
+
# Load the underlying ultralytics YOLO model safely
|
| 56 |
+
from ultralytics import YOLO
|
| 57 |
+
self.model = YOLO(model_path)
|
| 58 |
+
|
| 59 |
+
def get_text(self, plate_crop: np.ndarray) -> dict[str, Any]:
|
| 60 |
+
"""
|
| 61 |
+
Interface method required by coordinator.py line 84.
|
| 62 |
+
Processes a cropped license plate image and extracts characters.
|
| 63 |
+
"""
|
| 64 |
+
if plate_crop is None or plate_crop.size == 0:
|
| 65 |
+
return {"plate_string": "", "letters": [], "numbers": []}
|
| 66 |
+
|
| 67 |
+
letters_list = []
|
| 68 |
+
numbers_list = []
|
| 69 |
+
|
| 70 |
+
# Run inference using the underlying YOLO engine
|
| 71 |
+
results = self.model.predict(plate_crop, conf=self.conf_threshold, verbose=False)
|
| 72 |
+
|
| 73 |
+
detected_chars = []
|
| 74 |
+
for result in results:
|
| 75 |
+
boxes = result.boxes
|
| 76 |
+
for box in boxes:
|
| 77 |
+
cls_id = int(box.cls[0].item())
|
| 78 |
+
label = self.model.names[cls_id]
|
| 79 |
+
# Track the top-left x-coordinate to properly sequence text flow
|
| 80 |
+
x_min = float(box.xyxy[0][0].item())
|
| 81 |
+
detected_chars.append((x_min, label))
|
| 82 |
+
|
| 83 |
+
# Arabic license plates flow from right-to-left.
|
| 84 |
+
# Sorting reverse=True ensures text builds seamlessly in the correct reading order.
|
| 85 |
+
detected_chars.sort(key=lambda x: x[0], reverse=True)
|
| 86 |
+
|
| 87 |
+
# Segment arrays into dedicated letter and numerical streams
|
| 88 |
+
for _, char in detected_chars:
|
| 89 |
+
char_clean = char.strip()
|
| 90 |
+
# Check if digit (translating Arabic Indic characters to standard ascii digits safely)
|
| 91 |
+
if re.match(r"^\d+$", char_clean.translate(_ARABIC_INDIC_TO_ASCII)):
|
| 92 |
+
numbers_list.append(char_clean)
|
| 93 |
+
else:
|
| 94 |
+
letters_list.append(char_clean)
|
| 95 |
+
|
| 96 |
+
# Combine character slices into complete output string arrays
|
| 97 |
+
combined_letters = " ".join(letters_list)
|
| 98 |
+
combined_numbers = " ".join(numbers_list)
|
| 99 |
+
|
| 100 |
+
if combined_letters and combined_numbers:
|
| 101 |
+
plate_string = f"{combined_letters} {combined_numbers}"
|
| 102 |
+
else:
|
| 103 |
+
plate_string = combined_letters if combined_letters else combined_numbers
|
| 104 |
+
|
| 105 |
+
# Return the exact keys your coordinator.py needs to process scoring
|
| 106 |
+
return {
|
| 107 |
+
"plate_string": plate_string.strip(),
|
| 108 |
+
"letters": letters_list,
|
| 109 |
+
"numbers": numbers_list,
|
| 110 |
+
}
|
app/pipeline/scorer.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from ..config import PROJECT_ROOT
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
# Price is the dominant signal
|
| 10 |
+
TIER_THRESHOLDS = {
|
| 11 |
+
"Elite": 0.90, # top 10% by price
|
| 12 |
+
"High": 0.70, # next 20%
|
| 13 |
+
"Mid": 0.40, # next 30%
|
| 14 |
+
"Standard": 0.0, # bottom 40%
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
TIER_ORDER = ["Standard", "Mid", "High", "Elite"]
|
| 18 |
+
SPECIAL_PLATE_BUMP = 1
|
| 19 |
+
|
| 20 |
+
class Scorer:
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.saved_prices = np.array([])
|
| 23 |
+
|
| 24 |
+
csv_path = PROJECT_ROOT / "datasets" / "purchasing_power_results.csv"
|
| 25 |
+
if csv_path.exists():
|
| 26 |
+
try:
|
| 27 |
+
df = pd.read_csv(csv_path)
|
| 28 |
+
if "estimated_price" in df.columns:
|
| 29 |
+
self.saved_prices = df["estimated_price"].dropna().values
|
| 30 |
+
logger.info(f"Loaded {len(self.saved_prices)} historical car prices for tier ranking.")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.warning(f"Could not load prices for Scorer: {e}")
|
| 33 |
+
else:
|
| 34 |
+
logger.warning(f"Purchasing power CSV not found at {csv_path}. Using fallback distribution.")
|
| 35 |
+
|
| 36 |
+
if len(self.saved_prices) == 0:
|
| 37 |
+
self.saved_prices = np.array([500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000])
|
| 38 |
+
|
| 39 |
+
def score_plate(self, digits: str) -> float:
|
| 40 |
+
return 0.0
|
| 41 |
+
|
| 42 |
+
def score_segment(self, segment: str) -> float:
|
| 43 |
+
return 0.0
|
| 44 |
+
|
| 45 |
+
def calculate_purchasing_power(
|
| 46 |
+
self,
|
| 47 |
+
plate_score: float,
|
| 48 |
+
segment_score: float,
|
| 49 |
+
visit_time,
|
| 50 |
+
estimated_price: float,
|
| 51 |
+
is_special_plate: bool,
|
| 52 |
+
segment_name: str = "", # ADDED: Pass the segment string here
|
| 53 |
+
) -> dict:
|
| 54 |
+
|
| 55 |
+
# SMART FALLBACK: If price is 0, check the car's name before assigning the median!
|
| 56 |
+
if estimated_price is None or pd.isna(estimated_price) or estimated_price <= 0:
|
| 57 |
+
name_lower = str(segment_name).lower()
|
| 58 |
+
|
| 59 |
+
# If it's an obvious luxury car, give it a 15 Million EGP default
|
| 60 |
+
if any(vip in name_lower for vip in ["g63", "mercedes", "porsche", "rover", "bmw_x", "lexus"]):
|
| 61 |
+
estimated_price = 15_000_000.0
|
| 62 |
+
# Otherwise, use the standard median average
|
| 63 |
+
else:
|
| 64 |
+
estimated_price = float(np.median(self.saved_prices))
|
| 65 |
+
|
| 66 |
+
price_pct = float(np.mean(self.saved_prices <= estimated_price))
|
| 67 |
+
|
| 68 |
+
if price_pct >= TIER_THRESHOLDS["Elite"]: price_tier = "Elite"
|
| 69 |
+
elif price_pct >= TIER_THRESHOLDS["High"]: price_tier = "High"
|
| 70 |
+
elif price_pct >= TIER_THRESHOLDS["Mid"]: price_tier = "Mid"
|
| 71 |
+
else: price_tier = "Standard"
|
| 72 |
+
|
| 73 |
+
if is_special_plate:
|
| 74 |
+
idx = TIER_ORDER.index(price_tier)
|
| 75 |
+
final_tier = TIER_ORDER[min(idx + SPECIAL_PLATE_BUMP, len(TIER_ORDER) - 1)]
|
| 76 |
+
else:
|
| 77 |
+
final_tier = price_tier
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
"pp_score": round(price_pct, 4),
|
| 81 |
+
"pp_tier": final_tier,
|
| 82 |
+
"final_price": estimated_price
|
| 83 |
+
}
|
app/pipeline/special_plate.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import csv
|
| 2 |
+
import logging
|
| 3 |
+
import re
|
| 4 |
+
from collections import Counter
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
_ARABIC_INDIC_TO_ASCII = str.maketrans("٠١٢٣٤٥٦٧٨٩", "0123456789")
|
| 11 |
+
_OCR_NORMALISE = str.maketrans({
|
| 12 |
+
"ا": "أ",
|
| 13 |
+
"إ": "أ",
|
| 14 |
+
"آ": "أ",
|
| 15 |
+
"ى": "ي",
|
| 16 |
+
"ه": "ة",
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
SPECIAL_THRESHOLD = 6
|
| 20 |
+
ARABIC_NAMES_CSV = r"D:\Uni y3s2\ML\Project_Main_ML\vehicle-intelligence-dashboard\datasets\special_plates\arabic_names.csv"
|
| 21 |
+
PRESTIGE_WORDS = {
|
| 22 |
+
"ملك", "أمر", "سيد", "شيخ",
|
| 23 |
+
"أسد", "نمر", "صقر", "فهد", "ذئب", "نسر",
|
| 24 |
+
"عز", "مجد", "ذهب", "نور", "هيب",
|
| 25 |
+
"حب", "أمل", "روح", "حلم"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def normalize(text: str) -> str:
|
| 30 |
+
return str(text).translate(_OCR_NORMALISE)
|
| 31 |
+
|
| 32 |
+
def extract_letters(plate: str) -> str:
|
| 33 |
+
letters = re.sub(r"[0-9٠١٢٣٤٥٦٧٨٩\s]", "", str(plate))
|
| 34 |
+
return normalize(letters).strip()
|
| 35 |
+
|
| 36 |
+
def extract_digits_from_list(nums: list[Any]) -> list[int]:
|
| 37 |
+
digits: list[int] = []
|
| 38 |
+
for value in nums or []:
|
| 39 |
+
if value is None:
|
| 40 |
+
continue
|
| 41 |
+
if isinstance(value, int):
|
| 42 |
+
digits.append(value)
|
| 43 |
+
continue
|
| 44 |
+
text = str(value).translate(_ARABIC_INDIC_TO_ASCII).strip()
|
| 45 |
+
if not text:
|
| 46 |
+
continue
|
| 47 |
+
for ch in text:
|
| 48 |
+
if ch.isdigit():
|
| 49 |
+
digits.append(int(ch))
|
| 50 |
+
return digits
|
| 51 |
+
|
| 52 |
+
def load_arabic_names(path: str = ARABIC_NAMES_CSV, name_col: str = "arabic_name") -> set[str]:
|
| 53 |
+
location = Path(path)
|
| 54 |
+
if not location.exists():
|
| 55 |
+
logger.warning(f"arabic names csv not found: {location} — continuing with empty name set")
|
| 56 |
+
return set()
|
| 57 |
+
|
| 58 |
+
names: set[str] = set()
|
| 59 |
+
with location.open(newline="", encoding="utf-8") as csvfile:
|
| 60 |
+
reader = csv.DictReader(csvfile)
|
| 61 |
+
for row in reader:
|
| 62 |
+
name = str(row.get(name_col, "")).strip()
|
| 63 |
+
if 2 <= len(name) <= 3:
|
| 64 |
+
names.add(name)
|
| 65 |
+
return names
|
| 66 |
+
|
| 67 |
+
def get_level(score: float) -> str:
|
| 68 |
+
if score <= 4:
|
| 69 |
+
return "normal"
|
| 70 |
+
if score <= 6:
|
| 71 |
+
return "special"
|
| 72 |
+
if score <= 9:
|
| 73 |
+
return "very_special"
|
| 74 |
+
return "VIP"
|
| 75 |
+
|
| 76 |
+
def is_sequential(digits: list[int]) -> bool:
|
| 77 |
+
if len(digits) < 3:
|
| 78 |
+
return False
|
| 79 |
+
return all(digits[i] + 1 == digits[i + 1] for i in range(len(digits) - 1)) or \
|
| 80 |
+
all(digits[i] - 1 == digits[i + 1] for i in range(len(digits) - 1))
|
| 81 |
+
|
| 82 |
+
def is_three_plus_one(digits: list[int]) -> bool:
|
| 83 |
+
if len(digits) != 4:
|
| 84 |
+
return False
|
| 85 |
+
return sorted(Counter(digits).values(), reverse=True) == [3, 1]
|
| 86 |
+
|
| 87 |
+
def contains_year(digits: list[int]) -> bool:
|
| 88 |
+
s = "".join(map(str, digits))
|
| 89 |
+
for i in range(len(s) - 3):
|
| 90 |
+
try:
|
| 91 |
+
value = int(s[i:i + 4])
|
| 92 |
+
if 1970 <= value <= 2026:
|
| 93 |
+
return True
|
| 94 |
+
except ValueError:
|
| 95 |
+
continue
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
def _score_plate(full_plate: str, digits_list: list[int]) -> dict[str, Any]:
|
| 99 |
+
letters = extract_letters(full_plate)
|
| 100 |
+
digits = digits_list
|
| 101 |
+
|
| 102 |
+
arabic_names = load_arabic_names()
|
| 103 |
+
prestige_words_norm = {normalize(w) for w in PRESTIGE_WORDS}
|
| 104 |
+
|
| 105 |
+
score = 0
|
| 106 |
+
if letters in arabic_names:
|
| 107 |
+
score += 3
|
| 108 |
+
if letters in prestige_words_norm:
|
| 109 |
+
score += 5
|
| 110 |
+
if len(letters) == 1:
|
| 111 |
+
score += 6
|
| 112 |
+
if len(digits) == 1:
|
| 113 |
+
score += 6
|
| 114 |
+
if len(set(letters)) == 1 and letters:
|
| 115 |
+
score += 4
|
| 116 |
+
if len(set(digits)) == 1 and digits:
|
| 117 |
+
score += 6
|
| 118 |
+
if len(digits) == 4 and digits[:2] * 2 == digits:
|
| 119 |
+
score += 4
|
| 120 |
+
if len(digits) >= 3 and is_sequential(digits):
|
| 121 |
+
score += 3
|
| 122 |
+
if contains_year(digits):
|
| 123 |
+
score += 2
|
| 124 |
+
if is_three_plus_one(digits):
|
| 125 |
+
score += 2
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"plate": full_plate,
|
| 129 |
+
"score": round(score, 2),
|
| 130 |
+
"is_special": int(score >= SPECIAL_THRESHOLD),
|
| 131 |
+
"level": get_level(score),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class SpecialPlateAnalyzer:
|
| 136 |
+
"""Analyzes a detected plate using the special plate scoring rules."""
|
| 137 |
+
|
| 138 |
+
def __init__(self):
|
| 139 |
+
self.arabic_names = None
|
| 140 |
+
|
| 141 |
+
def analyze_plate(self, plate_string: str) -> dict[str, Any]:
|
| 142 |
+
"""
|
| 143 |
+
Intercepts pipeline requests from coordinator.py cleanly.
|
| 144 |
+
"""
|
| 145 |
+
digits = []
|
| 146 |
+
if plate_string:
|
| 147 |
+
clean_str = plate_string.replace(" ", "").translate(_ARABIC_INDIC_TO_ASCII)
|
| 148 |
+
digits = list(re.sub(r"\D", "", clean_str))
|
| 149 |
+
return self.analyze(plate_string, digits)
|
| 150 |
+
|
| 151 |
+
def analyze(self, plate_string: str, digits: list[Any]) -> dict[str, Any]:
|
| 152 |
+
"""Main internal pipeline logic."""
|
| 153 |
+
digits_list = extract_digits_from_list(digits)
|
| 154 |
+
|
| 155 |
+
if not plate_string or not digits_list:
|
| 156 |
+
return {
|
| 157 |
+
"score": 0.0,
|
| 158 |
+
"level": "normal",
|
| 159 |
+
"is_special": False,
|
| 160 |
+
"special_plate_score": 0.0,
|
| 161 |
+
"special_plate_level": "normal",
|
| 162 |
+
"is_special_plate": False,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
result = _score_plate(plate_string, digits_list)
|
| 167 |
+
scr = float(result.get("score", 0.0))
|
| 168 |
+
lvl = result.get("level", "normal")
|
| 169 |
+
is_spc = bool(result.get("is_special", 0))
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"score": scr,
|
| 173 |
+
"level": lvl,
|
| 174 |
+
"is_special": is_spc,
|
| 175 |
+
"special_plate_score": scr,
|
| 176 |
+
"special_plate_level": lvl,
|
| 177 |
+
"is_special_plate": is_spc,
|
| 178 |
+
}
|
| 179 |
+
except Exception as exc:
|
| 180 |
+
logger.warning(f"Special plate scoring failed: {exc}")
|
| 181 |
+
return {
|
| 182 |
+
"score": 0.0,
|
| 183 |
+
"level": "normal",
|
| 184 |
+
"is_special": False,
|
| 185 |
+
"special_plate_score": 0.0,
|
| 186 |
+
"special_plate_level": "normal",
|
| 187 |
+
"is_special_plate": False,
|
| 188 |
+
}
|
app/pipeline/video_capture.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Video capture module — OpenCV VideoCapture + YOLOv11 vehicle detection.
|
| 3 |
+
|
| 4 |
+
Reads a video file or camera stream, selects sharp frames, crops one vehicle
|
| 5 |
+
per trigger, and yields crops for the LPR coordinator (no HTTP).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
from collections import deque
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Iterator
|
| 15 |
+
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
from .. import config as app_config
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
VEHICLE_CLASS_IDS = {2, 3, 5, 7} # car, motorcycle, bus, truck
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class CaptureSettings:
|
| 28 |
+
"""Runtime settings for VideoCapturer (defaults from app.config)."""
|
| 29 |
+
|
| 30 |
+
process_every_n_frames: int = 3
|
| 31 |
+
frame_buffer_size: int = 20
|
| 32 |
+
motion_threshold: int = 1500
|
| 33 |
+
mog2_history: int = 500
|
| 34 |
+
mog2_var_threshold: int = 50
|
| 35 |
+
cooldown_seconds: float = 2.0
|
| 36 |
+
trigger_mode: str = "yolo"
|
| 37 |
+
vehicle_model: str = "yolo11n.pt"
|
| 38 |
+
vehicle_conf: float = 0.40
|
| 39 |
+
vehicle_min_box_area: int = 3000
|
| 40 |
+
vehicle_crop_padding: int = 50
|
| 41 |
+
vehicle_filter_enabled: bool = True
|
| 42 |
+
max_vehicles_per_trigger: int = 1
|
| 43 |
+
min_sharpness_score: float = 5.0
|
| 44 |
+
min_brightness: int = 30
|
| 45 |
+
max_brightness: int = 220
|
| 46 |
+
dedup_iou: float = 0.45
|
| 47 |
+
max_triggers: int | None = None # cap API runs on long videos
|
| 48 |
+
|
| 49 |
+
@classmethod
|
| 50 |
+
def from_app_config(cls) -> CaptureSettings:
|
| 51 |
+
return cls(
|
| 52 |
+
process_every_n_frames=getattr(app_config, "CAPTURE_PROCESS_EVERY_N_FRAMES", 3),
|
| 53 |
+
frame_buffer_size=getattr(app_config, "CAPTURE_FRAME_BUFFER_SIZE", 20),
|
| 54 |
+
motion_threshold=getattr(app_config, "CAPTURE_MOTION_THRESHOLD", 1500),
|
| 55 |
+
mog2_history=getattr(app_config, "CAPTURE_MOG2_HISTORY", 500),
|
| 56 |
+
mog2_var_threshold=getattr(app_config, "CAPTURE_MOG2_VAR_THRESHOLD", 50),
|
| 57 |
+
cooldown_seconds=getattr(app_config, "CAPTURE_COOLDOWN_SECONDS", 2.0),
|
| 58 |
+
trigger_mode=getattr(app_config, "CAPTURE_TRIGGER_MODE", "yolo"),
|
| 59 |
+
vehicle_model=getattr(app_config, "CAPTURE_VEHICLE_MODEL", "yolo11n.pt"),
|
| 60 |
+
vehicle_conf=getattr(app_config, "CAPTURE_VEHICLE_CONF", 0.40),
|
| 61 |
+
vehicle_min_box_area=getattr(app_config, "CAPTURE_VEHICLE_MIN_BOX_AREA", 3000),
|
| 62 |
+
vehicle_crop_padding=getattr(app_config, "CAPTURE_VEHICLE_CROP_PADDING", 50),
|
| 63 |
+
vehicle_filter_enabled=getattr(app_config, "CAPTURE_VEHICLE_FILTER_ENABLED", True),
|
| 64 |
+
max_vehicles_per_trigger=getattr(app_config, "CAPTURE_MAX_VEHICLES_PER_TRIGGER", 1),
|
| 65 |
+
min_sharpness_score=getattr(app_config, "CAPTURE_MIN_SHARPNESS_SCORE", 5.0),
|
| 66 |
+
min_brightness=getattr(app_config, "CAPTURE_MIN_BRIGHTNESS", 30),
|
| 67 |
+
max_brightness=getattr(app_config, "CAPTURE_MAX_BRIGHTNESS", 220),
|
| 68 |
+
dedup_iou=getattr(app_config, "CAPTURE_DEDUP_IOU", 0.45),
|
| 69 |
+
max_triggers=getattr(app_config, "CAPTURE_MAX_TRIGGERS", None),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass
|
| 74 |
+
class VehicleCropEvent:
|
| 75 |
+
"""One vehicle crop ready for the LPR pipeline."""
|
| 76 |
+
|
| 77 |
+
crop: np.ndarray
|
| 78 |
+
bbox: list[int]
|
| 79 |
+
label: str
|
| 80 |
+
conf: float
|
| 81 |
+
frame_index: int
|
| 82 |
+
trigger_index: int
|
| 83 |
+
sharpness: float = 0.0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _bbox_iou(a: list[int], b: list[int]) -> float:
|
| 87 |
+
ax1, ay1, ax2, ay2 = a
|
| 88 |
+
bx1, by1, bx2, by2 = b
|
| 89 |
+
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
| 90 |
+
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
| 91 |
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 92 |
+
if inter == 0:
|
| 93 |
+
return 0.0
|
| 94 |
+
area_a = (ax2 - ax1) * (ay2 - ay1)
|
| 95 |
+
area_b = (bx2 - bx1) * (by2 - by1)
|
| 96 |
+
return inter / (area_a + area_b - inter + 1e-6)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class VideoCapturer:
|
| 100 |
+
"""
|
| 101 |
+
VideoCapture → YOLOv11 → one vehicle crop per trigger.
|
| 102 |
+
|
| 103 |
+
Use iter_vehicle_crops() to drive the LPR pipeline without HTTP.
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
def __init__(self, settings: CaptureSettings | None = None):
|
| 107 |
+
self.settings = settings or CaptureSettings.from_app_config()
|
| 108 |
+
self._vehicle_model = None
|
| 109 |
+
|
| 110 |
+
def _load_vehicle_model(self):
|
| 111 |
+
if self._vehicle_model is None:
|
| 112 |
+
from ultralytics import YOLO
|
| 113 |
+
self._vehicle_model = YOLO(self.settings.vehicle_model)
|
| 114 |
+
logger.info("Vehicle detector loaded (%s)", self.settings.vehicle_model)
|
| 115 |
+
return self._vehicle_model
|
| 116 |
+
|
| 117 |
+
def detect_vehicles(self, frame: np.ndarray) -> list[dict]:
|
| 118 |
+
if not self.settings.vehicle_filter_enabled:
|
| 119 |
+
h, w = frame.shape[:2]
|
| 120 |
+
return [{"label": "vehicle", "conf": 1.0, "bbox": [0, 0, w, h], "area": w * h}]
|
| 121 |
+
|
| 122 |
+
model = self._load_vehicle_model()
|
| 123 |
+
results = model.predict(
|
| 124 |
+
frame,
|
| 125 |
+
conf=self.settings.vehicle_conf,
|
| 126 |
+
verbose=False,
|
| 127 |
+
classes=list(VEHICLE_CLASS_IDS),
|
| 128 |
+
)
|
| 129 |
+
detections = []
|
| 130 |
+
for box in results[0].boxes:
|
| 131 |
+
cls_id = int(box.cls)
|
| 132 |
+
if cls_id not in VEHICLE_CLASS_IDS:
|
| 133 |
+
continue
|
| 134 |
+
x1, y1, x2, y2 = box.xyxy[0].tolist()
|
| 135 |
+
area = (x2 - x1) * (y2 - y1)
|
| 136 |
+
if area < self.settings.vehicle_min_box_area:
|
| 137 |
+
continue
|
| 138 |
+
detections.append({
|
| 139 |
+
"label": results[0].names[cls_id],
|
| 140 |
+
"conf": float(box.conf),
|
| 141 |
+
"bbox": [int(x1), int(y1), int(x2), int(y2)],
|
| 142 |
+
"area": area,
|
| 143 |
+
})
|
| 144 |
+
return detections
|
| 145 |
+
|
| 146 |
+
@staticmethod
|
| 147 |
+
def sharpness_score(frame: np.ndarray) -> float:
|
| 148 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 149 |
+
return cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 150 |
+
|
| 151 |
+
def frame_is_usable(self, frame: np.ndarray) -> bool:
|
| 152 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 153 |
+
mean_b = gray.mean()
|
| 154 |
+
if not (self.settings.min_brightness <= mean_b <= self.settings.max_brightness):
|
| 155 |
+
return False
|
| 156 |
+
return self.sharpness_score(frame) >= self.settings.min_sharpness_score
|
| 157 |
+
|
| 158 |
+
def best_frame_from_buffer(self, buffer: deque) -> np.ndarray | None:
|
| 159 |
+
scored = []
|
| 160 |
+
for frame in buffer:
|
| 161 |
+
if self.frame_is_usable(frame):
|
| 162 |
+
scored.append((self.sharpness_score(frame), frame))
|
| 163 |
+
if not scored:
|
| 164 |
+
return None
|
| 165 |
+
scored.sort(key=lambda x: x[0], reverse=True)
|
| 166 |
+
return scored[0][1]
|
| 167 |
+
|
| 168 |
+
def crop_vehicle(self, frame: np.ndarray, bbox: list[int]) -> np.ndarray:
|
| 169 |
+
pad = self.settings.vehicle_crop_padding
|
| 170 |
+
h, w = frame.shape[:2]
|
| 171 |
+
x1, y1, x2, y2 = bbox
|
| 172 |
+
x1, y1 = max(0, x1 - pad), max(0, y1 - pad)
|
| 173 |
+
x2, y2 = min(w, x2 + pad), min(h, y2 + pad)
|
| 174 |
+
return frame[y1:y2, x1:x2]
|
| 175 |
+
|
| 176 |
+
def select_vehicles(
|
| 177 |
+
self,
|
| 178 |
+
detections: list[dict],
|
| 179 |
+
exclude_bbox: list[int] | None = None,
|
| 180 |
+
) -> list[dict]:
|
| 181 |
+
if not detections:
|
| 182 |
+
return []
|
| 183 |
+
ranked = sorted(
|
| 184 |
+
detections,
|
| 185 |
+
key=lambda d: d["conf"] * d.get("area", 1),
|
| 186 |
+
reverse=True,
|
| 187 |
+
)
|
| 188 |
+
if exclude_bbox is not None:
|
| 189 |
+
ranked = [
|
| 190 |
+
d for d in ranked
|
| 191 |
+
if _bbox_iou(d["bbox"], exclude_bbox) < self.settings.dedup_iou
|
| 192 |
+
]
|
| 193 |
+
return ranked[: self.settings.max_vehicles_per_trigger]
|
| 194 |
+
|
| 195 |
+
def iter_vehicle_crops(self, source: str | int) -> Iterator[VehicleCropEvent]:
|
| 196 |
+
"""
|
| 197 |
+
Scan video/camera source and yield one vehicle crop per trigger.
|
| 198 |
+
"""
|
| 199 |
+
s = self.settings
|
| 200 |
+
mode = (s.trigger_mode or "yolo").lower()
|
| 201 |
+
if s.vehicle_filter_enabled:
|
| 202 |
+
self._load_vehicle_model()
|
| 203 |
+
|
| 204 |
+
cap = cv2.VideoCapture(source)
|
| 205 |
+
if not cap.isOpened():
|
| 206 |
+
raise ValueError(f"Cannot open video source: {source}")
|
| 207 |
+
|
| 208 |
+
fgbg = None
|
| 209 |
+
if mode == "motion":
|
| 210 |
+
fgbg = cv2.createBackgroundSubtractorMOG2(
|
| 211 |
+
history=s.mog2_history,
|
| 212 |
+
varThreshold=s.mog2_var_threshold,
|
| 213 |
+
detectShadows=True,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
buffer: deque = deque(maxlen=s.frame_buffer_size)
|
| 217 |
+
frame_count = 0
|
| 218 |
+
trigger_index = 0
|
| 219 |
+
last_trigger_ts = 0.0
|
| 220 |
+
last_sent_bbox = None
|
| 221 |
+
|
| 222 |
+
logger.info(
|
| 223 |
+
"VideoCapturer started source=%s mode=%s max_triggers=%s",
|
| 224 |
+
source, mode, s.max_triggers,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
while True:
|
| 229 |
+
if s.max_triggers is not None and trigger_index >= s.max_triggers:
|
| 230 |
+
logger.info("Reached max_triggers=%s", s.max_triggers)
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
ret, frame = cap.read()
|
| 234 |
+
if not ret:
|
| 235 |
+
break
|
| 236 |
+
|
| 237 |
+
frame_count += 1
|
| 238 |
+
buffer.append(frame.copy())
|
| 239 |
+
|
| 240 |
+
if frame_count % s.process_every_n_frames != 0:
|
| 241 |
+
continue
|
| 242 |
+
|
| 243 |
+
now = time.time()
|
| 244 |
+
if (now - last_trigger_ts) < s.cooldown_seconds:
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
should_scan = False
|
| 248 |
+
if mode == "yolo":
|
| 249 |
+
should_scan = True
|
| 250 |
+
elif fgbg is not None:
|
| 251 |
+
fgmask = fgbg.apply(frame)
|
| 252 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 253 |
+
cleaned = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel)
|
| 254 |
+
if cv2.countNonZero(cleaned) > s.motion_threshold:
|
| 255 |
+
should_scan = True
|
| 256 |
+
|
| 257 |
+
if not should_scan:
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
best = self.best_frame_from_buffer(buffer)
|
| 261 |
+
if best is None:
|
| 262 |
+
continue
|
| 263 |
+
|
| 264 |
+
vehicles = self.detect_vehicles(best)
|
| 265 |
+
vehicles = self.select_vehicles(vehicles, exclude_bbox=last_sent_bbox)
|
| 266 |
+
if not vehicles:
|
| 267 |
+
continue
|
| 268 |
+
|
| 269 |
+
score = self.sharpness_score(best)
|
| 270 |
+
for vehicle in vehicles:
|
| 271 |
+
crop = self.crop_vehicle(best, vehicle["bbox"])
|
| 272 |
+
if crop is None or crop.size == 0:
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
trigger_index += 1
|
| 276 |
+
last_trigger_ts = now
|
| 277 |
+
last_sent_bbox = vehicle["bbox"]
|
| 278 |
+
|
| 279 |
+
yield VehicleCropEvent(
|
| 280 |
+
crop=crop,
|
| 281 |
+
bbox=vehicle["bbox"],
|
| 282 |
+
label=vehicle["label"],
|
| 283 |
+
conf=vehicle["conf"],
|
| 284 |
+
frame_index=frame_count,
|
| 285 |
+
trigger_index=trigger_index,
|
| 286 |
+
sharpness=score,
|
| 287 |
+
)
|
| 288 |
+
finally:
|
| 289 |
+
cap.release()
|
app_gradio.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import difflib
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import cv2 as cv
|
| 6 |
+
import numpy as np
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
from app.pipeline.coordinator import LPRPipeline
|
| 10 |
+
from app.database.connection import Database
|
| 11 |
+
from app.database.logger import VisitLogger
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
_pipeline: Optional[LPRPipeline] = None
|
| 17 |
+
_visit_logger: Optional[VisitLogger] = None
|
| 18 |
+
|
| 19 |
+
def get_pipeline() -> LPRPipeline:
|
| 20 |
+
global _pipeline
|
| 21 |
+
if _pipeline is None:
|
| 22 |
+
logger.info("Initializing real LPRPipeline and loading model weights...")
|
| 23 |
+
_pipeline = LPRPipeline(use_hugging_face=True)
|
| 24 |
+
return _pipeline
|
| 25 |
+
|
| 26 |
+
def get_logger() -> Optional[VisitLogger]:
|
| 27 |
+
"""Initializes the database connection for Gradio exactly like FastAPI does."""
|
| 28 |
+
global _visit_logger
|
| 29 |
+
if _visit_logger is None:
|
| 30 |
+
try:
|
| 31 |
+
logger.info("Connecting to Supabase for Gradio logging...")
|
| 32 |
+
db = Database()
|
| 33 |
+
_visit_logger = VisitLogger(db)
|
| 34 |
+
except Exception as e:
|
| 35 |
+
logger.warning(f"Database logging disabled in Gradio (check config): {e}")
|
| 36 |
+
return _visit_logger
|
| 37 |
+
|
| 38 |
+
def _log_detections_to_db(detections: list[dict]):
|
| 39 |
+
"""Helper to silently log to the database using a valid camera ID."""
|
| 40 |
+
v_logger = get_logger()
|
| 41 |
+
if not v_logger:
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
# 1. Fetch a real, valid camera ID from Supabase
|
| 45 |
+
valid_camera_id = None
|
| 46 |
+
try:
|
| 47 |
+
existing_cameras = v_logger.db.query("cameras", select="id")
|
| 48 |
+
if existing_cameras and len(existing_cameras) > 0:
|
| 49 |
+
# Use the first available camera
|
| 50 |
+
valid_camera_id = existing_cameras[0]["id"]
|
| 51 |
+
else:
|
| 52 |
+
# AUTOMATIC FIX: Create a default camera if the table is empty!
|
| 53 |
+
logger.info("No cameras found in DB. Creating a default 'Gradio' camera...")
|
| 54 |
+
new_cam = v_logger.db.insert("cameras", {
|
| 55 |
+
"location_name": "Gradio Manual Uploads",
|
| 56 |
+
"location_type": "dashboard"
|
| 57 |
+
})
|
| 58 |
+
valid_camera_id = new_cam[0]["id"]
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"❌ Failed to fetch or create camera in DB: {e}")
|
| 62 |
+
return
|
| 63 |
+
|
| 64 |
+
# 2. Log the detections using the guaranteed real camera ID
|
| 65 |
+
now = datetime.now()
|
| 66 |
+
for det in detections:
|
| 67 |
+
try:
|
| 68 |
+
visit_id = v_logger.log(
|
| 69 |
+
plate_digits=det.get("digits", "0000"),
|
| 70 |
+
plate_letters=det.get("letters", "UNKNOWN"),
|
| 71 |
+
plate_score=det.get("plate_score", 0.0),
|
| 72 |
+
segment=det.get("segment", "unknown"),
|
| 73 |
+
segment_score=det.get("segment_score", 0.0),
|
| 74 |
+
purchasing_power=det.get("purchasing_power", 0.0),
|
| 75 |
+
car_brand=det.get("car_brand", "unknown"),
|
| 76 |
+
car_model=det.get("car_model", "unknown"),
|
| 77 |
+
timestamp=now,
|
| 78 |
+
camera_id=valid_camera_id,
|
| 79 |
+
)
|
| 80 |
+
det["visit_id"] = visit_id
|
| 81 |
+
logger.info(f"✅ Successfully logged Gradio detection to DB (Visit: {visit_id})")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error(f"❌ Failed to log to Supabase from Gradio: {e}")
|
| 84 |
+
# ── Hotfix Overrides ─────────────────────────────────────────────────────────
|
| 85 |
+
VEHICLE_DB_HOTFIX = {
|
| 86 |
+
"G63": {"segment": "Luxury", "price": 18000000.0, "power": 0.98},
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
def _patch_detection(det: dict) -> dict:
|
| 90 |
+
if not det: return det
|
| 91 |
+
raw_segment = str(det.get("segment", ""))
|
| 92 |
+
if "_" in raw_segment:
|
| 93 |
+
det["segment"] = "Luxury" if "G63" in raw_segment else "Premium"
|
| 94 |
+
|
| 95 |
+
model = str(det.get("car_model", ""))
|
| 96 |
+
if model in VEHICLE_DB_HOTFIX:
|
| 97 |
+
fix = VEHICLE_DB_HOTFIX[model]
|
| 98 |
+
det["segment"] = fix["segment"]
|
| 99 |
+
if not det.get("estimated_price") or det.get("estimated_price") <= 0:
|
| 100 |
+
det["estimated_price"] = fix["price"]
|
| 101 |
+
if det.get("purchasing_power", 0.0) < fix["power"]:
|
| 102 |
+
det["purchasing_power"] = fix["power"]
|
| 103 |
+
|
| 104 |
+
return det
|
| 105 |
+
|
| 106 |
+
# ── Schema Formatting Helpers ────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
def _fmt_price(price: Optional[float]) -> str:
|
| 109 |
+
if price is None or price <= 0:
|
| 110 |
+
return "Unknown (Not in DB)"
|
| 111 |
+
return f"EGP {price:,.0f}"
|
| 112 |
+
|
| 113 |
+
def _fmt_power(power: float) -> str:
|
| 114 |
+
try:
|
| 115 |
+
p = float(power)
|
| 116 |
+
if p >= 0.90: return f"🟣 Elite ({p:.2f})"
|
| 117 |
+
elif p >= 0.70: return f"🔴 High ({p:.2f})"
|
| 118 |
+
elif p >= 0.40: return f"🟡 Mid ({p:.2f})"
|
| 119 |
+
else: return f"🟢 Standard ({p:.2f})"
|
| 120 |
+
except (ValueError, TypeError):
|
| 121 |
+
return "🟢 Standard (0.00)"
|
| 122 |
+
|
| 123 |
+
def _fmt_special(level: str) -> str:
|
| 124 |
+
lvl = str(level).title()
|
| 125 |
+
if lvl.lower() in ["normal", "none", "false", ""]:
|
| 126 |
+
return "Normal"
|
| 127 |
+
return f"✨ {lvl}"
|
| 128 |
+
|
| 129 |
+
def _best_detection(detections: list) -> Optional[dict]:
|
| 130 |
+
if not detections: return None
|
| 131 |
+
return max(detections, key=lambda d: d.get("purchasing_power", 0.0))
|
| 132 |
+
|
| 133 |
+
def _draw_boxes(frame: np.ndarray, detections: list) -> np.ndarray:
|
| 134 |
+
out = frame.copy()
|
| 135 |
+
for idx, det in enumerate(detections):
|
| 136 |
+
bbox = det.get("bbox")
|
| 137 |
+
if bbox is None: continue
|
| 138 |
+
x1, y1, x2, y2 = map(int, bbox)
|
| 139 |
+
cv.rectangle(out, (x1, y1), (x2, y2), (225, 112, 85), 3)
|
| 140 |
+
cv.putText(out, f"Plate #{idx + 1}", (x1, y1 - 8),
|
| 141 |
+
cv.FONT_HERSHEY_SIMPLEX, 0.7, (225, 112, 85), 2)
|
| 142 |
+
return out
|
| 143 |
+
|
| 144 |
+
# ── Core Processing ──────────────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
def process_image(image: np.ndarray):
|
| 147 |
+
empty_returns = (None, "", "", "", "", "", "", "", "", "", "", "⚠️ No image provided.")
|
| 148 |
+
if image is None: return empty_returns
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
pipeline = get_pipeline()
|
| 152 |
+
frame_bgr = cv.cvtColor(image, cv.COLOR_RGB2BGR)
|
| 153 |
+
result = pipeline.process(frame_bgr)
|
| 154 |
+
|
| 155 |
+
if not result.get("success"):
|
| 156 |
+
return (image, "", "", "", "", "", "", "", "", "", "", f"❌ Error: {result.get('error')}")
|
| 157 |
+
|
| 158 |
+
detections = result.get("detections", [])
|
| 159 |
+
if not detections:
|
| 160 |
+
return (image, "", "", "", "", "", "", "", "", "", "", "ℹ️ No license plates detected.")
|
| 161 |
+
|
| 162 |
+
# Patch detections before logging
|
| 163 |
+
patched_detections = [_patch_detection(d) for d in detections]
|
| 164 |
+
|
| 165 |
+
# LOG TO DATABASE HERE
|
| 166 |
+
_log_detections_to_db(patched_detections)
|
| 167 |
+
|
| 168 |
+
annotated_rgb = cv.cvtColor(_draw_boxes(frame_bgr, patched_detections), cv.COLOR_BGR2RGB)
|
| 169 |
+
det = _best_detection(patched_detections)
|
| 170 |
+
|
| 171 |
+
return (
|
| 172 |
+
annotated_rgb,
|
| 173 |
+
str(det.get("plate_string", "—")),
|
| 174 |
+
str(det.get("digits", "—")),
|
| 175 |
+
str(det.get("letters", "—")),
|
| 176 |
+
_fmt_special(det.get("special_plate_level", "Normal")),
|
| 177 |
+
f"{det.get('special_plate_score', 0.0):.1f} / 20.0",
|
| 178 |
+
str(det.get("car_brand", "—")).title(),
|
| 179 |
+
str(det.get("car_model", "—")).replace("_", " ").title(),
|
| 180 |
+
str(det.get("segment", "Unknown")).title(),
|
| 181 |
+
_fmt_price(det.get("estimated_price")),
|
| 182 |
+
_fmt_power(det.get("purchasing_power", 0.0)),
|
| 183 |
+
f"✅ Processed {len(patched_detections)} plate(s) and logged to DB.",
|
| 184 |
+
)
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.exception("Image processing failed")
|
| 187 |
+
return (image, "", "", "", "", "", "", "", "", "", "", f"❌ Failed: {e}")
|
| 188 |
+
|
| 189 |
+
def process_video(video_path: str):
|
| 190 |
+
if not video_path: return "", [], "⚠️ No video provided."
|
| 191 |
+
try:
|
| 192 |
+
pipeline = get_pipeline()
|
| 193 |
+
result = pipeline.process_video(video_path)
|
| 194 |
+
|
| 195 |
+
if not result.get("success"):
|
| 196 |
+
return "", [], f"❌ Error: {result.get('error')}"
|
| 197 |
+
|
| 198 |
+
detections = result.get("detections", [])
|
| 199 |
+
|
| 200 |
+
# Patch detections before logging
|
| 201 |
+
patched_detections = [_patch_detection(d) for d in detections]
|
| 202 |
+
|
| 203 |
+
# LOG TO DATABASE HERE
|
| 204 |
+
_log_detections_to_db(patched_detections)
|
| 205 |
+
|
| 206 |
+
unique_rows = []
|
| 207 |
+
for det in patched_detections:
|
| 208 |
+
plate_str = str(det.get("plate_string", "—")).strip()
|
| 209 |
+
digits = str(det.get("digits", "—"))
|
| 210 |
+
letters = str(det.get("letters", "—"))
|
| 211 |
+
sp_level = _fmt_special(det.get("special_plate_level", "Normal"))
|
| 212 |
+
brand = str(det.get("car_brand", "—")).title()
|
| 213 |
+
model = str(det.get("car_model", "—")).replace("_", " ").title()
|
| 214 |
+
segment = str(det.get("segment", "Unknown")).title()
|
| 215 |
+
price = _fmt_price(det.get("estimated_price"))
|
| 216 |
+
power = _fmt_power(det.get("purchasing_power", 0.0))
|
| 217 |
+
|
| 218 |
+
is_dup = False
|
| 219 |
+
for r in unique_rows:
|
| 220 |
+
if plate_str != "—" and difflib.SequenceMatcher(None, plate_str, r[0]).ratio() >= 0.75:
|
| 221 |
+
is_dup = True
|
| 222 |
+
break
|
| 223 |
+
|
| 224 |
+
if not is_dup:
|
| 225 |
+
unique_rows.append([plate_str, digits, letters, sp_level, brand, model, segment, price, power])
|
| 226 |
+
|
| 227 |
+
summary = f"**Total Frames Tracked:** {result.get('num_vehicles_processed', 0)} | **Unique Output Logs:** {len(unique_rows)}"
|
| 228 |
+
return summary, unique_rows, f"✅ Processing complete and logged to DB."
|
| 229 |
+
except Exception as e:
|
| 230 |
+
logger.exception("Video processing failed")
|
| 231 |
+
return "", [], f"❌ Failed: {e}"
|
| 232 |
+
|
| 233 |
+
# ── Dashboard Layout ───────────────────────────────────────────��─────────────
|
| 234 |
+
|
| 235 |
+
def build_ui() -> gr.Blocks:
|
| 236 |
+
with gr.Blocks(title="Vehicle Intelligence Dashboard") as demo:
|
| 237 |
+
gr.Markdown("# 🚗 Vehicle Intelligence Dashboard")
|
| 238 |
+
with gr.Tabs():
|
| 239 |
+
|
| 240 |
+
with gr.Tab("📷 Image Processing"):
|
| 241 |
+
with gr.Row():
|
| 242 |
+
img_input = gr.Image(label="Input View", type="numpy", height=340)
|
| 243 |
+
img_output = gr.Image(label="Tracking Overlays", type="numpy", height=340, interactive=False)
|
| 244 |
+
|
| 245 |
+
img_btn = gr.Button("Run Real-Time Pipeline", variant="primary")
|
| 246 |
+
img_status = gr.Markdown("")
|
| 247 |
+
|
| 248 |
+
gr.Markdown("### 📋 Deep Analysis (Schema Data)")
|
| 249 |
+
with gr.Row():
|
| 250 |
+
|
| 251 |
+
with gr.Group():
|
| 252 |
+
gr.Markdown("#### 🪪 Plate OCR & Analysis")
|
| 253 |
+
out_plate = gr.Textbox(label="Full Plate String", interactive=False)
|
| 254 |
+
with gr.Row():
|
| 255 |
+
out_digits = gr.Textbox(label="🔢 Digits", interactive=False)
|
| 256 |
+
out_letters = gr.Textbox(label="🔠 Letters", interactive=False)
|
| 257 |
+
with gr.Row():
|
| 258 |
+
out_sp_level = gr.Textbox(label="✨ Special Level", interactive=False)
|
| 259 |
+
out_sp_score = gr.Textbox(label="⭐ Special Score", interactive=False)
|
| 260 |
+
|
| 261 |
+
with gr.Group():
|
| 262 |
+
gr.Markdown("#### 🚘 Vehicle Classification")
|
| 263 |
+
with gr.Row():
|
| 264 |
+
out_brand = gr.Textbox(label="🏷️ Brand", interactive=False)
|
| 265 |
+
out_model = gr.Textbox(label="🚗 Model", interactive=False)
|
| 266 |
+
out_segment = gr.Textbox(label="🚙 Segment", interactive=False)
|
| 267 |
+
with gr.Row():
|
| 268 |
+
out_price = gr.Textbox(label="💰 Est. Valuation", interactive=False)
|
| 269 |
+
out_power = gr.Textbox(label="📊 Purchasing Power", interactive=False)
|
| 270 |
+
|
| 271 |
+
img_btn.click(
|
| 272 |
+
fn=process_image,
|
| 273 |
+
inputs=[img_input],
|
| 274 |
+
outputs=[
|
| 275 |
+
img_output, out_plate, out_digits, out_letters, out_sp_level, out_sp_score,
|
| 276 |
+
out_brand, out_model, out_segment, out_price, out_power, img_status
|
| 277 |
+
]
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
with gr.Tab("🎬 Video Processing"):
|
| 281 |
+
vid_input = gr.Video(label="Upload surveillance stream", height=300)
|
| 282 |
+
vid_btn = gr.Button("Run Sequential Batch Processing", variant="primary")
|
| 283 |
+
vid_summary = gr.Markdown("")
|
| 284 |
+
|
| 285 |
+
vid_table = gr.Dataframe(
|
| 286 |
+
headers=["Plate", "Digits", "Letters", "Special Level", "Brand", "Model", "Segment", "Est. Value", "Power"],
|
| 287 |
+
datatype=["str", "str", "str", "str", "str", "str", "str", "str", "str"],
|
| 288 |
+
label="Surveillance Logs",
|
| 289 |
+
interactive=False,
|
| 290 |
+
wrap=True
|
| 291 |
+
)
|
| 292 |
+
vid_status = gr.Markdown("")
|
| 293 |
+
vid_btn.click(fn=process_video, inputs=[vid_input], outputs=[vid_summary, vid_table, vid_status])
|
| 294 |
+
|
| 295 |
+
return demo
|
| 296 |
+
|
| 297 |
+
if __name__ == "__main__":
|
| 298 |
+
build_ui().launch(server_name="127.0.0.1", server_port=7862, theme=gr.themes.Soft(), share=False)
|
requirements-space.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal dependencies for Hugging Face Spaces (Gradio SDK)
|
| 2 |
+
gradio>=4.44.0
|
| 3 |
+
opencv-python-headless>=4.8.0
|
| 4 |
+
numpy>=1.24.0,<3
|
| 5 |
+
ultralytics>=8.3.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
huggingface_hub>=0.23.0
|
| 8 |
+
torch>=2.0.0
|
| 9 |
+
torchvision>=0.15.0
|
| 10 |
+
Pillow>=10.0.0
|
| 11 |
+
pandas>=2.0.0
|
requirements.backend.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
pydantic
|
| 4 |
+
python-multipart
|
| 5 |
+
python-dotenv
|
| 6 |
+
supabase
|
| 7 |
+
opencv-python
|
| 8 |
+
numpy
|
| 9 |
+
ultralytics
|
| 10 |
+
Pillow
|
| 11 |
+
torch
|
| 12 |
+
huggingface-hub
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces — Gradio demo (see requirements.backend.txt for API server)
|
| 2 |
+
gradio>=4.44.0
|
| 3 |
+
opencv-python-headless>=4.8.0
|
| 4 |
+
numpy>=1.24.0,<3
|
| 5 |
+
ultralytics>=8.3.0
|
| 6 |
+
python-dotenv>=1.0.0
|
| 7 |
+
huggingface_hub>=0.23.0
|
| 8 |
+
torch>=2.0.0
|
| 9 |
+
torchvision>=0.15.0
|
| 10 |
+
Pillow>=10.0.0
|
| 11 |
+
pandas>=2.0.0
|
server.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces entrypoint.
|
| 3 |
+
|
| 4 |
+
Spaces expect `app.py` at the repository root. It re-exports the Gradio demo
|
| 5 |
+
defined in app_gradio.py (same UI, GRADIO_SPACE env already set there).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from app_gradio import demo
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
demo.launch()
|
src/capture/Diagnose.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
EALPR — Camera Diagnostic
|
| 3 |
+
──────────────────────────
|
| 4 |
+
Reads 5 seconds of frames from your webcam and prints the
|
| 5 |
+
actual sharpness + brightness scores so you can set the
|
| 6 |
+
right thresholds in config.py.
|
| 7 |
+
|
| 8 |
+
Run with:
|
| 9 |
+
python diagnose.py
|
| 10 |
+
python diagnose.py --camera 0
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import cv2
|
| 14 |
+
import numpy as np
|
| 15 |
+
import argparse
|
| 16 |
+
import time
|
| 17 |
+
|
| 18 |
+
def diagnose(camera_source=0):
|
| 19 |
+
print("═" * 52)
|
| 20 |
+
print(" EALPR — Camera Diagnostic")
|
| 21 |
+
print(" Reading 5 seconds of frames...")
|
| 22 |
+
print(" Move around naturally in front of the camera.")
|
| 23 |
+
print("═" * 52)
|
| 24 |
+
|
| 25 |
+
cap = cv2.VideoCapture(camera_source)
|
| 26 |
+
if not cap.isOpened():
|
| 27 |
+
print(f"❌ Cannot open camera: {camera_source}")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 31 |
+
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 32 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 33 |
+
print(f"\nCamera: {w}×{h} @ {fps:.1f} FPS\n")
|
| 34 |
+
|
| 35 |
+
sharpness_scores = []
|
| 36 |
+
brightness_scores = []
|
| 37 |
+
start = time.time()
|
| 38 |
+
|
| 39 |
+
while time.time() - start < 5.0:
|
| 40 |
+
ret, frame = cap.read()
|
| 41 |
+
if not ret:
|
| 42 |
+
break
|
| 43 |
+
|
| 44 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 45 |
+
sharpness = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 46 |
+
brightness = gray.mean()
|
| 47 |
+
|
| 48 |
+
sharpness_scores.append(sharpness)
|
| 49 |
+
brightness_scores.append(brightness)
|
| 50 |
+
|
| 51 |
+
# Live preview
|
| 52 |
+
cv2.putText(frame, f"Sharpness : {sharpness:.1f}",
|
| 53 |
+
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2)
|
| 54 |
+
cv2.putText(frame, f"Brightness: {brightness:.1f}",
|
| 55 |
+
(10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,255,0), 2)
|
| 56 |
+
cv2.imshow("Diagnostic — press Q to stop early", frame)
|
| 57 |
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
| 58 |
+
break
|
| 59 |
+
|
| 60 |
+
cap.release()
|
| 61 |
+
cv2.destroyAllWindows()
|
| 62 |
+
|
| 63 |
+
if not sharpness_scores:
|
| 64 |
+
print("No frames captured.")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
s_min = min(sharpness_scores)
|
| 68 |
+
s_max = max(sharpness_scores)
|
| 69 |
+
s_mean = np.mean(sharpness_scores)
|
| 70 |
+
s_med = np.median(sharpness_scores)
|
| 71 |
+
|
| 72 |
+
b_min = min(brightness_scores)
|
| 73 |
+
b_max = max(brightness_scores)
|
| 74 |
+
b_mean = np.mean(brightness_scores)
|
| 75 |
+
|
| 76 |
+
print(f"── Sharpness Results ({'frames':>6}) ──────────────")
|
| 77 |
+
print(f" Min : {s_min:.1f}")
|
| 78 |
+
print(f" Max : {s_max:.1f}")
|
| 79 |
+
print(f" Mean : {s_mean:.1f}")
|
| 80 |
+
print(f" Median : {s_med:.1f}")
|
| 81 |
+
print()
|
| 82 |
+
print(f"── Brightness Results ───────────────────────")
|
| 83 |
+
print(f" Min : {b_min:.1f}")
|
| 84 |
+
print(f" Max : {b_max:.1f}")
|
| 85 |
+
print(f" Mean : {b_mean:.1f}")
|
| 86 |
+
print()
|
| 87 |
+
print(f"── Recommended config.py values ─────────────")
|
| 88 |
+
|
| 89 |
+
# Recommend threshold at 70% of median sharpness
|
| 90 |
+
recommended_sharpness = max(10.0, round(s_med * 0.7, 1))
|
| 91 |
+
recommended_min_brightness = max(10, int(b_min * 0.8))
|
| 92 |
+
recommended_max_brightness = min(245, int(b_max * 1.1))
|
| 93 |
+
|
| 94 |
+
print(f" MIN_SHARPNESS_SCORE = {recommended_sharpness}")
|
| 95 |
+
print(f" MIN_BRIGHTNESS = {recommended_min_brightness}")
|
| 96 |
+
print(f" MAX_BRIGHTNESS = {recommended_max_brightness}")
|
| 97 |
+
print()
|
| 98 |
+
print(" Copy these values into config.py and re-run capture.py")
|
| 99 |
+
print("═" * 52)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
parser = argparse.ArgumentParser()
|
| 104 |
+
parser.add_argument("--camera", default=0, type=int)
|
| 105 |
+
args = parser.parse_args()
|
| 106 |
+
diagnose(args.camera)
|
src/capture/__pycache__/capture.cpython-314.pyc
ADDED
|
Binary file (29.2 kB). View file
|
|
|
src/capture/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (1.67 kB). View file
|
|
|
src/capture/capture.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLI for video capture — uploads video to POST /api/detect (pipeline runs server-side).
|
| 3 |
+
|
| 4 |
+
For local processing without HTTP:
|
| 5 |
+
python capture.py --local
|
| 6 |
+
|
| 7 |
+
Legacy standalone loop with preview (uses app pipeline in-process):
|
| 8 |
+
python capture.py --local --preview
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# Project root on path so `app` package resolves
|
| 18 |
+
_ROOT = Path(__file__).resolve().parents[2]
|
| 19 |
+
if str(_ROOT) not in sys.path:
|
| 20 |
+
sys.path.insert(0, str(_ROOT))
|
| 21 |
+
|
| 22 |
+
import config as capture_config # noqa: E402
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def post_video_to_api(video_path: str, camera_id: str, api_url: str, max_triggers: int | None):
|
| 26 |
+
import requests
|
| 27 |
+
|
| 28 |
+
with open(video_path, "rb") as f:
|
| 29 |
+
data = {"camera_id": camera_id}
|
| 30 |
+
if max_triggers is not None:
|
| 31 |
+
data["max_triggers"] = str(max_triggers)
|
| 32 |
+
resp = requests.post(
|
| 33 |
+
api_url,
|
| 34 |
+
files={"file": (Path(video_path).name, f, "video/mp4")},
|
| 35 |
+
data=data,
|
| 36 |
+
timeout=getattr(capture_config, "API_TIMEOUT", 600),
|
| 37 |
+
)
|
| 38 |
+
print(f"Status: {resp.status_code}")
|
| 39 |
+
print(resp.text[:4000])
|
| 40 |
+
return resp.status_code == 200
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def run_local(video_source, trigger_mode: str | None, max_triggers: int | None, preview: bool):
|
| 44 |
+
from app.pipeline.coordinator import LPRPipeline
|
| 45 |
+
from app.pipeline.video_capture import CaptureSettings
|
| 46 |
+
|
| 47 |
+
settings = CaptureSettings(
|
| 48 |
+
trigger_mode=trigger_mode or getattr(capture_config, "TRIGGER_MODE", "yolo"),
|
| 49 |
+
process_every_n_frames=getattr(capture_config, "PROCESS_EVERY_N_FRAMES", 3),
|
| 50 |
+
frame_buffer_size=getattr(capture_config, "FRAME_BUFFER_SIZE", 20),
|
| 51 |
+
motion_threshold=getattr(capture_config, "MOTION_THRESHOLD", 1500),
|
| 52 |
+
mog2_history=getattr(capture_config, "MOG2_HISTORY", 500),
|
| 53 |
+
mog2_var_threshold=getattr(capture_config, "MOG2_VAR_THRESHOLD", 50),
|
| 54 |
+
cooldown_seconds=getattr(capture_config, "COOLDOWN_SECONDS", 2.0),
|
| 55 |
+
vehicle_model=getattr(capture_config, "VEHICLE_MODEL", "yolo11n.pt"),
|
| 56 |
+
vehicle_conf=getattr(capture_config, "VEHICLE_CONF", 0.40),
|
| 57 |
+
vehicle_min_box_area=getattr(capture_config, "VEHICLE_MIN_BOX_AREA", 3000),
|
| 58 |
+
vehicle_crop_padding=getattr(capture_config, "VEHICLE_CROP_PADDING", 50),
|
| 59 |
+
vehicle_filter_enabled=getattr(capture_config, "VEHICLE_FILTER_ENABLED", True),
|
| 60 |
+
max_vehicles_per_trigger=getattr(capture_config, "MAX_VEHICLES_PER_TRIGGER", 1),
|
| 61 |
+
min_sharpness_score=getattr(capture_config, "MIN_SHARPNESS_SCORE", 5.0),
|
| 62 |
+
min_brightness=getattr(capture_config, "MIN_BRIGHTNESS", 30),
|
| 63 |
+
max_brightness=getattr(capture_config, "MAX_BRIGHTNESS", 220),
|
| 64 |
+
max_triggers=max_triggers,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
print("Loading LPR pipeline (models may take a minute)...")
|
| 68 |
+
pipeline = LPRPipeline()
|
| 69 |
+
source = video_source if video_source is not None else capture_config.CAMERA_URL
|
| 70 |
+
print(f"Processing video: {source}")
|
| 71 |
+
result = pipeline.process_video(source, capture_settings=settings)
|
| 72 |
+
|
| 73 |
+
print(f"success: {result.get('success')}")
|
| 74 |
+
print(f"vehicles processed: {result.get('num_vehicles_processed')}")
|
| 75 |
+
print(f"plates found: {result.get('num_plates')}")
|
| 76 |
+
for ev in result.get("vehicle_events", []):
|
| 77 |
+
print(
|
| 78 |
+
f" trigger #{ev['trigger_index']} frame {ev['frame_index']} "
|
| 79 |
+
f"{ev['vehicle_label']} {ev['vehicle_conf']:.0%} → {ev['num_plates']} plate(s)"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if preview:
|
| 83 |
+
print("Preview not available in --local mode; use API upload or extend VideoCapturer.")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def main():
|
| 87 |
+
parser = argparse.ArgumentParser(description="EALPR video capture client")
|
| 88 |
+
parser.add_argument("--camera", default=None, help="Video path, RTSP URL, or 0 for webcam")
|
| 89 |
+
parser.add_argument("--camera-id", default=None, help="Camera UUID for /api/detect")
|
| 90 |
+
parser.add_argument("--local", action="store_true", help="Run pipeline in-process (no HTTP)")
|
| 91 |
+
parser.add_argument("--mode", choices=["yolo", "motion"], default=None)
|
| 92 |
+
parser.add_argument("--max-triggers", type=int, default=None, help="Max vehicles to process")
|
| 93 |
+
parser.add_argument(
|
| 94 |
+
"--endpoint",
|
| 95 |
+
default=getattr(capture_config, "API_ENDPOINT", "http://localhost:8000/api/detect"),
|
| 96 |
+
)
|
| 97 |
+
args = parser.parse_args()
|
| 98 |
+
|
| 99 |
+
source = args.camera
|
| 100 |
+
if source is not None and str(source).isdigit():
|
| 101 |
+
source = int(source)
|
| 102 |
+
|
| 103 |
+
camera_id = args.camera_id or getattr(capture_config, "CAMERA_ID", "")
|
| 104 |
+
video_path = source if source is not None else capture_config.CAMERA_URL
|
| 105 |
+
|
| 106 |
+
if args.local:
|
| 107 |
+
run_local(video_path, args.mode, args.max_triggers, preview=False)
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
if not camera_id:
|
| 111 |
+
print("Set CAMERA_ID in config.py or pass --camera-id")
|
| 112 |
+
sys.exit(1)
|
| 113 |
+
|
| 114 |
+
if not post_video_to_api(str(video_path), camera_id, args.endpoint, args.max_triggers):
|
| 115 |
+
sys.exit(1)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
src/capture/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────
|
| 2 |
+
# EALPR — Video Capture Configuration
|
| 3 |
+
# ─────────────────────────────────────────────
|
| 4 |
+
|
| 5 |
+
# ── Camera Settings ───────────────────────────
|
| 6 |
+
# Use 0 for the default webcam (testing)
|
| 7 |
+
# Use an RTSP URL for a real IP camera, e.g.:
|
| 8 |
+
# "rtsp://admin:password@192.168.1.100:554/stream1"
|
| 9 |
+
# Use a file path to process a local video, e.g.:
|
| 10 |
+
# "1900-151662242.mp4" or r"C:\path\to\video.mp4"
|
| 11 |
+
|
| 12 |
+
# ▼ Switch between sources by commenting / uncommenting:
|
| 13 |
+
#CAMERA_URL = r"d:\Uni y3s2\ML\Project_Main_ML\1900-151662242.mp4" # local video
|
| 14 |
+
CAMERA_URL = "http://10.48.140.136:4747/video" # IP camera (DroidCam)
|
| 15 |
+
# CAMERA_URL = 0 # default webcam
|
| 16 |
+
|
| 17 |
+
# Frames per second to READ from the stream
|
| 18 |
+
# (does not need to match camera FPS — we sample every N frames)
|
| 19 |
+
# OPTIMIZATION: Changed from 3 to 12 to jump over identical redundant frames rapidly
|
| 20 |
+
PROCESS_EVERY_N_FRAMES = 12
|
| 21 |
+
|
| 22 |
+
# ── Frame Buffer ──────────────────────────────
|
| 23 |
+
# How many frames to keep in memory before picking the best one
|
| 24 |
+
# More = better chance of catching a sharp frame, but more memory
|
| 25 |
+
# OPTIMIZATION: Raised to 45 to give the selector a broader pool to find the sharpest snapshot
|
| 26 |
+
FRAME_BUFFER_SIZE = 45
|
| 27 |
+
|
| 28 |
+
# ── Motion / Trigger Settings ─────────────────
|
| 29 |
+
# Minimum number of changed pixels to count as "vehicle present"
|
| 30 |
+
MIN_MOTION_AREA = 500 # Lower = more sensitive to small objects / distance
|
| 31 |
+
|
| 32 |
+
# Max frame count a vehicle can go without motion before ending the cluster event
|
| 33 |
+
MAX_STATIONARY_FRAMES = 15 # Lower = splits slow traffic into separate events
|
| 34 |
+
|
| 35 |
+
# Quality/Selection rules:
|
| 36 |
+
MIN_PLATE_CONFIDENCE = 0.40 # discard frames where plate detection is weak
|
| 37 |
+
MAX_PLATE_ANGLE = 15.0 # degrees max skew allowed (if computed)
|
| 38 |
+
|
| 39 |
+
# Brightness constraints to filter out garbage flashes
|
| 40 |
+
MIN_BRIGHTNESS = 30 # widened for dark/night scenes in video
|
| 41 |
+
MAX_BRIGHTNESS = 220 # widened for bright scenes in video
|
| 42 |
+
|
| 43 |
+
# ── Model Settings ────────────────────────────
|
| 44 |
+
CONF_THRESHOLD = 0.25
|
| 45 |
+
USE_HUGGING_FACE = True
|
| 46 |
+
|
| 47 |
+
HF_TOKEN = None
|
| 48 |
+
HF_PLATE_DETECTOR_REPO = "alyhassankamel/egyptian_license_plate_detector"
|
| 49 |
+
HF_CHAR_OCR_REPO = "alyhassankamel/egyptian-license-plate-ocr"
|
| 50 |
+
HF_CAR_CLASSIFIER_REPO = "PatrickBLB/egyptian_cars_recognition_computer_vision_model"
|
| 51 |
+
|
| 52 |
+
from pathlib import Path
|
| 53 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 54 |
+
PLATE_DETECTOR_MODEL_PATH = str(PROJECT_ROOT / "weights" / "detector_best.pt")
|
| 55 |
+
CHAR_OCR_MODEL_PATH = str(PROJECT_ROOT / "weights" / "ocr_best.pt")
|
| 56 |
+
|
| 57 |
+
# ── Output / Storage ──────────────────────────
|
| 58 |
+
import os
|
| 59 |
+
|
| 60 |
+
# Directory where captured frames are saved
|
| 61 |
+
OUTPUT_DIR = "captured_frames"
|
| 62 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 63 |
+
|
| 64 |
+
# Save every triggered frame to disk? True = yes, False = send to API only
|
| 65 |
+
SAVE_FRAMES_TO_DISK = True
|
| 66 |
+
|
| 67 |
+
# Image format for saved frames
|
| 68 |
+
SAVE_FORMAT = ".jpg"
|
| 69 |
+
JPEG_QUALITY = 95 # 0–100
|
| 70 |
+
|
| 71 |
+
# ── API Settings ──────────────────────────────
|
| 72 |
+
# FastAPI /api/detect endpoint.
|
| 73 |
+
# Set to None to skip sending and only save locally.
|
| 74 |
+
API_ENDPOINT = "http://localhost:8000/api/detect" # ← FastAPI server
|
| 75 |
+
API_TIMEOUT = 60 # seconds (LPR pipeline can be slow on CPU)
|
| 76 |
+
|
| 77 |
+
# Camera UUID registered in the 'cameras' table (Supabase).
|
| 78 |
+
# Every detection is tagged with this ID so the dashboard knows
|
| 79 |
+
# which physical camera triggered the event.
|
| 80 |
+
# Create a camera first: POST /api/cameras → copy the returned UUID here.
|
| 81 |
+
CAMERA_ID = "70a649c0-4ad7-4a85-bc12-9e7144e4a777"
|
| 82 |
+
|
| 83 |
+
# ── Display / Debug ───────────────────────────
|
| 84 |
+
# Show live preview window while running? Set False on headless servers
|
| 85 |
+
SHOW_PREVIEW = True
|
| 86 |
+
|
| 87 |
+
# Draw motion mask overlay on the preview?
|
| 88 |
+
SHOW_MOTION_MASK = False
|
| 89 |
+
|
| 90 |
+
# Print verbose console status?
|
| 91 |
+
VERBOSE_LOGGING = True
|
src/capture/register_camera.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Register a camera via POST /api/cameras and optionally save CAMERA_ID to config.py.
|
| 3 |
+
|
| 4 |
+
Usage (FastAPI must be running on port 8000):
|
| 5 |
+
python register_camera.py
|
| 6 |
+
python register_camera.py --name "Gate A" --type entrance --write-config
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import re
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
import requests
|
| 14 |
+
|
| 15 |
+
import config
|
| 16 |
+
|
| 17 |
+
DEFAULT_API = "http://localhost:8000"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def create_camera(api_base: str, location_name: str, location_type: str) -> dict:
|
| 21 |
+
url = f"{api_base.rstrip('/')}/api/cameras"
|
| 22 |
+
resp = requests.post(
|
| 23 |
+
url,
|
| 24 |
+
json={"location_name": location_name, "location_type": location_type},
|
| 25 |
+
timeout=15,
|
| 26 |
+
)
|
| 27 |
+
if resp.status_code != 201:
|
| 28 |
+
print(f"Failed ({resp.status_code}): {resp.text}")
|
| 29 |
+
sys.exit(1)
|
| 30 |
+
return resp.json()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def write_camera_id_to_config(camera_id: str) -> None:
|
| 34 |
+
config_path = config.__file__
|
| 35 |
+
with open(config_path, encoding="utf-8") as f:
|
| 36 |
+
text = f.read()
|
| 37 |
+
|
| 38 |
+
if re.search(r'^CAMERA_ID\s*=', text, flags=re.MULTILINE):
|
| 39 |
+
text = re.sub(
|
| 40 |
+
r'^CAMERA_ID\s*=.*$',
|
| 41 |
+
f'CAMERA_ID = "{camera_id}"',
|
| 42 |
+
text,
|
| 43 |
+
count=1,
|
| 44 |
+
flags=re.MULTILINE,
|
| 45 |
+
)
|
| 46 |
+
else:
|
| 47 |
+
text += f'\nCAMERA_ID = "{camera_id}"\n'
|
| 48 |
+
|
| 49 |
+
with open(config_path, "w", encoding="utf-8") as f:
|
| 50 |
+
f.write(text)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main():
|
| 54 |
+
parser = argparse.ArgumentParser(description="Register a camera for capture → API integration")
|
| 55 |
+
parser.add_argument("--api", default=DEFAULT_API, help="FastAPI base URL")
|
| 56 |
+
parser.add_argument("--name", default="Video test camera", help="location_name")
|
| 57 |
+
parser.add_argument("--type", default="entrance", help="location_type")
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--write-config",
|
| 60 |
+
action="store_true",
|
| 61 |
+
help="Write returned UUID into config.py as CAMERA_ID",
|
| 62 |
+
)
|
| 63 |
+
args = parser.parse_args()
|
| 64 |
+
|
| 65 |
+
print(f"Creating camera at {args.api}/api/cameras ...")
|
| 66 |
+
try:
|
| 67 |
+
row = create_camera(args.api, args.name, args.type)
|
| 68 |
+
except requests.exceptions.ConnectionError:
|
| 69 |
+
print("Cannot reach API. Start the server first:")
|
| 70 |
+
print(' cd vehicle-intelligence-dashboard')
|
| 71 |
+
print(" python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000")
|
| 72 |
+
sys.exit(1)
|
| 73 |
+
|
| 74 |
+
camera_id = row["id"]
|
| 75 |
+
print(f"\nCamera created:")
|
| 76 |
+
print(f" id : {camera_id}")
|
| 77 |
+
print(f" location_name : {row['location_name']}")
|
| 78 |
+
print(f" location_type : {row['location_type']}")
|
| 79 |
+
|
| 80 |
+
if args.write_config:
|
| 81 |
+
write_camera_id_to_config(camera_id)
|
| 82 |
+
print(f"\nWrote CAMERA_ID to {config.__file__}")
|
| 83 |
+
else:
|
| 84 |
+
print(f"\nAdd to config.py: CAMERA_ID = \"{camera_id}\"")
|
| 85 |
+
print(f"Or run capture with: --camera-id {camera_id}")
|
| 86 |
+
|
| 87 |
+
print("\nTest detect with a saved crop:")
|
| 88 |
+
print(
|
| 89 |
+
f' curl -X POST "{args.api}/api/detect" '
|
| 90 |
+
f'-F "file=@captured_frames/plate_....jpg" '
|
| 91 |
+
f'-F "camera_id={camera_id}"'
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
main()
|
src/capture/test_capture.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
EALPR — Capture System Test Suite
|
| 3 |
+
───────────────────────────────────
|
| 4 |
+
Tests every component of capture.py independently
|
| 5 |
+
so you can verify each piece works before connecting
|
| 6 |
+
a real camera or the FastAPI backend.
|
| 7 |
+
|
| 8 |
+
Run with:
|
| 9 |
+
python test_capture.py
|
| 10 |
+
python xtest_capture.py --image path/to/car.jpg
|
| 11 |
+
python test_capture.py --video path/to/clip.mp4
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
import os
|
| 17 |
+
import argparse
|
| 18 |
+
import time
|
| 19 |
+
from collections import deque
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
_ROOT = Path(__file__).resolve().parents[2]
|
| 26 |
+
if str(_ROOT) not in sys.path:
|
| 27 |
+
sys.path.insert(0, str(_ROOT))
|
| 28 |
+
|
| 29 |
+
import config
|
| 30 |
+
from app.pipeline.video_capture import VideoCapturer
|
| 31 |
+
|
| 32 |
+
_capturer = VideoCapturer()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def sharpness_score(frame):
|
| 36 |
+
return _capturer.sharpness_score(frame)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def brightness_ok(frame):
|
| 40 |
+
gray = __import__("cv2").cvtColor(frame, __import__("cv2").COLOR_BGR2GRAY)
|
| 41 |
+
return config.MIN_BRIGHTNESS <= gray.mean() <= config.MAX_BRIGHTNESS
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def frame_is_usable(frame):
|
| 45 |
+
if not brightness_ok(frame):
|
| 46 |
+
return False, "brightness out of range"
|
| 47 |
+
score = sharpness_score(frame)
|
| 48 |
+
if score < config.MIN_SHARPNESS_SCORE:
|
| 49 |
+
return False, f"too blurry (score={score:.1f})"
|
| 50 |
+
return True, ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def best_frame_from_buffer(buffer):
|
| 54 |
+
return _capturer.best_frame_from_buffer(buffer)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def save_vehicle_crop(crop, vehicle_idx, timestamp):
|
| 58 |
+
import cv2
|
| 59 |
+
filename = f"plate_{timestamp}_v{vehicle_idx}{config.SAVE_FORMAT}"
|
| 60 |
+
filepath = os.path.join(config.OUTPUT_DIR, filename)
|
| 61 |
+
cv2.imwrite(filepath, crop, [cv2.IMWRITE_JPEG_QUALITY, config.JPEG_QUALITY])
|
| 62 |
+
return filepath
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ─────────────────────────────────────────────
|
| 66 |
+
# Helpers
|
| 67 |
+
# ─────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
def make_synthetic_frame(brightness: int = 120, blur_ksize: int = 1) -> np.ndarray:
|
| 70 |
+
"""Creates a fake frame for testing quality functions."""
|
| 71 |
+
# White text on dark background (simulates a plate)
|
| 72 |
+
frame = np.full((480, 640, 3), brightness, dtype=np.uint8)
|
| 73 |
+
cv2.putText(frame, "ABC 1234", (80, 260),
|
| 74 |
+
cv2.FONT_HERSHEY_SIMPLEX, 4, (255, 255, 255), 8)
|
| 75 |
+
if blur_ksize > 1:
|
| 76 |
+
frame = cv2.GaussianBlur(frame, (blur_ksize, blur_ksize), 0)
|
| 77 |
+
return frame
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def pass_fail(condition: bool) -> str:
|
| 81 |
+
return "✅ PASS" if condition else "❌ FAIL"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ─────────────────────────────────────────────
|
| 85 |
+
# Unit tests
|
| 86 |
+
# ─────────────────────────────────────────────
|
| 87 |
+
|
| 88 |
+
def test_sharpness():
|
| 89 |
+
print("\n── Test 1: Sharpness Scoring ─────────────────")
|
| 90 |
+
sharp = make_synthetic_frame(blur_ksize=1)
|
| 91 |
+
blurry = make_synthetic_frame(blur_ksize=51)
|
| 92 |
+
|
| 93 |
+
s_sharp = sharpness_score(sharp)
|
| 94 |
+
s_blurry = sharpness_score(blurry)
|
| 95 |
+
|
| 96 |
+
print(f" Sharp frame score : {s_sharp:.1f}")
|
| 97 |
+
print(f" Blurry frame score : {s_blurry:.1f}")
|
| 98 |
+
print(f" Sharp > Blurry : {pass_fail(s_sharp > s_blurry)}")
|
| 99 |
+
print(f" Sharp passes gate : {pass_fail(s_sharp >= config.MIN_SHARPNESS_SCORE)}")
|
| 100 |
+
print(f" Blurry fails gate : {pass_fail(s_blurry < config.MIN_SHARPNESS_SCORE)}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_brightness():
|
| 104 |
+
print("\n── Test 2: Brightness Check ──────────────────")
|
| 105 |
+
normal = make_synthetic_frame(brightness=120)
|
| 106 |
+
too_dark = make_synthetic_frame(brightness=10)
|
| 107 |
+
blown_out = make_synthetic_frame(brightness=250)
|
| 108 |
+
|
| 109 |
+
print(f" Normal frame ok : {pass_fail(brightness_ok(normal))}")
|
| 110 |
+
print(f" Dark frame rejected: {pass_fail(not brightness_ok(too_dark))}")
|
| 111 |
+
print(f" Blown out rejected : {pass_fail(not brightness_ok(blown_out))}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_usability():
|
| 115 |
+
print("\n── Test 3: Combined Usability Gate ──────────")
|
| 116 |
+
good = make_synthetic_frame(brightness=120, blur_ksize=1)
|
| 117 |
+
bad = make_synthetic_frame(brightness=5, blur_ksize=51)
|
| 118 |
+
|
| 119 |
+
ok_good, _ = frame_is_usable(good)
|
| 120 |
+
ok_bad, why = frame_is_usable(bad)
|
| 121 |
+
|
| 122 |
+
print(f" Good frame passes : {pass_fail(ok_good)}")
|
| 123 |
+
print(f" Bad frame rejected : {pass_fail(not ok_bad)} (reason: {why})")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_best_frame_selection():
|
| 127 |
+
print("\n── Test 4: Best Frame from Buffer ───────────")
|
| 128 |
+
buffer = deque(maxlen=20)
|
| 129 |
+
|
| 130 |
+
# Fill with increasingly sharp frames
|
| 131 |
+
for blur in [51, 31, 21, 11, 1]:
|
| 132 |
+
buffer.append(make_synthetic_frame(blur_ksize=blur))
|
| 133 |
+
|
| 134 |
+
best = best_frame_from_buffer(buffer)
|
| 135 |
+
if best is None:
|
| 136 |
+
print(" ❌ FAIL — no frame selected")
|
| 137 |
+
return
|
| 138 |
+
|
| 139 |
+
score = sharpness_score(best)
|
| 140 |
+
print(f" Best frame score : {score:.1f}")
|
| 141 |
+
print(f" Correctly sharpest : {pass_fail(score >= config.MIN_SHARPNESS_SCORE)}")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_save_frame():
|
| 145 |
+
print("\n── Test 5: Save Frame to Disk ───────────────")
|
| 146 |
+
frame = make_synthetic_frame()
|
| 147 |
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 148 |
+
filepath = save_vehicle_crop(frame, vehicle_idx=1, timestamp=ts)
|
| 149 |
+
|
| 150 |
+
exists = os.path.isfile(filepath)
|
| 151 |
+
size = os.path.getsize(filepath) if exists else 0
|
| 152 |
+
|
| 153 |
+
print(f" File created : {pass_fail(exists)}")
|
| 154 |
+
print(f" File not empty : {pass_fail(size > 0)}")
|
| 155 |
+
print(f" Saved to : {filepath}")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_motion_detection():
|
| 159 |
+
print("\n── Test 6: Motion Detection (MOG2) ──────────")
|
| 160 |
+
fgbg = cv2.createBackgroundSubtractorMOG2(
|
| 161 |
+
history=config.MOG2_HISTORY,
|
| 162 |
+
varThreshold=config.MOG2_VAR_THRESHOLD,
|
| 163 |
+
detectShadows=True,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
background = make_synthetic_frame(brightness=80, blur_ksize=1)
|
| 167 |
+
# "Prime" the background subtractor
|
| 168 |
+
for _ in range(30):
|
| 169 |
+
fgbg.apply(background)
|
| 170 |
+
|
| 171 |
+
# Now introduce a "car" (bright rectangle = large foreground change)
|
| 172 |
+
car_frame = background.copy()
|
| 173 |
+
cv2.rectangle(car_frame, (100, 150), (540, 350), (200, 200, 200), -1)
|
| 174 |
+
|
| 175 |
+
mask = fgbg.apply(car_frame)
|
| 176 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 177 |
+
cleaned = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
| 178 |
+
pixels = cv2.countNonZero(cleaned)
|
| 179 |
+
|
| 180 |
+
print(f" Foreground pixels : {pixels}")
|
| 181 |
+
print(f" Trigger fires : {pass_fail(pixels > config.MOTION_THRESHOLD)}")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ─────────────────────────────────────────────
|
| 185 |
+
# Integration test on a real image
|
| 186 |
+
# ─────────────────────────────────────────────
|
| 187 |
+
|
| 188 |
+
def test_with_image(image_path: str):
|
| 189 |
+
print(f"\n── Integration Test: Real Image ─────────────")
|
| 190 |
+
print(f" Path: {image_path}")
|
| 191 |
+
|
| 192 |
+
frame = cv2.imread(image_path)
|
| 193 |
+
if frame is None:
|
| 194 |
+
print(f" ❌ Cannot load image: {image_path}")
|
| 195 |
+
return
|
| 196 |
+
|
| 197 |
+
h, w = frame.shape[:2]
|
| 198 |
+
print(f" Dimensions : {w}×{h}")
|
| 199 |
+
|
| 200 |
+
score = sharpness_score(frame)
|
| 201 |
+
ok, reason = frame_is_usable(frame)
|
| 202 |
+
|
| 203 |
+
print(f" Sharpness : {score:.1f}")
|
| 204 |
+
print(f" Usable : {pass_fail(ok)}", f"({reason})" if not ok else "")
|
| 205 |
+
|
| 206 |
+
if ok:
|
| 207 |
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 208 |
+
filepath = save_vehicle_crop(frame, vehicle_idx=1, timestamp=ts)
|
| 209 |
+
print(f" Saved to : {filepath}")
|
| 210 |
+
|
| 211 |
+
# Show the frame
|
| 212 |
+
cv2.imshow("Test Image", frame)
|
| 213 |
+
print(" (Press any key to close preview)")
|
| 214 |
+
cv2.waitKey(0)
|
| 215 |
+
cv2.destroyAllWindows()
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ─────────────────────────────────────────────
|
| 219 |
+
# Integration test on a real video clip
|
| 220 |
+
# ─────────────────────────────────────────────
|
| 221 |
+
|
| 222 |
+
def test_with_video(video_path: str):
|
| 223 |
+
print(f"\n── Integration Test: Real Video Clip ────────")
|
| 224 |
+
print(f" Path: {video_path}")
|
| 225 |
+
|
| 226 |
+
cap = cv2.VideoCapture(video_path)
|
| 227 |
+
if not cap.isOpened():
|
| 228 |
+
print(f" ❌ Cannot open video: {video_path}")
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 232 |
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
| 233 |
+
print(f" Frames: {total_frames} FPS: {fps:.1f}")
|
| 234 |
+
|
| 235 |
+
fgbg = cv2.createBackgroundSubtractorMOG2(
|
| 236 |
+
history=config.MOG2_HISTORY,
|
| 237 |
+
varThreshold=config.MOG2_VAR_THRESHOLD,
|
| 238 |
+
detectShadows=True,
|
| 239 |
+
)
|
| 240 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 241 |
+
buffer = deque(maxlen=config.FRAME_BUFFER_SIZE)
|
| 242 |
+
triggers = 0
|
| 243 |
+
last_trigger = 0
|
| 244 |
+
|
| 245 |
+
frame_idx = 0
|
| 246 |
+
while True:
|
| 247 |
+
ret, frame = cap.read()
|
| 248 |
+
if not ret:
|
| 249 |
+
break
|
| 250 |
+
|
| 251 |
+
frame_idx += 1
|
| 252 |
+
buffer.append(frame.copy())
|
| 253 |
+
|
| 254 |
+
if frame_idx % config.PROCESS_EVERY_N_FRAMES != 0:
|
| 255 |
+
continue
|
| 256 |
+
|
| 257 |
+
fgmask = fgbg.apply(frame)
|
| 258 |
+
cleaned = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel)
|
| 259 |
+
pixels = cv2.countNonZero(cleaned)
|
| 260 |
+
|
| 261 |
+
now = time.time()
|
| 262 |
+
if pixels > config.MOTION_THRESHOLD and (now - last_trigger) > config.COOLDOWN_SECONDS:
|
| 263 |
+
triggers += 1
|
| 264 |
+
last_trigger = now
|
| 265 |
+
best = best_frame_from_buffer(buffer)
|
| 266 |
+
if best is not None:
|
| 267 |
+
score = sharpness_score(best)
|
| 268 |
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 269 |
+
filepath = save_vehicle_crop(best, vehicle_idx=1, timestamp=ts)
|
| 270 |
+
print(f" 🚗 Trigger #{triggers} at frame {frame_idx} "
|
| 271 |
+
f"sharpness={score:.1f} → {os.path.basename(filepath)}")
|
| 272 |
+
|
| 273 |
+
cap.release()
|
| 274 |
+
print(f"\n Total triggers : {triggers}")
|
| 275 |
+
print(f" Frames saved : {triggers}")
|
| 276 |
+
print(f" {pass_fail(triggers > 0)}")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ─────────────────────────────────────────────
|
| 280 |
+
# Entry point
|
| 281 |
+
# ─────────────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
parser = argparse.ArgumentParser(description="EALPR Capture System Tests")
|
| 285 |
+
parser.add_argument("--image", default=None, help="Path to a test car image")
|
| 286 |
+
parser.add_argument("--video", default=None, help="Path to a test video clip")
|
| 287 |
+
args = parser.parse_args()
|
| 288 |
+
|
| 289 |
+
print("═" * 52)
|
| 290 |
+
print(" EALPR Capture System — Test Suite")
|
| 291 |
+
print("═" * 52)
|
| 292 |
+
|
| 293 |
+
# Always run unit tests
|
| 294 |
+
test_sharpness()
|
| 295 |
+
test_brightness()
|
| 296 |
+
test_usability()
|
| 297 |
+
test_best_frame_selection()
|
| 298 |
+
test_save_frame()
|
| 299 |
+
test_motion_detection()
|
| 300 |
+
|
| 301 |
+
# Optional integration tests
|
| 302 |
+
if args.image:
|
| 303 |
+
test_with_image(args.image)
|
| 304 |
+
|
| 305 |
+
if args.video:
|
| 306 |
+
test_with_video(args.video)
|
| 307 |
+
|
| 308 |
+
print("\n" + "═" * 52)
|
| 309 |
+
print(" All unit tests complete.")
|
| 310 |
+
if not args.image and not args.video:
|
| 311 |
+
print(" Tip: run with --image car.jpg or --video clip.mp4")
|
| 312 |
+
print(" for integration tests on real footage.")
|
| 313 |
+
print("═" * 52)
|
src/capture/test_detect_api.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
POST a video (or image) to /api/detect.
|
| 3 |
+
|
| 4 |
+
Usage (FastAPI running):
|
| 5 |
+
python test_detect_api.py
|
| 6 |
+
python test_detect_api.py --video "d:\\path\\to\\file.mp4"
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import glob
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
import config
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
parser = argparse.ArgumentParser(description="Test POST /api/detect with video or image")
|
| 21 |
+
parser.add_argument("--video", default=None, help="Path to MP4/video file")
|
| 22 |
+
parser.add_argument("--image", default=None, help="Path to a single crop image (legacy)")
|
| 23 |
+
parser.add_argument("--camera-id", default=None)
|
| 24 |
+
parser.add_argument(
|
| 25 |
+
"--endpoint",
|
| 26 |
+
default=getattr(config, "API_ENDPOINT", "http://localhost:8000/api/detect"),
|
| 27 |
+
)
|
| 28 |
+
parser.add_argument("--max-triggers", type=int, default=5, help="Limit vehicles for test")
|
| 29 |
+
args = parser.parse_args()
|
| 30 |
+
|
| 31 |
+
camera_id = args.camera_id or getattr(config, "CAMERA_ID", "")
|
| 32 |
+
if not camera_id:
|
| 33 |
+
print("CAMERA_ID empty. Run: python register_camera.py --write-config")
|
| 34 |
+
sys.exit(1)
|
| 35 |
+
|
| 36 |
+
path = args.video
|
| 37 |
+
content_type = "video/mp4"
|
| 38 |
+
if not path:
|
| 39 |
+
if args.image:
|
| 40 |
+
path = args.image
|
| 41 |
+
content_type = "image/jpeg"
|
| 42 |
+
else:
|
| 43 |
+
path = getattr(config, "CAMERA_URL", None)
|
| 44 |
+
if path and os.path.isfile(str(path)) and str(path).lower().endswith(
|
| 45 |
+
(".mp4", ".avi", ".mov", ".mkv")
|
| 46 |
+
):
|
| 47 |
+
content_type = "video/mp4"
|
| 48 |
+
else:
|
| 49 |
+
candidates = sorted(glob.glob(os.path.join(config.OUTPUT_DIR, "plate_*.jpg")))
|
| 50 |
+
if candidates:
|
| 51 |
+
path = candidates[-1]
|
| 52 |
+
content_type = "image/jpeg"
|
| 53 |
+
else:
|
| 54 |
+
print("Provide --video or set CAMERA_URL to an mp4 in config.py")
|
| 55 |
+
sys.exit(1)
|
| 56 |
+
|
| 57 |
+
if not os.path.isfile(path):
|
| 58 |
+
print(f"File not found: {path}")
|
| 59 |
+
sys.exit(1)
|
| 60 |
+
|
| 61 |
+
print(f"POST {args.endpoint}")
|
| 62 |
+
print(f" file : {path}")
|
| 63 |
+
print(f" type : {content_type}")
|
| 64 |
+
print(f" camera_id : {camera_id}")
|
| 65 |
+
print(f" max_triggers: {args.max_triggers}")
|
| 66 |
+
|
| 67 |
+
with open(path, "rb") as f:
|
| 68 |
+
resp = requests.post(
|
| 69 |
+
args.endpoint,
|
| 70 |
+
files={"file": (os.path.basename(path), f, content_type)},
|
| 71 |
+
data={"camera_id": camera_id, "max_triggers": str(args.max_triggers)},
|
| 72 |
+
timeout=getattr(config, "API_TIMEOUT", 600),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
print(f"\nStatus: {resp.status_code}")
|
| 76 |
+
print(resp.text[:5000])
|
| 77 |
+
sys.exit(0 if resp.status_code == 200 else 1)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
src/capture/yolov8n.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36
|
| 3 |
+
size 6549796
|
src/car_recognition/car_scraper.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import requests
|
| 3 |
+
import re
|
| 4 |
+
import time
|
| 5 |
+
import random
|
| 6 |
+
import argparse
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from urllib.parse import unquote, quote
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 10 |
+
from scrapling.fetchers import StealthyFetcher
|
| 11 |
+
from exterior_filterer import ExteriorFilterer
|
| 12 |
+
|
| 13 |
+
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
| 14 |
+
MAX_IMAGES = 100
|
| 15 |
+
BETWEEN_DELAY_MIN = 3
|
| 16 |
+
BETWEEN_DELAY_MAX = 6
|
| 17 |
+
DOWNLOAD_TIMEOUT = 10
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CarImageScraper:
|
| 21 |
+
def __init__(self, output_dir=None):
|
| 22 |
+
self.output_dir = output_dir or Path(__file__).parent.parent.parent / "datasets/car_images_scrapler/"
|
| 23 |
+
self.session = requests.Session()
|
| 24 |
+
self.session.headers.update({'User-Agent': USER_AGENT})
|
| 25 |
+
self.exterior_filterer = ExteriorFilterer()
|
| 26 |
+
|
| 27 |
+
def download_car_images(self, csv_path, start=0, end=None):
|
| 28 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
df = pd.read_csv(csv_path)
|
| 30 |
+
|
| 31 |
+
titles = df['title'].tolist()[start:end]
|
| 32 |
+
|
| 33 |
+
for index, title in enumerate(titles):
|
| 34 |
+
print(f"[{index + 1}/{len(titles)}] Processing: {title}")
|
| 35 |
+
try:
|
| 36 |
+
car_folder = self._make_car_folder(title)
|
| 37 |
+
existing = list(car_folder.glob("*.jpg"))
|
| 38 |
+
if len(existing) >= MAX_IMAGES:
|
| 39 |
+
print(f" Already have {len(existing)} images, skipping.")
|
| 40 |
+
continue
|
| 41 |
+
|
| 42 |
+
img_urls = self._search_google_images(title)
|
| 43 |
+
if img_urls:
|
| 44 |
+
count = self._download_images(img_urls, car_folder)
|
| 45 |
+
print(f" Downloaded {count} images for: {title}")
|
| 46 |
+
print(f" Running exterior filter on: {car_folder}")
|
| 47 |
+
self.exterior_filterer.filter_images(str(car_folder))
|
| 48 |
+
self._reindex_images(car_folder)
|
| 49 |
+
else:
|
| 50 |
+
print(f" No images found for: {title}")
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f" Error processing {title}: {e}")
|
| 53 |
+
|
| 54 |
+
if index < len(titles) - 1:
|
| 55 |
+
delay = random.uniform(BETWEEN_DELAY_MIN, BETWEEN_DELAY_MAX)
|
| 56 |
+
print(f" Waiting {delay:.1f}s before next search...")
|
| 57 |
+
time.sleep(delay)
|
| 58 |
+
|
| 59 |
+
def _search_google_images(self, query):
|
| 60 |
+
search_query = f"{query} car exterior"
|
| 61 |
+
|
| 62 |
+
# Step 1: Regular Google search to find the Images tab link
|
| 63 |
+
search_url = f"https://www.google.com/search?q={quote(search_query)}&hl=en"
|
| 64 |
+
print(f" Step 1: Fetching regular search page...")
|
| 65 |
+
try:
|
| 66 |
+
page = StealthyFetcher.fetch(search_url, headless=True)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
print(f" StealthyFetcher failed on search page: {e}")
|
| 69 |
+
return []
|
| 70 |
+
|
| 71 |
+
# Find the Images tab link from the navigation
|
| 72 |
+
images_url = None
|
| 73 |
+
links = page.css('a')
|
| 74 |
+
for link in links:
|
| 75 |
+
href = link.attrib.get('href', '')
|
| 76 |
+
text = link.text or ''
|
| 77 |
+
if 'Images' in text or 'images' in text:
|
| 78 |
+
if href.startswith('/'):
|
| 79 |
+
images_url = f"https://www.google.com{href}"
|
| 80 |
+
elif href.startswith('http'):
|
| 81 |
+
images_url = href
|
| 82 |
+
print(f" Found Images tab link: {images_url[:80]}...")
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
if not images_url:
|
| 86 |
+
# Fallback: use udm=2 which is Google's images mode
|
| 87 |
+
images_url = f"https://www.google.com/search?q={quote(search_query)}&udm=2&hl=en"
|
| 88 |
+
print(f" Images tab not found in nav, using udm=2 fallback")
|
| 89 |
+
|
| 90 |
+
# Step 2: Fetch the Images results page
|
| 91 |
+
print(f" Step 2: Fetching images page...")
|
| 92 |
+
time.sleep(random.uniform(1, 2))
|
| 93 |
+
try:
|
| 94 |
+
images_page = StealthyFetcher.fetch(images_url, headless=True)
|
| 95 |
+
html = images_page.html_content if hasattr(images_page, 'html_content') else str(images_page)
|
| 96 |
+
except Exception as e:
|
| 97 |
+
print(f" StealthyFetcher failed on images page: {e}")
|
| 98 |
+
return []
|
| 99 |
+
|
| 100 |
+
all_urls = self._extract_image_urls_from_html(html)
|
| 101 |
+
|
| 102 |
+
# Deduplicate while preserving order
|
| 103 |
+
seen = set()
|
| 104 |
+
unique_urls = []
|
| 105 |
+
for u in all_urls:
|
| 106 |
+
if u not in seen and len(u) > 20:
|
| 107 |
+
seen.add(u)
|
| 108 |
+
unique_urls.append(u)
|
| 109 |
+
|
| 110 |
+
print(f" Found {len(unique_urls)} unique image URLs from Google")
|
| 111 |
+
return unique_urls
|
| 112 |
+
|
| 113 |
+
def _extract_image_urls_from_html(self, html):
|
| 114 |
+
all_urls = []
|
| 115 |
+
|
| 116 |
+
# Pattern 1: Full-res image URLs in script tags ["url", width, height]
|
| 117 |
+
matches = re.findall(
|
| 118 |
+
r'\["(https?://[^"]+\.(?:jpg|jpeg|png|webp)(?:\?[^"]*)?)",\s*\d+,\s*\d+\]',
|
| 119 |
+
html
|
| 120 |
+
)
|
| 121 |
+
for m in matches:
|
| 122 |
+
cleaned = m.replace('\\u003d', '=').replace('\\u0026', '&')
|
| 123 |
+
if not self._is_google_domain(cleaned):
|
| 124 |
+
all_urls.append(cleaned)
|
| 125 |
+
|
| 126 |
+
# Pattern 2: data-ou attributes (direct image URLs)
|
| 127 |
+
ou_matches = re.findall(r'data-ou="(https?://[^"]+)"', html)
|
| 128 |
+
for m in ou_matches:
|
| 129 |
+
all_urls.append(unquote(m))
|
| 130 |
+
|
| 131 |
+
# Pattern 3: imgurl= in href links
|
| 132 |
+
imgurl_matches = re.findall(r'imgurl=(https?://[^&"]+)', html)
|
| 133 |
+
for m in imgurl_matches:
|
| 134 |
+
all_urls.append(unquote(m))
|
| 135 |
+
|
| 136 |
+
# Pattern 4: Broad image URL extraction from embedded script data
|
| 137 |
+
script_matches = re.findall(
|
| 138 |
+
r'(https?://[^\s"\\,\]\)]+\.(?:jpg|jpeg|png|webp))',
|
| 139 |
+
html
|
| 140 |
+
)
|
| 141 |
+
for m in script_matches:
|
| 142 |
+
cleaned = m.replace('\\u003d', '=').replace('\\u0026', '&')
|
| 143 |
+
if not self._is_google_domain(cleaned):
|
| 144 |
+
all_urls.append(cleaned)
|
| 145 |
+
|
| 146 |
+
return all_urls
|
| 147 |
+
|
| 148 |
+
def _is_google_domain(self, url):
|
| 149 |
+
blocked = ['gstatic.com', 'google.com', 'googleapis.com', 'googleusercontent.com', 'ggpht.com']
|
| 150 |
+
return any(domain in url for domain in blocked)
|
| 151 |
+
|
| 152 |
+
def _make_car_folder(self, title):
|
| 153 |
+
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '_', '-')).rstrip()
|
| 154 |
+
car_folder = self.output_dir / safe_title.replace(' ', '_')
|
| 155 |
+
car_folder.mkdir(parents=True, exist_ok=True)
|
| 156 |
+
return car_folder
|
| 157 |
+
|
| 158 |
+
def _download_single_image(self, img_url):
|
| 159 |
+
"""Download a single image, return bytes or None."""
|
| 160 |
+
try:
|
| 161 |
+
resp = self.session.get(img_url, timeout=DOWNLOAD_TIMEOUT)
|
| 162 |
+
if resp.status_code != 200:
|
| 163 |
+
return None
|
| 164 |
+
content_type = resp.headers.get('Content-Type', '')
|
| 165 |
+
if not content_type.startswith('image/'):
|
| 166 |
+
return None
|
| 167 |
+
if len(resp.content) < 5000:
|
| 168 |
+
return None
|
| 169 |
+
return resp.content
|
| 170 |
+
except Exception as e:
|
| 171 |
+
print(f" Failed to download {img_url}: {e}")
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
def _download_images(self, img_urls, car_folder, max_workers=20):
|
| 175 |
+
# Filter out logo/icon URLs first
|
| 176 |
+
filtered_urls = [u for u in img_urls if 'logo' not in u.lower() and 'icon' not in u.lower()]
|
| 177 |
+
# Only submit up to MAX_IMAGES + buffer to account for failures
|
| 178 |
+
candidate_urls = filtered_urls[:MAX_IMAGES + 50]
|
| 179 |
+
|
| 180 |
+
count = 0
|
| 181 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 182 |
+
future_to_url = {
|
| 183 |
+
executor.submit(self._download_single_image, url): url
|
| 184 |
+
for url in candidate_urls
|
| 185 |
+
}
|
| 186 |
+
for future in as_completed(future_to_url):
|
| 187 |
+
if count >= MAX_IMAGES:
|
| 188 |
+
break
|
| 189 |
+
img_data = future.result()
|
| 190 |
+
if img_data:
|
| 191 |
+
with open(car_folder / f"{car_folder.name}_{count}.jpg", 'wb') as f:
|
| 192 |
+
f.write(img_data)
|
| 193 |
+
count += 1
|
| 194 |
+
return count
|
| 195 |
+
|
| 196 |
+
def _reindex_images(self, car_folder):
|
| 197 |
+
images = sorted(car_folder.glob("*.jpg"), key=lambda f: f.stat().st_mtime)
|
| 198 |
+
for new_index, img_path in enumerate(images):
|
| 199 |
+
new_name = car_folder / f"{car_folder.name}_{new_index}.jpg"
|
| 200 |
+
if img_path != new_name:
|
| 201 |
+
img_path.rename(new_name)
|
| 202 |
+
print(f" Reindexed {len(images)} remaining images in: {car_folder.name}")
|
| 203 |
+
|
| 204 |
+
def _extract_car_id(self, title):
|
| 205 |
+
return title.replace(' ', '_')
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
parser = argparse.ArgumentParser(description="Scrape car images from Google via Scrapling")
|
| 210 |
+
parser.add_argument("--start", type=int, default=0, help="Start index in CSV (inclusive)")
|
| 211 |
+
parser.add_argument("--end", type=int, default=None, help="End index in CSV (exclusive)")
|
| 212 |
+
args = parser.parse_args()
|
| 213 |
+
|
| 214 |
+
csv_path = Path(__file__).parent.parent.parent / "datasets/hatla2ee_cars_unique.csv"
|
| 215 |
+
scraper = CarImageScraper()
|
| 216 |
+
scraper.download_car_images(csv_path, start=args.start, end=args.end)
|
src/car_recognition/exterior_filterer.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import List
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from transformers import CLIPProcessor, CLIPModel
|
| 9 |
+
|
| 10 |
+
class ExteriorFilterer:
|
| 11 |
+
def __init__(self, model_id="openai/clip-vit-base-patch32"):
|
| 12 |
+
self.device = "mps" if torch.backends.mps.is_available() else "cpu"
|
| 13 |
+
print(f"Loading CLIP model {model_id} on {self.device}...")
|
| 14 |
+
self.model = CLIPModel.from_pretrained(model_id).to(self.device)
|
| 15 |
+
self.processor = CLIPProcessor.from_pretrained(model_id)
|
| 16 |
+
self.labels = [
|
| 17 |
+
"a photo of the outside of a car showing the full body, paint, wheels, or bumper",
|
| 18 |
+
"a photo of the inside of a car showing the dashboard, steering wheel, seats, or center console",
|
| 19 |
+
"a photo of a car engine or motor under the hood",
|
| 20 |
+
"a photo of the trunk or boot of a car, open or closed, showing cargo area",
|
| 21 |
+
]
|
| 22 |
+
self.extensions = {".jpg", ".jpeg", ".png", ".webp"}
|
| 23 |
+
|
| 24 |
+
def filter_images(self, input_folder: str, dry_run: bool = False):
|
| 25 |
+
input_path = Path(input_folder)
|
| 26 |
+
if not input_path.is_dir():
|
| 27 |
+
print(f"Error: {input_folder} is not a valid directory.")
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
all_images = [f for f in input_path.rglob("*") if f.is_file() and f.suffix.lower() in self.extensions]
|
| 31 |
+
total_images = len(all_images)
|
| 32 |
+
|
| 33 |
+
if total_images == 0:
|
| 34 |
+
print("No valid images found in the specified directory.")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
kept_count = 0
|
| 38 |
+
deleted_count = 0
|
| 39 |
+
|
| 40 |
+
print(f"Processing {total_images} images...")
|
| 41 |
+
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
for i, img_path in enumerate(all_images, 1):
|
| 44 |
+
try:
|
| 45 |
+
image = Image.open(img_path).convert("RGB")
|
| 46 |
+
|
| 47 |
+
inputs = self.processor(
|
| 48 |
+
text=self.labels,
|
| 49 |
+
images=image,
|
| 50 |
+
return_tensors="pt",
|
| 51 |
+
padding=True
|
| 52 |
+
).to(self.device)
|
| 53 |
+
|
| 54 |
+
outputs = self.model(**inputs)
|
| 55 |
+
probs = outputs.logits_per_image.softmax(dim=1)
|
| 56 |
+
|
| 57 |
+
# Keep only if exterior (index 0) has the highest probability
|
| 58 |
+
best_label = probs[0].argmax().item()
|
| 59 |
+
if best_label != 0:
|
| 60 |
+
if dry_run:
|
| 61 |
+
print(f"[{i}/{total_images}] would delete: {img_path.name}")
|
| 62 |
+
else:
|
| 63 |
+
img_path.unlink()
|
| 64 |
+
print(f"[{i}/{total_images}] deleted: {img_path.name}")
|
| 65 |
+
deleted_count += 1
|
| 66 |
+
else:
|
| 67 |
+
kept_count += 1
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[{i}/{total_images}] Warning: Could not process {img_path.name} due to error: {e}")
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
print("\n--- Summary ---")
|
| 74 |
+
print(f"Total processed: {total_images}")
|
| 75 |
+
print(f"Kept: {kept_count}")
|
| 76 |
+
print(f"Deleted: {deleted_count}")
|
| 77 |
+
if dry_run:
|
| 78 |
+
print("Note: This was a dry run, no files were actually deleted.")
|
| 79 |
+
|
| 80 |
+
def main():
|
| 81 |
+
parser = argparse.ArgumentParser(description="Filter car images by removing interiors using CLIP zero-shot classification.")
|
| 82 |
+
parser.add_argument("--input", type=str, default="datasets/car_images_v3", help="Path to the folder containing car images")
|
| 83 |
+
parser.add_argument("--dry-run", action="store_true", help="Print what would be deleted without actually deleting")
|
| 84 |
+
args = parser.parse_args()
|
| 85 |
+
|
| 86 |
+
filterer = ExteriorFilterer()
|
| 87 |
+
filterer.filter_images(args.input, dry_run=args.dry_run)
|
| 88 |
+
|
| 89 |
+
if __name__ == "__main__":
|
| 90 |
+
main()
|
thisishowtorun.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Terminal 1 — FastAPI backend (from project root)
|
| 2 |
+
npm
|
| 3 |
+
|
| 4 |
+
# Terminal 2 — Frontend dashboard
|
| 5 |
+
npm run dev
|
| 6 |
+
|
| 7 |
+
# Terminal 3 — Upload video to API (from src/capture/)
|
| 8 |
+
# python register_camera.py --write-config # once, while API is up
|
| 9 |
+
# python capture.py # POST video → /api/detect (pipeline server-side)
|
| 10 |
+
# python test_detect_api.py --max-triggers 5
|
| 11 |
+
# python capture.py --local # run VideoCapturer + LPR without HTTP
|
yolo11n.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0ebbc80d4a7680d14987a577cd21342b65ecfd94632bd9a8da63ae6417644ee1
|
| 3 |
+
size 5613764
|