import os import shutil import tempfile os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # MUST come before torch / any CUDA-touching import import torch import torch.nn.functional as F import numpy as np import cv2 from PIL import Image import gradio as gr from huggingface_hub import hf_hub_download from gradio_client import Client, handle_file from iopath.common.file_io import g_pathmgr # Patch torch.load to allow weights_only=False for checkpoints _orig_load = torch.load def _patched_load(*args, **kwargs): kwargs["weights_only"] = kwargs.get("weights_only", False) return _orig_load(*args, **kwargs) torch.load = _patched_load from sam2.build_sam import build_sam2matting, build_sam2matting_video_predictor from sam2.sam2matting_image_predictor import SAM2MattingImagePredictor MODEL_ID = "FudanCVL/SAM2Matting" REMBG_SPACE_ID = "gokaygokay/Inspyrenet-Rembg" DEVICE = "cuda" COLOR_MAP = { "Green": [0, 255, 0], "Blue": [0, 0, 255], "Red": [255, 0, 0], "White": [255, 255, 255], "Black": [0, 0, 0], "Gray": [128, 128, 128], } # variant -> (family, ckpt filename, config or None, mask_prompt_size) VARIANTS = { "SAM2.1-Tiny": ( "sam2", "checkpoints/SAM2Matting-SAM2.1Tiny.pt", "configs/sam2matting-sam2.1tiny.yaml", 256, ), "SAM2.1-Base+": ( "sam2", "checkpoints/SAM2Matting-SAM2.1Base+.pt", "configs/sam2matting-sam2.1base+.yaml", 256, ), "SAM3": ( "sam3", "checkpoints/SAM2Matting-SAM3.pt", None, 288, ), } _ckpt_cache: dict[str, str] = {} _image_predictors: dict[str, object] = {} _video_predictors: dict[str, object] = {} def _download_ckpt(filename: str) -> str: if filename not in _ckpt_cache: _ckpt_cache[filename] = hf_hub_download( repo_id=MODEL_ID, filename=filename, repo_type="model" ) return _ckpt_cache[filename] def _load_sam3_tracker_state_dict(checkpoint: str) -> dict: with g_pathmgr.open(checkpoint, "rb") as f: ckpt = torch.load(f, map_location="cpu", weights_only=True) sd = ckpt["model"] out = {} for k, v in sd.items(): if k.startswith("detector.backbone.vision_backbone."): out[k.removeprefix("detector.")] = v elif k.startswith("tracker."): out[k.removeprefix("tracker.")] = v return out def get_image_predictor(variant: str): if variant in _image_predictors: return _image_predictors[variant], VARIANTS[variant][3] family, ckpt_name, config, mask_size = VARIANTS[variant] ckpt_path = _download_ckpt(ckpt_name) if family == "sam2": model = build_sam2matting( config_file=config, ckpt_path=ckpt_path, device=DEVICE ) predictor = SAM2MattingImagePredictor(model) else: from sam3.model.build_sam3matting import build_sam3matting from sam3.model.sam3matting_image_predictor import SAM3MattingImagePredictor sd = _load_sam3_tracker_state_dict(ckpt_path) model = build_sam3matting(checkpoint=None, device=DEVICE) model.load_state_dict(sd, strict=False) predictor = SAM3MattingImagePredictor(model) _image_predictors[variant] = predictor return predictor, mask_size def get_video_predictor(variant: str): if variant in _video_predictors: return _video_predictors[variant], VARIANTS[variant][3] family, ckpt_name, config, mask_size = VARIANTS[variant] ckpt_path = _download_ckpt(ckpt_name) if family == "sam2": predictor = build_sam2matting_video_predictor( config, ckpt_path, device=DEVICE ) else: from sam3.model.sam3matting_video_predictor import ( build_sam3matting_video_predictor, ) sd = _load_sam3_tracker_state_dict(ckpt_path) predictor = build_sam3matting_video_predictor( checkpoint=None, device=DEVICE ) predictor.load_state_dict(sd, strict=False) _video_predictors[variant] = predictor return predictor, mask_size def _auto_generate_mask(image: Image.Image) -> Image.Image: tmp_path = os.path.join("/tmp", "rembg_input.png") image.save(tmp_path) client = Client(REMBG_SPACE_ID, token=os.environ.get("HF_TOKEN")) mask_path = client.predict( input_image=handle_file(tmp_path), output_type="Mask only", api_name="/predict", ) return Image.open(mask_path).convert("L") def _prepare_image_mask_tensors(mask: Image.Image, mask_size: int): mask_np = np.array(mask.convert("L")) raw_mask = (torch.from_numpy(mask_np) / 255) > 0 mask_input = (torch.from_numpy(mask_np) > 0).float() * 20 - 10 mask_input = mask_input.unsqueeze(0).unsqueeze(0) mask_input = F.interpolate( mask_input, size=(mask_size, mask_size), mode="bilinear", align_corners=False, ) return raw_mask, mask_input def _compose_rgb(orig_rgb: np.ndarray, alpha_u8: np.ndarray, bg_color: str) -> np.ndarray: bg = np.full( (*alpha_u8.shape, 3), COLOR_MAP.get(bg_color, [0, 255, 0]), dtype=np.uint8 ) a = (alpha_u8[..., None] / 255.0).astype(np.float32) return (orig_rgb * a + bg * (1.0 - a)).astype(np.uint8) def _extract_video_frames(video_path: str, max_frames: int) -> tuple[str, list[str], float]: """Extract frames to a temp jpg folder (numeric names for SAM loaders).""" cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise ValueError(f"Cannot open video: {video_path}") fps = float(cap.get(cv2.CAP_PROP_FPS) or 25.0) if fps <= 1e-3: fps = 25.0 frame_dir = tempfile.mkdtemp(prefix="sam2matting_frames_") frame_files: list[str] = [] idx = 0 while idx < max_frames: ok, frame_bgr = cap.read() if not ok: break name = f"{idx:05d}.jpg" out_path = os.path.join(frame_dir, name) cv2.imwrite(out_path, frame_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 95]) frame_files.append(name) idx += 1 cap.release() if not frame_files: shutil.rmtree(frame_dir, ignore_errors=True) raise ValueError("No frames extracted from the video.") return frame_dir, frame_files, fps def _write_mp4(path: str, frames_bgr: list[np.ndarray], fps: float): h, w = frames_bgr[0].shape[:2] writer = cv2.VideoWriter( path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h) ) for f in frames_bgr: writer.write(f) writer.release() @spaces.GPU(duration=90) def image_matting( image: Image.Image, mask: Image.Image, bg_color: str, variant: str, ) -> tuple[Image.Image, Image.Image, Image.Image]: """Image matting with SAM2 or SAM3 backbone.""" if image is None: raise ValueError("An input image is required.") if variant not in VARIANTS: raise ValueError(f"Unknown variant: {variant}") image = image.convert("RGB") if mask is None: mask = _auto_generate_mask(image) mask = mask.convert("L") if mask.size != image.size: mask = mask.resize(image.size, Image.BILINEAR) predictor, mask_size = get_image_predictor(variant) family = VARIANTS[variant][0] with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): img = predictor.set_image(image) raw_mask, mask_input = _prepare_image_mask_tensors(mask, mask_size) if family == "sam2": _, alpha, _ = predictor.predict( img=img, raw_mask=raw_mask, mask_input=mask_input, multimask_output=False, ) else: _, alpha, _, _ = predictor.predict( img=img, raw_mask=raw_mask, mask_input=mask_input, multimask_output=False, ) alpha_result = (np.asarray(alpha) * 255).astype(np.uint8).squeeze() alpha_image = Image.fromarray(alpha_result, mode="L") orig_array = np.array(image) composite_image = Image.fromarray(_compose_rgb(orig_array, alpha_result, bg_color)) cutout_image = Image.fromarray( np.dstack([orig_array, alpha_result]).astype(np.uint8), mode="RGBA" ) return alpha_image, composite_image, cutout_image @spaces.GPU(duration=180) def video_matting( video_path: str, mask: Image.Image, bg_color: str, variant: str, max_frames: int = 60, ) -> tuple[str, str]: """Video matting with SAM2 or SAM3 backbone. Returns (alpha_mp4, composite_mp4).""" if video_path is None: raise ValueError("An input video is required.") if variant not in VARIANTS: raise ValueError(f"Unknown variant: {variant}") max_frames = int(max_frames) frame_dir, frame_files, fps = _extract_video_frames(video_path, max_frames) try: first_frame = Image.open( os.path.join(frame_dir, frame_files[0]) ).convert("RGB") if mask is None: mask = _auto_generate_mask(first_frame) mask = mask.convert("L") if mask.size != first_frame.size: mask = mask.resize(first_frame.size, Image.BILINEAR) predictor, mask_size = get_video_predictor(variant) family = VARIANTS[variant][0] device = DEVICE # Soft mask logits, same as UniMatting inference_*_video_*.py m = np.array(mask).astype(np.float32) / 255.0 m = (m > 0.005).astype(np.float32) * 20 - 10 m = torch.from_numpy(m)[None, None] m = F.interpolate( m, size=(mask_size, mask_size), mode="bilinear", align_corners=False ) alpha_frames_bgr: list[np.ndarray] = [] comp_frames_bgr: list[np.ndarray] = [] with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16): inference_state = predictor.init_state(video_path=frame_dir) predictor.reset_state(inference_state) predictor.add_new_mask( inference_state=inference_state, frame_idx=0, obj_id=1, mask=m.to(device), ) for out in predictor.propagate_in_video(inference_state): if family == "sam2": out_frame_idx, _, _, alpha, _ = out alpha_2d = ( alpha.detach().cpu().squeeze().float().numpy().clip(0, 1) ) else: out_frame_idx, _, _, alpha, _ = out alpha_2d = np.asarray(alpha).squeeze().clip(0, 1) alpha_u8 = (alpha_2d * 255).astype(np.uint8) alpha_frames_bgr.append( cv2.cvtColor(alpha_u8, cv2.COLOR_GRAY2BGR) ) orig = np.array( Image.open( os.path.join(frame_dir, frame_files[out_frame_idx]) ).convert("RGB") ) comp = _compose_rgb(orig, alpha_u8, bg_color) comp_frames_bgr.append(cv2.cvtColor(comp, cv2.COLOR_RGB2BGR)) out_dir = tempfile.mkdtemp(prefix="sam2matting_out_") alpha_path = os.path.join(out_dir, "pha.mp4") comp_path = os.path.join(out_dir, "fgr.mp4") _write_mp4(alpha_path, alpha_frames_bgr, fps) _write_mp4(comp_path, comp_frames_bgr, fps) return alpha_path, comp_path finally: shutil.rmtree(frame_dir, ignore_errors=True) # --- UI --- CSS = """ #col-container { max-width: 1100px; margin: 0 auto; } .dark .gradio-container { color: var(--body-text-color); } """ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo: with gr.Column(elem_id="col-container"): gr.Markdown( """ # SAM2Matting: Generalized Image and Video Matting Choose a backbone (**SAM2.1-Tiny / SAM2.1-Base+ / SAM3**) and run **image** or **video** matting. Optionally provide a rough foreground mask; if omitted, one is auto-generated via [Inspyrenet-Rembg](https://huggingface.co/spaces/gokaygokay/Inspyrenet-Rembg). [Paper](https://arxiv.org/abs/2606.27339) | [GitHub](https://github.com/FudanCVL/SAM2Matting) | [Model](https://huggingface.co/FudanCVL/SAM2Matting) """ ) variant = gr.Dropdown( label="Backbone", choices=list(VARIANTS.keys()), value="SAM2.1-Tiny", ) with gr.Tabs(): with gr.Tab("Image"): with gr.Row(): with gr.Column(scale=1): input_image = gr.Image( label="Input Image", type="pil", height=400 ) with gr.Accordion("Foreground Mask (optional)", open=False): input_mask = gr.Image( label="Foreground Mask (white = foreground). " "Leave empty to auto-generate.", type="pil", height=320, image_mode="L", ) img_bg = gr.Dropdown( label="Background Color for Composite", choices=list(COLOR_MAP.keys()), value="Green", ) img_btn = gr.Button("Matte Image", variant="primary") with gr.Column(scale=1): alpha_out = gr.Image( label="Alpha Matte", type="pil", height=280 ) composite_out = gr.Image( label="Composite Preview", type="pil", height=280 ) cutout_out = gr.Image( label="Transparent PNG (alpha applied)", type="pil", image_mode="RGBA", format="png", height=280, ) img_btn.click( fn=image_matting, inputs=[input_image, input_mask, img_bg, variant], outputs=[alpha_out, composite_out, cutout_out], api_name="matte_image", ) gr.Examples( examples=[ ["examples/image.jpg", "examples/mask.png", "Green", "SAM2.1-Tiny"], ], inputs=[input_image, input_mask, img_bg, variant], outputs=[alpha_out, composite_out, cutout_out], fn=image_matting, cache_examples=False, ) with gr.Tab("Video"): with gr.Row(): with gr.Column(scale=1): input_video = gr.Video(label="Input Video (mp4)") with gr.Accordion( "First-frame Foreground Mask (optional)", open=False ): video_mask = gr.Image( label="Mask for the first frame (white = foreground). " "Leave empty to auto-generate from frame 0.", type="pil", height=320, image_mode="L", ) vid_bg = gr.Dropdown( label="Background Color for Composite", choices=list(COLOR_MAP.keys()), value="Green", ) max_frames = gr.Slider( label="Max frames (ZeroGPU / time limit)", minimum=8, maximum=150, value=60, step=1, ) vid_btn = gr.Button("Matte Video", variant="primary") with gr.Column(scale=1): alpha_video_out = gr.Video(label="Alpha Matte Video") composite_video_out = gr.Video( label="Composite Preview Video" ) vid_btn.click( fn=video_matting, inputs=[input_video, video_mask, vid_bg, variant, max_frames], outputs=[alpha_video_out, composite_video_out], api_name="matte_video", ) gr.Examples( examples=[ ["examples/demo_video.mp4", "examples/video_mask.png", "Green", "SAM2.1-Tiny", 30], ], inputs=[input_video, video_mask, vid_bg, variant, max_frames], outputs=[alpha_video_out, composite_video_out], fn=video_matting, cache_examples=False, ) gr.Markdown( """ ### Tips - Mask should roughly cover the foreground (white = foreground). - Soft grayscale masks work best. - **SAM2.1-Tiny** is fastest; **SAM3** is heaviest (needs more VRAM/time). - Video matting uses the first-frame mask and propagates through the clip. - Keep `Max frames` modest on ZeroGPU Spaces. ### License CC-BY-NC-SA-4.0 (non-commercial research use only). """ ) if __name__ == "__main__": demo.launch(mcp_server=True)