Add video_visual_inference: SAM2-style mask-prompt video tracking
Browse filesNew MCP tool/endpoint, a drop-in match for the sam2 Space's process_video:
seed objects with base64 PNG masks on a reference frame and propagate them
through the video — forward, plus a reverse pass when ref_frame_idx > 0 —
returning the same per-object-per-frame detections format (frame / track_id /
normalized coco bbox / conf / mask_b64, with drop_masks).
- Load Sam3TrackerVideoModel/Processor (transformers' SAM2-API-compatible
PVS tracker) from the same facebook/sam3 checkpoint at import time; a
fourth full model instantiation, so startup RAM grows accordingly.
- Session uses the same OOM-safe device kwargs as video_inference (CPU frame
storage/state, frames streamed to the GPU per step) and reuses
extract_frames + calc_effective_fps, so sample_fps/every_x downsampling
and the sampled-frame index semantics apply here too.
- Seed all objects in a single add_inputs_to_inference_session call — each
call overwrites session.obj_with_new_inputs (same trap as SAM2).
- Split the GPU lease estimator into a shared _estimate_gpu_duration core
with per-endpoint signature-bound callables; the visual one adds
ref_frame_idx reverse-pass steps to the estimate.
- New "Video Visual Tracking" tab mirroring the sam2 Videos tab plus this
repo's timeout-override/downsampling/device inputs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -40,11 +40,13 @@ The HF Space header config lives in `README.md` frontmatter (`sdk: gradio`, `app
|
|
| 40 |
- default → list of per-object-per-frame detection dicts: normalized `x/y/w/h` bbox (from `visualizer.mask_to_xyxy`), `track_id`, and `mask_b64` (1-bit PNG base64, via `toolbox.mask_encoding.b64_mask_encode`). See `example_output.json`.
|
| 41 |
- `annotation_mode=True` → renders mask overlays per frame (`apply_mask_overlay`, colored by `object_id`) and muxes back to mp4 (`frames_to_vid`).
|
| 42 |
|
| 43 |
-
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
| 49 |
## Not part of the deployed app
|
| 50 |
|
|
|
|
| 40 |
- default → list of per-object-per-frame detection dicts: normalized `x/y/w/h` bbox (from `visualizer.mask_to_xyxy`), `track_id`, and `mask_b64` (1-bit PNG base64, via `toolbox.mask_encoding.b64_mask_encode`). See `example_output.json`.
|
| 41 |
- `annotation_mode=True` → renders mask overlays per frame (`apply_mask_overlay`, colored by `object_id`) and muxes back to mp4 (`frames_to_vid`).
|
| 42 |
|
| 43 |
+
A third video endpoint, `video_visual_inference`, is the SAM2-parity PVS path (drop-in match for the sam2 Space's `process_video`): seed objects with base64 PNG masks on `ref_frame_idx` via `Sam3TrackerVideoProcessor.add_inputs_to_inference_session` — **all objects in a single call**, because each call overwrites `session.obj_with_new_inputs` — then `Sam3TrackerVideoModel.propagate_in_video_iterator` forward, plus a `reverse=True` pass when `ref_frame_idx > 0`. Same extraction/downsampling/session-device handling as `video_inference`.
|
| 44 |
|
| 45 |
+
The Gradio UI (tabs: "Video-Object Tracking" → JSON, "Video Annotation" → video, "Video Visual Tracking" → JSON, plus two image tabs) wraps these as separate `api_name`s.
|
| 46 |
|
| 47 |
+
**ZeroGPU GPU allocation:** `@spaces.GPU(duration=...)` takes a *callable* per video endpoint (`calc_timeout_duration` for `video_inference`, `calc_visual_timeout_duration` for `video_visual_inference`) that introspects the endpoint's signature and binds the incoming args, then delegates to the shared `_estimate_gpu_duration`. Keep each callable's binding in sync if you change its endpoint's parameters. An explicit `timeout_duration` is a hard override; otherwise the duration is estimated in the main process (unbilled) from the ffprobe'd video length, the downsampling params, and (for the visual endpoint) the extra reverse-pass steps from a nonzero `ref_frame_idx` (`GPU_DURATION_*` constants: per-frame cost, overhead, 30–300s clamp, 120s fallback when the probe fails). Tune `GPU_DURATION_PER_FRAME_S` from Space logs — an underestimate kills the task mid-run, an overestimate only raises the quota gate.
|
| 48 |
+
|
| 49 |
+
Models + processors load **once at import time** into module globals (`VID_MODEL`, `IMG_MODEL`, `TRK_MODEL`, `TRK_VID_MODEL` + their processors — four full instantiations of the same `facebook/sam3` checkpoint, so startup RAM scales with that); on failure each group is set to `None` and every request asserts against that. `DTYPE` is `bfloat16` on capable CUDA, else `float16`, else CPU.
|
| 50 |
|
| 51 |
## Not part of the deployed app
|
| 52 |
|
|
@@ -18,6 +18,8 @@ from transformers import (
|
|
| 18 |
Sam3Processor,
|
| 19 |
Sam3TrackerModel,
|
| 20 |
Sam3TrackerProcessor,
|
|
|
|
|
|
|
| 21 |
Sam3VideoModel,
|
| 22 |
Sam3VideoProcessor,
|
| 23 |
)
|
|
@@ -26,7 +28,7 @@ from transformers import (
|
|
| 26 |
from ffmpeg_extractor import extract_frames, get_video_metadata
|
| 27 |
|
| 28 |
# import local helpers
|
| 29 |
-
from toolbox.mask_encoding import b64_mask_encode
|
| 30 |
from visualizer import mask_to_xyxy
|
| 31 |
|
| 32 |
logger.remove()
|
|
@@ -68,6 +70,19 @@ except Exception as e:
|
|
| 68 |
TRK_MODEL = None
|
| 69 |
TRK_PROCESSOR = None
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
def apply_mask_overlay(base_image, mask_data, object_ids=None, opacity=0.5):
|
| 73 |
"""Draws segmentation masks on top of an image, using object IDs for coloring."""
|
|
@@ -174,13 +189,11 @@ GPU_DURATION_MAX_S = 300 # videos estimated above this should be downsampled in
|
|
| 174 |
GPU_DURATION_FALLBACK_S = 120 # metadata probe failed
|
| 175 |
|
| 176 |
|
| 177 |
-
def
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
bound.apply_defaults()
|
| 183 |
-
timeout_duration = bound.arguments.get("timeout_duration")
|
| 184 |
if timeout_duration: # explicit user value is a hard override
|
| 185 |
return int(timeout_duration)
|
| 186 |
try:
|
|
@@ -196,17 +209,45 @@ def calc_timeout_duration(input_video, *args, **kwargs):
|
|
| 196 |
if not vmeta or not vmeta.get("fps") or not vmeta.get("duration"):
|
| 197 |
return GPU_DURATION_FALLBACK_S
|
| 198 |
effective_fps = calc_effective_fps(
|
| 199 |
-
vmeta["fps"],
|
| 200 |
-
sample_fps=bound.arguments.get("sample_fps"),
|
| 201 |
-
every_x=bound.arguments.get("every_x"),
|
| 202 |
)
|
| 203 |
-
|
| 204 |
-
est = GPU_DURATION_OVERHEAD_S +
|
| 205 |
duration = max(GPU_DURATION_MIN_S, min(GPU_DURATION_MAX_S, math.ceil(est)))
|
| 206 |
-
logger.info(f"requesting {duration}s of GPU time for ~{
|
| 207 |
return duration
|
| 208 |
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
# Our Inference Function
|
| 211 |
@spaces.GPU(duration=calc_timeout_duration)
|
| 212 |
def video_inference(
|
|
@@ -367,6 +408,134 @@ def video_annotation(
|
|
| 367 |
)
|
| 368 |
|
| 369 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
def image_visual_inference(
|
| 371 |
im: Image.Image,
|
| 372 |
variant=None,
|
|
@@ -584,6 +753,59 @@ with gr.Blocks() as app:
|
|
| 584 |
description="Segment Objects in Video using Text Prompts",
|
| 585 |
api_name="video_annotation",
|
| 586 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
with gr.Tab("Image Visual Segmentation"):
|
| 588 |
gr.Interface(
|
| 589 |
fn=image_visual_inference,
|
|
|
|
| 18 |
Sam3Processor,
|
| 19 |
Sam3TrackerModel,
|
| 20 |
Sam3TrackerProcessor,
|
| 21 |
+
Sam3TrackerVideoModel,
|
| 22 |
+
Sam3TrackerVideoProcessor,
|
| 23 |
Sam3VideoModel,
|
| 24 |
Sam3VideoProcessor,
|
| 25 |
)
|
|
|
|
| 28 |
from ffmpeg_extractor import extract_frames, get_video_metadata
|
| 29 |
|
| 30 |
# import local helpers
|
| 31 |
+
from toolbox.mask_encoding import b64_mask_decode, b64_mask_encode
|
| 32 |
from visualizer import mask_to_xyxy
|
| 33 |
|
| 34 |
logger.remove()
|
|
|
|
| 70 |
TRK_MODEL = None
|
| 71 |
TRK_PROCESSOR = None
|
| 72 |
|
| 73 |
+
try:
|
| 74 |
+
# Visual-prompt video tracking (mask prompts, SAM2-style PVS); same checkpoint —
|
| 75 |
+
# the class pulls the tracker weights out and drops the detector head.
|
| 76 |
+
TRK_VID_MODEL = Sam3TrackerVideoModel.from_pretrained("facebook/sam3").to(
|
| 77 |
+
DEVICE, dtype=DTYPE
|
| 78 |
+
)
|
| 79 |
+
TRK_VID_PROCESSOR = Sam3TrackerVideoProcessor.from_pretrained("facebook/sam3")
|
| 80 |
+
logger.success("Tracker Video Model and Processor Loaded!")
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"❌ CRITICAL ERROR LOADING TRACKER VIDEO MODEL: {e}")
|
| 83 |
+
TRK_VID_MODEL = None
|
| 84 |
+
TRK_VID_PROCESSOR = None
|
| 85 |
+
|
| 86 |
|
| 87 |
def apply_mask_overlay(base_image, mask_data, object_ids=None, opacity=0.5):
|
| 88 |
"""Draws segmentation masks on top of an image, using object IDs for coloring."""
|
|
|
|
| 189 |
GPU_DURATION_FALLBACK_S = 120 # metadata probe failed
|
| 190 |
|
| 191 |
|
| 192 |
+
def _estimate_gpu_duration(
|
| 193 |
+
input_video, timeout_duration=None, sample_fps=None, every_x=None, extra_steps=0
|
| 194 |
+
):
|
| 195 |
+
"""Estimate the GPU lease from the probed video length and downsampling; runs in the
|
| 196 |
+
main process before the GPU is requested (probe time is not billed)."""
|
|
|
|
|
|
|
| 197 |
if timeout_duration: # explicit user value is a hard override
|
| 198 |
return int(timeout_duration)
|
| 199 |
try:
|
|
|
|
| 209 |
if not vmeta or not vmeta.get("fps") or not vmeta.get("duration"):
|
| 210 |
return GPU_DURATION_FALLBACK_S
|
| 211 |
effective_fps = calc_effective_fps(
|
| 212 |
+
vmeta["fps"], sample_fps=sample_fps, every_x=every_x
|
|
|
|
|
|
|
| 213 |
)
|
| 214 |
+
steps = math.ceil(vmeta["duration"] * effective_fps) + extra_steps
|
| 215 |
+
est = GPU_DURATION_OVERHEAD_S + steps * GPU_DURATION_PER_FRAME_S
|
| 216 |
duration = max(GPU_DURATION_MIN_S, min(GPU_DURATION_MAX_S, math.ceil(est)))
|
| 217 |
+
logger.info(f"requesting {duration}s of GPU time for ~{steps} propagation steps")
|
| 218 |
return duration
|
| 219 |
|
| 220 |
|
| 221 |
+
def calc_timeout_duration(input_video, *args, **kwargs):
|
| 222 |
+
# spaces calls this with video_inference's args — keep the binding in sync with its
|
| 223 |
+
# signature.
|
| 224 |
+
sig = inspect.signature(video_inference)
|
| 225 |
+
bound = sig.bind(input_video, *args, **kwargs)
|
| 226 |
+
bound.apply_defaults()
|
| 227 |
+
return _estimate_gpu_duration(
|
| 228 |
+
input_video,
|
| 229 |
+
timeout_duration=bound.arguments.get("timeout_duration"),
|
| 230 |
+
sample_fps=bound.arguments.get("sample_fps"),
|
| 231 |
+
every_x=bound.arguments.get("every_x"),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def calc_visual_timeout_duration(input_video, *args, **kwargs):
|
| 236 |
+
# spaces calls this with video_visual_inference's args — keep the binding in sync with
|
| 237 |
+
# its signature.
|
| 238 |
+
sig = inspect.signature(video_visual_inference)
|
| 239 |
+
bound = sig.bind(input_video, *args, **kwargs)
|
| 240 |
+
bound.apply_defaults()
|
| 241 |
+
return _estimate_gpu_duration(
|
| 242 |
+
input_video,
|
| 243 |
+
timeout_duration=bound.arguments.get("timeout_duration"),
|
| 244 |
+
sample_fps=bound.arguments.get("sample_fps"),
|
| 245 |
+
every_x=bound.arguments.get("every_x"),
|
| 246 |
+
# a nonzero ref frame adds the reverse pass (ref..0) on top of the forward one
|
| 247 |
+
extra_steps=int(bound.arguments.get("ref_frame_idx") or 0),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
# Our Inference Function
|
| 252 |
@spaces.GPU(duration=calc_timeout_duration)
|
| 253 |
def video_inference(
|
|
|
|
| 408 |
)
|
| 409 |
|
| 410 |
|
| 411 |
+
@spaces.GPU(duration=calc_visual_timeout_duration)
|
| 412 |
+
def video_visual_inference(
|
| 413 |
+
input_video,
|
| 414 |
+
masks: str | list,
|
| 415 |
+
drop_masks: bool = False,
|
| 416 |
+
ref_frame_idx: int = 0,
|
| 417 |
+
timeout_duration: int = None,
|
| 418 |
+
sample_fps: float = None,
|
| 419 |
+
every_x: int = None,
|
| 420 |
+
video_load_device: str = "cpu",
|
| 421 |
+
) -> list[dict]:
|
| 422 |
+
"""SAM3 video segmentation with visual prompts: track objects through a video given their segmentation masks on a reference frame; a drop-in match for SAM2's process_video.
|
| 423 |
+
|
| 424 |
+
Seed the objects to track by supplying their masks on the reference frame (ref_frame_idx); SAM3 then propagates each object forward -- and, when ref_frame_idx is nonzero, also backward -- through every frame. Unlike video_inference (which finds objects from a text prompt), this tool tracks exactly the objects whose masks you provide, e.g. masks obtained from image_visual_inference or image_text_inference on the reference frame. The video can optionally be downsampled before tracking with sample_fps or every_x; all frame indices (the "frame" field of every detection AND the ref_frame_idx input) are 0-based positions in the SAMPLED frame sequence, NOT original video frame numbers, so with every_x=5 the detection "frame": 2 corresponds to original frame 10. Returns a JSON list of per-frame detections, one entry per tracked object per frame in which it appears (frames where an object is absent are skipped). Each detection is a dict with: "frame" (integer sampled-frame index as defined above); "track_id" (integer object id matching the position of the seed mask in the masks input, stable across frames); "x", "y", "w", "h" (the object's bounding box as top-left-x, top-left-y, width, height, each NORMALIZED to 0.0-1.0 by dividing by the frame width or height -- this is the albumentations "coco" layout [x_min, y_min, width, height] but normalized to 0-1 rather than absolute pixels, and it is NOT [x_min, y_min, x_max, y_max]; box formats documented at https://albumentations.ai/docs/3-basic-usage/bounding-boxes-augmentations/#bounding-box-formats ); "conf" (always 1); and, unless drop_masks is true, "mask_b64" (a base64-encoded 1-bit PNG string the same width and height as the video frame, 1 inside the object and 0 elsewhere).
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
input_video: The input video to segment (a file path, or an uploaded-file object with a "name" key).
|
| 428 |
+
masks: JSON list of base64-encoded 1-bit PNG masks for the reference frame, one per object to track, e.g. ["b'iVBORw0KGgo...'", ...]; the b'...' literal wrapper is accepted and stripped.
|
| 429 |
+
drop_masks: When true, omit the "mask_b64" field from every detection so only bounding-box information is returned.
|
| 430 |
+
ref_frame_idx: Index of the frame the provided masks correspond to, counted in the SAMPLED frame sequence when sample_fps or every_x is set; a nonzero value triggers bidirectional tracking (forward and backward from this frame).
|
| 431 |
+
timeout_duration: Max GPU lease in seconds for this request, used as a hard override; leave empty to auto-estimate it from the video length, downsampling settings, and ref_frame_idx.
|
| 432 |
+
sample_fps: Sample the video down to roughly this many frames per second before tracking (clamped to the source fps; takes precedence over every_x); leave empty to keep every frame.
|
| 433 |
+
every_x: Keep only every Nth frame of the video before tracking, e.g. 5 keeps original frames 0, 5, 10, ... (ignored when sample_fps is set); leave empty to keep every frame.
|
| 434 |
+
video_load_device: Device the video frames are preprocessed and stored on: "cpu" (default) streams frames to the GPU one at a time and keeps GPU memory low; "cuda" holds the whole preprocessed video in GPU memory, which is faster per frame but runs out of GPU memory on long videos.
|
| 435 |
+
"""
|
| 436 |
+
assert TRK_VID_MODEL is not None and TRK_VID_PROCESSOR is not None, (
|
| 437 |
+
"Tracker video model failed to load on startup."
|
| 438 |
+
)
|
| 439 |
+
assert input_video and masks, "Missing video or masks."
|
| 440 |
+
video_path = (
|
| 441 |
+
input_video if isinstance(input_video, str) else input_video.get("name", None)
|
| 442 |
+
)
|
| 443 |
+
assert video_path, "Invalid video input."
|
| 444 |
+
|
| 445 |
+
masks = json.loads(masks) if isinstance(masks, str) else masks
|
| 446 |
+
masks = [
|
| 447 |
+
m[2:-1].encode() if m.startswith("b'") and m.endswith("'") else m for m in masks
|
| 448 |
+
] # expect the b'' literal to be included
|
| 449 |
+
masks = [b64_mask_decode(m).astype(bool) for m in masks]
|
| 450 |
+
|
| 451 |
+
vmeta = get_video_metadata(video_path, bverbose=False)
|
| 452 |
+
assert vmeta, "Failed to extract video metadata."
|
| 453 |
+
vid_fps = vmeta["fps"]
|
| 454 |
+
vid_w = vmeta["width"]
|
| 455 |
+
vid_h = vmeta["height"]
|
| 456 |
+
effective_fps = calc_effective_fps(vid_fps, sample_fps=sample_fps, every_x=every_x)
|
| 457 |
+
|
| 458 |
+
pil_frames = extract_frames(
|
| 459 |
+
video_path,
|
| 460 |
+
fps=effective_fps,
|
| 461 |
+
max_short_edge=min(vid_w, vid_h),
|
| 462 |
+
write_timestamp=False,
|
| 463 |
+
write_frame_num=False,
|
| 464 |
+
output_dir=None,
|
| 465 |
+
)
|
| 466 |
+
assert len(pil_frames) > 0, "No frames found in video."
|
| 467 |
+
ref_frame_idx = int(ref_frame_idx or 0)
|
| 468 |
+
assert 0 <= ref_frame_idx < len(pil_frames), (
|
| 469 |
+
f"ref_frame_idx {ref_frame_idx} out of range for "
|
| 470 |
+
f"{len(pil_frames)} sampled frames."
|
| 471 |
+
)
|
| 472 |
+
video_frames = [np.array(frame.convert("RGB")) for frame in pil_frames]
|
| 473 |
+
|
| 474 |
+
session = TRK_VID_PROCESSOR.init_video_session(
|
| 475 |
+
video=video_frames,
|
| 476 |
+
inference_device=DEVICE,
|
| 477 |
+
# same OOM guard as video_inference: keep frames + per-frame state off the GPU
|
| 478 |
+
processing_device=video_load_device,
|
| 479 |
+
video_storage_device=video_load_device,
|
| 480 |
+
inference_state_device="cpu",
|
| 481 |
+
dtype=DTYPE,
|
| 482 |
+
)
|
| 483 |
+
# Seed every object in a single call — add_inputs_to_inference_session overwrites
|
| 484 |
+
# session.obj_with_new_inputs on each call, so seeding masks one-per-call would leave
|
| 485 |
+
# only the last object registered as "new".
|
| 486 |
+
TRK_VID_PROCESSOR.add_inputs_to_inference_session(
|
| 487 |
+
inference_session=session,
|
| 488 |
+
frame_idx=ref_frame_idx,
|
| 489 |
+
obj_ids=list(range(len(masks))),
|
| 490 |
+
input_masks=masks,
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
def frame_detections(model_out):
|
| 494 |
+
pp_masks = TRK_VID_PROCESSOR.post_process_masks(
|
| 495 |
+
[model_out.pred_masks],
|
| 496 |
+
original_sizes=[[session.video_height, session.video_width]],
|
| 497 |
+
binarize=False,
|
| 498 |
+
)[0] # (num_objects, 1, H, W)
|
| 499 |
+
dets = []
|
| 500 |
+
for i, obj_id in enumerate(session.obj_ids):
|
| 501 |
+
mask_bin = (pp_masks[i, 0].cpu().numpy() > 0.0).astype(np.uint8)
|
| 502 |
+
xyxy = mask_to_xyxy(mask_bin)
|
| 503 |
+
if not xyxy:
|
| 504 |
+
continue
|
| 505 |
+
x0, y0, x1, y1 = xyxy
|
| 506 |
+
det = {
|
| 507 |
+
"frame": model_out.frame_idx,
|
| 508 |
+
"track_id": int(obj_id),
|
| 509 |
+
"x": x0 / vid_w,
|
| 510 |
+
"y": y0 / vid_h,
|
| 511 |
+
"w": (x1 - x0) / vid_w,
|
| 512 |
+
"h": (y1 - y0) / vid_h,
|
| 513 |
+
"conf": 1,
|
| 514 |
+
}
|
| 515 |
+
if not drop_masks:
|
| 516 |
+
det["mask_b64"] = b64_mask_encode(mask_bin).decode("ascii")
|
| 517 |
+
dets.append(det)
|
| 518 |
+
return dets
|
| 519 |
+
|
| 520 |
+
detections = []
|
| 521 |
+
for model_out in TRK_VID_MODEL.propagate_in_video_iterator(
|
| 522 |
+
inference_session=session,
|
| 523 |
+
start_frame_idx=ref_frame_idx,
|
| 524 |
+
max_frame_num_to_track=len(video_frames),
|
| 525 |
+
):
|
| 526 |
+
detections.extend(frame_detections(model_out))
|
| 527 |
+
if ref_frame_idx > 0:
|
| 528 |
+
# backward pass ref..0; the ref frame itself is already covered by the forward pass
|
| 529 |
+
for model_out in TRK_VID_MODEL.propagate_in_video_iterator(
|
| 530 |
+
inference_session=session, start_frame_idx=ref_frame_idx, reverse=True
|
| 531 |
+
):
|
| 532 |
+
if model_out.frame_idx == ref_frame_idx:
|
| 533 |
+
continue
|
| 534 |
+
detections.extend(frame_detections(model_out))
|
| 535 |
+
detections.sort(key=lambda d: (d["frame"], d["track_id"]))
|
| 536 |
+
return detections
|
| 537 |
+
|
| 538 |
+
|
| 539 |
def image_visual_inference(
|
| 540 |
im: Image.Image,
|
| 541 |
variant=None,
|
|
|
|
| 753 |
description="Segment Objects in Video using Text Prompts",
|
| 754 |
api_name="video_annotation",
|
| 755 |
)
|
| 756 |
+
with gr.Tab("Video Visual Tracking"):
|
| 757 |
+
gr.Interface(
|
| 758 |
+
fn=video_visual_inference,
|
| 759 |
+
inputs=[
|
| 760 |
+
gr.Video(label="Input Video"),
|
| 761 |
+
gr.Textbox(
|
| 762 |
+
label="Masks for Objects of Interest in the Reference Frame",
|
| 763 |
+
value=None,
|
| 764 |
+
lines=5,
|
| 765 |
+
placeholder="""
|
| 766 |
+
JSON list of base64 encoded masks, e.g.: ["b'iVBORw0KGgoAAAANSUhEUgAABDgAAAeAAQAAAAADGtqnAAAXz...'",...]
|
| 767 |
+
""",
|
| 768 |
+
),
|
| 769 |
+
gr.Checkbox(
|
| 770 |
+
label="Drop Masks",
|
| 771 |
+
info="remove base64 encoded masks from result JSON",
|
| 772 |
+
value=True,
|
| 773 |
+
),
|
| 774 |
+
gr.Number(
|
| 775 |
+
label="Reference Frame Index",
|
| 776 |
+
info="frame index for the provided object masks",
|
| 777 |
+
value=0,
|
| 778 |
+
precision=0,
|
| 779 |
+
),
|
| 780 |
+
gr.Number(
|
| 781 |
+
label="Timeout Override (seconds)",
|
| 782 |
+
info="max GPU lease; leave empty to auto-estimate from video length and downsampling",
|
| 783 |
+
value=None,
|
| 784 |
+
precision=0,
|
| 785 |
+
),
|
| 786 |
+
gr.Number(
|
| 787 |
+
label="Sample FPS",
|
| 788 |
+
info="downsample the video to this many frames per second before tracking; empty = every frame",
|
| 789 |
+
value=None,
|
| 790 |
+
),
|
| 791 |
+
gr.Number(
|
| 792 |
+
label="Every Xth Frame",
|
| 793 |
+
info="keep only every Nth frame before tracking; ignored when Sample FPS is set",
|
| 794 |
+
value=None,
|
| 795 |
+
precision=0,
|
| 796 |
+
),
|
| 797 |
+
gr.Dropdown(
|
| 798 |
+
label="Video Load Device",
|
| 799 |
+
info="where preprocessed frames are stored; cpu streams them to the GPU per frame",
|
| 800 |
+
choices=["cpu", "cuda"],
|
| 801 |
+
value="cpu",
|
| 802 |
+
),
|
| 803 |
+
],
|
| 804 |
+
outputs=gr.JSON(label="Output JSON"),
|
| 805 |
+
title="SAM3 Video Tracking (Visual Prompts)",
|
| 806 |
+
description="Track Objects in Video from their Masks on a Reference Frame (SAM2-style)",
|
| 807 |
+
api_name="video_visual_inference",
|
| 808 |
+
)
|
| 809 |
with gr.Tab("Image Visual Segmentation"):
|
| 810 |
gr.Interface(
|
| 811 |
fn=image_visual_inference,
|