Pre-existing working-tree feature, committed as-is: optional MASK input gray-fills the hole for the inpaint adapter, and the cond resize switches to bicubic+antialias to match the repo inference path's LANCZOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
267 lines
12 KiB
Python
267 lines
12 KiB
Python
"""EasyControl image-conditioning node for the Anima DiT — KSampler-compatible.
|
|
|
|
A single model-patch node: ``MODEL + VAE + IMAGE -> MODEL``. The returned MODEL
|
|
carries the reference-image conditioning as a set of object patches, so it flows
|
|
straight into the stock KSampler (and composes with ControlNet, schedulers,
|
|
other adapters) — no dedicated sampler node, unlike the upstream Flux/Qwen
|
|
EasyControl ComfyUI nodes.
|
|
|
|
The EasyControl ``.safetensors`` goes in ``ComfyUI/models/loras/`` (same folder
|
|
the LoRA loaders read). Feed the conditioning image (e.g. the grayscale / manga
|
|
line page for colorization) into the IMAGE socket and a VAE that matches the
|
|
Anima latent space into the VAE socket.
|
|
"""
|
|
|
|
import folder_paths
|
|
import torch
|
|
import torch.nn.functional as F
|
|
|
|
from .easycontrol_patch import install_easycontrol
|
|
|
|
|
|
def _vae_spatial(vae):
|
|
"""Spatial downscale (h_mult, w_mult) of a ComfyUI VAE.
|
|
|
|
``downscale_ratio`` is either an int (square) or a ``(callable, h, w)`` tuple
|
|
for the temporally-aware video VAEs. We only need the spatial multiples.
|
|
"""
|
|
dr = getattr(vae, "downscale_ratio", 8)
|
|
if isinstance(dr, (tuple, list)):
|
|
return int(dr[1]), int(dr[2])
|
|
return int(dr), int(dr)
|
|
|
|
|
|
def _resize_to_megapixels(image, vae, target_mp):
|
|
"""Resize an IMAGE ``[B,H,W,C]`` (in [0,1]) to at most ``target_mp``
|
|
megapixels, aspect-preserving, snapping each side to ``vae_spatial *
|
|
patch(2)`` so the resulting latent has even dims (cosmos ``patch_spatial=2``).
|
|
|
|
``target_mp`` is a MAX cap: a source already below it is left at its native
|
|
resolution (only snapped) rather than upscaled.
|
|
|
|
``target_mp <= 0`` means keep the native resolution (still snapped, so the
|
|
VAE/patch grid is clean). Returns the resized image ``[B,H,W,C]``.
|
|
"""
|
|
_, h, w, _ = image.shape
|
|
sh, sw = _vae_spatial(vae)
|
|
mult_h, mult_w = sh * 2, sw * 2
|
|
|
|
if target_mp and target_mp > 0:
|
|
# target_mp is a MAX cap: only downscale when the source exceeds it,
|
|
# never upscale a smaller source (clamp scale to <= 1.0).
|
|
scale = min(1.0, (target_mp * 1_000_000 / float(h * w)) ** 0.5)
|
|
new_h, new_w = h * scale, w * scale
|
|
else:
|
|
new_h, new_w = float(h), float(w)
|
|
|
|
snap_h = max(mult_h, int(round(new_h / mult_h)) * mult_h)
|
|
snap_w = max(mult_w, int(round(new_w / mult_w)) * mult_w)
|
|
if (snap_h, snap_w) == (h, w):
|
|
return image
|
|
|
|
# IMAGE [B,H,W,C] → [B,C,H,W] for interpolate → back.
|
|
# antialias=True is essential: the reference is almost always *downscaled*
|
|
# (target_mp caps at ~1MP, sources are larger), and plain bilinear without
|
|
# anti-aliasing both softens the cond and aliases high-frequency detail
|
|
# (manga lines, fine texture), which locally shifts averaged colors — the
|
|
# cond then looks blurred / colour-off. bicubic+antialias matches the LANCZOS
|
|
# resampling the repo's own inference path uses (library/inference/
|
|
# generation.py), so the node's cond latent tracks the trained distribution.
|
|
chw = image.permute(0, 3, 1, 2)
|
|
chw = F.interpolate(
|
|
chw, size=(snap_h, snap_w), mode="bicubic", antialias=True
|
|
).clamp_(0.0, 1.0)
|
|
return chw.permute(0, 2, 3, 1).contiguous()
|
|
|
|
|
|
# Mid-gray fill for the inpaint hole, matching the training-time convention
|
|
# (easycontrol_adapters/inpainting/mask_image.py ``GRAY = 128`` in [0,255]). The
|
|
# VAE input transform is ``x*2 - 1`` on a [0,1] image, so 128/255 ≈ 0.502 → ~0 in
|
|
# [-1,1] — a flat latent region the cond LoRA + b_cond gate learn as "regenerate
|
|
# here." Must match training or the hole reads off-distribution.
|
|
_INPAINT_GRAY = 128.0 / 255.0
|
|
|
|
|
|
def _apply_inpaint_mask(pixels, mask):
|
|
"""Gray-fill the masked region of ``pixels`` ([1,H,W,C], [0,1]) → inpaint cond.
|
|
|
|
``mask`` is a ComfyUI MASK ([B,H,W], [0,1]); the *selected* region (value 1)
|
|
is the hole to regenerate. The mask is resized (nearest, to preserve the hard
|
|
hole edge the synthetic LaMa masks have) to the image grid and thresholded at
|
|
0.5, then those pixels are set to mid-gray — reproducing what the inpaint
|
|
training saw (cond = original image with a free-form gray hole).
|
|
"""
|
|
_, h, w, _ = pixels.shape
|
|
m = mask[:1].to(pixels.device, pixels.dtype) # [1,Hm,Wm]
|
|
if m.shape[-2:] != (h, w):
|
|
m = F.interpolate(m.unsqueeze(1), size=(h, w), mode="nearest").squeeze(1)
|
|
hole = (m > 0.5).unsqueeze(-1) # [1,H,W,1]
|
|
return torch.where(hole, pixels.new_full((), _INPAINT_GRAY), pixels)
|
|
|
|
|
|
class AnimaEasyControlPatch:
|
|
"""Apply Anima EasyControl reference-image conditioning to a MODEL.
|
|
|
|
EasyControl is a frozen-DiT adapter: a per-block cond LoRA on self-attn
|
|
(q/k/v/o) + FFN plus a scalar ``b_cond`` logit-bias gate. At inference the
|
|
reference image is VAE-encoded, the cond stream is walked once to build a
|
|
per-block key/value cache, and each block's self-attention is extended to
|
|
attend over those cached cond keys. Output is a MODEL for the stock KSampler.
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
loras = folder_paths.get_filename_list("loras")
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"vae": ("VAE",),
|
|
"image": ("IMAGE",),
|
|
"base_model": (
|
|
["anima"],
|
|
{
|
|
"default": "anima",
|
|
"tooltip": (
|
|
"Base model family this node supports. Currently "
|
|
"Anima only — the EasyControl reimplementation targets "
|
|
"the Anima/Cosmos DiT backbone. More families may be "
|
|
"added later."
|
|
),
|
|
},
|
|
),
|
|
"easycontrol_lora": (
|
|
loras,
|
|
{
|
|
"tooltip": (
|
|
"EasyControl checkpoint (networks.methods.easycontrol "
|
|
"— per-block cond LoRA + b_cond gate). Lives in "
|
|
"ComfyUI/models/loras/."
|
|
)
|
|
},
|
|
),
|
|
"strength": (
|
|
"FLOAT",
|
|
{
|
|
"default": 1.0,
|
|
"min": 0.0,
|
|
"max": 3.0,
|
|
"step": 0.05,
|
|
"tooltip": (
|
|
"Conditioning strength. Scales the cond-LoRA deltas "
|
|
"(multiplies the trained cond_scale). 1.0 = as trained; "
|
|
"raise to push harder toward the reference, lower to "
|
|
"loosen."
|
|
),
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"mask": (
|
|
"MASK",
|
|
{
|
|
"tooltip": (
|
|
"Optional inpaint mask. White (selected) region is the "
|
|
"hole to regenerate: the node fills it with mid-gray and "
|
|
"encodes that as the conditioning image, matching the "
|
|
"`inpaint` adapter's training (cond = original image with "
|
|
"a gray hole). Wire the ORIGINAL image into the IMAGE "
|
|
"socket and the mask here. Leave unconnected for adapters "
|
|
"that take a pre-built cond image (e.g. colorize)."
|
|
)
|
|
},
|
|
),
|
|
"target_megapixels": (
|
|
"FLOAT",
|
|
{
|
|
"default": 1.0,
|
|
"min": 0.0,
|
|
"max": 4.0,
|
|
"step": 0.1,
|
|
"tooltip": (
|
|
"Output size, in megapixels, for the returned LATENT "
|
|
"(and the conditioning encode). The image is resized "
|
|
"aspect-preserving to ~this many MP and snapped to the "
|
|
"VAE/patch grid; the cond stream is encoded at the same "
|
|
"resolution so it shares the target's grid (best spatial "
|
|
"alignment for colorize). 1.0 keeps generation on the "
|
|
"~1MP distribution Anima was trained at. 0 = keep the "
|
|
"input's native resolution."
|
|
),
|
|
},
|
|
),
|
|
"cond_scale_override": (
|
|
"FLOAT",
|
|
{
|
|
"default": 0.0,
|
|
"min": 0.0,
|
|
"max": 4.0,
|
|
"step": 0.05,
|
|
"tooltip": (
|
|
"If > 0, replaces the checkpoint's trained cond_scale "
|
|
"(before strength). 0 = use the trained value."
|
|
),
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "LATENT")
|
|
RETURN_NAMES = ("model", "latent")
|
|
FUNCTION = "apply"
|
|
CATEGORY = "loaders"
|
|
DESCRIPTION = (
|
|
"Anima EasyControl image conditioning, KSampler-compatible. Feeds a "
|
|
"reference image (VAE-encoded) into a frozen-DiT extended self-attention "
|
|
"stream and returns a patched MODEL plus an empty LATENT sized to the "
|
|
"conditioning resolution — wire the latent straight into the KSampler so "
|
|
"it generates at the (aspect-matched, ~1MP) cond grid. Ideal for spatially"
|
|
" aligned tasks like colorization."
|
|
)
|
|
|
|
def apply(self, model, vae, image, base_model, easycontrol_lora, strength,
|
|
mask=None, target_megapixels=1.0, cond_scale_override=0.0):
|
|
# base_model is an informational guard — only "anima" is offered today.
|
|
del base_model
|
|
new_model = model.clone()
|
|
weight_path = folder_paths.get_full_path("loras", easycontrol_lora)
|
|
if weight_path is None:
|
|
raise FileNotFoundError(
|
|
f"EasyControl checkpoint not found in loras folder: {easycontrol_lora}"
|
|
)
|
|
|
|
# IMAGE is [B, H, W, C] in [0, 1]; use the first image as the reference.
|
|
# Resize to ~target_megapixels (aspect-preserving, VAE/patch-snapped) so
|
|
# the cond encode and the returned target latent share one grid.
|
|
pixels = image[:1, :, :, :3]
|
|
pixels = _resize_to_megapixels(pixels, vae, float(target_megapixels))
|
|
# Inpaint: gray-fill the masked hole AFTER resizing, so the gray region
|
|
# lands on the cond's own (snapped) grid — matching the training cond.
|
|
if mask is not None:
|
|
pixels = _apply_inpaint_mask(pixels, mask)
|
|
vae_latent = vae.encode(pixels) # ComfyUI latent space [1, C, h, w]
|
|
|
|
# Map into the DiT's input space — the same transform apply_model applies
|
|
# to the noisy latent before calling diffusion_model. Without this the
|
|
# cond stream sees a differently-scaled latent than the target stream.
|
|
cond_latent = new_model.model.process_latent_in(vae_latent)
|
|
|
|
override = float(cond_scale_override) if cond_scale_override and cond_scale_override > 0 else None
|
|
install_easycontrol(
|
|
new_model, weight_path, cond_latent, float(strength),
|
|
cond_scale_override=override,
|
|
)
|
|
|
|
# Empty target latent matching the cond grid. KSampler overwrites the
|
|
# contents with noise (full denoise); only the shape matters here.
|
|
out_latent = {"samples": torch.zeros_like(vae_latent)}
|
|
return (new_model, out_latent)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"AnimaEasyControlPatch": AnimaEasyControlPatch,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"AnimaEasyControlPatch": "Anima EasyControl (KSampler)",
|
|
}
|