twanghcmut's picture
download
raw
24 kB
"""SAM 3.1 video segmentation adapter.
Wraps ``sam3.model_builder.build_sam3_multiplex_video_predictor`` and its
stateful ``handle_request``/``handle_stream_request`` API behind the
:class:`~fpgm.segmentation.base.VideoSegmenter` ABC.
Two SAM 3.1 behaviours drive most of the code here and are worth stating up
front because they are easy to get subtly wrong:
* Points passed to ``add_prompt`` are *relative* ``[0, 1]`` coordinates by
default (``rel_coordinates=True``); our ABC contract is absolute pixels, so
every point prompt is converted using the session's known frame resolution.
* Switching to a different text prompt on a session that already has one
produces wrong results unless ``reset_session`` runs first. This adapter
auto-resets (loudly, via a warning log) rather than ever risking a silent
wrong answer.
This module also carries two runtime shims (applied in :meth:`Sam3VideoSegmenter._build`)
for known upstream bugs in the pinned ``third_party/sam3`` checkout, each documented
in detail on the shim itself: :func:`_patch_init_state_kwargs` (an ``init_state``
kwarg mismatch) and :func:`_patch_build_sam2_output` (point-prompt propagation
silently returning empty masks at every frame but the prompt frame). Both are
self-test-driven so they become no-ops automatically once sam3 is fixed.
"""
from __future__ import annotations
import functools
import inspect
import logging
import types
from collections.abc import Iterator
from pathlib import Path
import numpy as np
from fpgm.config import SegmentationConfig
from fpgm.segmentation.base import VideoSegmenter
from fpgm.tracking.sampling import mask_centroid
from fpgm.types import FrameMasks, Masklet
logger = logging.getLogger(__name__)
def _patch_init_state_kwargs(predictor: object) -> None:
"""Drop ``init_state`` kwargs the underlying model does not accept.
Upstream incompatibility in sam3 (observed at commit ``6dbb02b``):
``Sam3BasePredictor.start_session`` *unconditionally* forwards
``offload_state_to_cpu`` to ``model.init_state``, but none of the SAM 3.1
multiplex tracking classes accept that parameter. The result is that the
documented ``start_session`` request path raises ``TypeError`` for SAM 3.1 no
matter what the caller passes -- even the default ``False``.
Rather than fork the vendored repo, the model's ``init_state`` is wrapped to
discard kwargs outside its real signature. The wrap is signature-driven, so it
becomes a no-op automatically once upstream adds the parameter.
"""
model = getattr(predictor, "model", None)
init_state = getattr(model, "init_state", None)
if model is None or init_state is None:
return
try:
accepted = set(inspect.signature(init_state).parameters)
except (TypeError, ValueError): # pragma: no cover - exotic callables
return
if "kwargs" in accepted or "offload_state_to_cpu" in accepted:
return
@functools.wraps(init_state)
def _init_state(*args: object, **kwargs: object):
dropped = [k for k in kwargs if k not in accepted]
for key in dropped:
kwargs.pop(key)
if dropped:
logger.debug("dropping unsupported init_state kwargs: %s", dropped)
return init_state(*args, **kwargs)
model.init_state = _init_state
logger.info(
"applied sam3 compatibility shim: init_state does not accept %s",
sorted({"offload_state_to_cpu"} - accepted),
)
def _patch_build_sam2_output(predictor: object) -> None:
"""Fix the bug that makes point-prompt video propagation return empty masks.
Empirically diagnosed (see ``tests/test_sam3_point_prompt.py`` for the
self-contained regression case): a point prompt followed by
``propagate_in_video`` -- *any* direction, with or without
``is_instance_processing`` -- returns a real mask at the prompt frame and an
**empty** mask at every other frame. Text/visual-grounding prompting is
unaffected. Direct reproduction against the pinned sam3 checkpoint (bypassing
this adapter entirely, single point prompt on a static object over a 30-frame
clip) showed the same pattern regardless of ``propagate(direction=...)`` or a
fresh session with only one ``propagate_in_video`` call -- which rules out this
adapter's own request construction and rules out the action-history
``propagation_fetch`` dispatch in
``Sam3MultiplexTracking.parse_action_history_for_propagation`` (a plausible
prior suspect: it never triggers on the very first propagate call either).
Traced to ``sam3.model.sam3_multiplex_tracking.Sam3MultiplexTracking.
_build_sam2_output`` (in the pinned ``third_party/sam3`` checkout)::
def _build_sam2_output(self, inference_state, frame_idx, refined_obj_id_to_mask=None):
if not frame_idx in inference_state["cached_frame_outputs"]:
return {}
... # merge refined_obj_id_to_mask into cached_outputs.copy()
The early return discards the caller-supplied ``refined_obj_id_to_mask`` --
the just-computed SAM2-propagated mask for that frame -- whenever the frame has
never been cached before, instead of treating the cache miss as an empty *base*
dict to merge the refined masks into. Text/VG prompting never trips this: the
initial VG detection pass pre-caches every frame before any refinement runs. A
point prompt never runs that pass, so nearly every frame visited by propagation
is a cache miss, and the real mask is thrown away right before
``_postprocess_output`` would have kept it -- its ``out_binary_masks.any(dim=(1,
2))`` keep-filter then reports the frame as having no objects. (Frame 0 is the
one exception: ``add_prompt`` itself populates ``cached_frame_outputs[0]``, even
though *its own* immediate response is emptied by this same bug, so by the time
propagation revisits frame 0 the cache-hit path runs and merges correctly --
which is why the prompt frame alone looks fine.)
Verified by patching this in and rerunning the repro: propagation area went
from "real mask at frame 0, zero at every other frame" to a stable mask area
(+/-2%) at all 30 frames, matching a text-prompted control run on the same clip.
Applied as a self-test-driven monkeypatch (mirrors :func:`_patch_init_state_kwargs`)
rather than an upstream fork, so it becomes a no-op automatically once sam3 fixes
this: it calls the *unpatched* method with a synthetic cache-miss case first and
only replaces it if that call reproduces the bug.
"""
model = getattr(predictor, "model", None)
build_sam2_output = getattr(model, "_build_sam2_output", None)
if model is None or build_sam2_output is None:
return
probe_state = {"cached_frame_outputs": {}}
try:
probed = build_sam2_output(
probe_state, frame_idx=0, refined_obj_id_to_mask={"probe": "mask"}
)
except Exception: # pragma: no cover - upstream changed the method outright
logger.debug(
"could not probe _build_sam2_output for the point-prompt cache-miss bug; "
"leaving it unpatched",
exc_info=True,
)
return
if probed:
# Already merges correctly on a cache miss -- upstream fixed it, nothing to do.
return
def _fixed_build_sam2_output(self, inference_state, frame_idx, refined_obj_id_to_mask=None):
obj_id_to_mask = inference_state["cached_frame_outputs"].get(frame_idx, {}).copy()
if refined_obj_id_to_mask is not None:
for obj_id, refined_mask in refined_obj_id_to_mask.items():
assert refined_mask is not None, (
f"Refined mask data must be provided for obj_id {obj_id}"
)
obj_id_to_mask[obj_id] = refined_mask
return obj_id_to_mask
model._build_sam2_output = types.MethodType(_fixed_build_sam2_output, model)
logger.info(
"applied sam3 compatibility shim: _build_sam2_output was discarding refined "
"masks on frames with no prior cache entry, which breaks point-prompt "
"video propagation"
)
class Sam3VideoSegmenter(VideoSegmenter):
"""Adapts SAM 3.1's multiplex video predictor to :class:`VideoSegmenter`.
The predictor itself (``build_sam3_multiplex_video_predictor``) is
expensive to construct -- it loads the SAM 3.1 checkpoint -- so it is
built once, lazily, on first use and reused across every session this
instance opens.
"""
def __init__(
self,
config: SegmentationConfig,
device: str = "cuda",
checkpoint_path: str | None = None,
bpe_path: str | None = None,
) -> None:
"""Args:
config: Segmentation hyperparameters (``use_fa3``, offload flags, ...).
device: Torch device SAM 3.1 should run on. Not currently accepted by
``build_sam3_multiplex_video_predictor`` itself -- pin the active
GPU (e.g. via ``CUDA_VISIBLE_DEVICES`` or ``torch.cuda.set_device``)
before constructing this adapter if ``RuntimeConfig.gpu_index`` is
set. Kept as a constructor argument so the adapter's interface
does not need to change if a future sam3 release adds one.
checkpoint_path: Local checkpoint path; ``None`` auto-downloads
``facebook/sam3.1`` via ``hf_hub_download``.
bpe_path: Optional tokenizer BPE path for the text encoder.
"""
self.config = config
self.device = device
self.checkpoint_path = checkpoint_path
self.bpe_path = bpe_path
self._predictor = None # built lazily: importing sam3 needs torch + weights
self._session_id: str | None = None
self._video_path: str | None = None
self._has_text_prompt = False
self._resolution: tuple[int, int] | None = None # (width, height), cached from first output
def _build(self):
"""Construct the SAM 3.1 predictor on first use and cache it."""
if self._predictor is None:
# Heavy, GPU-framework-dependent import: kept out of module scope so
# `import fpgm.segmentation.sam3` works without sam3/torch installed.
from sam3.model_builder import build_sam3_multiplex_video_predictor
logger.info("building SAM 3.1 predictor (use_fa3=%s)", self.config.use_fa3)
self._predictor = build_sam3_multiplex_video_predictor(
checkpoint_path=self.checkpoint_path,
bpe_path=self.bpe_path,
max_num_objects=self.config.max_num_objects,
multiplex_count=self.config.multiplex_count,
# FlashAttention-3 is sam3's own default (use_fa3=True) but needs a
# separate, optional install; SegmentationConfig.use_fa3 defaults
# to False so this adapter works with a plain sam3 install.
# use_rope_real must track use_fa3 -- they are not independent.
use_fa3=self.config.use_fa3,
use_rope_real=self.config.use_fa3,
compile=False,
warm_up=False,
default_output_prob_thresh=self.config.output_prob_thresh,
)
_patch_init_state_kwargs(self._predictor)
_patch_build_sam2_output(self._predictor)
return self._predictor
def start_session(self, video_path: str) -> str:
"""Open a session on an mp4 or a folder of ``<idx>.jpg`` frames."""
predictor = self._build()
response = predictor.handle_request(
request=dict(
type="start_session",
resource_path=video_path,
# The session preloads every frame as a (T, 3, 1008, 1008)
# float16 tensor, ~6.1 MB/frame. This host's GPUs are shared and
# often have only 11-27 GB free, so offload to host RAM by
# default; SegmentationConfig.offload_video_to_cpu=True.
offload_video_to_cpu=self.config.offload_video_to_cpu,
offload_state_to_cpu=self.config.offload_state_to_cpu,
)
)
self._session_id = response["session_id"]
self._video_path = video_path
self._has_text_prompt = False
self._resolution = None
return self._session_id
def add_text_prompt(self, frame_idx: int, text: str) -> FrameMasks:
"""Prompt with a natural-language phrase; returns that frame's masks."""
self._require_session()
if self._has_text_prompt:
logger.warning(
"add_text_prompt: session %s already has a text prompt applied; "
"auto-resetting before applying %r, per sam3's requirement that "
"a new text prompt needs a fresh session state",
self._session_id,
text,
)
self.reset_session()
predictor = self._build()
response = predictor.handle_request(
request=dict(
type="add_prompt",
session_id=self._session_id,
frame_index=frame_idx,
text=text,
output_prob_thresh=self.config.output_prob_thresh,
)
)
self._has_text_prompt = True
return self._frame_masks_from_response(response)
def add_point_prompt(
self,
frame_idx: int,
points_xy: np.ndarray,
labels: np.ndarray,
obj_id: int | None = None,
) -> FrameMasks:
"""Prompt with positive/negative clicks in absolute pixel coordinates."""
self._require_session()
predictor = self._build()
width, height = self._resolution_for_conversion(frame_idx)
points_xy = np.asarray(points_xy, dtype=np.float32)
# sam3's add_prompt wants *relative* [0, 1] coordinates
# (rel_coordinates=True is the default); our ABC contract is absolute
# pixels, so convert here rather than pushing the distinction onto callers.
rel_points = points_xy / np.array([width, height], dtype=np.float32)
request = dict(
type="add_prompt",
session_id=self._session_id,
frame_index=frame_idx,
points=rel_points.tolist(),
point_labels=np.asarray(labels, dtype=np.int32).tolist(),
rel_coordinates=True,
clear_old_points=False,
output_prob_thresh=self.config.output_prob_thresh,
)
if obj_id is not None:
request["obj_id"] = obj_id
response = predictor.handle_request(request=request)
return self._frame_masks_from_response(response)
def propagate(
self,
direction: str = "both",
start_frame_index: int = 0,
max_frame_num_to_track: int | None = None,
) -> Iterator[FrameMasks]:
"""Stream per-frame masks across the whole video.
Args:
direction: ``"both"``, ``"forward"``, or ``"backward"``.
start_frame_index: Frame to start propagation from.
max_frame_num_to_track: Optional cap on the number of frames
propagated (in each direction); unlimited if ``None``.
"""
self._require_session()
predictor = self._build()
request = dict(
type="propagate_in_video",
session_id=self._session_id,
propagation_direction=direction,
start_frame_index=start_frame_index,
output_prob_thresh=self.config.output_prob_thresh,
)
if max_frame_num_to_track is not None:
request["max_frame_num_to_track"] = max_frame_num_to_track
for event in predictor.handle_stream_request(request=request):
yield self._frame_masks_from_outputs(int(event["frame_index"]), event["outputs"])
def collect_masklets(
self,
direction: str = "both",
start_frame_index: int = 0,
max_frame_num_to_track: int | None = None,
) -> dict[int, Masklet]:
"""Run :meth:`propagate` across the whole clip and assemble one Masklet per object.
Returns:
``obj_id -> Masklet``, with every frame's mask and score for that
object.
"""
masklets: dict[int, Masklet] = {}
for frame_masks in self.propagate(
direction=direction,
start_frame_index=start_frame_index,
max_frame_num_to_track=max_frame_num_to_track,
):
for i, obj_id in enumerate(frame_masks.obj_ids.tolist()):
masklet = masklets.setdefault(obj_id, Masklet(obj_id=obj_id))
masklet.frames[frame_masks.frame_idx] = frame_masks.masks[i]
masklet.scores[frame_masks.frame_idx] = float(frame_masks.scores[i])
return masklets
def select_object(
self,
masklets: dict[int, Masklet],
gripper_uv_per_frame: dict[int, np.ndarray] | None = None,
prefer_largest: bool = False,
) -> int:
"""Disambiguate which masklet is "the" object a text prompt referred to.
A text prompt like ``"brick"`` can match several instances in a scene.
Preference order:
1. If ``gripper_uv_per_frame`` is given and
``config.select_by_gripper_proximity`` is set, pick the masklet
whose mask centroid is (on average, over frames shared with the
gripper trajectory) nearest the gripper's pixel projection -- the
manipulated object is usually the one closest to the gripper.
2. Otherwise, or if no masklet shares a frame with the gripper
trajectory, fall back to mean mask area (``prefer_largest=True``)
or mean detection score (the default).
Args:
masklets: ``obj_id -> Masklet``, typically from
:meth:`collect_masklets`.
gripper_uv_per_frame: ``frame_idx -> (2,)`` gripper pixel
projection, at the same resolution as the masks.
prefer_largest: Use mean mask area rather than mean score as the
score/gripper-proximity fallback criterion.
Returns:
The chosen ``obj_id``.
Raises:
ValueError: If ``masklets`` is empty.
"""
if not masklets:
raise ValueError("select_object: masklets is empty")
if len(masklets) == 1:
return next(iter(masklets))
if gripper_uv_per_frame and self.config.select_by_gripper_proximity:
obj_id = self._select_by_gripper_proximity(masklets, gripper_uv_per_frame)
if obj_id is not None:
return obj_id
logger.warning(
"select_object: no masklet shares a frame with the gripper "
"trajectory; falling back to %s",
"mean area" if prefer_largest else "mean score",
)
if prefer_largest:
return max(masklets, key=lambda oid: _mean_area(masklets[oid]))
return max(masklets, key=lambda oid: _mean_score(masklets[oid]))
def reset_session(self) -> None:
"""Clear prompts. Required before switching to a different text prompt."""
self._require_session()
predictor = self._build()
predictor.handle_request(request=dict(type="reset_session", session_id=self._session_id))
self._has_text_prompt = False
def close_session(self) -> None:
"""Release the session and its GPU memory. Safe to call more than once."""
if self._session_id is None or self._predictor is None:
return # never opened, or already closed: double-close must be a no-op
try:
self._predictor.handle_request(
request=dict(type="close_session", session_id=self._session_id, run_gc_collect=True)
)
finally:
# Always clear local state, even if the server-side close failed --
# a stuck session handle here would wedge every subsequent call.
self._session_id = None
self._video_path = None
self._has_text_prompt = False
self._resolution = None
def _require_session(self) -> None:
if self._session_id is None:
raise RuntimeError(
f"{type(self).__name__}: no active session; call start_session() first"
)
def _resolution_for_conversion(self, frame_idx: int) -> tuple[int, int]:
"""Resolve the (width, height) needed to turn absolute pixels into sam3's relative coords.
Cached from the first frame's mask output; if no prompt has produced
output yet (e.g. ``add_point_prompt`` is the very first prompt) and
the session was opened on an mp4 file, the resolution is probed
directly from the container instead of decoding any frames.
"""
if self._resolution is not None:
return self._resolution
if self._video_path is not None and Path(self._video_path).is_file():
import cv2 # local import: only needed for this one-off probe
cap = cv2.VideoCapture(self._video_path)
try:
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
finally:
cap.release()
if width > 0 and height > 0:
self._resolution = (width, height)
return self._resolution
raise RuntimeError(
f"{type(self).__name__}: frame resolution is unknown at frame {frame_idx}; "
"call add_text_prompt first, or open the session on an mp4 file so the "
"resolution can be probed directly"
)
def _frame_masks_from_response(self, response: dict) -> FrameMasks:
return self._frame_masks_from_outputs(int(response["frame_index"]), response["outputs"])
def _frame_masks_from_outputs(self, frame_idx: int, outputs: dict) -> FrameMasks:
# out_binary_masks is already dense bool (N, H_orig, W_orig) at the
# video's original resolution -- no RLE/logit decoding needed.
masks = np.asarray(outputs["out_binary_masks"], dtype=bool)
if self._resolution is None and masks.ndim == 3:
self._resolution = (int(masks.shape[2]), int(masks.shape[1]))
return FrameMasks(
frame_idx=frame_idx,
obj_ids=np.asarray(outputs["out_obj_ids"], dtype=np.int64),
masks=masks,
scores=np.asarray(outputs["out_probs"], dtype=np.float32),
boxes_xywh=np.asarray(outputs["out_boxes_xywh"], dtype=np.float32),
)
def _select_by_gripper_proximity(
self,
masklets: dict[int, Masklet],
gripper_uv_per_frame: dict[int, np.ndarray],
) -> int | None:
best_obj_id: int | None = None
best_dist = np.inf
for obj_id, masklet in masklets.items():
dists = []
for frame_idx, gripper_uv in gripper_uv_per_frame.items():
mask = masklet.frames.get(frame_idx)
if mask is None or not mask.any():
continue
centroid = mask_centroid(mask)
gripper = np.asarray(gripper_uv, dtype=np.float32)
dists.append(float(np.linalg.norm(centroid - gripper)))
if not dists:
continue
mean_dist = float(np.mean(dists))
if mean_dist < best_dist:
best_dist = mean_dist
best_obj_id = obj_id
return best_obj_id
def _mean_area(masklet: Masklet) -> float:
if not masklet.frames:
return 0.0
return float(np.mean([mask.sum() for mask in masklet.frames.values()]))
def _mean_score(masklet: Masklet) -> float:
if not masklet.scores:
return 0.0
return float(np.mean(list(masklet.scores.values())))

Xet Storage Details

Size:
24 kB
·
Xet hash:
547b5b9ccfca74d30203ce672c16bdbf7517e18cecc6ac3a52c4e6b1e1b747ca

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.