feat: Anima LLLite inpainting support (#2378)

* feat(inpainting): 4ch conditioning support with mask image handling and training adjustments

* refactor(mask_generator): 政策の変更によりポリゴンと形状マスクの中心をキャンバス全体に均等に配置
refactor(inpainting): マスクチャンネルを[-1, 1]に正規化してRGB範囲に一致させる

* fix(imports): accelerator_setupをインポートに追加

* fix: support for multi-GPU distributed inference with PartialState for Anima training

* Add per-block torch.compile support for Anima ControlNet-LLLite

Wire the shared --compile / --compile_* per-block torch.compile path
(already used by anima_train_network.py) into the LLLite trainer, and
fix two LLLite-specific patterns that defeated dynamo's per-block graph
reuse and caused per-step recompilation:

- Move the per-module depth-embedding addition out of the compiled
  forward into set_cond_image. The forward previously indexed
  depth_embeds[self.layer_idx] with a per-block Python int, which dynamo
  bakes as a static constant, forcing a distinct graph per block. The
  addition is now done outside the compiled region (still inside the
  autograd scope, so grad to depth_embeds is unchanged); numerically
  equivalent (depth_embeds is zero-init). Drops the now-dead
  _depth_embeds_ref attribute.

- Disable force_nn_module_property_static_shapes in compile_transformer.
  LLLite injects the conditioning via the nn.Module attribute
  self.cond_emb, whose shape dynamo specializes statically by default
  even with dynamic=True (which only applies to forward inputs), causing
  a recompile per resolution bucket. Allowing module-property shapes to
  be dynamic collapses this to a single warmup compile.

Also add the --compile x --torch_compile and --compile_fullgraph x
--split_attn exclusivity asserts, apply CUDA perf switches, and document
the feature in docs/anima_train_control_net_lllite.md (links to
anima_torch_compile.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address Copilot review on Anima LLLite inpainting

- LLLite: share cond embedding cx across modules and add per-module
  depth embedding inside forward, avoiding N copies of (B, S, cond_emb_dim).
  depth_embeds is re-indexed per step in set_cond_image (outside the
  compiled region) so multi-step backward keeps flowing grads and
  torch.compile still emits a single graph.
- LLLite: emit the inpaint log suffix only for cond_in_channels == 4,
  consistent with the rest of the inpaint detection.
- sampling: strip the --mk mask path like the --i image flag.
- docs: fix the mask convention to state the mask is rescaled to
  {-1, 1} via mask * 2 - 1 (EN/JA), matching the implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Kohya S.
2026-06-16 08:00:05 +09:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ac67c122a2
commit ce2581e47a
8 changed files with 543 additions and 56 deletions
+81 -1
View File
@@ -74,6 +74,36 @@ def _load_control_image(
return t.to(device=device, dtype=dtype)
def _load_mask_image(
path: str, height: int, width: int, device: torch.device, dtype: torch.dtype
) -> torch.Tensor:
"""Load and binarize a mask image to a (1, 1, H, W) tensor in {0, 1}.
1.0 = inpaint area (穴), 0.0 = keep.
"""
img = Image.open(path).convert("L")
if img.size != (width, height):
img = img.resize((width, height), Image.NEAREST)
arr = np.asarray(img).astype(np.float32) / 255.0
arr = (arr >= 0.5).astype(np.float32)
t = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0).contiguous()
return t.to(device=device, dtype=dtype)
def _build_inpaint_cond_image(
rgb: torch.Tensor, mask: torch.Tensor, masked_input: bool
) -> torch.Tensor:
"""rgb: (B, 3, H, W) in [-1, 1], mask: (B, 1, H, W) in {0, 1}. Return (B, 4, H, W).
The mask channel is normalized to [-1, 1] (= (mask - 0.5) * 2) to match the RGB range.
"""
if masked_input:
keep = (mask < 0.5).to(rgb.dtype)
rgb = rgb * keep
# mask channel: {0, 1} -> {-1, 1}. matches transforms.Normalize([0.5], [0.5])
mask_pm1 = mask.to(rgb.dtype) * 2.0 - 1.0
return torch.cat([rgb, mask_pm1], dim=1)
# ---------------------------------------------------------------------------
# parse_args (replaces ami.parse_args)
# ---------------------------------------------------------------------------
@@ -162,6 +192,21 @@ def parse_args() -> argparse.Namespace:
"--lllite_use_aspp", type=str, default=None, choices=["true", "false"],
help="override use_aspp from weights metadata (true/false)",
)
parser.add_argument(
"--mask_image", type=str, default=None,
help=(
"[inpaint] global mask image. Required for single-prompt inpainting (cond_in_channels=4). "
"Per-prompt override: --mk <path>."
),
)
parser.add_argument(
"--lllite_cond_in_channels", type=int, default=None,
help="override cond_in_channels from weights metadata (3 or 4)",
)
parser.add_argument(
"--lllite_inpaint_masked_input", type=str, default=None, choices=["true", "false"],
help="override inpaint_masked_input from weights metadata (true/false)",
)
args = parser.parse_args()
@@ -222,6 +267,8 @@ def parse_prompt_line(line: str) -> Dict[str, Any]:
overrides["negative_prompt"] = value
elif option == "cn":
overrides["control_image"] = value
elif option == "mk":
overrides["mask_image"] = value
elif option == "am":
overrides["lllite_multiplier"] = float(value)
@@ -275,12 +322,26 @@ def load_dit_model(args, device, dit_weight_dtype=None):
else:
from networks.control_net_lllite_anima import ASPP_DEFAULT_DILATIONS as _ASPP_DD
aspp_dilations = _ASPP_DD
cond_in_channels = (
args.lllite_cond_in_channels
if args.lllite_cond_in_channels is not None
else int(meta.get("lllite.cond_in_channels", 3))
)
if args.lllite_inpaint_masked_input is not None:
inpaint_masked_input = args.lllite_inpaint_masked_input == "true"
else:
inpaint_masked_input = (
meta.get("lllite.inpaint_masked_input", "false").lower() == "true"
)
version = meta.get("lllite.version", "?")
inpaint_log = (
f", inpaint=on(masked_input={inpaint_masked_input})" if cond_in_channels == 4 else ""
)
logger.info(
f"LLLite config (v{version}): cond_emb_dim={cond_emb_dim}, mlp_dim={mlp_dim}, "
f"target_layers={target_layers}, cond_dim={cond_dim}, cond_resblocks={cond_resblocks}, "
f"use_aspp={use_aspp}{(' dilations=' + str(list(aspp_dilations))) if use_aspp else ''}, "
f"multiplier={args.lllite_multiplier}"
f"cond_in_channels={cond_in_channels}{inpaint_log}, multiplier={args.lllite_multiplier}"
)
lllite = ControlNetLLLiteDiT(
@@ -293,6 +354,8 @@ def load_dit_model(args, device, dit_weight_dtype=None):
cond_resblocks=cond_resblocks,
use_aspp=use_aspp,
aspp_dilations=aspp_dilations,
cond_in_channels=cond_in_channels,
inpaint_masked_input=inpaint_masked_input,
)
load_lllite_weights(lllite, args.lllite_weights, strict=False)
lllite.apply_to()
@@ -333,6 +396,23 @@ def generate_body(
if not hasattr(anima, "lllite"):
raise RuntimeError("DiT has no .lllite attribute; load_dit_model patch was not applied")
# inpainting (4ch): require a mask image; concat to cond_image as 4th channel
if anima.lllite.cond_in_channels == 4:
mk_path = getattr(args, "mask_image", None)
if mk_path is None:
raise ValueError(
"mask_image is required for 4-channel (inpaint) LLLite. "
"Specify --mask_image globally, or --mk per prompt in --from_file mode."
)
mask = _load_mask_image(mk_path, height, width, device, torch.bfloat16)
cond_image = _build_inpaint_cond_image(
cond_image, mask, anima.lllite.inpaint_masked_input
)
logger.info(
f"Loaded mask image: {mk_path} -> 4ch cond_image {tuple(cond_image.shape)}"
f" (masked_input={anima.lllite.inpaint_masked_input})"
)
# honor per-prompt override of multiplier
anima.lllite.set_multiplier(args.lllite_multiplier)
anima.lllite.set_cond_image(cond_image)
+142 -2
View File
@@ -37,6 +37,7 @@ from library import (
)
import library.accelerator_setup as accelerator_setup
import library.args as args_util
import library.compile_utils as compile_utils
import library.dataset as dataset_util
import library.model_io as model_io
import library.optimizer as optimizer_util
@@ -59,6 +60,7 @@ from networks.control_net_lllite_anima import (
PRESETS as LLLITE_PRESETS,
ATOMIC_SPECIFIERS as LLLITE_ATOMIC_SPECIFIERS,
)
from library.mask_generator import random_mask as _gen_random_mask
setup_logging()
import logging
@@ -74,12 +76,62 @@ def _load_control_image(path: str, width: int, height: int, device, dtype) -> to
return tensor.to(device=device, dtype=dtype)
def _load_mask_image(path: str, width: int, height: int, device, dtype) -> torch.Tensor:
"""Load a mask image and return (1, 1, H, W) in {0, 1}.
1.0 = inpaint area (穴), 0.0 = keep.
"""
img = Image.open(path).convert("L").resize((width, height), Image.NEAREST)
arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr >= 0.5).astype(np.float32)
tensor = torch.from_numpy(arr).unsqueeze(0).unsqueeze(0).contiguous() # (1, 1, H, W)
return tensor.to(device=device, dtype=dtype)
def _build_inpaint_cond_image(
rgb: torch.Tensor,
masks: torch.Tensor,
masked_input: bool,
) -> torch.Tensor:
"""rgb: (B, 3, H, W) in [-1, 1], masks: (B, 1, H, W) in {0, 1} (1=inpaint).
Returns (B, 4, H, W) with the mask channel normalized to [-1, 1] to match the RGB range.
masked_input=True のとき、RGB を mask 域で 0 に潰してから concat する。
"""
if masked_input:
keep = (masks < 0.5).to(rgb.dtype) # (B, 1, H, W)
rgb = rgb * keep
# mask channel: {0, 1} -> {-1, 1} (= (mask - 0.5) * 2). matches transforms.Normalize([0.5], [0.5])
mask_pm1 = masks.to(rgb.dtype) * 2.0 - 1.0
return torch.cat([rgb, mask_pm1], dim=1)
def _generate_random_masks_for_batch(
batch_size: int, height: int, width: int, device, dtype
) -> torch.Tensor:
"""library.mask_generator.random_mask を使ってバッチ分のランダム mask を生成する.
返り値: (B, 1, H, W) in {0, 1} (1=inpaint, 0=keep)."""
masks_np = np.empty((batch_size, 1, height, width), dtype=np.float32)
for i in range(batch_size):
pil = _gen_random_mask(width, height) # PIL "L", 0=keep / 255=inpaint
arr = np.asarray(pil, dtype=np.float32) / 255.0
arr = (arr >= 0.5).astype(np.float32)
masks_np[i, 0] = arr
return torch.from_numpy(masks_np).to(device=device, dtype=dtype)
def _make_lllite_sample_hooks(args, lllite, dit_dtype):
"""Build (on_prompt_start, on_prompt_end) callbacks that wire control image / multiplier
into the LLLite module before each sample prompt is rendered. The pre-sample multiplier is
saved and restored so that, e.g., `--am 0` for inspection does not leak into training (which
would otherwise hit the multiplier==0 short-circuit in LLLiteModuleDiT.forward and break
backward by yielding a graph with no grad)."""
backward by yielding a graph with no grad).
In inpainting mode (cond_in_channels=4) the prompt line additionally accepts `--mk <path>`
for the mask image. If the mask is missing/not found the prompt is rendered without LLLite cond
(warning logged), so users can intentionally inspect the base DiT.
"""
is_inpaint = lllite.cond_in_channels == 4
saved = {"multiplier": None}
@@ -113,7 +165,28 @@ def _make_lllite_sample_hooks(args, lllite, dit_dtype):
h = prompt_dict.get("height", 512)
h = max(64, h - h % 16)
w = max(64, w - w % 16)
cond_image = _load_control_image(ci_path, w, h, accelerator.device, dit_dtype)
rgb = _load_control_image(ci_path, w, h, accelerator.device, dit_dtype)
if is_inpaint:
mk_path = prompt_dict.get("mask_image")
if mk_path is None:
logger.warning(
"inpaint LLLite: no mask image for sample prompt (use '--mk <path>'); "
"running base DiT without LLLite cond"
)
lllite.clear_cond_image()
return
if not os.path.isfile(mk_path):
logger.warning(
f"inpaint LLLite: mask image not found: {mk_path}; running base DiT without LLLite cond"
)
lllite.clear_cond_image()
return
mask = _load_mask_image(mk_path, w, h, accelerator.device, dit_dtype)
cond_image = _build_inpaint_cond_image(rgb, mask, args.lllite_inpaint_masked_input)
else:
cond_image = rgb
lllite.set_cond_image(cond_image)
def on_prompt_end(prompt_dict: dict):
@@ -184,6 +257,25 @@ def add_anima_lllite_arguments(parser: argparse.ArgumentParser):
default=None,
help="pretrained LLLite weights to resume from / 学習を再開する LLLite の初期重み",
)
parser.add_argument(
"--lllite_cond_in_channels",
type=int,
default=3,
help=(
"number of input channels for the LLLite conditioning1 trunk (default: 3, RGB only). "
"Set to 4 to enable inpainting mode (RGB + 1ch mask). "
"/ LLLite の conditioning1 入力チャネル数。デフォルト 3 (RGB)、4 で inpainting 用 (RGB+mask)"
),
)
parser.add_argument(
"--lllite_inpaint_masked_input",
action="store_true",
help=(
"[inpaint] additionally zero out RGB inside the mask region before concatenating with mask. "
"Only effective when --lllite_cond_in_channels=4. "
"/ inpainting 時、RGB の mask 域を 0 で穴埋めしてから concat する (cond_in_channels=4 のときのみ有効)"
),
)
# --conditioning_data_dir は args_util.add_dataset_arguments 側で既に定義済み
@@ -217,6 +309,17 @@ def train(args):
"fused_backward_pass is not supported in Anima ControlNet-LLLite training (MVP)"
)
# per-block torch.compile の排他チェック (anima_train_network.assert_extra_args と同等)
if args.compile:
assert not args.torch_compile, (
"--compile (per-block torch.compile) and --torch_compile (accelerate dynamo) cannot be used together"
" / --compile(ブロック単位torch.compile)と--torch_compile(accelerate dynamo)は併用できません"
)
assert not (args.compile_fullgraph and args.split_attn), (
"--compile_fullgraph cannot be used with --split_attn (split attention uses dynamic control flow)"
" / --compile_fullgraphは--split_attnと併用できません(split attentionは動的な制御フローを使用します)"
)
cache_latents = args.cache_latents
if args.seed is not None:
@@ -382,6 +485,16 @@ def train(args):
dit.requires_grad_(False)
# inpainting (4ch) フラグの早期検証
if args.lllite_cond_in_channels < 1:
raise ValueError(f"--lllite_cond_in_channels must be >= 1, got {args.lllite_cond_in_channels}")
if args.lllite_inpaint_masked_input and args.lllite_cond_in_channels != 4:
logger.warning(
f"--lllite_inpaint_masked_input is only effective when --lllite_cond_in_channels=4 "
f"(got {args.lllite_cond_in_channels}); flag will be ignored at runtime"
)
is_inpaint = args.lllite_cond_in_channels == 4
# Build LLLite (DiT を走査して各 Attention Linear に貼る)
logger.info("Building ControlNet-LLLite (Anima)...")
lllite = ControlNetLLLiteDiT(
@@ -394,6 +507,8 @@ def train(args):
cond_dim=args.lllite_cond_dim,
cond_resblocks=args.lllite_cond_resblocks,
use_aspp=args.lllite_use_aspp,
cond_in_channels=args.lllite_cond_in_channels,
inpaint_masked_input=args.lllite_inpaint_masked_input,
)
if args.network_weights is not None:
@@ -473,6 +588,17 @@ def train(args):
if args.full_fp16:
accelerator_setup.patch_accelerator_for_fp16_training(accelerator)
# CUDA perf switches are independent of torch.compile; apply whenever requested.
compile_utils.apply_cuda_optimizations(args)
if args.compile:
# per-block torch.compile を凍結 DiT のブロックに適用する。LLLite の forward 差し替え
# (apply_to) と accelerator.prepare の後でなければならない。block swap は MVP で無効
# のため disable_linear=False 固定。LLLite モジュールは対象 Linear の forward を差し替え
# ているため、compile は patch 済みの forward を取り込む (cond_emb はガード付き入力扱い)。
dit_to_compile = accelerator.unwrap_model(wrapper).dit
compile_utils.compile_transformer(args, dit_to_compile, [dit_to_compile.blocks], disable_linear=False)
args_util.resume_from_local_or_hf_if_specified(accelerator, args)
# epoch計算
@@ -553,6 +679,10 @@ def train(args):
sai_metadata["lllite.use_aspp"] = "true" if args.lllite_use_aspp else "false"
if args.lllite_use_aspp:
sai_metadata["lllite.aspp_dilations"] = ",".join(str(d) for d in unwrapped.aspp_dilations)
sai_metadata["lllite.cond_in_channels"] = str(args.lllite_cond_in_channels)
sai_metadata["lllite.inpaint_masked_input"] = (
"true" if args.lllite_inpaint_masked_input else "false"
)
save_lllite_model(ckpt_file, unwrapped, dtype=save_dtype, metadata=sai_metadata)
def _save_step(global_step_: int, epoch_: int):
@@ -658,6 +788,16 @@ def train(args):
# cond image: dataset 側で IMAGE_TRANSFORMS により [-1,1] 正規化済み
cond_image = batch["conditioning_images"].to(accelerator.device, dtype=dit_weight_dtype)
# inpainting: ランダム mask をバッチ毎に生成し、cond_image を 4ch (RGB + mask) 化
if is_inpaint:
bs_c, _, h_c, w_c = cond_image.shape
mask = _generate_random_masks_for_batch(
bs_c, h_c, w_c, accelerator.device, dit_weight_dtype
)
cond_image = _build_inpaint_cond_image(
cond_image, mask, args.lllite_inpaint_masked_input
)
# 5D化
noisy_model_input = noisy_model_input.unsqueeze(2) # (B, C, 1, H, W)
+138 -1
View File
@@ -352,6 +352,8 @@ The metadata records `modelspec.architecture = "anima-preview/control-net-lllite
| `lllite.mlp_dim` | per-module MLP / FiLM hidden dim |
| `lllite.target_layers` | the user-supplied `--lllite_target_layers` string verbatim |
| `lllite.target_atomics` | resolved canonical atomic specifier list (comma-separated) |
| `lllite.cond_in_channels` | conditioning1 input channel count (`"3"` standard, `"4"` inpaint) |
| `lllite.inpaint_masked_input` | `"true"` if RGB was masked before concat at train time (inpaint only) |
The inference script (Section 6) reads these back, so you normally do not need to specify the architecture options on the command line again. Currently the inference script **requires** the metadata to reconstruct the architecture: state-dict-only auto-detection (e.g. for hand-edited weight files) is not implemented.
@@ -402,6 +404,8 @@ lllite_conditioning1.out_norm.{weight,bias}
| `lllite.mlp_dim` | LLLite モジュール毎の MLP / FiLM 中間次元 |
| `lllite.target_layers` | ユーザが指定した `--lllite_target_layers` 文字列をそのまま記録 |
| `lllite.target_atomics` | 解決後の canonical atomic specifier 列(カンマ区切り) |
| `lllite.cond_in_channels` | conditioning1 入力チャネル数(`"3"` 通常、`"4"` inpainting) |
| `lllite.inpaint_masked_input` | 学習時 RGB を mask 域で 0 化してから concat したか(inpaint 専用、`"true"`/`"false"`) |
これらは推論スクリプト(第 6 節)で自動的に読み出されるため、通常はコマンドラインで再指定する必要はありません。現在の推論スクリプトはアーキテクチャ復元に**メタデータが必須**で、state_dict 単独からの自動判定(手編集された重みファイル等)には対応していません。
@@ -480,12 +484,144 @@ CFG 推論(cond / uncond の 2 pass)は両 pass に同じ `cond_emb` を配
</details>
## 7. Tips & Limitations / 補足と制限
## 7. Inpainting Support / Inpainting 対応
> **Status:** experimental. Adds an optional 4-channel conditioning path (RGB + 1ch binary mask). When enabled, LLLite is trained to fill in the masked region of an image given the rest of it as conditioning.
When `--lllite_cond_in_channels=4` is passed, the LLLite `conditioning1` trunk accepts a 4-channel input instead of the default 3 channels: `[R, G, B, mask]`. The training script generates a fresh random mask per sample at every step (via `library.mask_generator.random_mask`) and concatenates it with the conditioning RGB image. At inference time you pass the control image and the mask separately (the script concatenates them internally).
The default 3-channel behavior (no mask, generic ControlNet usage) is unchanged. The same code path is intentionally generic so that future 4-channel conditioning (e.g. user-supplied control masks for lighting / region control) can reuse the same trunk; for the initial release only inpainting is wired up.
### 7.1. Mask Convention / マスク規約
* `white` (1.0) = **inpaint area** — the region the model should fill in / 生成すべき穴.
* `black` (0.0) = **keep** — the region whose pixels are preserved.
The mask is binarized at 0.5 on load (`{0.0, 1.0}`) and then rescaled to `{-1.0, 1.0}` via `mask * 2 - 1` before being concatenated as the 4th channel, so that the mask channel matches the `[-1, 1]` range of the RGB conditioning channels.
### 7.2. Dataset Setup / データセット設定
For inpainting, **the conditioning image is the original training image itself**. The simplest way to wire this up without modifying the dataset code is to point `conditioning_data_dir` at the same directory as `image_dir` (or a symlink / copy of it). The same image is then loaded twice per step (once as the training target, once as the LLLite conditioning), and the random mask is generated on the fly inside the training script.
Minimal TOML:
```toml
[general]
caption_extension = ".txt"
shuffle_caption = false
[[datasets]]
resolution = 1024
batch_size = 1
[[datasets.subsets]]
image_dir = "/path/to/training_images"
conditioning_data_dir = "/path/to/training_images" # same as image_dir for inpainting
num_repeats = 1
```
The CLI form `--train_data_dir <dir> --conditioning_data_dir <dir>` works analogously (point both at the same directory).
### 7.3. Training-Specific Arguments / 学習引数
* `--lllite_cond_in_channels=<int>` (default `3`)
* `3` = standard ControlNet-LLLite (RGB-only conditioning, no mask).
* `4` = inpainting (RGB + 1ch mask). Activates random-mask generation per step.
* `--lllite_inpaint_masked_input` (flag, default off)
* Only effective when `--lllite_cond_in_channels=4`. If set, the RGB channels of the conditioning image are zeroed in the mask region **before** being concatenated with the mask channel (so the model never sees the ground-truth pixels behind the hole). Without this flag, the model sees the full RGB image plus the mask channel — the model is expected to learn to ignore the masked region from the mask signal. The flag is recorded in the saved metadata as `lllite.inpaint_masked_input`.
Example command (one line in practice; line continuations as in Section 3):
```bash
accelerate launch --num_cpu_threads_per_process 1 anima_train_control_net_lllite.py \
--pretrained_model_name_or_path="<path to Anima DiT model>" \
--qwen3="<path to Qwen3-0.6B model or directory>" \
--vae="<path to Qwen-Image VAE model>" \
--dataset_config="my_anima_lllite_inpaint.toml" \
--output_dir="<output directory>" \
--output_name="my_anima_lllite_inpaint" \
--save_model_as=safetensors \
--cond_emb_dim=32 --lllite_mlp_dim=64 --lllite_cond_dim=64 \
--lllite_target_layers=self_attn_q \
--lllite_cond_in_channels=4 \
--learning_rate=1e-4 --optimizer_type="AdamW8bit" --lr_scheduler="constant" \
--timestep_sampling="shift" --discrete_flow_shift=3.0 \
--max_train_epochs=10 --save_every_n_epochs=1 \
--mixed_precision="bf16" --gradient_checkpointing \
--cache_latents --cache_text_encoder_outputs \
--vae_chunk_size=64 --vae_disable_cache
```
### 7.4. Sample Image Generation / 学習中のサンプル生成
The sample prompt line accepts an additional flag in inpainting mode:
* `--mk <path>` — per-prompt mask image (white=inpaint, black=keep).
In inpaint mode (`cond_in_channels=4`), if `--mk` is omitted (or the file is not found) the prompt is rendered with the base DiT (LLLite cond cleared) and a warning is logged. The `--cn <path>` flag is still required (it supplies the RGB conditioning image).
Example `prompts.txt`:
```
a cat in a forest --w 1024 --h 1024 --cn original_a.png --mk mask_a.png --d 42
a sunset over mountains --w 1024 --h 1024 --cn original_b.png --mk mask_b.png
```
### 7.5. Inference / 推論
The inference script auto-detects the 4-channel mode from the saved metadata (`lllite.cond_in_channels`, `lllite.inpaint_masked_input`) and requires a mask alongside the control image:
* `--mask_image <path>` — global mask image (required for single-prompt inference when `cond_in_channels=4`).
* Prompt-line `--mk <path>` — per-prompt mask override in `--from_file` / `--interactive` mode.
* `--lllite_cond_in_channels {3,4}` / `--lllite_inpaint_masked_input {true,false}` — manual metadata overrides (usually unnecessary).
Single-prompt example:
```bash
python anima_minimal_inference_control_net_lllite.py \
--dit "<path to Anima DiT>" --vae "<path to Qwen-Image VAE>" --text_encoder "<path to Qwen3-0.6B>" \
--lllite_weights "out/my_anima_lllite_inpaint-last.safetensors" \
--control_image "original.png" --mask_image "mask.png" \
--prompt "a cat in a forest" --image_size 1024 1024 \
--infer_steps 50 --guidance_scale 3.5 --save_path "out/"
```
### 7.6. Limitations / 制限
* The model architecture is otherwise unchanged — the DiT itself is not modified for inpainting. All "where to fill" information must flow through the LLLite conditioning path.
* Only random masks are supported during training in this release; future versions may allow user-provided masks via dataset configuration.
* The 4-channel feature is implementation-orthogonal to ASPP, ResBlock count, target_layers, etc. Combine freely.
* `cond_in_channels` other than `3` and `4` are accepted by the architecture (it is generic), but only `4` is wired up to the random-mask training path. Custom values are intended for future user-supplied channels.
<details>
<summary>日本語</summary>
`--lllite_cond_in_channels=4` を指定すると、`conditioning1` の入力チャネルが 3 → 4 になり、`[R, G, B, mask]` を受け付けるようになります。学習時は `library.mask_generator.random_mask` でステップ毎にランダム mask を生成し、`conditioning_images` (= 元画像) と concat して渡します。3ch のデフォルト動作は変更されません。
**マスク規約**: 白 (1.0) = inpaint 対象 (穴) / 黒 (0.0) = 保持。ロード時に 0.5 で二値化 (`{0.0, 1.0}`) した後、`mask * 2 - 1` で `{-1.0, 1.0}` にスケールしてから 4ch 目として concat します (RGB conditioning チャンネルの `[-1, 1]` レンジに合わせるため)。
**データセット**: inpainting では「conditioning 画像 = 元画像」となるため、`conditioning_data_dir` には `image_dir` と同じパス (もしくは symlink / コピー) を指定してください。データセットコード側は無改修です。
**学習引数**:
- `--lllite_cond_in_channels=4`: inpainting モードを有効化 (ステップ毎ランダム mask 生成 + 4ch 化)。
- `--lllite_inpaint_masked_input`: フラグ。立てると RGB を mask 域で 0 化してから concat します (穴の中の真値は見せない)。OFF だと RGB はそのまま渡し、mask チャネル経由で「どこが穴か」を学習させます。メタデータ `lllite.inpaint_masked_input` に記録されます。
**サンプル画像生成**: prompt 行に `--mk <path>` を追加できます。inpaint モードで `--mk` が指定されない/ファイルが見つからない場合は LLLite cond をクリアし素の DiT で生成し warning を出します。
**推論**: メタデータから自動的に 4ch モードを検出します。`--mask_image <path>` (グローバル) または prompt 行 `--mk <path>` (per-prompt) で mask を指定してください。
**制限**: DiT 本体は無改修なので、「どこを埋めるか」の情報は LLLite cond 経路を通る必要があります。現状ランダム mask のみ対応 (将来的にはユーザー指定 mask に対応予定)。`cond_in_channels` の `3`/`4` 以外も構築可能ですが、本リリースでは `4` のみが random-mask 学習パスに接続されています。
</details>
## 8. Tips & Limitations / 補足と制限
* **Resolution alignment.** The conditioning encoder uses fixed stride 16, so `cond_image` HW must equal `latent HW × 8` (i.e. the original training image size) (the latent is patchified with patch size=2, so stride is 8*2=16). The DataLoader for the ControlNet dataset already resizes the conditioning image to match the training image, so in practice you only need to make sure the control image you pass at inference time matches the requested `--image_size`.
* **`T=1` only.** Video-style multi-frame inputs are not supported — the wrapper asserts `T==1` at forward time.
* **Bucket size.** The training script enforces a bucket resolution step of 16 (Qwen-Image VAE /8 × patch /2).
* **Memory.** `--blocks_to_swap`, `--cpu_offload_checkpointing`, `--unsloth_offload_checkpointing` are not yet supported. If VRAM is tight, prefer `--full_bf16`, smaller `--lllite_mlp_dim`, lower `--cond_emb_dim`, and `--gradient_checkpointing`.
* **`torch.compile` (speed-up).** Per-block `torch.compile` of the DiT is supported here as well, via the same `--compile` / `--compile_*` arguments shared with the LoRA script. See [`anima_torch_compile.md`](./anima_torch_compile.md) for details and recommended settings. Note that with aspect-ratio bucketing each distinct bucket resolution triggers a one-time recompile, so the first pass over your buckets is slower before it settles.
* **Save format.** The saved `.safetensors` is **not** compatible with the SDXL LLLite format and **not** loadable by `sdxl_gen_img.py`. Use the dedicated inference script in Section 6.
* **Metadata-required at inference.** Inference relies on the architecture metadata (`lllite.version`, `lllite.cond_dim`, `lllite.cond_resblocks`, `lllite.use_aspp`, `lllite.target_atomics`, ...) saved by the training script to reconstruct the LLLite architecture. State-dict-only auto-detection of those fields is not implemented; if a weight file lacks metadata, you currently need to pass the override flags listed in Section 6.3 explicitly.
@@ -496,6 +632,7 @@ CFG 推論(cond / uncond の 2 pass)は両 pass に同じ `cond_emb` を配
* **`T=1` のみ.** 動画的な多フレーム入力はサポートしていません(wrapper の forward 冒頭で assert)。
* **bucket サイズ.** 学習スクリプトは bucket 解像度ステップを 16(Qwen-Image VAE /8 × patch /2)として検証します。
* **メモリ.** `--blocks_to_swap`、`--cpu_offload_checkpointing`、`--unsloth_offload_checkpointing` は未対応です。VRAM が厳しい場合は `--full_bf16`、`--lllite_mlp_dim` を下げる、`--cond_emb_dim` を下げる、`--gradient_checkpointing` を有効にする、などで対応してください。
* **`torch.compile`(高速化).** LoRA 学習スクリプトと共通の `--compile` / `--compile_*` 引数により、本スクリプトでも DiT のブロック単位 `torch.compile` が利用できます。詳細や推奨設定は [`anima_torch_compile.md`](./anima_torch_compile.md) を参照してください。なお aspect-ratio bucketing 使用時はバケット解像度ごとに初回 1 回の recompile が走るため、全バケットを一巡するまでは遅くなりますが、その後は安定します。
* **保存形式.** 保存される `.safetensors` は SDXL LLLite フォーマットとは**互換性がなく**、`sdxl_gen_img.py` ではロードできません。推論には第 6 節の専用スクリプトを使用してください。
* **推論時のメタデータ依存.** 推論時のアーキテクチャ復元は、学習スクリプトが書き込んだメタデータ(`lllite.version` / `lllite.cond_dim` / `lllite.cond_resblocks` / `lllite.use_aspp` / `lllite.target_atomics` など)に依存します。state_dict 単独からの自動判定は実装されていないため、メタデータの無い重みを使う場合は第 6.3 節の手動上書き引数を明示的に指定してください。
+41 -25
View File
@@ -10,7 +10,7 @@ from typing import Optional
from library import device_utils
import numpy as np
import torch
from accelerate import Accelerator
from accelerate import Accelerator, PartialState
from tqdm import tqdm
from PIL import Image
@@ -549,6 +549,8 @@ def sample_images(
dit.switch_block_swap_for_inference()
distributed_state = PartialState() # for multi gpu distributed inference. this is a singleton, so it's safe to use it here
prompts = sampling.load_prompts(args.sample_prompts)
save_dir = os.path.join(args.output_dir, "sample")
os.makedirs(save_dir, exist_ok=True)
@@ -561,30 +563,44 @@ def sample_images(
except Exception:
pass
with torch.no_grad(), accelerator.autocast():
for prompt_dict in prompts:
dit.prepare_block_swap_before_forward()
if on_prompt_start is not None:
on_prompt_start(prompt_dict, accelerator)
try:
_sample_image_inference(
accelerator,
args,
dit,
text_encoder,
vae,
tokenize_strategy,
text_encoding_strategy,
save_dir,
prompt_dict,
epoch,
steps,
sample_prompts_te_outputs,
prompt_replacement,
)
finally:
if on_prompt_end is not None:
on_prompt_end(prompt_dict)
def _run_one(prompt_dict):
dit.prepare_block_swap_before_forward()
if on_prompt_start is not None:
on_prompt_start(prompt_dict, accelerator)
try:
_sample_image_inference(
accelerator,
args,
dit,
text_encoder,
vae,
tokenize_strategy,
text_encoding_strategy,
save_dir,
prompt_dict,
epoch,
steps,
sample_prompts_te_outputs,
prompt_replacement,
)
finally:
if on_prompt_end is not None:
on_prompt_end(prompt_dict)
if distributed_state.num_processes <= 1:
with torch.no_grad(), accelerator.autocast():
for prompt_dict in prompts:
_run_one(prompt_dict)
else:
# Distribute prompts across processes. Each process handles prompts[i :: num_processes].
per_process_prompts = [] # list of lists
for i in range(distributed_state.num_processes):
per_process_prompts.append(prompts[i :: distributed_state.num_processes])
with torch.no_grad(), accelerator.autocast():
with distributed_state.split_between_processes(per_process_prompts) as prompt_dict_lists:
for prompt_dict in prompt_dict_lists[0]:
_run_one(prompt_dict)
# Restore RNG state
torch.set_rng_state(rng_state)
+8
View File
@@ -86,6 +86,14 @@ def compile_transformer(
if args.compile_cache_size_limit is not None:
torch._dynamo.config.cache_size_limit = args.compile_cache_size_limit
# nn.Module の tensor 属性 (例: ControlNet-LLLite が注入する self.cond_emb) は、
# 既定では dynamic=True でも shape が static に specialize され、解像度バケット毎に
# recompile を誘発する。forward 入力ではなく属性経由で渡る可変長テンソルを dynamic
# 対象に含めるため、この強制 static 化を無効化する。Parameter (学習重み) は形状不変
# なので force_parameter_static_shapes は既定 (True) のままにしておく。
if hasattr(torch._dynamo.config, "force_nn_module_property_static_shapes"):
torch._dynamo.config.force_nn_module_property_static_shapes = False
for blocks in target_blocks:
for i, block in enumerate(blocks):
blocks[i] = torch.compile(
+9 -4
View File
@@ -136,8 +136,11 @@ def polygon_mask(
for _ in range(n_polygons):
radius = rng.uniform(min_r, max_r)
cx = rng.uniform(radius * 0.2, width - radius * 0.2)
cy = rng.uniform(radius * 0.2, height - radius * 0.2)
# Center uniformly anywhere on the canvas; vertices that fall outside are clipped by PIL.
# This is intentional: large polygons centered near edges/corners give edge-region masks,
# which the previous radius*0.2 margin suppressed.
cx = rng.uniform(0, width)
cy = rng.uniform(0, height)
pts = _random_convex_polygon(cx, cy, radius, n_points, irregularity, rng)
draw.polygon(pts, fill=255)
@@ -171,8 +174,10 @@ def shape_mask(
size_w = rng.randint(int(width * min_coverage ** 0.5), int(width * max_coverage ** 0.5))
size_h = rng.randint(int(height * min_coverage ** 0.5), int(height * max_coverage ** 0.5))
x1 = rng.randint(0, width - size_w)
y1 = rng.randint(0, height - size_h)
# Allow the shape's bbox to overhang the canvas by up to half its size, so its center is
# uniform in [0, width) x [0, height). Off-canvas portions are clipped by PIL.
x1 = rng.randint(-size_w // 2, width - size_w // 2)
y1 = rng.randint(-size_h // 2, height - size_h // 2)
x2, y2 = x1 + size_w, y1 + size_h
use_ellipse = (shape == "ellipse") or (shape == "random" and rng.random() < 0.5)
+6 -1
View File
@@ -8,7 +8,7 @@ call periodically (every N steps / epochs) to produce preview images:
``--sample_sampler`` (ddim / pndm / euler / dpmsolver / ...).
- :func:`line_to_prompt_dict` / :func:`load_prompts` — parse prompt files
(``.txt`` / ``.toml`` / ``.json``) into prompt dicts. ``--w / --h /
--d / --s / --l / --g / --n / --ss / --cn / --i / --ctr / --rcfg /
--d / --s / --l / --g / --n / --ss / --cn / --mk / --i / --ctr / --rcfg /
--fs / --am`` flags are recognised.
- :func:`sample_images` / :func:`sample_images_common` /
:func:`sample_image_inference` — orchestrate one round of sample
@@ -178,6 +178,11 @@ def line_to_prompt_dict(line: str) -> dict:
prompt_dict["controlnet_image"] = m.group(1)
continue
m = re.match(r"mk (.+)", parg, re.IGNORECASE)
if m:
prompt_dict["mask_image"] = m.group(1).strip()
continue
m = re.match(r"i (.+)", parg, re.IGNORECASE)
if m:
prompt_dict["image"] = m.group(1).strip()
+118 -22
View File
@@ -144,7 +144,7 @@ class _ASPP(nn.Module):
class _Conditioning1(nn.Module):
"""v2 conditioning trunk.
in (B,3,H,W)
in (B,C_in,H,W)
-> Conv 4x4 s=4 + GN + SiLU # cond_dim/2, H/4
-> Conv 3x3 s=1 + GN + SiLU # cond_dim/2, H/4 (受容野拡張)
-> Conv 4x4 s=4 + GN + SiLU # cond_dim, H/16 (token 解像度)
@@ -152,6 +152,8 @@ class _Conditioning1(nn.Module):
-> Conv 1x1 # cond_emb_dim
-> flatten (B, S, cond_emb_dim)
-> LayerNorm
C_in は cond_in_channels で指定する。デフォルト 3 (RGB のみ)、4 で inpainting (RGB+mask) 等。
"""
def __init__(
@@ -161,12 +163,15 @@ class _Conditioning1(nn.Module):
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.conv1 = nn.Conv2d(3, ch_half, kernel_size=4, stride=4, padding=0)
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)
@@ -234,12 +239,16 @@ class LLLiteModuleDiT(nn.Module):
nn.init.zeros_(self.up.weight)
nn.init.zeros_(self.up.bias)
# 親 ControlNetLLLiteDiT が set_cond_image で注入する
# 親 ControlNetLLLiteDiT が set_cond_image で注入する。
# cond_emb は全モジュールで共有される cx (B, S, cond_emb_dim)、
# depth_emb はこのモジュール用の depth embedding (cond_emb_dim,)。
# cx を共有参照することで N コピーを避け、加算は forward 内で行う。
self.cond_emb: Optional[torch.Tensor] = None
self.depth_emb: Optional[torch.Tensor] = None
# 親 ControlNetLLLiteDiT が __init__ 末尾で設定 (list 包みで Parameter 二重登録回避)
# 親 ControlNetLLLiteDiT が __init__ 末尾で layer_idx を設定する。
# depth embedding の index 参照は set_cond_image 側で行う (torch.compile 対策)。
self.layer_idx: int = -1
self._depth_embeds_ref: List[nn.Parameter] = []
def apply_to(self):
self.org_forward = self.org_module[0].forward
@@ -259,24 +268,21 @@ class LLLiteModuleDiT(nn.Module):
B, T, H, W, D = orig_shape
x = x.reshape(B, T * H * W, D)
cx = self.cond_emb # (B, H*W, cond_emb_dim)
# cond_emb は全モジュール共有の cx、depth_emb はこのモジュール用の depth vector。
# ここで加算することで cx の N コピーを避ける。整数 layer_idx は参照せず
# テンソル属性 depth_emb だけを足すので、torch.compile は単一グラフを維持する
# (gradient checkpointing 下では cond_local は領域内で再計算され retain されない)。
cond_local = self.cond_emb + self.depth_emb # (B, H*W, cond_emb_dim)
# CFG 推論用 (学習時は通らない想定)
if x.shape[0] // 2 == cx.shape[0]:
cx = cx.repeat(2, 1, 1)
if x.shape[0] // 2 == cond_local.shape[0]:
cond_local = cond_local.repeat(2, 1, 1)
# T=1 固定前提なので S == H*W のはず
assert x.shape[1] == cx.shape[1], (
f"LLLite seq mismatch ({self.lllite_name}): x={x.shape[1]} vs cond_emb={cx.shape[1]}"
assert x.shape[1] == cond_local.shape[1], (
f"LLLite seq mismatch ({self.lllite_name}): x={x.shape[1]} vs cond_emb={cond_local.shape[1]}"
)
# depth embedding を加算 (zero-init なので学習序盤は cx のまま)
if self._depth_embeds_ref:
depth_e = self._depth_embeds_ref[0][self.layer_idx] # (cond_emb_dim,)
cond_local = cx + depth_e # broadcast over (B, S)
else:
cond_local = cx
h = F.silu(self.down(x)) # (B, S, mlp)
# FiLM パラメータ (cond_local 由来、zero-init で identity)
@@ -318,6 +324,8 @@ class ControlNetLLLiteDiT(nn.Module):
cond_resblocks: int = 1,
use_aspp: bool = False,
aspp_dilations: Tuple[int, ...] = ASPP_DEFAULT_DILATIONS,
cond_in_channels: int = 3,
inpaint_masked_input: bool = False,
):
super().__init__()
@@ -333,11 +341,16 @@ class ControlNetLLLiteDiT(nn.Module):
self.cond_resblocks = cond_resblocks
self.use_aspp = use_aspp
self.aspp_dilations = tuple(aspp_dilations) if use_aspp else ()
# 4ch (RGB+mask) inpainting 用の付加情報。inpaint_masked_input は学習側の RGB マスキング方針を
# 記録するためのフラグで、モデル forward の挙動には影響しない (メタデータ復元用)。
self.cond_in_channels = cond_in_channels
self.inpaint_masked_input = inpaint_masked_input
# cond image (B,3,H*16,W*16) -> (B, S, cond_emb_dim)
# cond image (B, cond_in_channels, H*16, W*16) -> (B, S, cond_emb_dim)
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 = self._create_modules(dit, cond_emb_dim, mlp_dim, atomics, dropout, multiplier)
@@ -348,14 +361,16 @@ class ControlNetLLLiteDiT(nn.Module):
self.depth_embeds = nn.Parameter(torch.zeros(n, cond_emb_dim))
for i, m in enumerate(self.lllite_modules):
m.layer_idx = i
m._depth_embeds_ref = [self.depth_embeds]
aspp_info = f"aspp={'on' + str(list(self.aspp_dilations)) if use_aspp else 'off'}"
inpaint_info = (
f", inpaint=on(masked_input={inpaint_masked_input})" if cond_in_channels == 4 else ""
)
logger.info(
f"ControlNet-LLLite (Anima v{LLLITE_ARCH_VERSION}): created {n} modules for "
f"target={target_layers!r} (atomics={list(atomics)}), "
f"cond_dim={cond_dim}, cond_resblocks={cond_resblocks}, {aspp_info}, "
f"cond_emb_dim={cond_emb_dim}, mlp_dim={mlp_dim}"
f"cond_in_channels={cond_in_channels}, cond_dim={cond_dim}, cond_resblocks={cond_resblocks}, {aspp_info}, "
f"cond_emb_dim={cond_emb_dim}, mlp_dim={mlp_dim}{inpaint_info}"
)
@property
@@ -430,10 +445,21 @@ class ControlNetLLLiteDiT(nn.Module):
if cond_image is None:
for m in self.lllite_modules:
m.cond_emb = None
m.depth_emb = None
return
cx = self.conditioning1(cond_image) # (B, S, cond_emb_dim)
for m in self.lllite_modules:
m.cond_emb = cx
# 共有の cx を全モジュールに同一テンソルとして持たせ (N コピーを避ける)、
# depth embedding はこのモジュール用の (cond_emb_dim,) スライスだけを渡す。
# 加算は forward 内で行う (cx の N 倍メモリを回避するため)。
#
# depth_embeds[layer_idx] の index 参照はここ (compile 領域の外) で毎ステップ
# 行う。forward 内で整数 layer_idx を参照すると torch.compile がブロック毎に
# 別グラフを焼くため、参照はここに残す。また index 参照を毎ステップ行うことで
# SelectBackward が毎回張り直され、2 回目以降の backward でも depth_embeds へ
# 正しく勾配が流れる (__init__ で一度だけ index すると graph 再利用で破綻する)。
m.cond_emb = cx # 全モジュールで共有 (同一テンソル)
m.depth_emb = self.depth_embeds[m.layer_idx] # (cond_emb_dim,), broadcast over (B, S)
def clear_cond_image(self):
self.set_cond_image(None)
@@ -478,6 +504,11 @@ class AnimaControlNetLLLiteWrapper(nn.Module):
f"cond_image HW mismatch: latent={x.shape[-2]}x{x.shape[-1]} -> expected "
f"{expected_h}x{expected_w}, got {cond_image.shape[-2]}x{cond_image.shape[-1]}"
)
expected_c = self.lllite.cond_in_channels
assert cond_image.shape[1] == expected_c, (
f"cond_image channel mismatch: expected {expected_c} (cond_in_channels), "
f"got {cond_image.shape[1]}"
)
self.lllite.set_cond_image(cond_image)
return self.dit(x, timesteps, context, **kwargs)
@@ -763,6 +794,41 @@ if __name__ == "__main__":
assert torch.allclose(y_a, y_a_ref), "ASPP-on zero-init forward mismatch"
logger.info(" ASPP-on zero-init forward OK")
# 4ch (inpainting) パス: 構築 + conv1 入力チャネル + zero-init forward + save/load round-trip
dit_4ch = _DummyDiT(num_blocks=2, dim=64, ctx_dim=128)
lllite_4ch = ControlNetLLLiteDiT(
dit_4ch, cond_emb_dim=32, mlp_dim=64, target_layers="self_attn_q",
cond_dim=64, cond_resblocks=1, cond_in_channels=4, inpaint_masked_input=True,
)
assert lllite_4ch.cond_in_channels == 4
assert lllite_4ch.inpaint_masked_input is True
assert lllite_4ch.conditioning1.conv1.in_channels == 4
# 3ch 重みは 4ch モデルにそのまま load できないが、4ch round-trip は通る
lllite_4ch.apply_to()
wrapper_4ch = AnimaControlNetLLLiteWrapper(dit_4ch, lllite_4ch)
H4, W4 = 8, 8
cond4 = torch.randn(1, 4, H4 * 16, W4 * 16)
wrapper_4ch.lllite.set_cond_image(cond4)
cx4 = wrapper_4ch.lllite.lllite_modules[0].cond_emb
assert cx4 is not None and cx4.shape == (1, H4 * W4, 32), f"4ch cond_emb shape: {cx4.shape}"
# zero-init forward
mod4 = wrapper_4ch.lllite.lllite_modules[0]
x4 = torch.randn(1, H4 * W4, mod4.org_module[0].in_features)
y4 = mod4(x4)
y4_ref = mod4.org_forward(x4)
assert torch.allclose(y4, y4_ref), "4ch zero-init forward mismatch"
# Wrapper の cond_image チャネル assert: 3ch を渡すと AssertionError になる
x_lat = torch.randn(1, 16, 1, H4 * 2, W4 * 2) # dummy latent
try:
# 3ch cond を渡すと拒否されるか
wrapper_4ch(x_lat, torch.zeros(1), torch.zeros(1, 1, 1), cond_image=torch.randn(1, 3, H4 * 16, W4 * 16))
raise AssertionError("expected channel mismatch assert")
except AssertionError as e:
msg = str(e)
if "channel mismatch" not in msg and "expected 4" not in msg:
raise
logger.info(" 4ch (inpainting) build / forward / channel assert OK")
# 非デフォルト dilations
lllite_dil = ControlNetLLLiteDiT(
_DummyDiT(num_blocks=2, dim=64, ctx_dim=128),
@@ -872,6 +938,36 @@ if __name__ == "__main__":
assert torch.allclose(sd_orig[k].float(), sd_loaded[k].float()), f"mismatch at {k}"
logger.info(" save / load round-trip OK")
# 4ch round-trip
tmp4 = tmp + ".4ch.safetensors"
try:
meta4 = {
"lllite.version": LLLITE_ARCH_VERSION,
"lllite.cond_emb_dim": "32",
"lllite.mlp_dim": "64",
"lllite.target_layers": "self_attn_q",
"lllite.cond_dim": "64",
"lllite.cond_resblocks": "1",
"lllite.cond_in_channels": "4",
"lllite.inpaint_masked_input": "true",
}
save_lllite_model(tmp4, lllite_4ch, dtype=torch.float32, metadata=meta4)
dit_4ch_b = _DummyDiT(num_blocks=2, dim=64, ctx_dim=128)
lllite_4ch_b = ControlNetLLLiteDiT(
dit_4ch_b, cond_emb_dim=32, mlp_dim=64, target_layers="self_attn_q",
cond_dim=64, cond_resblocks=1, cond_in_channels=4, inpaint_masked_input=True,
)
load_lllite_weights(lllite_4ch_b, tmp4, strict=True)
sd_a = lllite_4ch.state_dict()
sd_b = lllite_4ch_b.state_dict()
assert set(sd_a.keys()) == set(sd_b.keys())
for k in sd_a:
assert torch.allclose(sd_a[k].float(), sd_b[k].float()), f"4ch round-trip mismatch at {k}"
logger.info(" 4ch save / load round-trip OK")
finally:
if os.path.exists(tmp4):
os.unlink(tmp4)
# 旧形式 (lllite_modules.* キー) は reject される
legacy_sd = {"lllite_modules.0.up.weight": torch.zeros(1)}
from safetensors.torch import save_file as _save_legacy