import gradio as gr import cv2 import tempfile import os import numpy as np import spaces from ultralytics import YOLO # ── Example video (place video.mp4 in the repo root) ─────────────────────── EXAMPLE_VIDEO_PATH = "video.mp4" # ── COCO 80 classes for reference hint ───────────────────────────────────── COCO_CLASSES = [ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush", ] # ── Model cache ───────────────────────────────────────────────────────────── model = None def _parse_class_filter(class_prompt: str, model_names: dict): """ Parse a comma-separated prompt like "person, car, dog" into (list[int] | None, list[str]). Returns (None, []) to detect all classes when prompt is empty. """ if not class_prompt or not class_prompt.strip(): return None, [] name_to_id = {v.lower(): k for k, v in model_names.items()} requested = [t.strip().lower() for t in class_prompt.split(",") if t.strip()] matched, unknown = [], [] for name in requested: if name in name_to_id: matched.append(name_to_id[name]) else: unknown.append(name) return (matched if matched else None), unknown # ── Core inference ────────────────────────────────────────────────────────── @spaces.GPU(duration=180) def process_video( video_path: str, conf_threshold: float, iou_threshold: float, model_size: str, task: str, class_prompt: str, ) -> tuple: """Run YOLO26 inference on an uploaded video and return annotated video + stats.""" if video_path is None: return None, "⚠️ 請先上傳影片" global model model_map = { "Nano (最快)": "yolo26n.pt", "Small": "yolo26s.pt", "Medium": "yolo26m.pt", "Large": "yolo26l.pt", "XLarge (最準)": "yolo26x.pt", } task_map = { "物件偵測 (detect)": "detect", "實例分割 (segment)": "segment", "姿態估計 (pose)": "pose", } selected_pt = model_map.get(model_size, "yolo26n.pt") selected_task = task_map.get(task, "detect") if selected_task == "detect": pt_name = selected_pt else: suffix = "-seg" if selected_task == "segment" else "-pose" pt_name = selected_pt.replace(".pt", f"{suffix}.pt") if model is None or getattr(model, "ckpt_path", None) != pt_name: try: model = YOLO(pt_name) except Exception as e: model = YOLO("yolo26n.pt") pt_name = "yolo26n.pt" return None, f"⚠️ 無法載入 {pt_name},已回退至 yolo26n.pt\n錯誤:{e}" # ── Parse class filter ────────────────────────────────────────────────── class_ids, unknown_names = _parse_class_filter(class_prompt, model.names) # ── Open video ────────────────────────────────────────────────────────── cap = cv2.VideoCapture(video_path) if not cap.isOpened(): return None, "❌ 無法開啟影片檔案" fps = cap.get(cv2.CAP_PROP_FPS) or 25 width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) out_path = tempfile.mktemp(suffix=".mp4") fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(out_path, fourcc, fps, (width, height)) frame_idx = 0 total_detections = 0 class_counts = {} while True: ret, frame = cap.read() if not ret: break infer_kwargs = dict(conf=conf_threshold, iou=iou_threshold, verbose=False) if class_ids: infer_kwargs["classes"] = class_ids # YOLO native class-filter param results = model(frame, **infer_kwargs) annotated = results[0].plot() boxes = results[0].boxes if boxes is not None: total_detections += len(boxes) for cls_id in boxes.cls.cpu().numpy().astype(int): name = model.names[cls_id] class_counts[name] = class_counts.get(name, 0) + 1 writer.write(annotated) frame_idx += 1 cap.release() writer.release() # ── Build stats ───────────────────────────────────────────────────────── if class_ids: filter_desc = "🎯 過濾類別:" + ", ".join(model.names[i] for i in class_ids) else: filter_desc = "🎯 過濾類別:全部(無限制)" stats_lines = [ "✅ 處理完成", f"📽️ 總幀數:{frame_idx}", f"🔍 總偵測次數:{total_detections}", f"⚡ 模型:{pt_name}", filter_desc, ] if unknown_names: stats_lines.append(f"⚠️ 找不到類別:{', '.join(unknown_names)}(已忽略)") stats_lines += ["", "📊 各類別累計偵測次數:"] if class_counts: for cls, cnt in sorted(class_counts.items(), key=lambda x: -x[1]): stats_lines.append(f" • {cls}: {cnt}") else: stats_lines.append(" (未偵測到任何物件)") # ── Re-encode to H.264 ────────────────────────────────────────────────── final_path = tempfile.mktemp(suffix=".mp4") os.system( f'ffmpeg -y -i "{out_path}" -vcodec libx264 -acodec aac "{final_path}" -loglevel quiet' ) if not os.path.exists(final_path): final_path = out_path return final_path, "\n".join(stats_lines) # ── Gradio UI ─────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap'); :root { --bg: #0a0a0f; --surface: #111118; --border: #1e1e2e; --accent: #00ff88; --accent2: #7c3aed; --text: #e2e8f0; --muted: #64748b; --warn: #f59e0b; --radius: 12px; } body, .gradio-container { background: var(--bg) !important; color: var(--text) !important; font-family: 'Syne', sans-serif !important; } h1, h2, h3 { font-family: 'Syne', sans-serif !important; } #header { text-align: center; padding: 2.5rem 1rem 1.5rem; background: linear-gradient(135deg, #0a0a0f 0%, #111118 100%); border-bottom: 1px solid var(--border); } #header h1 { font-size: 2.8rem; font-weight: 800; background: linear-gradient(90deg, var(--accent) 0%, #00ccff 50%, var(--accent2) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin: 0 0 0.4rem; letter-spacing: -1px; } #header p { color: var(--muted); font-family: 'Space Mono', monospace; font-size: 0.85rem; } .gr-group, .gr-box, .gr-form { background: var(--surface) !important; border: 1px solid var(--border) !important; border-radius: var(--radius) !important; } input[type=range]::-webkit-slider-thumb { background: var(--accent) !important; } button.primary { background: linear-gradient(135deg, var(--accent) 0%, #00ccff 100%) !important; color: #000 !important; font-weight: 700 !important; font-family: 'Syne', sans-serif !important; border: none !important; border-radius: 8px !important; letter-spacing: 0.5px; } button.secondary { background: transparent !important; border: 1px solid var(--border) !important; color: var(--text) !important; } textarea, .gr-textbox { background: var(--bg) !important; color: var(--accent) !important; font-family: 'Space Mono', monospace !important; font-size: 0.82rem !important; border: 1px solid var(--border) !important; } #class-filter textarea { color: var(--warn) !important; border-color: rgba(245,158,11,0.5) !important; } label span { color: var(--muted) !important; font-size: 0.8rem; } .tag { display: inline-block; background: rgba(0,255,136,0.1); color: var(--accent); border: 1px solid rgba(0,255,136,0.3); padding: 2px 10px; border-radius: 999px; font-size: 0.75rem; font-family: 'Space Mono', monospace; margin: 2px; } .tag-cls { background: rgba(245,158,11,0.12); color: var(--warn); border-color: rgba(245,158,11,0.35); } """ HEADER_HTML = """
Ultralytics YOLO26 · End-to-End NMS-Free · Real-time Object Detection
{c}"
for c in COCO_CLASSES)
+ "