Skip to content

Commit fab9b50

Browse files
committed
feat(vitmatte): object-aware tiling and multi-object matte path
Replace the full-image single-pass VitMatte inference with an object-aware tiled approach that processes only the regions that actually contain uncertain alpha (the trimap boundary zone around each segmented object). vitmatte_backend.py: - Add tiling constants (_TILE=512, _MIN_OV=64, _STRIDE=448) - Remove NEEDS_TRIMAP: trimap is derived internally per object - matte(): rewritten to use _matte_multi_frame for per-object bbox-focused tile selection — skips blank masks entirely - matte_multi(): new method; runs VitMatte for N objects in one forward pass per shared tile, merges per-object alphas into a composite, and returns both the merged alpha and the object list - _matte_multi_frame(): core tiled accumulation loop with cosine blend weights and trimap-presence masking per object - Tile helpers: _global_tile_starts_1d, _optimal_tile_starts_1d, _best_tile_starts_1d, _object_tile_positions - _verify_tile_logic(): offline smoke-test for tile geometry node.py: - Add use_multi gate in the matting section: when the upstream segmenter returns "object_masks"/"object_boxes" keys (e.g. from SAM 3.1 with multiplex tracking), and the matter backend supports matte_multi, route through the multi-object path; otherwise fall back transparently to the single-object matte() call.
1 parent 60f5adf commit fab9b50

2 files changed

Lines changed: 328 additions & 22 deletions

File tree

nodes/mask_matting/matters/vitmatte_backend.py

Lines changed: 307 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
from __future__ import annotations
88

99
import logging
10+
import math
1011
import os
11-
from typing import Optional
12+
import time
13+
from typing import List, Optional, Tuple
1214

1315
import numpy as np
1416
import torch
15-
import torch.nn.functional as F
1617

1718
from ..utils import (
1819
backend_first_root,
@@ -28,6 +29,10 @@
2829

2930
logger = logging.getLogger("MEC.MaskMatting.ViTMatte")
3031

32+
_TILE = 512
33+
_MIN_OV = 64
34+
_STRIDE = _TILE - _MIN_OV # 448
35+
3136

3237
def _have_transformers() -> bool:
3338
try:
@@ -42,7 +47,6 @@ class ViTMatteMatter(BaseMatter):
4247
KEY = "vitmatte"
4348
DISPLAY = "ViTMatte"
4449
MODELS_KEY = "vitmatte"
45-
NEEDS_TRIMAP = True
4650
STATUS = "ready" if _have_transformers() else "missing-deps"
4751

4852
def load(self) -> None:
@@ -111,29 +115,313 @@ def _is_hf_model_dir(p: str) -> bool:
111115
self._model = VitMatteForImageMatting.from_pretrained(src, torch_dtype=dtype).to(self.device).eval()
112116
self._dtype = dtype
113117

118+
# ── single-object path ──────────────────────────────────────────────
119+
114120
def matte(self, image_bhwc, coarse_mask, *, trimap=None, edge_radius=4, memory_size=8):
121+
"""Single-object matte via per-object optimised tiling.
122+
123+
``trimap`` is accepted for API compatibility but ignored — the trimap
124+
is derived internally from ``coarse_mask`` and ``edge_radius``.
125+
"""
115126
try:
127+
t0 = time.perf_counter()
116128
self.load()
117-
img = to_bhwc(image_bhwc)
129+
t1 = time.perf_counter()
130+
img = to_bhwc(image_bhwc)
118131
B, H, W, _ = img.shape
119-
if trimap is None:
120-
trimap = mask_to_trimap(coarse_mask, dilate=int(edge_radius) * 2, erode=int(edge_radius))
121-
trimap = to_mask(trimap)
122-
132+
coarse = to_mask(coarse_mask)
123133
alphas = []
124134
for i in interruptible_range(B, label="vitmatte"):
125135
frame = (img[i].cpu().numpy() * 255).astype(np.uint8)
126-
tri_np = (trimap[i].cpu().numpy() * 255).astype(np.uint8)
127-
inputs = self._processor(images=frame, trimaps=tri_np, return_tensors="pt")
128-
inputs = {k: v.to(self.device, dtype=self._dtype if v.dtype.is_floating_point else v.dtype) for k, v in inputs.items()}
129-
with torch.inference_mode(), torch.autocast(self.device, dtype=self._dtype, enabled=(self.device == "cuda")):
130-
out = self._model(**inputs)
131-
alpha = out.alphas
132-
# Resize to original
133-
alpha = F.interpolate(alpha, size=(H, W), mode="bilinear", align_corners=False)
134-
alphas.append(alpha[0, 0].float().cpu())
135-
alpha_t = torch.stack(alphas, 0).clamp(0, 1)
136-
return {"alpha": alpha_t, "info": {"backend": self.KEY}}
136+
m_np = coarse[i].cpu().numpy().astype(np.float32)
137+
ys, xs = np.where(m_np > 0.5)
138+
if not len(xs):
139+
alphas.append(torch.zeros(H, W, dtype=torch.float32))
140+
continue
141+
bbox = (int(xs.min()), int(ys.min()),
142+
int(xs.max()) + 1, int(ys.max()) + 1)
143+
result = self._matte_multi_frame(
144+
frame, [m_np], [bbox], H, W, int(edge_radius))
145+
alphas.append(result[0])
146+
t2 = time.perf_counter()
147+
logger.warning("[ViTMatte] matte (optimised) B=%d load=%.2fs infer=%.2fs total=%.2fs",
148+
B, t1-t0, t2-t1, t2-t0)
149+
return {"alpha": torch.stack(alphas, 0).clamp(0, 1),
150+
"info": {"backend": self.KEY}}
137151
except Exception:
138152
free_vram()
139153
raise
154+
155+
# ── multi-object path ────────────────────────────────────────────────
156+
157+
def matte_multi(
158+
self,
159+
image_bhwc: torch.Tensor,
160+
object_masks_list: List[torch.Tensor], # N × [B,H,W] or [H,W]
161+
object_bboxes_list: List[Tuple], # N × (x0,y0,x1,y1)
162+
*,
163+
edge_radius: int = 4,
164+
memory_size: int = 8,
165+
) -> dict:
166+
"""Run VitMatte for N objects with per-object optimised tile placement.
167+
168+
Tiles shared between non-intersecting objects are processed once
169+
(merged trimap); per-object alphas are separated by trimap-presence.
170+
171+
Returns {"alpha", "object_alphas", "object_boxes", "info"}.
172+
"""
173+
try:
174+
t0 = time.perf_counter()
175+
self.load()
176+
t1 = time.perf_counter()
177+
img = to_bhwc(image_bhwc)
178+
B, H, W, _ = img.shape
179+
N = len(object_masks_list)
180+
181+
if N == 0:
182+
zero = torch.zeros(B, H, W)
183+
return {"alpha": zero, "object_alphas": [], "object_boxes": [],
184+
"info": {"backend": self.KEY, "n_objects": 0}}
185+
186+
per_frame: List[List[torch.Tensor]] = []
187+
for b in interruptible_range(B, label="vitmatte-multi"):
188+
frame_hwc = (img[b].cpu().numpy() * 255).astype(np.uint8)
189+
frame_masks = []
190+
for mt in object_masks_list:
191+
m = mt[b] if mt.ndim == 3 else mt
192+
frame_masks.append(m.cpu().numpy().astype(np.float32))
193+
per_frame.append(
194+
self._matte_multi_frame(
195+
frame_hwc, frame_masks, object_bboxes_list, H, W, edge_radius)
196+
)
197+
198+
object_alphas: List[torch.Tensor] = []
199+
for i in range(N):
200+
frames = [per_frame[b][i] for b in range(B)]
201+
object_alphas.append(torch.stack(frames, 0).clamp(0, 1))
202+
203+
merged = torch.stack(object_alphas, 0).max(dim=0).values.clamp(0, 1)
204+
t2 = time.perf_counter()
205+
logger.warning(
206+
"[ViTMatte] matte_multi B=%d N=%d load=%.2fs infer=%.2fs total=%.2fs",
207+
B, N, t1-t0, t2-t1, t2-t0)
208+
return {
209+
"alpha": merged,
210+
"object_alphas": object_alphas,
211+
"object_boxes": object_bboxes_list,
212+
"info": {"backend": self.KEY, "n_objects": N},
213+
}
214+
except Exception:
215+
free_vram()
216+
raise
217+
218+
# ── per-frame multi-object implementation ───────────────────────────
219+
220+
def _matte_multi_frame(
221+
self,
222+
frame_hwc: np.ndarray,
223+
object_masks: List[np.ndarray],
224+
object_bboxes: List[Tuple],
225+
H: int, W: int,
226+
edge_radius: int,
227+
) -> List[torch.Tensor]:
228+
_t0_frame = time.perf_counter()
229+
N = len(object_masks)
230+
if N == 0:
231+
return []
232+
233+
dilate = edge_radius * 2
234+
erode = edge_radius
235+
pad = dilate
236+
237+
# 1. Per-object trimaps (uint8 0/127/255)
238+
trimaps: List[np.ndarray] = []
239+
for m_np in object_masks:
240+
m_t = torch.from_numpy(m_np).unsqueeze(0)
241+
tri_t = mask_to_trimap(m_t, dilate=dilate, erode=erode)
242+
trimaps.append((tri_t[0].cpu().numpy() * 255).astype(np.uint8))
243+
244+
# 2. Per-object tile positions
245+
tile_sets: List[List[Tuple[int, int]]] = []
246+
for i, bbox in enumerate(object_bboxes):
247+
positions = self._object_tile_positions(bbox, H, W, pad)
248+
tile_sets.append(positions)
249+
logger.debug("[ViTMatte] obj %d bbox=%s → %d tile(s)", i, bbox, len(positions))
250+
251+
# 3. Tile → object-index mapping
252+
tile_map: dict = {}
253+
for i, tset in enumerate(tile_sets):
254+
for pos in tset:
255+
tile_map.setdefault(pos, []).append(i)
256+
257+
global_full = (len(self._global_tile_starts_1d(W, _TILE, _STRIDE)) *
258+
len(self._global_tile_starts_1d(H, _TILE, _STRIDE)))
259+
logger.warning(
260+
"[ViTMatte] multi-frame N=%d unique_tiles=%d full-image_tiles=%d",
261+
N, len(tile_map), global_full,
262+
)
263+
264+
# 4. Per-object accumulators
265+
alpha_acc = np.zeros((N, H, W), dtype=np.float64)
266+
weight_acc = np.zeros((N, H, W), dtype=np.float64)
267+
268+
for (tx, ty), obj_idx in tile_map.items():
269+
ty1, tx1 = min(ty + _TILE, H), min(tx + _TILE, W)
270+
th, tw = ty1 - ty, tx1 - tx
271+
272+
patch_img = frame_hwc[ty:ty1, tx:tx1]
273+
merged_tri = np.zeros((th, tw), dtype=np.uint8)
274+
for i in obj_idx:
275+
merged_tri = np.maximum(merged_tri, trimaps[i][ty:ty1, tx:tx1])
276+
277+
if th < _TILE or tw < _TILE:
278+
patch_img = np.pad(patch_img,
279+
((0, _TILE-th), (0, _TILE-tw), (0, 0)), mode="reflect")
280+
merged_tri = np.pad(merged_tri,
281+
((0, _TILE-th), (0, _TILE-tw)), mode="edge")
282+
283+
inputs = self._processor(images=patch_img, trimaps=merged_tri, return_tensors="pt")
284+
inputs = {
285+
k: v.to(self.device,
286+
dtype=self._dtype if v.dtype.is_floating_point else v.dtype)
287+
for k, v in inputs.items()
288+
}
289+
with torch.inference_mode(), \
290+
torch.autocast(self.device, dtype=self._dtype,
291+
enabled=(self.device == "cuda")):
292+
out = self._model(**inputs)
293+
294+
patch_alpha = out.alphas[0, 0].float().cpu().numpy()[:th, :tw]
295+
w = self._blend_weight(th, tw, _MIN_OV)
296+
297+
for i in obj_idx:
298+
alpha_acc [i, ty:ty1, tx:tx1] += patch_alpha * w
299+
weight_acc[i, ty:ty1, tx:tx1] += w
300+
301+
# 5. Normalise and apply trimap-presence mask
302+
result: List[torch.Tensor] = []
303+
for i in range(N):
304+
wgt = weight_acc[i]
305+
with np.errstate(invalid="ignore", divide="ignore"):
306+
alpha = np.where(wgt > 1e-8, alpha_acc[i] / wgt, 0.0).astype(np.float32)
307+
alpha *= (trimaps[i] > 0).astype(np.float32)
308+
result.append(torch.from_numpy(alpha))
309+
elapsed_frame = time.perf_counter() - _t0_frame
310+
logger.warning(
311+
"[ViTMatte] _matte_multi_frame N=%d unique_tiles=%d %.2fs",
312+
N, len(tile_map), elapsed_frame,
313+
)
314+
return result
315+
316+
# ── tile-placement helpers ───────────────────────────────────────────
317+
318+
@staticmethod
319+
def _global_tile_starts_1d(length: int, tile: int, stride: int) -> List[int]:
320+
if length <= tile:
321+
return [0]
322+
starts = list(range(0, length - tile, stride))
323+
last = length - tile
324+
if not starts or starts[-1] != last:
325+
starts.append(last)
326+
return starts
327+
328+
@staticmethod
329+
def _optimal_tile_starts_1d(
330+
region_start: int, region_end: int,
331+
img_length: int, tile: int, min_overlap: int,
332+
) -> List[int]:
333+
"""Minimum evenly-spaced tiles covering [region_start, region_end]."""
334+
length = region_end - region_start
335+
if length <= 0:
336+
return []
337+
max_stride = tile - min_overlap
338+
if length <= tile:
339+
centre = region_start + length // 2
340+
s = max(0, min(img_length - tile, centre - tile // 2))
341+
return [s]
342+
n = 1 + math.ceil((length - tile) / max_stride)
343+
first = max(0, region_start)
344+
last = min(img_length - tile, region_end - tile)
345+
if last < first:
346+
last = first
347+
if n == 1:
348+
return [first]
349+
return [round(first + i * (last - first) / (n - 1)) for i in range(n)]
350+
351+
@classmethod
352+
def _best_tile_starts_1d(
353+
cls,
354+
px0: int, px1: int,
355+
img_length: int, tile: int, stride: int, min_overlap: int,
356+
) -> List[int]:
357+
"""Per-object optimal when fewer tiles than global grid; else global."""
358+
global_all = cls._global_tile_starts_1d(img_length, tile, stride)
359+
global_bbox = [t for t in global_all if t + tile > px0 and t < px1]
360+
optimal = cls._optimal_tile_starts_1d(px0, px1, img_length, tile, min_overlap)
361+
return optimal if len(optimal) < len(global_bbox) else global_bbox
362+
363+
@classmethod
364+
def _object_tile_positions(
365+
cls, bbox: Tuple, H: int, W: int, pad: int = _MIN_OV,
366+
) -> List[Tuple[int, int]]:
367+
x0, y0, x1, y1 = (int(v) for v in bbox)
368+
px0, py0 = max(0, x0 - pad), max(0, y0 - pad)
369+
px1, py1 = min(W, x1 + pad), min(H, y1 + pad)
370+
xs = cls._best_tile_starts_1d(px0, px1, W, _TILE, _STRIDE, _MIN_OV)
371+
ys = cls._best_tile_starts_1d(py0, py1, H, _TILE, _STRIDE, _MIN_OV)
372+
return [(x, y) for y in ys for x in xs]
373+
374+
# ── blend weight ─────────────────────────────────────────────────────
375+
376+
@staticmethod
377+
def _blend_weight(h: int, w: int, overlap: int) -> np.ndarray:
378+
wy = np.ones(h, dtype=np.float32)
379+
wx = np.ones(w, dtype=np.float32)
380+
for i in range(min(overlap, h // 2)):
381+
v = 0.5 * (1.0 - np.cos(np.pi * (i + 1) / (overlap + 1)))
382+
wy[i] = min(wy[i], v); wy[h-1-i] = min(wy[h-1-i], v)
383+
for i in range(min(overlap, w // 2)):
384+
v = 0.5 * (1.0 - np.cos(np.pi * (i + 1) / (overlap + 1)))
385+
wx[i] = min(wx[i], v); wx[w-1-i] = min(wx[w-1-i], v)
386+
return np.outer(wy, wx)
387+
388+
# ── self-verification ────────────────────────────────────────────────
389+
390+
@classmethod
391+
def _verify_tile_logic(cls) -> None:
392+
"""Smoke-test tile placement. Raises AssertionError on failure."""
393+
tile, min_ov, stride = _TILE, _MIN_OV, _STRIDE
394+
cases = [
395+
# (rs, re, img_L, exp_n, min_ov_check)
396+
(0, 512, 4080, 1, None),
397+
(0, 511, 4080, 1, None),
398+
(0, 960, 4080, 2, 64),
399+
(0, 961, 4080, 3, 64),
400+
(200, 1113, 4080, 2, 64), # 913 px — key case
401+
(0, 1000, 4080, 3, 64),
402+
]
403+
for rs, re, L, n_exp, ov_exp in cases:
404+
pos = cls._optimal_tile_starts_1d(rs, re, L, tile, min_ov)
405+
assert len(pos) == n_exp, \
406+
f"_optimal({rs},{re},{L}): expected {n_exp} got {len(pos)}: {pos}"
407+
assert pos[0] >= 0 and pos[-1] + tile <= L, \
408+
f"out of image: {pos}"
409+
assert pos[0] <= max(0, rs), \
410+
f"first tile misses region start: {pos[0]} > {rs}"
411+
assert pos[-1] + tile >= re, \
412+
f"last tile misses region end: {pos[-1]+tile} < {re}"
413+
if ov_exp:
414+
for a, b in zip(pos, pos[1:]):
415+
ov = (a + tile) - b
416+
assert ov >= ov_exp, \
417+
f"overlap {ov} < {ov_exp} between tiles {a} and {b}"
418+
419+
# _best_tile_starts_1d: 913-px region → 2 optimal vs 3 global
420+
best = cls._best_tile_starts_1d(200, 1113, 4080, tile, stride, min_ov)
421+
assert len(best) == 2, f"expected 2 best tiles for [200,1113], got {len(best)}: {best}"
422+
423+
# _object_tile_positions: 913×700 bbox with pad=8
424+
tiles = cls._object_tile_positions((200, 100, 1113, 800), 3072, 4080, pad=8)
425+
# x: [192,1121] = 929 px → 2 tiles; y: [92,808] = 716 px → 2 tiles → 4
426+
assert len(tiles) == 4, f"expected 4 tiles for 913×700, got {len(tiles)}: {tiles}"
427+
logger.warning("[ViTMatte] _verify_tile_logic: all checks passed")

nodes/mask_matting/node.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,10 +1119,28 @@ def _run_alt(alt_key: str) -> tuple:
11191119
device=device, precision=precision,
11201120
attention=attention, offload=offload,
11211121
)
1122-
mat_out = mat_inst.matte(
1123-
img_bhwc, mask_t, trimap=trimap_t,
1124-
edge_radius=edge, memory_size=int(memory_size),
1122+
# Multi-object path: activates when the segmenter returns
1123+
# per-object masks/boxes, B==1, and the backend has matte_multi.
1124+
# Falls back silently to the single-object path otherwise.
1125+
obj_masks_raw = seg_out.get("object_masks") # [N,H,W] or None
1126+
obj_boxes_raw = seg_out.get("object_boxes", [])
1127+
use_multi = (
1128+
obj_masks_raw is not None
1129+
and len(obj_boxes_raw) > 0
1130+
and B == 1
1131+
and hasattr(mat_inst, "matte_multi")
11251132
)
1133+
if use_multi:
1134+
om_list = [obj_masks_raw[i].unsqueeze(0) for i in range(len(obj_boxes_raw))]
1135+
mat_out = mat_inst.matte_multi(
1136+
img_bhwc, om_list, obj_boxes_raw,
1137+
edge_radius=edge, memory_size=int(memory_size),
1138+
)
1139+
else:
1140+
mat_out = mat_inst.matte(
1141+
img_bhwc, mask_t, trimap=trimap_t,
1142+
edge_radius=edge, memory_size=int(memory_size),
1143+
)
11261144
alpha_t = mat_out["alpha"].float().clamp(0, 1)
11271145
else:
11281146
alpha_t = mask_t

0 commit comments

Comments
 (0)