Nodes/NormalMapLightEstimator/Normal Map Light Estimator
ComfyUI Node

Normal Map Light Estimator

Your normal map already knows where the light is — this node reads it out

By APZmedia·Created 11 months ago·Updated 11 months ago· 0
Normal Map Light Estimator
  • normal_map
  • luma_image
  • exclusion_mask
  • x_direction
  • y_direction
  • combined_direction
  • hard_soft_index
  • x_confidence
  • y_confidence
  • overall_confidence
  • spread_value
  • debug_mask
  • lit_normals_viz
  • cluster_delta_chart
  • x_threshold_preview
  • y_threshold_preview
  • false_color_ire
  • shadow_mask
  • soft_shadow_mask
  • shadow_character
  • transition_quality
  • soft_ratio
  • hard_ratio
luma_threshold0.50
curve_types_curve
x_threshold0.40
y_threshold_upper0.10
y_threshold_lower0.10
central_threshold0.30
hard_light_threshold0.15
soft_light_threshold0.35
shadow_ire_threshold20
transition_sensitivity0.10
format_modeauto
normal_standardOpenGL
analysis_methodcombined
ire_analysis_weight0.5

This node answers a question you've probably asked while staring at a relighting workflow: which direction is the light actually coming from, and is it hard or soft? NormalMapLightEstimator takes a normal map plus a "luma" image and returns plain-English answers - "top-left", "soft" - plus confidence scores. It's an analysis node, not a generator: nothing gets rendered, you get numbers, strings, and debug images you wire into whatever's downstream.

When you'd actually reach for it

Relighting tools like IC-Light are picky about lighting intent, and matching a subject to a scene's lighting is one of the harder compositing jobs (the KB's IC-Light essay makes that point well). Most people eyeball it. This node is for when you want the lighting measured - you can auto-fill a lighting prompt, drive a directional mask, or verify that a generated normal map has plausible lighting before you build on it. It's niche, but it's the kind of measurement that's genuinely annoying to do by hand.

How it works

The key insight is real 3D physics: lit surface normals point toward the light source. So the algorithm is straightforward:

  1. Unpack the normal map - the 0–255 RGB values become XYZ surface orientations in −1..1 space.
  2. Build a luma mask from your luma_image - only the pixels that are actually lit survive the threshold.
  3. Average the surviving normals - that average direction points at the light. A tight cluster of directions means hard light; a spread means soft.

From there it classifies the X/Y averages into left/central/right and top/central/bottom, and the spread of the normal distribution becomes the hard/soft index (0.0 hard → 1.0 soft). There's also an IRE-based shadow analysis pass (using shadow_ire_threshold) that classifies shadow character and transition quality.

The inputs that matter

Don't touch all ten sliders. Set these:

  • normal_map (IMAGE) and luma_image (IMAGE) - both required. Your normal map can come from a ControlNet preprocessor, DepthAnything-style estimators, or a 3D render. The luma image is what defines "lit" - feed it the actual image whose lighting you're analyzing.
  • luma_threshold (default 0.5) - the big one. Too high and nothing is "lit" and the node bails to a default central direction; too low and background noise swamps the reading.
  • normal_standard (OpenGL default) - if your directions come back consistently flipped, this is why. OpenGL vs DirectX flip the Y axis; World_Space and Object_Space expect non-tangent maps. Get this wrong and everything's mirrored.
  • x_threshold (0.4) and y_threshold_upper/y_threshold_lower (0.1) - how far from center before something counts as left/right/top/bottom.
  • hard_light_threshold (0.15) and soft_light_threshold (0.35) - the range boundaries for the hard/soft index.

analysis_method (advanced/legacy/combined) is a nice knob: combined blends the clustering-based analysis with the older mean-based one. If you're passing an exclusion_mask, it's a MASK that crops out pixels you don't want counted (like a background you don't trust).

The outputs

The three you'll actually use: combined_direction ("top-left", "bottom-central"...), hard_soft_index, and overall_confidence. The rest are either fine-grained strings (x_direction, y_direction, shadow_character, transition_quality), ratios (soft_ratio/hard_ratio), or debug images. The viz outputs - debug_mask, false_color_ire, shadow_mask - are genuinely useful the first time you use this: preview them to confirm the mask is catching the right pixels before you trust the direction.

Installing it

Search ComfyUI Manager for "LightDirection" (pack title is "NormalMapLightEstimator"), or clone it manually:

cd ComfyUI/custom_nodes
git clone https://github.com/APZmedia/Comfyui-LightDirection-estimation

Then restart ComfyUI. The node lives under Custom/Lighting. Good news: there are no model downloads - this is pure math on tensors. The deps are the usual suspects (torch, numpy, Pillow) plus matplotlib, which the README's requirements.txt list but the install guide forgets to mention; it's needed for the chart outputs. If ComfyUI's auto-install fails, run pip install -r requirements.txt inside the node folder.

Where people get burned

  • "No light direction detected" - your luma_threshold is too high or the normal map has no real lighting info. Lower it, or check the debug_mask output to see what actually counted as lit.
  • Everything's mirrored - wrong normal_standard. Flip it.
  • Confidence is garbage but the direction looks right - trust the confidence score, not the string. Low confidence means a near-central/ambiguous reading, and pushing thresholds to force a direction just manufactures confidence.
  • The README is slightly out of sync with the code (its x_threshold default says 0.1; the shipped node uses 0.4). When in doubt, the values ComfyUI actually shows you are what runs.

This is a young pack with essentially zero community footprint yet - expect to read the source if you hit something weird. But for a measurement nobody else in the ComfyUI world ships, it's a clever little tool.

CategoryCustom/Lighting

Inputs (17)

NameTypeDefaultDescription
normal_mapIMAGE
luma_imageIMAGE
luma_thresholdFLOAT0.500–1
curve_typeCOMBOs_curve4 options: linear, s_curve, exponential, logarithmic
x_thresholdFLOAT0.400–1
y_threshold_upperFLOAT0.100–1
y_threshold_lowerFLOAT0.100–1
central_thresholdFLOAT0.300–1
hard_light_thresholdFLOAT0.150–1
soft_light_thresholdFLOAT0.350–1
shadow_ire_thresholdFLOAT200–50
transition_sensitivityFLOAT0.100.01–1
format_modeoptCOMBOauto2 options: auto, manual
normal_standardoptCOMBOOpenGL4 options: OpenGL, DirectX, World_Space, Object_Space
analysis_methodoptCOMBOcombined3 options: advanced, legacy, combined
exclusion_maskoptMASK
ire_analysis_weightoptFLOAT0.50–1

Outputs (20)

NameTypeDescription
x_directionSTRING
y_directionSTRING
combined_directionSTRING
hard_soft_indexSTRING
x_confidenceSTRING
y_confidenceSTRING
overall_confidenceSTRING
spread_valueSTRING
debug_maskIMAGE
lit_normals_vizIMAGE
cluster_delta_chartIMAGE
x_threshold_previewIMAGE
y_threshold_previewIMAGE
false_color_ireIMAGE
shadow_maskIMAGE
soft_shadow_maskIMAGE
shadow_characterSTRING
transition_qualitySTRING
soft_ratioFLOAT
hard_ratioFLOAT