Files
malkuthro-ComfyUI-Koolook/matte/pipeline.py
040b30733d feat(matte): first-party mask-guided one-step video matting nodes (#269)
* feat(matte): first-party mask-guided one-step video matting nodes

Adds matte/ — an independent implementation of the published VideoMaMa
one-step SVD method (matte/pipeline.py) plus Koolook's temporal windowing,
subject-focused crop/stitch, resolution control (/64, shortest-side) and
auto OOM resolution-backoff. Nodes: KoolookMatteLoader, KoolookMatteSampler,
KoolookMatteFocusCrop, KoolookMatteFocusStitch.

Registered via the existing guarded _merge_node_group("Koolook Matte",
".matte"), so a missing diffusers disables only these nodes. diffusers is
already pulled in by the radiance/whatdreamscost forks, so no new dependency.

Loads (never bundles) the VideoMaMa UNet (CC-BY-NC) + SVD VAE at runtime;
matte/NOTICE records the model licenses and user responsibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(dev-sync): include matte/ in RUNTIME_PATHS

So `sync_to_dev` copies the new matte node package into the live ComfyUI
install alongside the rest of the koolook pack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): raw cond latents; fp16 default; preserve checkpoints on dev-sync

pipeline.py: _encode_latents must NOT divide by vae scaling_factor. The
VideoMaMa UNet is trained on raw VAE latents (matching okdalto's
VideoInferencePipeline, which multiplies by scaling_factor then divides it
back out -> net x1). Dividing over-scaled the conditioning ~5.5x and produced
a systematically blurry matte on every frame. Verified 5/5 vs the desktop /
vanilla reference on identical single-frame inputs.

nodes.py: harden the loader precision fallback so fp16 is the true default -
only an explicit "bf16" opts out; a stale "auto" widget now resolves to fp16
instead of bf16.

sync_to_dev.py: the rmtree+copytree mirror deleted matte/checkpoints/ (the
gitignored ~10GB SVD+VideoMaMa weights) on every matte sync, forcing a
re-download. Add PRESERVE_IN_DEST + selective _clean_dest so runtime-
downloaded model data survives a sync.

docs: add docs/maintainers/matte-pipeline.md (latent-scaling invariant,
black-output causes, fidelity levers, single-frame + vanilla-oracle debug
loop, checkpoint preservation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 review

- license: align matte to the repo's GPL-3.0 (was self-contradictory: claimed
  "MIT licensed - see LICENSE" while LICENSE is GPL-3.0). pipeline.py header,
  NOTICE, and the maintainer doc updated. NOTICE still credits VideoMaMa
  (CC BY-NC) + SVD (Stability Community License).
- nodes.py _ensure_2d_mask: squeeze first, then handle [H,W,C]/[C,H,W]
  explicitly and RAISE on ambiguous shapes -- previously [H,W,3] silently
  became [W,3] (wrong geometry).
- nodes.py OOM backoff: catch RuntimeError (OutOfMemoryError subclasses it, but
  cuBLAS/cuDNN/older paths raise plain "RuntimeError: CUDA out of memory"); the
  existing message guard still filters non-OOM errors.
- nodes.py: warn when the guide mask is empty instead of silently falling back
  to full-frame.
- sync_to_dev.py: PRESERVE_IN_DEST is now an exact relative path
  ("matte/checkpoints"), not a bare name matched at any depth; handle symlinks
  before is_dir() in _clean_dest so a symlinked dir isn't recursed/rmtree'd.
- CHANGELOG: add the Koolook Matte nodes under [Unreleased] with the
  non-commercial model-license caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 re-review

- sync_to_dev.py [BLOCKING]: a top-level symlinked dest (e.g. target/matte -> X)
  was passed straight into _clean_dest()/copytree(), which follow the link and
  can mutate files outside the target. Now unlink a symlinked dest and own the
  path as a real dir (checked before exists(), which follows symlinks). Verified
  with a repro: the symlink target's file survives.
- nodes.py: force float32 on a CPU fallback (CUDA unavailable). Half precision
  is unreliable on CPU; fp16 kernels would error. Warn that CPU is very slow.
- docs: correct the preserved-path guidance to the target-relative
  "matte/checkpoints" (was a bare "checkpoints" name); reword "near-verbatim
  reimplementation" -> "independent implementation targeting behavioural parity"
  to match the NOTICE/pipeline framing.
- nodes.py: also warn on a degenerate (<8px) guide-mask region before the
  full-frame fallback (was silent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 re-review (CI red + FocusCrop frame check)

- nodes.py: KoolookMatteFocusCrop now validates images vs coarse_mask frame
  counts (raises like KoolookMatteSampler.run_inference). A mismatch previously
  made cropped_images/cropped_mask diverge and silently drop/misalign frames at
  FocusStitch.
- pipeline.py: split "vae.to('cpu'); _clear_cache(...)" onto two lines (Ruff
  E702 was failing Lint & Smoke).
- constraints-test.txt: bump pillow 12.2.0 -> 12.3.0 (pip-audit CVE, fixed in
  12.3.0). Pillow is a leaf dep so the closure is unaffected; CI re-audits.
- README: note the Koolook Matte nodes need diffusers (already via the LTX /
  Radiance forks) and huggingface_hub for runtime weight downloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:03:12 +02:00

222 lines
9.6 KiB
Python

"""
Koolook Matte — independent inference pipeline for mask-guided one-step video matting.
This is an ORIGINAL implementation of the *published* VideoMaMa method
("VideoMaMa: Mask-Guided Video Matting via Generative Prior", CVPR 2026), written
from the method description + the standard `diffusers` API — it does not incorporate
any third-party wrapper code. The method itself:
* a Stable-Video-Diffusion UNet fine-tuned to take **12 latent channels**
= 4 (noise) + 4 (VAE-encoded RGB frame) + 4 (VAE-encoded guide mask),
* a **single** denoising step at timestep 1 with the CLIP cross-attention
context zeroed (the fine-tune ignores it),
* decode the predicted latents with SVD's temporal VAE and take luminance
as the alpha matte.
The model WEIGHTS it loads (the VideoMaMa UNet, and SVD's VAE) carry their own
licenses (CC BY-NC 4.0 and the Stability Community License respectively); this
code does not redistribute them and the end user is responsible for compliance.
Licensed under GPL-3.0 — see the repository LICENSE.
"""
import numpy as np
import torch
from diffusers.models import AutoencoderKLTemporalDecoder, UNetSpatioTemporalConditionModel
class KoolookMattePipeline:
"""One-step mask-guided SVD matting. Reusable across many clips/windows."""
def __init__(self, base_model_path: str, unet_checkpoint_path: str, device: str = "cuda",
weight_dtype: torch.dtype = torch.float16, enable_model_cpu_offload: bool = False,
vae_encode_chunk_size: int = 1, attention_mode: str = "auto",
enable_vae_tiling: bool = False, enable_vae_slicing: bool = True):
self.device = torch.device(device)
self.weight_dtype = weight_dtype
self.enable_model_cpu_offload = enable_model_cpu_offload
self.vae_encode_chunk_size = max(1, int(vae_encode_chunk_size))
# SVD's temporal VAE (from the base model) + the VideoMaMa-fine-tuned UNet.
try:
self.vae = AutoencoderKLTemporalDecoder.from_pretrained(base_model_path, subfolder="vae", variant="fp16")
except Exception:
# some snapshots ship the vae without an fp16 variant
self.vae = AutoencoderKLTemporalDecoder.from_pretrained(base_model_path, subfolder="vae")
self.unet = UNetSpatioTemporalConditionModel.from_pretrained(unet_checkpoint_path, subfolder="unet")
self.vae.eval()
self.unet.eval()
self._apply_attention_optimization(attention_mode)
if enable_vae_slicing:
try:
self.vae.enable_slicing()
except (AttributeError, NotImplementedError):
pass
if enable_vae_tiling:
try:
self.vae.enable_tiling()
except (AttributeError, NotImplementedError):
pass
if self.enable_model_cpu_offload:
self.vae.to("cpu", dtype=self.weight_dtype)
self.unet.to("cpu", dtype=self.weight_dtype)
else:
self.vae.to(self.device, dtype=self.weight_dtype)
self.unet.to(self.device, dtype=self.weight_dtype)
# -- setup helpers ------------------------------------------------------
def _apply_attention_optimization(self, attention_mode: str):
if attention_mode == "none":
return
if attention_mode in ("auto", "xformers"):
try:
import xformers # noqa: F401
self.unet.enable_xformers_memory_efficient_attention()
return
except Exception:
if attention_mode == "xformers":
print("KoolookMatte: xformers unavailable, falling back")
if attention_mode in ("auto", "sdpa"):
if hasattr(torch.nn.functional, "scaled_dot_product_attention"):
try:
from diffusers.models.attention_processor import AttnProcessor2_0
self.unet.set_attn_processor(AttnProcessor2_0())
return
except Exception:
pass
@staticmethod
def _clear_cache(device):
if device.type == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
elif device.type == "mps" and torch.backends.mps.is_available():
torch.mps.empty_cache()
elif device.type == "xpu":
torch.xpu.empty_cache()
# -- core inference -----------------------------------------------------
@torch.no_grad()
def run(self, cond_frames, mask_frames, seed=42, fps=7, motion_bucket_id=127,
noise_aug_strength=0.0, pbar=None):
"""Run one-step matting on a list of PIL frames + PIL guide masks.
Returns a list of grayscale PIL alpha frames (mode 'L').
`pbar` is an optional ComfyUI ProgressBar (updated 4x per pass).
"""
def tick():
if pbar is not None:
pbar.update(1)
cond = self._pil_to_tensor(cond_frames).to(self.device) # [1,F,3,H,W] in [-1,1]
mask = self._pil_to_tensor(mask_frames, grayscale_ok=True).to(self.device)
if mask.shape[2] != 3: # replicate L -> 3ch for the VAE
mask = mask.repeat(1, 1, 3, 1, 1)
hidden = self.unet.config.cross_attention_dim
encoder_hidden_states = torch.zeros((1, 1, hidden), dtype=self.weight_dtype, device=self.device)
# --- encode RGB frame + guide mask to latents ---
if self.enable_model_cpu_offload:
self.vae.to(self.device)
cond_latents = self._encode_latents(cond.to(self.weight_dtype))
mask_latents = self._encode_latents(mask.to(self.weight_dtype))
tick()
del cond, mask
self._clear_cache(self.device)
if self.enable_model_cpu_offload:
self.vae.to("cpu")
self._clear_cache(self.device)
# --- single UNet step over all frames at once ---
if self.enable_model_cpu_offload:
self.unet.to(self.device)
gen = torch.Generator(device="cpu").manual_seed(int(seed))
noise = torch.randn(cond_latents.shape, generator=gen, device="cpu",
dtype=self.weight_dtype).to(self.device)
timesteps = torch.ones((1,), device=self.device, dtype=torch.long)
added_time_ids = self._add_time_ids(fps, motion_bucket_id, noise_aug_strength)
unet_in = torch.cat([noise, cond_latents, mask_latents], dim=2) # 12 channels
del noise, cond_latents, mask_latents
self._clear_cache(self.device)
tick()
pred = self.unet(unet_in, timesteps, encoder_hidden_states, added_time_ids=added_time_ids).sample
del unet_in
if self.enable_model_cpu_offload:
self.unet.to("cpu")
self._clear_cache(self.device)
tick()
# --- decode latents -> luminance alpha ---
if self.enable_model_cpu_offload:
self.vae.to(self.device)
pred = pred.squeeze(0) / self.vae.config.scaling_factor # [F,4,h,w]
chunk = min(self.vae_encode_chunk_size, pred.shape[0])
decoded = []
for i in range(0, pred.shape[0], chunk):
part = pred[i:i + chunk]
decoded.append(self.vae.decode(part, num_frames=part.shape[0]).sample.cpu())
del pred
if self.enable_model_cpu_offload:
self.vae.to("cpu")
self._clear_cache(self.device)
tick()
video = torch.cat(decoded, dim=0) # [F,3,H,W]
del decoded
video = (video / 2.0 + 0.5).clamp(0, 1).mean(dim=1) # [F,H,W] luminance
out = []
for frame in video.float().cpu().numpy():
out.append(_to_pil_L(frame))
return out
# -- tensor plumbing ----------------------------------------------------
def _pil_to_tensor(self, frames, grayscale_ok=False):
arrs = []
for f in frames:
a = np.asarray(f)
if a.ndim == 2:
a = np.stack([a] * 3, axis=-1)
t = torch.from_numpy(a.copy()).permute(2, 0, 1).float() / 255.0
arrs.append(t)
video = torch.stack(arrs).unsqueeze(0) # [1,F,3,H,W]
return video * 2.0 - 1.0
def _encode_latents(self, t):
"""Encode [1,F,3,H,W] -> raw VAE latents [1,F,4,h,w] (chunked over frames).
The UNet is fed conditioning + mask latents at the RAW VAE scale (no
``scaling_factor`` applied), matching okdalto's ``VideoInferencePipeline``
(which encodes ``* scaling_factor`` then divides it back out, netting to
raw). Dividing here would over-scale the conditioning ~5.5x (1/0.18215),
pushing the fine-tuned UNet out of distribution and softening the matte.
"""
b, f = t.shape[0], t.shape[1]
t = t.reshape(b * f, *t.shape[2:])
parts = []
for i in range(0, t.shape[0], self.vae_encode_chunk_size):
parts.append(self.vae.encode(t[i:i + self.vae_encode_chunk_size]).latent_dist.sample())
lat = torch.cat(parts, dim=0)
return lat.reshape(b, f, *lat.shape[1:])
def _add_time_ids(self, fps, motion_bucket_id, noise_aug_strength):
vals = [fps, motion_bucket_id, noise_aug_strength]
want = self.unet.config.addition_time_embed_dim * len(vals)
have = self.unet.add_embedding.linear_1.in_features
if want != have:
raise ValueError(f"added_time_ids dim mismatch: model expects {have}, built {want}")
return torch.tensor([vals], dtype=self.weight_dtype, device=self.device)
def _to_pil_L(arr01):
from PIL import Image
return Image.fromarray((arr01 * 255).clip(0, 255).astype(np.uint8), mode="L")