Train Lens (Contrastive)
Bake a concept into a vector with ten sentences, about 30 seconds of GPU
- lens_path
Want a concept lens that doesn't come from the preset list - say "80s VHS" or "brutalist concrete" - without fiddling with a terminal? This is the node. It takes text descriptions, optimizes a single direction vector that separates "texts that embody the concept" from "texts that don't," and saves a lens you can drop into the main Concept Steer node. It's the fastest training path in the pack: roughly 30 seconds on a GPU for a lens, no LoRA, no fine-tuning, no new model weights.
How it works
Under the hood it's the Contrastive-style paired-margin optimization the README describes, adapted from LLM alignment to finding a direction instead of a policy. You give it N positive/negative text pairs. It encodes all the positives and negatives through the target text encoder (Qwen 3.4B for Z Image, SigLIP for SD 1.5), then optimizes a unit vector d so that every positive text scores higher on the dot product with d than its paired negative - sweeping a temperature parameter β and keeping the β with the best minimum margin. With ten well-crafted pairs this typically hits 100% separation. The result is one unit vector that says "this way is the concept."
The author is honest about the weak spot: with only ~10 pairs, the direction can overfit to incidental correlations in the training texts. That's why the quality of your pairs matters more than their quantity.
The inputs that matter
- concept_name - used in the output filename.
my_conceptby default, so name it something real. - positive_texts / negative_texts - one per line, same count on both sides. The negative texts should describe a similar scene without the concept, not a totally different subject. The README's example for "cinematic": positive "dramatic chiaroscuro portrait with deep shadows," negative "portrait with standard studio lighting." Bad pairs like "cinematic mountain" vs "cat on couch" teach the lens scene differences instead of style.
- target -
zimage(2560d) orsd15(768d). Match the model you're actually generating with. - contrastive_steps - 500 is the floor and the default; more is more refined but rarely worth it for broad concepts.
- encoder_path - path to the Qwen 3.4B safetensors for
zimage, or set theQWEN_ENCODER_PATHenv var and leave it blank. - protect_existing -
trueby default, so reruns save as_v2instead of clobbering your good lens. Leave it on.
Output is lens_path (a STRING), the absolute path to the saved lens file. It lands in lenses/, which means it also shows up in the Concept Steer dropdown automatically - you only need to wire lens_path somewhere if you want to pipe it into a custom_lens_path input or save it elsewhere.
Installing
Same as every node in this pack - ComfyUI Manager, search "Concept Steer", or:
cd ComfyUI/custom_nodes
git clone https://github.com/Nynxz/ComfyUI-ConceptSteer
# restart ComfyUI
The pack itself ships no dependencies, but lens training needs transformers and safetensors (pip install transformers safetensors) and about 10 GB of VRAM for the Qwen encoder path. The exact same contrastive training is available from the CLI as python tools/lens_factory.py text-pairs pairs.json --concept mystyle --target zimage, which is handy if you'd rather version-control your text pairs as JSON.
Common gotchas
- Empty lens dropdown after training? Restart ComfyUI or check the file actually landed in
lenses/- the dropdown is populated by scanning that directory. - Lens does nothing / does the wrong thing? Revisit your negative texts. This is the #1 failure mode and it's almost always bad pairs, not the optimizer.
- Dimension mismatch errors if you train for
sd15and apply to a Z Image workflow (or vice versa) - the lens dims are hard per-target, 768 vs 2560. Match yourtargetto your model.
Inputs (8)
| Name | Type | Default | Description |
|---|---|---|---|
| concept_name | STRING | my_concept | Name for the concept (used in filename) |
| positive_texts | STRING | Texts that EMBODY the concept (one per line). E.g. for 'cinematic': 'A dramatic chiaroscuro portrait with deep shadows and golden rim lighting' | |
| negative_texts | STRING | Neutral/opposite texts (one per line, same count as positive). E.g. 'A portrait with standard studio lighting and even exposure' | |
| target | COMBO | zimage | Target model architecture. 'zimage' = Qwen 3.4B (2560d), 'sd15' = SigLIP (768d) |
| contrastive_steps | INT | 500500–20000 | Contrastive optimization steps (more = more refined) |
| encoder_path | STRING | Path to Qwen 3.4B safetensors file (for zimage target). Leave empty to use QWEN_ENCODER_PATH env var. | |
| output_dir | STRING | Override output directory for the lens file | |
| protect_existing | BOOLEAN | true | If the output lens file already exists, save as _v2, _v3, … instead of overwriting. Disable only when intentionally replacing. |
Outputs (1)
| Name | Type | Description |
|---|---|---|
| lens_path | STRING | — |