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
7.0 KiB
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:
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 packsq,kandvinto a singleqkv)._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_REinbobs_universal.py, and add the name toSTACK_ORDERif its execution position matters relative to other stacks. - Embedding or head tensors were missed — add the token to
_INPUT_TOKENSor_OUTPUT_TOKENS. Keep output tokens anchored: a bare substring like_head_also matchesmulti_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:
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.