Spaces:
Paused
Paused
File size: 6,086 Bytes
d0fe9e3 539a570 d0fe9e3 539a570 d0fe9e3 539a570 d0fe9e3 539a570 d0fe9e3 be872c7 d0fe9e3 be872c7 d0fe9e3 56d8c7a be872c7 d0fe9e3 be872c7 d0fe9e3 be872c7 d0fe9e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | """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()
@spaces.GPU(duration=300)
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)
|