John Ho Claude Fable 5 commited on
Commit
8da9cd2
·
1 Parent(s): 2f50931

Add video_visual_inference: SAM2-style mask-prompt video tracking

Browse files

New 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>

Files changed (2) hide show
  1. CLAUDE.md +5 -3
  2. app.py +236 -14
CLAUDE.md CHANGED
@@ -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
- The Gradio UI (two tabs: "Video-Object Tracking" → JSON, "Video Annotation" → video) wraps these as separate `api_name`s.
44
 
45
- **ZeroGPU GPU allocation:** `@spaces.GPU(duration=...)` takes a *callable* (`calc_timeout_duration`) that introspects `video_inference`'s signature and binds the incoming args — this is how the per-request GPU lease length is set dynamically. Keep that signature/binding in sync if you change `video_inference`'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 and the downsampling params (`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.
46
 
47
- Model + processor load **once at import time** into module globals (`VID_MODEL`, `VID_PROCESSOR`); on failure they're set to `None` and every request asserts against that. `DTYPE` is `bfloat16` on capable CUDA, else `float16`, else CPU.
 
 
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
 
app.py CHANGED
@@ -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 calc_timeout_duration(input_video, *args, **kwargs):
178
- # spaces calls this with video_inference's args in the main process, before the GPU is
179
- # requested (probe time is not billed) — keep the binding in sync with its signature.
180
- sig = inspect.signature(video_inference)
181
- bound = sig.bind(input_video, *args, **kwargs)
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
- n_frames = math.ceil(vmeta["duration"] * effective_fps)
204
- est = GPU_DURATION_OVERHEAD_S + n_frames * GPU_DURATION_PER_FRAME_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 ~{n_frames} frames")
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,