fullseye

鏡面反射の分離と頑健フォトメトリックステレオ — 使い方ガイド

この族は何をする道具箱か

Lambertian 前提が壊れる場所の道具箱です。photometric は自分の docstring でこう書いています — 線形最小二乗が厳密なのは「Lambertian + 既知光源 + 影なし」のときだけで、影とスペキュラは線形性を破るから頑健版が別途要る、と。本族はその「別途」であり、加えて形状復元にかける前にハイライトそのものを取り除く 2 つの経路(色・偏光)を第一級の op にしたものです。金属・塗装・樹脂といった光る面の外観検査は、ここが無いと最初の一歩で崩れます。13 op / 4 カテゴリ(numpy のみ、台帳は opsspecular.py、実体は specularity.py):

データ種は既存語彙の再利用が基本です: image2d(鏡面係数・残差・偏光度などのスカラ地図。閾値処理や形態素へそのまま流せる)、images(光源を切り替えた N 枚)、normalmap(photometric と同じ規約の法線場。+z がカメラ向き)、vector((3,) の光源色)、labels((H,W) の材質地図 — 既存のセグメンテーション出力がそのまま入口になる)、stokes(既存の偏光語彙)。新語は 2 つだけで、どちらも既存語では型レベルの嘘になるものです。rgbimage((H,W,3) の線形 RGB)は pointmap / normalmap と構造が完全に同じですが、そこへ計量 XYZ や単位法線を渡しても例外は出ず「もっともらしく間違った分離結果」が出ます。polsweep((N,H,W) の検光子掃引)は images と構造が同じで、これを分けた理由は §「入力契約」の最後に実測つきで書いてあります。

既存 op との棲み分け(重複させていないもの)

本族は既存資産を再実装せず import して合成します。下表の右列は「そちらを呼びなさい」という意味です。

やりたいこと 使う op 置き場所
Lambertian の順方向レンダ render_lambertian / synthesize_ps_images photometricdichromatic_render は body 項をこれで計算させており、ビット単位で一致することをテストで固定しています(光源の正規化規約とクリップが 2 モジュールで食い違わない唯一の担保)
影もスペキュラも無い場合の素のフォトメトリックステレオ photometric_stereo photometricphotometric_stereo_robust(method="lstsq") はこれを呼ぶだけで、だから「素の版がここで壊れる」が主張ではなく測定になります
法線場 → 高さ場の積分(Frankot-Chellappa) integrate_normals / integrate_gradients photometric。本族は法線までを出し、積分はそちらへ渡します
偏光度・方位角・楕円率・利き手の解釈 stokes_analyze opticspolarization_stokes はその入力になる Stokes ベクトルを作るだけで、解釈は一切しません
Jones / Mueller の代数(偏光子・波長板・回転子) jones_element / mueller_element ほか optics。本族は実測から Stokes を作る側で、素子のモデルは持ちません
面で曲がる実際の光線厳密な Fresnel 反射率 reflect / refract / fresnel_reflectance / normal_from_reflection(デフレクトメトリ) match3dbrdf_microfacet は Schlick 近似を名指しで使います(それが microfacet 文献の指定)。厳密な曲線が要るならそちら
色空間変換・ホワイトバランス・デモザイク color backends 本族は線形 RGB を受け取る前提で、各 docstring にそう書いてあります

ファミリ共通の入力契約(fail-closed)

全 op が入力を検証してから計算します。以下は敵対監査で実際に見つかったバグか、それを塞ぐために書いた罠です。この族が探したのは「例外が出る」ことではなく「黙って間違った数字を返す」ことでした。

代表的なパイプライン(op の繋がり)

光沢面の検査は「ハイライトを消す」か「ハイライトに耐える」かの二択で、本族は 3 本の道を用意します。色を使う道・偏光を使う道・多灯で押し切る道です。検証済み examples/specular_photometric.py がこの筋そのものです。

flowchart LR
    A[線形RGB rgbimage] --> B[illuminant_from_dichromatic_planes<br/>2材質以上 → 光源色 vector]
    B --> C[specular_diffuse_split<br/>拡散 + 鏡面]
    A --> C
    C -->|rgbimage| D[以降の検査は拡散像で]
    A --> E[specular_coefficient_map<br/>スカラ m_s → image2d]
    E --> F{閾値でテカり領域}
    A --> G[specular_free_transform<br/>ローブ形状を仮定しない射影]
    G --> H[特徴照合・エッジ検出]
    I[normalmap] --> J[dichromatic_render<br/>brdf_blinn_phong / brdf_microfacet]
    J -->|既知の答えを持つ画像| C

多灯の道は判断が先です。残差マップが静かなら素の最小二乗で十分で、頑健版は要りません。偏光の道は独立していて、色を一切使わないので多材質のテクスチャ面でも効きます。

flowchart LR
    K[images N灯] --> L[photometric_residual<br/>モデル違反の地図]
    L --> M{残差は大きいか}
    M -->|小さい| N[photometric_stereo_robust method=lstsq]
    M -->|大きい| O[method=median / ransac<br/>+ inliers でどの灯を信じたか]
    N --> P[normalmap]
    O --> P
    P --> Q[photometric.integrate_normals で高さ場へ]
    R[image2d 拡散 + image2d 鏡面] --> S[polarization_render → polsweep]
    S --> T[polarization_separate]
    S --> U[polarization_dolp_map<br/>偏光板が効くかを先に測る]
    S --> V[polarization_stokes]
    V -->|stokes| W[optics.stokes_analyze]

使い方(最小の 1 本)

import numpy as np
import specularity as SP

# 塗装面のテカりを色で落とす(1 材質・白色光源)
diffuse, specular = SP.specular_diffuse_split(image_rgb)
glare = SP.specular_coefficient_map(image_rgb) > 0.2      # 閾値でテカり領域

# 8 灯のうち何灯かが遮蔽されている前提で形状を復元する
normals, albedo, inliers = SP.photometric_stereo_robust(images, lights,
                                                        method="ransac")
print(inliers.mean(axis=(1, 2)))        # どの灯を、どれだけ信じたか(ゼロの灯は入らない)
solved = ~np.isnan(albedo)              # ★信じた灯が 3 本未満の画素は NaN で返る
print(solved.mean())                    # 解けた画素の割合。確認せずに使わないこと

# 偏光板を回した 4 枚から分離し、Stokes を光学族へ渡す
import optics as O
d, s = SP.polarization_separate(frames)                   # 既定 0/45/90/135 度
print(O.stokes_analyze(SP.polarization_stokes(frames))["dop"])

実測値(すべて実行して得た数字)

分離の厳密性 — 既知の鏡面成分を足した合成画像からの最大絶対誤差:

経路 拡散の誤差 分割の閉じ
単一材質(body 色を推定) 5.0e-16 1.1e-16
body 色を与える 4.0e-15 2.1e-15
body 色を画素ごとに与える(テクスチャ面) 2.9e-15
有色光源 1.1e-15

光源色の推定は 3 材質から最大成分誤差 4.4e-14(角度誤差は 0.0 度に丸まる)。鏡面不変部分空間は、任意の強さ・任意の形のハイライトを足しても出力の変化が 2.2e-16。

反射ローブ — brdf_blinn_phong の相反性は厳密に 0 差brdf_microfacet は 1.7e-16。microfacet の法線入射値は閉形式 f0 / (4 pi roughness^4) を粗さ 0.3 / 0.5 / 1.0 でビット単位に再現し、粗さ 0.1 でも 3.3e-16(GGX の分母を教科書どおり書くと桁落ちして 2.2e-13 ずれるので、代数的に等価な形に書き換えてあります)。GGX の分布は半球上で 1 に積分され、20000 点の中点則で相対誤差 3.2e-07 / 6.3e-08 / 4.0e-09(粗さ 0.2 / 0.3 / 0.6)、200000 点にするとちょうど 100 分の 1 — 残差が求積であってモデルではないことがこれで分かります。

頑健フォトメトリックステレオ — 8 灯のうち k 灯が投影影で潰れたときの平均角度誤差:

k lstsq median ransac
1 31.70 度 0.00011 度 0.00011 度
2 53.11 度 0.00011 度 0.00011 度
3 64.40 度 0.00011 度 0.00011 度
4 70.52 度 0.00011 度 0.00011 度
5 NaN(解けない) 0.00011 度
6 NaN(解けない) NaN(解けない)

k=4 の行は以前 70.52 度 / 70.20 度でした。それが直った経緯は下の「ゼロの測定は証拠ではない」を読んでください ―― 数字が良くなったこと自体より、悪かったときに診断が「異常なし」と言っていたことの方が重要です。

0.00011 度は「ほぼ正しい」ではなく下限です — 返る法線は float32(photometric と同じ規約)で、真の法線を float32 に丸めて戻すだけで同じ 0.000115 度が出ます。露出を 1e-3 倍・1e3 倍・1e6 倍しても inlier マスクはビット単位で同一(閾値が画素ごとの最大測定値に対する相対値だから)、法線の差はやはり 0.000115 度。光源の並び替えに対する不変性は 2.0e-16。ハイライトで 1 灯が汚れた場合も同じで、lstsq が 58.54 度のところ頑健版は 0.0001 度です。残差は真値を float64 で与えれば 1.4e-16、内部で解かせると float32 精度の 4.5e-08、3 灯遮蔽で 0.50。

偏光 — polarization_render との往復は 4 角度で 3.9e-16、最小構成の 3 角度で 4.4e-16。偏光度は閉形式と 2.8e-16 で一致し、露出を 1e-4 倍から 1e8 倍まで振っても 3.9e-16 しか動きません。polarization_stokes の S0 は場面の平均全放射輝度と一致し、optics.stokes_analyze は入れた方位角 30 度をそのまま返します。

この族でできないこと(正直な限界)

ここが一番読む価値のある節です。 上の数字はすべて「仮定が成り立っていれば」の話で、成り立たないときに何が起きるかを測ってあります。


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