fullseye

コヒーレンス走査干渉・クロマティック共焦点 — 使い方ガイド

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

位相ではなく、コヒーレンス包絡線のピークで高さを出す道具箱です。

高さを光で測る方法は 2 つあります。fullseye には既に片方 — fringe位相シフト法 — があり、縞の位相から高さを読みます。極めて精密ですが、位相は 2π の周期を持つので原理的に不定性を抱えます。この族はもう片方で、対物レンズを縦に振りながら撮った干渉縞のコントラストが最大になる位置を高さとします。包絡線には周期が無いので、縞次数を数え違えることも、アンラップすることもありません。その差が測定に出ていることが、この族を足した理由そのものです(次節)。

9 op / 6 カテゴリ(numpy + scipy のみ、台帳は opsinterferometry.py、実体は interferometry.py):

データ種は既存語彙の再利用が基本です: depth(高さマップ)、image2d(変調度マップ)、signal(包絡線 = 1-D の実関数)、measurement(単一画素の高さ)、table(設計値の dict)。新語は 2 つ、zscan((Z,H,W)、走査軸が先頭)と sweep(掃引軸に沿った非負 1-D 強度で局在ピークを 1 つ持つもの — 干渉信号とスペクトルの 2 種を同じ語彙に入れています)。どちらを足すかは推測ではなく実測で決めました(後述)。

零点との比較 — この族を足す理由が測定に出ている

同じ合成表面を位相シフト法(既存 fringesynthesize_fringesdecode_fringephase_gain = 4π/λ で干渉計として駆動)とコヒーレンス法の両方に食わせた結果です。λ = 0.60 µm、したがって λ/4 = 0.15 µm、λ/2 = 0.30 µm。

真の段差 位相シフト法(既存 fringe) コヒーレンス法(この族)
0.050 µm +0.0500(誤差 +0.0000) +0.0500(誤差 −0.0000)
0.100 µm +0.1000(誤差 +0.0000) +0.1000(誤差 +0.0000)
0.150 µm +0.1500(誤差 −0.0000) +0.1500(誤差 +0.0000)
0.200 µm −0.1000(誤差 −0.3000 = −λ/2 × 1) +0.2000(誤差 +0.0000)
0.300 µm −0.0000(誤差 −0.3000 = −λ/2 × 1) +0.3000(誤差 +0.0000)
0.500 µm −0.1000(誤差 −0.6000 = −λ/2 × 2) +0.5000(誤差 +0.0000)
1.000 µm +0.1000(誤差 −0.9000 = −λ/2 × 3) +1.0000(誤差 +0.0000)

読み方が 3 つあります。

  1. 壊れ始めは λ/4 = 0.15 µm ちょうどです。0.15 µm は厳密に正しく、0.20 µm で破綻します。0.05〜0.40 µm を 0.01 µm 刻みで掃いて境界を実測した結果で、丸めではありません。
  2. 誤差は常に λ/2 の整数倍です。−1 倍、−1 倍、−2 倍、−3 倍。これは縞次数(fringe order)の飛びで、ランダムな誤差ではなく構造を持った嘘です。
  3. 例外も NaN も警告も出ませんdecode_fringe は 0.200 µm の段差に対して −0.1000 µm という有限で、もっともらしく、λ/4 の範囲に収まった数を返します。出力だけを見て間違いに気づく方法はありません。

さらに、この境界が波長に紐づいていることも確かめてあります。λ = 0.8 µm に変えると破綻点も 0.20 µm(= 0.8/4)へ移動しました。定数を 1 つ当てたのではなく、物理の比が出ています。

この 3 点が「なぜ既存の族の隣にもう 1 つ置くのか」の答えです。「既存版が壊れる」ことを測定に出さないと、新しい族を足す理由が測定に現れません。 突き合わせは主張ではなくテスト(tests/test_interferometry.py::TestAgainstPhaseShifting)として置いてあり、同一の合成表面から両方を駆動しています。

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

やりたいこと 使う op 置き場所
位相シフト法・縞投影で高さを出す(N-step、Gray code) wrapped_phase / unwrap_phase_2d / phase_to_height / decode_fringe / synthesize_fringes fringe(この族は wrapped phase を一度も計算しません)
2-D 位相アンラップ phase_unwrap / unwrap_phase_2d complexops / fringe(アンラップしなくて済むのがこの族の存在理由なので、ここには置きません)
任意 1-D 信号の包絡線 envelope(登録名は 2-D 版の xsp_hilbert_env) dsp(csi_envelopeこれをそのまま呼び、干渉縞に固有の台座除去だけを足しています)
走査信号の帯域通過・スペクトル・リサンプル bandpass / spectrum / resample ほか dsp / funct1d(走査信号は素の 1-D float64 なので直接使えます。ラップし直しません)
到達時刻ヒストグラムから距離を出す(dToF) dtof_depth / dtof_cube_depth photoncount(あちらは (H,W,T)時間軸が最後、こちらは (Z,H,W)走査軸が先頭)
ビート信号から距離と速度を同時に出す range_doppler_map ほか rangedoppler(FMCW のコヒーレント測距。同じ「干渉」でも掃引するのは周波数であって位置ではありません)
時系列の微小変位を増幅・計測する motion_magnify / phase_displacement motionmag((T,H,W) の先頭軸は時間です)
光学設計(焦点距離・被写界深度・回折・MTF) thin_lens / depth_of_field / mtf_diffraction ほか optics / visiondesign(csi_design はその軸方向版で、横方向は複製しません)

入口は共有できませんが、出口の depth は意図的に共有しています。

fail-closed の入力契約 — なぜその罠を仕掛けたか

全 op が入力を検証してから計算します。以下は 2026-09-01 の敵対監査で実際に見つかった 5 件のバグと、それを塞いだ設計です。探したのは「例外が出る」ではなく「黙って間違った数字を返す」ケースです。

1. 端で切れた包絡線が 76% 間違った高さを黙って返す(最悪の 1 件)

12 µm の走査(241 plane × 0.05 µm)の中で、表面が 0.500 µm にある場合。包絡線の一部が走査の外に出ているので、Hilbert 変換(有限区間の大域変換です)が返す包絡線は本物とは別のものになります。返る値は 0.1189 µm ―― 有限で、もっともらしく、76% 間違っています。

厄介なのは「先頭/末尾 plane に張り付いたら拒否する」という素直な検査が発動しないことです。実測すると argmax は plane 2 of 241、つまり内部にあります。有限区間の解析信号は自分の端点で振幅が抑えられるので、走査の完全に外(−3.0 µm)にある表面ですら argmax は plane 1 に来ます。端検査はバックストップであって主力ではない、というのが実測から出た結論です。

そこで max_edge_envelope(既定 0.05)を置きました。中央値を基準にした端レベル (max(env[0], env[−1]) − median) / (max − median) で、しきい値は表から決めています:

端レベル 表面の位置 gaussian の誤差
0.0000 6.00 µm(走査中央) 3e-14 µm
0.0000 4.32 µm 1.9e-06 µm
0.0107 2.77 µm 7.8e-03 µm
0.1746 2.00 µm 2.7e-02 µm
0.6364 0.50 µm −0.38 µm(0.1189 を返す)

中央値を基準にしたのも実測の結果です。素の max(ends)/max雑音床を切断と読み違えます — 完全に中央に置いた 1% 雑音つき走査で 0.0586 を示し、どんなしきい値も誤爆します。中央値基準なら同じデータで 0.0000、5% 雑音でも 0.0000 です(雑音は端と中央値を同じだけ持ち上げるので相殺します)。

なお直すのではなく拒否したのも測定に基づきます。ゼロ詰めと鏡像詰めをどちらも試しましたが、端レベル 0.1746 の行で −3.3e-02 / −3.7e-02 と、素の −2.7e-02 より悪化しました。切断は FFT のアーティファクトではなく物理的な情報欠落なので、直しようがありません。

2. λ の nm/µm 取り違えが完全に無症状だった

wavelength_um は Nyquist 上限にしか使われません。したがって 600 nm を 600.0 と書いても、返る高さは 0.6 と書いたときと同じ 6.025 µm です。変わるのは「Nyquist 検査が事実上無効になる」ことだけで、出力には一切現れません。名前に単位を埋め込む規約(_um / _nm)は必要ですが、この一件は名前だけでは防げませんでした

解いた方法は、データ自身に波長を聞くことです。干渉信号の搬送波は往復のぶん 2/λ に立つので、DC を抜いた信号の rFFT ピークと突き合わせます(carrier_tolerance、既定 2 倍)。実測した比は基準走査で 0.996 で、雑音 1%・雑音 10%・端が切れた包絡線・0.3 µm の短い包絡線でも 0.996 のまま動きません。1000 倍の単位誤りを捕まえるには十分すぎる余裕があります。16 plane 未満の走査は FFT が搬送波を分解できない(9 plane で比 0.667)ので、検査を通すのではなくスキップします。

同じ検査が副産物として、掃引スペクトルを誤って csi_peak_position に渡した場合も捕まえます(共焦点ピークの主成分は Nyquist の 0.010、干渉信号は 0.333)。

3. Nyquist 割れを「折り返す」のではなく拒否する理由

搬送波の周期は λ/2 なので、走査ステップの上限は λ/4 です。上限を超えたときに拒否する理由は「原理的に間違うから」ではなく、間違い方が間欠的だからです。実測(λ = 0.60 µm、上限 0.15 µm):

出力からこの 2 つを区別する方法はありません。運が良かったのか正しかったのか分からない答えは返さない、という判断です。photoncount.dtof_cube_simulate が一意測距範囲の外を拒否するのと同じ扱いにしてあります。

4. 文字列・object・bool の配列が黙って float に化ける

float("0.55") が成功するのでスカラは弾いていましたが、配列側に同じ穴が空いていましたnp.ascontiguousarray(["1.0"] * 8, dtype=np.float64) は成功します。結果、未パースの設定値の列がそのまま包絡線になっていました。object 配列(Decimal でも通ります)と bool 配列(マスクの配線ミス)も同類なので、dtype kind が U/S/O/V/b のものを拒否します。ただの数値コンテナ(Python の float リスト、uint8 配列)は今までどおり通ります。

同じ検査の並びで、サイズ上限を float64 昇格の前に効かせています。要素数を shape から読んでから昇格するので、2²¹ 要素の 0 バイト broadcast view でも確保せずに拒否できます。上限を昇格の後に置くと、拒否した時点で既に確保が済んでいて意味がありません。

5. その他 2 件

この他に、退化した入力 — 定数信号、包絡線を持たない正弦波、ステップ 0、σ = 0、コントラスト 0、走査範囲の外にある表面、NaN/Inf、masked array、complex ―― はすべて名前つきの ValueError です。無言の NaN を返す経路はありません(on_invalid="fill" は既定 raise に対する明示の opt-in です)。

推定量の選び方 — 順位が雑音で逆転する

csi_peak_position / csi_height_mapmode は 4 種あります。基準走査(λ 0.60 µm、包絡線 FWHM 2.83 µm、ステップ 0.05 µm、表面を走査中央に、サブステップ位相 4 通り)と、同じ条件に 1% の加法雑音を入れた 200 試行:

推定量 雑音なし max|誤差| 雑音 1% の RMS
peak 2.500e-02 µm(= ステップ/2 ちょうど) 0.1395 µm
centroid 4.55e-07 µm 0.0219 µm(最良)
parabolic 4.21e-06 µm 0.1403 µm
gaussian 1.43e-07 µm(最良) 0.1403 µm

順位が逆転します。 雑音が無ければ gaussianparabolic の 29 倍精密です(標本化されたガウシアンの対数は厳密に放物線なので、3 点の対数当てはめは代数的に厳密 ―― 解析包絡線を渡すと 2.9e-14 まで出ます。上の 1.43e-07 という床は当てはめではなく Hilbert 包絡線自身の誤差 1.83e-07 です)。雑音が入ると centroid が 6.4 倍良くなります。局所当てはめは、雑音が動かす argmax の周りの 3 点しか見ないのに対し、重心は 241 plane 全部を平均するからです。

既定は gaussian です — データが良いときに厳密である推定量だから、というだけの理由で、最良の推定量は 1 つに決まりませんcentroid にも固有の弱点があり、走査窓の中で表面がどこにあるかで偏ります: 12 µm 走査で表面が 2 µm にあると +0.189 µm(雑音なし)、2% の雑音床があると +0.873 µm。10 µm 側では符号が反転して −0.189 / −0.851 です。局所推定量にはこの偏りがありません。

そして、精度は推定量でなく走査設計で決まる

上の表より大事な数字がこれです。同じ gaussian で、同じコードで、同じ表面を測って:

走査の置き方 RMS 誤差
表面を走査中央に置く 3e-14 µm
傾斜面 5.0–7.0 µm(0–12 µm 走査の内側) 2.08e-06 µm
同じ面を 2.0–10.0 µm に広げる 7.06e-03 µm(3403 倍悪化)

差はすべて走査端での包絡線の切断です。推定量を選び直しても取り返せません(同じ条件で centroid は 3.81e-05 → 5.98e-02 と 4 桁落ちます)。そして前述のとおりゼロ詰めも鏡像詰めも悪化させました(−3.3e-02 / −3.7e-02 対 −2.7e-02)。

実務上の結論は 1 行です: csi_designcapture_range_um のぶんだけ、表面の上下に走査の余白を取ること。 変調度が低い画素で局所当てはめがどれだけ苦しくなるかも測ってあります — 50 倍の反射率段差と 1% 雑音で、gaussian の誤差は明るい側 0.146 µm に対し暗い側 3.03 µm(20 倍)、暗い側の 30% の画素は拒否されます。centroid は同条件で 0.022 → 0.157 µm(7 倍)と、こちらでも粘ります。2 つの母集団を分けるのが csi_contrast_map で、実測 0.035 ± 0.004 と 0.412 ± 0.007 にきれいに分かれます。

代表的なパイプライン

段差のある表面を 1 台の干渉顕微鏡で測りきる筋(検証済み examples/coherence_scanning.py そのもの)。設計 → 前方モデル → 包絡線 → 高さ、とデータ種が table → depth → zscan → depth で繋がります。

flowchart LR
    A[光源 λ・スペクトル幅 Δλ] --> B[csi_design コヒーレンス長・Nyquist 上限・捕捉範囲]
    B -->|走査ステップと面数| C[csi_stack_simulate depth から zscan]
    C -->|zscan| D[csi_height_map 高さマップ depth]
    C -->|zscan| E[csi_contrast_map 変調度 image2d]
    E -->|閾値で有効画素| D
    D --> F[既存の depth op へ stereo / range_image]

1 画素だけを詳しく見る筋と、走査しないクロマティック共焦点の筋。前者の出口 csi_envelope は既存の signal 語彙へ戻るので、dsp / funct1d の 1-D op がそのまま続けられます。

flowchart LR
    G[csi_signal_simulate 既知 z0 の走査信号 sweep] --> H[csi_envelope 台座除去 + Hilbert]
    H -->|signal| I[dsp / funct1d の 1-D op]
    G -->|sweep| J[csi_peak_position 高さ measurement]
    K[chromatic_confocal_simulate スペクトル sweep] --> L[chromatic_confocal_height 波長 = 高さ]
    J -.同じ表面で突き合わせ.- L

使い方(最小の 1 本)

import interferometry as I

# 1) 買う前に決まる限界
d = I.csi_design(wavelength_um=0.6, bandwidth_um=0.10, z_range_um=12.0)
print(d["max_z_step_um"], d["envelope_fwhm_um"], d["capture_range_um"])

# 2) 既知の傾斜面から走査スタックを合成し、高さを戻す
import numpy as np
height = 5.0 + 2.0 * np.mgrid[0:32, 0:32][1] / 31.0
stack = I.csi_stack_simulate(height, 0.0, 0.05, 241, 0.6, envelope_fwhm_um=2.8258)
h = I.csi_height_map(stack, z_step_um=0.05, wavelength_um=0.6, mode="gaussian")
print(float(np.sqrt(np.mean((h - height) ** 2))))     # 2.08e-06 um

# 3) 走査せず、スペクトルのピーク波長だけで高さを出す
sp = I.chromatic_confocal_simulate(4.25, 500.0, 0.5, 401, 0.20, 600.0)
print(I.chromatic_confocal_height(sp, 500.0, 0.5, 0.20, 600.0))   # 4.25

アルゴリズムの正典(著者・年)

この族でできないこと


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