Spaces:
Running
Integrate weather-adaptive edge preprocessor into the pipeline
Browse filesWire 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 +33 -8
- backend/app/config.py +7 -0
- backend/app/models/preprocessor.py +14 -0
- backend/app/routes/upload.py +50 -8
- backend/app/schemas.py +1 -0
- backend/app/utils/annotator.py +13 -0
- run.bat +13 -2
- run.sh +12 -2
- ultimate_edge_preprocessor.py +678 -0
|
@@ -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
|
| 7 |
-
|
| 8 |
-
|
| 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
|
| 20 |
|
| 21 |
## Project layout
|
| 22 |
|
| 23 |
```
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 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:
|
|
@@ -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"
|
|
@@ -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)
|
|
@@ -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 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
road_users = summarize(detections)
|
| 28 |
|
| 29 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
captured_at = datetime.utcnow()
|
| 37 |
-
annotated = annotate(
|
| 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],
|
|
@@ -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]
|
|
@@ -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]
|
|
@@ -1,4 +1,15 @@
|
|
| 1 |
@echo off
|
| 2 |
-
REM Launch the backend with the project's
|
|
|
|
| 3 |
cd /d "%~dp0backend"
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -1,5 +1,15 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
-
# Launch the backend with the project's
|
|
|
|
| 3 |
set -e
|
| 4 |
cd "$(dirname "$0")/backend"
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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)
|