1385 lines
49 KiB
Python
1385 lines
49 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn.functional as F
|
|
from torch.hub import download_url_to_file
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
MATANYONE_VENDOR_ROOT = ROOT / "vendor" / "MatAnyone2"
|
|
SAM_VENDOR_ROOT = ROOT / "vendor" / "segment-anything"
|
|
|
|
MODEL_VARIANTS = {
|
|
"MatAnyone 2": {
|
|
"filename": "matanyone2.pth",
|
|
"url": "https://github.com/pq-yang/MatAnyone2/releases/download/v1.0.0/matanyone2.pth",
|
|
},
|
|
"MatAnyone": {
|
|
"filename": "matanyone.pth",
|
|
"url": "https://github.com/pq-yang/MatAnyone/releases/download/v1.0.0/matanyone.pth",
|
|
},
|
|
}
|
|
|
|
SAM_VARIANTS = {
|
|
"vit_h": {
|
|
"filename": "sam_vit_h_4b8939.pth",
|
|
"url": "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth",
|
|
},
|
|
"vit_l": {
|
|
"filename": "sam_vit_l_0b3195.pth",
|
|
"url": "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth",
|
|
},
|
|
"vit_b": {
|
|
"filename": "sam_vit_b_01ec64.pth",
|
|
"url": "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth",
|
|
},
|
|
}
|
|
|
|
MASK_PREVIEW_COLORS: Tuple[Tuple[int, int, int], ...] = (
|
|
(87, 199, 133),
|
|
(54, 128, 255),
|
|
(255, 184, 77),
|
|
(244, 91, 105),
|
|
)
|
|
POSITIVE_POINT_COLOR = (61, 220, 132)
|
|
NEGATIVE_POINT_COLOR = (255, 84, 112)
|
|
POINT_RADIUS = 7
|
|
MATANYONE_CATEGORY = "MatAnyone2"
|
|
MATANYONE_SAM_CATEGORY = "MatAnyone2/SAM"
|
|
|
|
_MATANYONE_CACHE: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
|
|
_SAM_CACHE: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
|
|
_MATANYONE_CACHE_LOCK = threading.Lock()
|
|
_SAM_CACHE_LOCK = threading.Lock()
|
|
_INTERACTIVE_SAM_SESSIONS: Dict[str, Dict[str, Any]] = {}
|
|
_INTERACTIVE_SAM_SESSIONS_LOCK = threading.Lock()
|
|
_INTERACTIVE_SAM_SESSION_TTL_SECONDS = 15 * 60
|
|
|
|
|
|
def _append_vendor_paths() -> None:
|
|
for vendor_root in (MATANYONE_VENDOR_ROOT, SAM_VENDOR_ROOT):
|
|
vendor_path = str(vendor_root)
|
|
if vendor_root.exists() and vendor_path not in sys.path:
|
|
sys.path.insert(0, vendor_path)
|
|
|
|
|
|
def _require_matanyone() -> None:
|
|
_append_vendor_paths()
|
|
try:
|
|
import cv2 # noqa: F401
|
|
import hydra # noqa: F401
|
|
import imageio # noqa: F401
|
|
import matanyone2 # noqa: F401
|
|
import omegaconf # noqa: F401
|
|
import PIL # noqa: F401
|
|
import tqdm # noqa: F401
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"MatAnyone dependencies are not installed yet. Run `python install.py` "
|
|
"from this custom node folder, then restart ComfyUI."
|
|
) from exc
|
|
|
|
|
|
def _require_segment_anything() -> None:
|
|
_append_vendor_paths()
|
|
try:
|
|
import segment_anything # noqa: F401
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"Segment Anything is not installed yet. Run `python install.py` "
|
|
"from this custom node folder, then restart ComfyUI."
|
|
) from exc
|
|
|
|
|
|
def _default_checkpoint_dir(subdir: str) -> Path:
|
|
try:
|
|
import folder_paths # type: ignore
|
|
|
|
return Path(folder_paths.models_dir) / subdir
|
|
except Exception:
|
|
return ROOT / "models" / subdir
|
|
|
|
|
|
def _ensure_download(checkpoint_path: str, url: str, subdir: str, filename: str) -> Path:
|
|
if checkpoint_path.strip():
|
|
resolved = Path(checkpoint_path).expanduser().resolve()
|
|
if not resolved.exists():
|
|
raise FileNotFoundError(f"Checkpoint not found: {resolved}")
|
|
return resolved
|
|
|
|
model_dir = _default_checkpoint_dir(subdir)
|
|
model_dir.mkdir(parents=True, exist_ok=True)
|
|
resolved = (model_dir / filename).resolve()
|
|
if not resolved.exists():
|
|
download_url_to_file(url, str(resolved), progress=True)
|
|
return resolved
|
|
|
|
|
|
def _ensure_model_checkpoint(model_name: str, checkpoint_path: str) -> Path:
|
|
variant = MODEL_VARIANTS[model_name]
|
|
return _ensure_download(
|
|
checkpoint_path=checkpoint_path,
|
|
url=variant["url"],
|
|
subdir="matanyone",
|
|
filename=variant["filename"],
|
|
)
|
|
|
|
|
|
def _ensure_sam_checkpoint(sam_model_type: str, checkpoint_path: str) -> Path:
|
|
variant = SAM_VARIANTS[sam_model_type]
|
|
return _ensure_download(
|
|
checkpoint_path=checkpoint_path,
|
|
url=variant["url"],
|
|
subdir="sams",
|
|
filename=variant["filename"],
|
|
)
|
|
|
|
|
|
def _resolve_device(device_name: str) -> torch.device:
|
|
if device_name == "cpu":
|
|
return torch.device("cpu")
|
|
if device_name == "cuda":
|
|
if torch.cuda.is_available():
|
|
return torch.device("cuda")
|
|
raise RuntimeError("CUDA was requested, but no CUDA device is available.")
|
|
if device_name == "mps":
|
|
if torch.backends.mps.is_built() and torch.backends.mps.is_available():
|
|
return torch.device("mps")
|
|
raise RuntimeError("MPS was requested, but no MPS device is available.")
|
|
|
|
try:
|
|
import comfy.model_management as model_management # type: ignore
|
|
|
|
device = model_management.get_torch_device()
|
|
return device if isinstance(device, torch.device) else torch.device(device)
|
|
except Exception:
|
|
if torch.cuda.is_available():
|
|
return torch.device("cuda")
|
|
if torch.backends.mps.is_built() and torch.backends.mps.is_available():
|
|
return torch.device("mps")
|
|
return torch.device("cpu")
|
|
|
|
|
|
def _load_matanyone_model(checkpoint_path: Path, device: torch.device):
|
|
_require_matanyone()
|
|
|
|
from hydra import compose, initialize_config_module
|
|
from hydra.core.global_hydra import GlobalHydra
|
|
from omegaconf import open_dict
|
|
|
|
from matanyone2.model.matanyone2 import MatAnyone2
|
|
|
|
global_hydra = GlobalHydra.instance()
|
|
if global_hydra.is_initialized():
|
|
global_hydra.clear()
|
|
|
|
try:
|
|
initialize_config_module(
|
|
version_base="1.3.2",
|
|
config_module="matanyone2.config",
|
|
job_name="matanyone_comfyui",
|
|
)
|
|
cfg = compose(config_name="eval_matanyone_config")
|
|
with open_dict(cfg):
|
|
cfg["weights"] = str(checkpoint_path)
|
|
cfg["model"]["pretrained_resnet"] = False
|
|
|
|
model = MatAnyone2(cfg, single_object=True).to(device).eval()
|
|
state_dict = torch.load(str(checkpoint_path), map_location="cpu")
|
|
model.load_weights(state_dict)
|
|
return model
|
|
finally:
|
|
if global_hydra.is_initialized():
|
|
global_hydra.clear()
|
|
|
|
|
|
def _get_cached_matanyone_model(model_name: str, checkpoint_path: Path, device: torch.device) -> Dict[str, Any]:
|
|
cache_key = (model_name, str(checkpoint_path), str(device))
|
|
with _MATANYONE_CACHE_LOCK:
|
|
cached = _MATANYONE_CACHE.get(cache_key)
|
|
if cached is None:
|
|
cached = {
|
|
"model": _load_matanyone_model(checkpoint_path, device),
|
|
"device": device,
|
|
"checkpoint_path": str(checkpoint_path),
|
|
"model_name": model_name,
|
|
}
|
|
_MATANYONE_CACHE[cache_key] = cached
|
|
return cached
|
|
|
|
|
|
def _load_sam_model(sam_model_type: str, checkpoint_path: Path, device: torch.device):
|
|
_require_segment_anything()
|
|
|
|
from segment_anything import sam_model_registry
|
|
|
|
model = sam_model_registry[sam_model_type](checkpoint=str(checkpoint_path))
|
|
model.to(device=device)
|
|
model.eval()
|
|
return model
|
|
|
|
|
|
def _get_cached_sam_model(sam_model_type: str, checkpoint_path: Path, device: torch.device) -> Dict[str, Any]:
|
|
cache_key = (sam_model_type, str(checkpoint_path), str(device))
|
|
with _SAM_CACHE_LOCK:
|
|
cached = _SAM_CACHE.get(cache_key)
|
|
if cached is None:
|
|
cached = {
|
|
"model": _load_sam_model(sam_model_type, checkpoint_path, device),
|
|
"device": device,
|
|
"checkpoint_path": str(checkpoint_path),
|
|
"sam_model_type": sam_model_type,
|
|
}
|
|
_SAM_CACHE[cache_key] = cached
|
|
return cached
|
|
|
|
|
|
def _prepare_image_batch(images: torch.Tensor) -> torch.Tensor:
|
|
if images.ndim == 3:
|
|
images = images.unsqueeze(0)
|
|
if images.ndim != 4 or images.shape[-1] != 3:
|
|
raise ValueError("Expected IMAGE input shaped as [frames, height, width, 3].")
|
|
return images.detach().float().cpu().clamp(0.0, 1.0).contiguous()
|
|
|
|
|
|
def _prepare_single_image(image: torch.Tensor) -> torch.Tensor:
|
|
return _prepare_image_batch(image)[0:1]
|
|
|
|
|
|
def _resize_mask_to(mask: torch.Tensor, height: int, width: int) -> torch.Tensor:
|
|
if mask.ndim == 2:
|
|
mask = mask.unsqueeze(0)
|
|
if mask.ndim != 3:
|
|
raise ValueError("Expected MASK input shaped as [frames, height, width] or [height, width].")
|
|
chosen = mask[0].detach().float().cpu().clamp(0.0, 1.0)
|
|
if chosen.shape != (height, width):
|
|
chosen = F.interpolate(
|
|
chosen.unsqueeze(0).unsqueeze(0),
|
|
size=(height, width),
|
|
mode="nearest",
|
|
)[0, 0]
|
|
return chosen
|
|
|
|
|
|
def _prepare_binary_mask(
|
|
mask: torch.Tensor,
|
|
height: int,
|
|
width: int,
|
|
threshold: float = 0.5,
|
|
invert_mask: bool = False,
|
|
erode_kernel: int = 0,
|
|
dilate_kernel: int = 0,
|
|
) -> torch.Tensor:
|
|
chosen = _resize_mask_to(mask, height, width)
|
|
if invert_mask:
|
|
chosen = 1.0 - chosen
|
|
mask_np = ((chosen >= threshold).float().numpy() * 255.0).astype(np.float32)
|
|
|
|
if dilate_kernel > 0 or erode_kernel > 0:
|
|
import cv2
|
|
|
|
if dilate_kernel > 0:
|
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (int(dilate_kernel), int(dilate_kernel)))
|
|
mask_np = cv2.dilate((mask_np != 0).astype(np.float32), kernel, iterations=1) * 255.0
|
|
|
|
if erode_kernel > 0:
|
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (int(erode_kernel), int(erode_kernel)))
|
|
mask_np = cv2.erode((mask_np == 255).astype(np.float32), kernel, iterations=1) * 255.0
|
|
|
|
return torch.from_numpy(mask_np.copy()).to(torch.float32)
|
|
|
|
|
|
def _check_interrupt() -> None:
|
|
try:
|
|
import comfy.model_management as model_management # type: ignore
|
|
|
|
model_management.throw_exception_if_processing_interrupted()
|
|
except Exception:
|
|
return
|
|
|
|
|
|
def _soft_empty_cache() -> None:
|
|
try:
|
|
import comfy.model_management as model_management # type: ignore
|
|
|
|
model_management.soft_empty_cache()
|
|
except Exception:
|
|
return
|
|
|
|
|
|
def _get_progress_bar(total: int):
|
|
try:
|
|
import comfy.utils # type: ignore
|
|
|
|
return comfy.utils.ProgressBar(total)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _update_progress(progress_bar, current: int, total: int) -> None:
|
|
if progress_bar is None:
|
|
return
|
|
if hasattr(progress_bar, "update_absolute"):
|
|
progress_bar.update_absolute(current, total)
|
|
elif hasattr(progress_bar, "update"):
|
|
progress_bar.update(1)
|
|
|
|
|
|
def _empty_prompt() -> Dict[str, List[Any]]:
|
|
return {"points": [], "labels": []}
|
|
|
|
|
|
def _clone_prompt(prompt: Optional[Dict[str, Any]]) -> Dict[str, List[Any]]:
|
|
if prompt is None:
|
|
return _empty_prompt()
|
|
return {
|
|
"points": [list(point) for point in prompt.get("points", [])],
|
|
"labels": list(prompt.get("labels", [])),
|
|
}
|
|
|
|
|
|
def _parse_label_token(token: str) -> int:
|
|
value = token.strip().lower()
|
|
if value in {"+", "1", "positive", "pos", "foreground", "fg", "true"}:
|
|
return 1
|
|
if value in {"-", "0", "negative", "neg", "background", "bg", "false"}:
|
|
return 0
|
|
raise ValueError(f"Unsupported label token: {token}")
|
|
|
|
|
|
def _parse_prompt_text(prompt_text: str) -> Dict[str, List[Any]]:
|
|
prompt = _empty_prompt()
|
|
for line in prompt_text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
parts = [part for part in re.split(r"[\s,]+", stripped) if part]
|
|
if len(parts) < 3:
|
|
raise ValueError(
|
|
"Each prompt line must look like `x,y,+` or `x y negative`."
|
|
)
|
|
x = int(float(parts[0]))
|
|
y = int(float(parts[1]))
|
|
label = _parse_label_token(parts[2])
|
|
prompt["points"].append([x, y])
|
|
prompt["labels"].append(label)
|
|
return prompt
|
|
|
|
|
|
def _prompt_to_numpy(prompt: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray]:
|
|
if not prompt.get("points"):
|
|
raise ValueError("The prompt is empty. Add at least one positive or negative point.")
|
|
points = np.asarray(prompt["points"], dtype=np.float32)
|
|
labels = np.asarray(prompt["labels"], dtype=np.int32)
|
|
if points.ndim != 2 or points.shape[1] != 2:
|
|
raise ValueError("Prompt points must have shape [N, 2].")
|
|
if labels.ndim != 1 or labels.shape[0] != points.shape[0]:
|
|
raise ValueError("Prompt labels must match the number of points.")
|
|
return points, labels
|
|
|
|
|
|
def _make_preview(
|
|
image: torch.Tensor,
|
|
masks: Sequence[torch.Tensor],
|
|
opacity: float,
|
|
prompt: Optional[Dict[str, Any]] = None,
|
|
) -> torch.Tensor:
|
|
image_batch = _prepare_single_image(image)
|
|
base = (image_batch[0].numpy() * 255.0).round().astype(np.uint8)
|
|
|
|
for index, mask in enumerate(masks):
|
|
color = np.asarray(MASK_PREVIEW_COLORS[index % len(MASK_PREVIEW_COLORS)], dtype=np.float32)
|
|
mask_np = mask.detach().float().cpu().numpy()
|
|
if mask_np.shape != base.shape[:2]:
|
|
mask_np = F.interpolate(
|
|
torch.from_numpy(mask_np).unsqueeze(0).unsqueeze(0),
|
|
size=base.shape[:2],
|
|
mode="nearest",
|
|
)[0, 0].numpy()
|
|
alpha = np.clip(mask_np, 0.0, 1.0) * opacity
|
|
base = np.clip(base * (1.0 - alpha[..., None]) + color * alpha[..., None], 0, 255).astype(np.uint8)
|
|
|
|
if prompt and prompt.get("points"):
|
|
import cv2
|
|
|
|
for point, label in zip(prompt["points"], prompt["labels"]):
|
|
color = POSITIVE_POINT_COLOR if int(label) == 1 else NEGATIVE_POINT_COLOR
|
|
cv2.circle(base, (int(point[0]), int(point[1])), POINT_RADIUS, color, thickness=-1, lineType=cv2.LINE_AA)
|
|
cv2.circle(base, (int(point[0]), int(point[1])), POINT_RADIUS + 2, (255, 255, 255), thickness=2, lineType=cv2.LINE_AA)
|
|
|
|
return torch.from_numpy(base.astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
|
|
def _normalize_mask_index(mask_choice: str, scores: np.ndarray) -> int:
|
|
if mask_choice == "best":
|
|
return int(np.argmax(scores))
|
|
return min(max(int(mask_choice), 0), len(scores) - 1)
|
|
|
|
|
|
def _merge_mask_list(masks: Iterable[torch.Tensor], threshold: float) -> torch.Tensor:
|
|
valid_masks = [mask for mask in masks if mask is not None]
|
|
if not valid_masks:
|
|
raise ValueError("At least one mask is required.")
|
|
|
|
first_mask = valid_masks[0]
|
|
if first_mask.ndim == 2:
|
|
first_mask = first_mask.unsqueeze(0)
|
|
height, width = first_mask.shape[-2:]
|
|
|
|
merged = torch.zeros((height, width), dtype=torch.float32)
|
|
for mask in valid_masks:
|
|
resized = _resize_mask_to(mask, height, width)
|
|
merged = torch.maximum(merged, resized)
|
|
|
|
return (merged >= threshold).float().unsqueeze(0)
|
|
|
|
|
|
def _image_tensor_to_numpy(image: torch.Tensor) -> np.ndarray:
|
|
return (_prepare_single_image(image)[0].numpy() * 255.0).round().astype(np.uint8)
|
|
|
|
|
|
def _numpy_image_to_tensor(image_np: np.ndarray) -> torch.Tensor:
|
|
return torch.from_numpy(image_np.astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
|
|
def _save_temp_preview(image_np: np.ndarray, prefix: str = "matanyone_preview") -> str:
|
|
"""Save image as temp file and return a /view URL instead of a data URL.
|
|
|
|
Falls back to base64 data URL if ComfyUI folder_paths is unavailable.
|
|
"""
|
|
from PIL import Image
|
|
|
|
try:
|
|
import folder_paths # type: ignore
|
|
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
Path(temp_dir).mkdir(parents=True, exist_ok=True)
|
|
filename = f"{prefix}_{uuid.uuid4().hex[:12]}.png"
|
|
filepath = Path(temp_dir) / filename
|
|
Image.fromarray(image_np).save(str(filepath), format="PNG")
|
|
return f"/view?filename={filename}&type=temp"
|
|
except Exception:
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(image_np).save(buffer, format="PNG")
|
|
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{encoded}"
|
|
|
|
|
|
def _encode_png_data_url(image_np: np.ndarray) -> str:
|
|
"""Legacy alias — now routes through temp file saving."""
|
|
return _save_temp_preview(image_np)
|
|
|
|
|
|
def _decode_image_data_url(image_data: str) -> np.ndarray:
|
|
from PIL import Image
|
|
|
|
if not image_data:
|
|
raise ValueError("Missing image data.")
|
|
|
|
# Support /view URL references (avoids base64 in request body)
|
|
if image_data.startswith("/view?") or image_data.startswith("view?"):
|
|
try:
|
|
import folder_paths # type: ignore
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
parsed = urlparse(image_data if "?" in image_data else f"/{image_data}")
|
|
params = parse_qs(parsed.query)
|
|
filename = params.get("filename", [""])[0]
|
|
file_type = params.get("type", ["temp"])[0]
|
|
subfolder = params.get("subfolder", [""])[0]
|
|
|
|
if file_type == "temp":
|
|
base_dir = folder_paths.get_temp_directory()
|
|
elif file_type == "input":
|
|
base_dir = folder_paths.get_input_directory()
|
|
elif file_type == "output":
|
|
base_dir = folder_paths.get_output_directory()
|
|
else:
|
|
base_dir = folder_paths.get_temp_directory()
|
|
|
|
filepath = Path(base_dir) / subfolder / filename if subfolder else Path(base_dir) / filename
|
|
with Image.open(str(filepath)) as img:
|
|
return np.asarray(img.convert("RGB"))
|
|
except Exception:
|
|
pass # Fall through to base64 decoding
|
|
|
|
encoded = image_data.split(",", 1)[1] if image_data.startswith("data:") else image_data
|
|
raw = base64.b64decode(encoded)
|
|
with Image.open(io.BytesIO(raw)) as image:
|
|
return np.asarray(image.convert("RGB"))
|
|
|
|
|
|
def _empty_editor_state() -> Dict[str, Any]:
|
|
return {"targets": [], "active_index": 0}
|
|
|
|
|
|
def _normalize_editor_target(target: Optional[Dict[str, Any]], index: int) -> Dict[str, Any]:
|
|
prompt = _clone_prompt(target)
|
|
mask_choice = str((target or {}).get("mask_choice", "best"))
|
|
if mask_choice not in {"best", "0", "1", "2"}:
|
|
mask_choice = "best"
|
|
name = str((target or {}).get("name") or f"Mask {index + 1}")
|
|
return {
|
|
"name": name,
|
|
"mask_choice": mask_choice,
|
|
"points": prompt["points"],
|
|
"labels": prompt["labels"],
|
|
}
|
|
|
|
|
|
def _normalize_editor_state(editor_state: Optional[Any]) -> Dict[str, Any]:
|
|
if isinstance(editor_state, str):
|
|
stripped = editor_state.strip()
|
|
if not stripped:
|
|
return _empty_editor_state()
|
|
try:
|
|
parsed = json.loads(stripped)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("The saved interactive editor state is not valid JSON.") from exc
|
|
elif isinstance(editor_state, dict):
|
|
parsed = editor_state
|
|
elif editor_state is None:
|
|
return _empty_editor_state()
|
|
else:
|
|
raise ValueError("Unsupported interactive editor state.")
|
|
|
|
raw_targets = parsed.get("targets", [])
|
|
if not isinstance(raw_targets, list):
|
|
raise ValueError("Interactive editor state must contain a `targets` list.")
|
|
|
|
targets = [_normalize_editor_target(target if isinstance(target, dict) else None, index) for index, target in enumerate(raw_targets)]
|
|
active_index = int(parsed.get("active_index", 0) or 0)
|
|
if targets:
|
|
active_index = min(max(active_index, 0), len(targets) - 1)
|
|
else:
|
|
active_index = 0
|
|
return {"targets": targets, "active_index": active_index}
|
|
|
|
|
|
def _editor_state_to_json(editor_state: Dict[str, Any]) -> str:
|
|
return json.dumps(_normalize_editor_state(editor_state), indent=2, sort_keys=True)
|
|
|
|
|
|
def _editor_state_prompt_count(editor_state: Dict[str, Any]) -> int:
|
|
return sum(len(target.get("points", [])) for target in editor_state.get("targets", []))
|
|
|
|
|
|
def _editor_state_target_count(editor_state: Dict[str, Any], include_empty: bool = False) -> int:
|
|
if include_empty:
|
|
return len(editor_state.get("targets", []))
|
|
return sum(1 for target in editor_state.get("targets", []) if target.get("points"))
|
|
|
|
|
|
def _predict_masks_for_editor_targets(
|
|
predictor,
|
|
image_batch: torch.Tensor,
|
|
editor_state: Dict[str, Any],
|
|
preview_opacity: float,
|
|
) -> Dict[str, Any]:
|
|
targets = editor_state.get("targets", [])
|
|
active_index = min(max(int(editor_state.get("active_index", 0) or 0), 0), max(len(targets) - 1, 0))
|
|
selected_masks: List[torch.Tensor] = []
|
|
active_candidates: List[Dict[str, Any]] = []
|
|
|
|
for target_index, target in enumerate(targets):
|
|
if not target.get("points"):
|
|
continue
|
|
|
|
points, labels = _prompt_to_numpy(target)
|
|
masks, scores, _ = predictor.predict(
|
|
point_coords=points,
|
|
point_labels=labels,
|
|
multimask_output=True,
|
|
return_logits=False,
|
|
)
|
|
|
|
selected_index = _normalize_mask_index(str(target.get("mask_choice", "best")), scores)
|
|
selected_mask = torch.from_numpy(masks[selected_index].astype(np.float32)).unsqueeze(0)
|
|
selected_masks.append(selected_mask)
|
|
|
|
if target_index == active_index:
|
|
for candidate_index, score in enumerate(scores):
|
|
candidate_mask = torch.from_numpy(masks[candidate_index].astype(np.float32)).unsqueeze(0)
|
|
candidate_preview = _make_preview(
|
|
image_batch,
|
|
[candidate_mask[0]],
|
|
float(preview_opacity),
|
|
prompt=target,
|
|
)
|
|
active_candidates.append(
|
|
{
|
|
"index": int(candidate_index),
|
|
"score": float(score),
|
|
"preview_url": _encode_png_data_url(_image_tensor_to_numpy(candidate_preview)),
|
|
"selected": candidate_index == selected_index,
|
|
}
|
|
)
|
|
|
|
active_prompt = targets[active_index] if targets and active_index < len(targets) else None
|
|
merged_preview = _make_preview(
|
|
image_batch,
|
|
[mask[0] for mask in selected_masks],
|
|
float(preview_opacity),
|
|
prompt=active_prompt if active_prompt and active_prompt.get("points") else active_prompt,
|
|
)
|
|
|
|
return {
|
|
"selected_masks": selected_masks,
|
|
"preview": merged_preview,
|
|
"active_candidates": active_candidates,
|
|
}
|
|
|
|
|
|
def _run_interactive_editor_node(
|
|
sam_model: Dict[str, Any],
|
|
image: torch.Tensor,
|
|
editor_state: Dict[str, Any],
|
|
preview_opacity: float,
|
|
merge_threshold: float,
|
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
_require_segment_anything()
|
|
|
|
from segment_anything import SamPredictor
|
|
|
|
image_batch = _prepare_single_image(image)
|
|
predictor = SamPredictor(sam_model["model"])
|
|
predictor.set_image(_image_tensor_to_numpy(image_batch))
|
|
|
|
prediction = _predict_masks_for_editor_targets(
|
|
predictor=predictor,
|
|
image_batch=image_batch,
|
|
editor_state=editor_state,
|
|
preview_opacity=preview_opacity,
|
|
)
|
|
|
|
if not prediction["selected_masks"]:
|
|
# First queue: no masks yet — return blank mask and raw frame preview
|
|
h, w = image_batch.shape[1], image_batch.shape[2]
|
|
blank_mask = torch.zeros((1, h, w), dtype=torch.float32)
|
|
return blank_mask, image_batch
|
|
|
|
merged_mask = _merge_mask_list(prediction["selected_masks"], threshold=float(merge_threshold))
|
|
return merged_mask, prediction["preview"]
|
|
|
|
|
|
def _cleanup_interactive_sam_sessions() -> None:
|
|
now = time.time()
|
|
with _INTERACTIVE_SAM_SESSIONS_LOCK:
|
|
expired = [
|
|
session_id
|
|
for session_id, session in _INTERACTIVE_SAM_SESSIONS.items()
|
|
if now - float(session.get("last_access", session.get("created_at", now))) > _INTERACTIVE_SAM_SESSION_TTL_SECONDS
|
|
]
|
|
for session_id in expired:
|
|
_INTERACTIVE_SAM_SESSIONS.pop(session_id, None)
|
|
|
|
|
|
def _create_interactive_sam_session(
|
|
image_data: str,
|
|
sam_model_type: str,
|
|
checkpoint_path: str,
|
|
device: str,
|
|
) -> Dict[str, Any]:
|
|
_require_segment_anything()
|
|
|
|
from segment_anything import SamPredictor
|
|
|
|
_cleanup_interactive_sam_sessions()
|
|
image_np = _decode_image_data_url(image_data)
|
|
resolved_checkpoint = _ensure_sam_checkpoint(sam_model_type, checkpoint_path)
|
|
resolved_device = _resolve_device(device)
|
|
sam_model = _get_cached_sam_model(sam_model_type, resolved_checkpoint, resolved_device)
|
|
|
|
predictor = SamPredictor(sam_model["model"])
|
|
predictor.set_image(image_np)
|
|
|
|
session_id = uuid.uuid4().hex
|
|
now = time.time()
|
|
session = {
|
|
"id": session_id,
|
|
"created_at": now,
|
|
"last_access": now,
|
|
"predictor": predictor,
|
|
"image_np": image_np,
|
|
"width": int(image_np.shape[1]),
|
|
"height": int(image_np.shape[0]),
|
|
"lock": threading.Lock(),
|
|
}
|
|
with _INTERACTIVE_SAM_SESSIONS_LOCK:
|
|
_INTERACTIVE_SAM_SESSIONS[session_id] = session
|
|
return session
|
|
|
|
|
|
def _get_interactive_sam_session(session_id: str) -> Dict[str, Any]:
|
|
_cleanup_interactive_sam_sessions()
|
|
with _INTERACTIVE_SAM_SESSIONS_LOCK:
|
|
session = _INTERACTIVE_SAM_SESSIONS.get(session_id)
|
|
if session is None:
|
|
raise KeyError(f"Interactive SAM session not found: {session_id}")
|
|
session["last_access"] = time.time()
|
|
return session
|
|
|
|
|
|
def _close_interactive_sam_session(session_id: str) -> None:
|
|
with _INTERACTIVE_SAM_SESSIONS_LOCK:
|
|
_INTERACTIVE_SAM_SESSIONS.pop(session_id, None)
|
|
|
|
|
|
class MatAnyoneModelLoader:
|
|
DESCRIPTION = "Loads a MatAnyone or MatAnyone2 checkpoint and auto-downloads it when no path is provided."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_name": (list(MODEL_VARIANTS.keys()), {"default": "MatAnyone 2"}),
|
|
"checkpoint_path": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": False,
|
|
"placeholder": "Leave blank to auto-download the selected checkpoint",
|
|
},
|
|
),
|
|
"device": (["auto", "cuda", "mps", "cpu"], {"default": "auto"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MATANYONE_MODEL",)
|
|
RETURN_NAMES = ("matanyone_model",)
|
|
FUNCTION = "load_model"
|
|
CATEGORY = MATANYONE_CATEGORY
|
|
|
|
def load_model(self, model_name: str, checkpoint_path: str, device: str):
|
|
resolved_checkpoint = _ensure_model_checkpoint(model_name, checkpoint_path)
|
|
resolved_device = _resolve_device(device)
|
|
return (_get_cached_matanyone_model(model_name, resolved_checkpoint, resolved_device),)
|
|
|
|
|
|
class MatAnyoneSAMLoader:
|
|
DESCRIPTION = "Loads a Segment Anything checkpoint for scripted or reusable SAM refinement."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"sam_model_type": (list(SAM_VARIANTS.keys()), {"default": "vit_h"}),
|
|
"checkpoint_path": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": False,
|
|
"placeholder": "Leave blank to auto-download the selected SAM checkpoint",
|
|
},
|
|
),
|
|
"device": (["auto", "cuda", "mps", "cpu"], {"default": "auto"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MATANYONE_SAM",)
|
|
RETURN_NAMES = ("sam_model",)
|
|
FUNCTION = "load_sam"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def load_sam(self, sam_model_type: str, checkpoint_path: str, device: str):
|
|
resolved_checkpoint = _ensure_sam_checkpoint(sam_model_type, checkpoint_path)
|
|
resolved_device = _resolve_device(device)
|
|
return (_get_cached_sam_model(sam_model_type, resolved_checkpoint, resolved_device),)
|
|
|
|
|
|
class MatAnyoneSliceFrames:
|
|
DESCRIPTION = "Slices an IMAGE batch down to the frame range you want to edit or matte."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"start_frame": ("INT", {"default": 0, "min": 0, "max": 999999}),
|
|
"end_frame_exclusive": ("INT", {"default": -1, "min": -1, "max": 999999}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "INT", "INT")
|
|
RETURN_NAMES = ("images", "start_frame_used", "end_frame_used")
|
|
FUNCTION = "slice_frames"
|
|
CATEGORY = MATANYONE_CATEGORY
|
|
|
|
def slice_frames(self, images: torch.Tensor, start_frame: int, end_frame_exclusive: int):
|
|
batch = _prepare_image_batch(images)
|
|
frame_count = batch.shape[0]
|
|
if frame_count == 0:
|
|
raise ValueError("No frames were provided.")
|
|
start_index = min(max(int(start_frame), 0), frame_count - 1)
|
|
if end_frame_exclusive < 0:
|
|
end_index = frame_count
|
|
else:
|
|
end_index = min(max(int(end_frame_exclusive), start_index + 1), frame_count)
|
|
return (batch[start_index:end_index].contiguous(), start_index, end_index)
|
|
|
|
|
|
class MatAnyoneSelectFrame:
|
|
DESCRIPTION = "Extracts one frame from an IMAGE batch for interactive masking or prompt setup."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"frame_index": ("INT", {"default": 0, "min": 0, "max": 999999}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "INT")
|
|
RETURN_NAMES = ("image", "frame_index_used")
|
|
FUNCTION = "select_frame"
|
|
CATEGORY = MATANYONE_CATEGORY
|
|
|
|
def select_frame(self, images: torch.Tensor, frame_index: int):
|
|
batch = _prepare_image_batch(images)
|
|
if batch.shape[0] == 0:
|
|
raise ValueError("No frames were provided.")
|
|
resolved_index = min(max(int(frame_index), 0), batch.shape[0] - 1)
|
|
return (batch[resolved_index:resolved_index + 1].contiguous(), resolved_index)
|
|
|
|
|
|
class MatAnyonePromptStart:
|
|
DESCRIPTION = "Creates an empty SAM prompt container for point-based edits."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required": {}}
|
|
|
|
RETURN_TYPES = ("MATANYONE_PROMPT",)
|
|
RETURN_NAMES = ("prompt",)
|
|
FUNCTION = "start"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def start(self):
|
|
return (_empty_prompt(),)
|
|
|
|
|
|
class MatAnyonePromptFromText:
|
|
DESCRIPTION = "Parses x,y,+/- lines into a reusable SAM prompt structure."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt_text": (
|
|
"STRING",
|
|
{
|
|
"multiline": True,
|
|
"default": "512,320,+\n540,318,-",
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MATANYONE_PROMPT", "INT")
|
|
RETURN_NAMES = ("prompt", "point_count")
|
|
FUNCTION = "from_text"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def from_text(self, prompt_text: str):
|
|
prompt = _parse_prompt_text(prompt_text)
|
|
return (prompt, len(prompt["points"]))
|
|
|
|
|
|
class MatAnyoneAddPoint:
|
|
DESCRIPTION = "Adds one positive or negative point to an existing SAM prompt."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("MATANYONE_PROMPT",),
|
|
"x": ("INT", {"default": 512, "min": 0, "max": 16384}),
|
|
"y": ("INT", {"default": 320, "min": 0, "max": 16384}),
|
|
"label": (["positive", "negative"], {"default": "positive"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MATANYONE_PROMPT", "INT")
|
|
RETURN_NAMES = ("prompt", "point_count")
|
|
FUNCTION = "add_point"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def add_point(self, prompt: Dict[str, Any], x: int, y: int, label: str):
|
|
updated = _clone_prompt(prompt)
|
|
updated["points"].append([int(x), int(y)])
|
|
updated["labels"].append(1 if label == "positive" else 0)
|
|
return (updated, len(updated["points"]))
|
|
|
|
|
|
class MatAnyoneSAMRefine:
|
|
DESCRIPTION = "Runs SAM against a frame and prompt, returning the chosen mask, preview, logits, and score."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"sam_model": ("MATANYONE_SAM",),
|
|
"image": ("IMAGE",),
|
|
"prompt": ("MATANYONE_PROMPT",),
|
|
"multimask_output": ("BOOLEAN", {"default": True}),
|
|
"mask_choice": (["best", "0", "1", "2"], {"default": "best"}),
|
|
"use_previous_logits": ("BOOLEAN", {"default": True}),
|
|
"preview_opacity": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
},
|
|
"optional": {
|
|
"previous_logits": ("MATANYONE_SAM_LOGITS",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MASK", "IMAGE", "MATANYONE_SAM_LOGITS", "FLOAT")
|
|
RETURN_NAMES = ("mask", "preview", "logits", "score")
|
|
FUNCTION = "refine"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def refine(
|
|
self,
|
|
sam_model: Dict[str, Any],
|
|
image: torch.Tensor,
|
|
prompt: Dict[str, Any],
|
|
multimask_output: bool,
|
|
mask_choice: str,
|
|
use_previous_logits: bool,
|
|
preview_opacity: float,
|
|
previous_logits: Optional[Dict[str, Any]] = None,
|
|
):
|
|
_require_segment_anything()
|
|
|
|
from segment_anything import SamPredictor
|
|
|
|
image_batch = _prepare_single_image(image)
|
|
image_np = (image_batch[0].numpy() * 255.0).round().astype(np.uint8)
|
|
points, labels = _prompt_to_numpy(prompt)
|
|
|
|
predictor = SamPredictor(sam_model["model"])
|
|
predictor.set_image(image_np)
|
|
|
|
mask_input = None
|
|
if previous_logits is not None and use_previous_logits:
|
|
mask_input = previous_logits.get("low_res")
|
|
if isinstance(mask_input, torch.Tensor):
|
|
mask_input = mask_input.detach().cpu().numpy()
|
|
if mask_input is not None and mask_input.ndim == 2:
|
|
mask_input = mask_input[None, :, :]
|
|
|
|
masks, scores, low_res_masks = predictor.predict(
|
|
point_coords=points,
|
|
point_labels=labels,
|
|
mask_input=mask_input,
|
|
multimask_output=bool(multimask_output),
|
|
return_logits=False,
|
|
)
|
|
|
|
selected_index = _normalize_mask_index(mask_choice, scores)
|
|
selected_mask = torch.from_numpy(masks[selected_index].astype(np.float32)).unsqueeze(0)
|
|
preview = _make_preview(image_batch, [selected_mask[0]], float(preview_opacity), prompt=prompt)
|
|
logits = {
|
|
"low_res": low_res_masks[selected_index].copy(),
|
|
"low_res_candidates": low_res_masks.copy(),
|
|
"scores": scores.copy(),
|
|
"selected_index": int(selected_index),
|
|
}
|
|
return (selected_mask, preview, logits, float(scores[selected_index]))
|
|
|
|
|
|
class MatAnyoneMergeMasks:
|
|
DESCRIPTION = "Combines up to four masks into one thresholded output mask."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"mask_a": ("MASK",),
|
|
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
},
|
|
"optional": {
|
|
"mask_b": ("MASK",),
|
|
"mask_c": ("MASK",),
|
|
"mask_d": ("MASK",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MASK",)
|
|
RETURN_NAMES = ("mask",)
|
|
FUNCTION = "merge"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def merge(
|
|
self,
|
|
mask_a: torch.Tensor,
|
|
threshold: float,
|
|
mask_b: Optional[torch.Tensor] = None,
|
|
mask_c: Optional[torch.Tensor] = None,
|
|
mask_d: Optional[torch.Tensor] = None,
|
|
):
|
|
return (_merge_mask_list([mask_a, mask_b, mask_c, mask_d], threshold),)
|
|
|
|
|
|
class MatAnyonePreviewMasks:
|
|
DESCRIPTION = "Draws one or more mask overlays on the source image for quick inspection."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"mask_a": ("MASK",),
|
|
"opacity": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
},
|
|
"optional": {
|
|
"mask_b": ("MASK",),
|
|
"mask_c": ("MASK",),
|
|
"mask_d": ("MASK",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("preview",)
|
|
FUNCTION = "preview"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def preview(
|
|
self,
|
|
image: torch.Tensor,
|
|
mask_a: torch.Tensor,
|
|
opacity: float,
|
|
mask_b: Optional[torch.Tensor] = None,
|
|
mask_c: Optional[torch.Tensor] = None,
|
|
mask_d: Optional[torch.Tensor] = None,
|
|
):
|
|
image_batch = _prepare_single_image(image)
|
|
height, width = image_batch.shape[1:3]
|
|
masks = []
|
|
for mask in (mask_a, mask_b, mask_c, mask_d):
|
|
if mask is None:
|
|
continue
|
|
masks.append(_resize_mask_to(mask, height, width))
|
|
return (_make_preview(image_batch, masks, float(opacity)),)
|
|
|
|
|
|
class MatAnyoneInteractiveSAM:
|
|
DESCRIPTION = "Opens the built-in multi-target SAM editor and returns the merged first-frame mask."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"sam_model_type": (list(SAM_VARIANTS.keys()), {"default": "vit_h"}),
|
|
"checkpoint_path": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": False,
|
|
"placeholder": "Leave blank to auto-download the selected SAM checkpoint",
|
|
},
|
|
),
|
|
"device": (["auto", "cuda", "mps", "cpu"], {"default": "auto"}),
|
|
"editor_state": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"placeholder": "Managed by the interactive editor",
|
|
},
|
|
),
|
|
"preview_opacity": ("FLOAT", {"default": 0.65, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"merge_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MASK", "IMAGE", "STRING", "INT")
|
|
RETURN_NAMES = ("mask", "preview", "editor_state", "target_count")
|
|
OUTPUT_NODE = True
|
|
FUNCTION = "run"
|
|
CATEGORY = MATANYONE_SAM_CATEGORY
|
|
|
|
def run(
|
|
self,
|
|
image: torch.Tensor,
|
|
sam_model_type: str,
|
|
checkpoint_path: str,
|
|
device: str,
|
|
editor_state: str,
|
|
preview_opacity: float,
|
|
merge_threshold: float,
|
|
):
|
|
import folder_paths
|
|
from PIL import Image as PILImage
|
|
|
|
normalized_state = _normalize_editor_state(editor_state)
|
|
image_batch = _prepare_single_image(image)
|
|
|
|
# Save input frame as temp preview for the editor UI
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
Path(temp_dir).mkdir(parents=True, exist_ok=True)
|
|
frame_filename = f"matanyone_frame_{uuid.uuid4().hex[:12]}.png"
|
|
frame_np = _image_tensor_to_numpy(image_batch)
|
|
PILImage.fromarray(frame_np).save(str(Path(temp_dir) / frame_filename), format="PNG")
|
|
ui_images = [{"filename": frame_filename, "type": "temp", "subfolder": ""}]
|
|
|
|
# Check if any targets have actual points — if not, skip SAM entirely
|
|
has_points = any(
|
|
len(t.get("points", []) if isinstance(t, dict) else []) > 0
|
|
for t in normalized_state.get("targets", [])
|
|
)
|
|
|
|
if not has_points:
|
|
# First pass: return blank mask + raw frame, no SAM loading needed
|
|
h, w = image_batch.shape[1], image_batch.shape[2]
|
|
blank_mask = torch.zeros((1, h, w), dtype=torch.float32)
|
|
result = (
|
|
blank_mask,
|
|
image_batch,
|
|
_editor_state_to_json(normalized_state),
|
|
0,
|
|
)
|
|
return {"ui": {"images": ui_images, "has_masks": [False]}, "result": result}
|
|
|
|
# Second pass: load SAM and compute masks
|
|
resolved_checkpoint = _ensure_sam_checkpoint(sam_model_type, checkpoint_path)
|
|
resolved_device = _resolve_device(device)
|
|
sam_model = _get_cached_sam_model(sam_model_type, resolved_checkpoint, resolved_device)
|
|
merged_mask, preview = _run_interactive_editor_node(
|
|
sam_model=sam_model,
|
|
image=image,
|
|
editor_state=normalized_state,
|
|
preview_opacity=float(preview_opacity),
|
|
merge_threshold=float(merge_threshold),
|
|
)
|
|
|
|
result = (
|
|
merged_mask,
|
|
preview,
|
|
_editor_state_to_json(normalized_state),
|
|
_editor_state_target_count(normalized_state),
|
|
)
|
|
return {"ui": {"images": ui_images, "has_masks": [True]}, "result": result}
|
|
|
|
|
|
class MatAnyoneMatte:
|
|
DESCRIPTION = "Propagates the first-frame mask through the clip and returns foreground, alpha, and preview outputs."
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"matanyone_model": ("MATANYONE_MODEL",),
|
|
"images": ("IMAGE",),
|
|
"first_frame_mask": ("MASK",),
|
|
"warmup_iterations": ("INT", {"default": 10, "min": 0, "max": 128}),
|
|
"mask_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"invert_mask": ("BOOLEAN", {"default": False}),
|
|
"erode_kernel": ("INT", {"default": 10, "min": 0, "max": 256}),
|
|
"dilate_kernel": ("INT", {"default": 10, "min": 0, "max": 256}),
|
|
"max_internal_size": ("INT", {"default": -1, "min": -1, "max": 4096}),
|
|
"preview_red": ("INT", {"default": 120, "min": 0, "max": 255}),
|
|
"preview_green": ("INT", {"default": 255, "min": 0, "max": 255}),
|
|
"preview_blue": ("INT", {"default": 155, "min": 0, "max": 255}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
|
RETURN_NAMES = ("foreground", "alpha", "preview")
|
|
FUNCTION = "run"
|
|
CATEGORY = MATANYONE_CATEGORY
|
|
|
|
def run(
|
|
self,
|
|
matanyone_model: Dict[str, Any],
|
|
images: torch.Tensor,
|
|
first_frame_mask: torch.Tensor,
|
|
warmup_iterations: int,
|
|
mask_threshold: float,
|
|
invert_mask: bool,
|
|
erode_kernel: int,
|
|
dilate_kernel: int,
|
|
max_internal_size: int,
|
|
preview_red: int,
|
|
preview_green: int,
|
|
preview_blue: int,
|
|
):
|
|
_require_matanyone()
|
|
|
|
from matanyone2.inference.inference_core import InferenceCore
|
|
|
|
frames = _prepare_image_batch(images)
|
|
frame_count, height, width, _ = frames.shape
|
|
if frame_count == 0:
|
|
raise ValueError("No frames were provided.")
|
|
|
|
init_mask = _prepare_binary_mask(
|
|
first_frame_mask,
|
|
height=height,
|
|
width=width,
|
|
threshold=float(mask_threshold),
|
|
invert_mask=bool(invert_mask),
|
|
erode_kernel=int(erode_kernel),
|
|
dilate_kernel=int(dilate_kernel),
|
|
)
|
|
|
|
model = matanyone_model["model"]
|
|
device = matanyone_model["device"]
|
|
|
|
processor = InferenceCore(model, cfg=model.cfg, device=device)
|
|
processor.max_internal_size = int(max_internal_size)
|
|
|
|
frame_tensors = frames.permute(0, 3, 1, 2).contiguous()
|
|
first_frame = frame_tensors[0].to(device)
|
|
mask_tensor = init_mask.to(device)
|
|
|
|
progress_bar = _get_progress_bar(frame_count)
|
|
alphas: List[torch.Tensor] = []
|
|
|
|
with torch.inference_mode():
|
|
_check_interrupt()
|
|
processor.step(first_frame, mask_tensor, objects=[1])
|
|
|
|
output_prob = None
|
|
for _ in range(int(warmup_iterations) + 1):
|
|
output_prob = processor.step(first_frame, first_frame_pred=True)
|
|
|
|
if output_prob is None:
|
|
raise RuntimeError("MatAnyone warmup did not produce an alpha matte.")
|
|
|
|
first_alpha = processor.output_prob_to_mask(output_prob).detach().float().cpu().clamp(0.0, 1.0)
|
|
alphas.append(first_alpha)
|
|
_update_progress(progress_bar, 1, frame_count)
|
|
|
|
for frame_index in range(1, frame_count):
|
|
_check_interrupt()
|
|
output_prob = processor.step(frame_tensors[frame_index].to(device))
|
|
alpha = processor.output_prob_to_mask(output_prob).detach().float().cpu().clamp(0.0, 1.0)
|
|
alphas.append(alpha)
|
|
_update_progress(progress_bar, frame_index + 1, frame_count)
|
|
|
|
alpha_batch = torch.stack(alphas, dim=0).contiguous()
|
|
alpha_rgb = alpha_batch.unsqueeze(-1)
|
|
foreground = frames * alpha_rgb
|
|
|
|
preview_bg = torch.tensor(
|
|
[preview_red, preview_green, preview_blue],
|
|
dtype=frames.dtype,
|
|
).view(1, 1, 1, 3) / 255.0
|
|
preview = foreground + preview_bg * (1.0 - alpha_rgb)
|
|
|
|
_soft_empty_cache()
|
|
return (foreground, alpha_batch, preview)
|
|
|
|
|
|
try:
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
except Exception:
|
|
PromptServer = None
|
|
else:
|
|
@PromptServer.instance.routes.post("/matanyone2/interactive/create_session")
|
|
async def matanyone_create_session(request):
|
|
try:
|
|
payload = await request.json()
|
|
session = _create_interactive_sam_session(
|
|
image_data=str(payload.get("image_data", "")),
|
|
sam_model_type=str(payload.get("sam_model_type", "vit_h")),
|
|
checkpoint_path=str(payload.get("checkpoint_path", "")),
|
|
device=str(payload.get("device", "auto")),
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"session_id": session["id"],
|
|
"width": session["width"],
|
|
"height": session["height"],
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
|
|
|
|
@PromptServer.instance.routes.post("/matanyone2/interactive/predict")
|
|
async def matanyone_predict_interactive(request):
|
|
try:
|
|
payload = await request.json()
|
|
session = _get_interactive_sam_session(str(payload.get("session_id", "")))
|
|
editor_state = _normalize_editor_state(payload.get("editor_state"))
|
|
preview_opacity = float(payload.get("preview_opacity", 0.65))
|
|
image_batch = _numpy_image_to_tensor(session["image_np"])
|
|
|
|
with session["lock"]:
|
|
prediction = _predict_masks_for_editor_targets(
|
|
predictor=session["predictor"],
|
|
image_batch=image_batch,
|
|
editor_state=editor_state,
|
|
preview_opacity=preview_opacity,
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"state": editor_state,
|
|
"preview_url": _encode_png_data_url(_image_tensor_to_numpy(prediction["preview"])),
|
|
"active_candidates": prediction["active_candidates"],
|
|
"target_count": _editor_state_target_count(editor_state),
|
|
"point_count": _editor_state_prompt_count(editor_state),
|
|
}
|
|
)
|
|
except KeyError as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
|
|
|
|
@PromptServer.instance.routes.post("/matanyone2/interactive/close_session")
|
|
async def matanyone_close_session(request):
|
|
try:
|
|
payload = await request.json()
|
|
_close_interactive_sam_session(str(payload.get("session_id", "")))
|
|
return web.json_response({"ok": True})
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=400)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"MatAnyoneModelLoader": MatAnyoneModelLoader,
|
|
"MatAnyoneSAMLoader": MatAnyoneSAMLoader,
|
|
"MatAnyoneSliceFrames": MatAnyoneSliceFrames,
|
|
"MatAnyoneSelectFrame": MatAnyoneSelectFrame,
|
|
"MatAnyonePromptStart": MatAnyonePromptStart,
|
|
"MatAnyonePromptFromText": MatAnyonePromptFromText,
|
|
"MatAnyoneAddPoint": MatAnyoneAddPoint,
|
|
"MatAnyoneSAMRefine": MatAnyoneSAMRefine,
|
|
"MatAnyoneMergeMasks": MatAnyoneMergeMasks,
|
|
"MatAnyonePreviewMasks": MatAnyonePreviewMasks,
|
|
"MatAnyoneInteractiveSAM": MatAnyoneInteractiveSAM,
|
|
"MatAnyoneMatte": MatAnyoneMatte,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"MatAnyoneModelLoader": "MatAnyone2 Model Loader",
|
|
"MatAnyoneSAMLoader": "MatAnyone2 SAM Loader",
|
|
"MatAnyoneSliceFrames": "MatAnyone2 Slice Frames",
|
|
"MatAnyoneSelectFrame": "MatAnyone2 Select Frame",
|
|
"MatAnyonePromptStart": "MatAnyone2 Prompt Start",
|
|
"MatAnyonePromptFromText": "MatAnyone2 Prompt From Text",
|
|
"MatAnyoneAddPoint": "MatAnyone2 Add Point",
|
|
"MatAnyoneSAMRefine": "MatAnyone2 SAM Refine",
|
|
"MatAnyoneMergeMasks": "MatAnyone2 Merge Masks",
|
|
"MatAnyonePreviewMasks": "MatAnyone2 Preview Masks",
|
|
"MatAnyoneInteractiveSAM": "MatAnyone2 Interactive SAM",
|
|
"MatAnyoneMatte": "MatAnyone2 Matte",
|
|
}
|