SAM3-Video / app.py
Sempy32's picture
Upload folder using huggingface_hub
539a570 verified
Raw
History Blame Contribute Delete
6.09 kB
"""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)