Nodes/Emulsion/Emulsion Extract
ComfyUI Node

Emulsion Extract

Reverse-engineer a look from two images

By 24latents·Created 2 months ago·Updated 2 months ago· 3
Emulsion Extract
  • image_source
  • image_target
  • STRING
◄preset_nameextracted►
◄iterations400►
◄fit_vignettetrue►

You have the finished image; you don't have the recipe. Emulsion Extract takes a before/after pair - the same photo, one ungraded, one carrying the grade you're chasing - fits a preset to it by gradient descent, and writes that preset out as JSON for Emulsion Grade to reuse. It's the parametric cousin of a colour-match node: instead of copying one image's statistics onto another, it hands you editable numbers you keep. A colour match is per-image and gone when the run ends; this is a house look for a batch, a sequence, or the next hundred renders.

How the fit actually works

No neural net, no LUT export. fitting.py runs Adam (lr ~0.02 with decay) in pure torch, on the device of your input tensors, against a deliberately tiny parameter family, in pipeline order:

  • A 6-point tone curve per RGB channel. The x positions are fixed at 0/51/102/153/204/255; only the y values move, reparametrized as a cumulative softplus sum so the curve stays monotone and points can't cross. Per-channel curves are the point of the whole thing: a vintage look that lifts red blacks while crushing blue is representable here, and multiplicative maps like temperature and tint are subsumed by it. When the three channels agree within a couple of levels, they collapse into one shared curve.
  • Saturation, initialised at 1 - curves can't mix channels, so it needs its own knob.
  • An optional vignette - amount, radius, feather, shape fixed to circle.

Grain is deliberately not fitted, because it's stochastic: after convergence the node estimates it from the residual high-frequency noise (standard deviation left after a sigma-1.5 blur) and writes a grain block only when there's a real gap.

The optimisation runs on a copy downscaled to 512px on the long side, so it stays fast; the RMSE printed to the console is measured at native resolution through the real Catmull-Rom renderer, and the fit stops early after 50 flat iterations.

Inputs and outputs

  • image_source - the ungraded original. image_target - the same image, graded. Order matters, and it has to be the same shot: resolutions may differ as long as the aspect ratio matches, since the target is resampled to the source before fitting.
  • preset_name - the "name" stored in the JSON; the file is written under a slugified version, so My Look! becomes my_look.json.
  • iterations - 50 to 2000, default 400. 100 for a draft, 400 for most grades, 800–2000 when the fit keeps creeping.
  • fit_vignette - on by default; turn it off when the pair has no vignette, or the optimiser is free to abuse it.

The output is a STRING holding the preset JSON. The node is also an output node, so it fires on its own and prints initial and final RMSE, iterations run, and the path written. Wire the string into rgthree's Display Any to read it in-graph - the bundled example does.

The written file contains only significantly non-neutral parameters - the fourteen shipped presets came out of this node the same way - and it lands in the pack's presets/ folder, the one Emulsion Grade's dropdown reads, so it's waiting there after a browser refresh. On a name collision it appends _2, _3: re-fitting while you iterate accumulates variants instead of replacing one. Since kodak_portra_160 and friends are fitted approximations of a look, not the film's published curves, treat them as starting points you can nudge.

Installing it

Same pack, so one install covers both nodes - ComfyUI Manager, search Emulsion, or:

cd ComfyUI/custom_nodes
git clone https://github.com/24latents/ComfyUI-Emulsion.git

Restart, done. No dependencies - requirements.txt is a comment saying torch comes with ComfyUI - no model files, nothing to download. The fit is pure torch, on CPU or GPU.

When the fit goes wrong

RMSE stuck in the tens of /255. The pair isn't the same image under a grade - swapped source and target, a re-render with different content, mismatched aspect ratios. Iterations don't fix content difference; a real film-style grade lands in the single digits.

The vignette eats the difference. A naturally darker corner gets explained as a vignette, and everything else fits around a phantom. No vignette in the pair, switch fit_vignette off.

It maxes out and still looks off. You've hit the ceiling of the parameter family, not the budget. Extract recovers tone, cast, saturation, vignette and a grain estimate; it knows nothing about per-hue HSL, bloom, halation, clarity or .cube LUTs. A heavy teal-orange grade comes back as a decent version of its tonal core. Open the JSON and add the rest by hand.

And keep a copy of anything you like. Extracted presets live inside the custom node's own folder, and a Manager update or clean re-clone can take them with it.

Categoryimage/postprocessing

Inputs (5)

NameTypeDefaultDescription
image_sourceIMAGE—
image_targetIMAGE—
preset_nameSTRINGextracted—
iterationsINT40050–2000—
fit_vignetteBOOLEANtrue—

Outputs (1)

NameTypeDescription
STRINGSTRING—