photometric_stereo_robust — SPECULAR photometric op

Datenarten: imagesnormalmap

Aufruf: import specularity; specularity.photometric_stereo_robust(images, lights, method='ransac', threshold=0.05, max_subsets=512, normalize=True, seed=0, min_inliers=3) (oder opsspecular.get("photometric_stereo_robust"))

Verwendung

Photometrisches Stereo, das Schatten und Glanzlichter übersteht. → (Normalen, Albedo, Inlier).

> Die ausführliche Beschreibung unten ist der Originaltext — Zusammenfassung und Überschriften sind übersetzt.

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

:func: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 0.00011 deg 0.00011 deg

2 53.11 deg 0.00011 deg 0.00011 deg

3 64.40 deg 0.00011 deg 0.00011 deg

4 70.52 deg 0.00011 deg 0.00011 deg

5 --- NaN 0.00011 deg

6 --- NaN NaN

==== ========== ============ ============

0.00011 degrees is not "almost right", it is the floor: the returned

normals are float32 (:mod:photometric's convention), and casting the

exact normals to float32 and back measures the same 0.000115 degrees.

`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 0.00011 deg).

That single change also repairs the estimates, because the zeroed frames

stop winning the consensus: `k = 4` goes from 70.52 to 0.00011 degrees for

both methods, and `ransac at k = 5` from 8.99 degrees to 0.00011. 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 0.00011 deg 0.00011 deg

2 0.00011 deg 0.00011 deg

3 0.00011 deg 0.00011 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

0.00011, 0.00011, 0.00011, 0.00011, 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 0.000115 degrees — the float32 output floor again, not an exposure

effect. 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 :data:MAX_SUBSETS, a pixel

count above :data:MAX_ROBUST_PIXELS or a total work product above

:data:MAX_ROBUST_WORK all raise instead of running for an hour.

Returns `(normals, albedo, inliers): normals (H, W, 3)` float32

unit vectors in :mod: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

:data:MAX_LIGHTS / :data:MAX_STACK_ELEMENTS / :data:MAX_ROBUST_PIXELS;

*lights* is not `(N, 3)` matching the frames or contains a zero-length

direction; fewer than 3 lights; *method* not in :data:ROBUST_METHODS;

*threshold* not positive; the work product exceeds

:data:MAX_ROBUST_WORK; every 3-light subset is singular (all light

directions coplanar through the origin, which no amount of robustness can

repair).

Ausführlicher Anwendungsleitfaden

Leitfaden zur Familie specular_photometric

Referenzen (Beispieldaten, Literatur)

• Katalog der Beispieldaten (Download-URLs / Lizenzen) — 2-D nutzt skimage.data (BSD/Public Domain) plus synthetische Bilder, 3-D nennt Download-URLs echter Datenquellen (Stanford, PDS, …).

• Herkunft und Literatur der Operatoren — die Quellen der Forschung/Verfahren, auf denen diese Operatorfamilie beruht.

• Der kanonische Algorithmus (Autor, Jahr) und seine Anwendungen stehen im Familienleitfaden oben.

Ausführbare Beispiele (verifizierte Samples, die diesen Operator wirklich aufrufen)

specular_photometricpy -3.11 examples/specular_photometric.py

Typkompatible Folge-Operatoren (nehmen normalmap als Eingabe)

brdf_blinn_phong · brdf_microfacet · dichromatic_render

Gleiche Kategorie (photometric)

photometric_residual


*Provenance: specularity.py — SPECULAR Operator-Registry. Diese Notiz wird von tools/opdocs.py md erzeugt (nicht von Hand bearbeiten).*

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