minimax-h3-inpainting / h3_sam3.py
linoyts's picture
linoyts HF Staff
Disable SAM 3's own tqdm: gradio's track_tqdm patch breaks on it
7136df7 verified
Raw
History Blame Contribute Delete
10.9 kB
"""Say what should change, and get a mask clip that tracks it.
The brush is the wrong tool for video: it paints one still, and a shape painted on the first frame is wrong by the
last one. SAM 3 takes a phrase — "the fox", "the person on the left" — segments it, and propagates the track through
every frame, which is the shape the rest of this Space already consumes: a mask clip, one frame per source frame.
The model is loaded lazily and only when a request asks for a mask, so a Space that is only ever driven through the
API with an uploaded mask never pays for it.
"""
from __future__ import annotations
import os
import tempfile
import numpy as np
import torch
from PIL import Image
SAM3_REPO = os.environ.get("H3_SAM3_REPO", "facebook/sam3")
# Masks are consumed as *hard* by default: a feathered boundary puts rows at intermediate timesteps and blends two
# images that differ in tone, which is what shows up as a soft band along the mask. Dilation is the knob that
# matters, not softness.
_model = None
_processor = None
_tracker = None
_tracker_processor = None
def available() -> tuple[bool, str]:
"""Whether SAM 3 can be used here. The checkpoint is gated, so this can fail on access rather than on code."""
try:
from transformers import Sam3VideoModel, Sam3VideoProcessor # noqa: F401
except ImportError:
return False, "this `transformers` has no `Sam3VideoModel`"
if not (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")):
return False, f"`{SAM3_REPO}` is gated and no `HF_TOKEN` is set on this Space"
return True, ""
def load():
"""Fetch the model once. Called at startup so the download is not on GPU time."""
global _model, _processor
if _model is not None:
return _model, _processor
from transformers import Sam3VideoModel, Sam3VideoProcessor
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
_model = Sam3VideoModel.from_pretrained(SAM3_REPO, token=token, dtype=torch.bfloat16)
_processor = Sam3VideoProcessor.from_pretrained(SAM3_REPO, token=token)
return _model, _processor
def load_tracker():
"""SAM 3's geometry-prompted tracker, which is what takes a click.
The text-grounded video model and the tracker are two different heads of the same release: the first finds what a
phrase names, the second follows what you point at. Only the tracker accepts points, and it is the faster of the
two by a wide margin — a single click propagated over 124 frames in 15 s against 73 s for the phrase.
"""
global _tracker, _tracker_processor
if _tracker is not None:
return _tracker, _tracker_processor
from transformers import Sam3TrackerVideoModel, Sam3TrackerVideoProcessor
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
_tracker = Sam3TrackerVideoModel.from_pretrained(SAM3_REPO, token=token, dtype=torch.bfloat16)
_tracker_processor = Sam3TrackerVideoProcessor.from_pretrained(SAM3_REPO, token=token)
return _tracker, _tracker_processor
def segment_from_points(
frames: np.ndarray,
points: list[tuple[int, int]],
labels: list[int],
frame_idx: int = 0,
progress=None,
) -> np.ndarray | None:
r"""
Track whatever the clicks point at, through every frame.
Args:
frames (`np.ndarray` of shape `(num_frames, height, width, 3)`): The clip, `uint8` RGB.
points (`list[tuple[int, int]]`): The clicked `(x, y)` pixels, on `frame_idx`.
labels (`list[int]`): `1` for a point that is part of the subject, `0` for one that is not.
frame_idx (`int`, defaults to 0): Which frame the clicks were made on. Tracking runs outward from it.
progress (`callable`, *optional*): Gradio progress callback, called per frame.
Returns:
`np.ndarray` of shape `(num_frames, height, width)` of `bool`, or None when the clicks selected nothing.
"""
if not points:
return None
model, processor = load_tracker()
model.to("cuda")
num_frames, height, width = frames.shape[:3]
frame_idx = max(0, min(int(frame_idx), num_frames - 1))
session = processor.init_video_session(
video=frames, inference_device="cuda", processing_device="cpu",
video_storage_device="cpu", dtype=torch.bfloat16,
)
processor.add_inputs_to_inference_session(
inference_session=session,
frame_idx=frame_idx,
obj_ids=1,
input_points=[[[list(map(int, point)) for point in points]]],
input_labels=[[list(map(int, labels))]],
)
masks = np.zeros((num_frames, height, width), dtype=bool)
# Two passes from the clicked frame: forward to the end, then backward to the start. Tracking only ever runs
# outward from where the prompt was made, so a click on a middle frame would otherwise leave the front unmasked.
for reverse in (False, True):
for output in model.propagate_in_video_iterator(
inference_session=session, start_frame_idx=frame_idx,
max_frame_num_to_track=num_frames, reverse=reverse, show_progress_bar=False,
):
index = output.frame_idx
if not 0 <= index < num_frames:
continue
found = processor.post_process_masks(
[output.pred_masks], original_sizes=[[height, width]], binarize=True
)[0]
masks[index] = found.cpu().numpy().reshape(-1, height, width).any(0)
if progress is not None:
done = abs(index - frame_idx) + 1
progress(min(1.0, done / num_frames), desc=f"Tracking — frame {index + 1}/{num_frames}")
return masks if masks.any() else None
def segment(frames: np.ndarray, phrase: str, progress=None) -> np.ndarray | None:
r"""
Track `phrase` through `frames` and return a boolean mask per frame.
Args:
frames (`np.ndarray` of shape `(num_frames, height, width, 3)`): The clip, `uint8` RGB.
phrase (`str`): What to segment, in plain words.
progress (`callable`, *optional*): Gradio progress callback, called per frame.
Returns:
`np.ndarray` of shape `(num_frames, height, width)` of `bool`, or None when nothing matched.
"""
model, processor = load()
model.to("cuda")
num_frames, height, width = frames.shape[:3]
session = processor.init_video_session(
video=frames,
inference_device="cuda",
processing_device="cpu",
video_storage_device="cpu",
dtype=torch.bfloat16,
)
session = processor.add_text_prompt(inference_session=session, text=phrase)
masks = np.zeros((num_frames, height, width), dtype=bool)
# Per-frame progress by hand rather than a tqdm wrapper: wrapping SAM 3's generator breaks
# `postprocess_outputs` when gradio's `track_tqdm` is active.
# Disabling SAM 3's own progress bar here is load-bearing, not tidiness. It wraps this loop in tqdm, and
# gradio's `Progress(track_tqdm=True)` patches tqdm with one whose `__next__` reads `self.iterables[-1]` — empty
# here, because the iterator is created inside a ZeroGPU worker rather than in the handler gradio set up, which
# raises `IndexError: list index out of range` from deep inside transformers. Progress is reported by hand below.
for output in model.propagate_in_video_iterator(
inference_session=session, max_frame_num_to_track=num_frames, show_progress_bar=False
):
found = processor.postprocess_outputs(session, output)
instances = found.get("masks")
index = output.frame_idx
if instances is not None and len(instances) and 0 <= index < num_frames:
array = instances.float().cpu().numpy()
# Every instance the phrase matched is part of the same mask.
array = (array.reshape(-1, array.shape[-2], array.shape[-1]) > 0.5).any(axis=0)
if array.shape != (height, width):
array = (
np.asarray(
Image.fromarray((array * 255).astype(np.uint8)).resize((width, height), Image.NEAREST)
)
> 127
)
masks[index] = array
if progress is not None and 0 <= index < num_frames:
progress((index + 1) / num_frames, desc=f"Segmenting — frame {index + 1}/{num_frames}")
return masks if masks.any() else None
def dilate(masks: np.ndarray, pixels: int) -> np.ndarray:
r"""
Grow every mask by `pixels`, always from the undilated original so repeated adjustments do not compound.
A segmentation hugs its subject, and the model needs the room around it: what replaces the subject rarely has the
same silhouette, and anything of the original left outside the mask is preserved and shows.
"""
if pixels <= 0:
return masks
import cv2
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * int(pixels) + 1, 2 * int(pixels) + 1))
return np.stack([cv2.dilate(frame.astype(np.uint8), kernel).astype(bool) for frame in masks])
def to_clip(masks: np.ndarray, fps: int = 24) -> str:
r"""Write a boolean mask batch as a mask clip, near-losslessly — its pixel values are read back as the mask."""
import av
path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
height, width = masks.shape[1:3]
container = av.open(path, mode="w")
stream = container.add_stream("libx264", rate=fps)
stream.width, stream.height, stream.pix_fmt = width, height, "yuv420p"
stream.options = {"crf": "6", "preset": "veryfast", "tune": "stillimage"}
for frame in masks:
plane = np.repeat((frame.astype(np.uint8) * 255)[:, :, None], 3, axis=2)
container.mux(stream.encode(av.VideoFrame.from_ndarray(plane, format="rgb24")))
for packet in stream.encode():
container.mux(packet)
container.close()
return path
def preview(frames: np.ndarray, masks: np.ndarray, fps: int = 24) -> str:
r"""The mask over the clip, so what will be repainted is visible before a request is booked."""
import av
path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
height, width = frames.shape[1:3]
container = av.open(path, mode="w")
stream = container.add_stream("libx264", rate=fps)
stream.width, stream.height, stream.pix_fmt = width, height, "yuv420p"
stream.options = {"crf": "20", "preset": "veryfast"}
for frame, mask in zip(frames, masks):
tinted = frame.astype(np.float32)
tinted[..., 0] = np.minimum(255, tinted[..., 0] + mask * 90)
tinted[..., 2] = np.maximum(0, tinted[..., 2] - mask * 40)
container.mux(stream.encode(av.VideoFrame.from_ndarray(tinted.astype(np.uint8), format="rgb24")))
for packet in stream.encode():
container.mux(packet)
container.close()
return path