Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """ | |
| run.py - Entry point for SAM 2 Robust Tracker | |
| ============================================== | |
| Usage examples: | |
| python run.py --video my_video.mp4 --prompt "person . car . dog" | |
| python run.py --video my_video.mp4 --prompt "person . car" --fps 5 --chunk 100 | |
| python run.py --video my_video.mp4 --prompt prompts.txt | |
| """ | |
| import os | |
| import sys | |
| import argparse | |
| import shutil | |
| import torch | |
| # Resolve SAM 2 checkpoint path | |
| HOME_DIR = os.path.expanduser("~") | |
| DEFAULT_SAM2_CKPT = os.path.join( | |
| HOME_DIR, ".cache", "torch", "hub", "checkpoints", "sam2.1_hiera_small.pt" | |
| ) | |
| DEFAULT_SAM2_CFG = "configs/sam2.1/sam2.1_hiera_s.yaml" | |
| # Default detection prompt | |
| DEFAULT_PROMPT = ( | |
| "# Structures & Openings\n" | |
| "room door, door handle, wardrobe, window, stairs, socket, light switch\n\n" | |
| "# Furniture\n" | |
| "sofa, armchair, chair, table, bed, cabinet, bookcase, shelf, desk, nightstand\n\n" | |
| "# Kitchen & Bathroom\n" | |
| "sink, faucet, refrigerator, stove, oven, microwave, toilet, bathtub, shower\n\n" | |
| "# Lighting & Decor\n" | |
| "curtain, rug, lamp, painting, vase, clock, plant\n\n" | |
| "# Electronics\n" | |
| "tv, monitor, air conditioner, fan, laptop, phone, remote control" | |
| ) | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def parse_args(): | |
| p = argparse.ArgumentParser( | |
| description="SAM 2 + Grounding DINO Robust Object Tracker", | |
| formatter_class=argparse.RawTextHelpFormatter | |
| ) | |
| p.add_argument("--video", required=True, | |
| help="Path to input video file.") | |
| p.add_argument("--prompt", default=DEFAULT_PROMPT, | |
| help="Detection prompt: comma/dot separated labels, or path to .txt file.") | |
| p.add_argument("--output", default="tracked_output.mp4", | |
| help="Path for output annotated video. (default: tracked_output.mp4)") | |
| p.add_argument("--fps", type=float, default=2.0, | |
| help="Target processing FPS. 2.0 = 1 frame every 0.5s. (default: 2.0)") | |
| p.add_argument("--max-size", type=int, default=720, | |
| help="Longest edge in pixels after resize. (default: 720)") | |
| p.add_argument("--chunk", type=int, default=120, | |
| help="SAM 2 sliding window size in frames. (default: 120)\n" | |
| " 4 GB VRAM → 80 | 6 GB → 120 | 8 GB+ → 200") | |
| p.add_argument("--box-thresh", type=float, default=0.30, | |
| help="DINO box confidence threshold. (default: 0.30)") | |
| p.add_argument("--text-thresh", type=float, default=0.25, | |
| help="DINO text confidence threshold. (default: 0.25)") | |
| p.add_argument("--iou-thresh", type=float, default=0.45, | |
| help="NMS IoU threshold. (default: 0.45)") | |
| p.add_argument("--max-objects", type=int, default=15, | |
| help="Max objects to track (VRAM guard). (default: 15)") | |
| p.add_argument("--stabilize", action="store_true", default=False, | |
| help="Apply ORB-based video stabilization before tracking.") | |
| p.add_argument("--blur-thresh", type=float, default=60.0, | |
| help="Skip blurry frames below this Laplacian variance. 0 = off.") | |
| p.add_argument("--sam2-ckpt", default=DEFAULT_SAM2_CKPT, | |
| help=f"Path to SAM 2 checkpoint .pt file.\n(default: {DEFAULT_SAM2_CKPT})") | |
| p.add_argument("--sam2-cfg", default=DEFAULT_SAM2_CFG, | |
| help=f"SAM 2 model config yaml.\n(default: {DEFAULT_SAM2_CFG})") | |
| p.add_argument("--frames-dir", default="__frames_temp__", | |
| help="Temp directory for extracted frames. (default: __frames_temp__)") | |
| p.add_argument("--keep-frames", action="store_true", default=False, | |
| help="Keep extracted frames on disk after tracking.") | |
| return p.parse_args() | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def load_prompt(prompt_arg: str) -> str: | |
| """If prompt_arg is a path to a .txt file, read it; otherwise return as-is.""" | |
| if os.path.isfile(prompt_arg): | |
| with open(prompt_arg, "r", encoding="utf-8") as f: | |
| return f.read() | |
| return prompt_arg | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def print_banner(args): | |
| vram_str = (f"{torch.cuda.get_device_properties(0).total_memory // 1024**2} MB" | |
| if torch.cuda.is_available() else "CPU mode") | |
| print() | |
| print("=" * 65) | |
| print(" SAM 2 Robust Tracker - Grounding DINO + Sliding Window") | |
| print("=" * 65) | |
| print(f" Input : {args.video}") | |
| print(f" Output : {args.output}") | |
| print(f" FPS : {args.fps} (1 frame every {1/args.fps:.2f}s)") | |
| print(f" Chunk : {args.chunk} frames per SAM-2 window") | |
| print(f" MaxObj : {args.max_objects}") | |
| print(f" MaxPx : {args.max_size}px") | |
| print(f" Stab : {args.stabilize}") | |
| print(f" VRAM : {vram_str}") | |
| print("=" * 65) | |
| print() | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| def main(): | |
| args = parse_args() | |
| prompt = load_prompt(args.prompt) | |
| if not os.path.exists(args.video): | |
| print(f"[ERROR] Video not found: {args.video}") | |
| sys.exit(1) | |
| if not os.path.exists(args.sam2_ckpt): | |
| print(f"[ERROR] SAM 2 checkpoint not found: {args.sam2_ckpt}") | |
| print(" Download with: python download_models.py") | |
| sys.exit(1) | |
| print_banner(args) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| print(f"[Device] {device}\n") | |
| # ── Import our modules ─────────────────────────────────────────────────── | |
| from tracker import ( | |
| VideoFrameStore, | |
| DinoDetector, | |
| SAM2Tracker, | |
| TrackedObject, | |
| ) | |
| # ── Step 1: Extract frames ─────────────────────────────────────────────── | |
| print("-" * 50) | |
| print("Step 1/3 - Extract Frames") | |
| print("-" * 50) | |
| store = VideoFrameStore( | |
| video_path = args.video, | |
| output_dir = args.frames_dir, | |
| target_fps = args.fps, | |
| max_size = args.max_size, | |
| blur_threshold = args.blur_thresh, | |
| stabilize = args.stabilize, | |
| ) | |
| n_frames = store.extract() | |
| if n_frames == 0: | |
| print("[ERROR] No frames extracted.") | |
| sys.exit(1) | |
| print(f" → {n_frames} frames Âx {store.width}×{store.height}px\n") | |
| # ── Step 2: DINO detection on frame 0 ──────────────────────────────────── | |
| print("-" * 50) | |
| print("Step 2/3 - Initial Object Detection (DINO)") | |
| print("-" * 50) | |
| dino = DinoDetector(device) | |
| dino.load() | |
| first_frame = store.frame_paths[0] | |
| boxes, scores, labels = dino.detect( | |
| first_frame, prompt, | |
| box_threshold = args.box_thresh, | |
| text_threshold = args.text_thresh, | |
| iou_threshold = args.iou_thresh, | |
| ) | |
| if len(boxes) == 0: | |
| print("[WARN] DINO found no objects in the first frame.") | |
| print(" Tip: lower --box-thresh or widen your --prompt.") | |
| sys.exit(1) | |
| # Cap to max_objects (by score, highest first) | |
| if len(boxes) > args.max_objects: | |
| top_k = scores.argsort()[::-1][:args.max_objects] | |
| boxes = boxes[top_k] | |
| scores = scores[top_k] | |
| labels = [labels[i] for i in top_k] | |
| print(f" [Cap] Keeping top {args.max_objects} objects by confidence.") | |
| # Build TrackedObject list | |
| tracked_objects = [] | |
| for i, (box, score, label) in enumerate(zip(boxes, scores, labels)): | |
| obj = TrackedObject(obj_id=i, label=label, box=box) | |
| tracked_objects.append(obj) | |
| print(f" [ID {i:2d}] {label:<30s} conf={score:.3f} box={box.astype(int).tolist()}") | |
| print(f"\n → {len(tracked_objects)} objects registered.\n") | |
| # ── Step 3: SAM 2 sliding-window tracking ──────────────────────────────── | |
| print("-" * 50) | |
| print("Step 3/3 - SAM 2 Sliding-Window Tracking") | |
| print("-" * 50) | |
| sam2 = SAM2Tracker( | |
| sam2_checkpoint = args.sam2_ckpt, | |
| sam2_cfg = args.sam2_cfg, | |
| device = device, | |
| chunk_size = args.chunk, | |
| ) | |
| sam2.load() | |
| def progress(done, total): | |
| pct = 100 * done / total | |
| filled = int(pct // 2) | |
| bar = "#" * filled + "-" * (50 - filled) | |
| print(f"\r [{bar}] {pct:5.1f}% ({done}/{total} frames)", end="", flush=True) | |
| result_labels = sam2.track_video( | |
| frame_store = store, | |
| tracked_objects = tracked_objects, | |
| dino = dino, | |
| prompt = prompt, | |
| box_threshold = args.box_thresh, | |
| text_threshold = args.text_thresh, | |
| iou_threshold = args.iou_thresh, | |
| output_path = args.output, | |
| progress_cb = progress, | |
| ) | |
| print() | |
| # ── Cleanup ────────────────────────────────────────────────────────────── | |
| if not args.keep_frames and os.path.exists(args.frames_dir): | |
| shutil.rmtree(args.frames_dir, ignore_errors=True) | |
| print(f"[Clean] Removed temp frames: {args.frames_dir}") | |
| # ── Summary ────────────────────────────────────────────────────────────── | |
| print() | |
| print("=" * 65) | |
| print(" DONE!") | |
| print("=" * 65) | |
| print(f" Output video : {os.path.abspath(args.output)}") | |
| print(f" Tracked : {len(result_labels)} objects") | |
| for i, label in enumerate(result_labels): | |
| status = "OK" if not tracked_objects[i].lost else "LOST at end" | |
| print(f" ID {i:2d} [{status}] {label}") | |
| print("=" * 65) | |
| if __name__ == "__main__": | |
| main() | |