Spaces:
Sleeping
Sleeping
| """SAM 3 video concept-tracking API (ZeroGPU), transformers Sam3VideoModel route. | |
| Tracks every instance of the given concept(s) across video frames with stable | |
| object ids. Output schema matches the local video_client parser: | |
| {version, model, fps, width, height, n_frames, tracks:[{label, object_id, | |
| frames:[{frame, score, box, mask_png_b64}]}]} | |
| """ | |
| import base64 | |
| import io | |
| import os | |
| import gradio as gr | |
| import numpy as np | |
| import spaces | |
| from PIL import Image | |
| from transformers import Sam3VideoModel, Sam3VideoProcessor | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| MODEL_ID = "facebook/sam3" | |
| # Built at import on CPU; moved to CUDA inside the @spaces.GPU function. | |
| processor = Sam3VideoProcessor.from_pretrained(MODEL_ID, token=HF_TOKEN) | |
| model = Sam3VideoModel.from_pretrained(MODEL_ID, token=HF_TOKEN) | |
| model.eval() | |
| def _enc(mask_bool: np.ndarray, maxside: int = 512) -> str: | |
| h, w = mask_bool.shape | |
| img = Image.fromarray((mask_bool.astype(np.uint8) * 255), "L") | |
| scale = min(1.0, maxside / max(h, w)) | |
| if scale < 1.0: | |
| img = img.resize((max(1, int(w * scale)), max(1, int(h * scale)))) | |
| buf = io.BytesIO(); img.save(buf, "PNG") | |
| return base64.b64encode(buf.getvalue()).decode("ascii") | |
| def _np(x): | |
| return x.detach().cpu().numpy() if hasattr(x, "detach") else np.asarray(x) | |
| def _read_frames(path, max_frames): | |
| """Sample up to `max_frames` frames EVENLY across the whole video (proper intervals), not just the | |
| first N. A clip with <= max_frames frames is taken in full; a longer clip is sub-sampled at a | |
| constant stride so the selection spans start→end.""" | |
| import imageio | |
| max_frames = max(1, int(max_frames)) | |
| reader = imageio.get_reader(path) | |
| try: | |
| try: | |
| n = int(reader.count_frames()) | |
| except Exception: # some streams can't report a count -> fall back to sequential | |
| n = 0 | |
| if n > max_frames: | |
| # evenly-spaced indices spanning 0 .. n-1 (e.g. 96 frames, max 24 -> every 4th frame) | |
| idxs = sorted({round(i * (n - 1) / (max_frames - 1)) for i in range(max_frames)}) \ | |
| if max_frames > 1 else [0] | |
| frames = [] | |
| for i in idxs: | |
| try: | |
| frames.append(np.asarray(reader.get_data(i))) | |
| except Exception: | |
| break | |
| if frames: | |
| return frames | |
| # short clip (or no count / seek unsupported): read sequentially up to max_frames | |
| frames = [] | |
| for i, fr in enumerate(reader): | |
| if i >= max_frames: | |
| break | |
| frames.append(np.asarray(fr)) | |
| return frames | |
| finally: | |
| reader.close() | |
| def api_track(video, concepts, conf, max_frames): | |
| """Streaming generator: yields {done:False, progress, desc} per frame, then a | |
| final {done:True, ..., tracks:[...]}. (gr.Progress can't cross ZeroGPU's process | |
| boundary, so we stream progress as output instead.)""" | |
| device = "cuda" | |
| model.to(device) | |
| concept_list = [c.strip() for c in (concepts or "").split(",") if c.strip()] or ["person"] | |
| frames = _read_frames(video, int(max_frames)) | |
| if not frames: | |
| yield {"done": True, "error": "no frames read from video", "tracks": []} | |
| return | |
| H, W = frames[0].shape[:2] | |
| total = max(1, min(len(frames), int(max_frames))) | |
| yield {"done": False, "progress": 0.0, "desc": f"loaded {len(frames)} frames; starting tracker"} | |
| session = processor.init_video_session( | |
| video=frames, inference_device=device, | |
| processing_device="cpu", video_storage_device="cpu", | |
| ) | |
| processor.add_text_prompt(session, concept_list) | |
| tracks, obj_label, n_frames = {}, {}, 0 | |
| for mo in model.propagate_in_video_iterator(inference_session=session, | |
| max_frame_num_to_track=int(max_frames)): | |
| proc = processor.postprocess_outputs(session, mo) | |
| fi = int(mo.frame_idx); n_frames = max(n_frames, fi + 1) | |
| for prompt, oids in (proc.get("prompt_to_obj_ids") or {}).items(): | |
| for oid in oids: | |
| obj_label.setdefault(int(oid), prompt) | |
| oids = _np(proc["object_ids"]).tolist() | |
| scores = _np(proc["scores"]).tolist() | |
| masks = proc["masks"] | |
| boxes = _np(proc["boxes"]) | |
| for k, oid in enumerate(oids): | |
| oid = int(oid) | |
| m = _np(masks[k]) | |
| if m.ndim == 3: | |
| m = m[0] | |
| m = m > 0.5 if m.dtype != bool else m | |
| tr = tracks.get(oid) | |
| if tr is None: | |
| tr = {"label": obj_label.get(oid, concept_list[0]), "object_id": oid, "frames": []} | |
| tracks[oid] = tr | |
| tr["frames"].append({"frame": fi, "score": float(scores[k]), | |
| "box": [float(v) for v in boxes[k]], | |
| "mask_png_b64": _enc(m)}) | |
| yield {"done": False, "progress": min(fi + 1, total) / total, | |
| "desc": f"frame {fi + 1}/{total}"} | |
| out_tracks = [] | |
| for oid, tr in tracks.items(): | |
| tr["label"] = obj_label.get(oid, tr["label"]) | |
| if tr["frames"] and max(f["score"] for f in tr["frames"]) >= float(conf): | |
| out_tracks.append(tr) | |
| yield {"done": True, "version": "3", "model": MODEL_ID, "fps": 0.0, | |
| "width": W, "height": H, "n_frames": n_frames, "tracks": out_tracks} | |
| with gr.Blocks(title="SAM3 Video") as demo: | |
| gr.Markdown("# SAM 3 Video Tracking API\nUpload a video, enter comma-separated concepts.") | |
| with gr.Row(): | |
| vid = gr.File(file_count="single", type="filepath", label="Video (mp4)") | |
| out = gr.JSON(label="Tracks") | |
| txt = gr.Textbox(label="Concepts (comma-separated)", value="person") | |
| conf = gr.Slider(0.0, 1.0, value=0.4, step=0.05, label="Confidence") | |
| mf = gr.Slider(8, 96, value=48, step=8, label="Max frames") | |
| gr.Button("Track").click(api_track, [vid, txt, conf, mf], out, api_name="api_track") | |
| if __name__ == "__main__": | |
| demo.queue().launch(show_error=True) | |