photometric op• Data kinds: images → normalmap
• Call: import fullseye as fs; fs.ledger.photometric_stereo_robust(images, lights, method='ransac', threshold=0.05, max_subsets=512, normalize=True, seed=0, min_inliers=3) (implementation को सीधे call करना हो तो import specularity; specularity.photometric_stereo_robust(images, lights, method='ransac', threshold=0.05, max_subsets=512, normalize=True, seed=0, min_inliers=3), registry से लेना हो तो opsspecular.get("photometric_stereo_robust"))
• Ledger के ज़रिए return value: fullseye.ledger.photometric_stereo_robust(...) केवल घोषित out type normalmap का value लौटाता है (मूल function सहायक जानकारी भी लौटाता है)। जो हिस्सा छोड़ दिया गया है उसकी ज़रूरत हो तो fullseye.ledger.photometric_stereo_robust.raw(...) का उपयोग करें, या specularity.photometric_stereo_robust को सीधे call करें।
photometric stereo जो shadows और highlights में भी टिका रहता है। → (normals, albedo, inliers)।
> नीचे का विस्तृत विवरण मूल text में है — सारांश और headings का अनुवाद हो चुका है।
Woodham's (1980) linear model I_n = albedo * max(n.L_n, 0) is exact only where every light reaches the pixel and the surface is Lambertian. A cast shadow zeroes one measurement, a highlight inflates another, and least squares spreads the error over the whole normal — the estimate does not fail, it tilts, which is worse. This operator solves the three-light system exactly on subsets and keeps the subset the data agrees with.
*method*:
• "lstsq" — the plain least-squares solution, delegated to photometric.photometric_stereo. Present so the non-robust baseline is available through the same call and its failure is a measurement rather than a claim. On a Gaussian bump lit by 8 lights, with k of them blocked by a cast shadow, the mean angular error is (all measured in tests/test_specularity.py):
==== ========== ============ ============
k lstsq median ransac
==== ========== ============ ============
1 31.70 deg 2.3e-07 deg 2.3e-07 deg
2 53.11 deg 2.3e-07 deg 2.3e-07 deg
3 64.40 deg 2.3e-07 deg 2.3e-07 deg
4 70.52 deg 2.3e-07 deg 2.3e-07 deg
5 --- NaN 2.3e-07 deg
6 --- NaN NaN
==== ========== ============ ============
2.3e-07 degrees (max 7.5e-07) is not "almost right", it is the floor: the returned
normals are float32 (photometric's convention), and casting the
exact normals to float32 and back measures the same values to every digit.
(Until 2026-10-05 both read 0.00011 degrees — that was the acos floor of
photometric.angular_error_deg, not float32.)
NaN means *this pixel is not solvable from what was believed there* —
see "unsolvable pixels" below.
• "ransac" — Fischler-Bolles maximum consensus. Every 3-light subset is solved exactly, residuals are counted against *threshold*, and the subset with the largest consensus wins per pixel; the normal is then refitted by least squares on that consensus set. Tolerates up to N - 3 blocked lights if the survivors agree — at k = 5 of 8 exactly three lights are left and the answer is still exact.
• "median" — Rousseeuw's least median of squares. The subset minimising the median residual wins. Needs more than half the lights to be good, and needs no threshold at all — use it when the outlier magnitude is unknown. Its 50 % breakdown point is why the k = 5 and k = 6 rows are NaN rather than wrong: past half, its winning subset is one that believes nothing, and that is reported as "not solved".
A measurement of zero is not evidence. Woodham's model is I = albedo * max(n.L, 0), so a shadowed measurement of zero states n.L <= 0 — an *inequality*. The linear solve reads it as the equation n.L = 0, which is a different and stronger claim, and g = 0 reproduces every zeroed frame exactly. A set of blocked lights is therefore a perfectly self-consistent "black surface" hypothesis that can outscore the truth. Found by adversarial audit, and it did not raise anything:
===== ================ ===================== ==============================
k of 8 before, median inlier mask said what was actually true
===== ================ ===================== ==============================
4 70.52 deg wrong 8 of 8 believed 4 of the 8 frames were zero
5 8.99 deg 5 believed the 5 believed were the *zeros*
6 8.99 deg 6 believed the 6 believed were the *zeros*
===== ================ ===================== ==============================
The k = 4 row is the one to look at: at the worst estimate the diagnostic reported a clean bill of health. The k = 5 / k = 6 rows are worse than they look — 8.99 degrees is not a near miss, it is the degenerate albedo = 0 solution falling back to (0, 0, 1), which happens to be close on a shallow bump and would be arbitrary on any other surface, and the mask named the zeroed lights as the believed ones.
So a light is believed at a pixel only when the fit explains its measurement to within the method's tolerance and its measurement is farther from zero than that same tolerance — the same tolerance in both clauses, whichever tolerance the method uses (threshold * peak for "ransac", max(2.5 sigma, 1e-9 * peak) for "median"). Counting a measurement that is itself inside the tolerance of zero is counting a tautology, since zero albedo predicts it under *every* normal. The rule is exact, not
heuristic: on a scene with one light at grazing incidence the believed mask equals I_n > threshold * peak pixel for pixel (verified by array equality, 40x40, believed at 93.2 % of pixels, error 2.3e-07 deg).
That single change also repairs the estimates, because the zeroed frames stop winning the consensus: k = 4 goes from 70.52 to 2.3e-07 degrees for both methods, and ransac at k = 5 from 8.99 degrees to 2.3e-07. It does not repair a general outlier, and is not claimed to — a highlight is positive and the zero test cannot see it. Measured with j of 8 frames given a +3.0 additive spike, the true 50 % breakdown is unchanged and still disclosed:
==== ============ ============
j median ransac
==== ============ ============
1 2.3e-07 deg 2.3e-07 deg
2 2.3e-07 deg 2.3e-07 deg
3 2.3e-07 deg 2.3e-07 deg
4 7.42 deg 65.42 deg
==== ============ ============
The error is not monotone in the number of blocked lights, and never was. Before the repair the sequence over k = 0..6 for "median" ran 2.3e-07, 2.3e-07, 2.3e-07, 2.3e-07, 70.52, 8.99, 8.99 — the worst value sits in the middle. That is the median's 50 % breakdown point, not a bug: at k = 4 the two hypotheses have equal support and the wrong one is picked at full confidence, while at k = 5, 6 the "black surface" wins outright and the degenerate (0, 0, 1) fallback happens to be near the truth on a shallow surface. Do not read a small error at large k as recovery.
*threshold* is relative to the brightest measurement at that pixel, so the decision is invariant to exposure: scaling every image by 1e-3, 1e3 or 1e6 returns a bit-identical inlier mask and normals that differ by at most 2.2e-13 degrees — rounding, not an exposure effect (until 2026-10-05 the acos floor of photometric.angular_error_deg reported 0.000115). The word "bit-identical" applies to the mask and not to the normals, because the arithmetic downstream of the identical decision still rounds differently at a different scale.
Enumeration is exhaustive and therefore deterministic whenever C(N, 3) <= max_subsets; *seed* only matters past that point, and the regime is not hidden — a subset count above MAX_SUBSETS, a pixel count above MAX_ROBUST_PIXELS or a total work product above MAX_ROBUST_WORK all raise instead of running for an hour.
Returns (normals, albedo, inliers): normals (H, W, 3) float32 unit vectors in photometric's convention ((0, 0, 1) where the albedo is degenerate), albedo (H, W) float32, and inliers (N, H, W) bool — which lights were believed at which pixel, after the zero test above, so a blocked light is never named as believed.
Unsolvable pixels are NaN. Three unknowns need three independent equations; a pixel whose believed lights number fewer than *min_inliers* (default and minimum 3), or whose believed directions are coplanar so the 3x3 normal matrix is singular, gets NaN in both *normals* and *albedo*. It used to keep the winning 3-light subset's solution instead, which is how an underdetermined pixel came back as a confident 1.3-degree answer: the minimum-norm solution of 3 unknowns in 2 equations is near the truth only when the surface happens to be nearly flat. The two arrays therefore agree with the mask by construction — numpy.isnan(albedo) is exactly inliers.sum(axis=0) < min_inliers plus the singular pixels — and a caller that never checks will get NaN propagating rather than a plausible number. method="lstsq" is exempt: it is the deliberately non-robust baseline, it uses every light at every pixel, and its all-true mask says exactly that.
Raises ValueError: *images* is not an (N, H, W) stack or exceeds MAX_LIGHTS / MAX_STACK_ELEMENTS / MAX_ROBUST_PIXELS; *lights* is not (N, 3) matching the frames or contains a zero-length direction; fewer than 3 lights; *method* not in ROBUST_METHODS; *threshold* not positive; the work product exceeds MAX_ROBUST_WORK; every 3-light subset is singular (all light directions coplanar through the origin, which no amount of robustness can repair).
• specular_photometric family guide
• Sample data catalog (DL URL / license) — 2-D के लिए skimage.data (BSD/public) + synthetic, 3-D के लिए real data sources (Stanford/PDS आदि) के DL URL।
• Operators का स्रोत और संदर्भ — इस op family के मूल research/methods के स्रोत।
• Algorithm का मानक स्रोत (लेखक, वर्ष) और उपयोग ऊपर की family usage guide में दिए गए हैं।
• specular_photometric — py -3.11 examples/specular_photometric.py
normalmap को input के रूप में लेते हैं)brdf_blinn_phong · brdf_microfacet · dichromatic_render
photometric)*Provenance: specularity.py — SPECULAR operator registry. यह per-op note tools/opdocs.py md से अपने-आप generate होता है (हाथ से edit न करें)।*
© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.