Normal Map Light Estimator
Your normal map already knows where the light is — this node reads it out
- 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
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:
- Unpack the normal map - the 0–255 RGB values become XYZ surface orientations in −1..1 space.
- Build a luma mask from your
luma_image- only the pixels that are actually lit survive the threshold. - 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) andluma_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) andy_threshold_upper/y_threshold_lower(0.1) - how far from center before something counts as left/right/top/bottom.hard_light_threshold(0.15) andsoft_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_thresholdis too high or the normal map has no real lighting info. Lower it, or check thedebug_maskoutput 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_thresholddefault 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.
Inputs (17)
| Name | Type | Default | Description |
|---|---|---|---|
| normal_map | IMAGE | — | |
| luma_image | IMAGE | — | |
| luma_threshold | FLOAT | 0.500–1 | — |
| curve_type | COMBO | s_curve | 4 options: linear, s_curve, exponential, logarithmic |
| x_threshold | FLOAT | 0.400–1 | — |
| y_threshold_upper | FLOAT | 0.100–1 | — |
| y_threshold_lower | FLOAT | 0.100–1 | — |
| central_threshold | FLOAT | 0.300–1 | — |
| hard_light_threshold | FLOAT | 0.150–1 | — |
| soft_light_threshold | FLOAT | 0.350–1 | — |
| shadow_ire_threshold | FLOAT | 200–50 | — |
| transition_sensitivity | FLOAT | 0.100.01–1 | — |
| format_modeopt | COMBO | auto | 2 options: auto, manual |
| normal_standardopt | COMBO | OpenGL | 4 options: OpenGL, DirectX, World_Space, Object_Space |
| analysis_methodopt | COMBO | combined | 3 options: advanced, legacy, combined |
| exclusion_maskopt | MASK | — | |
| ire_analysis_weightopt | FLOAT | 0.50–1 | — |
Outputs (20)
| Name | Type | Description |
|---|---|---|
| x_direction | STRING | — |
| y_direction | STRING | — |
| combined_direction | STRING | — |
| hard_soft_index | STRING | — |
| x_confidence | STRING | — |
| y_confidence | STRING | — |
| overall_confidence | STRING | — |
| spread_value | STRING | — |
| debug_mask | IMAGE | — |
| lit_normals_viz | IMAGE | — |
| cluster_delta_chart | IMAGE | — |
| x_threshold_preview | IMAGE | — |
| y_threshold_preview | IMAGE | — |
| false_color_ire | IMAGE | — |
| shadow_mask | IMAGE | — |
| soft_shadow_mask | IMAGE | — |
| shadow_character | STRING | — |
| transition_quality | STRING | — |
| soft_ratio | FLOAT | — |
| hard_ratio | FLOAT | — |