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.
- List the module groups your LoRA touches (
attn.*vsmlp.*, and per block depth). - Build a test set that provokes the defect you care about.
- Pick a metric per symptom. Never judge blur by eye at 300px.
- Generate: baseline, no-LoRA, and one variant per ablation, same seed and prompt.
- 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.
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.
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 |
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.
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.
One metric per symptom. Generic "quality" scores tell you nothing.
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 edgesDo 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.
ArcFace cosine (insightface, buffalo_l) between the generated face and the reference.
Report it stratified, not averaged. See pitfall 2.
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 / widthFor 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.
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.
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.
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).
These cost me real time. All of them produced confident, wrong conclusions.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
#!/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.
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.
- Put the variants in a subfolder of
models/loras/and reference them assubfolder/name.safetensors. ComfyUI picks up new files without a restart, but if a fresh file is not found, hit/object_infoonce 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_1rather thanimage_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-launchand 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.
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:
- Your workflow in API format, and which node ids are the LoRA loader, the image loaders, the sampler and the save node.
- The symptom in one sentence, and which metric you consider decisive.
- The path to your checkpoint and where variants should be written.
- 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.
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.










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.