Spaces:
Sleeping
Sleeping
Commit ·
466c163
1
Parent(s): 4e9c6ba
Pothole YOLO pipeline: single-image mode, modular detector, red box visualization
Browse files- app/main.py +34 -15
- app/pothole_detection/__init__.py +2 -0
- app/pothole_detection/inference.py +52 -0
- app/pothole_detection/model_loader.py +18 -0
- app/pothole_detection/pothole_detector.py +52 -0
- app/pothole_detection/visualization.py +28 -0
- app/pothole_engine.py +58 -130
- requirements.txt +1 -0
- static/js/app.js +20 -4
- templates/index.html +1 -1
app/main.py
CHANGED
|
@@ -222,8 +222,8 @@ def me(user: Optional[User] = Depends(get_current_user)):
|
|
| 222 |
@app.post("/api/detect")
|
| 223 |
async def detect(
|
| 224 |
request: Request,
|
| 225 |
-
before: UploadFile = File(
|
| 226 |
-
after: UploadFile = File(
|
| 227 |
method: str = Form("AI-Based Deep Learning"),
|
| 228 |
detection_type: str = Form("change_detection"),
|
| 229 |
landslide_model: str = Form("Rule-Based v1"),
|
|
@@ -252,23 +252,42 @@ async def detect(
|
|
| 252 |
if not user:
|
| 253 |
raise HTTPException(status_code=401, detail="Login required")
|
| 254 |
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
if
|
| 259 |
-
raise HTTPException(status_code=400, detail="
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
|
| 268 |
if min_region_area is not None:
|
| 269 |
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 270 |
|
| 271 |
-
detection_type = (detection_type or "change_detection").strip().lower()
|
| 272 |
if detection_type == "landslide_detection":
|
| 273 |
from .landslide_engine import run_landslide_detection
|
| 274 |
method = f"Landslide - {landslide_model}"
|
|
|
|
| 222 |
@app.post("/api/detect")
|
| 223 |
async def detect(
|
| 224 |
request: Request,
|
| 225 |
+
before: Optional[UploadFile] = File(None),
|
| 226 |
+
after: Optional[UploadFile] = File(None),
|
| 227 |
method: str = Form("AI-Based Deep Learning"),
|
| 228 |
detection_type: str = Form("change_detection"),
|
| 229 |
landslide_model: str = Form("Rule-Based v1"),
|
|
|
|
| 252 |
if not user:
|
| 253 |
raise HTTPException(status_code=401, detail="Login required")
|
| 254 |
MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20 MB
|
| 255 |
+
detection_type = (detection_type or "change_detection").strip().lower()
|
| 256 |
+
|
| 257 |
+
def _read_upload(upload: Optional[UploadFile], field_name: str):
|
| 258 |
+
if upload is None:
|
| 259 |
+
raise HTTPException(status_code=400, detail=f"{field_name} image is required")
|
| 260 |
+
raw = None
|
| 261 |
+
try:
|
| 262 |
+
raw = upload.file.read()
|
| 263 |
+
if raw is None or len(raw) == 0:
|
| 264 |
+
raise HTTPException(status_code=400, detail=f"{field_name} image is empty")
|
| 265 |
+
if len(raw) > MAX_UPLOAD_BYTES:
|
| 266 |
+
raise HTTPException(status_code=400, detail="Image too large (max 20 MB)")
|
| 267 |
+
return Image.open(io.BytesIO(raw)).convert("RGB")
|
| 268 |
+
except HTTPException:
|
| 269 |
+
raise
|
| 270 |
+
except Exception as e:
|
| 271 |
+
raise HTTPException(status_code=400, detail=f"Invalid {field_name} image: {e}")
|
| 272 |
+
finally:
|
| 273 |
+
try:
|
| 274 |
+
if raw is not None:
|
| 275 |
+
del raw
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
if detection_type == "pothole_detection":
|
| 280 |
+
# Single-image mode: use after if present, else before.
|
| 281 |
+
primary = after if after is not None else before
|
| 282 |
+
after_pil = _read_upload(primary, "road")
|
| 283 |
+
before_pil = after_pil
|
| 284 |
+
else:
|
| 285 |
+
before_pil = _read_upload(before, "before")
|
| 286 |
+
after_pil = _read_upload(after, "after")
|
| 287 |
detection_sensitivity = max(0.0, min(1.0, float(detection_sensitivity)))
|
| 288 |
if min_region_area is not None:
|
| 289 |
min_region_area = int(max(50, min(10000, min_region_area)))
|
| 290 |
|
|
|
|
| 291 |
if detection_type == "landslide_detection":
|
| 292 |
from .landslide_engine import run_landslide_detection
|
| 293 |
method = f"Landslide - {landslide_model}"
|
app/pothole_detection/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .pothole_detector import PotholeDetector
|
| 2 |
+
|
app/pothole_detection/inference.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def run_pothole_inference(
|
| 9 |
+
model,
|
| 10 |
+
image_bgr: np.ndarray,
|
| 11 |
+
conf_threshold: float = 0.25,
|
| 12 |
+
iou_threshold: float = 0.45,
|
| 13 |
+
) -> List[Dict]:
|
| 14 |
+
"""
|
| 15 |
+
Run YOLO inference and normalize predictions to a simple list format.
|
| 16 |
+
"""
|
| 17 |
+
results = model.predict(
|
| 18 |
+
source=image_bgr,
|
| 19 |
+
conf=conf_threshold,
|
| 20 |
+
iou=iou_threshold,
|
| 21 |
+
verbose=False,
|
| 22 |
+
)
|
| 23 |
+
preds: List[Dict] = []
|
| 24 |
+
if not results:
|
| 25 |
+
return preds
|
| 26 |
+
|
| 27 |
+
r = results[0]
|
| 28 |
+
names = getattr(r, "names", {}) or {}
|
| 29 |
+
boxes = getattr(r, "boxes", None)
|
| 30 |
+
if boxes is None:
|
| 31 |
+
return preds
|
| 32 |
+
|
| 33 |
+
xyxy = boxes.xyxy.cpu().numpy() if hasattr(boxes.xyxy, "cpu") else boxes.xyxy
|
| 34 |
+
confs = boxes.conf.cpu().numpy() if hasattr(boxes.conf, "cpu") else boxes.conf
|
| 35 |
+
clss = boxes.cls.cpu().numpy() if hasattr(boxes.cls, "cpu") else boxes.cls
|
| 36 |
+
|
| 37 |
+
for i in range(len(xyxy)):
|
| 38 |
+
x1, y1, x2, y2 = [int(v) for v in xyxy[i]]
|
| 39 |
+
confidence = float(confs[i])
|
| 40 |
+
cls_id = int(clss[i]) if clss is not None else 0
|
| 41 |
+
cls_name = names.get(cls_id, "pothole")
|
| 42 |
+
preds.append(
|
| 43 |
+
{
|
| 44 |
+
"bbox": [x1, y1, x2, y2],
|
| 45 |
+
"confidence": confidence,
|
| 46 |
+
"class_id": cls_id,
|
| 47 |
+
"class_name": str(cls_name),
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
return preds
|
| 52 |
+
|
app/pothole_detection/model_loader.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@lru_cache(maxsize=1)
|
| 8 |
+
def get_yolo_model():
|
| 9 |
+
"""
|
| 10 |
+
Lazy-load Ultralytics YOLO model once per process.
|
| 11 |
+
|
| 12 |
+
Env:
|
| 13 |
+
- POTHOLE_MODEL_PATH: local path or model name (default: yolov8n.pt)
|
| 14 |
+
"""
|
| 15 |
+
model_path = os.environ.get("POTHOLE_MODEL_PATH", "yolov8n.pt").strip() or "yolov8n.pt"
|
| 16 |
+
from ultralytics import YOLO
|
| 17 |
+
return YOLO(model_path)
|
| 18 |
+
|
app/pothole_detection/pothole_detector.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Dict, Any, List
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from .model_loader import get_yolo_model
|
| 9 |
+
from .inference import run_pothole_inference
|
| 10 |
+
from .visualization import draw_pothole_boxes
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PotholeDetector:
|
| 14 |
+
"""
|
| 15 |
+
Modular pothole detector:
|
| 16 |
+
- preprocessing
|
| 17 |
+
- model inference
|
| 18 |
+
- post-processing
|
| 19 |
+
- visualization
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, conf_threshold: float = 0.25, iou_threshold: float = 0.45):
|
| 23 |
+
self.conf_threshold = float(conf_threshold)
|
| 24 |
+
self.iou_threshold = float(iou_threshold)
|
| 25 |
+
self.model = get_yolo_model()
|
| 26 |
+
|
| 27 |
+
def preprocess(self, image_bgr: np.ndarray) -> np.ndarray:
|
| 28 |
+
# Lightweight denoise for road textures
|
| 29 |
+
return cv2.bilateralFilter(image_bgr, 5, 35, 35)
|
| 30 |
+
|
| 31 |
+
def infer(self, image_bgr: np.ndarray) -> List[Dict[str, Any]]:
|
| 32 |
+
return run_pothole_inference(
|
| 33 |
+
self.model,
|
| 34 |
+
image_bgr,
|
| 35 |
+
conf_threshold=self.conf_threshold,
|
| 36 |
+
iou_threshold=self.iou_threshold,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def postprocess(self, detections: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 40 |
+
# Keep all detections; custom filtering can be added here.
|
| 41 |
+
return detections
|
| 42 |
+
|
| 43 |
+
def visualize(self, image_bgr: np.ndarray, detections: List[Dict[str, Any]]) -> np.ndarray:
|
| 44 |
+
return draw_pothole_boxes(image_bgr, detections)
|
| 45 |
+
|
| 46 |
+
def run(self, image_bgr: np.ndarray):
|
| 47 |
+
prep = self.preprocess(image_bgr)
|
| 48 |
+
detections = self.infer(prep)
|
| 49 |
+
detections = self.postprocess(detections)
|
| 50 |
+
vis = self.visualize(image_bgr, detections)
|
| 51 |
+
return detections, vis
|
| 52 |
+
|
app/pothole_detection/visualization.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
import cv2
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def draw_pothole_boxes(image_bgr: np.ndarray, detections: List[Dict]) -> np.ndarray:
|
| 10 |
+
"""
|
| 11 |
+
Draw red bounding boxes with confidence labels.
|
| 12 |
+
"""
|
| 13 |
+
out = image_bgr.copy()
|
| 14 |
+
for det in detections:
|
| 15 |
+
x1, y1, x2, y2 = det["bbox"]
|
| 16 |
+
conf = float(det.get("confidence", 0.0))
|
| 17 |
+
label = f"pothole {conf:.2f}"
|
| 18 |
+
|
| 19 |
+
# Red box (BGR)
|
| 20 |
+
cv2.rectangle(out, (x1, y1), (x2, y2), (0, 0, 255), 2)
|
| 21 |
+
|
| 22 |
+
# Label background
|
| 23 |
+
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1)
|
| 24 |
+
y_text = max(16, y1 - 6)
|
| 25 |
+
cv2.rectangle(out, (x1, y_text - th - 6), (x1 + tw + 8, y_text + 2), (0, 0, 255), -1)
|
| 26 |
+
cv2.putText(out, label, (x1 + 4, y_text - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA)
|
| 27 |
+
return out
|
| 28 |
+
|
app/pothole_engine.py
CHANGED
|
@@ -1,18 +1,19 @@
|
|
| 1 |
"""
|
| 2 |
-
Pothole / road damage detection
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
- Vehicle camera or low-altitude drone is the realistic input for pothole detection.
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
-
import cv2
|
| 14 |
import numpy as np
|
| 15 |
from PIL import Image
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def _preprocess(image: Image.Image, max_size: int = 1600) -> np.ndarray:
|
|
@@ -33,156 +34,83 @@ def _norm01(x: np.ndarray) -> np.ndarray:
|
|
| 33 |
return (x - lo) / (hi - lo)
|
| 34 |
|
| 35 |
|
| 36 |
-
def
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def _edge_score(gray: np.ndarray) -> np.ndarray:
|
| 52 |
-
med = float(np.median(gray))
|
| 53 |
-
t1 = int(max(0, 0.66 * med))
|
| 54 |
-
t2 = int(min(255, 1.33 * med))
|
| 55 |
-
edges = cv2.Canny(gray, t1, t2)
|
| 56 |
-
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
|
| 57 |
-
return edges.astype(np.float32) / 255.0
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _clean(mask: np.ndarray) -> np.ndarray:
|
| 61 |
-
m = mask.copy()
|
| 62 |
-
m = cv2.medianBlur(m, 5)
|
| 63 |
-
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 64 |
-
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
| 65 |
-
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, k_open)
|
| 66 |
-
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k_close)
|
| 67 |
-
return m
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
n, labels, stats, cents = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
| 72 |
-
h, w = mask.shape[:2]
|
| 73 |
-
img_area = h * w
|
| 74 |
-
regs = []
|
| 75 |
rid = 0
|
| 76 |
-
for
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
| 79 |
continue
|
| 80 |
-
x = int(stats[i, cv2.CC_STAT_LEFT])
|
| 81 |
-
y = int(stats[i, cv2.CC_STAT_TOP])
|
| 82 |
-
bw = int(stats[i, cv2.CC_STAT_WIDTH])
|
| 83 |
-
bh = int(stats[i, cv2.CC_STAT_HEIGHT])
|
| 84 |
-
if bw * bh > img_area * 0.25:
|
| 85 |
-
continue
|
| 86 |
-
ar = max(bw, bh) / max(1, min(bw, bh))
|
| 87 |
-
if ar > 6.0:
|
| 88 |
-
continue
|
| 89 |
-
cx, cy = cents[i]
|
| 90 |
-
fill = area / max(1, bw * bh)
|
| 91 |
-
conf = float(np.clip(0.25 + fill * 0.7, 0.25, 0.95))
|
| 92 |
-
sev = "minor"
|
| 93 |
-
if area / img_area > 0.01:
|
| 94 |
-
sev = "major"
|
| 95 |
-
elif area / img_area > 0.003:
|
| 96 |
-
sev = "moderate"
|
| 97 |
rid += 1
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
{
|
| 100 |
"id": rid,
|
| 101 |
"area": area,
|
| 102 |
-
"bbox": (
|
| 103 |
-
"center": (int(
|
| 104 |
"object_type": "Pothole / Road Damage",
|
| 105 |
"confidence": conf,
|
| 106 |
-
"severity":
|
| 107 |
-
"sub_type": "
|
| 108 |
"sub_type_confidence": conf,
|
| 109 |
"estimated_stories": None,
|
| 110 |
"estimated_height_m": None,
|
| 111 |
"construction_stage": None,
|
| 112 |
}
|
| 113 |
)
|
| 114 |
-
regs.sort(key=lambda r: r["area"], reverse=True)
|
| 115 |
-
return regs[:80]
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _visualize(img: np.ndarray, mask: np.ndarray, regions: list[dict]) -> np.ndarray:
|
| 119 |
-
out = img.copy().astype(np.float32)
|
| 120 |
-
m = (mask > 127).astype(np.float32)
|
| 121 |
-
# Orange overlay for road damage
|
| 122 |
-
layer = np.zeros_like(out)
|
| 123 |
-
layer[:, :, 0] = 255
|
| 124 |
-
layer[:, :, 1] = 165
|
| 125 |
-
alpha = 0.35
|
| 126 |
-
for c in range(3):
|
| 127 |
-
out[:, :, c] = out[:, :, c] * (1 - m * alpha) + layer[:, :, c] * (m * alpha)
|
| 128 |
-
vis = np.clip(out, 0, 255).astype(np.uint8)
|
| 129 |
-
for r in regions:
|
| 130 |
-
x, y, w, h = r["bbox"]
|
| 131 |
-
cv2.rectangle(vis, (x, y), (x + w, y + h), (0, 140, 255), 2)
|
| 132 |
-
cv2.putText(vis, str(r["id"]), (x + 4, max(14, y - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA)
|
| 133 |
-
return vis
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def run_pothole_detection(
|
| 137 |
-
before_pil: Image.Image,
|
| 138 |
-
after_pil: Image.Image,
|
| 139 |
-
model_name: str = "Rule-Based v1",
|
| 140 |
-
detection_sensitivity: float = 0.6,
|
| 141 |
-
min_region_area: int | None = None,
|
| 142 |
-
):
|
| 143 |
-
"""
|
| 144 |
-
Current UI uses (before, after) upload. For potholes, we treat the *after* image as
|
| 145 |
-
the road image and ignore the before image.
|
| 146 |
-
"""
|
| 147 |
-
img = _preprocess(after_pil)
|
| 148 |
-
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
| 149 |
-
|
| 150 |
-
rough = _road_texture_response(gray)
|
| 151 |
-
shadow = _shadow_score(gray)
|
| 152 |
-
edges = _edge_score(gray)
|
| 153 |
-
|
| 154 |
-
fused = 0.45 * shadow + 0.35 * rough + 0.20 * edges
|
| 155 |
-
fused = cv2.GaussianBlur(fused.astype(np.float32), (7, 7), 0)
|
| 156 |
-
|
| 157 |
-
sens = float(np.clip(detection_sensitivity, 0.0, 1.0))
|
| 158 |
-
q = float(np.clip(0.975 - (sens - 0.5) * 0.10, 0.85, 0.985))
|
| 159 |
-
thr = float(np.quantile(fused, q))
|
| 160 |
-
mask = (fused >= thr).astype(np.uint8) * 255
|
| 161 |
-
mask = _clean(mask)
|
| 162 |
-
|
| 163 |
-
if min_region_area is None:
|
| 164 |
-
min_region_area = int(max(150, min(1200, mask.shape[0] * mask.shape[1] * 0.00005)))
|
| 165 |
-
regions = _extract_regions(mask, min_area=int(min_region_area))
|
| 166 |
-
result = _visualize(img, mask, regions)
|
| 167 |
|
| 168 |
-
total = int(
|
| 169 |
-
changed = int(
|
| 170 |
stats = {
|
| 171 |
"total_pixels": total,
|
| 172 |
"changed_pixels": changed,
|
| 173 |
"unchanged_pixels": total - changed,
|
| 174 |
"change_percentage": (changed / total * 100.0) if total else 0.0,
|
| 175 |
-
"image_width":
|
| 176 |
-
"image_height":
|
| 177 |
"threshold_debug": {
|
| 178 |
"method": f"Pothole Detection ({model_name})",
|
| 179 |
-
"threshold_used":
|
| 180 |
-
"
|
|
|
|
| 181 |
"sensitivity": sens,
|
|
|
|
| 182 |
},
|
| 183 |
"params": {
|
| 184 |
"detection_sensitivity": sens,
|
| 185 |
-
"min_region_area": int(min_region_area),
|
| 186 |
"model_name": model_name,
|
| 187 |
"input": "after_only",
|
| 188 |
},
|
|
|
|
| 1 |
"""
|
| 2 |
+
Pothole / road damage detection engine (YOLO-ready).
|
| 3 |
|
| 4 |
+
Uses modular pipeline under app/pothole_detection:
|
| 5 |
+
- model_loader.py
|
| 6 |
+
- inference.py
|
| 7 |
+
- visualization.py
|
| 8 |
+
- pothole_detector.py
|
|
|
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
| 12 |
import numpy as np
|
| 13 |
from PIL import Image
|
| 14 |
+
import cv2
|
| 15 |
+
|
| 16 |
+
from .pothole_detection import PotholeDetector
|
| 17 |
|
| 18 |
|
| 19 |
def _preprocess(image: Image.Image, max_size: int = 1600) -> np.ndarray:
|
|
|
|
| 34 |
return (x - lo) / (hi - lo)
|
| 35 |
|
| 36 |
|
| 37 |
+
def run_pothole_detection(
|
| 38 |
+
before_pil: Image.Image,
|
| 39 |
+
after_pil: Image.Image,
|
| 40 |
+
model_name: str = "Rule-Based v1",
|
| 41 |
+
detection_sensitivity: float = 0.6,
|
| 42 |
+
min_region_area: int | None = None,
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Current UI uses (before, after) upload. For potholes, we treat the provided road
|
| 46 |
+
image as the target and run YOLO-style detection.
|
| 47 |
+
"""
|
| 48 |
+
img = _preprocess(after_pil)
|
| 49 |
+
# Ultralytics model expects BGR ndarray from OpenCV style pipeline.
|
| 50 |
+
bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
# Sensitivity maps to confidence threshold inversely.
|
| 53 |
+
sens = float(np.clip(detection_sensitivity, 0.0, 1.0))
|
| 54 |
+
conf_thr = float(np.clip(0.45 - (sens - 0.5) * 0.35, 0.10, 0.70))
|
| 55 |
+
iou_thr = 0.45
|
| 56 |
+
detector = PotholeDetector(conf_threshold=conf_thr, iou_threshold=iou_thr)
|
| 57 |
+
detections, vis_bgr = detector.run(bgr)
|
| 58 |
+
result = cv2.cvtColor(vis_bgr, cv2.COLOR_BGR2RGB)
|
| 59 |
|
| 60 |
+
regions = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
rid = 0
|
| 62 |
+
for d in detections:
|
| 63 |
+
x1, y1, x2, y2 = d["bbox"]
|
| 64 |
+
w = max(1, x2 - x1)
|
| 65 |
+
h = max(1, y2 - y1)
|
| 66 |
+
area = int(w * h)
|
| 67 |
+
if min_region_area is not None and area < int(min_region_area):
|
| 68 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
rid += 1
|
| 70 |
+
conf = float(d.get("confidence", 0.0))
|
| 71 |
+
severity = "minor"
|
| 72 |
+
area_ratio = area / max(1, img.shape[0] * img.shape[1])
|
| 73 |
+
if area_ratio > 0.01:
|
| 74 |
+
severity = "major"
|
| 75 |
+
elif area_ratio > 0.003:
|
| 76 |
+
severity = "moderate"
|
| 77 |
+
regions.append(
|
| 78 |
{
|
| 79 |
"id": rid,
|
| 80 |
"area": area,
|
| 81 |
+
"bbox": (int(x1), int(y1), int(w), int(h)),
|
| 82 |
+
"center": (int(x1 + w // 2), int(y1 + h // 2)),
|
| 83 |
"object_type": "Pothole / Road Damage",
|
| 84 |
"confidence": conf,
|
| 85 |
+
"severity": severity,
|
| 86 |
+
"sub_type": str(d.get("class_name", "pothole")),
|
| 87 |
"sub_type_confidence": conf,
|
| 88 |
"estimated_stories": None,
|
| 89 |
"estimated_height_m": None,
|
| 90 |
"construction_stage": None,
|
| 91 |
}
|
| 92 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
total = int(img.shape[0] * img.shape[1])
|
| 95 |
+
changed = int(sum(r["area"] for r in regions))
|
| 96 |
stats = {
|
| 97 |
"total_pixels": total,
|
| 98 |
"changed_pixels": changed,
|
| 99 |
"unchanged_pixels": total - changed,
|
| 100 |
"change_percentage": (changed / total * 100.0) if total else 0.0,
|
| 101 |
+
"image_width": img.shape[1],
|
| 102 |
+
"image_height": img.shape[0],
|
| 103 |
"threshold_debug": {
|
| 104 |
"method": f"Pothole Detection ({model_name})",
|
| 105 |
+
"threshold_used": None,
|
| 106 |
+
"confidence_threshold": conf_thr,
|
| 107 |
+
"iou_threshold": iou_thr,
|
| 108 |
"sensitivity": sens,
|
| 109 |
+
"detected_boxes": len(regions),
|
| 110 |
},
|
| 111 |
"params": {
|
| 112 |
"detection_sensitivity": sens,
|
| 113 |
+
"min_region_area": int(min_region_area) if min_region_area is not None else None,
|
| 114 |
"model_name": model_name,
|
| 115 |
"input": "after_only",
|
| 116 |
},
|
requirements.txt
CHANGED
|
@@ -10,3 +10,4 @@ numpy>=1.24.0
|
|
| 10 |
opencv-python-headless>=4.8.0
|
| 11 |
scikit-learn>=1.3.0
|
| 12 |
requests>=2.28.0
|
|
|
|
|
|
| 10 |
opencv-python-headless>=4.8.0
|
| 11 |
scikit-learn>=1.3.0
|
| 12 |
requests>=2.28.0
|
| 13 |
+
ultralytics>=8.2.0
|
static/js/app.js
CHANGED
|
@@ -251,12 +251,23 @@ setupUploadZone('file-after', 'name-after', 'zone-after', 'preview-after');
|
|
| 251 |
function refresh() {
|
| 252 |
const isLandslide = typeSel.value === 'landslide_detection';
|
| 253 |
const isPothole = typeSel.value === 'pothole_detection';
|
|
|
|
|
|
|
|
|
|
| 254 |
if (landslideGroup) landslideGroup.classList.toggle('hidden', !isLandslide);
|
| 255 |
if (potholeGroup) potholeGroup.classList.toggle('hidden', !isPothole);
|
| 256 |
const hideCore = isLandslide || isPothole;
|
| 257 |
if (methodGroup) methodGroup.classList.toggle('hidden', hideCore);
|
| 258 |
if (regGroup) regGroup.classList.toggle('hidden', hideCore);
|
| 259 |
if (normGroup) normGroup.classList.toggle('hidden', hideCore);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
typeSel.addEventListener('change', refresh);
|
|
@@ -470,9 +481,15 @@ function stopDetectionProgress(success) {
|
|
| 470 |
document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
|
| 471 |
e.preventDefault();
|
| 472 |
hideError('dashboard-error');
|
|
|
|
| 473 |
const before = document.getElementById('file-before').files?.[0];
|
| 474 |
const after = document.getElementById('file-after').files?.[0];
|
| 475 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
showError('dashboard-error', 'Please select both before and after images.');
|
| 477 |
return;
|
| 478 |
}
|
|
@@ -485,9 +502,8 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
|
|
| 485 |
|
| 486 |
const token = getToken();
|
| 487 |
const form = new FormData();
|
| 488 |
-
form.append('before', before);
|
| 489 |
-
form.append('after', after);
|
| 490 |
-
const detectionType = document.getElementById('detect-type')?.value || 'change_detection';
|
| 491 |
form.append('detection_type', detectionType);
|
| 492 |
form.append('method', document.getElementById('detect-method').value);
|
| 493 |
if (detectionType === 'landslide_detection') {
|
|
|
|
| 251 |
function refresh() {
|
| 252 |
const isLandslide = typeSel.value === 'landslide_detection';
|
| 253 |
const isPothole = typeSel.value === 'pothole_detection';
|
| 254 |
+
const beforeZone = document.getElementById('zone-before');
|
| 255 |
+
const beforeInput = document.getElementById('file-before');
|
| 256 |
+
const beforeName = document.getElementById('name-before');
|
| 257 |
if (landslideGroup) landslideGroup.classList.toggle('hidden', !isLandslide);
|
| 258 |
if (potholeGroup) potholeGroup.classList.toggle('hidden', !isPothole);
|
| 259 |
const hideCore = isLandslide || isPothole;
|
| 260 |
if (methodGroup) methodGroup.classList.toggle('hidden', hideCore);
|
| 261 |
if (regGroup) regGroup.classList.toggle('hidden', hideCore);
|
| 262 |
if (normGroup) normGroup.classList.toggle('hidden', hideCore);
|
| 263 |
+
// Pothole mode uses a single image upload (after image).
|
| 264 |
+
if (beforeZone) beforeZone.classList.toggle('hidden', isPothole);
|
| 265 |
+
if (isPothole && beforeInput) {
|
| 266 |
+
beforeInput.value = '';
|
| 267 |
+
if (beforeName) beforeName.textContent = 'No file chosen';
|
| 268 |
+
const prev = document.getElementById('preview-before');
|
| 269 |
+
if (prev) prev.classList.add('hidden');
|
| 270 |
+
}
|
| 271 |
}
|
| 272 |
|
| 273 |
typeSel.addEventListener('change', refresh);
|
|
|
|
| 481 |
document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
|
| 482 |
e.preventDefault();
|
| 483 |
hideError('dashboard-error');
|
| 484 |
+
const detectionType = document.getElementById('detect-type')?.value || 'change_detection';
|
| 485 |
const before = document.getElementById('file-before').files?.[0];
|
| 486 |
const after = document.getElementById('file-after').files?.[0];
|
| 487 |
+
if (detectionType === 'pothole_detection') {
|
| 488 |
+
if (!after && !before) {
|
| 489 |
+
showError('dashboard-error', 'Please upload one road image for pothole detection.');
|
| 490 |
+
return;
|
| 491 |
+
}
|
| 492 |
+
} else if (!before || !after) {
|
| 493 |
showError('dashboard-error', 'Please select both before and after images.');
|
| 494 |
return;
|
| 495 |
}
|
|
|
|
| 502 |
|
| 503 |
const token = getToken();
|
| 504 |
const form = new FormData();
|
| 505 |
+
if (before) form.append('before', before);
|
| 506 |
+
if (after) form.append('after', after);
|
|
|
|
| 507 |
form.append('detection_type', detectionType);
|
| 508 |
form.append('method', document.getElementById('detect-method').value);
|
| 509 |
if (detectionType === 'landslide_detection') {
|
templates/index.html
CHANGED
|
@@ -401,6 +401,6 @@
|
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
-
<script src="/static/js/app.js?v=
|
| 405 |
</body>
|
| 406 |
</html>
|
|
|
|
| 401 |
</div>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
+
<script src="/static/js/app.js?v=32"></script>
|
| 405 |
</body>
|
| 406 |
</html>
|