yonigozlan's picture
yonigozlan HF Staff
load video with cv2
942c318
raw
history blame
32.1 kB
import colorsys
import gc
from copy import deepcopy
from typing import Optional
import cv2
import gradio as gr
import numpy as np
import spaces
import torch
from gradio.themes import Soft
from PIL import Image, ImageDraw
from transformers import AutoModel, Sam2VideoProcessor
def pastel_color_for_object(obj_id: int) -> tuple[int, int, int]:
"""Generate a deterministic pastel RGB color for a given object id.
Uses golden ratio to distribute hues; low-medium saturation, high value.
"""
golden_ratio_conjugate = 0.61803398875
# Map obj_id (1-based) to hue in [0,1)
hue = (obj_id * golden_ratio_conjugate) % 1.0
saturation = 0.45
value = 1.0
r_f, g_f, b_f = colorsys.hsv_to_rgb(hue, saturation, value)
return int(r_f * 255), int(g_f * 255), int(b_f * 255)
def try_load_video_frames(video_path_or_url: str) -> tuple[list[Image.Image], dict]:
"""Load video frames as PIL Images using transformers.video_utils if available,
otherwise fall back to OpenCV. Returns (frames, info).
"""
cap = cv2.VideoCapture(video_path_or_url)
frames = []
print("loading video frames")
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frames.append(Image.fromarray(frame_rgb))
# Gather fps if available
fps_val = cap.get(cv2.CAP_PROP_FPS)
cap.release()
print("loaded video frames")
info = {
"num_frames": len(frames),
"fps": float(fps_val) if fps_val and fps_val > 0 else None,
}
return frames, info
def overlay_masks_on_frame(
frame: Image.Image,
masks_per_object: dict[int, np.ndarray],
color_by_obj: dict[int, tuple[int, int, int]],
alpha: float = 0.5,
) -> Image.Image:
"""Overlay per-object soft masks onto the RGB frame.
masks_per_object: mapping of obj_id -> (H, W) float mask in [0,1]
color_by_obj: mapping of obj_id -> (R, G, B)
"""
base = np.array(frame).astype(np.float32) / 255.0 # H, W, 3 in [0,1]
height, width = base.shape[:2]
overlay = base.copy()
for obj_id, mask in masks_per_object.items():
if mask is None:
continue
if mask.dtype != np.float32:
mask = mask.astype(np.float32)
# Ensure shape is H x W
if mask.ndim == 3:
mask = mask.squeeze()
mask = np.clip(mask, 0.0, 1.0)
color = np.array(color_by_obj.get(obj_id, (255, 0, 0)), dtype=np.float32) / 255.0
# Blend: overlay = (1 - a*m)*overlay + (a*m)*color
a = alpha
m = mask[..., None]
overlay = (1.0 - a * m) * overlay + (a * m) * color
out = np.clip(overlay * 255.0, 0, 255).astype(np.uint8)
return Image.fromarray(out)
def get_device_and_dtype() -> tuple[str, torch.dtype]:
device = "cpu"
dtype = torch.bfloat16
return device, dtype
class AppState:
def __init__(self):
self.reset()
def reset(self):
self.video_frames: list[Image.Image] = []
self.inference_session = None
self.model: Optional[AutoModel] = None
self.processor: Optional[Sam2VideoProcessor] = None
self.device: str = "cpu"
self.dtype: torch.dtype = torch.bfloat16
self.video_fps: float | None = None
self.masks_by_frame: dict[int, dict[int, np.ndarray]] = {}
self.color_by_obj: dict[int, tuple[int, int, int]] = {}
self.clicks_by_frame_obj: dict[int, dict[int, list[tuple[int, int, int]]]] = {}
self.boxes_by_frame_obj: dict[int, dict[int, list[tuple[int, int, int, int]]]] = {}
# Cache of composited frames (original + masks + clicks)
self.composited_frames: dict[int, Image.Image] = {}
# UI state for click handler
self.current_frame_idx: int = 0
self.current_obj_id: int = 1
self.current_label: str = "positive"
self.current_clear_old: bool = True
self.current_prompt_type: str = "Points" # or "Boxes"
self.pending_box_start: tuple[int, int] | None = None
self.pending_box_start_frame_idx: int | None = None
self.pending_box_start_obj_id: int | None = None
self.is_switching_model: bool = False
# Model selection
self.model_repo_key: str = "tiny"
self.model_repo_id: str | None = None
self.session_repo_id: str | None = None
def __repr__(self):
return f"AppState(video_frames={self.video_frames}, inference_session={self.inference_session is not None}, model={self.model is not None}, processor={self.processor is not None}, device={self.device}, dtype={self.dtype}, video_fps={self.video_fps}, masks_by_frame={self.masks_by_frame}, color_by_obj={self.color_by_obj}, clicks_by_frame_obj={self.clicks_by_frame_obj}, boxes_by_frame_obj={self.boxes_by_frame_obj}, composited_frames={self.composited_frames}, current_frame_idx={self.current_frame_idx}, current_obj_id={self.current_obj_id}, current_label={self.current_label}, current_clear_old={self.current_clear_old}, current_prompt_type={self.current_prompt_type}, pending_box_start={self.pending_box_start}, pending_box_start_frame_idx={self.pending_box_start_frame_idx}, pending_box_start_obj_id={self.pending_box_start_obj_id}, is_switching_model={self.is_switching_model}, model_repo_key={self.model_repo_key}, model_repo_id={self.model_repo_id}, session_repo_id={self.session_repo_id})"
@property
def num_frames(self) -> int:
return len(self.video_frames)
def _model_repo_from_key(key: str) -> str:
mapping = {
"tiny": "facebook/sam2.1-hiera-tiny",
"small": "facebook/sam2.1-hiera-small",
"base_plus": "facebook/sam2.1-hiera-base-plus",
"large": "facebook/sam2.1-hiera-large",
}
return mapping.get(key, mapping["base_plus"])
def load_model_if_needed(GLOBAL_STATE: gr.State) -> tuple[AutoModel, Sam2VideoProcessor, str, torch.dtype]:
desired_repo = _model_repo_from_key(GLOBAL_STATE.model_repo_key)
if GLOBAL_STATE.model is not None and GLOBAL_STATE.processor is not None:
if GLOBAL_STATE.model_repo_id == desired_repo:
return GLOBAL_STATE.model, GLOBAL_STATE.processor, GLOBAL_STATE.device, GLOBAL_STATE.dtype
# Different repo requested: dispose current and reload
GLOBAL_STATE.model = None
GLOBAL_STATE.processor = None
print(f"Loading model from {desired_repo}")
device, dtype = get_device_and_dtype()
# free up the gpu memory
model = AutoModel.from_pretrained(desired_repo)
processor = Sam2VideoProcessor.from_pretrained(desired_repo)
model.to(device, dtype=dtype)
GLOBAL_STATE.model = model
GLOBAL_STATE.processor = processor
GLOBAL_STATE.device = device
GLOBAL_STATE.dtype = dtype
GLOBAL_STATE.model_repo_id = desired_repo
def ensure_session_for_current_model(GLOBAL_STATE: gr.State) -> None:
"""Ensure the model/processor match the selected repo and inference_session exists.
If a video is already loaded, re-initialize the inference session when needed.
"""
load_model_if_needed(GLOBAL_STATE)
desired_repo = _model_repo_from_key(GLOBAL_STATE.model_repo_key)
if GLOBAL_STATE.inference_session is None or GLOBAL_STATE.session_repo_id != desired_repo:
if GLOBAL_STATE.video_frames:
# Clear session-related UI caches when switching model
GLOBAL_STATE.masks_by_frame.clear()
GLOBAL_STATE.clicks_by_frame_obj.clear()
GLOBAL_STATE.boxes_by_frame_obj.clear()
GLOBAL_STATE.composited_frames.clear()
GLOBAL_STATE.inference_session = None
GLOBAL_STATE.inference_session = GLOBAL_STATE.processor.init_video_session(
inference_device=GLOBAL_STATE.device,
video_storage_device="cpu",
dtype=GLOBAL_STATE.dtype,
)
GLOBAL_STATE.session_repo_id = desired_repo
def init_video_session(GLOBAL_STATE: gr.State, video: str | dict) -> tuple[AppState, int, int, Image.Image, str]:
"""Gradio handler: load video, init session, return state, slider bounds, and first frame."""
# Reset ONLY video-related fields, keep model loaded
GLOBAL_STATE.video_frames = []
GLOBAL_STATE.inference_session = None
GLOBAL_STATE.masks_by_frame = {}
GLOBAL_STATE.color_by_obj = {}
load_model_if_needed(GLOBAL_STATE)
# Gradio Video may provide a dict with 'name' or a direct file path
video_path: Optional[str] = None
if isinstance(video, dict):
video_path = video.get("name") or video.get("path") or video.get("data")
elif isinstance(video, str):
video_path = video
else:
video_path = None
if not video_path:
raise gr.Error("Invalid video input.")
frames, info = try_load_video_frames(video_path)
if len(frames) == 0:
raise gr.Error("No frames could be loaded from the video.")
# Enforce max duration of 8 seconds (trim if longer)
MAX_SECONDS = 8.0
trimmed_note = ""
fps_in = info.get("fps")
max_frames_allowed = int(MAX_SECONDS * fps_in)
if len(frames) > max_frames_allowed:
frames = frames[:max_frames_allowed]
trimmed_note = f" (trimmed to {int(MAX_SECONDS)}s = {len(frames)} frames)"
if isinstance(info, dict):
info["num_frames"] = len(frames)
GLOBAL_STATE.video_frames = frames
# Try to capture original FPS if provided by loader
GLOBAL_STATE.video_fps = float(fps_in)
# Initialize session
inference_session = GLOBAL_STATE.processor.init_video_session(
inference_device=GLOBAL_STATE.device,
video_storage_device="cpu",
dtype=GLOBAL_STATE.dtype,
)
GLOBAL_STATE.inference_session = inference_session
first_frame = frames[0]
max_idx = len(frames) - 1
status = (
f"Loaded {len(frames)} frames @ {GLOBAL_STATE.video_fps or 'unknown'} fps{trimmed_note}. "
f"Device: {GLOBAL_STATE.device}, dtype: bfloat16"
)
return GLOBAL_STATE, 0, max_idx, first_frame, status
def compose_frame(state: AppState, frame_idx: int) -> Image.Image:
if state is None or state.video_frames is None or len(state.video_frames) == 0:
return None
frame_idx = int(np.clip(frame_idx, 0, len(state.video_frames) - 1))
frame = state.video_frames[frame_idx]
masks = state.masks_by_frame.get(frame_idx, {})
out_img = frame
if len(masks) != 0:
out_img = overlay_masks_on_frame(out_img, masks, state.color_by_obj, alpha=0.65)
# Draw crosses for conditioning frames only (frames with recorded clicks)
clicks_map = state.clicks_by_frame_obj.get(frame_idx)
if clicks_map:
draw = ImageDraw.Draw(out_img)
cross_half = 6
for obj_id, pts in clicks_map.items():
for x, y, lbl in pts:
color = (0, 255, 0) if int(lbl) == 1 else (255, 0, 0)
# horizontal
draw.line([(x - cross_half, y), (x + cross_half, y)], fill=color, width=2)
# vertical
draw.line([(x, y - cross_half), (x, y + cross_half)], fill=color, width=2)
# Draw temporary cross for first corner in box mode
if (
state.pending_box_start is not None
and state.pending_box_start_frame_idx == frame_idx
and state.pending_box_start_obj_id is not None
):
draw = ImageDraw.Draw(out_img)
x, y = state.pending_box_start
cross_half = 6
color = state.color_by_obj.get(state.pending_box_start_obj_id, (255, 255, 255))
draw.line([(x - cross_half, y), (x + cross_half, y)], fill=color, width=2)
draw.line([(x, y - cross_half), (x, y + cross_half)], fill=color, width=2)
# Draw boxes for conditioning frames
box_map = state.boxes_by_frame_obj.get(frame_idx)
if box_map:
draw = ImageDraw.Draw(out_img)
for obj_id, boxes in box_map.items():
color = state.color_by_obj.get(obj_id, (255, 255, 255))
for x1, y1, x2, y2 in boxes:
draw.rectangle([(x1, y1), (x2, y2)], outline=color, width=2)
# Save to cache and return
state.composited_frames[frame_idx] = out_img
return out_img
def update_frame_display(state: AppState, frame_idx: int) -> Image.Image:
if state is None or state.video_frames is None or len(state.video_frames) == 0:
return None
frame_idx = int(np.clip(frame_idx, 0, len(state.video_frames) - 1))
# Serve from cache when available
cached = state.composited_frames.get(frame_idx)
if cached is not None:
return cached
return compose_frame(state, frame_idx)
def _ensure_color_for_obj(state: AppState, obj_id: int):
if obj_id not in state.color_by_obj:
state.color_by_obj[obj_id] = pastel_color_for_object(obj_id)
def on_image_click(
img: Image.Image | np.ndarray,
state: AppState,
frame_idx: int,
obj_id: int,
label: str,
clear_old: bool,
evt: gr.SelectData,
) -> Image.Image:
if state is None or state.inference_session is None:
return img # no-op preview when not ready
if state.is_switching_model:
# Gracefully ignore input during model switch; return current preview unchanged
return update_frame_display(state, int(frame_idx))
# Parse click coordinates from event
x = y = None
if evt is not None:
# Try different gradio event data shapes for robustness
try:
if hasattr(evt, "index") and isinstance(evt.index, (list, tuple)) and len(evt.index) == 2:
x, y = int(evt.index[0]), int(evt.index[1])
elif hasattr(evt, "value") and isinstance(evt.value, dict) and "x" in evt.value and "y" in evt.value:
x, y = int(evt.value["x"]), int(evt.value["y"])
except Exception:
x = y = None
if x is None or y is None:
raise gr.Error("Could not read click coordinates.")
_ensure_color_for_obj(state, int(obj_id))
processor = state.processor
model = state.model
inference_session = state.inference_session
original_size = None
pixel_values = None
if inference_session.processed_frames is None or frame_idx not in inference_session.processed_frames:
inputs = processor(images=state.video_frames[frame_idx], device=state.device, return_tensors="pt")
original_size = inputs.original_sizes[0]
pixel_values = inputs.pixel_values[0]
if state.current_prompt_type == "Boxes":
# Two-click box input
if state.pending_box_start is None:
# For boxes, always clear old inputs (points) for this object on this frame
frame_clicks = state.clicks_by_frame_obj.setdefault(int(frame_idx), {})
frame_clicks[int(obj_id)] = []
state.composited_frames.pop(int(frame_idx), None)
state.pending_box_start = (int(x), int(y))
state.pending_box_start_frame_idx = int(frame_idx)
state.pending_box_start_obj_id = int(obj_id)
# Invalidate cache so temporary cross is drawn
state.composited_frames.pop(int(frame_idx), None)
return update_frame_display(state, int(frame_idx))
else:
x1, y1 = state.pending_box_start
x2, y2 = int(x), int(y)
# Clear temporary state and invalidate cache
state.pending_box_start = None
state.pending_box_start_frame_idx = None
state.pending_box_start_obj_id = None
state.composited_frames.pop(int(frame_idx), None)
x_min, y_min = min(x1, x2), min(y1, y2)
x_max, y_max = max(x1, x2), max(y1, y2)
processor.add_inputs_to_inference_session(
inference_session=inference_session,
frame_idx=int(frame_idx),
obj_ids=int(obj_id),
input_boxes=[[[x_min, y_min, x_max, y_max]]],
clear_old_inputs=True, # For boxes, always clear old inputs
original_size=original_size,
)
frame_boxes = state.boxes_by_frame_obj.setdefault(int(frame_idx), {})
obj_boxes = frame_boxes.setdefault(int(obj_id), [])
# For boxes, always clear old inputs
obj_boxes.clear()
obj_boxes.append((x_min, y_min, x_max, y_max))
state.composited_frames.pop(int(frame_idx), None)
else:
# Points mode
label_int = 1 if str(label).lower().startswith("pos") else 0
# If clear_old is enabled, clear prior boxes for this object on this frame
if bool(clear_old):
frame_boxes = state.boxes_by_frame_obj.setdefault(int(frame_idx), {})
frame_boxes[int(obj_id)] = []
state.composited_frames.pop(int(frame_idx), None)
processor.add_inputs_to_inference_session(
inference_session=inference_session,
frame_idx=int(frame_idx),
obj_ids=int(obj_id),
input_points=[[[[int(x), int(y)]]]],
input_labels=[[[int(label_int)]]],
original_size=original_size,
clear_old_inputs=bool(clear_old),
)
frame_clicks = state.clicks_by_frame_obj.setdefault(int(frame_idx), {})
obj_clicks = frame_clicks.setdefault(int(obj_id), [])
if bool(clear_old):
obj_clicks.clear()
obj_clicks.append((int(x), int(y), int(label_int)))
state.composited_frames.pop(int(frame_idx), None)
# Forward on that frame
with torch.inference_mode():
outputs = model(inference_session=inference_session, frame=pixel_values, frame_idx=int(frame_idx))
H = inference_session.video_height
W = inference_session.video_width
# Detach and move off GPU as early as possible to reduce GPU memory pressure
pred_masks = outputs.pred_masks.detach().cpu()
video_res_masks = processor.post_process_masks([pred_masks], original_sizes=[[H, W]])[0]
# Map returned masks to object ids. For single object forward, it's [1, 1, H, W]
# But to be safe, iterate over session.obj_ids order.
masks_for_frame: dict[int, np.ndarray] = {}
obj_ids_order = list(inference_session.obj_ids)
for i, oid in enumerate(obj_ids_order):
mask_i = video_res_masks[i]
# mask_i shape could be (1, H, W) or (H, W); squeeze to 2D
mask_2d = mask_i.cpu().numpy().squeeze()
masks_for_frame[int(oid)] = mask_2d
state.masks_by_frame[int(frame_idx)] = masks_for_frame
# Invalidate cache for this frame to force recomposition
state.composited_frames.pop(int(frame_idx), None)
# Return updated preview
return update_frame_display(state, int(frame_idx))
@spaces.GPU()
def propagate_masks(GLOBAL_STATE: gr.State):
if GLOBAL_STATE is None or GLOBAL_STATE.inference_session is None:
# yield GLOBAL_STATE, "Load a video first.", gr.update()
return GLOBAL_STATE, "Load a video first.", gr.update()
processor = deepcopy(GLOBAL_STATE.processor)
model = deepcopy(GLOBAL_STATE.model)
inference_session = deepcopy(GLOBAL_STATE.inference_session)
# set inference device to cuda to use zero gpu
inference_session.inference_device = "cuda"
inference_session.cache.inference_device = "cuda"
model.to("cuda")
total = max(1, GLOBAL_STATE.num_frames)
processed = 0
# Initial status; no slider change yet
yield GLOBAL_STATE, f"Propagating masks: {processed}/{total}", gr.update()
last_frame_idx = 0
with torch.inference_mode():
for frame_idx, frame in enumerate(GLOBAL_STATE.video_frames):
pixel_values = None
if inference_session.processed_frames is None or frame_idx not in inference_session.processed_frames:
pixel_values = processor(images=frame, device="cuda", return_tensors="pt").pixel_values[0]
sam2_video_output = model(inference_session=inference_session, frame=pixel_values, frame_idx=frame_idx)
H = inference_session.video_height
W = inference_session.video_width
pred_masks = sam2_video_output.pred_masks.detach().cpu()
video_res_masks = processor.post_process_masks([pred_masks], original_sizes=[[H, W]])[0]
last_frame_idx = frame_idx
masks_for_frame: dict[int, np.ndarray] = {}
obj_ids_order = list(inference_session.obj_ids)
for i, oid in enumerate(obj_ids_order):
mask_2d = video_res_masks[i].cpu().numpy().squeeze()
masks_for_frame[int(oid)] = mask_2d
GLOBAL_STATE.masks_by_frame[frame_idx] = masks_for_frame
# Invalidate cache for that frame to force recomposition
GLOBAL_STATE.composited_frames.pop(frame_idx, None)
processed += 1
# Every 15th frame (or last), move slider to current frame to update preview via slider binding
if processed % 30 == 0 or processed == total:
yield GLOBAL_STATE, f"Propagating masks: {processed}/{total}", gr.update(value=frame_idx)
text = f"Propagated masks across {processed} frames for {len(inference_session.obj_ids)} objects."
# Final status; ensure slider points to last processed frame
yield GLOBAL_STATE, text, gr.update(value=last_frame_idx)
def reset_session(GLOBAL_STATE: gr.State) -> tuple[AppState, Image.Image, int, int, str]:
# Reset only session-related state, keep uploaded video and model
if not GLOBAL_STATE.video_frames:
# Nothing loaded; keep behavior
return GLOBAL_STATE, None, 0, 0, "Session reset. Load a new video."
# Clear prompts and caches
GLOBAL_STATE.masks_by_frame.clear()
GLOBAL_STATE.clicks_by_frame_obj.clear()
GLOBAL_STATE.boxes_by_frame_obj.clear()
GLOBAL_STATE.composited_frames.clear()
GLOBAL_STATE.pending_box_start = None
GLOBAL_STATE.pending_box_start_frame_idx = None
GLOBAL_STATE.pending_box_start_obj_id = None
# Dispose and re-init inference session for current model with existing frames
try:
if GLOBAL_STATE.inference_session is not None:
GLOBAL_STATE.inference_session.reset_inference_session()
except Exception:
pass
GLOBAL_STATE.inference_session = None
gc.collect()
ensure_session_for_current_model(GLOBAL_STATE)
# Keep current slider index if possible
current_idx = int(getattr(GLOBAL_STATE, "current_frame_idx", 0))
current_idx = max(0, min(current_idx, GLOBAL_STATE.num_frames - 1))
preview_img = update_frame_display(GLOBAL_STATE, current_idx)
slider_minmax = gr.update(minimum=0, maximum=max(GLOBAL_STATE.num_frames - 1, 0), interactive=True)
slider_value = gr.update(value=current_idx)
status = "Session reset. Prompts cleared; video preserved."
# clear and reload model and processor
return GLOBAL_STATE, preview_img, slider_minmax, slider_value, status
theme = Soft(primary_hue="blue", secondary_hue="rose", neutral_hue="slate")
with gr.Blocks(title="SAM2 Video (Transformers) - Interactive Segmentation", theme=theme) as demo:
GLOBAL_STATE = gr.State(AppState())
gr.Markdown(
"""
### SAM2 Video Tracking · powered by Hugging Face 🤗 Transformers
Segment and track objects across a video with SAM2 (Segment Anything 2). This demo runs the official implementation from the Hugging Face Transformers library for interactive, promptable video segmentation.
"""
)
with gr.Row():
with gr.Column():
gr.Markdown(
"""
**Quick start**
- **Load a video**: Upload your own or pick an example below.
- **Checkpoint**: Tiny / Small / Base+ / Large (trade speed vs. accuracy).
- **Points mode**: Select an Object ID and point label (positive/negative), then click the frame to add guidance. You can add **multiple points per object** and define **multiple objects** across frames.
- **Boxes mode**: Click two opposite corners to draw a box. Old inputs for that object are cleared automatically.
"""
)
with gr.Column():
gr.Markdown(
"""
**Working with results**
- **Preview**: Use the slider to navigate frames and see the current masks.
- **Propagate**: Click “Propagate across video” to track all defined objects through the entire video. The preview follows progress periodically to keep things responsive.
- **Export**: Render an MP4 for smooth playback using the original video FPS.
- **Note**: More info on the Hugging Face 🤗 Transformers implementation of SAM2 can be found [here](https://huggingface.co/docs/transformers/en/main/en/model_doc/sam2_video).
"""
)
with gr.Row():
with gr.Column(scale=1):
video_in = gr.Video(label="Upload video", sources=["upload", "webcam"], interactive=True)
ckpt_radio = gr.Radio(
choices=["tiny", "small", "base_plus", "large"],
value="tiny",
label="SAM2.1 checkpoint",
)
ckpt_progress = gr.Markdown(visible=False)
load_status = gr.Markdown(visible=True)
reset_btn = gr.Button("Reset Session", variant="secondary")
with gr.Column(scale=2):
preview = gr.Image(label="Preview", interactive=True)
with gr.Row():
frame_slider = gr.Slider(label="Frame", minimum=0, maximum=0, step=1, value=0, interactive=True)
with gr.Column(scale=0):
propagate_btn = gr.Button("Propagate across video", variant="primary")
propagate_status = gr.Markdown(visible=True)
with gr.Row():
obj_id_inp = gr.Number(value=1, precision=0, label="Object ID", scale=0)
label_radio = gr.Radio(choices=["positive", "negative"], value="positive", label="Point label")
clear_old_chk = gr.Checkbox(value=False, label="Clear old inputs for this object")
prompt_type = gr.Radio(choices=["Points", "Boxes"], value="Points", label="Prompt type")
# Wire events
def _on_video_change(GLOBAL_STATE: gr.State, video):
GLOBAL_STATE, min_idx, max_idx, first_frame, status = init_video_session(GLOBAL_STATE, video)
return (
GLOBAL_STATE,
gr.update(minimum=min_idx, maximum=max_idx, value=min_idx, interactive=True),
first_frame,
status,
)
video_in.change(
_on_video_change,
inputs=[GLOBAL_STATE, video_in],
outputs=[GLOBAL_STATE, frame_slider, preview, load_status],
show_progress=True,
)
# (moved) Examples are defined above the render button
# Each example row must match the number of inputs (GLOBAL_STATE, video_in)
examples_list = [
[None, "./deers.mp4"],
[None, "./penguins.mp4"],
[None, "./foot.mp4"],
]
with gr.Row():
gr.Examples(
examples=examples_list,
inputs=[GLOBAL_STATE, video_in],
fn=_on_video_change,
outputs=[GLOBAL_STATE, frame_slider, preview, load_status],
label="Examples",
cache_examples=False,
examples_per_page=5,
)
# Examples (place before the render MP4 button) — defined after handler below
with gr.Row():
render_btn = gr.Button("Render MP4 for smooth playback", variant="primary")
playback_video = gr.Video(label="Rendered Playback", interactive=False)
def _on_ckpt_change(s: AppState, key: str):
if s is not None and key:
key = str(key)
if key != s.model_repo_key:
# Update and drop current model to reload lazily next time
s.is_switching_model = True
s.model_repo_key = key
s.model_repo_id = None
s.model = None
s.processor = None
# Stream progress text while loading (first yield shows text)
yield gr.update(visible=True, value=f"Loading checkpoint: {key}...")
ensure_session_for_current_model(s)
if s is not None:
s.is_switching_model = False
# Final yield hides the text
yield gr.update(visible=False, value="")
ckpt_radio.change(_on_ckpt_change, inputs=[GLOBAL_STATE, ckpt_radio], outputs=[ckpt_progress])
def _sync_frame_idx(state_in: AppState, idx: int):
if state_in is not None:
state_in.current_frame_idx = int(idx)
return update_frame_display(state_in, int(idx))
frame_slider.change(
_sync_frame_idx,
inputs=[GLOBAL_STATE, frame_slider],
outputs=preview,
)
def _sync_obj_id(s: AppState, oid):
if s is not None and oid is not None:
s.current_obj_id = int(oid)
return gr.update()
obj_id_inp.change(_sync_obj_id, inputs=[GLOBAL_STATE, obj_id_inp], outputs=[])
def _sync_label(s: AppState, lab: str):
if s is not None and lab is not None:
s.current_label = str(lab)
return gr.update()
label_radio.change(_sync_label, inputs=[GLOBAL_STATE, label_radio], outputs=[])
def _sync_prompt_type(s: AppState, val: str):
if s is not None and val is not None:
s.current_prompt_type = str(val)
s.pending_box_start = None
is_points = str(val).lower() == "points"
# Show labels only for points; hide and disable clear_old when boxes
updates = [
gr.update(visible=is_points),
gr.update(interactive=is_points) if is_points else gr.update(value=True, interactive=False),
]
return updates
prompt_type.change(
_sync_prompt_type,
inputs=[GLOBAL_STATE, prompt_type],
outputs=[label_radio, clear_old_chk],
)
# Image click to add a point and run forward on that frame
preview.select(
on_image_click, [preview, GLOBAL_STATE, frame_slider, obj_id_inp, label_radio, clear_old_chk], preview
)
# Playback via MP4 rendering only
# Render a smooth MP4 using imageio/pyav (fallbacks to imageio v2 / OpenCV)
def _render_video(s: AppState):
if s is None or s.num_frames == 0:
raise gr.Error("Load a video first.")
fps = s.video_fps if s.video_fps and s.video_fps > 0 else 12
# Compose all frames (cache will help if already prepared)
frames_np = []
first = compose_frame(s, 0)
h, w = first.size[1], first.size[0]
for idx in range(s.num_frames):
img = s.composited_frames.get(idx)
if img is None:
img = compose_frame(s, idx)
frames_np.append(np.array(img)[:, :, ::-1]) # BGR for cv2
# Periodically release CPU mem to reduce pressure
if (idx + 1) % 60 == 0:
gc.collect()
out_path = "/tmp/sam2_playback.mp4"
# Prefer imageio with PyAV/ffmpeg to respect exact fps
try:
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(out_path, fourcc, fps, (w, h))
for fr_bgr in frames_np:
writer.write(fr_bgr)
writer.release()
return out_path
except Exception as e:
print(f"Failed to render video with cv2: {e}")
raise gr.Error(f"Failed to render video: {e}")
render_btn.click(_render_video, inputs=[GLOBAL_STATE], outputs=[playback_video])
# While propagating, we stream two outputs: status text and slider value updates
propagate_btn.click(
propagate_masks,
inputs=[GLOBAL_STATE],
outputs=[GLOBAL_STATE, propagate_status, frame_slider],
)
reset_btn.click(
reset_session,
inputs=GLOBAL_STATE,
outputs=[GLOBAL_STATE, preview, frame_slider, frame_slider, load_status],
)
demo.queue(api_open=False).launch()