Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

554 lines
22 KiB
Python

"""ControlNet-LLLite adapter for Anima (DiT), v2 weight format.
A LLLite adapter is a shared conv trunk (``conditioning1``) that encodes a
conditioning image into per-token embeddings, plus one tiny zero-init MLP per
target Linear in the DiT. Each module perturbs its Linear's *input*:
``y = org_forward(x + up(film_mlp(x, cond + depth_embed)) * multiplier)``, so
multiplier 0 or a missing cond image is an exact passthrough.
On-disk format (v2, named-key): shared trunk under ``lllite_conditioning1.*``,
per-module weights under ``lllite_dit_blocks_{i}_{target}.{down,mid,cond_to_film,up}.*``
plus a per-module ``.depth_embed``; hyperparams in safetensors metadata
(``lllite.*`` keys) with state-dict-shape fallbacks.
Unlike the reference implementation, the model is constructed from
``(state_dict, metadata)`` alone — no transformer instance is needed until
:meth:`AnimaControlNetLLLite.apply_to` binds the modules to the target Linears
by their saved names.
Original source code:
- kohya-ss ControlNet-LLLite for Anima: ComfyUI-Anima-LLLite port
(``control_net_lllite_anima.py``, ``nodes.py``) of kohya-ss/sd-scripts
``networks/control_net_lllite_anima.py``.
SPDX-License-Identifier: Apache-2.0
"""
from __future__ import annotations
import re
from typing import Callable, Sequence
import torch
import torch.nn.functional as F
from torch import nn
ASPP_DEFAULT_DILATIONS: tuple[int, ...] = (1, 2, 4, 8)
_SAVED_COND_PREFIX = "lllite_conditioning1."
_INTERNAL_COND_PREFIX = "conditioning1."
_INTERNAL_MODULES_PREFIX = "lllite_modules."
_LEGACY_MODULES_PREFIX = "lllite_modules."
MODULE_NAME_PATTERN = re.compile(
r"^lllite_dit_blocks_(\d+)_(self_attn_q_proj|self_attn_k_proj|self_attn_v_proj|cross_attn_q_proj|mlp_layer1)$"
)
# Saved module name suffix -> attribute path under transformer.blocks[i].
_SUFFIX_TO_ATTR_PATH: dict[str, tuple[str, ...]] = {
"self_attn_q_proj": ("self_attn", "q_proj"),
"self_attn_k_proj": ("self_attn", "k_proj"),
"self_attn_v_proj": ("self_attn", "v_proj"),
"cross_attn_q_proj": ("cross_attn", "q_proj"),
"mlp_layer1": ("mlp", "layer1"),
}
_SUFFIX_ORDER = list(_SUFFIX_TO_ATTR_PATH)
# ----------------------------------------------------------------------------
# Conditioning image preprocessing (torch-only, PIL-free)
# ----------------------------------------------------------------------------
def target_cond_hw(latent_h: int, latent_w: int, patch_spatial: int = 2) -> tuple[int, int]:
"""Return the (H, W) the cond image / mask must be resized to.
The LLLite ``conditioning1`` trunk has total conv stride 16, so the cond
image must be sized to ``latent_HW * 8`` in input pixel space (= token_HW
* 16 after DiT patchify with patch_spatial=2). The DiT internally pads the
latent up to a multiple of ``patch_spatial`` before patchify, so the same
rounding is mirrored here — otherwise odd latent dims yield a token-count
mismatch that silently bypasses every LLLite module.
"""
padded_h = ((latent_h + patch_spatial - 1) // patch_spatial) * patch_spatial
padded_w = ((latent_w + patch_spatial - 1) // patch_spatial) * patch_spatial
return padded_h * 8, padded_w * 8
def prepare_cond_image(rgb_bchw_01: torch.Tensor, latent_h: int, latent_w: int, patch_spatial: int = 2) -> torch.Tensor:
"""RGB image (B, 3, H, W) in [0, 1] -> (1, 3, H_t, W_t) in [-1, 1]."""
if rgb_bchw_01.ndim != 4 or rgb_bchw_01.shape[1] != 3:
raise ValueError(f"Unexpected cond image shape: {tuple(rgb_bchw_01.shape)} (expected B,3,H,W)")
img = rgb_bchw_01[:1]
target_h, target_w = target_cond_hw(latent_h, latent_w, patch_spatial)
if img.shape[-2] != target_h or img.shape[-1] != target_w:
img = F.interpolate(img, size=(target_h, target_w), mode="bicubic", align_corners=False)
img = img.clamp(0.0, 1.0)
return img * 2.0 - 1.0
def prepare_mask(mask_b1hw_01: torch.Tensor, latent_h: int, latent_w: int, patch_spatial: int = 2) -> torch.Tensor:
"""Mask (B, 1, H, W) or (B, H, W) in [0, 1] -> (1, 1, H_t, W_t) in {0.0, 1.0}.
1 = inpaint area, 0 = keep. The caller is responsible for the ``*2-1``
rescale before concat with RGB (see :func:`build_inpaint_cond_image`).
"""
if mask_b1hw_01.ndim == 3:
m = mask_b1hw_01.unsqueeze(1)
elif mask_b1hw_01.ndim == 4 and mask_b1hw_01.shape[1] == 1:
m = mask_b1hw_01
else:
raise ValueError(f"Unexpected mask shape: {tuple(mask_b1hw_01.shape)} (expected B,H,W or B,1,H,W)")
m = m[:1].float()
target_h, target_w = target_cond_hw(latent_h, latent_w, patch_spatial)
if m.shape[-2] != target_h or m.shape[-1] != target_w:
m = F.interpolate(m, size=(target_h, target_w), mode="nearest")
return (m >= 0.5).float()
def build_inpaint_cond_image(rgb_pm1: torch.Tensor, mask01: torch.Tensor, masked_input: bool) -> torch.Tensor:
"""rgb_pm1: (1, 3, H, W) in [-1, 1], mask01: (1, 1, H, W) in {0, 1}. Returns (1, 4, H, W).
The mask channel is rescaled to [-1, +1] (matching the RGB range), and if
``masked_input`` is set the RGB is zeroed where ``mask >= 0.5``.
"""
if masked_input:
keep = (mask01 < 0.5).to(rgb_pm1.dtype)
rgb_pm1 = rgb_pm1 * keep
mask_pm1 = mask01.to(rgb_pm1.dtype) * 2.0 - 1.0
return torch.cat([rgb_pm1, mask_pm1], dim=1)
# ----------------------------------------------------------------------------
# Conditioning1 trunk (v2)
# ----------------------------------------------------------------------------
def _gn(channels: int) -> nn.GroupNorm:
g = 8
while g > 1 and channels % g != 0:
g //= 2
return nn.GroupNorm(g, channels)
class _ResBlock(nn.Module):
def __init__(self, ch: int):
super().__init__()
self.norm1 = _gn(ch)
self.conv1 = nn.Conv2d(ch, ch, kernel_size=3, padding=1)
self.norm2 = _gn(ch)
self.conv2 = nn.Conv2d(ch, ch, kernel_size=3, padding=1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.conv1(F.silu(self.norm1(x)))
h = self.conv2(F.silu(self.norm2(h)))
return x + h
class _ASPP(nn.Module):
def __init__(self, ch: int, dilations: tuple[int, ...] = ASPP_DEFAULT_DILATIONS):
super().__init__()
assert len(dilations) >= 1, "ASPP needs at least one dilation"
branches = []
for d in dilations:
if d == 1:
conv = nn.Conv2d(ch, ch, kernel_size=1)
else:
conv = nn.Conv2d(ch, ch, kernel_size=3, padding=d, dilation=d)
branches.append(nn.Sequential(conv, _gn(ch), nn.SiLU()))
self.branches = nn.ModuleList(branches)
self.global_pool = nn.AdaptiveAvgPool2d(1)
self.global_conv = nn.Sequential(nn.Conv2d(ch, ch, kernel_size=1), _gn(ch), nn.SiLU())
n_branches = len(dilations) + 1
self.proj = nn.Sequential(nn.Conv2d(ch * n_branches, ch, kernel_size=1), _gn(ch), nn.SiLU())
def forward(self, x: torch.Tensor) -> torch.Tensor:
h, w = x.shape[-2:]
outs = [b(x) for b in self.branches]
g = self.global_conv(self.global_pool(x))
g = F.interpolate(g, size=(h, w), mode="bilinear", align_corners=False)
outs.append(g)
return self.proj(torch.cat(outs, dim=1))
class _Conditioning1(nn.Module):
def __init__(
self,
cond_dim: int,
cond_emb_dim: int,
n_resblocks: int,
use_aspp: bool = False,
aspp_dilations: tuple[int, ...] = ASPP_DEFAULT_DILATIONS,
cond_in_channels: int = 3,
):
super().__init__()
assert cond_dim % 2 == 0, f"cond_dim must be even, got {cond_dim}"
assert cond_in_channels >= 1, f"cond_in_channels must be >= 1, got {cond_in_channels}"
ch_half = cond_dim // 2
self.cond_in_channels = cond_in_channels
self.conv1 = nn.Conv2d(cond_in_channels, ch_half, kernel_size=4, stride=4, padding=0)
self.norm1 = _gn(ch_half)
self.conv2 = nn.Conv2d(ch_half, ch_half, kernel_size=3, stride=1, padding=1)
self.norm2 = _gn(ch_half)
self.conv3 = nn.Conv2d(ch_half, cond_dim, kernel_size=4, stride=4, padding=0)
self.norm3 = _gn(cond_dim)
self.resblocks = nn.ModuleList([_ResBlock(cond_dim) for _ in range(n_resblocks)])
self.aspp = _ASPP(cond_dim, aspp_dilations) if use_aspp else None
self.proj = nn.Conv2d(cond_dim, cond_emb_dim, kernel_size=1)
self.out_norm = nn.LayerNorm(cond_emb_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = F.silu(self.norm1(self.conv1(x)))
h = F.silu(self.norm2(self.conv2(h)))
h = F.silu(self.norm3(self.conv3(h)))
for rb in self.resblocks:
h = rb(h)
if self.aspp is not None:
h = self.aspp(h)
h = self.proj(h)
b, c, hh, ww = h.shape
h = h.view(b, c, hh * ww).permute(0, 2, 1).contiguous()
h = self.out_norm(h)
return h
# ----------------------------------------------------------------------------
# LLLite module (v2: FiLM + SiLU + 5D path + per-module depth embedding)
# ----------------------------------------------------------------------------
class LLLiteModuleDiT(nn.Module):
def __init__(
self,
name: str,
in_dim: int,
cond_emb_dim: int,
mlp_dim: int,
dropout: float | None = None,
multiplier: float = 1.0,
):
super().__init__()
self.lllite_name = name
self.in_dim = in_dim
self.cond_emb_dim = cond_emb_dim
self.mlp_dim = mlp_dim
self.dropout = dropout
self.multiplier = multiplier
self.down = nn.Linear(in_dim, mlp_dim)
self.mid = nn.Linear(mlp_dim + cond_emb_dim, mlp_dim)
# FiLM: cond_local -> (gamma, beta), zero-init for identity at start.
self.cond_to_film = nn.Linear(cond_emb_dim, 2 * mlp_dim)
nn.init.zeros_(self.cond_to_film.weight)
nn.init.zeros_(self.cond_to_film.bias)
self.up = nn.Linear(mlp_dim, in_dim)
nn.init.zeros_(self.up.weight)
nn.init.zeros_(self.up.bias)
self.depth_embed = nn.Parameter(torch.zeros(cond_emb_dim))
self.cond_emb: torch.Tensor | None = None
# Wrapped in a list so the original Linear is not registered as a
# submodule and its weights stay out of state_dict.
self._org_module: list[nn.Linear] = []
self._org_forward: Callable[[torch.Tensor], torch.Tensor] | None = None
self._org_forward_was_instance_attr = False
def bind(self, org_module: nn.Linear) -> None:
self.unbind()
self._org_module = [org_module]
self._org_forward = org_module.forward
self._org_forward_was_instance_attr = "forward" in org_module.__dict__
org_module.forward = self.forward # type: ignore[method-assign]
def unbind(self) -> None:
if self._org_forward is not None:
org_module = self._org_module[0]
if self._org_forward_was_instance_attr:
org_module.forward = self._org_forward # type: ignore[method-assign]
else:
# Restoring by assignment would pin a frozen bound method in the
# instance __dict__, which silently bypasses later class-level
# forward swaps that share the module __dict__ (see
# wrap_custom_layer notes in model_manager/load/load_default.py).
del org_module.__dict__["forward"]
self._org_forward = None
self._org_module = []
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Input layouts:
# self/cross attention q/k/v: (B, S, D) — already flattened in the Anima block
# mlp.layer1: (B, T, H, W, D) — passed un-flattened
# Flatten the 5D case to 3D for the LLLite path and reshape on exit.
assert self._org_forward is not None
if self.multiplier == 0.0 or self.cond_emb is None:
return self._org_forward(x)
orig_shape = x.shape
is_5d = x.dim() == 5
if is_5d:
b, t, hh, ww, d = orig_shape
x = x.reshape(b, t * hh * ww, d)
cx = self.cond_emb # (B_c, S, cond_emb_dim)
# Broadcast cond_emb to the runtime batch (CFG cond+uncond, multi-cond).
if x.shape[0] != cx.shape[0]:
if x.shape[0] % cx.shape[0] != 0:
return self._org_forward(x.reshape(orig_shape) if is_5d else x)
cx = cx.repeat(x.shape[0] // cx.shape[0], 1, 1)
if x.shape[1] != cx.shape[1]:
return self._org_forward(x.reshape(orig_shape) if is_5d else x)
# Run the LLLite mini-MLP in its own parameter dtype, then cast the
# correction back to ``x``'s dtype before adding. Robust to autocast
# flows where x and LLLite weights have different dtypes.
param_dtype = self.down.weight.dtype
x_proc = x if x.dtype == param_dtype else x.to(param_dtype)
if cx.dtype != param_dtype or cx.device != x.device:
cx = cx.to(device=x.device, dtype=param_dtype)
depth_e = self.depth_embed
if depth_e.dtype != param_dtype or depth_e.device != x.device:
depth_e = depth_e.to(device=x.device, dtype=param_dtype)
cond_local = cx + depth_e
h = F.silu(self.down(x_proc))
gb = self.cond_to_film(cond_local)
gamma, beta = gb.chunk(2, dim=-1)
m = self.mid(torch.cat([cond_local, h], dim=-1))
m = m * (1 + gamma) + beta
m = F.silu(m)
if self.dropout is not None and self.training:
m = F.dropout(m, p=self.dropout)
out = self.up(m) * self.multiplier
if out.dtype != x.dtype:
out = out.to(x.dtype)
y = self._org_forward(x + out)
if is_5d:
# org Linear out_features may differ from in_features — recover with -1.
y = y.reshape(orig_shape[0], orig_shape[1], orig_shape[2], orig_shape[3], -1)
return y
# ----------------------------------------------------------------------------
# AnimaControlNetLLLite
# ----------------------------------------------------------------------------
def _meta_int(metadata: dict[str, str], key: str, fallback: int) -> int:
value = metadata.get(key)
return int(value) if value is not None else fallback
def _meta_bool(metadata: dict[str, str], key: str, fallback: bool) -> bool:
value = metadata.get(key)
return str(value).lower() == "true" if value is not None else fallback
class AnimaControlNetLLLite(nn.Module):
"""Self-contained, cacheable LLLite adapter for the Anima transformer.
Construct via :meth:`from_state_dict`; bind to a transformer with
:meth:`apply_to` and undo with :meth:`restore`.
"""
def __init__(
self,
module_specs: Sequence[tuple[str, int]],
cond_emb_dim: int,
mlp_dim: int,
cond_dim: int,
cond_resblocks: int,
use_aspp: bool = False,
aspp_dilations: tuple[int, ...] = ASPP_DEFAULT_DILATIONS,
cond_in_channels: int = 3,
inpaint_masked_input: bool = False,
multiplier: float = 1.0,
):
super().__init__()
self.cond_emb_dim = cond_emb_dim
self.mlp_dim = mlp_dim
self.cond_dim = cond_dim
self.cond_resblocks = cond_resblocks
self.use_aspp = use_aspp
self.cond_in_channels = cond_in_channels
# Training-time RGB-masking policy for cond image preparation; does not
# alter the forward pass.
self.inpaint_masked_input = inpaint_masked_input
self.multiplier = multiplier
self.conditioning1 = _Conditioning1(
cond_dim,
cond_emb_dim,
cond_resblocks,
use_aspp=use_aspp,
aspp_dilations=aspp_dilations,
cond_in_channels=cond_in_channels,
)
modules = []
for name, in_dim in module_specs:
if MODULE_NAME_PATTERN.match(name) is None:
raise ValueError(f"Unrecognized LLLite module name: '{name}'")
modules.append(LLLiteModuleDiT(name, in_dim, cond_emb_dim, mlp_dim, multiplier=multiplier))
self.lllite_modules = nn.ModuleList(modules)
@classmethod
def from_state_dict(
cls, state_dict: dict[str, torch.Tensor], metadata: dict[str, str] | None
) -> AnimaControlNetLLLite:
"""Build the adapter from a saved v2 named-key state dict.
Hyperparams come from ``lllite.*`` metadata when present, with
state-dict-shape fallbacks. ``inpaint_masked_input`` is metadata-only
(not derivable from shapes; defaults to False).
"""
meta = metadata or {}
if any(k.startswith(_LEGACY_MODULES_PREFIX) for k in state_dict):
raise ValueError(
f"State dict appears to be in a legacy ControlNet-LLLite weight format (keys starting "
f"with '{_LEGACY_MODULES_PREFIX}'). Only the v2 named-key format is supported."
)
module_names: set[str] = set()
for key in state_dict:
head, dot, _tail = key.partition(".")
if dot and MODULE_NAME_PATTERN.match(head):
module_names.add(head)
if not module_names:
raise ValueError("State dict contains no LLLite modules (no 'lllite_dit_blocks_*' keys).")
def sort_key(name: str) -> tuple[int, int]:
match = MODULE_NAME_PATTERN.match(name)
assert match is not None
return int(match.group(1)), _SUFFIX_ORDER.index(match.group(2))
sorted_names = sorted(module_names, key=sort_key)
module_specs: list[tuple[str, int]] = []
for name in sorted_names:
down_key = f"{name}.down.weight"
if down_key not in state_dict:
raise ValueError(f"LLLite module '{name}' is missing key '{down_key}'")
module_specs.append((name, state_dict[down_key].shape[1]))
conv1_weight = state_dict[f"{_SAVED_COND_PREFIX}conv1.weight"]
conv3_weight = state_dict[f"{_SAVED_COND_PREFIX}conv3.weight"]
proj_weight = state_dict[f"{_SAVED_COND_PREFIX}proj.weight"]
resblock_indices = {
m.group(1) for m in (re.match(rf"^{_SAVED_COND_PREFIX}resblocks\.(\d+)\.", k) for k in state_dict) if m
}
has_aspp_keys = any(k.startswith(f"{_SAVED_COND_PREFIX}aspp.") for k in state_dict)
use_aspp = _meta_bool(meta, "lllite.use_aspp", has_aspp_keys)
aspp_dilations_meta = meta.get("lllite.aspp_dilations")
if use_aspp and aspp_dilations_meta:
aspp_dilations = tuple(int(d) for d in aspp_dilations_meta.split(",") if d.strip())
else:
aspp_dilations = ASPP_DEFAULT_DILATIONS
model = cls(
module_specs=module_specs,
cond_emb_dim=_meta_int(meta, "lllite.cond_emb_dim", proj_weight.shape[0]),
mlp_dim=_meta_int(meta, "lllite.mlp_dim", state_dict[f"{sorted_names[0]}.down.weight"].shape[0]),
cond_dim=_meta_int(meta, "lllite.cond_dim", conv3_weight.shape[0]),
cond_resblocks=_meta_int(meta, "lllite.cond_resblocks", len(resblock_indices)),
use_aspp=use_aspp,
aspp_dilations=aspp_dilations,
cond_in_channels=_meta_int(meta, "lllite.cond_in_channels", conv1_weight.shape[1]),
inpaint_masked_input=_meta_bool(meta, "lllite.inpaint_masked_input", False),
)
name_to_idx = {name: i for i, name in enumerate(sorted_names)}
remapped: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
if key.startswith(_SAVED_COND_PREFIX):
remapped[_INTERNAL_COND_PREFIX + key[len(_SAVED_COND_PREFIX) :]] = value
continue
head, dot, tail = key.partition(".")
if dot and head in name_to_idx:
remapped[f"{_INTERNAL_MODULES_PREFIX}{name_to_idx[head]}.{tail}"] = value
else:
# Unknown keys are passed through so strict loading reports them.
remapped[key] = value
model.load_state_dict(remapped, strict=True)
model.eval().requires_grad_(False)
return model
def set_cond_image(self, cond: torch.Tensor | None) -> None:
"""cond: (B, cond_in_channels, H_t, W_t) in [-1, 1]; ``None`` clears."""
if cond is None:
for m in self.lllite_modules:
m.cond_emb = None
return
trunk_weight = self.conditioning1.conv1.weight
cond = cond.to(device=trunk_weight.device, dtype=trunk_weight.dtype)
cx = self.conditioning1(cond) # (B, S, cond_emb_dim)
for m in self.lllite_modules:
m.cond_emb = cx
def clear_cond_image(self) -> None:
self.set_cond_image(None)
def set_multiplier(self, multiplier: float) -> None:
self.multiplier = multiplier
for m in self.lllite_modules:
m.multiplier = multiplier
def apply_to(self, transformer: nn.Module) -> None:
"""Swap the forward of each target Linear in ``transformer``. Idempotent."""
self.restore()
for m in self.lllite_modules:
target = self._resolve_target(transformer, m.lllite_name)
if not isinstance(target, nn.Linear):
raise TypeError(f"LLLite target for '{m.lllite_name}' is {type(target).__name__}, expected nn.Linear")
if target.in_features != m.in_dim:
raise ValueError(
f"LLLite module '{m.lllite_name}' was trained for in_features={m.in_dim}, but the "
f"target Linear has in_features={target.in_features}"
)
m.bind(target)
def restore(self) -> None:
"""Undo :meth:`apply_to`. Safe to call when not applied.
LIFO contract: each bind saves the forward that was CURRENT at bind
time, so when multiple adapters are stacked on one transformer they
must be restored in reverse apply order. Restoring an earlier adapter
first would delete a later adapter's wrapper and re-pin the earlier
one's saved forward.
"""
for m in self.lllite_modules:
m.unbind()
@staticmethod
def _resolve_target(transformer: nn.Module, name: str) -> nn.Module:
match = MODULE_NAME_PATTERN.match(name)
if match is None:
raise ValueError(f"Unrecognized LLLite module name: '{name}'")
block_idx = int(match.group(1))
blocks = transformer.blocks
if block_idx >= len(blocks):
raise ValueError(
f"LLLite module '{name}' targets block {block_idx}, but the transformer has only {len(blocks)} blocks"
)
target: nn.Module = blocks[block_idx]
for attr in _SUFFIX_TO_ATTR_PATH[match.group(2)]:
target = getattr(target, attr)
return target