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)