har2312 Claude Opus 4.8 commited on
Commit
58a8d53
Β·
1 Parent(s): f09c2db

Integrate weather-adaptive edge preprocessor into the pipeline

Browse files

Wire DynamicTrafficPreprocessor (ultimate_edge_preprocessor.py) into the
upload pipeline as the front-end cleaning step:

- Detect scene condition (FOG/NIGHT/DAY-RAIN) and feed the cleaned 640x640
letterboxed frame to detection, violation rules, annotation and evidence.
- Run ANPR on the full-resolution, weather-corrected frame instead β€” detection
boxes are mapped back from 640x640 to original pixels (letterbox_params /
unletterbox_bbox) so plate detail isn't lost to downscaling.
- Preserve the UI quality report via preprocessor.assess() (no double-cleaning).
- Log the condition: terminal print, evidence-image label, metadata, and a new
weather_condition field on the /api/upload response.
- Share the enhancement chain between the 640 and full-res paths (_apply_chain)
and make matplotlib a lazy import so the headless backend can import the class.
- Guard against undecodable uploads with an HTTP 400.

Tooling/docs:
- config.py puts the repo root on sys.path so the root-level module imports
when the backend runs with CWD=backend/.
- run.bat / run.sh auto-detect .venv or venv.
- README documents the weather-adaptive design and the Python 3.10-3.12
requirement (pinned numpy has no 3.13 wheels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Readme.md CHANGED
@@ -3,10 +3,10 @@
3
  Automated photo identification & classification of traffic violations β€” built for
4
  the Flipkart Gridlock Hackathon 2.0 (Round 2).
5
 
6
- Upload a roadside camera frame and TrafficGuard preprocesses it, detects vehicles
7
- and riders with YOLOv8, flags violations (triple-riding today; helmet, seatbelt,
8
- red-light next), reads license plates, and stores annotated evidence with a
9
- confidence score.
10
 
11
  ## Stack
12
 
@@ -16,20 +16,45 @@ confidence score.
16
  | OCR | EasyOCR + Indian-plate regex |
17
  | Backend | FastAPI Β· SQLAlchemy Β· SQLite |
18
  | Frontend | React + Vite Β· Recharts |
19
- | Imaging | OpenCV (CLAHE, denoise, quality score) |
20
 
21
  ## Project layout
22
 
23
  ```
24
- backend/ FastAPI app β€” pipeline, models, DB, routes
25
- frontend/ React dashboard (Vite)
26
- data/ uploads + generated evidence
 
27
  ```
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  ## Run locally
30
 
31
  Works on macOS / Linux and Windows. Create the virtualenv once at the repo root.
32
 
 
 
 
 
33
  **1. Set up the backend env** (from the repo root)
34
 
35
  macOS / Linux:
 
3
  Automated photo identification & classification of traffic violations β€” built for
4
  the Flipkart Gridlock Hackathon 2.0 (Round 2).
5
 
6
+ Upload a roadside camera frame and TrafficGuard runs a weather-adaptive
7
+ preprocessor (fog / night / rain), detects vehicles and riders with YOLOv8,
8
+ flags violations (triple-riding today; helmet, seatbelt, red-light next), reads
9
+ license plates, and stores annotated evidence with a confidence score.
10
 
11
  ## Stack
12
 
 
16
  | OCR | EasyOCR + Indian-plate regex |
17
  | Backend | FastAPI Β· SQLAlchemy Β· SQLite |
18
  | Frontend | React + Vite Β· Recharts |
19
+ | Imaging | OpenCV β€” weather-adaptive edge preprocessor + quality score |
20
 
21
  ## Project layout
22
 
23
  ```
24
+ ultimate_edge_preprocessor.py weather-adaptive edge preprocessor (repo root)
25
+ backend/ FastAPI app β€” pipeline, models, DB, routes
26
+ frontend/ React dashboard (Vite)
27
+ data/ uploads + generated evidence
28
  ```
29
 
30
+ ## Weather-adaptive preprocessing
31
+
32
+ Every uploaded frame first passes through `DynamicTrafficPreprocessor`
33
+ ([ultimate_edge_preprocessor.py](ultimate_edge_preprocessor.py)), which detects
34
+ the scene condition from image statistics and routes it through the matching
35
+ correction chain:
36
+
37
+ | Condition | Detected by | Chain |
38
+ |------------|---------------------------------|----------------------------------------|
39
+ | `FOG` | low contrast + bright | inverted-image dehaze β†’ unsharp |
40
+ | `NIGHT` | low mean + bright point sources | adaptive low-light β†’ denoise β†’ unsharp |
41
+ | `DAY/RAIN` | everything else | edge-preserving denoise β†’ unsharp |
42
+
43
+ Detection, violation rules and annotated evidence run on a fast 640Γ—640
44
+ letterboxed frame, while **ANPR (plate OCR) runs on the full-resolution,
45
+ weather-corrected frame** β€” detection boxes are mapped back from 640Γ—640 to
46
+ original pixels so plate detail isn't lost to downscaling. The detected
47
+ condition is logged, stored in the evidence metadata, burned onto the evidence
48
+ image, and returned as `weather_condition` in the `/api/upload` response.
49
+
50
  ## Run locally
51
 
52
  Works on macOS / Linux and Windows. Create the virtualenv once at the repo root.
53
 
54
+ > **Use Python 3.10–3.12.** The pinned `numpy`/`ultralytics` versions have no
55
+ > Python 3.13 wheels β€” a 3.13 venv segfaults importing numpy. The launchers
56
+ > (`run.bat` / `run.sh`) auto-detect either a `.venv` or `venv` directory.
57
+
58
  **1. Set up the backend env** (from the repo root)
59
 
60
  macOS / Linux:
backend/app/config.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from pathlib import Path
2
 
3
  from pydantic_settings import BaseSettings
@@ -5,6 +6,12 @@ from pydantic_settings import BaseSettings
5
  BASE_DIR = Path(__file__).resolve().parent.parent.parent
6
  DATA_DIR = BASE_DIR / "data"
7
 
 
 
 
 
 
 
8
 
9
  class Settings(BaseSettings):
10
  app_name: str = "TrafficGuard AI"
 
1
+ import sys
2
  from pathlib import Path
3
 
4
  from pydantic_settings import BaseSettings
 
6
  BASE_DIR = Path(__file__).resolve().parent.parent.parent
7
  DATA_DIR = BASE_DIR / "data"
8
 
9
+ # The backend runs with CWD=backend/ (see run.bat / run.sh), so repo-root
10
+ # modules like `ultimate_edge_preprocessor` aren't importable by default.
11
+ # Put the repo root on sys.path so they can be imported package-wide.
12
+ if str(BASE_DIR) not in sys.path:
13
+ sys.path.insert(0, str(BASE_DIR))
14
+
15
 
16
  class Settings(BaseSettings):
17
  app_name: str = "TrafficGuard AI"
backend/app/models/preprocessor.py CHANGED
@@ -88,6 +88,20 @@ def normalize(image: np.ndarray, size: int = 640) -> np.ndarray:
88
  return canvas
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def preprocess(image: np.ndarray) -> tuple[np.ndarray, QualityReport]:
92
  """Assess the frame, apply needed corrections, return (image, report)."""
93
  sharp, mean, std = _metrics(image)
 
88
  return canvas
89
 
90
 
91
+ def assess(image: np.ndarray, condition: str | None = None) -> QualityReport:
92
+ """Score a frame's quality (0-100) WITHOUT modifying it.
93
+
94
+ Used after the weather-adaptive edge preprocessor has already cleaned and
95
+ resized the frame: we only want a quality report for the UI/metadata, not a
96
+ second round of enhancement. `condition` (FOG/NIGHT/DAY-RAIN), when given,
97
+ is surfaced as the applied correction.
98
+ """
99
+ sharp, mean, std = _metrics(image)
100
+ overall, s, b, c = _score(sharp, mean, std)
101
+ corrections = [f"Weather-adaptive: {condition}"] if condition else []
102
+ return QualityReport(score=overall, sharpness=s, brightness=b, contrast=c, corrections=corrections)
103
+
104
+
105
  def preprocess(image: np.ndarray) -> tuple[np.ndarray, QualityReport]:
106
  """Assess the frame, apply needed corrections, return (image, report)."""
107
  sharp, mean, std = _metrics(image)
backend/app/routes/upload.py CHANGED
@@ -1,7 +1,7 @@
1
  from dataclasses import asdict
2
  from datetime import datetime
3
 
4
- from fastapi import APIRouter, Depends, File, Form, UploadFile
5
  from sqlalchemy.orm import Session
6
 
7
  from app.database.db import get_db
@@ -11,31 +11,71 @@ from app.models.detector import detector, summarize
11
  from app.models.plates import plate_service
12
  from app.models.violation import analyze
13
  from app.schemas import AnalysisResult, BatchResult, ViolationOut
14
- from app.utils.annotator import annotate, watermark
15
  from app.utils.evidence import save_evidence, save_metadata, save_upload
16
 
 
 
 
 
17
  router = APIRouter()
18
 
 
 
 
 
 
19
 
20
  def process_image(raw: bytes, location: str, db: Session) -> AnalysisResult:
21
  """The full pipeline for one image β€” shared by /upload and /batch-upload."""
22
  upload_path, image = save_upload(raw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- enhanced, quality = preprocessor.preprocess(image)
25
- detections = detector.detect(enhanced)
26
- violations = analyze(detections, enhanced)
 
 
 
27
  road_users = summarize(detections)
28
 
29
- # For each violation, OCR the plate from the offending vehicle's crop only
 
 
 
 
 
 
 
 
 
30
  vehicles_by_id = {d["id"]: d for d in detections}
31
  for v in violations:
32
  vehicle = vehicles_by_id.get(v["vehicle_id"])
33
  bbox = vehicle["bbox"] if vehicle else v.get("bbox")
34
- v["license_plate"] = plate_service.read_from_vehicle(enhanced, bbox) if bbox else None
 
 
 
 
35
 
36
  captured_at = datetime.utcnow()
37
- annotated = annotate(enhanced, detections, violations)
38
  annotated = watermark(annotated, location, captured_at.strftime("%Y-%m-%d %H:%M"))
 
39
  evidence_path = save_evidence(annotated)
40
  evidence_id = evidence_path.stem
41
 
@@ -62,6 +102,7 @@ def process_image(raw: bytes, location: str, db: Session) -> AnalysisResult:
62
  "location": location,
63
  "original_image": upload_path.name,
64
  "annotated_image": evidence_path.name,
 
65
  "quality": asdict(quality),
66
  "road_users": road_users,
67
  "violations": [
@@ -72,6 +113,7 @@ def process_image(raw: bytes, location: str, db: Session) -> AnalysisResult:
72
 
73
  return AnalysisResult(
74
  quality=asdict(quality),
 
75
  detections=sum(r["count"] for r in road_users),
76
  road_users=road_users,
77
  violations=[ViolationOut.model_validate(r) for r in records],
 
1
  from dataclasses import asdict
2
  from datetime import datetime
3
 
4
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
5
  from sqlalchemy.orm import Session
6
 
7
  from app.database.db import get_db
 
11
  from app.models.plates import plate_service
12
  from app.models.violation import analyze
13
  from app.schemas import AnalysisResult, BatchResult, ViolationOut
14
+ from app.utils.annotator import annotate, label_condition, watermark
15
  from app.utils.evidence import save_evidence, save_metadata, save_upload
16
 
17
+ # Repo-root module (see app.config for the sys.path wiring that makes this import
18
+ # work when the backend runs with CWD=backend/).
19
+ from ultimate_edge_preprocessor import DynamicTrafficPreprocessor
20
+
21
  router = APIRouter()
22
 
23
+ # Weather-adaptive edge preprocessor. Instantiated ONCE at import time β€” never
24
+ # per request β€” so its prebuilt gamma LUT / CLAHE objects are reused across all
25
+ # frames, keeping per-image latency low.
26
+ edge_preprocessor = DynamicTrafficPreprocessor()
27
+
28
 
29
  def process_image(raw: bytes, location: str, db: Session) -> AnalysisResult:
30
  """The full pipeline for one image β€” shared by /upload and /batch-upload."""
31
  upload_path, image = save_upload(raw)
32
+ # Handle a missing / undecodable frame gracefully instead of crashing.
33
+ if image is None:
34
+ raise HTTPException(status_code=400, detail="Could not decode the uploaded image.")
35
+
36
+ # ── Weather-adaptive edge preprocessing ──────────────────────────────
37
+ # Detect the scene condition (FOG / NIGHT / DAY-RAIN) and return a cleaned,
38
+ # 640x640 letterboxed frame. Every downstream stage β€” detection, violation
39
+ # analysis, ANPR, annotation and evidence β€” runs on this single clean frame,
40
+ # so all bounding boxes live in the same 640x640 coordinate space and no
41
+ # rescaling is needed.
42
+ processed = edge_preprocessor.process(image)
43
+ clean_frame = processed["processed_uint8"]
44
+ weather_condition = processed["condition"]
45
+ print(f"[edge-preprocess] {upload_path.name}: weather condition = {weather_condition}")
46
 
47
+ # Quality report for the UI/metadata, scored on the cleaned frame (the edge
48
+ # preprocessor has already applied the condition-specific correction chain).
49
+ quality = preprocessor.assess(clean_frame, weather_condition)
50
+
51
+ detections = detector.detect(clean_frame)
52
+ violations = analyze(detections, clean_frame)
53
  road_users = summarize(detections)
54
 
55
+ # ANPR runs on the FULL-RESOLUTION, weather-corrected frame β€” letterboxing to
56
+ # 640x640 for detection throws away the plate detail OCR needs. Detection
57
+ # boxes are in 640x640 space, so each is mapped back to original pixels
58
+ # before cropping. (Built lazily: skipped entirely when there are no
59
+ # violations to read plates for.)
60
+ anpr_frame = (
61
+ edge_preprocessor.enhance_full_resolution(image, weather_condition)
62
+ if violations else None
63
+ )
64
+
65
  vehicles_by_id = {d["id"]: d for d in detections}
66
  for v in violations:
67
  vehicle = vehicles_by_id.get(v["vehicle_id"])
68
  bbox = vehicle["bbox"] if vehicle else v.get("bbox")
69
+ if bbox:
70
+ orig_bbox = edge_preprocessor.unletterbox_bbox(bbox, image.shape)
71
+ v["license_plate"] = plate_service.read_from_vehicle(anpr_frame, orig_bbox)
72
+ else:
73
+ v["license_plate"] = None
74
 
75
  captured_at = datetime.utcnow()
76
+ annotated = annotate(clean_frame, detections, violations)
77
  annotated = watermark(annotated, location, captured_at.strftime("%Y-%m-%d %H:%M"))
78
+ annotated = label_condition(annotated, weather_condition)
79
  evidence_path = save_evidence(annotated)
80
  evidence_id = evidence_path.stem
81
 
 
102
  "location": location,
103
  "original_image": upload_path.name,
104
  "annotated_image": evidence_path.name,
105
+ "weather_condition": weather_condition,
106
  "quality": asdict(quality),
107
  "road_users": road_users,
108
  "violations": [
 
113
 
114
  return AnalysisResult(
115
  quality=asdict(quality),
116
+ weather_condition=weather_condition,
117
  detections=sum(r["count"] for r in road_users),
118
  road_users=road_users,
119
  violations=[ViolationOut.model_validate(r) for r in records],
backend/app/schemas.py CHANGED
@@ -45,6 +45,7 @@ class ViolationStatusUpdate(BaseModel):
45
 
46
  class AnalysisResult(BaseModel):
47
  quality: QualityReport
 
48
  detections: int
49
  road_users: list[RoadUserCount]
50
  violations: list[ViolationOut]
 
45
 
46
  class AnalysisResult(BaseModel):
47
  quality: QualityReport
48
+ weather_condition: str | None = None
49
  detections: int
50
  road_users: list[RoadUserCount]
51
  violations: list[ViolationOut]
backend/app/utils/annotator.py CHANGED
@@ -39,6 +39,19 @@ def _box(img: np.ndarray, bbox: list[int], label: str, color: tuple) -> None:
39
  cv2.putText(img, label, (x1 + 2, y1 - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def watermark(image: np.ndarray, location: str, timestamp: str) -> np.ndarray:
43
  """Burn a provenance bar (brand Β· location Β· time) along the bottom."""
44
  h, w = image.shape[:2]
 
39
  cv2.putText(img, label, (x1 + 2, y1 - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
40
 
41
 
42
+ def label_condition(image: np.ndarray, condition: str) -> np.ndarray:
43
+ """Burn the active weather condition into the top-left of the evidence frame.
44
+
45
+ Drawn in place (callers pass an already-copied annotated frame). A black
46
+ outline under coloured text keeps it legible over any background.
47
+ """
48
+ text = f"Weather: {condition}"
49
+ org = (10, 26)
50
+ cv2.putText(image, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 3, cv2.LINE_AA)
51
+ cv2.putText(image, text, org, cv2.FONT_HERSHEY_SIMPLEX, 0.6, (60, 220, 255), 1, cv2.LINE_AA)
52
+ return image
53
+
54
+
55
  def watermark(image: np.ndarray, location: str, timestamp: str) -> np.ndarray:
56
  """Burn a provenance bar (brand Β· location Β· time) along the bottom."""
57
  h, w = image.shape[:2]
run.bat CHANGED
@@ -1,4 +1,15 @@
1
  @echo off
2
- REM Launch the backend with the project's .venv (Windows).
 
3
  cd /d "%~dp0backend"
4
- ..\.venv\Scripts\uvicorn app.main:app --reload --port 8000
 
 
 
 
 
 
 
 
 
 
 
1
  @echo off
2
+ REM Launch the backend with the project's virtualenv (Windows).
3
+ REM Auto-detects whichever venv (.venv or venv) actually has uvicorn installed.
4
  cd /d "%~dp0backend"
5
+
6
+ if exist "..\.venv\Scripts\uvicorn.exe" (
7
+ set "UVICORN=..\.venv\Scripts\uvicorn.exe"
8
+ ) else if exist "..\venv\Scripts\uvicorn.exe" (
9
+ set "UVICORN=..\venv\Scripts\uvicorn.exe"
10
+ ) else (
11
+ echo [ERROR] uvicorn not found in .venv or venv.
12
+ echo Create a venv with Python 3.10-3.12 and run: pip install -r backend\requirements.txt
13
+ exit /b 1
14
+ )
15
+ "%UVICORN%" app.main:app --reload --port 8000
run.sh CHANGED
@@ -1,5 +1,15 @@
1
  #!/usr/bin/env bash
2
- # Launch the backend with the project's .venv (avoids anaconda PATH clashes).
 
3
  set -e
4
  cd "$(dirname "$0")/backend"
5
- exec ../.venv/bin/uvicorn app.main:app --reload --port 8000
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env bash
2
+ # Launch the backend with the project's virtualenv (avoids anaconda PATH clashes).
3
+ # Auto-detects whichever venv (.venv or venv) actually has uvicorn installed.
4
  set -e
5
  cd "$(dirname "$0")/backend"
6
+
7
+ if [ -x "../.venv/bin/uvicorn" ]; then
8
+ exec ../.venv/bin/uvicorn app.main:app --reload --port 8000
9
+ elif [ -x "../venv/bin/uvicorn" ]; then
10
+ exec ../venv/bin/uvicorn app.main:app --reload --port 8000
11
+ else
12
+ echo "[ERROR] uvicorn not found in .venv or venv." >&2
13
+ echo "Create a venv with Python 3.10-3.12 and run: pip install -r backend/requirements.txt" >&2
14
+ exit 1
15
+ fi
ultimate_edge_preprocessor.py ADDED
@@ -0,0 +1,678 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ultimate_edge_preprocessor.py β€” Final Edge-Preprocessing Pipeline
3
+ ==================================================================
4
+ A weather-adaptive, condition-aware preprocessing pipeline for traffic
5
+ enforcement cameras. The system dynamically detects the environmental
6
+ condition (FOG Β· NIGHT Β· DAY/RAIN) from image statistics and routes
7
+ the frame through the optimal algorithmic chain.
8
+
9
+ Condition detection:
10
+ β€’ FOG β€” low RMS contrast + moderate-to-high mean intensity
11
+ (the hallmark of atmospheric scattering)
12
+ β€’ NIGHT β€” low mean intensity regardless of contrast
13
+ β€’ DAY / RAIN β€” everything else (well-lit, adequate contrast)
14
+
15
+ Processing chains:
16
+ FOG β†’ fast_dehaze β†’ unsharp_mask
17
+ NIGHT β†’ adaptive_lowlight_enhancement β†’ edge_preserving_denoise β†’ unsharp_mask
18
+ DAY β†’ edge_preserving_denoise β†’ unsharp_mask
19
+
20
+ Dependencies : opencv-python, numpy, matplotlib
21
+ Author : Auto-generated for Gridlock project
22
+ Date : 2026-06-20
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Dict, Tuple, Union
31
+
32
+ import cv2
33
+ import numpy as np
34
+ # NOTE: matplotlib is only needed by the CLI visualisation (`_show_comparison`)
35
+ # and is imported lazily there. Keeping it out of the module top-level lets the
36
+ # headless backend import `DynamicTrafficPreprocessor` without matplotlib
37
+ # installed (it is not in backend/requirements.txt).
38
+
39
+
40
+ # ──────────────────────────────────────────────────────────────────────
41
+ # Core Preprocessor Class
42
+ # ──────────────────────────────────────────────────────────────────────
43
+ class DynamicTrafficPreprocessor:
44
+ """
45
+ Production-grade, weather-adaptive image preprocessor.
46
+
47
+ Every public method is self-contained and can be individually
48
+ replaced with a deep-learning alternative (e.g., swap
49
+ `fast_dehaze` for a learned dehazing network) without touching the
50
+ rest of the pipeline.
51
+
52
+ Parameters
53
+ ----------
54
+ target_size : Tuple[int, int]
55
+ (width, height) of the YOLO input canvas. Default (640, 640).
56
+ fog_contrast_threshold : float
57
+ RMS contrast below which the scene is considered foggy
58
+ (provided mean intensity is also above `fog_mean_floor`).
59
+ Default 50.
60
+ fog_mean_floor : float
61
+ Minimum mean intensity required for the fog classification.
62
+ Fog scatters light β†’ the frame is *not* dark. Default 80.
63
+ night_mean_threshold : float
64
+ Mean intensity below which the scene is classified as night /
65
+ low-light. Default 75.
66
+ clahe_clip : float
67
+ CLAHE clip limit used inside the inverted-image dehaze.
68
+ Default 3.0.
69
+ clahe_grid : Tuple[int, int]
70
+ CLAHE tile-grid size. Default (8, 8).
71
+ gamma : float
72
+ Gamma exponent for the non-linear low-light curve. Values
73
+ > 1.0 lift shadows. Default 2.0.
74
+ bilateral_d : int
75
+ Bilateral filter neighbourhood diameter. Default 5.
76
+ bilateral_sigma_color : float
77
+ Bilateral colour-space sigma. Default 40.
78
+ bilateral_sigma_space : float
79
+ Bilateral coordinate-space sigma. Default 40.
80
+ unsharp_ksize : Tuple[int, int]
81
+ Gaussian kernel for the Unsharp Mask. Default (3, 3).
82
+ unsharp_sigma : float
83
+ Gaussian sigma for the Unsharp Mask. Default 1.0.
84
+ unsharp_weight : float
85
+ High-frequency amplification factor. Default 0.5
86
+ (mild β€” just enough to crisp licence-plate glyphs).
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ target_size: Tuple[int, int] = (640, 640),
92
+ fog_contrast_threshold: float = 50.0,
93
+ fog_mean_floor: float = 80.0,
94
+ night_mean_threshold: float = 75.0,
95
+ clahe_clip: float = 3.0,
96
+ clahe_grid: Tuple[int, int] = (8, 8),
97
+ gamma: float = 2.0,
98
+ bilateral_d: int = 5,
99
+ bilateral_sigma_color: float = 40.0,
100
+ bilateral_sigma_space: float = 40.0,
101
+ unsharp_ksize: Tuple[int, int] = (3, 3),
102
+ unsharp_sigma: float = 1.0,
103
+ unsharp_weight: float = 0.5,
104
+ ) -> None:
105
+ self.target_size = target_size
106
+ self.fog_contrast_threshold = fog_contrast_threshold
107
+ self.fog_mean_floor = fog_mean_floor
108
+ self.night_mean_threshold = night_mean_threshold
109
+ self.clahe_clip = clahe_clip
110
+ self.clahe_grid = clahe_grid
111
+ self.gamma = gamma
112
+ self.bilateral_d = bilateral_d
113
+ self.bilateral_sigma_color = bilateral_sigma_color
114
+ self.bilateral_sigma_space = bilateral_sigma_space
115
+ self.unsharp_ksize = unsharp_ksize
116
+ self.unsharp_sigma = unsharp_sigma
117
+ self.unsharp_weight = unsharp_weight
118
+
119
+ # Pre-build the gamma look-up table once (used by low-light path).
120
+ self._gamma_lut = self._build_gamma_lut(self.gamma)
121
+
122
+ # ──────────────────────────────────────────────────────────────────
123
+ # Internal helpers
124
+ # ──────────────────────────────────────────────────────────────────
125
+ @staticmethod
126
+ def _build_gamma_lut(gamma: float) -> np.ndarray:
127
+ """
128
+ 256-entry uint8 LUT: output = 255 Γ— (input / 255) ^ (1/gamma).
129
+
130
+ With gamma = 2.0:
131
+ β€’ input 10 β†’ output 50 (dark shadow lifted 5Γ—)
132
+ β€’ input 200 β†’ output 226 (bright pixel barely moves)
133
+ β€’ input 255 β†’ output 255 (headlight stays at max)
134
+ """
135
+ inv_gamma = 1.0 / gamma
136
+ table = np.array(
137
+ [np.clip(((i / 255.0) ** inv_gamma) * 255, 0, 255) for i in range(256)],
138
+ dtype=np.uint8,
139
+ )
140
+ return table
141
+
142
+ # ──────────────────────────────────────────────────────────────────
143
+ # Geometry β€” Letterbox parameters & inverse mapping
144
+ # ──────────────────────────────────────────────────────────────────
145
+ def letterbox_params(
146
+ self, image_shape: Tuple[int, ...], size: Tuple[int, int] = None
147
+ ) -> Tuple[float, int, int]:
148
+ """
149
+ Compute the (scale, pad_left, pad_top) used by `letterbox` for an
150
+ image of shape *image_shape*.
151
+
152
+ Exposed so callers can map coordinates between the original frame
153
+ and the letterboxed canvas without re-deriving (and risking
154
+ diverging from) the resize math. `letterbox` itself uses this,
155
+ guaranteeing the forward resize and the inverse mapping agree.
156
+ """
157
+ if size is None:
158
+ size = self.target_size
159
+
160
+ target_w, target_h = size
161
+ h, w = image_shape[:2]
162
+
163
+ scale = min(target_w / w, target_h / h)
164
+ new_w = int(w * scale)
165
+ new_h = int(h * scale)
166
+
167
+ pad_left = (target_w - new_w) // 2
168
+ pad_top = (target_h - new_h) // 2
169
+ return scale, pad_left, pad_top
170
+
171
+ def unletterbox_bbox(
172
+ self,
173
+ bbox: list,
174
+ image_shape: Tuple[int, ...],
175
+ size: Tuple[int, int] = None,
176
+ ) -> list:
177
+ """
178
+ Map a bounding box from letterboxed space (e.g. 640Γ—640) back to
179
+ the original image's pixel coordinates, clamped to image bounds.
180
+
181
+ Inverse of the letterbox transform:
182
+ orig = (coord βˆ’ pad) / scale
183
+
184
+ Parameters
185
+ ----------
186
+ bbox : [x1, y1, x2, y2] in letterboxed-canvas pixels.
187
+ image_shape : shape of the ORIGINAL image, (h, w, ...).
188
+ size : letterbox canvas size. Defaults to self.target_size.
189
+
190
+ Returns
191
+ -------
192
+ list[int] β€” [x1, y1, x2, y2] in original-image pixels.
193
+ """
194
+ scale, pad_left, pad_top = self.letterbox_params(image_shape, size)
195
+ h, w = image_shape[:2]
196
+ x1, y1, x2, y2 = bbox
197
+
198
+ ox1 = (x1 - pad_left) / scale
199
+ oy1 = (y1 - pad_top) / scale
200
+ ox2 = (x2 - pad_left) / scale
201
+ oy2 = (y2 - pad_top) / scale
202
+
203
+ return [
204
+ int(round(max(0, min(w, ox1)))),
205
+ int(round(max(0, min(h, oy1)))),
206
+ int(round(max(0, min(w, ox2)))),
207
+ int(round(max(0, min(h, oy2)))),
208
+ ]
209
+
210
+ # ──────────────────────────────────────────────────────────────────
211
+ # Stage 1 β€” Letterbox Resize
212
+ # ──────────────────────────────────────────────────────────────────
213
+ def letterbox(
214
+ self, image: np.ndarray, size: Tuple[int, int] = None
215
+ ) -> np.ndarray:
216
+ """
217
+ Resize *image* to fit inside *size* while preserving the aspect
218
+ ratio, padding the remainder with black bars.
219
+
220
+ This is always the FIRST step so every downstream filter
221
+ operates on the compact 640Γ—640 canvas, not the raw megapixel
222
+ frame.
223
+
224
+ Parameters
225
+ ----------
226
+ image : np.ndarray β€” BGR uint8, any resolution.
227
+ size : (w, h) β€” target canvas. Defaults to self.target_size.
228
+
229
+ Returns
230
+ -------
231
+ np.ndarray β€” BGR uint8, exactly (size[1], size[0], 3).
232
+ """
233
+ if size is None:
234
+ size = self.target_size
235
+
236
+ target_w, target_h = size
237
+ h, w = image.shape[:2]
238
+
239
+ # Shared geometry: identical to what unletterbox_bbox inverts.
240
+ scale, pad_left, pad_top = self.letterbox_params(image.shape, size)
241
+ new_w = int(w * scale)
242
+ new_h = int(h * scale)
243
+
244
+ # Choose interpolation: INTER_AREA for shrinking (antialiased),
245
+ # INTER_LINEAR for enlarging.
246
+ interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR
247
+ resized = cv2.resize(image, (new_w, new_h), interpolation=interp)
248
+
249
+ # Centre on a black canvas.
250
+ pad_bottom = target_h - new_h - pad_top
251
+ pad_right = target_w - new_w - pad_left
252
+
253
+ letterboxed = cv2.copyMakeBorder(
254
+ resized,
255
+ top=pad_top,
256
+ bottom=pad_bottom,
257
+ left=pad_left,
258
+ right=pad_right,
259
+ borderType=cv2.BORDER_CONSTANT,
260
+ value=(0, 0, 0),
261
+ )
262
+ return letterboxed
263
+
264
+ # ──────────────────────────────────────────────────────────────────
265
+ # Stage 2a β€” FOG: Inverted-Image Dehazing
266
+ # ──────────────────────────────────────────────────────────────────
267
+ def fast_dehaze(self, image: np.ndarray) -> np.ndarray:
268
+ """
269
+ Remove atmospheric haze / fog using the **inverted-image**
270
+ trick, which avoids the computationally expensive Dark Channel
271
+ Prior.
272
+
273
+ Algorithm
274
+ ---------
275
+ 1. Invert the image: I' = 255 βˆ’ I
276
+ β€’ Fog is additive white light β†’ inversion turns it into
277
+ dark regions, which is exactly what CLAHE excels at
278
+ enhancing.
279
+ 2. Convert I' to LAB and apply CLAHE to the L-channel.
280
+ β€’ This stretches the contrast of the (now-dark) fog regions
281
+ while leaving saturated areas (vehicles, signs) intact.
282
+ 3. Convert back to BGR and invert again: result = 255 βˆ’ I''
283
+ β€’ The double inversion cancels out, but the CLAHE
284
+ enhancement survives β€” effectively subtracting the
285
+ atmospheric scattering.
286
+
287
+ Parameters
288
+ ----------
289
+ image : np.ndarray β€” BGR uint8, 640Γ—640.
290
+
291
+ Returns
292
+ -------
293
+ np.ndarray β€” Dehazed BGR uint8, 640Γ—640.
294
+ """
295
+ # Step 1 β€” Invert the image.
296
+ # np.clip is not needed here because 255 - uint8 is always [0, 255].
297
+ inverted = cv2.bitwise_not(image)
298
+
299
+ # Step 2 β€” CLAHE on the L-channel of the inverted image.
300
+ lab = cv2.cvtColor(inverted, cv2.COLOR_BGR2LAB)
301
+ l_ch, a_ch, b_ch = cv2.split(lab)
302
+
303
+ clahe = cv2.createCLAHE(
304
+ clipLimit=self.clahe_clip,
305
+ tileGridSize=self.clahe_grid,
306
+ )
307
+ l_enhanced = clahe.apply(l_ch)
308
+
309
+ lab_enhanced = cv2.merge([l_enhanced, a_ch, b_ch])
310
+ enhanced_bgr = cv2.cvtColor(lab_enhanced, cv2.COLOR_LAB2BGR)
311
+
312
+ # Step 3 β€” Invert back to recover the original colour polarity.
313
+ dehazed = cv2.bitwise_not(enhanced_bgr)
314
+
315
+ return dehazed
316
+
317
+ # ──────────────────────────────────────────────────────────────────
318
+ # Stage 2b β€” NIGHT: Adaptive Low-Light Enhancement
319
+ # ──────────────────────────────────────────────────────────────────
320
+ def adaptive_lowlight_enhancement(self, image: np.ndarray) -> np.ndarray:
321
+ """
322
+ Lift dark shadows using gamma correction while leaving bright
323
+ pixels (headlights, streetlamps, reflective signs) untouched.
324
+
325
+ How it works
326
+ ------------
327
+ 1. Convert to grayscale to compute a per-pixel brightness map.
328
+ 2. Build a **dark-pixel weight mask**:
329
+ weight = 1.0 βˆ’ (gray / 255)
330
+ Dark pixels get weight β‰ˆ 1.0 (full gamma lift).
331
+ Bright pixels get weight β‰ˆ 0.0 (no change).
332
+ 3. Apply the gamma LUT to the entire image to get a brightened
333
+ version.
334
+ 4. Blend: output = weight Γ— gamma_image + (1 βˆ’ weight) Γ— original
335
+ This applies the correction *only where it is needed*.
336
+
337
+ The result: road surfaces and vehicles in shadow are clearly
338
+ visible, while headlights remain at their original intensity
339
+ with zero blooming.
340
+
341
+ Parameters
342
+ ----------
343
+ image : np.ndarray β€” BGR uint8, 640Γ—640.
344
+
345
+ Returns
346
+ -------
347
+ np.ndarray β€” Low-light enhanced BGR uint8, 640Γ—640.
348
+ """
349
+ # Compute per-pixel brightness (single-channel, fast).
350
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
351
+
352
+ # Weight mask: dark pixels β†’ 1.0, bright pixels β†’ 0.0.
353
+ # Shape: (H, W, 1) so it broadcasts over 3 BGR channels.
354
+ weight = (1.0 - gray.astype(np.float32) / 255.0)[:, :, np.newaxis]
355
+
356
+ # Apply gamma LUT uniformly (the mask will limit where it takes
357
+ # effect). cv2.LUT is a single vectorised C++ pass β€” ~0.05 ms.
358
+ gamma_image = cv2.LUT(image, self._gamma_lut)
359
+
360
+ # Blend: selective correction weighted by darkness.
361
+ blended = (
362
+ weight * gamma_image.astype(np.float32)
363
+ + (1.0 - weight) * image.astype(np.float32)
364
+ )
365
+
366
+ # Clip to [0, 255] to guarantee mathematical safety, then cast.
367
+ result = np.clip(blended, 0, 255).astype(np.uint8)
368
+
369
+ return result
370
+
371
+ # ──────────────────────────────────────────────────────────────────
372
+ # Stage 3 β€” RAIN / NOISE: Edge-Preserving Denoise
373
+ # ──────────────────────────────────────────────────────────────────
374
+ def edge_preserving_denoise(self, image: np.ndarray) -> np.ndarray:
375
+ """
376
+ Suppress sensor noise and thin rain streaks using a carefully
377
+ tuned bilateral filter.
378
+
379
+ Why bilateral?
380
+ --------------
381
+ The bilateral filter applies a Gaussian in *both* the spatial
382
+ domain and the colour-intensity domain simultaneously. This
383
+ means:
384
+ β€’ Smooth, homogeneous regions (sky, wet road, noise) are
385
+ blurred effectively β†’ noise / streaks vanish.
386
+ β€’ Strong edges (vehicle contours, licence-plate glyphs) see
387
+ a large colour-intensity difference across the boundary β†’
388
+ the filter refuses to blur across them.
389
+
390
+ The parameters (d=5, Οƒ_color=40, Οƒ_space=40) are deliberately
391
+ conservative β€” enough to clean rain but not enough to melt
392
+ fine detail.
393
+
394
+ Parameters
395
+ ----------
396
+ image : np.ndarray β€” BGR uint8.
397
+
398
+ Returns
399
+ -------
400
+ np.ndarray β€” Denoised BGR uint8.
401
+ """
402
+ denoised = cv2.bilateralFilter(
403
+ image,
404
+ d=self.bilateral_d,
405
+ sigmaColor=self.bilateral_sigma_color,
406
+ sigmaSpace=self.bilateral_sigma_space,
407
+ )
408
+ return denoised
409
+
410
+ # ──────────────────────────────────────────────────────────────────
411
+ # Stage 4 β€” Final Sharpening: Unsharp Mask
412
+ # ──────────────────────────────────────────────────────────────────
413
+ def unsharp_mask(self, image: np.ndarray) -> np.ndarray:
414
+ """
415
+ Apply a mild Unsharp Mask to crisp up licence-plate text,
416
+ vehicle contours, and lane markings.
417
+
418
+ Formula: sharpened = image + weight Γ— (image βˆ’ blur)
419
+
420
+ A weight of 0.5 with a small 3Γ—3 kernel gives just enough
421
+ edge pop without reintroducing noise or producing ringing
422
+ artefacts.
423
+
424
+ Parameters
425
+ ----------
426
+ image : np.ndarray β€” BGR uint8.
427
+
428
+ Returns
429
+ -------
430
+ np.ndarray β€” Sharpened BGR uint8.
431
+ """
432
+ blurred = cv2.GaussianBlur(
433
+ image,
434
+ ksize=self.unsharp_ksize,
435
+ sigmaX=self.unsharp_sigma,
436
+ )
437
+
438
+ # Compute in float64 to avoid uint8 underflow in the subtraction.
439
+ sharp = (
440
+ image.astype(np.float64)
441
+ + self.unsharp_weight
442
+ * (image.astype(np.float64) - blurred.astype(np.float64))
443
+ )
444
+
445
+ # Absolute safety: clip to valid range before casting.
446
+ return np.clip(sharp, 0, 255).astype(np.uint8)
447
+
448
+ # ──────────────────────────────────────────────────────────────────
449
+ # Condition-specific enhancement chain (size-agnostic)
450
+ # ──────────────────────────────────────────────────────────────────
451
+ def _apply_chain(self, frame: np.ndarray, condition: str) -> np.ndarray:
452
+ """
453
+ Run the enhancement chain for *condition* on a frame of ANY size.
454
+
455
+ Shared by `process` (on the 640Γ—640 canvas) and
456
+ `enhance_full_resolution` (on the native-resolution frame), so the
457
+ two can never drift apart.
458
+
459
+ FOG β†’ dehaze β†’ sharpen
460
+ NIGHT β†’ lowlight β†’ denoise β†’ sharpen
461
+ DAY/RAIN β†’ denoise β†’ sharpen (the default / fallback)
462
+ """
463
+ if condition == "FOG":
464
+ frame = self.fast_dehaze(frame)
465
+ frame = self.unsharp_mask(frame)
466
+ elif condition == "NIGHT":
467
+ frame = self.adaptive_lowlight_enhancement(frame)
468
+ frame = self.edge_preserving_denoise(frame)
469
+ frame = self.unsharp_mask(frame)
470
+ else: # DAY/RAIN and any unexpected label
471
+ frame = self.edge_preserving_denoise(frame)
472
+ frame = self.unsharp_mask(frame)
473
+ return frame
474
+
475
+ def enhance_full_resolution(
476
+ self, image: np.ndarray, condition: str
477
+ ) -> np.ndarray:
478
+ """
479
+ Apply the SAME condition chain at the image's native resolution,
480
+ WITHOUT letterboxing/downsizing.
481
+
482
+ Detection runs on the compact 640Γ—640 canvas for speed, but ANPR
483
+ needs every pixel of plate detail β€” downscaling to 640Γ—640 first
484
+ would make small plates unreadable. This produces a full-res,
485
+ weather-corrected frame to crop plates from, using the condition
486
+ already detected by `process`.
487
+
488
+ Parameters
489
+ ----------
490
+ image : np.ndarray β€” raw BGR uint8, any resolution.
491
+ condition : str β€” "FOG" / "NIGHT" / "DAY/RAIN" from process().
492
+
493
+ Returns
494
+ -------
495
+ np.ndarray β€” weather-corrected BGR uint8 at the ORIGINAL resolution.
496
+ """
497
+ return self._apply_chain(image.copy(), condition)
498
+
499
+ # ──────────────────────────────────────────────────────────────────
500
+ # Orchestrator β€” Dynamic Condition Routing
501
+ # ──────────────────────────────────────────────────────────────────
502
+ def process(self, image: np.ndarray) -> Dict[str, Union[np.ndarray, str]]:
503
+ """
504
+ Analyse the image and dynamically route it through the optimal
505
+ processing chain based on detected weather / lighting.
506
+
507
+ Detection metrics (computed on the 640Γ—640 letterboxed frame):
508
+ β€’ **mean_intensity** β€” average grayscale pixel value.
509
+ β€’ **rms_contrast** β€” standard deviation of grayscale pixels.
510
+ (Technically Οƒ, not RMS, but it serves the same purpose:
511
+ low Οƒ in a bright image is the signature of fog.)
512
+
513
+ Routing:
514
+ FOG (low contrast, bright) β†’ dehaze β†’ sharpen
515
+ NIGHT (dark) β†’ lowlight β†’ denoise β†’ sharpen
516
+ DAY (everything else) β†’ denoise β†’ sharpen
517
+
518
+ Parameters
519
+ ----------
520
+ image : np.ndarray β€” Raw BGR uint8, any resolution.
521
+
522
+ Returns
523
+ -------
524
+ dict with keys:
525
+ "processed_uint8" β€” final 640Γ—640 BGR uint8.
526
+ "processed_float32" β€” final 640Γ—640 BGR float32 [0, 1].
527
+ "condition" β€” one of "FOG", "NIGHT", "DAY/RAIN".
528
+ """
529
+ # ── Step 0: Letterbox ────────────────────────────────────────
530
+ frame = self.letterbox(image)
531
+
532
+ # ── Step 1: Analyse scene statistics ─────────────────────────
533
+ # IMPORTANT: Compute stats ONLY on the content region, excluding
534
+ # the black letterbox padding bars. The padding pixels (value 0)
535
+ # would drag mean_intensity down and inflate rms_contrast,
536
+ # causing misclassification (e.g. a foggy scene wrongly detected
537
+ # as night because the padded mean drops below the threshold).
538
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
539
+ content_mask = gray > 5 # pixels above 5 are real content
540
+ if np.any(content_mask):
541
+ content_pixels = gray[content_mask]
542
+ mean_intensity = float(np.mean(content_pixels))
543
+ rms_contrast = float(np.std(content_pixels))
544
+ else:
545
+ # Extremely dark frame β€” fall back to full-image stats.
546
+ mean_intensity = float(np.mean(gray))
547
+ rms_contrast = float(np.std(gray))
548
+
549
+ # ── Step 2: Route through the correct chain ──────────────────
550
+ # Decision tree:
551
+ # 1. Low contrast + moderate mean β†’ FOG (atmospheric scattering
552
+ # washes out contrast but keeps brightness above black).
553
+ # 2. Low mean + high contrast β†’ NIGHT (dark scene with bright
554
+ # point sources like headlights producing high Οƒ).
555
+ # 3. Everything else β†’ DAY/RAIN (well-lit, or dark-but-uniform
556
+ # rain which benefits from bilateral denoise, not gamma).
557
+ if rms_contrast < self.fog_contrast_threshold and mean_intensity > self.fog_mean_floor:
558
+ # FOG: atmospheric scattering washes out contrast but keeps
559
+ # brightness above black.
560
+ condition = "FOG"
561
+ elif mean_intensity < self.night_mean_threshold and rms_contrast >= self.fog_contrast_threshold:
562
+ # NIGHT: dark scene with bright point sources (headlights,
563
+ # streetlamps) producing high Οƒ β†’ needs the selective gamma
564
+ # lift that protects bright pixels.
565
+ condition = "NIGHT"
566
+ else:
567
+ # DAY/RAIN: well-lit daytime, or dark-but-uniform rain which
568
+ # benefits from bilateral denoise + sharpen rather than gamma.
569
+ condition = "DAY/RAIN"
570
+
571
+ # Apply the matching enhancement chain (shared with the full-res
572
+ # ANPR path via _apply_chain, so both stay in lock-step).
573
+ frame = self._apply_chain(frame, condition)
574
+
575
+ # ── Step 3: Normalise ────────────────────────────────────────
576
+ processed_uint8 = frame
577
+ processed_float32 = frame.astype(np.float32) / 255.0
578
+
579
+ return {
580
+ "processed_uint8": processed_uint8,
581
+ "processed_float32": processed_float32,
582
+ "condition": condition,
583
+ }
584
+
585
+
586
+ # ──────────────────────────────────────────────────────────────────────
587
+ # Visualisation Helper
588
+ # ──────────────────────────────────────────────────────────────────────
589
+ def _show_comparison(
590
+ original_bgr: np.ndarray,
591
+ processed_bgr: np.ndarray,
592
+ condition: str,
593
+ elapsed_ms: float,
594
+ ) -> None:
595
+ """
596
+ Render a polished side-by-side comparison with condition and timing
597
+ in the figure title.
598
+ """
599
+ import matplotlib.pyplot as plt # lazy: only the CLI demo needs it
600
+
601
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6))
602
+
603
+ axes[0].imshow(cv2.cvtColor(original_bgr, cv2.COLOR_BGR2RGB))
604
+ axes[0].set_title("Original", fontsize=14, fontweight="bold")
605
+ axes[0].axis("off")
606
+
607
+ axes[1].imshow(cv2.cvtColor(processed_bgr, cv2.COLOR_BGR2RGB))
608
+ axes[1].set_title("Processed (640Γ—640)", fontsize=14, fontweight="bold")
609
+ axes[1].axis("off")
610
+
611
+ fig.suptitle(
612
+ f"Detected: {condition} Β· {elapsed_ms:.1f} ms",
613
+ fontsize=16,
614
+ fontweight="bold",
615
+ color="#1a73e8",
616
+ y=0.98,
617
+ )
618
+ plt.tight_layout(rect=[0, 0, 1, 0.93])
619
+ plt.show()
620
+
621
+
622
+ # ──────────────────────────────────────────────────────────────────────
623
+ # Entry Point
624
+ # ──────────────────────────────────────────────────────────────────────
625
+ if __name__ == "__main__":
626
+ # ---- Resolve image path ------------------------------------------------
627
+ if len(sys.argv) > 1:
628
+ image_path = Path(sys.argv[1])
629
+ else:
630
+ image_path = Path("sample_traffic.jpg")
631
+
632
+ # ---- Graceful error handling -------------------------------------------
633
+ if not image_path.exists():
634
+ print(
635
+ f"[ERROR] Image not found: {image_path.resolve()}\n"
636
+ f"Usage: python ultimate_edge_preprocessor.py <path_to_image>"
637
+ )
638
+ sys.exit(1)
639
+
640
+ raw_image = cv2.imread(str(image_path))
641
+ if raw_image is None:
642
+ print(
643
+ f"[ERROR] OpenCV could not decode: {image_path.resolve()}\n"
644
+ "Make sure the file is a valid image (JPEG, PNG, BMP, etc.)."
645
+ )
646
+ sys.exit(1)
647
+
648
+ h, w = raw_image.shape[:2]
649
+ print(f"[INFO] Loaded image : {image_path.resolve()}")
650
+ print(f"[INFO] Original size : {w}Γ—{h} ({raw_image.shape[2]} ch)")
651
+
652
+ # ---- Run pipeline ------------------------------------------------------
653
+ preprocessor = DynamicTrafficPreprocessor()
654
+
655
+ t_start = time.perf_counter()
656
+ result = preprocessor.process(raw_image)
657
+ t_end = time.perf_counter()
658
+
659
+ elapsed_ms = (t_end - t_start) * 1000.0
660
+
661
+ processed_uint8 = result["processed_uint8"]
662
+ processed_float32 = result["processed_float32"]
663
+ condition = result["condition"]
664
+
665
+ print(f"[INFO] Detected : {condition}")
666
+ print(f"[INFO] Processed size : {processed_uint8.shape[1]}Γ—{processed_uint8.shape[0]}")
667
+ print(f"[INFO] float32 range : [{processed_float32.min():.4f}, {processed_float32.max():.4f}]")
668
+ print(f"[INFO] Pipeline time : {elapsed_ms:.2f} ms")
669
+
670
+ # ---- Diagnostic: print the scene statistics for tuning ----------------
671
+ gray_diag = cv2.cvtColor(preprocessor.letterbox(raw_image), cv2.COLOR_BGR2GRAY)
672
+ mask = gray_diag > 5
673
+ if np.any(mask):
674
+ print(f"[DIAG] mean_intensity : {float(np.mean(gray_diag[mask])):.2f}")
675
+ print(f"[DIAG] rms_contrast : {float(np.std(gray_diag[mask])):.2f}")
676
+
677
+ # ---- Visualise ---------------------------------------------------------
678
+ _show_comparison(raw_image, processed_uint8, condition, elapsed_ms)