photometric op• 資料種類:images → normalmap
• 呼叫: 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)(要直接呼叫實作,import specularity; specularity.photometric_stereo_robust(images, lights, method='ransac', threshold=0.05, max_subsets=512, normalize=True, seed=0, min_inliers=3);從台帳取用則 opsspecular.get("photometric_stereo_robust"))
• 經台帳呼叫的回傳值: fullseye.ledger.photometric_stereo_robust(...) 只回傳宣告的 out 型別 normalmap 的值(底層函式還會回傳輔助資訊)。需要被捨棄的部分時,請用 fullseye.ledger.photometric_stereo_robust.raw(...),或直接呼叫 specularity.photometric_stereo_robust。
能扛住陰影與高光的光度立體。→ (法線, 反照率, 內點)。
> 以下的詳細說明為原文 —— 摘要與標題已翻譯。
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).
• 範例資料目錄(下載 URL / 授權) —— 2-D 用 skimage.data(BSD/公有領域)加合成圖,3-D 給出真實資料源(Stanford/PDS 等)的下載 URL。
• 運算子來歷與參考文獻 —— 該運算子族所依據的研究/方法出處。
• 演算法的正典(作者、年份)與用途見上面的族使用指南。
• specular_photometric — py -3.11 examples/specular_photometric.py
normalmap 作為輸入)brdf_blinn_phong · brdf_microfacet · dichromatic_render
photometric)*Provenance: specularity.py — SPECULAR 運算子登記表。本條目由 tools/opdocs.py md 自動產生(請勿手動編輯)。*
© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.