1636 lines
71 KiB
Python
1636 lines
71 KiB
Python
import numpy as np
|
||
from PIL import Image, ImageOps, ImageDraw, ImageFilter, ImageEnhance, ImageCms
|
||
from PIL.PngImagePlugin import PngInfo
|
||
import torch
|
||
import torch.nn.functional as F
|
||
import json
|
||
import random
|
||
import os
|
||
import re
|
||
import urllib.request, pathlib, shutil, tempfile
|
||
from typing import Dict, Any, List
|
||
import importlib.util
|
||
import sys
|
||
|
||
|
||
# Use importlib to probe for the optional global `config` module without triggering static
|
||
# import-time linter errors when it does not exist in the current environment.
|
||
_cfg_spec = importlib.util.find_spec("config")
|
||
if _cfg_spec is not None:
|
||
_cfg = importlib.util.module_from_spec(_cfg_spec) # type: ignore[var-annotated]
|
||
_cfg_spec.loader.exec_module(_cfg) # type: ignore[arg-type]
|
||
CONTEXT_THUMBNAIL_SIZE = getattr(_cfg, "CONTEXT_THUMBNAIL_SIZE", (64, 64))
|
||
else:
|
||
CONTEXT_THUMBNAIL_SIZE = (64, 64)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Remote model download helper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Base URL for hosting – we append <family>/<version>/<filename>
|
||
_REMOTE_MODEL_BASE_URL = "https://raw.githubusercontent.com/SuperBeastsAI/SuperBeastsAI-Models/main/"
|
||
# License info for downloaded model weights
|
||
_SPCA_LICENSE_ID = "SPCA-Community-NoSaaS"
|
||
_SPCA_LICENSE_URL = (
|
||
"https://github.com/SuperBeastsAI/SuperBeastsAI-Models/"
|
||
"blob/main/SuperPopColorAdjustment/LICENSE.txt"
|
||
)
|
||
# File written into models dir after first download so we don't spam the console
|
||
_SPCA_LICENSE_STUB_FILENAME = "SPCA_LICENSE_STUB.txt"
|
||
# Env var: set to "1" (or any truthy string) to suppress the banner (CI / headless automation)
|
||
_SUPERBEASTS_SILENCE_LICENSE_ENV = "SUPERBEASTS_SILENCE_LICENSE"
|
||
|
||
|
||
# Registry of published model families → version → filename
|
||
_MODEL_REGISTRY: dict[str, dict[str, str]] = {
|
||
"SuperPopColorAdjustment": {
|
||
# Default to safetensors weights. Companion TorchScript (.pt) and
|
||
"v1.0": "SuperBeasts_ColorAdjustment_512px_V1.onnx",
|
||
"v2.0": "SuperBeasts_ColorAdjustment_512px_V2.safetensors",
|
||
},
|
||
# Future models / versions can be added here
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Known SHA-256 digests for published weight files. This allows us to verify
|
||
# that a downloaded (or pre-existing) file has not been tampered with. When
|
||
# a file's entry is missing we skip verification so development models can be
|
||
# iterated quickly – but **release** models **should always** have a digest.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_MODEL_SHA256: dict[str, str] = {
|
||
"SuperBeasts_ColorAdjustment_512px_V1.onnx": "a813fbb091dc7849f460f17e54c70cc6e6ef2984589a0ae94ab308d2957dc4ad",
|
||
"SuperBeasts_ColorAdjustment_512px_V2.safetensors": "1a7e0aaa54d565b827c619a452bdeb04fd12975178b39560b92fe34cb839a065",
|
||
}
|
||
|
||
# Helper – compute sha256 digest of a file (hex string)
|
||
def _sha256_of_file(path: str, chunk: int = 1 << 20) -> str:
|
||
import hashlib
|
||
h = hashlib.sha256()
|
||
with open(path, "rb") as f:
|
||
while True:
|
||
blk = f.read(chunk)
|
||
if not blk:
|
||
break
|
||
h.update(blk)
|
||
return h.hexdigest()
|
||
|
||
# Verify checksum if we have one for this filename.
|
||
def _verify_model_checksum(path: str):
|
||
fn = os.path.basename(path)
|
||
expected = _MODEL_SHA256.get(fn)
|
||
if not expected:
|
||
# No checksum known – skip (development weight)
|
||
return
|
||
actual = _sha256_of_file(path)
|
||
if actual != expected:
|
||
raise RuntimeError(
|
||
f"Checksum mismatch for '{fn}'.\nExpected: {expected}\nActual: {actual}\n"
|
||
"The file appears to be corrupted or has been tampered with. Delete it and rerun to re-download, "
|
||
"or obtain a clean copy from the official model repository."
|
||
)
|
||
|
||
|
||
def _latest_version(family: str) -> str:
|
||
versions = list(_MODEL_REGISTRY.get(family, {}).keys())
|
||
if not versions:
|
||
raise ValueError(f"Unknown model family: {family}")
|
||
# assume semantic ‘vX.Y' strings – sort lexically
|
||
return sorted(versions)[-1]
|
||
|
||
|
||
def _download_remote_model(rel_path: str, dest_path: str):
|
||
"""Download file at *rel_path* (relative to base URL) to *dest_path*.
|
||
Emits a one-time license banner and writes a stub file so we don't repeat warnings.
|
||
"""
|
||
url = _REMOTE_MODEL_BASE_URL + rel_path
|
||
print(f"[SuperBeasts] Downloading model weights from {url} …")
|
||
|
||
tmp_dir = tempfile.mkdtemp()
|
||
tmp_file = os.path.join(tmp_dir, rel_path.split('/')[-1] + ".part")
|
||
|
||
try:
|
||
# --- download file ---
|
||
with urllib.request.urlopen(url) as r, open(tmp_file, "wb") as f:
|
||
shutil.copyfileobj(r, f)
|
||
|
||
# --- move into place ---
|
||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||
shutil.move(tmp_file, dest_path)
|
||
print(f"[SuperBeasts] Saved weights to {dest_path}")
|
||
|
||
# --- after successful download, print license notice (once per repo install) ---
|
||
_maybe_print_spca_license_notice(os.path.dirname(dest_path))
|
||
|
||
finally:
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||
|
||
|
||
# Import helper to add extra channels same way training did
|
||
try:
|
||
from .extras.inference.inference_utils import add_extra_color_channels_np as _add_extra_color_channels_np # type: ignore
|
||
except Exception:
|
||
_add_extra_color_channels_np = None # will define below
|
||
|
||
# ------------------------------------------------------------------
|
||
# Ensure we always add the extra YCbCr (+3) and Saturation (+1) channels so
|
||
# the input tensor matches the 7-channel format expected by the SuperBeasts
|
||
# models. The helper below mirrors the logic from inference_utils but forces
|
||
# the extras on regardless of global config flags.
|
||
# ------------------------------------------------------------------
|
||
|
||
try:
|
||
import cv2 # Optional – used for fast RGB→HSV conversion
|
||
_HAS_CV2 = True
|
||
except ImportError: # linter environments might not have it
|
||
_HAS_CV2 = False
|
||
|
||
def _sb_add_extra_channels(arr: np.ndarray) -> np.ndarray:
|
||
"""Return RGB array with YCbCr and saturation channels appended (total 7)."""
|
||
if arr.ndim != 3 or arr.shape[2] != 3:
|
||
# Already has extra channels or malformed – return unchanged
|
||
return arr
|
||
|
||
r = arr[:, :, 0]
|
||
g = arr[:, :, 1]
|
||
b = arr[:, :, 2]
|
||
|
||
# YCbCr as in training
|
||
y = 0.299 * r + 0.587 * g + 0.114 * b
|
||
cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 0.5
|
||
cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 0.5
|
||
|
||
ycbcr = np.stack([y, cb, cr], axis=-1).astype(arr.dtype)
|
||
|
||
# Saturation channel – use OpenCV if available, otherwise approximate
|
||
if _HAS_CV2:
|
||
hsv = cv2.cvtColor((arr * 255.0).astype(np.uint8), cv2.COLOR_RGB2HSV)
|
||
sat = (hsv[:, :, 1] / 255.0).astype(arr.dtype)
|
||
else:
|
||
# Simple saturation approximation without HSV conversion
|
||
max_rgb = arr.max(axis=2)
|
||
min_rgb = arr.min(axis=2)
|
||
sat = np.where(max_rgb == 0, 0.0, (max_rgb - min_rgb) / max_rgb).astype(arr.dtype)
|
||
sat = sat[:, :, np.newaxis]
|
||
|
||
return np.concatenate([arr, ycbcr, sat], axis=-1).astype(arr.dtype)
|
||
|
||
# If we imported the original util and it already produces ≥7 channels we keep it.
|
||
if _add_extra_color_channels_np is None:
|
||
_add_extra_color_channels_np = _sb_add_extra_channels
|
||
else:
|
||
# Wrap the existing util so we guarantee at least 7 channels.
|
||
_orig_fn = _add_extra_color_channels_np
|
||
|
||
def _add_extra_color_channels_np(arr): # type: ignore[override]
|
||
out = _orig_fn(arr)
|
||
if out.shape[2] < 7:
|
||
out = _sb_add_extra_channels(out)
|
||
return out
|
||
|
||
# ---------------------------------------------
|
||
# SB MODEL LOADER NODE
|
||
# ---------------------------------------------
|
||
|
||
def _maybe_print_spca_license_notice(models_dir: str):
|
||
"""
|
||
Print the SPCA license banner once per install.
|
||
We create a stub file in the models_dir so we don't repeat on future runs.
|
||
Suppress by setting env var SUPERBEASTS_SILENCE_LICENSE=1.
|
||
"""
|
||
if os.environ.get(_SUPERBEASTS_SILENCE_LICENSE_ENV, "").strip():
|
||
return
|
||
|
||
stub_path = os.path.join(models_dir, _SPCA_LICENSE_STUB_FILENAME)
|
||
if os.path.exists(stub_path):
|
||
# Already shown
|
||
return
|
||
|
||
banner = (
|
||
"\n"
|
||
"============================================================\n"
|
||
" Super Pop Color Adjustment – Model License Notice\n"
|
||
"------------------------------------------------------------\n"
|
||
f" Weights licensed under {_SPCA_LICENSE_ID}.\n"
|
||
" Personal / client / local use OK.\n"
|
||
" Public SaaS/API redistribution requires a license.\n"
|
||
f" Full terms: {_SPCA_LICENSE_URL}\n"
|
||
"============================================================\n"
|
||
)
|
||
print(banner, file=sys.stderr)
|
||
|
||
# write stub file so we don't print again
|
||
try:
|
||
with open(stub_path, "w", encoding="utf-8") as f:
|
||
f.write(
|
||
"Super Pop Color Adjustment weights downloaded.\n"
|
||
"No public SaaS/API redistribution without license. Contact: Via DM on Instagram @SuperBeasts.AI"
|
||
f"License: {_SPCA_LICENSE_ID}\n"
|
||
f"See: {_SPCA_LICENSE_URL}\n"
|
||
)
|
||
except Exception as e:
|
||
# non-fatal; just warn
|
||
print(f"[SuperBeasts] WARNING: could not write license stub: {e}", file=sys.stderr)
|
||
|
||
|
||
def _discover_sb_models(models_dir: str):
|
||
"""Return a list of available model filenames (searches recursively)."""
|
||
if not os.path.isdir(models_dir):
|
||
return []
|
||
names = []
|
||
for root, _dirs, files in os.walk(models_dir):
|
||
for file in files:
|
||
if file.lower().endswith(('.keras', '.h5', '.safetensors', '.onnx', '.pt')):
|
||
names.append(file)
|
||
return sorted(set(names))
|
||
|
||
|
||
class SBLoadModel:
|
||
"""Load a SuperBeasts colour-adjustment model (downloads if missing)./n
|
||
|
||
Select a model from the dropdown. Use Family/Version entries (e.g., SuperPopColorAdjustment/latest)
|
||
to auto-download from the official model registry when not found locally./n
|
||
|
||
⚠ License: Downloaded weights are licensed under SPCA-Community-NoSaaS.
|
||
Local / personal / commercial use OK. SaaS/API redistribution using this model requires a license.
|
||
See: https://github.com/SuperBeastsAI/SuperBeastsAI-Models/tree/main/SuperPopColorAdjustment
|
||
"""
|
||
# Shown in ComfyUI when the user clicks the "?" icon on the node title-bar.
|
||
DESCRIPTION = __doc__
|
||
|
||
_model_cache: Dict[str, Dict[str, Any]] = {}
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
models_dir = os.path.join(os.path.dirname(__file__), 'models')
|
||
available = _discover_sb_models(models_dir)
|
||
|
||
dropdown_set = set(available)
|
||
# Add registry family/version keys so saved workflows remain valid
|
||
for fam, vers_dict in _MODEL_REGISTRY.items():
|
||
dropdown_set.add(f"{fam}/latest")
|
||
for ver in vers_dict.keys():
|
||
dropdown_set.add(f"{fam}/{ver}")
|
||
|
||
dropdown = sorted(dropdown_set)
|
||
return {
|
||
"required": {
|
||
"model_key": (dropdown, ),
|
||
"device": (["AUTO", "CPU", "GPU"], {"default": "AUTO"}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("SBMODEL", )
|
||
RETURN_NAMES = ("model", )
|
||
FUNCTION = "load"
|
||
CATEGORY = "SuperBeastsAI/Model"
|
||
|
||
def _parse_patch_size(self, filename: str, default: int = 512):
|
||
m = re.search(r"(\d+)px", filename)
|
||
if m:
|
||
try:
|
||
return int(m.group(1))
|
||
except ValueError:
|
||
pass
|
||
return default
|
||
|
||
def load(self, model_key: str, device: str = "AUTO"):
|
||
"""`model_key` can be either a raw filename (legacy) *or* the form
|
||
"Family/Version" (e.g. "SuperPopColorAdjustment/v1.0" or "SuperPopColorAdjustment/latest")."""
|
||
|
||
# ------------------------------------------------------------------
|
||
# Resolve model filename, family, version
|
||
# ------------------------------------------------------------------
|
||
if "/" in model_key:
|
||
family, version = model_key.split("/", 1)
|
||
if version.lower() == "latest":
|
||
version = _latest_version(family)
|
||
try:
|
||
filename = _MODEL_REGISTRY[family][version]
|
||
except KeyError:
|
||
raise ValueError(f"Unknown model/version combination: {model_key}")
|
||
else:
|
||
# Legacy direct filename path (assume SuperPop)…
|
||
family = "SuperPopColorAdjustment"
|
||
version = _latest_version(family)
|
||
filename = model_key
|
||
|
||
# Resolve model storage directories --------------------------------
|
||
models_root = os.path.join(os.path.dirname(__file__), 'models')
|
||
version_dir = os.path.join(models_root, version)
|
||
os.makedirs(version_dir, exist_ok=True)
|
||
|
||
# Look for existing file in version dir first, then root (for legacy installs)
|
||
candidate_dirs = [version_dir, models_root]
|
||
model_path = None # type: ignore[assignment]
|
||
for _dir in candidate_dirs:
|
||
cand_path = os.path.join(_dir, filename)
|
||
if os.path.isfile(cand_path):
|
||
model_path = cand_path
|
||
models_dir = _dir # use this dir for subsequent operations
|
||
break
|
||
|
||
if model_path is None:
|
||
# Default to version_dir for new downloads
|
||
models_dir = version_dir
|
||
model_path = os.path.join(models_dir, filename)
|
||
|
||
# --------------------------------------------------------------
|
||
# Prioritise *.safetensors → *.pt → *.onnx in that order. If the
|
||
# requested file is missing locally we look for any preferred
|
||
# alternative that already exists. Otherwise we attempt to
|
||
# download the best-quality format available from the official
|
||
# model registry, trying each extension until one succeeds.
|
||
# --------------------------------------------------------------
|
||
if not os.path.isfile(model_path):
|
||
base_name, _orig_ext = os.path.splitext(filename)
|
||
|
||
# Preferred search / download order
|
||
_EXT_PRIORITY = ['.safetensors', '.pt', '.onnx']
|
||
|
||
# Build ordered unique candidate list starting with preferred
|
||
cand_filenames = [base_name + ext for ext in _EXT_PRIORITY]
|
||
if filename not in cand_filenames:
|
||
# Include the originally requested filename at front if it
|
||
# does not conform to the priority list (for backwards compatibility)
|
||
cand_filenames.insert(0, filename)
|
||
|
||
# 1) Check for any existing local file following priority
|
||
model_found = False
|
||
for cand in cand_filenames:
|
||
cand_path = os.path.join(models_dir, cand)
|
||
if os.path.isfile(cand_path):
|
||
filename = cand
|
||
model_path = cand_path
|
||
model_found = True
|
||
break
|
||
|
||
# 2) Attempt remote download for each preferred extension
|
||
if not model_found:
|
||
last_exc = None
|
||
for cand in cand_filenames:
|
||
rel_path = f"{family}/{version}/{cand}"
|
||
cand_path = os.path.join(models_dir, cand)
|
||
try:
|
||
_download_remote_model(rel_path, cand_path)
|
||
filename = cand
|
||
model_path = cand_path
|
||
model_found = True
|
||
break
|
||
except Exception as e:
|
||
last_exc = e # keep last for context
|
||
continue
|
||
|
||
if not model_found:
|
||
raise FileNotFoundError(
|
||
f"Model file not found locally and automatic download failed for any supported extension (.safetensors/.pt/.onnx).\nLast error: {last_exc}"
|
||
)
|
||
else:
|
||
# File exists locally; nothing to do.
|
||
pass
|
||
|
||
# --------------------------------------------------------------
|
||
# Ensure both *.safetensors and companion *.pt files are present
|
||
# (useful for users who switch between Torch and TorchScript).
|
||
# --------------------------------------------------------------
|
||
base_name, _ = os.path.splitext(filename)
|
||
for _ext in ('.safetensors', '.pt'):
|
||
_comp_path = os.path.join(models_dir, base_name + _ext)
|
||
if not os.path.isfile(_comp_path):
|
||
try:
|
||
_download_remote_model(f"{family}/{version}/{base_name + _ext}", _comp_path)
|
||
except Exception:
|
||
# It's OK if one of the companion formats is missing from the
|
||
# remote registry – we just skip silently.
|
||
pass
|
||
|
||
# --------------------------------------------------------------
|
||
# Attempt to download optional metadata JSON (licence, provenance).
|
||
# The file is named <base>_MODEL_INFO.json in the remote repo.
|
||
# --------------------------------------------------------------
|
||
_meta_filename = f"{base_name}_MODEL_INFO.json"
|
||
_meta_path = os.path.join(models_dir, _meta_filename)
|
||
if not os.path.isfile(_meta_path):
|
||
try:
|
||
# Remote repo stores a generic MODEL_INFO.json per version.
|
||
_download_remote_model(f"{family}/{version}/MODEL_INFO.json", _meta_path)
|
||
except Exception:
|
||
# Non-critical: metadata missing – warn once.
|
||
print(f"[SuperBeasts] Warning: metadata file not found for {_meta_filename}")
|
||
|
||
# --- Security: check SHA-256 digest (raises if mismatch) ---
|
||
try:
|
||
_verify_model_checksum(model_path)
|
||
except Exception as _ck_err:
|
||
# Surface early so users see a clear error in ComfyUI
|
||
raise
|
||
|
||
# Use cache if possible
|
||
if model_path in self._model_cache:
|
||
return (self._model_cache[model_path], )
|
||
|
||
# We defer importing onnxruntime unless we end up loading an ONNX model
|
||
ort = None # type: ignore[assignment]
|
||
|
||
# Optionally set device context
|
||
dev_ctx = None
|
||
if device == "GPU":
|
||
dev_ctx = "/GPU:0"
|
||
elif device == "CPU":
|
||
dev_ctx = "/CPU:0"
|
||
|
||
# Determine patch size from filename
|
||
patch_size = self._parse_patch_size(filename)
|
||
|
||
# --------------------------------------------------------------
|
||
# 1. TorchScript checkpoint – no external architecture required
|
||
# --------------------------------------------------------------
|
||
if model_path.lower().endswith('.pt'):
|
||
import torch
|
||
|
||
device_str = 'cuda' if device == 'GPU' and torch.cuda.is_available() else 'cpu'
|
||
|
||
try:
|
||
model_pt = torch.jit.load(model_path, map_location=device_str)
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to load TorchScript model: {e}")
|
||
|
||
model_pt.eval()
|
||
|
||
model_info = {
|
||
"model": model_pt,
|
||
"device": device_str,
|
||
"patch_size": patch_size,
|
||
"type": "torchscript",
|
||
}
|
||
|
||
# --------------------------------------------------------------
|
||
# 2. Safetensors + Python architecture (requires contextual_residual_unet_v4.py)
|
||
# --------------------------------------------------------------
|
||
elif model_path.lower().endswith('.safetensors'):
|
||
import torch
|
||
from safetensors.torch import load_file as safe_load
|
||
import importlib.util, sys, pathlib
|
||
model_dir = pathlib.Path(__file__).parent / 'torch_models'
|
||
model_dir.mkdir(exist_ok=True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Ensure architecture implementation file is present – download on
|
||
# demand if missing so repo stays lean for users who only use ONNX.
|
||
# ------------------------------------------------------------------
|
||
arch_filename = 'contextual_residual_unet_v4_pt.py'
|
||
module_path = (model_dir / arch_filename).resolve()
|
||
if not module_path.exists():
|
||
try:
|
||
rel_path_arch = f"{family}/TorchModel/{arch_filename}"
|
||
_download_remote_model(rel_path_arch, str(module_path))
|
||
except Exception as e:
|
||
raise RuntimeError(
|
||
"Required architecture file was not found locally and automatic download failed.\n" + str(e)
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Safety guard – ensure we never import a file outside our plugin dir
|
||
# even if the relative path is somehow manipulated.
|
||
# ------------------------------------------------------------------
|
||
|
||
allowed_root = model_dir.resolve()
|
||
if not str(module_path).startswith(str(allowed_root)):
|
||
raise RuntimeError("Blocked unsafe module import: path escapes plugin directory")
|
||
|
||
sys.path.append(str(model_dir))
|
||
spec = importlib.util.spec_from_file_location('contextual_residual_unet_v4_pt', module_path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module) # type: ignore
|
||
ContextualResidualUNetV4Torch = module.ContextualResidualUNetV4Torch
|
||
|
||
device_str = 'cuda' if device == 'GPU' and torch.cuda.is_available() else 'cpu'
|
||
|
||
# Prefer TorchScript if it exists next to the safetensors
|
||
base_no_ext = os.path.splitext(model_path)[0]
|
||
pt_path_candidates = [base_no_ext + '.pt', base_no_ext + '_scripted.pt']
|
||
pt_path = next((p for p in pt_path_candidates if os.path.isfile(p)), None)
|
||
if pt_path and os.path.isfile(pt_path):
|
||
try:
|
||
model_pt = torch.jit.load(pt_path, map_location=device_str)
|
||
model_pt.eval()
|
||
except Exception as e:
|
||
raise RuntimeError(f"Failed to load TorchScript companion file {pt_path}: {e}")
|
||
else:
|
||
# Fall back to python architecture + direct state_dict load (legacy path)
|
||
model_pt = ContextualResidualUNetV4Torch()
|
||
|
||
state_in = safe_load(model_path, device='cpu')
|
||
try:
|
||
model_pt.load_state_dict(state_in, strict=True)
|
||
except Exception as e:
|
||
raise RuntimeError("Checkpoint did not match architecture and no .pt file found. "
|
||
"Convert the model to TorchScript to avoid mismatches.\n" + str(e))
|
||
|
||
model_pt.eval()
|
||
|
||
model_info = {
|
||
"model": model_pt,
|
||
"device": device_str,
|
||
"patch_size": patch_size,
|
||
"type": "torch",
|
||
}
|
||
|
||
# Load ONNX with onnxruntime (import lazily so safetensors users don't need it)
|
||
else:
|
||
if ort is None:
|
||
try:
|
||
import onnxruntime as ort # type: ignore
|
||
except Exception as e:
|
||
raise RuntimeError(
|
||
"onnxruntime is required for SuperBeasts ONNX models. Install with:\n"
|
||
" pip install onnxruntime-gpu # (or onnxruntime for CPU)\n\n" + str(e)
|
||
)
|
||
|
||
providers: List[str]
|
||
avail_prov = ort.get_available_providers()
|
||
print(f"[SuperBeasts] ONNX Runtime providers available: {avail_prov}")
|
||
cuda_available = 'CUDAExecutionProvider' in avail_prov
|
||
dml_available = 'DmlExecutionProvider' in avail_prov
|
||
|
||
chosen_provider = 'CPUExecutionProvider'
|
||
|
||
if device == "GPU":
|
||
if cuda_available:
|
||
chosen_provider = 'CUDAExecutionProvider'
|
||
elif dml_available:
|
||
chosen_provider = 'DmlExecutionProvider'
|
||
elif device == "AUTO":
|
||
if cuda_available:
|
||
chosen_provider = 'CUDAExecutionProvider'
|
||
elif dml_available:
|
||
chosen_provider = 'DmlExecutionProvider'
|
||
|
||
if chosen_provider != 'CPUExecutionProvider':
|
||
providers = [chosen_provider, 'CPUExecutionProvider']
|
||
else:
|
||
providers = ['CPUExecutionProvider']
|
||
|
||
print(f"[SuperBeasts] Using ORT providers: {providers}")
|
||
|
||
# Create session
|
||
session_opts = ort.SessionOptions()
|
||
session_opts.log_severity_level = 3 # suppress info logs
|
||
session = ort.InferenceSession(model_path, session_options=session_opts, providers=providers)
|
||
|
||
input_names = [i.name for i in session.get_inputs()]
|
||
|
||
model_info = {
|
||
"session": session,
|
||
"input_names": input_names,
|
||
"patch_size": patch_size,
|
||
"path": model_path,
|
||
"type": "onnx",
|
||
}
|
||
|
||
# Attach metadata path if we fetched it earlier
|
||
if '_meta_path' in locals() and os.path.isfile(_meta_path):
|
||
model_info["metadata_path"] = _meta_path
|
||
|
||
# Cache and return
|
||
self._model_cache[model_path] = model_info
|
||
return (model_info, )
|
||
|
||
|
||
sRGB_profile = ImageCms.createProfile("sRGB")
|
||
Lab_profile = ImageCms.createProfile("LAB")
|
||
|
||
# Tensor to PIL
|
||
def tensor2pil(image):
|
||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||
|
||
# PIL to Tensor
|
||
def pil2tensor(image):
|
||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||
|
||
|
||
def adjust_shadows(luminance_array, shadow_intensity, hdr_intensity):
|
||
# Darken shadows more as shadow_intensity increases, scaled by hdr_intensity
|
||
return np.clip(luminance_array - luminance_array * shadow_intensity * hdr_intensity * 0.5, 0, 255)
|
||
|
||
def adjust_highlights(luminance_array, highlight_intensity, hdr_intensity):
|
||
# Brighten highlights more as highlight_intensity increases, scaled by hdr_intensity
|
||
return np.clip(luminance_array + (255 - luminance_array) * highlight_intensity * hdr_intensity * 0.5, 0, 255)
|
||
|
||
def apply_adjustment(base, factor, intensity_scale):
|
||
"""Apply positive adjustment scaled by intensity."""
|
||
# Ensure the adjustment increases values within [0, 1] range, scaling by intensity
|
||
adjustment = base + (base * factor * intensity_scale)
|
||
# Ensure adjustment stays within bounds
|
||
return np.clip(adjustment, 0, 1)
|
||
|
||
|
||
def multiply_blend(base, blend):
|
||
"""Multiply blend mode."""
|
||
return np.clip(base * blend, 0, 255)
|
||
|
||
def overlay_blend(base, blend):
|
||
"""Overlay blend mode."""
|
||
# Normalize base and blend to [0, 1] for blending calculation
|
||
base = base / 255.0
|
||
blend = blend / 255.0
|
||
return np.where(base < 0.5, 2 * base * blend, 1 - 2 * (1 - base) * (1 - blend)) * 255
|
||
|
||
def adjust_shadows_non_linear(luminance, shadow_intensity, max_shadow_adjustment=1.5):
|
||
lum_array = np.array(luminance, dtype=np.float32) / 255.0 # Normalize
|
||
# Apply a non-linear darkening effect based on shadow_intensity
|
||
shadows = lum_array ** (1 / (1 + shadow_intensity * max_shadow_adjustment))
|
||
return np.clip(shadows * 255, 0, 255).astype(np.uint8) # Re-scale to [0, 255]
|
||
|
||
def adjust_highlights_non_linear(luminance, highlight_intensity, max_highlight_adjustment=1.5):
|
||
lum_array = np.array(luminance, dtype=np.float32) / 255.0 # Normalize
|
||
# Brighten highlights more aggressively based on highlight_intensity
|
||
highlights = 1 - (1 - lum_array) ** (1 + highlight_intensity * max_highlight_adjustment)
|
||
return np.clip(highlights * 255, 0, 255).astype(np.uint8) # Re-scale to [0, 255]
|
||
|
||
def merge_adjustments_with_blend_modes(luminance, shadows, highlights, hdr_intensity, shadow_intensity, highlight_intensity):
|
||
# Ensure the data is in the correct format for processing
|
||
base = np.array(luminance, dtype=np.float32)
|
||
|
||
# Scale the adjustments based on hdr_intensity
|
||
scaled_shadow_intensity = shadow_intensity ** 2 * hdr_intensity
|
||
scaled_highlight_intensity = highlight_intensity ** 2 * hdr_intensity
|
||
|
||
# Create luminance-based masks for shadows and highlights
|
||
shadow_mask = np.clip((1 - (base / 255)) ** 2, 0, 1)
|
||
highlight_mask = np.clip((base / 255) ** 2, 0, 1)
|
||
|
||
# Apply the adjustments using the masks
|
||
adjusted_shadows = np.clip(base * (1 - shadow_mask * scaled_shadow_intensity), 0, 255)
|
||
adjusted_highlights = np.clip(base + (255 - base) * highlight_mask * scaled_highlight_intensity, 0, 255)
|
||
|
||
# Combine the adjusted shadows and highlights
|
||
adjusted_luminance = np.clip(adjusted_shadows + adjusted_highlights - base, 0, 255)
|
||
|
||
# Blend the adjusted luminance with the original luminance based on hdr_intensity
|
||
final_luminance = np.clip(base * (1 - hdr_intensity) + adjusted_luminance * hdr_intensity, 0, 255).astype(np.uint8)
|
||
|
||
return Image.fromarray(final_luminance)
|
||
|
||
def apply_gamma_correction(lum_array, gamma):
|
||
"""
|
||
Apply gamma correction to the luminance array.
|
||
:param lum_array: Luminance channel as a NumPy array.
|
||
:param gamma: Gamma value for correction.
|
||
"""
|
||
if gamma == 0:
|
||
return np.clip(lum_array, 0, 255).astype(np.uint8)
|
||
|
||
epsilon = 1e-7 # Small value to avoid dividing by zero
|
||
gamma_corrected = 1 / (1.1 - gamma)
|
||
adjusted = 255 * ((lum_array / 255) ** gamma_corrected)
|
||
return np.clip(adjusted, 0, 255).astype(np.uint8)
|
||
|
||
# create a wrapper function that can apply a function to multiple images in a batch while passing all other arguments to the function
|
||
def apply_to_batch(func):
|
||
def wrapper(self, image, *args, **kwargs):
|
||
images = []
|
||
for img in image:
|
||
images.append(func(self, img, *args, **kwargs))
|
||
batch_tensor = torch.cat(images, dim=0)
|
||
return (batch_tensor, )
|
||
return wrapper
|
||
|
||
class HDREffects:
|
||
DESCRIPTION = "Apply an HDR-style tone-mapping effect with separate control over shadows, highlights, gamma, contrast and colour boost. Accepts a batch of images and returns the processed batch."
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {'required': {'image': ('IMAGE', {'default': None}),
|
||
'hdr_intensity': ('FLOAT', {'default': 0.5, 'min': 0.0, 'max': 5.0, 'step': 0.01}),
|
||
'shadow_intensity': ('FLOAT', {'default': 0.25, 'min': 0.0, 'max': 1.0, 'step': 0.01}),
|
||
'highlight_intensity': ('FLOAT', {'default': 0.75, 'min': 0.0, 'max': 1.0, 'step': 0.01}),
|
||
'gamma_intensity': ('FLOAT', {'default': 0.0, 'min': 0.0, 'max': 1.0, 'step': 0.01}),
|
||
'contrast': ('FLOAT', {'default': 0.1, 'min': 0.0, 'max': 1.0, 'step': 0.01}),
|
||
'enhance_color': ('FLOAT', {'default': 0.25, 'min': 0.0, 'max': 1.0, 'step': 0.01})
|
||
}}
|
||
|
||
RETURN_TYPES = ('IMAGE',)
|
||
RETURN_NAMES = ('result_img',)
|
||
FUNCTION = 'apply_hdr2'
|
||
CATEGORY = 'SuperBeastsAI/Image'
|
||
|
||
@apply_to_batch
|
||
def apply_hdr2(self, image, hdr_intensity=0.5, shadow_intensity=0.25, highlight_intensity=0.75, gamma_intensity=0.25, contrast=0.1, enhance_color=0.25):
|
||
# Load the image
|
||
img = tensor2pil(image)
|
||
|
||
# Step 1: Convert RGB to LAB for better color preservation
|
||
img_lab = ImageCms.profileToProfile(img, sRGB_profile, Lab_profile, outputMode='LAB')
|
||
|
||
# Extract L, A, and B channels
|
||
luminance, a, b = img_lab.split()
|
||
|
||
# Convert luminance to a NumPy array for processing
|
||
lum_array = np.array(luminance, dtype=np.float32)
|
||
|
||
# Preparing adjustment layers (shadows, midtones, highlights)
|
||
# This example assumes you have methods to extract or calculate these adjustments
|
||
shadows_adjusted = adjust_shadows_non_linear(luminance, shadow_intensity)
|
||
highlights_adjusted = adjust_highlights_non_linear(luminance, highlight_intensity)
|
||
|
||
|
||
merged_adjustments = merge_adjustments_with_blend_modes(lum_array, shadows_adjusted, highlights_adjusted, hdr_intensity, shadow_intensity, highlight_intensity)
|
||
|
||
# Apply gamma correction with a base_gamma value (define based on desired effect)
|
||
gamma_corrected = apply_gamma_correction(np.array(merged_adjustments), gamma_intensity)
|
||
gamma_corrected = Image.fromarray(gamma_corrected).resize(a.size)
|
||
|
||
|
||
# Merge L channel back with original A and B channels
|
||
adjusted_lab = Image.merge('LAB', (gamma_corrected, a, b))
|
||
|
||
# Step 3: Convert LAB back to RGB
|
||
img_adjusted = ImageCms.profileToProfile(adjusted_lab, Lab_profile, sRGB_profile, outputMode='RGB')
|
||
|
||
|
||
# Enhance contrast
|
||
enhancer = ImageEnhance.Contrast(img_adjusted)
|
||
contrast_adjusted = enhancer.enhance(1 + contrast)
|
||
|
||
|
||
# Enhance color saturation
|
||
enhancer = ImageEnhance.Color(contrast_adjusted)
|
||
color_adjusted = enhancer.enhance(1 + enhance_color * 0.2)
|
||
|
||
return pil2tensor(color_adjusted)
|
||
|
||
class MakeResizedMaskBatch:
|
||
DESCRIPTION = "Combine up to 12 individual masks/batches into one mask batch, automatically sizing/cropping each mask to the requested width/height. Useful for building consistent video masks."
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"width": ("INT", {"default": 512, "min": 1, "step": 1}),
|
||
"height": ("INT", {"default": 768, "min": 1, "step": 1}),
|
||
},
|
||
"optional": {
|
||
"mask1": ("MASK",),
|
||
"mask2": ("MASK",),
|
||
"mask3": ("MASK",),
|
||
"mask4": ("MASK",),
|
||
"mask5": ("MASK",),
|
||
"mask6": ("MASK",),
|
||
"mask7": ("MASK",),
|
||
"mask8": ("MASK",),
|
||
"mask9": ("MASK",),
|
||
"mask10": ("MASK",),
|
||
"mask11": ("MASK",),
|
||
"mask12": ("MASK",),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("MASK",)
|
||
FUNCTION = "append"
|
||
CATEGORY = "SuperBeastsAI/Masks"
|
||
|
||
def append(self, width, height, mask1=None, mask2=None, mask3=None, mask4=None, mask5=None, mask6=None,
|
||
mask7=None, mask8=None, mask9=None, mask10=None, mask11=None, mask12=None):
|
||
masks = [mask1, mask2, mask3, mask4, mask5, mask6, mask7, mask8, mask9, mask10, mask11, mask12]
|
||
valid_masks = [mask for mask in masks if mask is not None]
|
||
|
||
if not valid_masks:
|
||
raise ValueError("At least one input mask must be provided.")
|
||
|
||
cropped_masks = []
|
||
for mask in valid_masks:
|
||
if mask.ndim == 2:
|
||
mask = mask.unsqueeze(0).unsqueeze(0) # Add batch and channel dimensions
|
||
elif mask.ndim == 3:
|
||
mask = mask.unsqueeze(0) # Add batch dimension
|
||
elif mask.ndim != 4 or (mask.ndim == 4 and mask.shape[1] != 1):
|
||
raise ValueError(f"Invalid mask shape: {mask.shape}. Expected (N, 1, H, W) or (1, H, W) or (H, W).")
|
||
|
||
# Scale the mask to match the desired width while maintaining the aspect ratio
|
||
scale_factor = width / mask.shape[-1]
|
||
scaled_height = int(mask.shape[-2] * scale_factor)
|
||
scaled_mask = F.interpolate(mask, size=(scaled_height, width), mode='bilinear', align_corners=False)
|
||
|
||
# Perform center cropping
|
||
if scaled_height < height:
|
||
# Pad the top and bottom of the mask
|
||
pad_top = (height - scaled_height) // 2
|
||
pad_bottom = height - scaled_height - pad_top
|
||
cropped_mask = F.pad(scaled_mask, (0, 0, pad_top, pad_bottom), mode='constant', value=0)
|
||
else:
|
||
# Crop the center of the mask
|
||
crop_top = (scaled_height - height) // 2
|
||
crop_bottom = crop_top + height
|
||
cropped_mask = scaled_mask[:, :, crop_top:crop_bottom, :]
|
||
|
||
cropped_masks.append(cropped_mask)
|
||
|
||
# Concatenate the cropped masks along the batch dimension
|
||
result = torch.cat(cropped_masks, dim=0)
|
||
|
||
return (result,)
|
||
|
||
|
||
|
||
def adjust_brightness(image, brightness_factor):
|
||
enhancer = ImageEnhance.Brightness(image)
|
||
adjusted_image = enhancer.enhance(brightness_factor)
|
||
return adjusted_image
|
||
|
||
def calculate_brightness_factor(target_brightness, current_brightness):
|
||
return target_brightness / current_brightness
|
||
|
||
def get_average_brightness(image):
|
||
grayscale_image = image.convert("L")
|
||
histogram = grayscale_image.histogram()
|
||
pixels = sum(histogram)
|
||
brightness = scale = len(histogram)
|
||
|
||
total_brightness = sum(i * w for i, w in enumerate(histogram))
|
||
return total_brightness / pixels
|
||
|
||
def apply_dithering(image):
|
||
return image.convert("P", palette=Image.ADAPTIVE, colors=256).convert("RGB")
|
||
|
||
def apply_noise_reduction(image, strength):
|
||
return image.filter(ImageFilter.GaussianBlur(radius=strength))
|
||
|
||
def apply_gradient_smoothing(image, strength):
|
||
return image.filter(ImageFilter.SMOOTH_MORE if strength > 1 else ImageFilter.SMOOTH)
|
||
|
||
def blend_images(image1, image2, alpha):
|
||
return Image.blend(image1, image2, alpha)
|
||
|
||
class Deflicker:
|
||
DESCRIPTION = "Experimental high-level deflicker pass for video/animation. Analyses brightness across neighbouring frames and blends, denoises and smooths gradients to reduce global flicker."
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"images": ("IMAGE",),
|
||
"context_length": ("INT", {"default": 5, "min": 1, "max": 20, "step": 1}),
|
||
"brightness_threshold": ("FLOAT", {"default": 0.05, "min": 0.01, "max": 0.5, "step": 0.01}),
|
||
"blending_strength": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||
"noise_reduction_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1}),
|
||
"gradient_smoothing_strength": ("INT", {"default": 1, "min": 0, "max": 3, "step": 1}),
|
||
"batch_size": ("INT", {"default": 10, "min": 1, "max": 100, "step": 1})
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
FUNCTION = "deflicker"
|
||
CATEGORY = "SuperBeastsAI/Animation"
|
||
|
||
def deflicker(self, images, context_length=5, brightness_threshold=0.05, blending_strength=0.5,
|
||
noise_reduction_strength=1.0, gradient_smoothing_strength=1, batch_size=10):
|
||
num_frames = len(images)
|
||
adjusted_tensor = []
|
||
|
||
for i in range(0, num_frames, batch_size):
|
||
batch_images = images[i:i+batch_size]
|
||
|
||
# Convert batch tensor to a list of PIL images
|
||
pil_images = [tensor2pil(image) for image in batch_images]
|
||
|
||
adjusted_images = []
|
||
|
||
for j in range(len(pil_images)):
|
||
current_image = pil_images[j]
|
||
context_start = max(0, i + j - context_length // 2)
|
||
context_end = min(num_frames, i + j + context_length // 2 + 1)
|
||
context_images = images[context_start:context_end]
|
||
|
||
current_brightness = get_average_brightness(current_image)
|
||
context_brightnesses = [get_average_brightness(tensor2pil(img)) for img in context_images]
|
||
average_brightness = np.mean(context_brightnesses)
|
||
|
||
if abs(current_brightness - average_brightness) > brightness_threshold:
|
||
brightness_factor = calculate_brightness_factor(average_brightness, current_brightness)
|
||
adjusted_image = adjust_brightness(current_image, brightness_factor)
|
||
else:
|
||
adjusted_image = current_image
|
||
|
||
# Apply noise reduction to the adjusted image
|
||
denoised_image = apply_noise_reduction(adjusted_image, noise_reduction_strength)
|
||
|
||
# Apply gradient smoothing to the denoised image
|
||
smoothed_image = apply_gradient_smoothing(denoised_image, gradient_smoothing_strength)
|
||
|
||
# Apply dithering to the smoothed image
|
||
dithered_image = apply_dithering(smoothed_image)
|
||
|
||
# Blend the dithered image with the original image using adaptive blending
|
||
blending_alpha = min(1.0, blending_strength * (1.0 + abs(current_brightness - average_brightness)))
|
||
blended_image = blend_images(current_image, dithered_image, blending_alpha)
|
||
|
||
adjusted_images.append(blended_image)
|
||
|
||
# Convert the adjusted PIL images back to a tensor
|
||
adjusted_batch_tensor = torch.cat([pil2tensor(img) for img in adjusted_images], dim=0)
|
||
adjusted_tensor.append(adjusted_batch_tensor)
|
||
|
||
# Concatenate the adjusted batches along the first dimension
|
||
adjusted_tensor = torch.cat(adjusted_tensor, dim=0)
|
||
|
||
return (adjusted_tensor,)
|
||
|
||
|
||
def temporal_smoothing(frames, window_size):
|
||
num_frames = len(frames)
|
||
smoothed_frames = []
|
||
|
||
for i in range(num_frames):
|
||
start = max(0, i - window_size // 2)
|
||
end = min(num_frames, i + window_size // 2 + 1)
|
||
window_frames = frames[start:end]
|
||
|
||
smoothed_frame = np.mean(window_frames, axis=0)
|
||
smoothed_frames.append(smoothed_frame)
|
||
|
||
return smoothed_frames
|
||
|
||
class PixelDeflicker:
|
||
DESCRIPTION = "Experimental per-pixel temporal smoothing for animation. Operates in a sliding window to average noisy pixels while preserving detail, helping mitigate small-scale flicker."
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"images": ("IMAGE",),
|
||
"window_size": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}),
|
||
"blending_strength": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||
"batch_size": ("INT", {"default": 10, "min": 1, "max": 100, "step": 1})
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE",)
|
||
FUNCTION = "pixelDeflicker"
|
||
|
||
CATEGORY = "SuperBeastsAI/Animation"
|
||
|
||
def pixelDeflicker(self, images, window_size=5, blending_strength=0.5, batch_size=10):
|
||
num_frames = len(images)
|
||
blended_tensor = []
|
||
|
||
for i in range(0, num_frames, batch_size):
|
||
batch_images = images[i:i+batch_size]
|
||
|
||
# Convert batch tensor to a list of PIL images
|
||
pil_images = [tensor2pil(image) for image in batch_images]
|
||
|
||
# Convert PIL images to numpy arrays
|
||
numpy_frames = [np.array(img) / 255.0 for img in pil_images]
|
||
|
||
# Apply temporal smoothing to the numpy frames
|
||
smoothed_frames = temporal_smoothing(numpy_frames, window_size)
|
||
|
||
# Blend the smoothed frames with the original frames
|
||
blended_frames = [
|
||
np.clip(original * (1 - blending_strength) + smoothed * blending_strength, 0, 1)
|
||
for original, smoothed in zip(numpy_frames, smoothed_frames)
|
||
]
|
||
|
||
# Convert the blended frames back to PIL images
|
||
blended_pil_images = [Image.fromarray((frame * 255).astype(np.uint8)) for frame in blended_frames]
|
||
|
||
# Convert the blended PIL images back to a tensor
|
||
blended_batch_tensor = torch.cat([pil2tensor(img) for img in blended_pil_images], dim=0)
|
||
|
||
blended_tensor.append(blended_batch_tensor)
|
||
|
||
# Concatenate the blended batches along the first dimension
|
||
blended_tensor = torch.cat(blended_tensor, dim=0)
|
||
|
||
return (blended_tensor,)
|
||
|
||
def resize_and_crop(pil_img, target_width, target_height):
|
||
"""Resize and crop an image to fit exactly the specified dimensions."""
|
||
original_width, original_height = pil_img.size
|
||
aspect_ratio = original_width / original_height
|
||
target_aspect_ratio = target_width / target_height
|
||
|
||
if target_aspect_ratio > aspect_ratio:
|
||
# Target is wider than the image
|
||
scale_factor = target_width / original_width
|
||
scaled_height = int(original_height * scale_factor)
|
||
scaled_width = target_width
|
||
else:
|
||
# Target is taller than the image
|
||
scale_factor = target_height / original_height
|
||
scaled_height = target_height
|
||
scaled_width = int(original_width * scale_factor)
|
||
|
||
# Resize the image
|
||
resized_img = pil_img.resize((scaled_width, scaled_height), Image.BILINEAR)
|
||
|
||
# Crop the image
|
||
if scaled_width != target_width or scaled_height != target_height:
|
||
left = (scaled_width - target_width) // 2
|
||
top = (scaled_height - target_height) // 2
|
||
right = left + target_width
|
||
bottom = top + target_height
|
||
cropped_img = resized_img.crop((left, top, right, bottom))
|
||
else:
|
||
cropped_img = resized_img
|
||
|
||
return cropped_img
|
||
|
||
class ImageBatchManagement:
|
||
DESCRIPTION = "Resize, crop, limit, optionally shuffle or manually reorder an image batch so all frames match the specified resolution and sequence order. Returns the new batch and a CSV string listing filenames."
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"width": ("INT", {"default": 512, "order": 1}),
|
||
"height": ("INT", {"default": 768}),
|
||
"max_images": ("INT", {"default": 10}), # New INT input for maximum number of images
|
||
"random_order": ("BOOLEAN", {"default": False})
|
||
},
|
||
"optional": {
|
||
"new_manual_order": ("STRING", {"default": ""}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "STRING")
|
||
FUNCTION = "reorder"
|
||
CATEGORY = "SuperBeastsAI/Image"
|
||
|
||
def reorder(self, width, height, random_order, max_images, **kwargs):
|
||
images = [kwargs["image1"]] # Start with the required image1 input
|
||
|
||
i = 2
|
||
while f"image{i}" in kwargs:
|
||
images.append(kwargs[f"image{i}"])
|
||
i += 1
|
||
|
||
if max_images is not None:
|
||
images = images[:max_images]
|
||
|
||
# Default order_output if new_manual_order isn't provided or is empty
|
||
order_output = ",".join(str(idx + 1) for idx in range(len(images)))
|
||
|
||
# Retrieve and apply new_manual_order if it exists
|
||
if 'new_manual_order' in kwargs and kwargs['new_manual_order']:
|
||
order_indices = [int(idx) - 1 for idx in kwargs['new_manual_order'].split(',') if idx.strip()]
|
||
images = [images[idx] for idx in order_indices if idx < len(images)]
|
||
order_output = kwargs['new_manual_order']
|
||
|
||
processed_images = []
|
||
for img in images:
|
||
pil_img = tensor2pil(img)
|
||
resized_cropped_img = resize_and_crop(pil_img, width, height)
|
||
img_tensor = pil2tensor(resized_cropped_img)
|
||
processed_images.append(img_tensor)
|
||
|
||
result = torch.cat(processed_images, dim=0) if processed_images else torch.empty(0, 3, height, width)
|
||
return (result, order_output)
|
||
|
||
|
||
class MaskBatchManagement:
|
||
DESCRIPTION = "Resize, crop and re-order a batch of masks so they all share the same dimensions, or to match a new manual order list."
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"width": ("INT", {"default": 512}),
|
||
"height": ("INT", {"default": 768})
|
||
},
|
||
"optional": {
|
||
"new_order": ("STRING", {"default": ""}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("MASK",)
|
||
FUNCTION = "append"
|
||
CATEGORY = "SuperBeastsAI/Masks"
|
||
|
||
def append(self, width, height, new_order, **kwargs):
|
||
masks = [kwargs["mask1"]] # Start with the required mask1 input
|
||
|
||
i = 2
|
||
while f"mask{i}" in kwargs:
|
||
masks.append(kwargs[f"mask{i}"])
|
||
i += 1
|
||
|
||
if new_order:
|
||
order_indices = [int(idx) - 1 for idx in new_order.split(',') if idx.strip()]
|
||
masks = [masks[idx] for idx in order_indices if idx < len(masks)]
|
||
|
||
processed_masks = []
|
||
for mask in masks:
|
||
pil_mask = tensor2pil(mask)
|
||
resized_cropped_mask = resize_and_crop(pil_mask, width, height)
|
||
mask_tensor = pil2tensor(resized_cropped_mask)
|
||
processed_masks.append(mask_tensor)
|
||
|
||
result = torch.cat(processed_masks, dim=0) if processed_masks else torch.empty(0, 1, height, width)
|
||
|
||
return (result,)
|
||
|
||
class StringListManager:
|
||
DESCRIPTION = "Re-order or time-expand a list of strings (e.g. prompts or captions) so each item can repeat for N frames. Outputs the adjusted list as a single newline-separated string."
|
||
def __init__(self):
|
||
pass
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
"frames_per_image": ("INT", {"default": 1, "min": 1, "step": 1})
|
||
},
|
||
"optional": {
|
||
"new_order": ("STRING", {"default": ""}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
FUNCTION = "reorder_strings"
|
||
CATEGORY = "SuperBeastsAI/Utils"
|
||
|
||
def reorder_strings(self, frames_per_image, new_order, **kwargs):
|
||
strings = [kwargs["string1"]] # Start with the required string1 input
|
||
|
||
i = 2
|
||
while f"string{i}" in kwargs:
|
||
strings.append(kwargs[f"string{i}"])
|
||
i += 1
|
||
|
||
if new_order:
|
||
order_indices = [int(idx) - 1 for idx in new_order.split(',') if idx.strip()]
|
||
strings = [strings[idx] for idx in order_indices if idx < len(strings)]
|
||
|
||
result = []
|
||
for i, string in enumerate(strings):
|
||
result.append('"{frames}": "{string}"'.format(frames=frames_per_image * i, string=string))
|
||
|
||
return (",\n".join(result),)
|
||
|
||
class SuperPopColorAdjustment:
|
||
"""Generate a series of color-adjusted images by blending the residual produced by a model-based correction with the original image at different strengths.
|
||
|
||
The node calls the provided *SBModel* once to obtain a fully-corrected reference image (Requires use of "SB Load Model" node). It then blends that result back into the
|
||
original image `count` times using evenly spaced strength values between `max_strength/count` and `max_strength` (inclusive).
|
||
"""
|
||
DESCRIPTION = __doc__
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"model": ("SBMODEL", ),
|
||
"image": ("IMAGE", ),
|
||
"max_strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||
"count": ("INT", {"default": 1, "min": 1, "max": 99999, "step": 1}),
|
||
"overlap": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 0.99, "step": 0.01}),
|
||
"initial_context_for_batch": ("BOOLEAN", {"default": False}),
|
||
},
|
||
"optional": {
|
||
"context": ("IMAGE", ), # Optional context image(s)
|
||
}
|
||
}
|
||
|
||
# primary output: processed image batch
|
||
# secondary output: per-frame filename prefix strings for Save Image
|
||
# third output: raw residual tensors (type SPCA_RESIDUAL) so that a
|
||
# dedicated blend node can re-apply any arbitrary strength later on.
|
||
RETURN_TYPES = ("IMAGE", "STRING", "SPCA_RESIDUAL")
|
||
RETURN_NAMES = ("images", "filename_prefix", "residuals")
|
||
FUNCTION = "apply_adjustment"
|
||
CATEGORY = "SuperBeastsAI/Image"
|
||
|
||
def _run_model(self, sb_model_info, pil_image, ctx_np=None):
|
||
"""Attempt to run the supplied SBModel on *pil_image* to obtain a corrected image.
|
||
The implementation gracefully falls back to a PIL colour enhancement if the model
|
||
does not expose a compatible callable interface yet. This allows the node to work
|
||
out-of-the-box while the dedicated SuperBeasts model loader is still under development."""
|
||
try:
|
||
# Build context thumbnail from global image once if not provided
|
||
if ctx_np is None:
|
||
thumb = pil_image.resize((CONTEXT_THUMBNAIL_SIZE[1], CONTEXT_THUMBNAIL_SIZE[0]), Image.Resampling.BILINEAR)
|
||
ctx_np_local = _add_extra_color_channels_np(np.asarray(thumb, dtype=np.float32) / 255.0)
|
||
else:
|
||
ctx_np_local = ctx_np
|
||
|
||
# --- Torch path ---
|
||
if isinstance(sb_model_info, dict) and sb_model_info.get("type") == "torch":
|
||
import torch
|
||
model_pt = sb_model_info["model"]
|
||
device_str = sb_model_info["device"]
|
||
|
||
# Prepare inputs NCHW float32
|
||
tile_np = _add_extra_color_channels_np(np.asarray(pil_image, dtype=np.float32) / 255.0)
|
||
|
||
tile_t = torch.from_numpy(tile_np.transpose(2,0,1)).unsqueeze(0).to(device_str)
|
||
ctx_t = torch.from_numpy(ctx_np_local.transpose(2,0,1)).unsqueeze(0).to(device_str)
|
||
|
||
with torch.no_grad():
|
||
residual = model_pt(tile_t, ctx_t)
|
||
residual_np = residual.squeeze(0).cpu().numpy().transpose(1,2,0)
|
||
|
||
tile_base = np.asarray(pil_image, dtype=np.float32) / 255.0
|
||
corrected_np = np.clip(tile_base + residual_np, 0.0, 1.0)
|
||
result = Image.fromarray((corrected_np * 255.0).astype(np.uint8))
|
||
|
||
# --- ONNX path ---
|
||
elif isinstance(sb_model_info, dict) and sb_model_info.get("type") == "onnx":
|
||
import onnxruntime as ort # type: ignore
|
||
session: ort.InferenceSession = sb_model_info["session"]
|
||
input_names: List[str] = sb_model_info["input_names"]
|
||
|
||
# Prepare inputs (NHWC float32)
|
||
ctx_np_batch = ctx_np_local[np.newaxis, ...] # add batch dim
|
||
|
||
tile_np = _add_extra_color_channels_np(np.asarray(pil_image, dtype=np.float32) / 255.0)
|
||
tile_np = tile_np[np.newaxis, ...]
|
||
|
||
# Some exported models keep NHWC; others are converted to NCHW. Detect by checking input shape channels position.
|
||
def maybe_transpose(inp: np.ndarray, shape) -> np.ndarray:
|
||
# shape may have None for batch, use length check
|
||
if len(shape) == 4 and shape[1] == 3: # likely NCHW
|
||
return np.transpose(inp, (0, 3, 1, 2))
|
||
return inp # assume NHWC
|
||
|
||
tile_ready = maybe_transpose(tile_np, session.get_inputs()[0].shape)
|
||
ctx_ready = maybe_transpose(ctx_np_batch, session.get_inputs()[1].shape)
|
||
|
||
ort_inputs = {
|
||
input_names[0]: tile_ready.astype(np.float32),
|
||
input_names[1]: ctx_ready.astype(np.float32),
|
||
}
|
||
|
||
pred_residual = session.run(None, ort_inputs)[0]
|
||
if pred_residual.shape[1] == 3 and pred_residual.ndim == 4: # NCHW output
|
||
pred_residual = np.transpose(pred_residual, (0, 2, 3, 1))
|
||
residual_np = pred_residual[0]
|
||
|
||
tile_base = np.asarray(pil_image, dtype=np.float32) / 255.0
|
||
corrected_np = np.clip(tile_base + residual_np, 0.0, 1.0)
|
||
result = Image.fromarray((corrected_np * 255.0).astype(np.uint8))
|
||
|
||
else:
|
||
# If the supplied model information structure is not recognised, raise an error so
|
||
# users are immediately aware of the mis-configuration instead of falling back.
|
||
raise TypeError("Unsupported SBMODEL object – expected dict with type 'torch' or 'onnx'.")
|
||
|
||
# Convert potential tensor outputs to PIL
|
||
try:
|
||
import torch as _torch
|
||
except ImportError:
|
||
_torch = None
|
||
|
||
if _torch is not None and isinstance(result, _torch.Tensor):
|
||
result = tensor2pil(result)
|
||
if not isinstance(result, Image.Image):
|
||
raise TypeError("Model output is not an image")
|
||
return result
|
||
except Exception:
|
||
# Re-raise any exception so the UI surfaces the real failure rather than silently
|
||
# producing an uncorrected image.
|
||
raise
|
||
|
||
def _get_patch_positions(self, img_len: int, patch_len: int, overlap_px: int):
|
||
"""Return list of starting indices so patches cover dimension with given overlap."""
|
||
if patch_len >= img_len:
|
||
return [0]
|
||
stride = max(1, patch_len - overlap_px)
|
||
positions = [0]
|
||
while True:
|
||
next_pos = positions[-1] + stride
|
||
if next_pos + patch_len >= img_len:
|
||
break
|
||
positions.append(next_pos)
|
||
last_start = img_len - patch_len
|
||
if positions[-1] != last_start:
|
||
positions.append(last_start)
|
||
return positions
|
||
|
||
# --- Patch weight map (mirrors extras/inference_utils._create_patch_weight_map) ---
|
||
def _create_patch_weight_map(self, ph: int, pw: int, overlap: int | tuple[int,int]):
|
||
if isinstance(overlap, (list, tuple)) and len(overlap) == 2:
|
||
oh, ow = int(overlap[0]), int(overlap[1])
|
||
else:
|
||
oh = ow = int(overlap)
|
||
|
||
oh = max(0, min(oh, ph))
|
||
ow = max(0, min(ow, pw))
|
||
|
||
if oh == 0 and ow == 0:
|
||
return np.ones((ph, pw), dtype=np.float32)
|
||
|
||
w = np.ones((ph, pw), dtype=np.float32)
|
||
if oh > 0:
|
||
for i in range(oh):
|
||
fade = (i + 1) / (oh + 1)
|
||
w[i, :] *= fade
|
||
w[ph - 1 - i, :] *= fade
|
||
if ow > 0:
|
||
for i in range(ow):
|
||
fade = (i + 1) / (ow + 1)
|
||
w[:, i] *= fade
|
||
w[:, pw - 1 - i] *= fade
|
||
return w
|
||
|
||
def apply_adjustment(self, model, image, max_strength=1.0, count=1, overlap=0.5,
|
||
initial_context_for_batch=False, context=None):
|
||
# Ensure *image* is a batch tensor – iterate over each frame
|
||
output_tensors: list[torch.Tensor] = []
|
||
filename_prefixes: list[str] = []
|
||
residual_tensors: list[torch.Tensor] = []
|
||
|
||
# Cache for the first computed context thumbnail when reusing across batch
|
||
ctx_np_cached = None
|
||
|
||
# Helper to obtain the source PIL for context, considering optional input
|
||
def _get_context_pil(idx, orig_pil):
|
||
if context is not None:
|
||
# Guard against length mismatch – fallback to first element if idx out of range
|
||
ctx_tensor = context[idx] if idx < context.shape[0] else context[0]
|
||
return tensor2pil(ctx_tensor)
|
||
# Default: use the original image as its own context
|
||
return orig_pil
|
||
|
||
pbar = None # progress bar placeholder
|
||
try:
|
||
import importlib
|
||
ProgressBar = getattr(importlib.import_module("comfy.utils"), "ProgressBar", None) # type: ignore[attr-defined,ignored-type]
|
||
if ProgressBar is not None:
|
||
pbar = ProgressBar(image.shape[0])
|
||
except Exception:
|
||
pbar = None
|
||
|
||
for idx, img_tensor in enumerate(image):
|
||
# Convert to PIL for easier colour operations
|
||
original_pil = tensor2pil(img_tensor)
|
||
|
||
# -------------------------------------------------
|
||
# Prepare / reuse context thumbnail
|
||
# -------------------------------------------------
|
||
if initial_context_for_batch and ctx_np_cached is not None:
|
||
ctx_np_global = ctx_np_cached
|
||
else:
|
||
ctx_source_pil = _get_context_pil(idx, original_pil)
|
||
# Resize exactly to CONTEXT_THUMBNAIL_SIZE using bicubic resampling
|
||
thumb_global = ctx_source_pil.resize((CONTEXT_THUMBNAIL_SIZE[1], CONTEXT_THUMBNAIL_SIZE[0]),
|
||
Image.Resampling.BICUBIC)
|
||
ctx_np_global = _add_extra_color_channels_np(np.asarray(thumb_global, dtype=np.float32) / 255.0)
|
||
|
||
if initial_context_for_batch:
|
||
ctx_np_cached = ctx_np_global
|
||
|
||
# ---------- Patch-based processing ----------
|
||
h, w = original_pil.size[1], original_pil.size[0]
|
||
patch_size = model.get("patch_size", 512) if isinstance(model, dict) else 512
|
||
patch_size = int(patch_size)
|
||
overlap_px = int(patch_size * overlap)
|
||
if overlap_px >= patch_size:
|
||
overlap_px = patch_size // 2
|
||
|
||
# Prepare accumulators for residual image
|
||
residual_acc = np.zeros((h, w, 3), dtype=np.float32)
|
||
counter = np.zeros((h, w, 1), dtype=np.float32)
|
||
|
||
x_positions = self._get_patch_positions(w, patch_size, overlap_px)
|
||
y_positions = self._get_patch_positions(h, patch_size, overlap_px)
|
||
|
||
orig_np_full = np.array(original_pil, dtype=np.float32) / 255.0
|
||
|
||
# ctx_np_global already prepared above
|
||
|
||
for y in y_positions:
|
||
for x in x_positions:
|
||
patch = original_pil.crop((x, y, x + patch_size, y + patch_size))
|
||
patch_corrected = self._run_model(model, patch, ctx_np_global)
|
||
|
||
# Resize patch_corrected to patch size in case model changes size
|
||
if patch_corrected.size != (patch_size, patch_size):
|
||
patch_corrected = patch_corrected.resize((patch_size, patch_size), Image.Resampling.BILINEAR)
|
||
|
||
orig_patch_np = np.array(patch, dtype=np.float32) / 255.0
|
||
corr_patch_np = np.array(patch_corrected, dtype=np.float32) / 255.0
|
||
|
||
# Determine overlap area within the original image boundaries
|
||
target_h = min(patch_size, h - y)
|
||
target_w = min(patch_size, w - x)
|
||
|
||
# If the patch is already full size use it directly -----------------------
|
||
if target_h == patch_size and target_w == patch_size:
|
||
residual_patch = corr_patch_np - orig_patch_np
|
||
else:
|
||
# --------------------------------------------------------------
|
||
# Pad the original patch to 512×512 instead of scaling to avoid
|
||
# introducing blur / ghosting. Use edge padding.
|
||
# --------------------------------------------------------------
|
||
# Crop to the valid region before padding
|
||
orig_cropped = orig_patch_np[:target_h, :target_w, :]
|
||
|
||
# --- Symmetric edge-padding approach (avoid stretched background artefacts) ---
|
||
pad_vert = patch_size - target_h
|
||
pad_horz = patch_size - target_w
|
||
pad_top = pad_vert // 2
|
||
pad_bottom = pad_vert - pad_top
|
||
pad_left = pad_horz // 2
|
||
pad_right = pad_horz - pad_left
|
||
|
||
# Edge-pad the crop up to (patch_size, patch_size)
|
||
orig_pad = np.pad(orig_cropped, (
|
||
(pad_top, pad_bottom),
|
||
(pad_left, pad_right),
|
||
(0, 0)), mode='edge')
|
||
|
||
# Convert to PIL for model inference
|
||
pad_pil = Image.fromarray((orig_pad * 255.0).astype(np.uint8))
|
||
|
||
# Run model on padded patch – override earlier corr_patch_np
|
||
patch_corrected = self._run_model(model, pad_pil, ctx_np_global)
|
||
|
||
if patch_corrected.size != (patch_size, patch_size):
|
||
patch_corrected = patch_corrected.resize((patch_size, patch_size), Image.Resampling.BILINEAR)
|
||
|
||
corrected_pad_np = np.array(patch_corrected, dtype=np.float32) / 255.0
|
||
|
||
# Crop back to original region
|
||
corrected_crop = corrected_pad_np[pad_top:pad_top + target_h, pad_left:pad_left + target_w, :]
|
||
|
||
# Residual relative to original crop
|
||
residual_patch = corrected_crop - orig_cropped
|
||
|
||
# Weight map for smooth blending (same crop)
|
||
weight_full = self._create_patch_weight_map(patch_size, patch_size, overlap_px)
|
||
if target_h != patch_size or target_w != patch_size:
|
||
weight = weight_full[pad_top:pad_top + target_h, pad_left:pad_left + target_w]
|
||
else:
|
||
weight = weight_full[:target_h, :target_w]
|
||
|
||
# Determine the actual location in the full image where this residual belongs
|
||
residual_acc[y:y + target_h, x:x + target_w, :] += residual_patch * weight[:, :, np.newaxis]
|
||
counter[y:y + target_h, x:x + target_w, :] += weight[:, :, np.newaxis]
|
||
|
||
# Use a tiny epsilon instead of 1.0 so that edge pixels retain their full residual
|
||
counter = np.maximum(counter, 1e-6)
|
||
residual_full = residual_acc / counter
|
||
|
||
# Generate blended outputs for this frame
|
||
strengths = [max_strength * (count - i) / count for i in range(count)]
|
||
for s in strengths:
|
||
blended_np = np.clip(orig_np_full + residual_full * s, 0.0, 1.0)
|
||
blended_pil = Image.fromarray((blended_np * 255.0).astype(np.uint8))
|
||
|
||
# Convert blended PIL → tensor and append to batch list
|
||
output_tensors.append(pil2tensor(blended_pil))
|
||
|
||
# Build filename prefix string per‐image (we will join later)
|
||
filename_prefixes.append(f"SPCA_{s:.2f}_")
|
||
|
||
# Store residual for this output so downstream nodes can re-blend
|
||
residual_tensor = torch.from_numpy(residual_full).unsqueeze(0).float()
|
||
residual_tensors.append(residual_tensor)
|
||
# Update progress bar
|
||
if pbar is not None:
|
||
pbar.update_absolute(idx + 1)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Finalise outputs – convert accumulated lists to batched tensors and
|
||
# join filename prefixes into a single string (Save Image expects str)
|
||
# ------------------------------------------------------------------
|
||
|
||
if not output_tensors:
|
||
# Fallback – no processing happened; return originals and zero residual.
|
||
images_batch = image
|
||
residual_batch = [torch.zeros_like(image)]
|
||
filename_prefix_str = ""
|
||
else:
|
||
images_batch = torch.cat(output_tensors, dim=0)
|
||
residual_batch = residual_tensors # keep list per-frame
|
||
filename_prefix_str = "".join(filename_prefixes)
|
||
|
||
return (images_batch, filename_prefix_str, residual_batch)
|
||
|
||
class SuperPopResidualBlend:
|
||
"""Interactively re-apply a residual predicted by **Super Pop Color Adjustment** at any arbitrary strength.
|
||
|
||
Inputs
|
||
------
|
||
image : original image batch to adjust
|
||
residual : batch of residual tensors output from SuperPopColorAdjustment
|
||
strength : scalar multiplier (e.g. 0.0-2.0) applied to the residual
|
||
"""
|
||
|
||
DESCRIPTION = __doc__
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"image": ("IMAGE", ),
|
||
"residual": ("SPCA_RESIDUAL", ),
|
||
"strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "STRING")
|
||
RETURN_NAMES = ("images", "filename_prefix")
|
||
FUNCTION = "blend"
|
||
CATEGORY = "SuperBeastsAI/Image"
|
||
|
||
def blend(self, image, residual, strength=1.0):
|
||
"""Apply *residual × strength* to *image* per-frame."""
|
||
import torch
|
||
|
||
# ------------------------------------------------------------------
|
||
# Normalise inputs to per-frame lists --------------------------------
|
||
# ------------------------------------------------------------------
|
||
|
||
if isinstance(image, torch.Tensor):
|
||
# Split the batch tensor into per-frame tensors (keeps gradients off)
|
||
image_list = list(image)
|
||
elif isinstance(image, (list, tuple)):
|
||
image_list = list(image)
|
||
else:
|
||
raise TypeError("Unsupported image type for SuperPopResidualBlend – expected torch.Tensor or list of tensors.")
|
||
|
||
if isinstance(residual, torch.Tensor):
|
||
residual_list = list(residual)
|
||
elif isinstance(residual, (list, tuple)):
|
||
residual_list = list(residual)
|
||
else:
|
||
raise TypeError("Unsupported residual type for SuperPopResidualBlend – expected torch.Tensor or list of tensors.")
|
||
|
||
# Simple broadcast if we only got one residual for many images
|
||
if len(residual_list) == 1 and len(image_list) > 1:
|
||
residual_list = residual_list * len(image_list)
|
||
|
||
if len(image_list) != len(residual_list):
|
||
raise ValueError("Mismatch between number of images and residuals ({} vs {}).".format(len(image_list), len(residual_list)))
|
||
|
||
# ------------------------------------------------------------------
|
||
# Blend per frame ----------------------------------------------------
|
||
# ------------------------------------------------------------------
|
||
|
||
blended_frames: list[torch.Tensor] = []
|
||
prefixes: list[str] = []
|
||
|
||
for img_t, res_t in zip(image_list, residual_list):
|
||
# Ensure on same device (fallback to img_t's device)
|
||
res_t = res_t.to(img_t.device)
|
||
|
||
# Both tensors may include an extra leading batch/channel dimension; squeeze safely
|
||
img = img_t.squeeze().clamp(0,1)
|
||
res = res_t.squeeze()
|
||
|
||
# Quantise the source to 8-bit – SuperPopColorAdjustment converted the
|
||
# original tensor to PIL (uint8) before computing residuals. Without
|
||
# replicating that rounding we would blend residuals into a higher-
|
||
# precision version of the source, leading to small but noticeable
|
||
# mismatches (especially at strength = 1.0). By rounding to the
|
||
# same 0–255 integer grid first we guarantee that
|
||
# original + residual == corrected
|
||
img_q = (img * 255.0).round().div(255.0)
|
||
|
||
blended = (img_q + res * strength).clamp(0,1)
|
||
|
||
# Restore missing channel dimension if blend lost it (should stay (C,H,W))
|
||
if blended.ndim == 2:
|
||
blended = blended.unsqueeze(0)
|
||
|
||
blended_frames.append(blended.unsqueeze(0)) # keep batch dim so we can cat
|
||
prefixes.append(f"SPCA_{strength:.2f}_")
|
||
|
||
# Concatenate into a single batch tensor (N,C,H,W)
|
||
batch_tensor = torch.cat(blended_frames, dim=0)
|
||
|
||
joined_prefix = "".join(prefixes)
|
||
|
||
return (batch_tensor, joined_prefix)
|
||
|
||
NODE_CLASS_MAPPINGS = {
|
||
'HDR Effects (SuperBeasts.AI)': HDREffects,
|
||
'Make Resized Mask Batch (SuperBeasts.AI)': MakeResizedMaskBatch,
|
||
'Mask Batch Manager (SuperBeasts.AI)': MaskBatchManagement,
|
||
'Image Batch Manager (SuperBeasts.AI)': ImageBatchManagement,
|
||
'String List Manager (SuperBeasts.AI)': StringListManager,
|
||
'Deflicker - Experimental (SuperBeasts.AI)': Deflicker,
|
||
'Pixel Deflicker - Experimental (SuperBeasts.AI)': PixelDeflicker,
|
||
'Super Pop Color Adjustment (SuperBeasts.AI)': SuperPopColorAdjustment,
|
||
'SB Load Model (SuperBeasts.AI)': SBLoadModel,
|
||
'Super Pop Residual Blend (SuperBeasts.AI)': SuperPopResidualBlend,
|
||
}
|
||
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
'HDREffects': 'HDR Effects (SuperBeasts.AI)',
|
||
'MakeResizedMaskBatch': 'Make Resized Mask Batch (SuperBeasts.AI)',
|
||
'MaskBatchManagement':'Mask Batch Manager (SuperBeasts.AI)',
|
||
'ImageBatchManagement':'Image Batch Manager (SuperBeasts.AI)',
|
||
'StringListManager': 'String List Manager (SuperBeasts.AI)',
|
||
'Deflicker': 'Deflicker - Experimental (SuperBeasts.AI)',
|
||
'PixelDeflicker': 'Pixel Deflicker - Experimental (SuperBeasts.AI)',
|
||
'SuperPopColorAdjustment': 'Super Pop Color Adjustment (SuperBeasts.AI)',
|
||
'SBLoadModel': 'SB Load Model (SuperBeasts.AI)',
|
||
'SuperPopResidualBlend': 'Super Pop Residual Blend (SuperBeasts.AI)',
|
||
} |