Skip to content

Instantly share code, notes, and snippets.

@alisson-anjos
Last active October 4, 2026 01:25
Show Gist options
  • Select an option

  • Save alisson-anjos/de6da1e6d318e0567d978aed11e7b3ad to your computer and use it in GitHub Desktop.

Select an option

Save alisson-anjos/de6da1e6d318e0567d978aed11e7b3ad to your computer and use it in GitHub Desktop.
Diagnosing a LoRA by cutting it apart — finding which modules cause a defect (blur, lost identity, ignored pose) and fixing it without retraining

Diagnosing a LoRA by cutting it apart

A practical guide to finding which modules of a trained LoRA cause a specific defect (blurry skin, lost identity, ignored pose) and to fixing it without retraining.

Everything here is post-hoc weight surgery: you take a .safetensors you already trained, produce variants with some module groups zeroed or scaled, generate with each under a fixed seed, and measure. No GPU training, minutes per iteration.

Worked examples at the end come from two real LoRAs on Qwen-Image-2.1 (a head swap and a body swap), but the method is architecture-agnostic.


0. TL;DR

  1. List the module groups your LoRA touches (attn.* vs mlp.*, and per block depth).
  2. Build a test set that provokes the defect you care about.
  3. Pick a metric per symptom. Never judge blur by eye at 300px.
  4. Generate: baseline, no-LoRA, and one variant per ablation, same seed and prompt.
  5. Read the table, then look at the images. A variant can win every metric and still have killed the feature.

The single most useful cut, in both case studies, was the MLP input projection (gate_up / w1+w3) in a subset of blocks. Attention almost never caused the defect.

Default to training the full LoRA and pruning afterwards, not to training with modules excluded. Section 9 explains why, and it is not a detail.

If you would rather click than script, section 12 covers a ComfyUI node that does the weight surgery live inside a workflow.


1. Why this works

A LoRA's update is ΔW = B·A per module. The model's forward pass sums these into specific places. Different places do different jobs:

  • Attention projections move information between tokens. In a reference-conditioned task, this is where the reference gets read. Identity, layout correspondence.
  • MLP / feed-forward rewrites the representation at each position. This is where the model's notion of how a surface looks lives. Texture, materials, style.

So when a reference-conditioned LoRA produces plastic skin, the prior is that training pushed the MLP toward the texture distribution of your dataset. If your targets are upscaled, recompressed, or generated by another model, the MLP learns that. Attention is mostly innocent.

This is a hypothesis, not a law. The point of the method is to test it per LoRA.


2. Step 1: map the module structure

import json, struct, re, collections
f = open("your_lora.safetensors", "rb")
n = struct.unpack("<Q", f.read(8))[0]
h = json.loads(f.read(n)); h.pop("__metadata__", None)

keys = [k for k in h if k.endswith(("lora_A.weight", "lora_down.weight"))]
pat  = collections.Counter(re.sub(r'\d+', 'N', k).rsplit('.lora', 1)[0] for k in keys)
for p, c in pat.most_common():
    print(f"{c:4d}  {p}")
print("rank:", h[keys[0]]["shape"])

Typical output for Qwen-Image-2.1:

  32  diffusion_model.transformer_blocks.N.attn.to_q
  32  diffusion_model.transformer_blocks.N.attn.to_k
  32  diffusion_model.transformer_blocks.N.attn.to_v
  32  diffusion_model.transformer_blocks.N.attn.to_out.0
  32  diffusion_model.transformer_blocks.N.img_mlp.gate_up
  32  diffusion_model.transformer_blocks.N.img_mlp.out

For Krea 2 it is blocks.N.attn.{wq,wk,wv,wo,gate} + blocks.N.mlp.{gate,up,down}. For most DiTs there is an equivalent split. The mapping you need:

role Qwen-Image-2.1 Krea 2 generic
MLP input projections img_mlp.gate_up mlp.gate, mlp.up w1, w3, fc1
MLP output projection img_mlp.out mlp.down w2, fc2
attention attn.to_* attn.w* q,k,v,o

Where did training put its weight?

Before ablating, look at where the update is largest. This predicts what will matter:

import torch
from safetensors.torch import load_file
sd = load_file("your_lora.safetensors")
bases = sorted({k[:-len(".lora_A.weight")] for k in sd if k.endswith(".lora_A.weight")})
for b in bases:
    A = sd[b+".lora_A.weight"].cuda().float()
    B = sd[b+".lora_B.weight"].cuda().float()
    Qb, Rb = torch.linalg.qr(B)
    Qa, Ra = torch.linalg.qr(A.T)
    S = torch.linalg.svdvals(Rb @ Ra.T)        # exact singular values of ΔW, cheap
    print(b, float(S.pow(2).sum().sqrt()))

ΔW = B·A has rank ≤ r, so you never need an SVD of the full d×d matrix: QR both factors and take the SVD of the small r×r product. Exact, and milliseconds instead of minutes.

In the head-swap case, gate_up had norm 3.57 against ~1.24 for attention, and it dominated, and it was the culprit. In the body-swap case the profile was flat (1.65 vs 1.05) and the defect was subtler. The norm profile is a useful prior, not an answer.


3. Step 2: build a test set that provokes the defect

This is where most of the value is, and it is easy to get wrong.

  • Target the symptom. If the complaint is "identity is lost when the subject is far away", your set must contain far-away subjects. A set of close-ups will show nothing.
  • Use failures you already have. If you have outputs you consider bad, extract their inputs and use exactly those. You are not looking for average behaviour, you are looking at the failure mode.
  • Keep ground truth when it exists. A holdout with real targets gives you a ceiling. Without it you only have relative comparisons.
  • Include a no-LoRA baseline. Several times the base model turned out to already do most of the task, which changes what "improvement" means.

10-15 pairs is enough to rank variants. 5 is not. See the pitfalls below.


4. Step 3: pick metrics that match the symptom

One metric per symptom. Generic "quality" scores tell you nothing.

Skin micro-texture (blur / plastic look)

Bilateral filter removes texture while preserving edges, so the residual is exactly the fine detail: pores, freckles, fabric weave.

def micro_texture(img_bgr, mask=None):
    g = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
    r = g - cv2.bilateralFilter(g, 9, 40, 7)
    sel = r if mask is None else r[mask > 0]
    return float(np.sqrt((sel ** 2).mean()))

Restrict the mask to skin away from edges. Eyes, mouth and the face outline are strong gradients that swamp the measurement:

x1, y1, x2, y2 = face.bbox; w, h = x2-x1, y2-y1
m = np.zeros(img.shape[:2], np.uint8)
m[int(y1+0.15*h):int(y2-0.05*h), int(x1+0.15*w):int(x2-0.15*w)] = 255
ycc = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
m = cv2.bitwise_and(m, cv2.inRange(ycc, (40,133,77), (250,180,127)))     # skin only
e = cv2.GaussianBlur(np.abs(cv2.Sobel(g,cv2.CV_32F,1,0,3)) +
                     np.abs(cv2.Sobel(g,cv2.CV_32F,0,1,3)), (0,0), 2)
m[e > np.percentile(e[m>0], 70)] = 0                                     # drop edges

Do not use a raw Laplacian variance. I did at first, and it ranked the variants backwards: it measures edge energy, and the blurry variant had harder shadow edges. The bilateral residual and the eye agreed; the Laplacian did not.

Identity

ArcFace cosine (insightface, buffalo_l) between the generated face and the reference. Report it stratified, not averaged. See pitfall 2.

Expression / pose transfer

For faces, mouth aspect ratio from the 68-point landmarks catches the biggest failure mode:

L = face.landmark_3d_68[:, :2]
width = np.linalg.norm(L[64] - L[60])
opening = np.mean([np.linalg.norm(L[61]-L[67]),
                   np.linalg.norm(L[62]-L[66]),
                   np.linalg.norm(L[63]-L[65])])
mar = opening / width

For bodies, YOLOv8-pose keypoints, normalized by image size, mean L2 over keypoints where both detections are confident.

Head rotation: face.pose gives (pitch, yaw, roll) in degrees; compare to the source image.

Background preservation

Compare the output to the input scene outside the subject's box. Beware: if your pipeline regenerates the whole image rather than compositing, every variant will score badly, including no-LoRA. That is a pipeline property, not a LoRA defect, so check the no-LoRA number before concluding anything.

Global similarity to ground truth

DINOv3 (or CLIP) cosine between output and the holdout target. This catches framing, clothing and pose at once, and it was the metric that revealed the LoRA's actual value in the body-swap case: 0.619 without LoRA against 0.700 with it, even though the LoRA scored worse on texture and identity.


5. Step 4: generate the variants

Scale, do not only zero. Both matter, and they behave differently:

  • Scaling (ΔW × s) keeps the direction of the update and only reduces magnitude. The learned behaviour survives, weaker.
  • Zeroing a group removes a direction entirely. The behaviour can disappear.

To scale a LoRA module by s, multiply both factors by sqrt(s):

g = s ** 0.5
out[b+".lora_A.weight"] = (sd[b+".lora_A.weight"].float() * g).to(torch.bfloat16)
out[b+".lora_B.weight"] = (sd[b+".lora_B.weight"].float() * g).to(torch.bfloat16)

A first pass worth running, all at strength 1.0:

variant rule
baseline the LoRA as trained
no_lora nothing, the ceiling/floor reference
attn_only MLP modules removed
mlp_only attention removed
gate_up_off MLP input projection removed
out_off MLP output projection removed
blk{0-7,8-15,16-23,24-31}_off all modules in that depth range removed
gu{range}_off MLP input projection removed in that range only
mlp{25,50,75} MLP scaled
attn{25,50,75} attention scaled

That is ~20 variants. With 10 pairs and a fast sampler it runs in under an hour, and it localizes the defect to a module family and a depth range in one shot.

The script in section 10 writes these out as files. The node in section 12 does the same edits in memory, which is faster to iterate but harder to batch.

Then refine: combine the two or three best cuts, and try compensating the lost magnitude by scaling the surviving attention up (1.15 worked; 1.30 and 1.45 were worse).


6. Step 5: pitfalls

These cost me real time. All of them produced confident, wrong conclusions.

1. The metric that measures the wrong thing

Laplacian variance ranked blurry above sharp. Validate any new metric against a handful of images you have already judged by eye, at 1:1 zoom, before trusting a table built from it.

2. Stratifying by a property of the output

I bucketed results by "face size in the generated image" to study far-away subjects. But face size in the output depends on the framing each variant produces, so variants that frame wider got sorted into the "small face" bucket and the comparison became circular. The baseline had no samples in that bucket at all. Stratify by a property of the input pair, which is fixed across variants.

3. Too few samples

With 5 pairs / 1 seed, a variant showed a 4.99° pose error. At 12 pairs × 3 seeds the same variant measured 6.11°. The ranking had been noise. Before publishing a number, get n above ~20 samples per variant.

4. Winning every metric while destroying the feature

In the head-swap case, removing the MLP gave the best texture and the best identity score. It also made the LoRA stop copying expression from the source: given a source with a wide-open mouth, the output was placid. The metrics I had did not cover that, so I added one (mouth aspect ratio) and re-ran. Always include a deliberately extreme test case, a strong expression or an unusual angle, and look at it.

5. Confusing "the LoRA is bad" with "the base model is good"

Check the no-LoRA baseline on every metric. In the body-swap case the base model beat the LoRA on both texture and identity; the LoRA's value only showed up in similarity to ground truth. Without that metric I would have "improved" the LoRA by deleting most of it.

6. Silent no-op filters when you move this to training

If you exclude modules during training via a name filter, count the modules the network creates and compare to the unfiltered count. Substring filters do not accept regex in most trainers, and a pattern that does not match fails silently: you train the full LoRA and name the file after a filter that never applied.

7. Post-hoc ablation is not training ablation

Removing a module group from a trained LoRA tells you what that group was doing. It does not tell you what happens if you train without it: the remaining modules have free capacity and may relearn the same defect, because the root cause is usually the dataset. Treat the result as a fix and as a hypothesis for the next run, not as proof.


7. Case study A: plastic skin in a head-swap LoRA

Symptom. Skin came out smoothed and slightly plastic; pores and freckles gone.

Weight profile. img_mlp.gate_up norm 3.57 vs ~1.24 for attention, so the MLP input projection dominated the update.

Ablation (5 pairs × 3 seeds, skin micro-texture / ArcFace):

variant texture identity
original 3.15 0.612
MLP 25% 4.81 0.613
gate_up off everywhere 4.87 0.621
attention only 5.16 0.656
base model, no LoRA 4.94 n/a

Texture went above the base model's level with the MLP removed. But the extreme test case showed those same variants no longer copied the source expression. They degenerated into pasting the reference head, which is what the base model does unaided.

head swap comparison

head swap comparison

Face crops, same seed and prompt. Left to right: the reference, the base model with no LoRA, the LoRA as trained, and two surgeries. The freckles and pores that the trained LoRA smooths away come back without touching attention.

Shipped fix. Remove img_mlp.gate_up from the second half of the network only (blocks 16-31): texture 3.15 → 4.30 with expression transfer intact. The early-block MLP is what carries pose and expression; the late-block MLP is what smooths texture.

An alternative that preserved expression better still: scale the whole MLP path to 35% instead of cutting (texture 4.56, and stronger expression transfer than the original).

Bonus finding. A third party had published a "de-blur fix" of this LoRA using DARE masking plus rank reduction. Measuring ΔW module by module against the original, the best scalar fit was a = 0.589 for the density-0.60 variant, exactly the mask density. It was the original LoRA at strength ~0.6 plus unstructured noise. Rank truncation had nothing to do with it: rank-32 truncation retains 94% of the Frobenius energy, and r32/r16/spectral thresholding all left the blur untouched. If someone claims a rank-based fix, check whether it is just attenuation.


8. Case study B: identity lost at distance in a body-swap LoRA

Symptom. "When the person is far from the camera it doesn't look like the same person."

Quantified first. Using the author's own bad outputs: ArcFace 0.428 when the face is ≥140px, 0.204 when below. Correlation between face size and identity: +0.416.

Test set. Extracted the (scene, reference) pairs out of the author's own comparison grids , the exact cases that had failed, and stratified pairs by how much of the frame the face occupies in the input scene.

Ablation (12 pairs × 2 seeds):

variant identity far identity near texture pose err
original 0.299 0.534 5.16 0.0547
gate_up off, blocks 8-15 0.389 0.642 5.71 0.0660
all modules off, blocks 16-23 0.368 0.547 5.46 0.0548
both combined 0.421 0.684 5.87 0.0555
all modules off, blocks 8-15 0.237 0.274 6.52 0.0695

Note the last row: zeroing blocks 8-15 entirely is much worse than zeroing only the gate_up inside them. Attention in those blocks is load-bearing; the MLP input projection is the problem. That distinction is invisible unless you ablate module type and depth separately.

Final recipe, on a later checkpoint, adding a 1.15× on the surviving attention to compensate the removed magnitude:

before after
identity, subject far 0.294 0.485 (+65%)
identity, subject near 0.539 0.680 (+26%)
skin texture 5.16 5.85
global detail 6.19 6.60
pose error 0.0574 0.0554
background PSNR 12.93 12.81

136 of 192 modules kept. Attention at 1.30 and 1.45 was worse than 1.15.

body swap comparison

body swap comparison

body swap comparison

Full frames, same seed and prompt. The pruned versions hold the reference identity better, keep more detail in skin and fabric, and follow the scene's pose at least as well.


9. Taking it back to training, and why you probably should not

Once you know which modules hurt, the obvious next step is to exclude them from the next run. Resist that instinct, or at least treat it as an experiment rather than a conclusion.

Why pruning after training is the better default

Post-hoc surgery is free and reversible. One checkpoint gives you every variant in seconds. You can change your mind, ship two versions, revisit when the next checkpoint lands. Training with a module family excluded commits you before you have evidence, and costs a full run to undo.

Ablation tells you what a group was doing, not what happens without it. When you remove gate_up from a trained LoRA, the remaining modules keep the weights they learned while gate_up was carrying part of the job. Train without it and attention has free capacity, so it may simply relearn the same defect, because in most cases the root cause is the dataset, not the layer. The layer is where the problem lodges, not where it comes from.

It can break training outright. In the body-swap case the author tried excluding gate_up and reported the run failing to converge. That is a real, expensive data point: the MLP input projection was load-bearing for learning the task, even though it was also the source of the defect. The working fix removed it from only 8 of 32 blocks, post-hoc.

Your evidence is about one checkpoint. A recipe validated at step 7000 may not hold at step 20000, as the weight distribution shifts. Keeping the LoRA whole and pruning per checkpoint tracks that; baking the exclusion into training does not.

When training restricted is justified

  • Little data and overfitting is the main risk. Fewer trainable parameters is honest regularization.
  • The ablation showed the group is purely harmful: removing it improved every metric including the ones that measure whether the task still happens. That was not true in either case study here.
  • VRAM or time is the binding constraint. Usually it is not: the base model dominates memory, not the adapters.

If you do it, verify the filter actually applied:

ai-toolkit: under network_kwargs, not the model: section, when training a LoRA (the model: variant filters named parameters, which is the full-finetune path):

network:
  type: lora
  linear: 64
  linear_alpha: 64
  network_kwargs:
    ignore_if_contains:
      - "transformer_blocks.8.img_mlp.gate_up"
      - "transformer_blocks.9.img_mlp.gate_up"
      # ... one entry per block. Substring match, no regex.

musubi-tuner: regex, and names use underscores because the module path is flattened:

--network_args "exclude_patterns=['.*img_mlp_gate_up.*']"

Then count the modules the network creates in the startup log and compare to the unfiltered count. A pattern that does not match fails silently: you train the full LoRA and name the file after a filter that never applied. This happens more often than you would think.

Two cheap checks before spending a full run: measure the skin micro-texture of your training targets against generations from the base model. If your targets are softer than what the base model produces, no layer choice will save you, the fix is the dataset. And stop at 500-1000 steps to see whether the defect is already returning.

10. Reusable script

#!/usr/bin/env python3
"""surgery.py <ckpt.safetensors> <outdir> [recipe ...]"""
import sys, json, struct, re, torch
from pathlib import Path
from safetensors.torch import load_file, save_file

IS_MLP = lambda b: "img_mlp" in b          # adjust for your architecture
IS_GU  = lambda b: "img_mlp.gate_up" in b
IS_ATT = lambda b: "attn" in b

RECIPES = {
    "attn_only":  lambda b, i: 0.0 if IS_MLP(b) else 1.0,
    "gu_off":     lambda b, i: 0.0 if IS_GU(b) else 1.0,
    "mlp25":      lambda b, i: 0.25 if IS_MLP(b) else 1.0,
    "mlp50":      lambda b, i: 0.50 if IS_MLP(b) else 1.0,
    "gu_late":    lambda b, i: 0.0 if (IS_GU(b) and i >= 16) else 1.0,
    "gu_mid":     lambda b, i: 0.0 if (IS_GU(b) and 8 <= i <= 15) else 1.0,
    "combo":      lambda b, i: 0.0 if (IS_GU(b) and 8 <= i <= 15)
                               else (0.0 if 16 <= i <= 23
                               else (1.15 if IS_ATT(b) else 1.0)),
}

def main():
    ckpt, outdir = Path(sys.argv[1]), Path(sys.argv[2])
    outdir.mkdir(parents=True, exist_ok=True)
    names = sys.argv[3:] or list(RECIPES)
    sd = load_file(str(ckpt))
    with open(ckpt, "rb") as f:
        n = struct.unpack("<Q", f.read(8))[0]
        meta = json.loads(f.read(n)).get("__metadata__", {})
    bases = sorted({k[:-len(".lora_A.weight")] for k in sd if k.endswith(".lora_A.weight")})
    blk = lambda b: int(re.search(r'blocks\.(\d+)\.', b).group(1))
    for name in names:
        fn, out = RECIPES[name], {}
        for b in bases:
            s = fn(b, blk(b))
            if s == 0.0:
                continue
            g = s ** 0.5
            out[b+".lora_A.weight"] = (sd[b+".lora_A.weight"].float()*g).to(torch.bfloat16)
            out[b+".lora_B.weight"] = (sd[b+".lora_B.weight"].float()*g).to(torch.bfloat16)
        m = dict(meta)
        m["surgery"] = json.dumps({"recipe": name, "modules": len(out)//2})
        save_file(out, str(outdir/f"{ckpt.stem}__{name}.safetensors"),
                  metadata={k: str(v) for k, v in m.items()})
        print(f"  {name}: {len(out)//2} modules")

if __name__ == "__main__":
    main()

Generate with each variant at strength 1.0, same seed, same prompt, then measure. The whole loop is minutes, and it answers questions that would otherwise cost a training run each.


11. Automating this on your own ComfyUI workflow

You do not need the exact pipeline used here. The loop is: swap one node's value, queue, wait, collect. ComfyUI's HTTP API does all of it, and the whole harness is about 30 lines.

Export your workflow in API format (ComfyUI menu → Save (API Format)). You get a JSON where each node is keyed by id, with its class_type and inputs. Find your LoRA loader:

"5": {"class_type": "LoraLoaderModelOnly",
      "inputs": {"model": ["4", 0], "lora_name": "mine.safetensors", "strength_model": 1.0}}

Now drive it:

import json, urllib.request, time
API = "http://127.0.0.1:8188"

def queue(graph):
    req = urllib.request.Request(API + "/prompt",
        data=json.dumps({"prompt": graph, "client_id": "ablation"}).encode(),
        headers={"Content-Type": "application/json"})
    return json.load(urllib.request.urlopen(req))["prompt_id"]

def wait(pid, timeout=900):
    t0 = time.time()
    while time.time() - t0 < timeout:
        h = json.load(urllib.request.urlopen(f"{API}/history/{pid}"))
        if pid in h:
            if h[pid]["status"].get("completed"):
                return [im["filename"] for o in h[pid]["outputs"].values()
                        for im in o.get("images", [])]
            if h[pid]["status"].get("status_str") == "error":
                raise RuntimeError(json.dumps(h[pid]["status"])[:800])
        time.sleep(2)
    raise TimeoutError(pid)

base = json.load(open("workflow_api.json"))
for variant in ["attn_only", "gu_off", "mlp25", "combo"]:
    for pair in pairs:
        g = json.loads(json.dumps(base))                  # deep copy per job
        g["5"]["inputs"]["lora_name"] = f"ablation/{variant}.safetensors"
        g["7"]["inputs"]["image"] = pair["scene"]         # your LoadImage nodes
        g["8"]["inputs"]["image"] = pair["reference"]
        g["13"]["inputs"]["seed"] = 42                    # fixed across variants
        g["15"]["inputs"]["filename_prefix"] = f"ablation/{variant}__{pair['id']}"
        wait(queue(g))

Name outputs <variant>__<pair_id>__<seed>.png and the metrics script in this gist reads them directly.

Practical notes

  • Put the variants in a subfolder of models/loras/ and reference them as subfolder/name.safetensors. ComfyUI picks up new files without a restart, but if a fresh file is not found, hit /object_info once to refresh.
  • Keep the seed, prompt, sampler, steps and resolution fixed across variants. The only thing that changes is the LoRA file. Otherwise you are measuring noise.
  • Some nodes have non-obvious input names in API format. Dynamic/autogrow inputs may be images.image_1 rather than image_1. If a job errors with "unexpected keyword argument", read the error, it names the parameter it did not expect.
  • Run it headless. python main.py --listen 127.0.0.1 --port 8188 --disable-auto-launch and drive it from a script. ~10s per image on a modern GPU with a distilled sampler means a 20-variant × 10-pair sweep finishes over lunch.

Letting a coding agent build it

This whole pipeline (extracting pairs, generating variants, driving ComfyUI, computing the metrics, assembling comparison grids) is well suited to a coding agent (Claude Code, Codex, Cursor, whatever you use). It is mechanical work with a clear success criterion, and the agent can iterate on the harness while you look at images.

What to hand it so it does not flail:

  1. Your workflow in API format, and which node ids are the LoRA loader, the image loaders, the sampler and the save node.
  2. The symptom in one sentence, and which metric you consider decisive.
  3. The path to your checkpoint and where variants should be written.
  4. A hard instruction to keep the seed fixed and to change one thing at a time.

Then ask it to show you the images, not only the table. An agent optimizing a number will happily hand you a variant that scores beautifully and has deleted the feature you trained. That is pitfall 4, and it applies to agents more than to people, because the agent only sees what you told it to measure.


12. A node, if you prefer clicking

The whole method is weight arithmetic, so it also fits in a ComfyUI node. BFS LoRA Surgery (in ComfyUI-BFSNodes, also on the Comfy Registry) reads any LoRA, shows what it touches, and applies the edited version to the model without writing files.

It is model-agnostic for the same reason the parser above is: the structure comes from the key names, not from a hardcoded list. It handles both the lora_A/lora_B and the lora_down/lora_up+alpha conventions, and it was checked against three architectures that name things differently:

LoRA what it finds
Qwen-Image-2.1 transformer_blocks, 32 blocks, 6 module types
Krea 2 blocks (28) plus txtfusion.layerwise_blocks and txtfusion.refiner_blocks
MiniMax-H3 blocks (50) plus token_refiner.blocks

What the panel gives you

  • every family expanded into its module types, each with a bar proportional to ||dW||, which reads as a map of where training invested
  • a ruler of block indices you drag across to select a range
  • a scale slider, where 0 drops the group and 1 leaves it as trained
  • add rule for a structural match, or add as regex to convert the same selection into a pattern you can hand-edit for cases the tree cannot express
  • the rule list, reorderable, since later rules override earlier ones

The node takes MODEL and returns MODEL, so you drop it where a LoRA loader would go and re-queue after every change. The rules live in a JSON widget that saves with the workflow:

[
  {"enabled": true, "match": {"type": "attn.*"}, "scale": 1.15},
  {"enabled": true, "match": {"type": "img_mlp.gate_up", "blocks": "8-15"}, "scale": 0},
  {"enabled": true, "match": {"blocks": "16-23", "type": "*"}, "scale": 0}
]

Those three rules are the body-swap recipe from section 8. Order matters: the boost comes first, so the drops that follow win where they overlap. Reversed, the boost would silently undo the drops, which is a mistake worth making once in a scratch workflow rather than in a result you believe.

When to use which. The node is better for the first pass, when you do not yet know what you are looking for and want to move a slider and look. The script is better once you do, because it writes variants as files you can sweep over many pairs and seeds unattended, which is how you get an n large enough to trust.

#!/usr/bin/env python3
"""
Metrics bench for LoRA ablation studies.
Pairs an input set with a directory of generations named `<variant>__<pair_id>__<seed>.png`
and reports, per variant: skin micro-texture, global detail, face identity (stratified by how
far the subject is in the INPUT scene), body-pose error and background preservation.
deps: opencv-python, numpy, insightface, onnxruntime, ultralytics
"""
import cv2, numpy as np, glob, os, json, statistics as st
from insightface.app import FaceAnalysis
from ultralytics import YOLO
IN = "inputs/" # <id>_scene.png, <id>_person.png (person = the reference)
OUT = "outputs/" # <variant>__<id>__<seed>.png
FAR_THRESHOLD = 0.14 # face height / image height in the input scene
app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
app.prepare(ctx_id=-1, det_size=(1024, 1024))
yolo = YOLO("yolov8n-pose.pt")
def face(img):
fs = app.get(img)
return max(fs, key=lambda f: (f.bbox[2]-f.bbox[0])*(f.bbox[3]-f.bbox[1])) if fs else None
def keypoints(img):
r = yolo(img, verbose=False)[0]
if r.keypoints is None or len(r.keypoints.data) == 0:
return None, None
areas = [(b[2]-b[0])*(b[3]-b[1]) for b in r.boxes.xyxy.cpu().numpy()]
i = int(np.argmax(areas))
k = r.keypoints.data[i].cpu().numpy() # 17 x (x, y, conf)
h, w = img.shape[:2]
k[:, 0] /= w; k[:, 1] /= h # normalize so sizes are comparable
return k, r.boxes.xyxy.cpu().numpy()[i]
def pose_error(a, b):
if a is None or b is None:
return None
m = (a[:, 2] > 0.4) & (b[:, 2] > 0.4)
if m.sum() < 5:
return None
return float(np.sqrt(((a[m, :2] - b[m, :2])**2).sum(axis=1)).mean())
def micro_texture(img, mask=None):
"""Bilateral residual = fine detail without edges. Higher = more real texture."""
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
r = g - cv2.bilateralFilter(g, 9, 40, 7)
if mask is None:
return float(np.sqrt((r**2).mean()))
if (mask > 0).sum() < 300:
return None
return float(np.sqrt((r[mask > 0]**2).mean()))
def skin_mask(img, f):
"""Inner face region, skin-colored, with strong gradients removed."""
m = np.zeros(img.shape[:2], np.uint8)
if f is None:
return m
x1, y1, x2, y2 = [int(v) for v in f.bbox]
w, h = x2-x1, y2-y1
m[max(0, int(y1+0.15*h)):int(y2-0.05*h), max(0, int(x1+0.15*w)):int(x2-0.15*w)] = 255
ycc = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
m = cv2.bitwise_and(m, cv2.inRange(ycc, (40, 133, 77), (250, 180, 127)))
g = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
e = cv2.GaussianBlur(np.abs(cv2.Sobel(g, cv2.CV_32F, 1, 0, 3)) +
np.abs(cv2.Sobel(g, cv2.CV_32F, 0, 1, 3)), (0, 0), 2)
if (m > 0).sum() > 100:
m[e > np.percentile(e[m > 0], 70)] = 0
return m
def outside_subject(img, box, grow=0.12):
m = np.ones(img.shape[:2], np.uint8) * 255
if box is None:
return m
h, w = img.shape[:2]
x1, y1, x2, y2 = box
bw, bh = x2-x1, y2-y1
m[max(0, int(y1-bh*grow)):min(h, int(y2+bh*grow)),
max(0, int(x1-bw*grow)):min(w, int(x2+bw*grow))] = 0
return m
# ---- reference pass: classify each pair by how far the subject is ------------------
REF = {}
for p in sorted(glob.glob(IN + "*_scene.png")):
pid = os.path.basename(p).split("_")[0]
scene = cv2.imread(p)
person = cv2.imread(f"{IN}{pid}_person.png")
fp, fs = face(person), face(scene)
k, box = keypoints(scene)
frac = (max(fs.bbox[2]-fs.bbox[0], fs.bbox[3]-fs.bbox[1]) / scene.shape[0]) if fs else 0
REF[pid] = dict(scene=scene, emb=(fp.normed_embedding if fp is not None else None),
kps=k, box=box, group=("FAR" if frac < FAR_THRESHOLD else "NEAR"))
# ---- measure every generation ------------------------------------------------------
rows = []
for fp_ in sorted(glob.glob(OUT + "*.png")):
parts = os.path.basename(fp_).split("__")
variant, pid = parts[0], parts[1].split("_")[0]
if pid not in REF or REF[pid]["emb"] is None:
continue
img = cv2.imread(fp_)
f = face(img)
k, box = keypoints(img)
scene = cv2.resize(REF[pid]["scene"], (img.shape[1], img.shape[0]),
interpolation=cv2.INTER_LANCZOS4)
bm = cv2.bitwise_and(outside_subject(img, box), outside_subject(scene, REF[pid]["box"]))
bg = None
if (bm > 0).sum() > 2000:
d = ((img.astype(np.float32) - scene.astype(np.float32))**2).mean(axis=2)
bg = float(10 * np.log10(255.0**2 / max(d[bm > 0].mean(), 1e-6)))
rows.append(dict(
v=variant, pid=pid, group=REF[pid]["group"],
identity=(float(np.dot(f.normed_embedding, REF[pid]["emb"])) if f is not None else None),
texture=micro_texture(img, skin_mask(img, f)),
detail=micro_texture(img),
pose=pose_error(k, REF[pid]["kps"]),
bg=bg))
json.dump(rows, open("metrics.json", "w"), default=float, indent=1)
# ---- report -----------------------------------------------------------------------
V = {}
for r in rows:
V.setdefault(r["v"], []).append(r)
def mean(rs, key, group=None):
vals = [x[key] for x in rs if x[key] is not None and (group is None or x["group"] == group)]
return st.mean(vals) if vals else float("nan")
print(f"{'variant':16s} {'id_FAR':>7s} {'id_NEAR':>8s} {'texture':>8s} {'detail':>7s} "
f"{'pose_err':>9s} {'bg_psnr':>8s} n")
for v, rs in sorted(V.items(), key=lambda kv: -mean(kv[1], "identity", "FAR")):
print(f"{v:16s} {mean(rs,'identity','FAR'):7.3f} {mean(rs,'identity','NEAR'):8.3f} "
f"{mean(rs,'texture'):8.2f} {mean(rs,'detail'):7.2f} {mean(rs,'pose'):9.4f} "
f"{mean(rs,'bg'):8.2f} {len(rs)}")
@Poukpalaova

Copy link
Copy Markdown

Ya, that's working but it make that resulting lora very instable in comfyui, it even make it crash very often. Look at the console while you use your lora, you'll see.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment