integrate_normals — 3D photometric op

• Data kinds: normalmap → image2d

• Call: import fullseye as fs; fs.ledger.integrate_normals(normals, mask=None) (to call the implementation directly, import photometric; photometric.integrate_normals(normals, mask=None); from the registry, ops3d.get("integrate_normals"))

• GPU: none (CPU reference implementation only)

Usage

Normal field → height field z by Frankot-Chellappa integration. → z, HxW (a constant offset is free; referenced to zero mean).

Procedure: converts the normals (H,W,3) to gradients p = -nx/nz, q = -ny/nz (pixels with |nz| < 1e-6 have nz replaced by ±1e-6 to avoid division by zero), finds the least-squares solution in the FFT domain Z(wx,wy) = (-j·wx·P - j·wy·Q) / (wx² + wy²) (DC component 0), transforms back and subtracts the mean to make it 0. If mask (bool HxW) is given, gradients outside the mask are set to 0 before integrating (that region is treated as flat). Returns float64 (H,W).

Caveats:

• A periodic boundary is assumed, so the borders distort as if the top/bottom and left/right of the image were connected (the global tilt is restored, see ★ below).

• The absolute height is not determined (mean 0). Integration uses a grid spacing of 1 pixel, so z is also a height in pixel units; multiply by the pixel pitch for real dimensions.

• nz ≈ 0 (near contours, surfaces parallel to the line of sight) makes the gradient diverge and spreads ripples into the surroundings. Exclude such pixels with mask or smooth the normals beforehand.

surface_normals is the inverse transform; passing the normals of photometric_stereo here gives the shape (height field). Empty input (normals with 0 elements) raises ValueError.

★Global tilt (2026-11-09): the periodic-boundary Frankot-Chellappa drops the mean of the gradient (DC) to 0 and returned a tilted plane as level (slope 0.2 px/px → recovered 0.0000, rms 5.5 px). After integrating, the first-order term of the mean gradient mean(p)·(x − x̄) + mean(q)·(y − ȳ) is added back (the mean is over the whole grid, including the zeros outside mask). A tilted plane comes back exactly, and a surface whose mean gradient is 0 (a masked sphere, etc.) only moves by rounding dust. For the raw periodic solution use integrate_gradients. ★The tilt added back is a mean, so it is weak against outliers (independent review 2026-11-09): on a flat surface, just 20 pixels of normals tilted strongly the same way (nz 0.002, which passes the 1e-3 cut) raise the rms error from 10.7 to 27.9 px (2.6× worse). Steep normals lined up on one side of a contour are common in real data, so remove them with mask before passing. It is not replaced by a robust estimate such as the median because on surfaces where a few large gradients carry the true tilt (like the wall of a step) the tilt would be lost. Another limit: the tilt is the mean of the measured normals, so walls of steps that photometric stereo cannot see are not in it —— on a part with steps the added tilt differs from the true mean gradient (in the 3-D method comparison PoC −0.066 to −0.084 vs the true +0.0125 px/px). ★Input gate: anything other than (H,W,3) and non-finite normals (one NaN pixel turned every pixel NaN through the FFT) raise ValueError. Pixels with |nz| < 1e-3 (gradient above 1000: contours, surfaces parallel to the line of sight) get gradient 0 and are left out of the integration, and their count is reported with a RuntimeWarning (one pixel with nz=0 swung the height by 2.3e5 px).

References (sample data, literature)

• Sample-data catalog (download URLs / licences) — 2-D uses skimage.data (BSD/public domain) plus synthetic images; 3-D lists download URLs for real data sources (Stanford, PDS, …).

• Operator provenance and references — the sources of the research/methods this op family came from.

Runnable examples (verified samples that actually call this op)

• photometric_stereo — py -3.11 examples_3d/photometric_stereo.py

Ops the type connects to (they accept image2d as input)

fuse_to_voxel · fit_poly_surface · eval_poly_surface · surface_form_error · background_flatten · polar_unwrap · fit_zernike · matcap_shade

Same category (photometric)

photometric_stereo · surface_normals · render_lambertian


*Provenance: photometric.py — 3D operator registry. This per-op note is generated by tools/opdocs.py md (do not hand-edit).*

© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.