Files changed (48) hide show
  1. .gitattributes +1 -35
  2. .gitignore +0 -0
  3. app/__init__.py +0 -0
  4. app/config.py +130 -0
  5. app/database/__pycache__/connection.cpython-314.pyc +0 -0
  6. app/database/__pycache__/logger.cpython-314.pyc +0 -0
  7. app/database/connection.py +83 -0
  8. app/database/logger.py +113 -0
  9. app/database/supabase/config.toml +1 -0
  10. app/database/supabase/migrations/20260508175106_d61705db-2487-451d-ad0c-3940fa1fb5b0.sql +61 -0
  11. app/main.py +113 -0
  12. app/pipeline/__init__.py +0 -0
  13. app/pipeline/__pycache__/classifier.cpython-314.pyc +0 -0
  14. app/pipeline/__pycache__/coordinator.cpython-314.pyc +0 -0
  15. app/pipeline/__pycache__/detector.cpython-314.pyc +0 -0
  16. app/pipeline/__pycache__/ocr.cpython-314.pyc +0 -0
  17. app/pipeline/__pycache__/price_lookup.cpython-314.pyc +0 -0
  18. app/pipeline/__pycache__/scorer.cpython-314.pyc +0 -0
  19. app/pipeline/__pycache__/special_plate.cpython-314.pyc +0 -0
  20. app/pipeline/__pycache__/video_capture.cpython-314.pyc +0 -0
  21. app/pipeline/__pycache__/video_capturer.cpython-314.pyc +0 -0
  22. app/pipeline/classifier.py +156 -0
  23. app/pipeline/coordinator.py +228 -0
  24. app/pipeline/corrector.py +129 -0
  25. app/pipeline/detector.py +106 -0
  26. app/pipeline/enhancer.py +68 -0
  27. app/pipeline/ocr.py +110 -0
  28. app/pipeline/scorer.py +83 -0
  29. app/pipeline/special_plate.py +188 -0
  30. app/pipeline/video_capture.py +289 -0
  31. app_gradio.py +298 -0
  32. requirements-space.txt +11 -0
  33. requirements.backend.txt +12 -0
  34. requirements.txt +11 -0
  35. server.py +11 -0
  36. src/capture/Diagnose.py +106 -0
  37. src/capture/__pycache__/capture.cpython-314.pyc +0 -0
  38. src/capture/__pycache__/config.cpython-314.pyc +0 -0
  39. src/capture/capture.py +119 -0
  40. src/capture/config.py +91 -0
  41. src/capture/register_camera.py +96 -0
  42. src/capture/test_capture.py +313 -0
  43. src/capture/test_detect_api.py +81 -0
  44. src/capture/yolov8n.pt +3 -0
  45. src/car_recognition/car_scraper.py +216 -0
  46. src/car_recognition/exterior_filterer.py +90 -0
  47. thisishowtorun.md +11 -0
  48. yolo11n.pt +3 -0
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
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