234 lines
7.5 KiB
Python
234 lines
7.5 KiB
Python
"""ComfyUI custom nodes for XT Matte Toolbox core matting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Callable, Dict, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
_RESAMPLING = getattr(Image, "Resampling", Image)
|
|
|
|
|
|
FALLBACK_MODEL_CHOICES: Tuple[str, ...] = (
|
|
"General",
|
|
"General-Lite",
|
|
"General-Lite-2K",
|
|
"Matting",
|
|
"Portrait",
|
|
"DIS",
|
|
"HRSOD",
|
|
"COD",
|
|
"DIS-TR_TEs",
|
|
"General-legacy",
|
|
)
|
|
|
|
_BOOTSTRAP_ERROR: Optional[str] = None
|
|
_PROJECT_ROOT: Optional[Path] = None
|
|
_MODEL_CHOICES: Tuple[str, ...] = FALLBACK_MODEL_CHOICES
|
|
_APPLY_BACKGROUND_REPLACEMENT: Optional[Callable] = None
|
|
|
|
|
|
def _find_project_root() -> Path:
|
|
"""Locate project root that contains src/models/loader.py."""
|
|
env_root = os.environ.get("XT_MATTE_PROJECT_ROOT")
|
|
if env_root:
|
|
root = Path(env_root).expanduser().resolve()
|
|
if (root / "src" / "models" / "loader.py").exists():
|
|
return root
|
|
raise RuntimeError(
|
|
f"XT_MATTE_PROJECT_ROOT is set but invalid: {root}. "
|
|
"Expected src/models/loader.py under this path."
|
|
)
|
|
|
|
here = Path(__file__).resolve()
|
|
for parent in [here.parent] + list(here.parents):
|
|
if (parent / "src" / "models" / "loader.py").exists():
|
|
return parent
|
|
|
|
raise RuntimeError(
|
|
"Project root not found. Set env XT_MATTE_PROJECT_ROOT to your project path."
|
|
)
|
|
|
|
|
|
def _bootstrap() -> None:
|
|
"""Wire this node to your existing matting core under src/."""
|
|
global _BOOTSTRAP_ERROR
|
|
global _PROJECT_ROOT
|
|
global _MODEL_CHOICES
|
|
global _APPLY_BACKGROUND_REPLACEMENT
|
|
|
|
try:
|
|
_PROJECT_ROOT = _find_project_root()
|
|
|
|
# Keep core model/output directories in your project root, not ComfyUI cwd.
|
|
os.environ.setdefault("XT_MATTE_BASE_DIR", str(_PROJECT_ROOT))
|
|
|
|
root_str = str(_PROJECT_ROOT)
|
|
if root_str not in sys.path:
|
|
sys.path.insert(0, root_str)
|
|
|
|
from src.config import USAGE_TO_WEIGHTS_FILE
|
|
from src.processors.image_processor import apply_background_replacement
|
|
|
|
_MODEL_CHOICES = tuple(USAGE_TO_WEIGHTS_FILE.keys()) or FALLBACK_MODEL_CHOICES
|
|
_APPLY_BACKGROUND_REPLACEMENT = apply_background_replacement
|
|
_BOOTSTRAP_ERROR = None
|
|
except Exception as exc: # noqa: BLE001
|
|
_BOOTSTRAP_ERROR = str(exc)
|
|
|
|
|
|
_bootstrap()
|
|
|
|
|
|
def _ensure_bootstrap_ok() -> None:
|
|
if _BOOTSTRAP_ERROR is not None or _APPLY_BACKGROUND_REPLACEMENT is None:
|
|
raise RuntimeError(
|
|
"XT Matte node bootstrap failed. "
|
|
f"Details: {_BOOTSTRAP_ERROR or 'Unknown error'}"
|
|
)
|
|
|
|
|
|
def _to_uint8_rgb(image_tensor: torch.Tensor) -> np.ndarray:
|
|
img = image_tensor.detach().cpu().numpy()
|
|
if img.ndim != 3 or img.shape[2] < 3:
|
|
raise ValueError(f"Invalid IMAGE shape: {img.shape}")
|
|
img = np.clip(img[..., :3], 0.0, 1.0)
|
|
return (img * 255.0 + 0.5).astype(np.uint8)
|
|
|
|
|
|
def _pick_roi_mask(
|
|
roi_mask: Optional[torch.Tensor], batch_index: int, target_hw: Tuple[int, int]
|
|
) -> Optional[np.ndarray]:
|
|
if roi_mask is None:
|
|
return None
|
|
|
|
h, w = target_hw
|
|
mask = roi_mask.detach().cpu().float()
|
|
if mask.ndim == 2:
|
|
selected = mask
|
|
elif mask.ndim == 3:
|
|
selected = mask[batch_index] if batch_index < mask.shape[0] else mask[0]
|
|
else:
|
|
raise ValueError(f"Invalid roi_mask shape: {tuple(mask.shape)}")
|
|
|
|
m = torch.clamp(selected, 0.0, 1.0).numpy().astype(np.float32)
|
|
if m.shape != (h, w):
|
|
# Nearest keeps user strokes hard-edged.
|
|
resized = Image.fromarray((m * 255.0 + 0.5).astype(np.uint8)).resize(
|
|
(w, h), resample=_RESAMPLING.NEAREST
|
|
)
|
|
m = np.asarray(resized, dtype=np.float32) / 255.0
|
|
return m
|
|
|
|
|
|
class XTMatteCutoutNode:
|
|
"""
|
|
Core matting node:
|
|
- Input IMAGE
|
|
- Output foreground IMAGE + alpha MASK + mask preview IMAGE
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Sequence]]:
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"model_name": (_MODEL_CHOICES,),
|
|
"resolution": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 64}),
|
|
"semi_transparent": ("BOOLEAN", {"default": False}),
|
|
"semi_strength": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"defringe": ("BOOLEAN", {"default": False}),
|
|
"defringe_strength": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"roi_crop_before": ("BOOLEAN", {"default": True}),
|
|
"roi_pad_px": ("INT", {"default": 16, "min": 0, "max": 256, "step": 1}),
|
|
},
|
|
"optional": {
|
|
"roi_mask": ("MASK",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
|
RETURN_NAMES = ("foreground", "alpha_mask", "mask_preview")
|
|
FUNCTION = "matte"
|
|
CATEGORY = "XT/Matting"
|
|
|
|
def matte(
|
|
self,
|
|
image: torch.Tensor,
|
|
model_name: str,
|
|
resolution: int,
|
|
semi_transparent: bool,
|
|
semi_strength: float,
|
|
defringe: bool,
|
|
defringe_strength: float,
|
|
roi_crop_before: bool,
|
|
roi_pad_px: int,
|
|
roi_mask: Optional[torch.Tensor] = None,
|
|
):
|
|
_ensure_bootstrap_ok()
|
|
assert _APPLY_BACKGROUND_REPLACEMENT is not None
|
|
|
|
if image.ndim != 4:
|
|
raise ValueError(f"Expected IMAGE shape [B,H,W,C], got {tuple(image.shape)}")
|
|
|
|
batch = image.shape[0]
|
|
fg_list = []
|
|
alpha_list = []
|
|
preview_list = []
|
|
|
|
for i in range(batch):
|
|
rgb_u8 = _to_uint8_rgb(image[i])
|
|
h, w = rgb_u8.shape[:2]
|
|
roi = _pick_roi_mask(roi_mask, i, (h, w))
|
|
|
|
result, mask_preview, err = _APPLY_BACKGROUND_REPLACEMENT(
|
|
image=rgb_u8,
|
|
model_name=model_name,
|
|
input_size=(int(resolution), int(resolution)),
|
|
semi_transparent=bool(semi_transparent),
|
|
semi_strength=float(semi_strength),
|
|
semi_mode="auto",
|
|
remove_white_halo=bool(defringe),
|
|
defringe_strength=float(defringe_strength),
|
|
roi_mask_fullres=roi,
|
|
roi_crop_before=bool(roi_crop_before),
|
|
roi_pad_px=int(roi_pad_px),
|
|
)
|
|
|
|
if result is None:
|
|
raise RuntimeError(f"Matting failed on batch index {i}: {err}")
|
|
|
|
rgba = np.asarray(result.convert("RGBA"), dtype=np.uint8)
|
|
alpha = (rgba[:, :, 3].astype(np.float32) / 255.0).copy()
|
|
fg = (rgba[:, :, :3].astype(np.float32) / 255.0).copy()
|
|
|
|
preview_img = (
|
|
mask_preview.convert("RGB")
|
|
if isinstance(mask_preview, Image.Image)
|
|
else Image.fromarray((alpha * 255.0 + 0.5).astype(np.uint8)).convert("RGB")
|
|
)
|
|
preview = (np.asarray(preview_img, dtype=np.float32) / 255.0).copy()
|
|
|
|
fg_list.append(torch.from_numpy(fg))
|
|
alpha_list.append(torch.from_numpy(alpha))
|
|
preview_list.append(torch.from_numpy(preview))
|
|
|
|
foreground = torch.stack(fg_list, dim=0)
|
|
alpha_mask = torch.stack(alpha_list, dim=0)
|
|
mask_preview_tensor = torch.stack(preview_list, dim=0)
|
|
return (foreground, alpha_mask, mask_preview_tensor)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"XTMatteCutout": XTMatteCutoutNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"XTMatteCutout": "XT Matte Cutout",
|
|
}
|