Spaces:
Running
Running
| #!/usr/bin/env python | |
| """Persistent YOLOv8 pothole classifier worker (real-time). | |
| Loads the model ONCE at startup, then serves one request per line from stdin | |
| and writes one JSON result per line to stdout. This avoids the multi-second | |
| cold start of loading torch + the model on every request. | |
| Protocol: | |
| startup -> emits {"ready": true} (or {"error": "..."} and exits) | |
| stdin -> one image file path per line ("__quit__" to stop) | |
| stdout -> one JSON result per line: | |
| {"isPothole":bool,"confidence":float,"size":"Small|Medium|Large"|null, | |
| "count":int,"largestAreaFraction":float,"boxes":[...],"model":"..."} | |
| or {"error":"..."} for that request. | |
| """ | |
| import sys | |
| import os | |
| import json | |
| # Size = area (as a fraction of the whole frame) of the largest *confident* pothole | |
| # detection. A single 2D photo has no true scale, so box area is a heuristic proxy. | |
| # >= SIZE_LARGE -> Large | |
| # >= SIZE_MEDIUM -> Medium | |
| # else -> Small | |
| # Thresholds are calibrated to the ground-truth pothole box-area distribution | |
| # (3,625 annotated boxes): SIZE_MEDIUM ~= 40th percentile, SIZE_LARGE ~= 82nd | |
| # percentile. That yields a sensible operational split of roughly 40% Small / | |
| # 42% Medium / 18% Large instead of over-calling everything "Small". | |
| SIZE_LARGE = 0.09 | |
| SIZE_MEDIUM = 0.013 | |
| # CONF_THRESHOLD: minimum confidence for a box to count as a detection at all. | |
| CONF_THRESHOLD = 0.25 | |
| # SIZE_CONF_FLOOR: a box must clear this higher bar before its area is allowed to | |
| # drive the size label. This stops low-confidence, sprawling false boxes (e.g. a | |
| # shadow spanning the frame) from inflating a real, smaller pothole to "Large". | |
| SIZE_CONF_FLOOR = 0.45 | |
| MODEL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pothole-yolov8.pt") | |
| def emit(obj): | |
| sys.stdout.write(json.dumps(obj) + "\n") | |
| sys.stdout.flush() | |
| def classify(model, image_path): | |
| if not os.path.exists(image_path): | |
| return {"error": "image not found: " + image_path} | |
| try: | |
| result = model.predict(image_path, conf=CONF_THRESHOLD, verbose=False)[0] | |
| h, w = result.orig_shape | |
| frame_area = float(h * w) if h and w else 1.0 | |
| boxes = [] | |
| max_frac = 0.0 # largest box area (any confidence) — reported for transparency | |
| max_conf = 0.0 # best detection confidence | |
| size_frac = 0.0 # area that actually drives the size label (confident boxes only) | |
| best_conf_frac = 0.0 # area of the single highest-confidence box (fallback) | |
| best_conf = -1.0 | |
| for b in result.boxes: | |
| x1, y1, x2, y2 = (float(v) for v in b.xyxy[0].tolist()) | |
| conf = float(b.conf[0]) | |
| frac = ((x2 - x1) * (y2 - y1)) / frame_area | |
| boxes.append({ | |
| "x1": round(x1), "y1": round(y1), "x2": round(x2), "y2": round(y2), | |
| "conf": round(conf, 3), "areaFraction": round(frac, 4), | |
| }) | |
| max_frac = max(max_frac, frac) | |
| max_conf = max(max_conf, conf) | |
| if conf >= SIZE_CONF_FLOOR: | |
| size_frac = max(size_frac, frac) | |
| if conf > best_conf: | |
| best_conf = conf | |
| best_conf_frac = frac | |
| is_pothole = len(boxes) > 0 | |
| # Prefer the largest confident pothole; if none clear the floor, fall back to | |
| # the area of the single most-confident detection (never a noisy wide box). | |
| sizing_frac = size_frac if size_frac > 0 else best_conf_frac | |
| if not is_pothole: | |
| size = None | |
| elif sizing_frac >= SIZE_LARGE: | |
| size = "Large" | |
| elif sizing_frac >= SIZE_MEDIUM: | |
| size = "Medium" | |
| else: | |
| size = "Small" | |
| return { | |
| "isPothole": is_pothole, | |
| "confidence": round(max_conf, 3), | |
| "size": size, | |
| "count": len(boxes), | |
| "largestAreaFraction": round(max_frac, 4), | |
| "sizeAreaFraction": round(sizing_frac, 4), | |
| "boxes": boxes, | |
| "model": "peterhdd/pothole-detection-yolov8", | |
| } | |
| except Exception as e: # noqa: BLE001 | |
| return {"error": str(e)} | |
| def main(): | |
| if not os.path.exists(MODEL_FILE): | |
| emit({"error": "model file missing: " + MODEL_FILE}) | |
| return | |
| try: | |
| from ultralytics import YOLO | |
| except Exception as e: # noqa: BLE001 | |
| emit({"error": "ultralytics not installed: " + str(e)}) | |
| return | |
| try: | |
| model = YOLO(MODEL_FILE) | |
| except Exception as e: # noqa: BLE001 | |
| emit({"error": "model load failed: " + str(e)}) | |
| return | |
| emit({"ready": True}) | |
| for line in sys.stdin: | |
| path = line.strip() | |
| if not path: | |
| continue | |
| if path == "__quit__": | |
| break | |
| emit(classify(model, path)) | |
| if __name__ == "__main__": | |
| main() | |