Files
Claude 1f1db18e85 Update the README and add architecture documentation
The README still described the project as two nodes for SDXL and FLUX, told
readers to "choose either", and showed an info example predating the
architecture line. It also stated the wrong licence.

README:

- Lead with the three nodes and when to use each, rather than bolting the
  Universal loader onto a two-node description.
- Refresh the info example to a real one, including the architecture/stacks
  line, and explain every column.
- Add a Troubleshooting section covering the cases the node can now report:
  nodes missing from the menu, the mismatch warning, a LoRA that matches
  nothing, a high Other Tensors count, and divergence from the built-in
  LoraLoader.
- Note that the SDXL node also covers SD1.5 and SD2.
- Separate architectures actually verified from those supported by the same
  mechanism but not exercised, rather than implying one list.
- Correct the licence to Apache-2.0; the README claimed GPL-3.0.
- Move the changelog to CHANGELOG.md, which was 90 of the README's 250 lines.

docs/ARCHITECTURE.md is new and covers why classification runs on the canonical
target key rather than the LoRA's own names, why stack matching uses the raw
dotted key instead of the normalised one, how runtime stack discovery and the
depth axis work, and how to add an architecture or a family -- including the
widget-order compatibility contract that is easy to break silently.

Every factual claim in both documents was checked against the code: the FLUX
and SDXL block range tables, the Universal block list, the preset names, the
documented stack names against STACK_ORDER, the test count, and all internal
links and anchors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUT2wQ8UsjNgosRWu6jvpj
2026-07-30 11:35:59 +00:00

156 lines
7.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# How classification works
This document explains the mechanism behind the three nodes, the reasoning
behind two non-obvious design choices, and how to add support for a new
architecture.
## The core idea: classify on the target key, not the LoRA key
A LoRA file's own key names vary wildly by exporter — kohya writes
`lora_unet_double_blocks_0_img_attn_qkv`, OneTrainer writes
`lora_transformer_...`, diffusers writes `transformer.transformer_blocks.0...`,
and LyCORIS, DiffSynth and PEFT each differ again.
ComfyUI already solves this. `comfy.lora.model_lora_keys_unet()` and
`model_lora_keys_clip()` build a **key map** from the *loaded model*, whose keys
are every dialect's spelling and whose values are the canonical model
state-dict key each one targets:
```python
key_map["lora_unet_double_blocks_0_img_attn_qkv"] = "diffusion_model.double_blocks.0.img_attn.qkv.weight"
key_map["transformer.transformer_blocks.0.attn.to_q"] = ("diffusion_model.double_blocks.0.img_attn.qkv.weight", (0, 0, 3072))
```
`comfy.lora.load_lora()` then returns a patch dict keyed by those *values*. So
this project never parses LoRA key names at all — it classifies the canonical
target. If ComfyUI can load the LoRA, we can bucket it, and new exporter formats
are free.
Two consequences worth knowing:
- A patch key is either a plain `str`, or a `(key, offset)` tuple when several
LoRA tensors patch different slices of one fused weight (FLUX packs `q`, `k`
and `v` into a single `qkv`). `_patch_target()` unwraps both. A real FLUX.1
key map contains 534 such tuples, so this is the common case, not an edge one.
- Routing model vs. CLIP patches needs no name guessing: the UNet key map's
values are the set of UNet-owned targets, and anything outside it goes to the
text encoder.
## Design choice 1: stack matching uses the raw dotted key
Everywhere else, keys are normalised (`.` and `/` → `_`, lower-cased) so token
checks like `"_txt_in"` are simple substring tests. **Block-stack matching
deliberately does not use the normalised form.**
Normalising erases the separator that carries the structure. Consider an SDXL
key:
```
diffusion_model.input_blocks.4.1.transformer_blocks.0.attn1.to_q.weight
```
Normalised, it becomes `..._input_blocks_4_1_transformer_blocks_0_...`, where a
search for a stack named `blocks` or `transformer_blocks` happily matches the
*inner* one — the attention module's transformer blocks, not the UNet stage the
key actually belongs to. On the dotted key, `input_blocks.4` is unambiguously
the outermost component.
The same reasoning distinguishes a stack named `mystery_blocks` from one named
`blocks`: `mystery_blocks.0` and `blocks.0` are distinct, but
`_mystery_blocks_0` and `_blocks_0` both contain `_blocks_0`.
The FLUX and SDXL classifiers go one step further and anchor to the *start* of
the module path (after an optional `diffusion_model.` / `transformer.` prefix),
because anchoring on `.` alone is not enough — the nested stack is dot-preceded
too. FLUX's and SDXL's stacks are always top-level, so a nested
`transformer_blocks` can never be mistaken for a top-level one.
## Design choice 2: the Universal loader discovers the layout
ComfyUI ships close to a hundred model configs and adds more regularly, so a
per-family block table would be permanently out of date. Instead,
`bobs_universal.discover_layout()` scans the model's own key set for module path
components that look like a repeated stack — a name ending in `blocks`, `layers`
or `refiner`, followed by an index — and records each stack's size.
The discovered stacks are ordered by `STACK_ORDER` (a known execution order,
with unrecognised names sorted after it alphabetically) and concatenated. Every
block then has a position on a single 0..1 depth axis:
```
fraction = (offset_of_stack + index + 0.5) / total_blocks
```
which is bucketed into Early / Early-Mid / Mid / Late-Mid / Late. Because the
sizes come from the model, a pruned 8+16 FLUX and a full 19+38 FLUX both map
correctly, and the same five sliders mean the same thing on a 60-block
Qwen-Image as on a 20-stage SDXL UNet.
Keys outside any stack are matched against token lists for embeddings
(`Input & Embeddings`) and output heads (`Output Head`), falling through to
`Other Tensors`.
Negative indices are resolved against the stack size: ComfyUI's SD3 key map
addresses the final block as `joint_blocks.-1`.
## Strength resolution
```
Custom preset: block_weight_slider × strength
Named preset: preset_block_weight × preset_strength × strength
```
A named preset ignores the sliders entirely rather than blending with them.
`strength` always applies on top.
## Adding a new architecture
**Usually nothing is needed.** If the model's blocks live in a stack named
`*blocks` / `*layers` / `*refiner` with an index, the Universal loader finds it.
Check by loading a LoRA and reading the `info` output: it names the architecture
and the stacks discovered.
If tensors land in `Other Tensors`:
- **A stack was missed** — its name does not end in a recognised suffix. Extend
`_STACK_RE` in `bobs_universal.py`, and add the name to `STACK_ORDER` if its
execution position matters relative to other stacks.
- **Embedding or head tensors were missed** — add the token to `_INPUT_TOKENS`
or `_OUTPUT_TOKENS`. Keep output tokens anchored: a bare substring like
`_head_` also matches `multi_head_attention`.
To add a whole new *family* with its own named blocks (as FLUX and SDXL have),
call `bobs_blocks.register_family()` with the block list, presets, text-encoder
block name and tooltips, then add a node subclassing `_BobsLoraLoaderBase` with
`FAMILY`, `BLOCKS`, `CATCH_ALL_BLOCK` and a `_classifier()`. Registration is
explicit rather than done by module-level assignment, so behaviour never depends
on import order.
**Widget order is a compatibility contract.** ComfyUI serialises widget values
positionally, so reordering or removing an entry in a `ALL_*_BLOCKS` list
silently corrupts saved workflows. Only ever append. `tests/test_bobs_blocks.py`
pins the existing order.
## Testing
`bobs_blocks.py` and `bobs_universal.py` import nothing from ComfyUI or torch,
so the whole classification layer is testable on a bare interpreter:
```bash
python -m unittest discover -s tests -v
```
The suite covers both index-range tables, regex shadowing between nested and
top-level stacks, head-token vs. block-key precedence, full-state-dict sweeps
asserting nothing falls into `Other Tensors`, non-standard depths, preset and
slider precedence, the widget-order contract, and the node's registration,
report and mismatch warning.
Beyond that, the classifiers have been exercised against real ComfyUI: models
built from ComfyUI's own configs on torch's `meta` device (full module
structure, real key names, no weight memory) plus its `*_to_diffusers` key
tables, across SD1.5, SDXL, SD3, FLUX, AuraFlow, PixArt, LTX-Video, Lumina,
Qwen-Image and Wan. That harness lives outside the repository because it needs a
ComfyUI checkout and torch; the findings it produced are pinned as offline
regression tests in `tests/test_bobs_universal.py`.