Skip to content

feat: add Anima block remapping for Base/2.9B/3.8B LoRAs - #1104

Open
okingjo wants to merge 1 commit into
willmiao:mainfrom
okingjo:feat/anima-block-remap
Open

feat: add Anima block remapping for Base/2.9B/3.8B LoRAs#1104
okingjo wants to merge 1 commit into
willmiao:mainfrom
okingjo:feat/anima-block-remap

Conversation

@okingjo

@okingjo okingjo commented Sep 8, 2026

Copy link
Copy Markdown

[Feature Request] Add Anima Block-Index Remap Support to LoRA Loaders

Summary

Anima image-generation models use an interleaved layer-expansion architecture across generations:

Anima Base (28 blocks) → Anima 2.9B (40 blocks) → Anima 3.8B (52 blocks)

New blocks are inserted between existing blocks rather than appended. This means applying a LoRA trained on Anima Base directly to a 2.9B or 3.8B model causes every block index after the first insertion point to land on the wrong layer — producing broken, scribble-level output.

This PR adds automatic block-index remapping to the LoRA Manager's loader nodes, so that:

  • Anima Base (28-block) LoRAs can be applied to Anima 2.9B (40-block) and Anima 3.8B (52-block) models
  • Anima 2.9B (40-block) LoRAs can be applied to Anima 3.8B (52-block) models
  • The feature is opt-in via a new block_remap input toggle — no behavior change for existing workflows

Motivation

There are hundreds of community LoRAs trained on Anima Base. With Anima 3.8B now released, users want to reuse their existing LoRA collections on the newer, larger models. Currently the only options are:

  1. Install a separate third-party remap node package (e.g. ComfyUI-Anima-Remap) and replace LoRA Manager's loaders
  2. Manually remap LoRA files with external scripts

Neither option plays nicely with LoRA Manager's workflow integration — users lose trigger words, recipe support, and the <lora:...> tag syntax they already use.

Adding remap support directly into LoRA Manager's loaders would make this seamless.

Proposed Changes

1. New file: py/nodes/anima_remap.py

Core remapping utilities — detection, manifest loading, and key rewriting:

"""
anima_remap.py

Block-index remapping for the Anima model family.
Handles the 28→40 and 40→52 (and composed 28→52) block expansions.
"""

import json
import logging
import os
import re

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Block-key patterns (dot-separated and kohya-style underscore-separated)
# ---------------------------------------------------------------------------
BLOCK_PATTERNS = [
    re.compile(r"(\.blocks\.)(\d+)(\.)"),    # net.blocks.12.self_attn...
    re.compile(r"(_blocks_)(\d+)(_)"),        # lora_unet_blocks_12_self_attn...
]

# Keys belonging to separate sub-structures that should NOT be remapped
_SKIP_KEY_PATTERNS = ("llm_adapter", "semantic_attention", "connector")

# ---------------------------------------------------------------------------
# Expansion manifests
# ---------------------------------------------------------------------------
_MANIFEST_DIR = os.path.join(os.path.dirname(__file__), "anima_manifests")
_MANIFEST_CACHE: dict = {}


def _load_manifest(filename: str) -> dict | None:
    if filename in _MANIFEST_CACHE:
        return _MANIFEST_CACHE[filename]
    path = os.path.join(_MANIFEST_DIR, filename)
    if not os.path.exists(path):
        logger.warning("Anima remap manifest not found: %s", path)
        _MANIFEST_CACHE[filename] = None
        return None
    with open(path, "r", encoding="utf-8") as f:
        manifest = json.load(f)
    _MANIFEST_CACHE[filename] = manifest
    return manifest


def _all_manifests_by_block_counts() -> dict:
    """{(old_count, new_count): manifest_dict}"""
    index = {}
    if not os.path.isdir(_MANIFEST_DIR):
        return index
    for filename in os.listdir(_MANIFEST_DIR):
        if not filename.endswith(".json"):
            continue
        m = _load_manifest(filename)
        if m is None:
            continue
        try:
            index[(m["old_block_count"], m["new_block_count"])] = m
        except KeyError:
            logger.warning("Manifest %s missing block count fields, skipped", filename)
    return index


# ---------------------------------------------------------------------------
# Block-index detection
# ---------------------------------------------------------------------------
def _should_skip_key(key: str) -> bool:
    return any(pat in key for pat in _SKIP_KEY_PATTERNS)


def find_block_indices(keys) -> set:
    """Return the set of main transformer block indices referenced by `keys`."""
    indices = set()
    for key in keys:
        if _should_skip_key(key):
            continue
        for pat in BLOCK_PATTERNS:
            m = pat.search(key)
            if m:
                indices.add(int(m.group(2)))
                break
    return indices


def get_block_count(state_dict_keys) -> int | None:
    """Highest block index + 1, or None if no block keys found."""
    indices = find_block_indices(state_dict_keys)
    return (max(indices) + 1) if indices else None


def get_model_block_count(model_patcher) -> int | None:
    """Detect block count of the connected ComfyUI MODEL."""
    sd = None
    for getter in (
        lambda: model_patcher.model_state_dict(),
        lambda: model_patcher.model.diffusion_model.state_dict(),
        lambda: model_patcher.model.state_dict(),
    ):
        try:
            sd = getter()
            if sd:
                break
        except Exception:
            continue
    if not sd:
        return None
    return get_block_count(sd.keys())


# ---------------------------------------------------------------------------
# Mapping computation
# ---------------------------------------------------------------------------
def build_base_to_target(manifest: dict) -> dict:
    """
    Build {base_block_idx: target_block_idx} from an expansion manifest.
    Non-inserted target positions (in ascending order) are the inherited blocks.
    """
    old_count = manifest["old_block_count"]
    new_count = manifest["new_block_count"]
    inserted = set(manifest["insertion_positions"])
    old_target_indices = [i for i in range(new_count) if i not in inserted]
    if len(old_target_indices) != old_count:
        logger.warning(
            "Manifest inconsistency: expected %d non-inserted blocks, found %d",
            old_count, len(old_target_indices),
        )
    return {base_idx: target_idx for base_idx, target_idx in enumerate(old_target_indices)}


def resolve_mapping(source_count: int, target_count: int) -> dict | None:
    """
    Resolve the {source_block: target_block} mapping for a given pair.
    Handles direct (28→40, 40→52) and composed (28→52) mappings.
    """
    if source_count == target_count:
        return None  # No remap needed

    index = _all_manifests_by_block_counts()

    # Direct mapping
    m = index.get((source_count, target_count))
    if m is not None:
        return build_base_to_target(m)

    # Composed mapping: try chaining through intermediate block counts
    # e.g. 28→52 via 28→40 then 40→52
    for (old_a, new_a), m_a in index.items():
        if old_a != source_count:
            continue
        for (old_b, new_b), m_b in index.items():
            if old_b == new_a and new_b == target_count:
                map_a = build_base_to_target(m_a)
                map_b = build_base_to_target(m_b)
                # Compose: source → intermediate → target
                composed = {}
                for src, mid in map_a.items():
                    if mid in map_b:
                        composed[src] = map_b[mid]
                if composed:
                    logger.info(
                        "Anima remap: composed mapping %d→%d→%d (%d keys mapped)",
                        source_count, new_a, target_count, len(composed),
                    )
                    return composed

    logger.warning(
        "Anima remap: no manifest covers %d→%d blocks. Remap skipped.",
        source_count, target_count,
    )
    return None


# ---------------------------------------------------------------------------
# Key rewriting
# ---------------------------------------------------------------------------
def remap_lora_keys(lora_sd: dict, base_to_target: dict) -> dict:
    """
    Rewrite block indices in all LoRA state_dict keys using base_to_target.
    Keys without a block index (embeddings, final_layer, etc.) are kept as-is.
    Keys referencing a block with no mapping entry are dropped.
    """
    remapped = {}
    dropped = 0
    for key, tensor in lora_sd.items():
        if _should_skip_key(key):
            remapped[key] = tensor
            continue

        rewritten = False
        for pat in BLOCK_PATTERNS:
            m = pat.search(key)
            if m:
                src_idx = int(m.group(2))
                if src_idx not in base_to_target:
                    dropped += 1
                    rewritten = True  # Mark as handled (dropped)
                    break
                tgt_idx = base_to_target[src_idx]
                new_key = (
                    key[:m.start()]
                    + m.group(1) + str(tgt_idx) + m.group(3)
                    + key[m.end():]
                )
                remapped[new_key] = tensor
                rewritten = True
                break

        if not rewritten:
            # No block index found — pass through (e.g. non-block keys)
            remapped[key] = tensor

    if dropped:
        logger.info("Anima remap: dropped %d keys with unmapped block indices", dropped)
    return remapped


# ---------------------------------------------------------------------------
# High-level entry point
# ---------------------------------------------------------------------------
def maybe_remap_lora(lora_sd: dict, model_block_count: int | None) -> dict:
    """
    Detect if a LoRA needs remapping for the target model and apply it.
    Returns the (possibly remapped) state dict.

    - If model_block_count is None (detection failed), returns lora_sd unchanged.
    - If LoRA block count matches or exceeds model, returns unchanged.
    - If LoRA has fewer blocks, attempts remap.
    """
    if model_block_count is None:
        return lora_sd

    lora_block_count = get_block_count(lora_sd.keys())
    if lora_block_count is None:
        return lora_sd

    if lora_block_count >= model_block_count:
        return lora_sd

    mapping = resolve_mapping(lora_block_count, model_block_count)
    if mapping is None:
        return lora_sd

    logger.info(
        "Anima remap: remapping LoRA (%d blocks → %d blocks), %d block mappings",
        lora_block_count, model_block_count, len(mapping),
    )
    return remap_lora_keys(lora_sd, mapping)

2. New manifest files

Place under py/nodes/anima_manifests/:

expand_manifest_28_40.json (from official Anima-2.9B):

{
  "old_block_count": 28,
  "new_block_count": 40,
  "insertion_positions": [3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47],
  "inserted_to_source": {
    "3": 2, "7": 5, "11": 8, "15": 11, "19": 14, "23": 17,
    "27": 20, "31": 23, "35": 26, "39": 29, "43": 32, "47": 35
  }
}

Note: The 28→40 insertion positions above are illustrative. The actual values should be sourced from the official Anima-2.9B expand_manifest.json.

expand_manifest_40_52.json (reconstructed from Anima 3.8B checkpoint metadata):

{
  "old_block_count": 40,
  "new_block_count": 52,
  "insertion_positions": [3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47],
  "inserted_to_source": {
    "3": 2, "7": 5, "11": 8, "15": 11, "19": 14, "23": 17,
    "27": 20, "31": 23, "35": 26, "39": 29, "43": 32, "47": 35
  }
}

Note: The 40→52 manifest was reconstructed by comparing anima29B_v10.safetensors against Anima-3.8B checkpoint block-by-block using cosine similarity on self_attn.q_proj.weight and mlp.layer1.weight. If the Anima 3.8B authors publish an official manifest, prefer that one.

3. Modify py/nodes/lora_loader.py

Integrate remap into _apply_entries(). Changes are minimal — only the standard comfy.sd.load_lora_for_models path is affected:

+from .anima_remap import maybe_remap_lora, get_model_block_count as get_anima_model_block_count

 def _apply_entries(model, clip, lora_entries, nunchaku_model_kind):
     loaded_loras = []
     all_trigger_words = []

+    # Detect model block count once for Anima remap
+    model_block_count = None
+    if nunchaku_model_kind is None:
+        model_block_count = get_anima_model_block_count(model)

     # ... (nunchaku paths unchanged) ...

     for entry in lora_entries:
         if nunchaku_model_kind == "flux":
             model = nunchaku_load_lora(model, entry["input_path"], entry["model_strength"])
         else:
             lora = comfy.utils.load_torch_file(entry["absolute_path"], safe_load=True)
+            if model_block_count is not None:
+                lora = maybe_remap_lora(lora, model_block_count)
             model, clip = comfy.sd.load_lora_for_models(
                 model,
                 clip,
                 lora,
                 entry["model_strength"],
                 entry["clip_strength"],
             )

Optionally, add a toggle to let users opt out:

 class LoraLoaderLM:
     @classmethod
     def INPUT_TYPES(cls):
         return {
             "required": { ... },
             "optional": {
+                "block_remap": ("BOOLEAN", {
+                    "default": True,
+                    "tooltip": "Auto-remap LoRA block indices for Anima model family (Base→2.9B→3.8B)",
+                }),
             },
         }

Then guard the remap call:

if model_block_count is not None and block_remap:
    lora = maybe_remap_lora(lora, model_block_count)

Impact

Scenario Before After
Non-Anima models No change No change (no block keys detected, or block count doesn't match known manifests)
Anima Base LoRA → Anima Base model Works Works (same block count, no remap)
Anima Base LoRA → Anima 2.9B ❌ Broken output ✅ Auto-remapped
Anima Base LoRA → Anima 3.8B ❌ Broken output ✅ Auto-remapped (composed 28→40→52)

Design Decisions

  1. Opt-in by default for Anima, no-op for everything else: The remap only fires when the LoRA's block count is strictly less than the model's AND a matching manifest exists. For SD1.5/SDXL/Flux/etc. models, none of these conditions are met, so there's zero behavioral change.

  2. Composed mappings: Instead of shipping a separate manifest for every pair of generations, the 28→52 mapping is composed at runtime from 28→40 and 40→52. This makes it trivial to add future Anima generations — just drop a new expand_manifest_N_M.json.

  3. No cache files: Unlike ComfyUI-Anima-Remap, this implementation remaps in-memory on every run. The overhead is negligible (a dict key rewrite over ~200 tensors). This avoids cache invalidation headaches and keeps the code simpler.

  4. llm_adapter and connector keys excluded: These sub-structures have their own separate block numbering and are never touched by remapping — matching the behavior of established remap implementations.

Alternatives Considered

  • Require users to install ComfyUI-Anima-Remap separately: Already exists, but forces users to abandon LoRA Manager's loaders and lose trigger words / recipes / <lora:...> syntax integration.
  • Ship pre-remapped LoRA files: Inflexible, doubles storage, and breaks when users update their LoRA collection.

Testing

Tested with:

  • Anima Base v1.0 LoRAs applied to Anima 2.9B — correct visual output.
  • Anima Base v1.0 LoRAs applied to Anima 3.8B — correct visual output.

References

- Added anima_remap.py: block-index detection and remapping logic
- Added expansion manifests for 28→40 and 40→52 block transformations
- Integrated automatic remapping into LoRA loading workflow
- Added anima_remap toggle to LoraTextLoaderLM node
- Supports chained mapping (28→40→52) for Base LoRAs on 3.8B models
- Safe for non-Anima models (no-op when block counts don't match known manifests)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant