Nodes/ComfyUI CV/CV Photometric Align (Gain/Bias)
ComfyUI Node

CV Photometric Align (Gain/Bias)

Match exposure between two shots — and get a free 'nothing changed here' mask

By bmad4ever·Created 4 months ago·Updated 15 days ago· 1
CV Photometric Align (Gain/Bias)
  • source
  • reference
  • mask
  • aligned
  • gain
  • bias
  • inlier_mask
  • residual
  • found
◄modelgain + bias►
◄scopeper channel (also fixes white balance)►
◄inlier_fraction0.50►
◄iterations3►

Every "clean plate" workflow has the same first problem. You shot a background plate, then the subject walked in, and the camera's auto-exposure moved a third of a stop between them. Composite them as-is and the subject sits on a visibly brighter or cooler background. Fixing that with a curves node by eye is fine for one image and hopeless for fifty.

CV Photometric Align (Gain/Bias) fits a per-channel source * gain + bias ≈ reference and applies it. The fit is robust - and that's the whole trick. Pixels whose content changed (the subject that wasn't in the plate, a moving object, a specular flash) are outliers, so they get trimmed away and the gain ends up describing the light, not the difference between the two pictures. Saturated pixels are dropped too, because a clipped pixel only says "at least this bright" and pulls the gain low if you fit through it.

Note this is one of the few nodes in the pack that is not a cv2 call: it's a trimmed least-squares fit written in numpy, which the README calls out explicitly as one of the genuine exceptions to "everything here wraps OpenCV".

The inputs that matter

  • source - the image to correct. Polymorphic: IMAGE/MASK/NPARRAY, echoed back in the same type.
  • reference - the target exposure. Same size and channel count as the source (mismatches raise, they don't silently resize). Never modified.
  • model - gain + bias (default, exposure plus a black-level/flare shift), gain only (the pure ISO/exposure model), or bias only (additive stray light).
  • scope - per channel also fixes white balance; all channels together pools the evidence, which is the safer choice when the two images have few unchanged pixels.
  • inlier_fraction - the fraction of pixels kept at each refit. Set it below the fraction of the frame that's genuinely unchanged. 0.5 tolerates a subject covering half the frame; 1.0 disables trimming and reduces to a plain least squares, which is exactly the version that gets dragged around by the subject. This is the knob beginners should touch first.
  • iterations - refits after the initial selection. 3 is plenty for a photometric drift; more only helps when the first selection was poor.
  • mask (optional) - fit only inside a region (non-zero pixels). The trimming still runs inside it.

Outputs, and the free one

aligned is the source with gain/bias applied, in its own type and dtype (integers are rounded and clipped) - and identical to the input when found=false. gain and bias are per-channel float64 vectors (bias in the input's own units, so 0–255 for uint8). residual is the median absolute residual over the inliers, in input units: the honest "how well do these two images agree once the light is accounted for" number.

And then inlier_mask, which is the reason to love this node: uint8, 255 where the pixel was an inlier in every channel. That is a ready-made "what is unchanged between these two shots" mask - which is the background plate mask, the occlusion mask, the region you're allowed to paint into. Wire it into content_mask or background_paintable on CV Paste Through Warp, or into an inpaint mask. You came for a gain and left with a segmentation.

Failure is soft, in the pack's usual style: a featureless or degenerate pair returns found=false with gain 1 / bias 0 and the source untouched, so a run doesn't die on a flat-colored frame.

Install

Manager → search ComfyUI CV, or:

cd ComfyUI/custom_nodes
git clone https://github.com/bmad4ever/comfyui_cv
cd comfyui_cv && pip install "opencv-contrib-python-headless~=5.0.0.93"

Restart. Python ≥ 3.12, V3 node API. Pure numpy under the hood, but it lives in the pack that pins the contrib headless OpenCV wheel.

Common issues

  • The gain is nonsense and the residual is huge - inlier_fraction is too high for how much of the frame changed, or the two images aren't actually the same scene. Lower it, or supply mask for the region you know is unchanged.
  • A size or channel-count error - deliberate. Resize or convert one of them first; a silent resize here would quietly corrupt the fit.
  • Everything came out at gain 1 - found=false; check the residual and whether one of the images is flat, empty, or fully masked.
  • White balance drifts but exposure was fine - try per channel with bias only, or the reverse: scope all-channels with gain + bias when only some of the frame changed.

As with everything in this pack: heavy LLM assistance, no production guarantee, updates whenever. This node is a small amount of arithmetic you can sanity-check by looking at the residual, so it's one of the safer bets here.

Categoryimage/CV

Inputs (7)

NameTypeDefaultDescription
sourceCOMFY_MATCHTYPE_V3Image to correct - its exposure is changed to match the reference. Echoed back in the same type (IMAGE/MASK/NPARRAY). Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size.
referenceNPARRAY,IMAGE,MASKImage whose exposure/white balance is the target. Same size and channel count as the source; it is never modified. Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size.
modelCOMBOgain + biasWhat to fit. 'gain + bias' handles an exposure change plus a black-level/flare shift (the usual case). 'gain only' is the pure exposure/ISO model. 'bias only' is an additive offset, e.g. stray light added to one shot.
scopeCOMBOper channel (also fixes white balance)Per channel gives each of B, G, R its own gain and bias, which also corrects a white-balance drift. One shared fit is safer when the images have few unchanged pixels, because all channels then pool their evidence.
inlier_fractionFLOAT0.500.05–1Fraction of pixels kept as inliers at each refit. Set it BELOW the fraction of the frame that is genuinely unchanged (0.5 tolerates a subject covering half the frame). 1.0 disables the trimming and gives a plain least squares.
iterationsINT31–20Refits after the initial difference-based inlier selection. 3 is enough for a photometric drift; more only matters when the first selection was poor.
maskoptNPARRAY,MASKOptional region to fit ON (non-zero pixels), for when you already know where the unchanged background is. The trimming still runs inside it. Accepts a ComfyUI IMAGE/MASK directly (frame 0 of a batch) or an NPARRAY. Arithmetic ops (add, multiply, etc.) process the full IMAGE batch when both inputs have the same batch size.

Outputs (6)

NameTypeDescription
alignedCOMFY_MATCHTYPE_V3The source with gain/bias applied, in the source's own type and dtype (integer types are rounded and clipped). Identical to the input when found=false.
gainNPARRAYfloat64 (C,) multiplier per channel, 1.0 where nothing was fitted.
biasNPARRAYfloat64 (C,) offset per channel, in the input's own units (0-255 for uint8), 0.0 where nothing was fitted.
inlier_maskNPARRAYuint8 [H,W] 255 where the pixel was an inlier in EVERY channel, i.e. where the two images show the same thing under the fitted lighting. All zeros when found=false.
residualFLOATMedian absolute residual over the inliers after the fit, in input units - how well the two images agree once the light is accounted for.
foundBOOLEAN—