fullseye

光子計数・時間分解(SPAD・TCSPC・dToF・寿命) — 使い方ガイド

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

画素値になる前の層です。普通のセンサーは光子を積分して階調にしますが、単一光子検出器(SPAD)は光子を何個数えたかいつ届いたかを返します。そこから先は算術が変わります — 測定値は Poisson カウントで、雑音は調整項ではなく sqrt(N)、検出器は 1 個数えるたびに一定時間目が見えなくなり、到達時刻のヒストグラムには距離(直接飛行時間 = dToF)と蛍光寿命が入っています。それらは全部閉形式の計算で、この族はそれを第一級の op にしたものです。17 op / 6 カテゴリ(numpy + scipy のみ、台帳は opsphoton.py、実体は photoncount.py):

データ種は既存語彙の再利用が基本です: image2d(光子カウント画像 — 整数値を float64 に載せた 2-D なので既存のフィルタ・閾値・morphology がそのまま意味を持つ)、depth((H, W) の距離マップ = stereo / range_image 側の depth op へ直結)、measurement(単一画素の距離)、table(統計・フィット結果の dict)。

新語は 3 つ、いずれも「既存語彙で宣言すると型レベルの嘘になる」ものだけです:

この 3 語を分けた理由は次節の入力契約と合わせて読んでください(特に counts は、分けなかったせいで op が一度も実行されない事故が実際に起きています)。

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

やりたいこと 使う op 置き場所
加法ガウス読み出し雑音(アンプ + ADC、信号に依存しない) aug_read_noise backends_aug。こちらは乗法的(分散 = 平均)な光子ショット雑音。両者が出会う唯一の場所が anscombe_transform の一般化形で、gain / read_sigmaaug_read_noise が注入するパラメータそのもの
学習データ増強としての正規化ショット雑音 aug_shot_noise backends_aug(Poisson(v*K)/K を [0, 1] にクリップして返す)。photon_sampleカウント N そのものを返す — Fano / Anscombe / Coates / dToF はすべて N が要るので、再スケール + クリップは不可逆
Poisson 逆畳み込み(光子制限データのデブラー) vol_gaussian_psf / vol_richardson_lucy volrestore。Richardson–Lucy はまさにこの族が生成する Poisson モデル下の最尤デブラーなので、両者は合成関係にあります: photon_sample または dtof_cube_simulate が光子制限データを作り、そちらが復元する。ここでは一切デブラーしないし、あちらは一切サンプリングしない
光学設計(PSF・MTF・回折・被写界深度) airy_pattern / psf_to_mtf / depth_of_field ほか opticstcspc_irf_convolve はその時間軸版であり、空間側は複製しない
1-D 信号処理(フィルタ・スペクトル・リサンプル) lowpass / envelope / spectrum / smooth_funct_1d_gauss ほか dsp / funct1d。ヒストグラムは素の 1-D float64 配列なのでそのまま渡せます(ラップし直していません)。非対称なのは意図的で、ヒストグラムは常に signal op に渡せますが、任意の signal はヒストグラムではありません(光子カウントは非負)
位相シフト干渉法・縞投影(位相からの距離) wrapped_phase / phase_to_height fringe。あれは間接飛行時間(位相)、こちらは直接飛行時間(到達時刻)。原理が違うので別族です

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

全 op が入力を検証してから計算します。以下は 2026-09-01 の敵対監査で実際に見つかったバグか、それを塞ぐために書いた罠です。罠を仕掛けた理由も併記します — 「なぜそこを守るのか」が分からないと、後から善意で外されるからです。

実際に見つかった 5 件のバグと、そこに置いた罠

  1. denormal による無言 NaN(最も危険だったもの)irf_fwhm_ps=5e-324(正の最小の double)は > 0 の検査を通りますが、5e-324 / 2.35480.0 に underflow します。すると erf の引数 (edge - t0)/0 はほぼ全域で inf(erf は飽和して正しく見える)、しかし bin 境界がパルス中心に一致した画素だけ 0/0 = NaN になります。最小再現 tcspc_simulate(0.0149896229, bins=8, bin_ps=100.0, irf_fwhm_ps=5e-324, noise=False)[nan, nan, 0, 0, 0, 0, 0, 0]例外なしで返していました。「有限を返す」と書いてある op から無言の NaN が出るのが最悪なので、sigma が 0 に落ちた時点で ValueError にしています。教訓は「正の数かどうか」と「割り算に使える数かどうか」は別の検査だ、ということです。
  2. flat ヒストグラムで argmax が bin 0 を拾う。全 bin が同じ値のヒストグラムにピークはありませんが、argmax は黙って 0 を返し、dtof_depth は最初の bin の距離(100 ps bin なら 0.0075 m)を測定値として返していました。例外も警告も出ません。これは一様な (D, H, W) ボリュームを立方体として渡したときにちょうど起きる経路でもあります(実測: 全画素 0.0075 m)。いまは 1-D 側は ValueError、立方体側は該当画素を empty 判定します(二重防御)。histcubevoxel から分けたのはこの経路を型で塞ぐためで、型と実行時検査の両方で守っています。
  3. 文字列の暗黙パースdtof_cube_depth(cube, empty_value="3") が成功していました。float("3") はパースに成功するので、一度も解釈されなかった設定値が「深度 3 m」として通り抜ける。長さ・時刻・レートを取る全引数で str / bool / complex を名指しで拒否しています。
  4. 型レベルの嘘anscombe_transform(np.arange(5.0)) が 1-D を返していました。台帳は image2d -> image2d と宣言しているので、これは連鎖ファザーが検出すべき TYPEMISS を素通りさせる穴です。厳密 2-D 化しました(1-D ヒストグラムを安定化したいなら hist[None, :] と明示的に書く)。
  5. 既定値の地雷leading_bins=8 が固定だったため、8 bin 未満のヒストグラムでは method="leading"既定呼び出しが必ず失敗していました。誰も選んでいない定数のために失敗するのは契約ではなく事故なので、Nonemin(8, len(hist)) に変えています。

型語彙を分けた理由(連鎖ファザーの実測に基づく)

counts を最初は既存の signal で宣言していました。1200 連鎖 × 長さ 6(seed 7001)の実測で、photon 族 17 op のうち 7 op が一度も実行されていないことが分かりました。ファザーの signal プールは負値を持つ正弦波で、光子カウントを要求する op は毎回こう落ちます:

dtof_depth: hist has 127 negative bin(s) (min -1.17595) — a photon count cannot be negative

fail-closed は完璧に効いていました。そしてそれが問題でした — 「発見ゼロ」が頑健さの証拠に見えて、実際には未実行だったからです。これは opsopticsjones / stokes を専用プールにしたのと同じ状況(「signal へ相乗りさせると常に CONTRACT にしかならない = 偏光連鎖を一度も通らない」)で、同じ判断をしました。新語彙にしたあとは 17/17 op が実行され、CONTRACT も TYPEMISS もゼロです。

countratecounts からさらに分けたのは、2 つの実測理由によります。(a) 値域が 7 桁違うため、counts スケールの配列を spad_deadtime_apply に渡すと恒等写像に限りなく近い値が例外なく返る(実測: ピーク 2212.5 カウントで相対変化 1.11e-04、既定の tcspc_simulate() のピーク 23.0 なら 1.15e-06。本物のレート列 1e3–1e7 Hz は 33.3% 動く)。op は「到達」しても、飽和・1/tau の fail-closed・麻痺型の非単射性は一度も踏まれません。(b) 物理が違います。デッドタイムは検出器のレート流に効くのであって、TCSPC の時間 bin ヒストグラムに bin ごとに掛かるものではありません(ヒストグラムに対する正しい歪みモデルは Coates)。同じ語彙にすると、進化探索が「ヒストグラムにデッドタイム補正を掛ける」という物理的に誤った連鎖を正当な型接続として学習します。

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

単一光子距離計を 1 台仕立てる筋(検証済み examples/photon_timeresolved.py そのもの)。counts を軸に、counts -> counts で整形し、table / measurement / depth へ抜けます。

flowchart LR
    A[tcspc_simulate 既知距離の合成波形] -->|counts| B[tcspc_irf_convolve ジッタ]
    B -->|counts| C[tcspc_coates_correct パイルアップ除去]
    C -->|counts| D[tcspc_background_subtract 背景光の床]
    D -->|counts| E[tcspc_stats ピーク・重心・半値幅]
    D -->|counts| F[dtof_depth 距離 measurement]
    D -->|counts| G[lifetime_fit 単一指数 table]
    D -->|counts| H[lifetime_phasor g,s と円からのずれ table]
    G -.単一指数の仮定を検算.- H

SPAD アレイ(画素配列)の筋は depth で入って depth で出るので、既存の 3-D 知覚族へそのまま繋がります。

flowchart LR
    P[深度マップ depth + 反射率] --> Q[dtof_cube_simulate]
    Q -->|histcube H,W,T| R[dtof_cube_depth peak/centroid/parabolic/gaussian]
    R -->|depth| S[既存の depth 族 平面当てはめ・点群化・走行可能性]

光子制限画像の筋は、デノイズと復元で既存族へ橋渡しします。線形平滑には Anscombe を挟まないのが正解である点に注意(実測は次節)。

flowchart LR
    I[期待光子数 image2d] --> J[photon_sample Poisson 実現]
    J --> K[photon_statistics Fano・SNR table]
    J --> L[photon_uncertainty 誤差棒 image2d]
    J --> M[anscombe_transform 分散 1 へ]
    M --> N[絶対雑音スケールを引数に持つデノイザ]
    N --> O[anscombe_inverse unbiased]
    J --> V[volrestore.vol_richardson_lucy Poisson 逆畳み込み]

計数レートの筋は独立した 2 op で、互いに厳密逆です。

flowchart LR
    W[真の入射レート countrate] --> X[spad_deadtime_apply 非麻痺型/麻痺型]
    X -->|countrate| Y[spad_deadtime_correct 非麻痺型の厳密逆]
    Y -.往復は機械精度.- W

使い方(最小の 1 本)

import photoncount as P

# 3 m 先の対象。256 bin x 100 ps = 一意測距範囲 3.84 m、1 bin = 1.50 cm
hist = P.tcspc_simulate(distance_m=3.0, bins=256, bin_ps=100.0,
                        signal_photons=300.0, ambient_photons=1500.0, seed=0)
clean = P.tcspc_background_subtract(hist, "median")     # 屋外の日射を引く
print(P.dtof_depth(clean, bin_ps=100.0, mode="gaussian"))   # ≈ 3.0 m

# 光子計数画像の誤差棒は校正不要 — Poisson は分散 = 平均
counts = P.photon_sample(scene, photons_per_unit=100.0, seed=0)
print(P.photon_statistics(counts)["fano_factor"])       # ≈ 1.0 なら本当に Poisson
sigma = P.photon_uncertainty(counts)                    # sqrt(N)

# 蛍光寿命は 2 通りで出して突き合わせる
fit = P.lifetime_fit(decay, bin_ps=25.0, background=0.0)
ph = P.lifetime_phasor(decay, bin_ps=25.0)
print(fit["lifetime_ps"], ph["tau_phi_ps"], ph["semicircle_residual"])

実測値(この族の性能と、その正直な内訳)

すべて実測です。Poisson の期待値表は標本化せず pmf を直接足した厳密値なので、誰でも再現できます。

実測値 条件
Fano 因子(平坦場) 1.001089 λ=100、512x512、seed 0(平均 99.9796、SNR 実測 9.9935 / 理論 9.9990)
Fano 因子(傾斜場) 22.4102 同じ検出器で 20→180 光子のランプ。両方とも「正しい」が意味があるのは一方だけ
var(A)(Anscombe) 0.717443 / 0.924297 / 0.998754 / 1.000910 / 1.000006 λ = 1 / 2 / 4 / 10 / 100(厳密)
代数逆変換の往復 最大絶対誤差 2.7e-12、最大相対誤差 3.7e-16 x を [0, 1e4] に 100001 点、相対は x > 1
デッドタイム往復 最大要素別相対誤差 6.0e-16 τ=50 ns、1e3–5e7 Hz を 2000 点(飽和の 71.4% まで)
Coates の逆変換 最大相対誤差 1.6e-15 最終 bin が真値の 14.8% まで潰れた重いパイルアップから
dToF 重心(雑音なし) 4.4e-16 m 2.4371 m、256 bin x 100 ps、IRF 500 ps
立方体の重心(雑音なし) RMS 3.2e-16 m 32x32、1.0–3.0 m の傾斜平面
寿命フィット(雑音なし) 相対誤差 0.0、R² = 1.0 τ=2000 ps。bin 積分で作っても厳密(全 bin が同じ定数倍なので傾きが変わらない)
phasor の離散化誤差 残差 +6.07e-05 → 3.79e-06 256 bin → 1024 bin でちょうど 16.00 倍改善(= O(bin^2) の中点則、偏りではない)

推定量の選択は、雑音が入ると意味を失います。 2.4371 m の合成復路での距離誤差:

mode 雑音なし ショット雑音下(信号 200 + 背景 200 光子、seed 0)
peak 1.286 mm 13.7 mm
centroid 4.4e-16 m 146.5 mm(背景除去つき)
parabolic 0.067 mm 8.5 mm
gaussian 9.4e-09 m 8.0 mm

雑音なしでは 3 桁の差が付きますが、ショット雑音下では peakgaussian の差は 1.7 倍しかありません。推定量を凝る前に光子を増やすべき、というのがこの表の読み方です。重心だけは崩れます(中央値で床を引いても窓全体に残る雑音が一次モーメントを中央へ引くため)。

平均値だけを見ると誤解する例も残しておきます。32x32 の傾斜平面を反射率 0.3–1.0、光子 20 個/画素 + 背景 5 で撮ると、深度誤差は中央値 14.9 mm に対し RMS 165.6 mm。10 cm を超える外れ値が 3.0% あり(暗い列では 1 画素 4–6 光子)、それが RMS を支配しています。

docstring の数値を自分で 3 件訂正した話

この族の docstring には「実測してから書く」規律を当てていますが、最初に書いた数値のうち 3 件は間違っていて、自分の検証で見つけて直しました。どれも「もっともらしいので誰も疑わない」種類の誤りです。

  1. 厳密不偏逆変換が負になる点。「D = 0.6867 あたりで負に転じる」と書いていました。実際に測ると、閉形式の正の根は厳密に A(0) = 1.2247448714(根と A(0) の差は 0.0)で、これは anscombe_transform が返しうる最小値そのものです。したがって有効域では丸め誤差しか負になりません(A(0) から 6 までを 500001 点で測って最小 -1.11e-16)。最初の -3.97e-05 という値は、1.2247 と桁を切って有効域の外側を測っていたための誤りでした。有効域の外(D = 1.20 で -0.0217)は本当に負なので、そこは clip ではなく拒否しています。
  2. 立ち上がりを含めると寿命は「短く」出る、が逆だった。直感的には「立ち上がりを入れると速く見える」ですが、実測は逆です。2000 ps の減衰を 600 ps の IRF でぼかした波形で、ピーク(bin 4)から始めると 2008.0 ps(+0.40%)、start_bin=0 を強制すると 2100.7 ps(+5.0%) — 4 bin 余分に入れるだけで偏りが 12 倍悪化し、しかも長い側へ動きます(立ち上がりが log の傾きを寝かせるため)。
  3. 「Anscombe を挟むとデノイズが良くなる」が、線形平滑では成り立たない。ガウシアンフィルタを直接カウントに掛けると RMSE 2.387、Anscombe 経由だと 2.459 でわずかに負けます。当然で、Poisson カウントの平均を取るのは既に正しい操作なので、先に分散を安定化しても得がありません。変換が効くのは絶対雑音スケールを引数に持つデノイザ(閾値・シグマフィルタ・ウェーブレット収縮・NLM・BM3D)で、そこでは 1 個の定数が全域で正しくなります。実測(4 と 64 光子/画素の 2 段シーン、seed 5、5x5 シグマフィルタ):変換域で 3σ 閾値 → 1.191、生カウント域で同じ 3σ 則を全体推定 sigma で当てて 2.307。ただし真値を知って掃引した「神託」閾値 24 なら生カウント域でも 1.080 に届くので、正直な見出しは「常に勝つ」ではなく「調整済みの当て推量が、原理的な定数 1 個で置き換わる」です。

三件とも、テストに測定値ごと固定してあるので docstring が静かに元へ戻ることはありません。

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

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


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