1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

説明できる古典画像処理を「スキル」として1000個持ち歩く ―― Physical AI のための自作ビジョン工房 Fullseye をつくっている話

1
Last updated at Posted at 2026-08-30

言語 / Language: 日本語 · English

説明できる古典画像処理を「スキル」として1000個持ち歩く ―― Physical AI のための自作ビジョン工房 Fullseye をつくっている話

English version: fullseye_overview_qiita_en.md

Fullseye ―― 実処理結果 12 タイルの看板(欠陥検査・寸法計測・watershed・恐竜X線・星雲・LiDAR ほか、すべて本記事に登場する実出力)

小惑星イトカワの実形状モデル(49,152 面、間引きなし)を物理ベースの自作レンダラでターンテーブル表示 —— Hapke 反射則・太陽視直径 0.53° の硬い影・環境光ゼロ・帯域制限した起伏と角ばった岩(全部 numpy 自前実装)

これは小惑星 25143 イトカワの実形状モデル(はやぶさ探査機の観測から作られた Gaskell 形状モデル、JAXA DARTS アーカイブ公開、49,152 面を間引かずそのまま)の周りを、本記事の主役 Fullseye の自作 3D レンダラのカメラと太陽が一周しているところです(位相角 45° 固定)。形状の読み込み、辺長 1.5 m への適応テッセレーション(幾何は不変)、波長ごとに帯域制限した起伏、べき則 D^-3.1 で撒いた角ばった岩、Hapke 反射則、太陽の視直径ぶんのレイキャスト影まで、すべて numpy の自前実装(環境光ゼロ、露出は全フレーム共通)。

同じイトカワの実形状モデル(49,152 面)を物理ベースで描いた静止画 —— Hapke 反射則・太陽視直径 0.53° の硬い影・環境光ゼロ・fBm 起伏 + べき則 D^-3.1 の岩(クリックでフルサイズ)

*↑ 同じ形状モデルの物理ベース版の静止画(位相角 55°)。上の GIF は Lambert 拡散 + 環境光 + 台座つきのスタジオ照明でしたが、こちらはレゴリスの測光にそろえました —— 反射則は Hapke(単一散乱アルベド 0.42、対向効果、平均傾斜角 26°。Lambert だと縁が暗く明暗境界がなだらかになるのに対し、小惑星の実画像は縁まで明るく平坦です)、影は太陽の視直径 0.53° でメッシュにレイを飛ばした硬い影(半影は幾何どおり数 cm)、環境光はゼロ(宇宙に空光は無いので影の底は地形の一回反射だけ)。凹凸が見えるように作り直した版(2026-09-03): 元の 49,152 面は辺長が 2.6〜14 m と不揃いなので、まず mesh_subdivide幾何を一切変えずに(面積・体積の相対誤差 0)辺長 1.5 m 目標の適応テッセレーション(432,550 面)にし、起伏は mesh_displace_spectrum波長ごとに帯域制限(その場所の面サイズの 2 倍より短い波長は変位せず bump_normals_fbm の法線側へ回す ——粗い所に細かい起伏を刻んでも面のノイズになるだけなので)、岩は mesh_scatter_boulders(shape='hull')角ばった凸包 2,909 個(N(>D) ∝ D^-3.1、Michikami et al. 2008、30〜60 % 埋没、首の「海」には撒かない)、露出は照らされた面の中央値を 0.45 に(前の版は 0.74 で飛び気味)。起伏コントラストは 0.034 → 0.081(AMICA 円盤尺度で 0.090。AMICA 実画像の 0.037 は位相角 8.8° なので厳密な比較ではありません)。すべて fullseye の op mesh_subdivide → mesh_displace_spectrum → terrain_region_mask → mesh_scatter_boulders → render_regolith の連鎖で、examples_3d/itokawa_regolith_hero.py が GT つきで再生成します。*この「目」をどう作ってきたかの話をします。

TL;DR

  • Fullseye(フルズアイ/"Bullseye = 的のど真ん中" のもじり) は、古典的な画像処理・幾何ビジョンのアルゴリズムを numpy で自前実装し、1本の型付きインターフェースの裏に約1000個そろえた自作ライブラリです。「中身が説明できる(explainable)ビジョン」を、Physical AI(物理世界で身体を持って動く AI ―― つまりロボット)の目のために持ち歩けるようにするのが狙いです。
  • 産業用マシンビジョンの定番 HALCON を「網羅の地図」として使い、実測で 981 / 2313 operator(42.4%) に自作の対応物を用意しました(この数字は記憶頼みでなく、公式リファレンスの operator 一覧に対して機械集計したものです)。
  • ライブラリの上には、アルゴリズムを進化計算で"設計"するモードと、ステレオ→深度→点群→6自由度姿勢とつなぐ Physical AI 知覚スタック、そして HDevelop 風の IDE「Fullseye Studio」 が乗っています。
  • いちばんの推奨運用は AI の RAG 知識ベース。Claude Code などに読ませると、「この画像から○○を検出して」という会話だけで約1000個の op からパイプラインが組まれ、実行され、Studio の画面に結果が出る ―― そういう下地として設計しています。
  • 本記事の裏テーマは 「正直さ(honest disclosure)を仕組みにする」 こと ―― 良い数字だけ見せない・失敗も消さない・限界は明記する、という規律です。品質ゲートが実際にバグを捕まえた例を、今まさに直した6件を含めてそのまま載せます。
  • テストは現在 10982 件。深い依存(OpenCV / torch など)は全部オプションで、numpy + scipy だけでコアが動く設計です。

この記事は「完成した自慢」ではありません。なぜこの形にしたのか・どこがまだ弱いのかを、追試できる粒度で残すものです。数字はすべて実測、限界は隠さず書きます。

まず1枚。これは Fullseye の 3D レンダラ(もちろん numpy 自前実装)が、SDF で作った形状に環境光遮蔽・ソフトシャドウ・ACES トーンマップまでかけて焼いた出力です:

Fullseye 自前レンダラの出力: SDF/CSG の静物(ジャイロイド格子球・三葉結び目・歯車、AO + ソフトシャドウ + ACES、法線は SDF 勾配、色は頂点色)— クリックでフルサイズ

絵そのものは差別化ではありません(この程度は DirectX で昔からできる)。違いは、同じシーンから depth・法線・AO・影が numpy 配列で返り、同じツールキットの op で真値つきに採点できること ―― ゲームエンジンではなく計測器です:

Fullseye で何ができるか ―― 画像処理の分類で並べる

ここから先は、同じ静物・同じライブラリでできることを画像処理の分類順に並べます。順番は「作った日付」ではなく 光と形の関係の種類です。どれも真値つきで採点した実測値を付けてあります。

分類 やっていること 実測
① 合成と計測チャンネル 描いた絵から depth・法線・AO・影を numpy 配列として取り出す 境界で内部の 21 倍(sobel_mag(depth))
② 受動計測(光を当てて解く) 複数照明の陰影から法線とアルベドを解く 角度誤差 0.000°、再照明も 0.000°
③ 能動計測(光を投げて測る) 縞と符号を投影し、位相から三角測量する RMSE 0.036 mm(奥行き 102 mm)
④ 断層(中を見る) 投影 → フィルタ逆投影で内部の減衰係数を戻す Dice 0.882、μ 誤差 4.3〜16.6%
⑤ 材質の見え方(構造色) 回折・干渉・微小面から波長ごとの反射を作る 薄膜は解析値と厳密一致、白は (1,1,1)
⑥ 作り込みの過程 低品質だった hero をどう直したか 5 段の改善履歴

① 合成と計測チャンネル ―― 返るのは絵ではなく配列

同じシーンの計測チャンネル: beauty / depth / 法線 / AO / 影 / depth を食った sobel_mag と真値照合

(右下: depth を sobel_mag に通すと、レンダラ自身のシルエット境界で内部の 21 倍。op の出力を真値で採点する閉ループの最小例)

② 受動計測 ―― フォトメトリックステレオと再照明

同じシーンを 6 方向の光で撮り(render_beauty)、photometric_stereo で法線を復元し、真値法線との角度誤差で採点 ―― 撮影・復元・採点が全部 Fullseye の op です:

フォトメトリックステレオの閉ループ: 6 灯撮影 → photometric_stereo / robust → 真値法線との角度誤差

さらに、復元した法線とアルベドだけで光を動かせます(左=復元のみ / 右=真値法線。ほぼ区別がつかない=中央値 0.000°):

復元した法線から再照明: 左は photometric_stereo_robust の復元のみ、右は真値法線

(素朴な最小二乗は付着影で 4.5° 偏り、影+AO ありでは 16°。RANSAC 版は 0.000° / 影ありでも 0.04°)

③ 能動計測 ―― 構造化光スキャナ

そしてこの静物へ、構造化光スキャナを当てられます。投影機から相補 Gray code 20 枚 + 位相シフト 4 枚を投げた「撮影」を合成し、wrapped_phase(精密だが 2π 不定)と graycode_decode(粗いが絶対)を新しい absolute_phase で合流させて絶対位相にし、triangulate_column視線と投影機コラム平面の交点を閉じた式で解いて深度に戻します ―― 撮影から復号・三角測量まで全部 Fullseye の op です:

構造化光スキャナ: 相補 Gray + 位相シフトの撮影 → 絶対位相 → 三角測量 → 真値深度との差

ここが「絵」との分かれ目です。真値深度はレンダラ自身が z-buffer で持っているので、復元した深度を mm で突き合わせられます ―― 奥行き 102 mm のシーンで RMSE 0.036 mm・中央値 0.017 mm、Gray 復号の誤りは 36,011 画素中 0。零点も同じ撮影から測っています:Gray の整数コラムだけだと 0.073 mm(量子化で 2 倍悪い)、位相だけだと 2π 不定性で桁違いに壊れます。

実行できるサンプルは examples_3d/structured_light_scan.py(球+段差箱+床、22 枚)。奥行き 287 mm を **RMSE 0.233 mm(0.081%)**で復元し、Gray のみ 0.548 mm・位相のみ 209.8 mm の 2 つの零点を判別的に上回ることを assert しています。

最初の実行は RMSE 78 mm でした。look_at が gluLookAt 規約(カメラは −Z を向く)で姿勢を作るのに対し、render_mesh はそれを (x, −y, −z) に直してから K を掛ける(= CV 規約)。この差を飛ばすと投影機がカメラの背後を向き、深度は**「もっともらしい大きさ」のまま全部間違う**。零点(Gray のみ 79 mm)と数字が並んで初めて気づきました ―― 零点を一緒に測っていなかったら、たぶん通していました。

④ 断層 ―― 静物を X 線 CT にかける

表面を測ったなら、次はです。同じ静物をX 線 CT にかけます。SDF から中身の詰まった減衰係数ボリューム(材質 3 種)を作り、スライスごとに radon_transform で平行ビーム投影 → 光子数のポアソンノイズを載せる → filtered_backprojection で再構成 → 真値と突き合わせます:

静物の X 線 CT: 真値スライス / サイノグラム / FBP / 単純逆投影 / 24 ビュー / 誤差 / 最大値投影

横幅 30 mm・体素 0.210 mm・74 スライス × 180 ビュー。Dice 0.882(適合率 0.79 / 再現率 1.00)、μ の復元誤差は Ti 4.3% / PMMA 6.3% / Al 16.6%。零点はランプフィルタ無しの単純逆投影 0.492(その手法に最も有利なしきい値で採点)、24 ビュー FBP 0.452 で、実手法が 1.8〜2.0 倍上回ります。

正直な内訳を 2 つ: (1) 取りこぼしはほぼゼロ(再現率 1.00)で、Dice を下げているのは拾いすぎ(適合率 0.79)です。二値化しきい値を最も薄い材質 PMMA の半分に置くので、再構成ノイズも一緒に拾います。(2) Al だけ μ が 16.6% 低いのは、格子球の殻が局所肉厚 2.0 体素しかないから ―― 部分体積効果であってバグではありません(Ti は 4.9 体素で 4.3%、PMMA は 4.0 体素で 6.3%)。

ここでも一度転びました。最初は μ を 0.55〜1.0「/画素」で置いてしまい、線積分 p が 30 に達して exp(−p) が光子 1 個を割り、対数が飽和(photon starvation)。復元 μ が 50〜84% 低く出て、零点の単純逆投影の方が Dice で勝ってしまった(0.63 対 0.43)。零点が実手法に勝ったら、まず自分の物理を疑う ―― μ を実材質の値(1/cm)と体素の物理サイズから決め直したら最大 p は 1.46 に収まりました。

⑤ 材質の見え方 ―― 色を塗らずに波長から作る

かつてこの節の末尾には「鏡面・ガラス・CD の虹・ヘアラインはまだ出せません」と書いていました。そのうち 光線追跡を必要としない 3 つ(回折・干渉・異方性)は出せるようになったので、ここに入れます。鏡とガラスの屈折だけが残り(ラスタライザにレイトレーサを足す話)です。

予告していた材質: ヘアライン(Ward 異方性) / CD の虹(回折格子 1.6 µm) / 薄膜干渉(水膜 380 nm) と、その物理の検算

色は塗っていません。 回折・干渉・微小面の統計から波長ごとの反射率を作り、CIE 1931 等色関数で XYZ に積分してから線形 sRGB に落としています(新モジュール matappear、7 op)。だから角度を変えれば色が動き、格子ピッチや膜厚を変えれば色そのものが変わります。

検算は下段の 3 枚です。(1) 薄膜は膜厚 0 で基板単体のフレネル反射に厳密一致(n=1.5 で 0.040000)、λ/4 で解析値 0.077113 に一致、λ/2 は「無いのと同じ」(absentee layer)。(2) 回折は格子の式 d(sinθ_out − sinθ_in) = mλ をそのまま解いていて、CD 1.6 µm・Δsin 0.35 で 1 次が 560 nm。(3) 異方性ローブの伸び比は αx/αy に従います。等色関数側も、平坦な反射率 1 が sRGB の白 (1.000, 1.000, 1.000) に、0.5 が 0.5 に落ちることを確認済み。

ここでも 2 回転びました。(a) 溝と同じ向きに光源をずらしても分散は起きません(λ = d·Δsin が ±100 nm にしかならず不可視)。「色が出ない」の大半はバグではなく配置ミスです。(b) 実際の格子は両側に回折するのに +m しか計算しておらず、解が全部負になって全部フィルタで落ち、真っ黒になっていました。CD を溝に直交して照らしたときの本命は m = −2 の 440 nm です。

⑥ 作り込みの過程 ―― hero が「ジャガイモ」だった頃

この 1 枚も一発では出ていません。 改善の過程をそのまま載せます(作っている感が伝わるように):

hero 改善の過程: ①旧640pxフラット法線 ②smooth法線でも残る格子バンディング ③SDF勾配法線 ④1280pxだがジャガイモ ⑤SDF/CSG静物

  1. 最初の版は 640px・面ごとのフラット法線でした。ファセットと四角いハイライトが見えます。
  2. 頂点法線の補間(smooth)に切り替えても、marching cubes のボクセル格子が法線に残って等高線状のバンディングが出ました(拡大②)。
  3. 等値面の法線は定義から ∇f/|∇f| なので、SDF の勾配を頂点でサンプルして法線にしたら消えました(③)。render_beauty(vertex_normals=) はこのとき生えた注入口です。
  4. それでも被写体が 茶色い滑らかな塊=ジャガイモにしか見えなかった(④。※左のはウ○コではありません、SDF の smooth union です)。
  5. 被写体を SDF/CSG の静物に変えました:ジャイロイド格子球(陰関数曲面 ∩ 球、空洞だらけで AO が効く、鋼)・三葉結び目(曲線への距離場、張り出しがソフトシャドウを作る、金)・歯車(CSG の和と差、黒鉄)。物体ごとの色は vertex_albedo=(頂点色)で付けています。

立体なので回しておきます:

SDF/CSG 静物のターンテーブル


この記事は何?(3行で)

  • 個人開発の画像処理ライブラリ Fullseye の全体像を、設計思想から中身の作りまでまとめた総集編です。
  • 読者として想定しているのは、マシンビジョン / ロボット知覚 / 画像処理に関わる人、または「ライブラリをどう設計すると長く保守できるか」に興味がある人。
  • 個々の技術(3DGS、進化計算、筋骨格制御…)は別記事で深掘りしています。ここは 地図 です。

所要時間の目安は 20 分ほど。長いので、目次から興味のある層に飛んでもらって構いません。

主張の「確からしさ」を先に仕分けます

長い記事なので、どの主張がどの段階にあるのかを最初に仕分けておきます。該当する章の冒頭にも同じ Status ラベルを置きました。

階層 Status ラベル 本記事での中身
実装済み・再現可能 Production-ready / Verified 2D 877 + 3D 346 の op、型契約と統一インターフェース、Studio、PyPI 配布、テスト 10982 件、HALCON 対応 981/2313 の機械集計、展示・デモの実出力
実証途上 PoC / Research prototype 進化によるパイプライン設計(hold-out 評価つき・限定条件)、RAG 経由の自然言語→パイプライン生成、Physical AI 知覚スタック(シミュレーション実証。実機投入・Sim-to-Real は未着手)
将来構想 Roadmap / Design proposal ロボット向けの包括的な op 基盤、AI が約 1000 op を選んで自律実行する運用、産業検査と Physical AI の共通知覚基盤

数字はすべて実測です。この仕分け自体も「盛らない」ための仕組みのひとつです。


使ってみる ―― 導入方法(先に置いておきます)

読みながら手元で試したい人のために、導入を先に。公開リポジトリ(GitHub)と PyPI から入れられます。numpy + scipy だけでコアが動くので、まず素で入れて、必要になったら extras(オプションの追加依存)を足すのがおすすめです。

# ① まず動かす(コアは numpy + scipy のみ)
pip install fullseye

# ② Studio(IDE)や重い op も使うなら extras を足す
pip install "fullseye[gui]"        # PySide6 の IDE だけ
pip install "fullseye[all]"        # OpenCV / torch / GUI / 動画など全部入り

# ③ Studio を起動
fullseye-studio

①のコマンドだけで何が起きるか、少し具体的に書いておきます。pip install fullseye は、依存として numpy と scipy だけを引っ張ってきます(OpenCV も torch も PySide6 も入りません)。この状態でも import fullseye は通り、500種類以上の op がすぐに使える状態になっています。「性能の正直な話(GPU)」や「公開前夜」の章で触れる通り、この「素のインストールで最低限が動く」という主張自体が、CI の中で毎回実行して確かめられているもので、口約束ではありません。②の extras は、必要になったときに足す後乗せの選択肢という位置づけです。

そして いちばんおすすめの使い方が、AI コーディングアシスタント(Claude Code など)の RAG(検索拡張生成)知識ベースにすることです。同梱のセットアップスクリプトが1コマンドでスキルを配置します:

fullseye-rag              # op カタログを Claude Code のスキルとして登録
fullseye-rag --uninstall  # きれいに外す

これで「この画像から傷を検出して」と AI に話しかけるだけで、AI が約1000個の op から適切なパイプラインを組んで実行してくれるようになります(何がどこまで出来るかは後述の RAG の節で)。Claude Code をまだ使っていない方は、こちらの紹介リンクから始めてもらえると、作者の開発費(お財布)がちょっと助かります。

ソースから入れる場合(op ドキュメント約1000枚のフルコーパスを RAG に使いたい人向け):

git clone https://github.com/furuse-kazufumi/fullseye && cd fullseye
pip install -e ".[all]"
py -3.11 tools/update_fullseye.py --check   # 以後の更新は安全アップデータで

アップデータは環境をつぶさない方針で作ってあります(未コミットの変更があれば拒否・--ff-only のみ・RAG スキルはバックアップしてから更新・Studio の設定には触れない)。使い方の詳細は、リポジトリの README.mddocs/AI_RAG_GUIDE.md(RAG 化手順)、docs/STUDIO_GUIDE.md(IDE ガイド)にまとまっています。

ここで挙げた4つの制約(未コミット拒否・fast-forward のみ・バックアップしてから更新・Studio 設定に触れない)は、どれも単体では地味な工夫です。それでも並べて書いたのは、「AI 用の下地を育てるための更新スクリプト」自体が、人間の作業を壊す加害者になり得るという自覚があるからです。RAG 化のスクリプトも、Studio のセットアップも、開発を助けるはずの道具が、うっかり未保存の変更を消したり、手で調整した設定を上書きしたりすれば、本末転倒です。この後の節で紹介する fail-closed の設計も、突き詰めれば同じ発想の繰り返しです。

全体像を先に掴みたい人向けのリンク集(すべて GitHub 上でそのまま読めます):

見たいもの リンク
約1,200 op のヘルプ総目次(2D / 3D) docs/ops/INDEX.md
全 op の一枚カタログ(型契約つき) docs/OP_CATALOG.md
処理結果ギャラリー(本記事の図版のフルサイズ+解説) docs/GALLERY.md
サンプルデータの入手先カタログ(実 DL URL・ライセンスつき) docs/ops/SAMPLES.md

まず言葉を4つだけ(最小用語集)

頻出する4語だけ先に。それ以外の用語は、本文の初出時にその場でかみくだいて説明しますので、覚えなくて大丈夫です。

用語 ざっくり言うと
Fullseye(フルズアイ) この記事の主役。画像処理・幾何ビジョンの自作オペレータ・ライブラリ。名前は Bullseye(ブルズアイ=射的の的のど真ん中)のもじり(詳細は下の「名前の話」)。開発リポジトリ名は imgevolve
オペレータ(operator, op) 「1つの画像処理関数」。例:ガウスぼかし、大津の二値化、Sobel エッジ。Fullseye では約1000個。
HALCON(ハルコン) 独 MVTec 社の産業用マシンビジョンの定番ソフト。2313 個の operator を持つ巨人。Fullseye はこれを「どこまで自作でカバーできたか」を測る物差しに使っています。
Studio(Fullseye Studio) Fullseye を見て・試して・仕事で使うための IDE(統合開発環境)。産業ビジョンの HDevelop に相当。

名前の話 ―― Fullseye = Bullseye + eye

名前の由来を先に。Fullseye(フルズアイ)Bullseye(ブルズアイ) のもじりです。Bullseye は射的やダーツの的のど真ん中、転じて「ど真ん中・大当たり」。発音もそれに寄せて「フルズ・アイ」。

そこに三重の意味を込めています:

  • full(全部)+ eye(目) で、「何でも見える目」。あらゆる画像処理・幾何ビジョンを"スキル"として全部持つ、という狙いそのもの。
  • Bullseye の"ど真ん中を射抜く"精度。産業検査やロボット知覚で求められる、説明できて外さない目でありたい、という含意。
  • そして、作者である私の名字 Furuseeye をつないだ響き ―― 「Furuse の目」。個人開発として、名前に自分を編み込んでいます。

「進化で設計する(imgevolve)」という手段の名前から、「的を射抜く目(Fullseye)」という目的の名前へ ―― 名前の変化そのものが、このプロジェクトの方向転換を映しています。

開発リポジトリ名を imgevolve のまま残しているのも、実は意図的です。名前を変えたからといって、そこに至るまでの積み重ね(進化計算で op を探索していた時代の資産や教訓)を無かったことにはしたくない。表に出す製品名は Fullseye に切り替えつつ、内部の呼び名には最初の発想の痕跡を残しておく――これも一種の来歴の記録だと捉えています。


なぜ作ったのか ―― 「進化で設計する」から「説明できる目」へ

Fullseye には前身があります。もともとは imgevolve、つまり 「画像処理パイプラインを進化計算で自動設計する」 実験でした。「このデータでこの指標を最大化する処理手順」を、遺伝的アルゴリズムに探させる ―― という発想です。

ここで方向を決め直したのが転機でした。進化で"探す"には、まず探索の部品(op)が豊富で、型が揃っていて、正直に評価できる必要がある。部品をそろえていくうちに、「部品ライブラリそのものが主役だ」 と気づいたわけです。

そして 2026 年 8 月、私は Fullseye のミッションをこう定義し直しました:

あらゆる画像処理・視覚アルゴリズムを「スキル」として保持し、即使える包括的なライブラリにする。中身が説明できる、ロボットのための"専用 HALCON"を作る。

この再定義は自分にとって大きな判断でした。汎用的なアルゴリズム(ソートや圧縮など)を積みかけていたのを「それは筋が違う」と切り、画像・幾何ビジョンに一点集中すると決めた。何を作らないかを決めるのが、実は一番効きました。

背景には evis(エビス) の存在があります。私が別シリーズの記事で進めている筋骨格ヒューマノイド(700 筋モデルなど)の実験群で、Fullseye の"お客さん第一号"です。ロボットに箸で豆をつまませたり、歩かせたりするには、「世界を正しく見る目」 が要る。

実解剖由来の骨メッシュ(MyoSuite myo_sim、Apache-2.0)の手骨 27 個(手根骨8・中手骨5・指骨14)を MJCF の運動学木から組み立て、Fullseye の自前レンダラ(AO・接地影・SSAA・ACES)で描画 — クリックでフルサイズ

↑ 以前ここに置いていた手続き生成(カプセル SDF)の手は、実物の骨と並べると明らかに粗かったので差し替えました。「正確な骨格」は画像生成 AI のもっともらしさではなく実データの幾何で担保する、という方針です。骨の配置は MJCF を純 numpy で辿って計算し、MuJoCo の forward kinematics と 1e-10 m で一致することを確認しています(examples_3d/anatomical_hand.py、データは同梱せず git clone MyoHub/myo_sim)。手続き版は後段のターンテーブルにそのまま残しています。

↑ その「手」を Fullseye 側から見た一枚。手続き生成した手の骨格(手根骨8・中手骨5・指骨14)を、これも自前レンダラで描画しています。

深層学習の目も強力ですが、"なぜその姿勢だと判断したのか"が説明できないと、身体の安全に関わる判断は任せづらい。だから 説明できる古典ビジョンを、スキルとして手元に全部持っておきたい ―― これが Fullseye の芯です。

もう少し噛み砕くと、こういう順番の話です。ロボットが箸で豆をつまむにも、二足で歩くにも、まず「今、目の前に何があって、どこにあるか」が分からないと話になりません。動く(制御)より先に、見える(知覚)が要る――当たり前のようですが、evis の実験群を積み重ねる中で、これを痛感する場面が何度もありました。動作計画がどれだけ賢くても、入力の点群が歪んでいたり、姿勢推定が裏返っていたりすれば、その上に積んだものは全部意味を失います。だからこそ、知覚の土台を後回しにせず、最初に、独立したライブラリとして作る、という判断をしました。evis という"お客さん"が要求を出し、Fullseye という"納品先"がそれに応える――この関係を、同じ人間が両方の帽子をかぶって回している、というのが実態に近い言い方です。

その「箸で豆をつまむ」実験を、実際に Fullseye の目で見た映像がこれです(クリックで動画再生):

evis の箸先カメラ映像に Fullseye の segment_objects → draw_objects を毎フレーム適用して豆を追跡(クリックで mp4 再生)

↑ ▶ ChopMimic 実験(evis_chopstick プロジェクトの実験素材)の箸先カメラ映像に、Fullseye の segment_objects → draw_objects を毎フレーム適用して豆を追跡した実出力。左=三人称視点(文脈用、無加工)、右=Fullseye が検出した豆の bbox。豆が写っている 163 フレームすべてで検出(可視フレーム検出率 100%、重心誤差は真値比で中央値 0.10px・最大 14.5px=部分遮蔽フレーム)。隠れて見えない 78 フレームは検出なし ―― 捏造はありません。


全体像 ―― Fullseye は3つの層でできている

  • 層①:型付きオペレータ・ライブラリ ―― 約1000個の op を、image / region / feature / contour / volume といった**型(sort と呼んでいます。画像・領域・特徴量・輪郭・体積という「データの種別」)**でつなげる土台。
  • 層②:2つの使い方 ―― ほとんどの場合は 既知の op を適用apply / run_pipeline)。単発の op で解けないときだけ、進化計算でパイプラインを設計evolve / robust)。
  • 層③:Physical AI 知覚スタック ―― フレームを幾何と物体に変える部品群。ロボットが見て・測って・動くための道具。
  • そして全体を横断する Fullseye Studio。産業ビジョンの HDevelop に相当する、機能を把握・試験・実務で使う IDE です。

この図を眺めるときのコツを1つ書いておきます。矢印を「データが流れる向き」ではなく、「型が保証されている向き」として読んでください。層①の op はすべて apply を通れば型付きの結果を返す――だから層②の進化計算は、部品の中身を気にせず「型が繋がる op の並べ方」だけを探索できます。同じ理由で、層③の Physical AI 知覚スタックも、層①の op をそのまま部品として呼び出せます。もし層①に型の保証が無ければ、層②も層③も「実際に動くかどうか毎回試すまで分からない」状態になっていたはずです。1000個規模のライブラリが破綻せずに育っているのは、この矢印1本1本が型契約で裏打ちされているからだ、と考えてもらえれば、以降の各層の説明が繋がりやすくなると思います。

以下、層ごとに掘っていきます。


層①:即使えるオペレータ・ライブラリ(約1000個)

Status: Production-ready / Verified ―― PyPI から導入可。テスト 10982 件、数字は機械集計です。

op とは何か(3段階でかみくだく)

  1. ひとことで:op は「画像を入れると画像(や領域・数値)が出てくる1つの関数」。
  2. もう少し:Fullseye では op が型(sort)を持っています。「画像 → 領域」(例:二値化)、「領域 → 数値」(例:物体数を数える)というふうに、入口と出口の型が決まっている。だから型が合う op を数珠つなぎにできます。
  3. 正確には:各 op は apply(image, name, a=0.5, b=0.5) という統一シグネチャで呼べます。a, b[0,1] の2つのツマミ。特徴量 op は Python の float を、輪郭 op は dict を返す ―― という型の契約が全 op で守られています。
import fullseye, numpy as np

frame = np.asarray(img, np.float64)              # H×W グレー画像 [0,1]
edges = fullseye.apply(frame, "sobel_amp")       # 画像 → 画像(エッジ強度)
seg   = fullseye.apply(frame, "otsu")            # 画像 → 領域(二値 {0,1})
n     = fullseye.apply(seg,   "count_obj")       # 領域 → 特徴量(物体数, float)

# 型が合う op をつなぐ:ぼかし → エッジ → 二値化
out = fullseye.run_pipeline(frame, ["gaussian", "sobel_amp", "otsu"])

コード例に出てきた a, b という2つのツマミについて、一言補足しておきます。なぜ「op ごとに自由なパラメータ名」ではなく、全 op 共通で a, b ∈ [0,1] の2つだけにしたのか。理由は単純で、進化計算(層②)がパイプラインを探索するとき、パラメータの意味を知らなくても機械的に探索できるようにするためです。gaussiana は「ぼかしの強さ」、otsua は(意味を持たないダミーとして無視される、あるいは別の閾値調整として使われる)というように、op ごとに意味は違いますが、探索する側から見ればどれも「0から1の間のツマミを2つ回すだけ」。パラメータの数と範囲を揃えることで、進化計算のコード自体は op の中身を一切気にしなくて良くなります。これも層①の「型で繋ぐ」思想の延長線上にある設計判断です。

実際の出力を見てもらうのが早いでしょう。エッジ検出・セグメンテーション・輪郭計測など、定番どころを1枚に並べるとこうなります(すべて上の apply / run_pipeline の実出力。入力は scikit-image 同梱のサンプル画像 coins):

2D 古典ビジョン op の実出力モンタージュ(エッジ / セグメンテーション / 輪郭計測 ほか)— クリックでフルサイズ

各パネルの詳しい説明(何の op がどの数値を出しているか)は、リポジトリの 処理結果ギャラリー(docs/GALLERY.md) にまとめてあります。

型(sort)という背骨

op が1000個あっても、それぞれが独立した孤島では意味がありません。効いているのは、型(sort)で繋ぐという一点です。もう少し掘っておきます。

  1. ひとことで:Fullseye の全 op は image / color / region / feature / contour / volume という6種類の型のどれかを入口に取り、どれかを出口として返します。「二値化 (otsu) は画像を食べて領域を吐く」「物体数を数える (count_obj) は領域を食べて数値を吐く」――この入出力の型が、op ごとに固定されています。
  2. もう少し:型が合う op だけを数珠つなぎにできる、という制約が逆に効きます。将棋の駒に動ける方向が決まっているから盤面が読めるのと同じで、「この型の次にはこの型しか繋げない」というルールがあるから、パイプラインは組み合わせ爆発の中でも迷わず探索できる。層②で出てくる進化計算の探索空間も、突き詰めれば「型が繋がる op の並べ方」を数え上げている、と言えます。
  3. 正確には:型の整合性は tests/test_op_contracts.py契約として強制します。image/color[0,1] の float、region{0,1} の二値、feature はスカラー float、contour{"shape","cs"} を持つ辞書、volume は3次元の float スタック――という具合に、出力の形そのものが仕様です。そして仕様は「有限であること(NaN/Inf を返さない)」「同じ入力・同じつまみなら同じ出力になること(決定論的)」も要求します。乱数任せの op や、たまに壊れる op はそもそも登録できません

型が合わない接続をどう弾くかも大事な設計です。otsu(画像→領域)の出力を、いきなり sobel_amp(画像→画像)に食わせようとすると、run_pipeline はそこで明示的に落ちます。「なんとなく動くけど数値がおかしい」ではなく、「型が合わないので実行前に拒否する」――これは料理で言えば、生の魚を切ったまな板と、パンを切るまな板を分けておくようなものです。混ぜてはいけないものを、混ぜられないように物理的にしておく。fail-closed という設計哲学(本記事の随所に出てくる考え方)は、まずこの一番小さな単位、op と op の継ぎ目に効いています。

6種類の型を、具体的な繋がりで見る

image / color / region / feature / contour / volume という6種類、名前だけ聞いてもピンと来ないと思うので、実際にどう繋がるかを具体的に書きます。

  • image(画像):H×W の float 配列 [0,1]gaussiansobel_amp のように、画像を受け取って画像を返す op はここに属します。層①の最初のコード例そのものです。
  • color(カラー画像)image の3チャンネル版(H×W×3)。グレースケール前提の op とカラー前提の op を型で分けておくことで、「グレー画像のつもりでカラー画像を渡してしまう」というありがちな事故を、実行前に検出できます。
  • region(領域){0,1} の二値配列。otsu(画像→領域)のような二値化 op の出口であり、count_obj(HALCON 相当、領域→特徴量)のような計測 op の入口でもあります。「どこが物体か」を表す型、と考えると分かりやすいと思います。
  • feature(特徴量):スカラーの float 1個。パイプラインの最終出力になることが多い型です。「物体数」「平均輝度」「曲率の相関係数」――層③の各論で出てきた実測値の多くは、この feature 型として出てきます。
  • contour(輪郭){"shape", "cs"} を持つ辞書。エッジ検出 op(edges_sub_pix)の出口であり、輪郭選別 op(select_contours)の入口でもあります。冒頭のモンタージュで紹介した「サブピクセル輪郭抽出→短い輪郭を除去」という流れは、まさにこの型でリレーされています。
  • volume(体積):3次元の float スタック。CT ボリュームのような、奥行き方向にも解像度を持つデータの型です。層③3D の節で出てくる op の多くがこの型を経由します。

型を並べて眺めると、image → region → feature(「画像を撮る→どこが物体か分ける→数える」)や image → contour → feature(「画像を撮る→輪郭を取る→測る」)といったよくある処理の流れが、そのまま型の並びとして読めることに気づきます。逆に言えば、型が読めれば「この op の次に何を置けそうか」を、実装の中身を読まなくても当てられるようになります。これは AI が RAG として op を選ぶときにも、人間が Studio でパイプラインを組むときにも、同じように効く読み方です。

op を1個足すと何が起きるか

「op が1000個ある」と書くと、まるで最初から1000個並べたように聞こえますが、実際には1個ずつ足してきた歴史の積み重ねです。では、1個足すと裏側で何が起きるのか。読者が追体験できる粒度で書いておきます(docs/ADDING_OPS.md が実際の手順書で、以下はそれをかみくだいたものです)。

op を足す入口は2つあります。Path A(手書き)は ops.py_myop(v, a, b) という関数を書き、_DEFS("myop", "category", "halcon名かカラ", 入力sort, 出力sort, _myop) というタプルを1行足すだけ。REGISTRY はこのタプル群から自動で再構築されるので、他に触る場所はありません。Path B(データ駆動)は、pointwise / linfilter / rank / graymorph / edge / freq / diffusion / texture / geom / threshold / segment / binmorph / region_trans / region_feat / img_feat / xld といった、すでに検証済みの型枠にハマる場合に使います。コードを書かず、名前・形・パラメータだけの仕様(spec)を足せば op になる。HALCON エイリアスを名乗るなら、実在する operator 一覧と照合され、存在しなければそのまま落とされます(水増し防止の fail-closed)。

ここから先は人間が何もしなくても自動で追従します

  • 進化の探索空間:新しい op は、型が合う場所ならどこでも挿さる候補になります。層②の evolve.py は「今ある op から良い並びを探す」プログラムなので、op を1個足すだけで探索空間が黙って1段広がる――新しいレゴブロックを箱に足すのと同じ感覚です。
  • 機能ゲートverify_auto.py が実際の画像・領域・輪郭データに op をその場で走らせ、宣言した型をちゃんと返すかを確認します。クラッシュする op、型を裏切る op はカウントされません。「登録した数=動く数」を実測で担保する仕組みです。
  • ドキュメントpy -3.11 tools/opdocs.py all を実行すると、Markdown ノートが自動生成され、そこからStudio の HTML ヘルプ(2D も 3D も同じ経路)と**目次(INDEX.md)**が一括で更新されます。手書きの目次はゼロ、というのは前述の通りですが、その裏にはこの自動生成があります。
  • CI の drift テストtests/test_opdocs.py)が、「コミットされているノート」と「今のレジストリから再生成したノート」を副作用なしで比較し、ズレていれば CI を落とします。op の仕様を変えたのにノートを更新し忘れる、というよくある腐り方を機械的に防いでいます。
  • 仕上げに、tests/test_op_example_coverage.py が「この op には ground-truth 付きの worked example があるか」をチェックします。例が無い op は CI を落とす――「動くけど誰も使い方を知らない op」を作らせない、という最後の関門です。

op を1個足すだけで、レジストリ・探索空間・コード生成・ドキュメント・目次・CI・Studio ヘルプまでが連鎖して更新される――この「1点を触ると全体が追従する」設計こそが、1000個規模のライブラリを1人で保守できている理由です。裏で人手を介する箇所を減らすほど、「ドキュメントだけ古い」「この op だけ例が無い」という腐食の芽が生まれる余地が減ります。

どれくらいの規模か(実測)

  • 2D オペレータ:731 種(レジストリ登録 735 エントリのうち、名前で distinct なもの 731。46 カテゴリ)
  • 3D オペレータ:265 種(点群・メッシュ・ボリューム・SDF など、55 カテゴリ)
  • 合わせて 約1000個。denoise / 平滑化 / 先鋭化 / 二値化・セグメンテーション / モルフォロジー / エッジ・コーナー・ブロブ検出 / 距離変換 / 色空間 / テクスチャ・形状特徴 / 輪郭 / 3D幾何 ―― をカバーします。

この「幅」は言葉より図の方が早いので、実レジストリから機械集計したツリーマップを貼っておきます(面積=カテゴリの op 数。スクリプトが 731/46・265/55 と一致することを assert してから描いています ―― 数字を盛れない作りです)。

Fullseye operator taxonomy ―― 2D 731 op / 46 カテゴリ、3D 265 op / 55 カテゴリのツリーマップ(クリックでフルサイズ)

↑ op 分類ツリーマップ ―― 左が 2D レジストリ(halcon_ext 81・region 76・features 71…)、右が 3D レジストリ(geometry 23・render 14…)。面積=op 数。

「では実際にどんな出力になるのか」も1枚で。24 カテゴリから代表 op を1個ずつ機械選出して、同じコイン写真に実適用したサンプラーです(スキップゼロ。輪郭を返す op は実 XLD 点を焼き込み、数値を返す op ―― 計数・マッチング・形状記述など ―― は「正規の使い方」で適用して入力+検出オーバーレイ+実測値で見せています。たとえば blob_count は前処理後の region で count = 24(コイン枚数と一致することを assert)ncc_locate は実テンプレートで見つけた位置に枠、decode_barcode は合成バーコードを入力にして bars = 12 です)。

2D op サンプラー ―― 24 カテゴリの代表 op を coins に実適用(クリックでフルサイズ)

↑ 2D op サンプラー ―― gaussian から decode_barcode、xmh_zernike(Zernike モーメント)まで、24 分類 24 通りの「見え方」。全部実出力です。

工場の検査ラインで使われてきた処理が、pip 一発で

Fullseye の op 体系は、突き詰めると HALCON という産業用マシンビジョンの系譜から多くを学んで作られています。だから、工場の検査ラインで使われてきた"定番の仕事"に相当する op が、素の pip install fullseye だけで手に入ります。実装済みの op 名を挙げて具体的に書きます。

  • 欠陥検出tophat / bothat(グレースケール・モルフォロジーのトップハット/ボトムハット、ムラや小傷を背景から浮かせる定番手法)、morph_grad(モルフォロジー勾配)に加えて、ブロブ検出xsk_blob_log / xsk_blob_dog / xsk_blob_doh、スケール空間の極値からブロブ=染み状の欠陥候補を拾う)と blob_count(HALCON の count_obj 相当、検出数を数える)を組み合わせれば、「小さな染み・穴・傷を検出して数える」という検査ラインの基本形が組めます。
  • サブピクセル寸法計測:HALCON の measure_pos / measure_thresh / measure_pairs / fuzzy_measure_posキャリパー計測――指定した探索窓の中でエッジ位置をサブピクセル精度で求め、寸法や位置を測る手法)に相当する m1_measure_pos / m1_measure_thresh / m1_measure_pairs / m1_fuzzy_measure_pos を実装済みです。輪郭の極値・鞍点をサブピクセルで求める sp_local_max_sub_pix / sp_saddle_points_sub_pix / sp_critical_points_sub_pix などの subpix op 群も揃っています。
  • 位置決め(shape matching)ncc_locate(HALCON find_ncc_model 相当、正規化相互相関によるテンプレートマッチング)と shape_locate(HALCON find_shape_model 相当、輪郭ベースの形状マッチング)で、部品の位置・姿勢決め(ロボットが掴む前に「今どこにあるか」を求める、検査ラインの入口の仕事)ができます。
  • コード読み取りdecode_barcode(HALCON find_bar_code 相当)で、バーコード読み取りも対応済みです。

これらは個別に見れば地味な op ですが、工場の検査ラインで実際に日々回っている処理の大半は、突き詰めるとこの手の"地味な古典"の組み合わせです。深層学習の物体検出モデルを1つ学習させるよりも、こうした説明できる・軽い・決定論的な op を組み合わせる方が向いている現場は、今でも数多くあります。Fullseye が層③で Physical AI 側に倒れているのは事実ですが、土台にある op 体系そのものは、最初から産業ビジョンの系譜の上に立っている――これは名前の由来にある「Bullseye = 的を外さない精度」という含意とも重なります。

検査ラインで古典アルゴリズムがいまだに現役である理由は、単純です。照明とカメラを固定した検査治具の上で、決まった部品の決まった欠陥を探すという状況では、学習データを集めて再学習するコストに見合うだけの複雑さが、そもそも要らないことが多い。むしろ「なぜこの部品を不良品と判定したのか」を、しきい値とアルゴリズムの言葉でそのまま説明できることの方が、量産ラインの現場では価値を持ちます。層①の「型(sort)という背骨」の節に書いた通り、Fullseye の op は決定論的で、同じ入力・同じつまみなら必ず同じ結果を返す――これは学習済みモデルには無い性質で、検査基準の説明責任が問われる現場ほど効いてくる特性だと思っています。

言葉だけだと抽象的なので、この"検査ラインの定番"を実際に組んで動かした画を並べます(すべて合成データ上の実処理。検出・計測結果は既知の真値と照合済みで、たとえば欠陥検出は 6/6 件・粒子計数は 60/60 粒が assert で確認されています)。

表面欠陥検査 ―― 背景差分+blob 解析

表面欠陥検査 ―― 合成した金属面の傷 3・打痕 2・異物 1 を、median フィルタで地合いを推定 → 差分ヒートマップ → blob 解析で 6/6 件検出。さらに真値を使わない特徴量分類(離心率・色み)で傷/打痕/異物の種別まで全件一致させ、種別ごとに色分け枠+拡大インセットで表示しています。使用 op: median_image, dilation_circle, segment_objects

サブピクセル寸法計測 ―― 1D measuring キャリパー

サブピクセル寸法計測 ―― 測定矩形に沿ったグレープロファイルの微分極値をサブピクセル補間してエッジ対を抽出。段付きシャフト 3 段の径を実測し、描画寸法との誤差は最大 0.02px。HALCON の 1D Measuring と同じ流儀です。使用 op: m1_measure_pairs ほか。

位置決め ―― 回転探索つき shape matching

位置決め ―― エッジ勾配ベースの形状モデルをピラミッド探索で照合し、回転したワーク 3 個の位置と角度を検出(真値と 0.0px・0.0° 一致)。円板や長方形の紛らわしい別部品には反応しません。使用 op: create_shape_model, find_shape_model

ブロブ解析 ―― 粒子計数とサイズ分布

粒子計数 ―― 60 粒(うち 6 組は接触)をマーカー式 watershed で切り分けて 60/60 計数し、面積で 3 サイズに色分け。粉粒体の品質検査の型です。使用 op: otsu, xcv_watershed_markers, segment_objects

コード読取りの土台 ―― 走査線エッジ対によるバー検出

バー検出 ―― 実際のリーダーと同じく走査線のグレープロファイルからバーのエッジ対を検出。45 本全ての両端を ±1.5px 以内で特定(フル復号器ではなくバー検出・幅計測の素材です)。使用 op: decode_barcode, m1_measure_pairs

物差しは HALCON(実測 42.5%)

「網羅」を主観で語らないために、産業ビジョンの巨人 HALCON を物差しにしています。公式リファレンスから整理した 2313 operator の一覧に対し、Fullseye の各 op が「どの実在 operator に相当するか」をタグ付けし、機械集計しています。

なぜ物差しが要るのか、を少し立ち止まって書いておきます。「op が約1000個あります」だけでは、それが多いのか少ないのか、読者には判断のしようがありません。1000個という数字自体には、他と比べない限り意味がないからです。かといって「網羅している」と主観で言い切ってしまうのは、本記事の裏テーマである honest disclosure に反します。そこで、業界で実際に使われている巨人と同じ物差しで測り、数字を機械集計で出す、という方法を選びました。HALCON を選んだのは、単に有名だからではなく、operator の一覧が公式リファレンスとして整理されて公開されているという、比較可能性の高さゆえです。

imgevolve maps to 982 / 2313 HALCON operators(42.5%) ―― これは記憶でなく、スクレイプした一覧に対する実測です。

正直に言えば、まだ半分未満です。Tuple 処理・System・Classification・OCR といった章はほぼ手つかず(画像処理の芯から外れるので優先度を下げています)。逆に Matching / Morphology / Filtering / 幾何計測は厚くしてきました。"どこが埋まっていて、どこが空か"を章別の表で常に開示しているのが、この数字の肝です。

章別の地図 ―― 厚い所と空の所

HALCON の 2313 operator は、30 の章(chapter)に分かれています。Fullseye の 982 対応も、この章をまたいで均等に付いているわけではありません。

厚くしてきたのは、画像処理の芯にあたる章です。Filtering(平滑化・エッジ・周波数フィルタ)、Morphology(膨張・収縮・オープニング・クロージング)、Regions(領域演算・特徴計測)、Segmentation(二値化・領域分割)、そして Matching(テンプレート・変形マッチング)――このあたりは、実測 42.5% という全体平均よりもずっと濃く塗られています。逆に薄いのは、Tuple 処理(数値タプルの操作、プログラミング言語的な部分)、System(プロセス・スレッド制御)、Classification / OCR(機械学習ベースの分類・文字認識)といった章です。ここはほぼ手つかず、というのが正直なところです。

言葉で書いた偏りを、章別カバレッジの棒グラフでそのまま開示します(docs/HALCON_COVERAGE.md の実測と一致することを描画スクリプトが assert しています)。

HALCON 章別カバレッジ ―― 982/2313(42.5%)の内訳(クリックでフルサイズ)

↑ 厚い所(Regions 105/106・Morphology 42/44・Filters 186/196)と、意図して空けてある所(System 0/141・OCR 0/96・Tuple 0/165)が一目で分かります。

この偏りは手が回っていないからではなく、最初から狙ってそうした偏りです。次の節で、その判断の中身を書きます。

何を作らないかを決めた話

HALCON を物差しに選んだ時点で、ある問いが立ちます。「じゃあ 2313 個、全部作るのか?」――答えは です。

Tuple・System・File・Develop・Control といった章は、画像処理そのものではなく、HDevelop という開発環境を動かすための配管です。プロセスを起動する、ファイルを開く、変数を保存する――これらは Python のエコシステムが元々得意な領域で、numpy ベースの vision スキルライブラリが再発明する意味は薄い。Classification・OCR・Deep Learning は、学習済みモデルが要る別ドメインで、「中身が説明できる古典アルゴリズム」という Fullseye の軸そのものから外れます。Legacy は文字通り非推奨。

これは「なぜ作ったのか」の節で書いた方向転換imgevolve から Fullseye への再定義)と同じ判断の繰り返しです。汎用アルゴリズム(ソート・圧縮など)を切ったときと同じく、「何を作らないかを決める」方が、「何を作るかを決める」より効く、という家訓がここでも働いています。範囲を広げれば見かけの数字は伸びますが、それは画像・幾何ビジョンという的(Fullseye の名前の由来そのもの)から外れて的を散らすことになる。42.5% という数字を「まだ半分未満」と書きつつ、それでも範囲を広げないのは、この数字の分母自体を、意図して絞ってあるからです。

もちろん、これは一枚岩の判断ではありません。「Tuple や System まで含めた HALCON 完全互換」を目指す設計も、理屈の上ではあり得たはずです。そちらを選ばなかったのは、Fullseye の存在理由が「HALCON の代替品を作ること」ではなく、「説明できるアルゴリズムを、ロボットの目として持ち歩けるようにすること」だからです。物差しとしての HALCON は借りるが、HALCON そのものになろうとはしない――この線引きを、章別の表という形で常に開示しておくことが、「網羅していると盛らない」ための一番地味で一番効く仕組みだと思っています。

ドキュメントを「1つの真実源」にする(md=SoT)

op が1000個もあると、ヘルプが無いと使い物になりません。かといってヘルプ HTML と AI 用の説明を二重管理すると、必ず片方が腐ります。そこで採ったのが Markdown を単一真実源(Single Source of Truth)にする 設計です。

  • op ごとの Markdown ノート(約1000枚)が真実源。frontmatter に著者名・ライセンス(Apache-2.0)・ライブラリ版・参考文献・サンプルデータの実 DL URL・関連 op へのリンクを持ちます。
  • そこから Studio のヘルプ HTML を一括生成(2D も 3D も同じ経路)。二重管理はしません
  • 目次はフォルダ階層を walk して自動生成。手書き目次はゼロ。
  • 版接続が地味に大事です。画像処理は「わずかな仕様変更が結果の違いに直結」しやすい。だから 「commit 済みノート == 現レジストリから生成したノート」を副作用なしで比較する drift テストを CI に入れ、op の仕様が変わればノートがズレてテストが落ちる ―― つまり ドキュメントとコードが同じ版に固定されるようにしています。

この「md=真実源」化は、まさにこの総集編を書いている一連の作業で仕上げました。関連 op リンク・参考文献(Tomasi & Manduchi 1998、Serra 1982、Sobel & Feldman 1968… と実在する古典を正確に、DOI を捏造せず)・サンプルデータの実 URL まで含めて、長く保守できる形にしてあります。

(さらに一言、経験談として。)以前は「ヘルプ HTML は手で書く、AI 向けの説明はまた別に書く」を試したことがあります。結果は分かりやすい失敗でした。op のパラメータを1つ変えると、HTML だけ直して満足し、AI 向けの説明を直し忘れる。逆に AI 向けを直して HTML を忘れる。半年もすれば、どちらを信じればいいか本人にも分からなくなる――ドキュメントの二重管理は、放っておくと必ずこうなります。一方が真実で、他方が参照でしかない構造にしない限り、両方とも徐々に嘘をつき始めるのです。

md=SoT にしてからは、この種の「気づいたら片方だけ古い」という事故が構造的に起きなくなりました。ノートを直せば HTML も目次も追従する。ノートを直し忘れれば、CI の drift テストが教えてくれる(落ちるので気づかざるを得ない)。「気をつける」を「仕組みで担保する」に置き換える――これは本記事の裏テーマである honest disclosure とも根っこが同じです。人間の注意力に頼らない。

層①をここまで読んでもらうと、「型(sort)」「op を1個足すと連鎖する自動化」「md=SoT」の3つが、実は同じ1つの設計判断の別の顔だと分かってもらえると思います。型は op と op の継ぎ目を、md=SoT はコードとドキュメントの継ぎ目を、それぞれ人間の注意力ではなく機械的な契約で保証する――この土台があるからこそ、層②の進化計算も、層③の Physical AI 知覚スタックも、層①の中身を気にせず安心して積み上げられます。次の層②では、その土台の上で「良い並びを探す」という、また別の正直さが求められます。


層②:パイプラインを"進化"で設計する(正直な評価つき)

Status: PoC ―― hold-out 評価つきの限定条件での実証です。汎用の自動設計はまだ主張しません。

単発の op で解けない課題 ―― 「このデータでこの指標を最大化する処理手順を見つけたい」 ―― のときだけ、進化計算の出番です。層①で「ほとんどの場合は既知の op を適用する」と書いた通り、これは主役ではなく脇役の位置づけです。1000個の op から選んで繋ぐだけで大抵の用途は間に合いますが、それでも足りない場面――最適な組み合わせが人間の直感では見つけにくい場面――のために、探索という手段を残してあります。

py -3.11 baseline.py --problem denoise --workdir out/mine     # まず"正直な床"を測る
py -3.11 evolve.py   --problem denoise --workdir out/mine --gens 40 --pop 24
py -3.11 robust.py   --problem denoise --workdir out/mine --seeds 5

ここで一番こだわっているのは 評価の正直さです。

  • 適合度は訓練(train)分割だけで測る。
  • hold-out(取り置き)分割は追跡するが、選択には絶対使わない。

こうすることで、報告する汎化性能が「評価セットへの過剰適合」ではなく、正直な数字になります。「良い結果が出たら、勝った気になる前に必ず内訳を疑う」 ―― これは開発全体の家訓でもあります。

3本のコマンドがそれぞれ何をしているか

3行のコマンドをもう一段かみくだきます。

  1. baseline.py――まず何もしない・素朴な手法でどれくらいの数字が出るかを測ります。囲碁や将棋で言えば、定石を知らない素人がどれだけ打てるかを先に測っておくようなもの。これが無いと、「進化計算で良い数字が出ました」と言われても、それがすごいのか当たり前なのか判断できません。「ベースラインを先に測る」は、本記事の随所に出てくる家訓の中でも、一番地味で一番効くものだと思っています。異常に良い数字が出たら、まず「ベースラインは?」と聞き返す――これは開発チーム(実質ひとり)内の口癖です。
  2. evolve.py――遺伝的アルゴリズムで、op の並び(パイプライン)そのものを進化させます。世代(--gens)ごとに、パイプラインの集団(--pop)を突然変異・交叉させ、訓練データでの適合度が高い個体を残す。ここでのポイントは、探索する対象が「op の並び」であって「1個の巨大モデルの重み」ではないことです。出てきた解は人間が読める op の列なので、なぜその解が良いのかを後から検証できます
  3. robust.py――複数シード(--seeds)で evolve を独立に何度も回し、訓練データで選んだベストな個体(best-of-N、train-selected)を1つ選びます。乱数の運・不運を均すのが目的です。

コード例に出てきた --problem denoise は、ノイズ除去を目的に指定した1例です。baseline.py はまず素朴な手法(例えばパラメータ固定のガウシアンぼかし1発)でどれくらいのスコアが出るかを測り、evolve.py はそこから出発して、層①の op を数珠つなぎにしたもっと良いパイプライン(例えば「メディアンフィルタ→バイラテラルフィルタ→アンシャープマスク」のような組み合わせ)を世代を重ねながら探します。人間が最初から思いつく組み合わせもあれば、進化計算が見つけてくる人間には意外な順番の組み合わせもある――そこが遺伝的アルゴリズムを使う面白さで、かつ「なぜその順番が効くのか」を op 単位で後から読み解けるのが、層①の型付き設計の恩恵です。

held-out を選択に使わない、が効く理由(直感的に)

なぜ「hold-out は追跡するが選択には使わない」という一見遠回りなルールが要るのか。直感的な説明を試みます。

テストの点数を上げたい受験生が、過去問を繰り返し解いて答えを覚えてしまう状況を想像してください。過去問(訓練データ)の点数はどんどん上がりますが、それは「理解が深まった」のか「答えを暗記しただけ」なのか、過去問だけでは区別がつきません。区別するには、**まだ見ていない新しい問題(hold-out)**で測るしかない。ここで罠があります。もし「hold-out の点数が上がる方向」に勉強法そのものを選び直してしまったら――例えば「hold-out で成績が良かった参考書だけを残す」を繰り返したら――hold-out もまた"覚える対象"になってしまい、測る意味を失います。

進化計算でも同じことが起きます。もし世代ごとの選択(どの個体を生き残らせるか)を hold-out のスコアで行うと、何世代も回すうちに、hold-out という特定のデータセットに"効く"パイプラインだけが生き残ります。それは汎化ではなく、hold-out への過学習です。だから Fullseye では、選択は必ず訓練データだけで行い、hold-out は**「本当に汎化しているかを最後に覗く窓」**として、選択プロセスの外に置いています。これは統計的検定における「検証用に取っておいたデータで多重比較をしない」という規律の、進化計算版だと考えてもらえれば近いです。

正直に言えば、この規律を守ると見かけの数字は控えめになりがちです。hold-out に寄せて選べば、報告される性能はもっと良く見えたはずの局面も、実際にはあります。それでも hold-out を選択に使わないのは、あとで裏切られない数字の方が、開発を続ける上でずっと価値があるからです。


層③:Physical AI 知覚スタック(ロボットの目)

Status: Research prototype ―― この章の実証はすべてシミュレーション内です。実機投入・Sim-to-Real は未着手。

フレームを幾何と物体に変える部品群です。ロボットが見て・測って・動くための道具箱。

import fullseye as fs
disp  = fs.disparity_map(left, right, max_disp=16)          # ステレオ視差
Z     = fs.depth_from_disparity(disp, focal=f, baseline=B)  # 深度  Z = f·B/d
pts   = fs.reproject_to_points(Z, fx=f, fy=f)               # 点群 (N,3)
grid,_= fs.elevation_map(world_pts, cell=0.05)              # 2.5D 地形ハイトマップ
ok    = fs.traversability(grid, cell=0.05, max_step=0.1)    # 足場/障害物マスク
objs  = fs.segment_objects(frame, threshold="otsu")        # 物体ごとの幾何+記述子

このコード例、実は6行で**「見る」から「歩く/掴む」の一歩手前までを通しています。左右2枚のカメラ画像(left, right)から視差 disp を求め、それを深度 Z に変換し(層③各論のステレオの節で紹介した $Z = f \cdot B / d$ そのものです)、深度を3D 点群 pts に逆投影する。そこから地形の高さマップ grid を作り、traversability(踏破可能性)で「歩ける場所・歩けない場所」を判定する。最後の segment_objects は、画像を物体ごとに分けて、それぞれの幾何情報(位置・サイズなど)と記述子(識別に使う特徴量)を取り出す――これは工場のビンピッキングで言えば「どの部品がどこにあるか」を求める処理そのものです。層①の op を、層③では目的に応じて名前を変えた薄い facade(外側のインターフェース)**として呼んでいる、という構造がこの6行に表れています。

この層にはセンサー・シミュレーション一式も含まれます。実機を持っていなくても、疑似 LiDAR・ステレオカメラ・イベントカメラ(DVS)・フォトメトリックステレオ・TSDF 融合・偏光カメラ・焦点合成といったセンサーの出力を合成シーンから作り、知覚パイプラインを実機なしで開発・検証できる ―― Physical AI 開発の"練習場"です。すべて Fullseye 自身の op の実出力です:

Physical AI センサ・シミュレーションのモンタージュ(疑似LiDAR / ステレオ深度 / イベントカメラDVS / 焦点合成 / 偏光カメラ / カメラ+IMUフュージョン)— クリックでフルサイズ

パネルごとの説明と、これ以外の処理結果(3D レンダリング・メッシュ処理・ターンテーブル GIF など)の一覧は 処理結果ギャラリー(docs/GALLERY.md) へ。

6つのセンサーをひとつずつ

モンタージュの6パネルは、それぞれ独立したセンサー・シミュレーションです。同じ MuJoCo の合成シーン(緑の直方体・黄色の円柱・青い球・オレンジの箱・紫の板)を、6種類のセンサーの目を通して見るとどう変わるか――というのがこの絵の狙いです。1つずつ、原理を3段でかみくだきながら、パネルに焼き込まれた実測値を見ていきます。

LiDAR(ライダー、光による距離測定)

  1. ひとことで:レーザー光を四方八方に飛ばし、跳ね返ってくるまでの時間(や位相)から距離を測るセンサー。自動運転車の屋根に載っている、あれです。
  2. もう少し:Fullseye のシミュレーションでは、シーンをレンジ画像(各画素が距離を持つ画像)として描き、それを3D 点群に逆投影します。実機の LiDAR がチャンネル数(垂直方向にいくつのレーザーを積んでいるか)で分解能が決まるのと同じく、シミュレーションも32chを模擬しています。
  3. 実測lidar_sim.py の出力では、2965 点・32ch・hit-ratio 26%。hit-ratio は「飛ばしたレイのうち、実際に物体に当たった割合」――残り 74% は空振り(背景や視野外に飛んだ)で、これはまばらな物体を対象にした室内シーンでは自然な値です。密な物体ばかりのシーンなら、この比率はもっと上がります。

ステレオ深度(2眼カメラでの奥行き推定)

  1. ひとことで:左右2つのカメラの映像の**ズレ(視差)**から距離を計算する、人間の両目と同じ原理のセンサー。
  2. もう少し:左右の画像で同じ模様を探し(ブロックマッチング)、そのズレ幅(disparity)を測る。近い物体ほどズレが大きく、遠い物体ほどズレが小さい――これを $Z = f \cdot B / d$(焦点距離 × 基線長 ÷ 視差)という式で距離 $Z$ に変換します。この式は層③冒頭のコード例に出てきた depth_from_disparity そのものです。
  3. 実測stereo_sim.py の出力は depth corr 0.55、median err 1.3cm。depth corr は推定深度と真の深度(MuJoCo が知っている正解)の相関、median err はその誤差の中央値です。テクスチャの薄い平面(モンタージュ中央上の白いブロック)ではマッチングが難しく、誤差が乗りやすい――これはステレオ視の古典的な弱点で、正直に数字にも出ています。

イベントカメラ(DVS、動きだけを吐くセンサー)

  1. ひとことで:ふつうのカメラのように「毎秒 30 枚の画像」を吐くのではなく、画素ごとの明るさが変化した瞬間だけ、非同期にイベントを吐くセンサー。
  2. もう少し:各画素が独立して「対数輝度がしきい値を超えて変化したら、+(明るくなった/ON)か −(暗くなった/OFF)のイベントを1個発火する」という動きをします。動きが無ければ何も吐かない――静止画は原理的に見えないが、動きには極端に強い(マイクロ秒オーダーの時間分解能、広いダイナミックレンジ)。
  3. 実測event_camera.py の出力は 247,189 イベント・edge-corr 0.56。edge-corr は「イベントが発生した密度」と「その場所のエッジ強度(Sobel 等で測った輪郭の強さ)」の相関で、輪郭に沿ってイベントが多く出ていることを裏付けます。数値としては直感の通りですが、「直感で合っていそうな設計を、実測でも裏付ける」のが honest disclosure のやり方です。

この DVS は動きで見るのが一番わかりやすいので、実際に流れる gif を後述します。

焦点合成(フォーカス・スタッキングと depth-from-focus)

  1. ひとことで:ピントの合う距離を変えて何枚も撮り、「そこだけピントが合っている」場所を各写真から集めてつなげる技法。マクロ写真や顕微鏡でよく使われます。
  2. もう少し:各焦点距離での画像から、画素ごとにシャープネス(局所的なコントラスト)を測り、一番シャープな焦点位置を採用してつなぎ合わせると、全部にピントが合った画像ができます(フォーカス・スタッキング)。同時に、「どの焦点距離で一番シャープだったか」を画素ごとに記録すれば、それがそのままその画素までの距離の推定になります(depth-from-focus)。
  3. 実測focus_stack.py の出力は、近距離(0.91m)・中距離(3.33m)・遠距離(5.74m) の3焦点から合成し、シャープネス倍率 ×1.27、depth corr 0.89。倍率 ×1.27 は「合成後の画像が、単一焦点の画像よりどれだけシャープになったか」、depth corr は「焦点から逆算した深度」と「MuJoCo の正解深度」の相関です。0.89 は、6パネル中でも高めの相関――複数の焦点距離を使う分、単眼1枚のステレオより安定するのは理にかなっています。

偏光カメラ(DoLP / AoLP、光の"振動方向"を見る)

  1. ひとことで:ふつうのカメラは光の強さしか見ませんが、偏光カメラは光の**振動方向(偏光)**も見ます。同じ明るさでも、表面の向きによって反射光の偏光の仕方が変わるので、テクスチャが無いツルツルの面でも、法線(表面の向き)が分かるという変わり種のセンサーです。
  2. もう少し:4方向の偏光子を通した画像から Stokes ベクトルを再構成し、そこから**DoLP(直線偏光度、0〜1)**と **AoLP(偏光角)を求めます。フレネルの反射式に従えば、DoLP から表面の傾き(天頂角)を、AoLP から傾いている向き(方位角)**を逆算できる、という物理モデルです。
  3. 実測polar_cam.py の出力は mean DoLP 0.79、Stokes round-trip 1.00。round-trip 1.00 は、「4方向の偏光画像から Stokes ベクトルを作り、そこから元の4方向の画像を逆算し直す」という往復変換が完全に一致したことを示します(実装の内部整合性チェック)。DoLP が高いのは、モンタージュの球体マテリアルが光沢寄りで、grazing angle(視線がほぼ真横)に近い場所ほど偏光が強く出るという物理と整合しています。

カメラ + IMU センサーフュージョン(カルマンフィルタ)

  1. ひとことで:カメラ単体の位置推定(ノイズが乗りやすい)と、IMU(加速度・角速度センサー)単体の推定(ドリフト=誤差が時間とともに蓄積する)を、お互いの弱点を補い合う形で融合する古典的な手法。
  2. もう少し:カルマンフィルタは「予測」と「観測による補正」を交互に繰り返すアルゴリズムです。IMU から動きを予測し、カメラの観測で補正する――両方とも不完全な情報源ですが、それぞれの誤差の性質(ノイズ vs ドリフト)が違うことを利用して、単体よりも良い推定を作ります。
  3. 実測sensor_fusion.py は、投げたボールの放物運動を追跡するデモで、位置センサ単体の RMSE 22.6cm、IMU デッドレコニング単体の RMSE 8.4cm に対し、カルマン融合後は RMSE 6.1cm。単体のどちらよりも融合後の誤差が小さい――これがセンサーフュージョンの効能を、数字で見せている一枚です。

6つとも原理は別物ですが、実装の足場は共通しています。外部のシミュレータやセンサー専用 SDK を呼んでいるわけではなく、どのセンサーモデルも層①の型付き op と同じ numpy のコードで書かれているという点です。LiDAR のレイキャストも、ステレオのブロックマッチングも、偏光の Stokes ベクトル計算も、突き詰めれば「配列を受け取って配列を返す関数」の組み合わせにすぎません。センサーごとに全く違う物理現象を模擬しているのに、呼び出し方も検証の仕方も統一されている――これが、層①で繰り返し書いてきた「型で繋ぐ」「md=SoT で検証する」という設計を、Physical AI というより複雑な応用先に持ち込んだときにも壊れずに機能している証拠だと考えています。

シミュレーションと工場ラインが、同じ op 体系の上にある

ここまでの6センサーは Physical AI(ロボット知覚)の文脈で紹介しましたが、同じ op がそのまま工場の検査・ピッキングラインの文脈でも使われるようにできています。

  • ステレオ視差 → 深度 → 点群(この節の stereo_sim.py と同じ経路)は、そのままばら積みピッキング(bin picking)の入力になります。実際、euclidean_cluster(点群を近接グラフの連結成分で距離クラスタリングする op)を使い、台の平面を除去してから物体をクラスタに分ける、というワークフローの worked example(examples_3d/object_segmentation.py)を同梱しています。台平面を除去せずにクラスタリングすると全物体が1塊に癒着してしまう、という「何もしない」零点との比較もちゃんと入れてあります(honest gate)。
  • LiDAR 点群のクラスタリングも同じ euclidean_cluster の守備範囲です。屋外シーンの地面除去→物体分割という、倉庫や屋外ヤードのロボットが直面する状況に対応する worked example(examples_3d/lidar_scene_segmentation.py)もあります。この例では、傾斜地面(約5.4度)の上に置かれた4物体(球・箱・円柱・円錐)の LiDAR 点群5316点を、fit_plane_ransac で地面を検出し、地面より高い点だけを残してから euclidean_cluster で分割する、という手順を踏みます。実測では検出クラスタ数が真の物体数(4個)と一致し、各クラスタの重心と真の物体との対応も最大誤差0.128mで全単射しました。地面除去を省いて全点をそのままクラスタリングすると、全物体が1つの塊に癒着してしまう――この「何もしない」場合との比較が、地面除去という一手間の効能を数字で裏付けています。
  • 把持点(grasp point)の検出には、曲率解析(principal_curvatures / gaussian_curvature)を使う経路を用意しています(curvature_grasp の worked example)。「つまみやすい場所=曲率が滑らかで凸な場所」という、掴む対象の形状から候補点を絞り込む古典的なアプローチです。
  • イベントカメラ(DVS)は、高速搬送ラインでぶれずに動きだけを捉えるという用途で工業側とも相性が良いセンサーです(原理上モーションブラーが存在しないため)。

シミュレーションの中で育てたロボット視覚のパイプラインが、そのまま検査ラインの部品として転用できる――これは"おまけ"ではなく、同じ型付き op 体系の上に両方が乗っているという設計の直接の帰結です。

その帰結を、実際にシミュレーションで組んで動かした画で示します(すべて MuJoCo 物理・実レイキャスト・実レンダ上の実処理。クラスタ数・物体数は真値と照合済み)。

bin picking ―― 深度セグメントと把持候補の採点

bin picking の前段 ―― 部品 10 個を物理シミュレーションで箱に落とし、真上の深度カメラで観測 → 深度セグメント → 「周囲クリアランス+高さ」で把持候補 8 件を採点(緑=最優先)。把持ジョーの向きは長方形フィットの長軸から。使用 op: segment_objects, fit_rectangle2, colorize_depth

さらに、この採点結果から 6 自由度 IK で実際に掴んで搬出するフルサイクルも動画にしてあります → phai_bin_pick.mp4(GitHub でインライン再生)。接着なしの素の物理で、箱の外に出た部品だけを成功と数えて 3 個搬出成功、という正直な集計です。

LIDAR 点群 → 地面除去 → クラスタリング

LIDAR 障害物認識 ―― リング型 LIDAR を模して 2 万本超のレイを実際に飛ばし、RANSAC 地面除去 → ユークリッドクラスタリングで物体 6 個が 6 クラスタに。各クラスタに OBB を当てて鳥瞰表示。使用 op: remove_ground, euclidean_clusters, obb

ステレオ視差 → 3D 復元 → 鳥瞰障害物マップ

ステレオ障害物マップ ―― 視差 → $Z = f \cdot B / d$ で奥行き → 3D 点群 → 高さ 12cm 超をクラスタリングで 4 物体が 4 クラスタに(復元した地面の高さ誤差は中央値 3mm)。視差は speckle フィルタ+信頼度ゲート後の「使う視差」だけを表示し、無効画素はグレーでマスクしています。使用 op: disparity_subpixel, disparity_confidence, euclidean_clusters

焦点合成 ―― ボケた 7 枚から全焦点 1 枚

焦点合成 ―― ピント位置を振った 7 枚から各画素で最もシャープな 1 枚を選んで合成(鮮鋭度は単写比 1.27 倍)。顕微鏡検査・基板検査で使われる仕組みです。

この中の1つ、イベントカメラ(DVS) ―― 画素ごとの輝度変化だけを非同期に吐くセンサー ―― は、動きで見るのが一番わかりやすい。カメラがパンすると、エッジに沿って ON イベント(シアン)と OFF イベント(マゼンタ)が流れます:

イベントカメラ(DVS)シミュレーション ―― パンに伴い ON(シアン)/OFF(マゼンタ)イベントがエッジに沿って発生

より滑らかな mp4 版と、イトカワ等のターンテーブル動画は、リポジトリの docs/articles/assets/media/ に置いてあります(GitHub 上でそのまま再生できます)。

3D をここまで自作している話(いちばんの差別化ポイント)

Fullseye の差別化がいちばん出るのは 3D 系だと思っています。2D の古典画像処理は、OpenCV や scikit-image という強力な OSS がすでに広くカバーしています。そこで numpy の再実装を積むだけでは、正直「車輪の再発明」と言われても仕方がない部分があります。一方 3D は、点群・メッシュ・ボリュームという複数のデータ形式にまたがり、しかも「型付き・純 numpy・機械可読ノート付き」という条件を揃えたまま網羅しているライブラリは、私が調べた限りではあまり見当たりませんでした。だからこそ、この節では他の層より少し長く、実データでの実演にページを割きます。

冒頭の GIF に出てきた小惑星イトカワの実点群に、3D op を実際に当てるとこうなります:

小惑星イトカワ実点群への 3D op(曲率解析 / ICP 自己レジストレーション / PCA 正準姿勢)— クリックでフルサイズ

実点群 3000 点に対して、曲率解析(近傍曲率の相関 r=0.87 ―― 実在表面である証拠)、ICP 自己レジストレーション(未知の 30° 回転+ノイズから回転誤差 0.027° で復元)、PCA 正準姿勢(未知の 50° 回転後も主軸を完全回復)。全部この場で実行した実測値です。

4パネルをもう少し丁寧に見ておきます。

  • ①生点群itokawa_points.npy は3000点、外接寸法は 559×301×242 m――イトカワは長径600m弱の細長い"ラッコ型"小惑星で、この寸法もその形状をよく表しています。色は原点(重心付近)からの距離で塗っており、岩石らしい陰影は3D レンダラのシェーディングによるものです。
  • ②曲率解析curvature3d.curvedness は、各点の曲がり具合を数値化する op です(層③の各論で改めて触れる曲率の一種)。実測は curvedness の平均 0.0102・標準偏差 0.0059、そして近傍点との曲率の相関 r=0.87。この相関が高いことの意味を言葉にすると――「隣り合う点は似た曲がり方をしている」という、実在する連続した表面ならではの性質です。もしこれがただのランダムな点の集まりだったら、曲率はてんでバラバラで、相関はゼロに近くなるはずです。r=0.87 は、この点群が本物の岩の表面から採取されたデータであることの、間接的だが定量的な証拠になっています。
  • ③ ICP 自己レジストレーション:同じ点群に未知の 30° 回転とセンサーノイズを加えたコピーを作り、元の点群に位置合わせ(ICP、Iterative Closest Point)できるかを試します。実測は 回転誤差 0.027°、RMSE 4.27m。0.027° がどれくらい小さいかというと、1周 360° のうち 1/13000 程度のズレで、ノイズを乗せた条件下としてはほぼ完全な復元と言っていい精度です。
  • ④ PCA 正準姿勢:点群の主慣性軸(3本の直交する軸、モーメント・オブ・イナーシャの固有ベクトル)を求め、未知の 50° 回転を掛けた後でもその軸を復元できるかを試します。実測は 主軸比 4.02:1(一番長い軸と次に長い軸の固有値比――イトカワの細長さを表す数値)、軸復元 |cos| ≥ 1.0000(50° 回転後も主軸の向きを完全に一致させて復元)。

3枚とも同じ 3000 点の実点群に対して、別の角度から実在性・再現性を検算しているという点が肝です。曲率解析は「表面として一貫しているか」を、ICP は「同一物体として同定できるか」を、PCA は「向きが分からなくなっても復元できるか」を、それぞれ別の数学(微分幾何・最近点探索・固有値分解)で確かめている。3D op のカバレッジをただ数え上げるより、同じデータに複数の角度から刃を入れて実演する方が、説明できるビジョンの価値を伝えられると思っています。

レンダラも自前 ―― 「見た目」を測定可能な部品に分解する

本記事の冒頭に置いた1枚(SDF smooth union + AO + ソフトシャドウ + ACES)や、イトカワのターンテーブル GIF は、どちらも自前のレンダラの出力です。ここで大事なのは、「見た目が綺麗」ということそのものより、「見た目」を作っている要素を1つずつ独立した op に分解してあることです。処理結果ギャラリー(docs/GALLERY.md)には、それぞれの要素を単体で確認できる図版が並んでいます。

  • アンビエントオクルージョン(AO):頂点ごとに半球方向へレイを飛ばし、周囲に遮られている度合いを測って、接触部や凹部を選択的に暗くする手法です。影というより「そこに物があることで光が届きにくくなっている場所」を可視化する、と考えると分かりやすいと思います。
  • シェーディング(陰影づけ):法線マップに対する Phong 鏡面ハイライトと、MatCap(環境をあらかじめ焼き込んだ球状テクスチャで質感を近似する手法)シェーディングを別々の op として持っています。
  • シャドウマッピング:光源から見えるかどうかを判定してキャストシャドウ(物体が落とす影)を作り、面光源を近似してソフトシャドウ(輪郭がぼやけた影)も出せます。
  • SSAA(スーパーサンプリング・アンチエイリアス):解像度を上げてレンダリングしてから縮小することで、メッシュの輪郭のジャギー(ギザギザ)を消します。
  • トーンマッピング:HDR(露出の幅が広いレンダリング結果)を、そのままだと白飛び・黒つぶれする箇所を Reinhard や ACES という手法で階調を保ったまま通常の表示レンジに収めます。素朴なクリップ(単純に明るすぎる値を切り捨てる)との比較で、階調が残ることを実測で確認しています。

これらは「見た目のための飾り」ではなく、それぞれが独立して検証できる opです。AO なら「遮蔽されている頂点ほど暗くなっているか」、トーンマッピングなら「白飛びしていた領域の階調が復元されているか」――どちらも数値で確認できます。冒頭のヒーロー画像1枚の裏には、型付き op として書かれた測定可能な部品が積み重なっている、という点は、層①で書いた「型(sort)という背骨」の思想が、レンダリングという一見遠い分野にまで一貫して適用されていることの表れです。

3D op は現在 346 個。点群・メッシュ・ボリューム・SDF を跨いで、SHOT / FPFH / スピンイメージといった 3D 特徴記述子TSDF 融合縞投影(fringe projection)フォトメトリックステレオスーパークアドリク当てはめメディアル軸測地距離ビジュアルハルQEM メッシュ簡略化(境界保存・多様体厳格) まで揃えています。

同じイトカワ実点群に基本の 3D op を順に当てたサンプラーも1枚にまとめてあります(法線推定・shape index・ボクセル間引き 3000→635 点・OBB・凸包 ―― 全部実行結果です)。

3D op サンプラー ―― イトカワ実点群に法線推定 / shape index / ボクセル間引き / OBB / 凸包(クリックでフルサイズ)

↑ 3D op サンプラー ―― OBB の外形実測は 281×149×122 m。教科書の図ではなく、この小惑星の実データで測った数字です。

いくつか実物を:

接触した2物体を距離変換+分水嶺(watershed)で分離。連結成分ラベリングでは1個に融合してしまうケース

↑ 3D 分水嶺(watershed)分割 ―― くっついた 2 物体を距離変換の「谷」で切り分ける。ビンピッキングで部品が重なる、あの状況の道具です。

QEM edge-collapse による境界保存・多様体厳格なメッシュ簡略化の実測比較

↑ QEM メッシュ簡略化の実測比較。「境界を保つ」「多様体を壊さない」を実測で確認しながら削る ―― この op は本記事のバグ⑥の舞台でもあります(後述)。

手続き生成した手の骨格をボクセル化 → marching_cubes → 骨色マテリアルで回すターンテーブル(掌を上に寝かせた標本展示風)

↑ 手続き生成した手の骨格(手根骨8・中手骨5・指骨14=計27骨、名前の話の節に出てきた hand_hero.png と同じ被写体)の SDF を占有ボクセル格子に落とし込み、marching_cubes(等値面レベル 0.5)でメッシュ抽出して骨格標本風に回したもの。掌を上にして寝かせ、俯瞰で1回転させています。ボリューム→メッシュ→レンダまで一気通貫です。

個々のアルゴリズムは PCL(C++)や Open3D に散在しますが、「純 numpy・型付き・全 op に機械可読ノート付き」で1つのライブラリがこの範囲を持っている構成は、あまり見ないと思います。産業の縞投影計測からロボットの把持姿勢まで、同じ書き味で届くのが狙いです。

試すための 3D データは、公開ソースから無料で手に入ります。定番どころ:

ライセンスと直 DL URL を整理した一覧はリポジトリの docs/ops/SAMPLES.md にあり、sample_data.download('bunny', yes=True) の1行で取得できるものはコード側にも登録済みです(未取得なら明示エラー、勝手に拾わない fail-closed 設計)。

この層の"お客さん第一号"が evis(筋骨格ヒューマノイド)です。evis の視覚パイプラインは、ステレオ → 深度 → 点群 → セグメント → 6自由度(6DoF)姿勢 → 動作計画 → 700筋での実現、という流れ。ロボットに箸を使わせる・歩かせるといった課題の"目"を、この層が担います。

その最初の 2 段「ステレオ → 深度」を、evis 自身の両眼で実演した映像がこれです(クリックで動画再生):

evis の両眼キャプチャに Fullseye の disparity_sgm → depth_from_disparity を毎フレーム適用(クリックで mp4 再生)

↑ ▶ evis が箸で豆を打つ ChopMimic シーンを、evis 自身の両眼カメラ(瞳孔間距離 64mm)で撮った実験キャプチャ 241 フレームに、Fullseye の disparity_sgm → speckle_filter → fill_disparity → depth_from_disparity を毎フレーム適用した実出力。左=evis の左眼映像、中央=Fullseye が計算した視差、右=深度。下段 HUD の「豆までの距離」は左右眼の segment_objects 重心視差から $Z = f \cdot B / d$ で読み出した値で、シミュレータ真値との誤差は 229 フレームで中央値 0.66%・最大 1.91%(豆が箸に隠れた 12 フレームは「bean not in view」と正直に表示)。

大事にしているのは OSS を再発明しないこと。PCL / OpenCV / MoveIt2 といった標準は、忠実性とカバレッジの地図+薄いアダプタとして使う。統一インターフェースの裏に隠すので、使う側は「中身が自作 numpy か OSS ラッパか」を気にせず、同じ書き味で呼べます。

「HALCON カバレッジの物語」の節で書いた**「何を作らないかを決める」**という判断は、ここにも顔を出しています。PCL や MoveIt2 が既にカバーしている領域(点群処理の一部・動作計画の一部)を丸ごと自前実装で置き換える必要はない。Fullseye がそこに足すべき価値は「同じ書き味で呼べるようにする」ことであって、「もう1つの実装を増やす」ことではありません。何を numpy で自前実装し、何を薄いアダプタで済ませるかの線引きは、設計思想の4本柱すべてに関わる判断で、ここでも honest disclosure の精神――「これは自前実装、これは OSS ラッパです」と、機械可読なノート(層①の md=SoT)で常に区別して開示する――が効いています。


Fullseye Studio ―― 見て・試して・仕事で使う IDE

Status: Production-ready ―― v0.1.3 に同梱。この章のスクリーンショットはすべて実画面です。

アルゴリズムを揃えるだけでは足りません。「見て確かめる」層が要る。それが Fullseye Studio です。位置づけは:

産業ビジョンの HDevelop(2D 画像の IDE)+ ロボットの RViz2(3D 知覚の可視化:点群・深度・6DoF姿勢の軸・地形レイヤ)の融合。

  • 左にオペレータ/サンプル一覧、中央にコード編集+パラメータのスライダ、右に描画ペイン。サンプルを選ぶ → コードが出る → Run で即描画、スライダで即再描画。
  • 約1000個の op それぞれに生成済みヘルプページが付き、2D も 3D も同じヘルプ辞書から引けます(この総集編の作業で 3D 側も統一しました)。
  • 画像上を hover すると画素座標と値が出る、検出領域を入力画像に重畳する ―― といった HDevelop 由来の"検査"の作法も入れています。
  • そして Python Editor(Qt Creator 風の編集+実行環境)。以前はサンプルコードを読むだけ・パイプラインとして呼ぶだけでしたが、いまはギャラリーの worked example を編集可能なエディタに開いて、書き換えて、F5 で即実行できます(構文ハイライト・行番号・実行コンソール付き。実行はサブプロセスなので UI は固まりません)。HDevelop の「メイン+サブスクリプト」と同じく複数のスクリプトをタブで同時に開いて編集でき、サンプルは独立した MDI ウィンドウとして何枚でも並べて、断片を選択コピーしながら書けます。「サンプルをそのまま動かす → 少し変えて自分の問題に寄せる」という学習と実務の間の階段を、Studio 内で完結させるための層です(この総集編の作業で追加)。
  • デバッガ級の実行制御と変数ウォッチも入れました。ブレークポイントで止め、実行行から再開(Continue)任意の行からやり直し(Run from here)。変数は右クリックで中身をポップアップ検査でき、ウォッチ式v.mean()np.percentile(v, 99) など任意の式)が選択変数に対してパイプラインの変更のたびに自動再評価されます。
  • 複数の描画ウィンドウをスクリプトから制御できます。HDevelop と同じ流儀で、プログラム中の dev_open_window (行, 列, 幅, 高さ) がウィンドウを開いて配置し、dev_set_window で切り替え、dev_set_window_extents で動かせる。画像処理の実務では「入力・中間・結果を別ウィンドウに並べる」が日常なので、これをコードで再現可能にしています(開き過ぎ防止の上限は環境設定で調整可能、既定 256)。

実際の画面がこれです(モックアップではなく、studio.build_window() が組み立てた実 UI をそのまま撮ったもの)。左に結果ビュー(ホイールでズーム、ドラッグでパン)、下に Program パネル、右に演算子ブラウザという構成で、op を選んでつなぎ、ノブを回すと結果がライブ更新されます。

Fullseye Studio メイン画面

↑ Studio メイン画面 ―― coins サンプルに blob 分割パイプライン(gaussian → otsu → opening_circle → sk_clear_border)を適用し、検出した 21 枚のコインを region overlay で重畳した状態。ステータスバーに「21 obj」。

タブ・エディタ ―― HDevelop の「メイン+サブスクリプト」を継ぐ

HDevelop を触ったことがある人向けに、Studio の Python Editor がどう対応するかを整理しておきます。

HDevelop では、1つのメインプログラムから複数のサブプログラム(サブルーチン)を呼び出し、それぞれを別タブで編集する、という書き方が定番です。Studio の Python EditorFile ▸ Python Editor…、またはギャラリーの「Open in editor」)も同じ発想で、複数の Python スクリプトを同時にタブで開いて編集できます。F5(Run)で現在のタブをサブプロセス実行――サブプロセスなので、重い処理を回してもメインの UI は固まりません。リポジトリが PYTHONPATH に自動で乗るので、import fullseye はどのタブでもそのまま動きます。未保存のバッファはscratch コピーとして実行されるので、「試しに書き換えて動かしてみる」ために毎回セーブを強制されることもありません。

Studio Python Editor

↑ Python Editor ―― itokawa_curvature.py を F5 実行した直後(PASS, exit 0)。下部コンソールに実データの曲率統計がストリームされている。

もう1つ、MDI コード窓(ギャラリーの「Open in window」)という選択肢もあります。こちらはサンプルを独立したウィンドウとして何枚でも並べられ、Window ▸ Tile/Cascade で整列もできる。タブは「順番に読み書きする」用途、MDI 窓は「複数のサンプルを見比べながら断片をコピーする」用途――と、使い分けが効くようにしてあります。

変数ウォッチとデバッガ級の実行制御

HDevelop の強みの1つは、プログラムを1行ずつ実行しながら変数の中身をその場で覗けることです。Studio にも同種の機能を入れました。

  • ブレークポイント:gutter(行番号の左)をクリックすると、その行で実行が一時停止します。
  • Continue:止まった実行行から、次のブレークポイントか末尾まで再開。
  • Run from here / Run to here:ステージを右クリックして、任意の行から実行をやり直す(あるいはそこまで実行する)。パイプラインの後半だけを何度も試したいときに、頭から毎回流し直さずに済みます。
  • 変数ウォッチ:Variables 窓に、v.mean()np.percentile(v, 99)(v > 0.5).sum() のような任意の式を登録できます(v が選択中の変数、np が numpy、img が入力画像)。この式は、選択を変えるたびに・パイプラインを変えるたびに自動で再評価されます。式が失敗しても、その行に警告マークが出るだけでパネル全体は落ちません。
  • 右クリック ▸ Inspect in popup…:変数を右クリックすると、型別のインスペクション(shape・min/max/mean・パーセンタイル・値のプレビュー)がポップアップで即座に出ます。

正直な注意点も1つ書いておきます。変数ウォッチの式はGUI スレッドで同期評価されるため、巨大な配列を全走査するような重い式を登録すると、その間 UI が待たされます。重い集計をしたいときは、式を軽くするか、Python Editor 側で実行するのが実務上のコツです。

手で回して確かめる ―― 3D surface(Ctrl+3)

Studio の右パネルには、現在の結果を回転可能な 3D サーフェスで開く機能があります(DISPLAY & PERCEPTIONCtrl+3、内部的には Qt の Q3DSurface を使った best-effort 実装)。使い方は単純です。深度マップ・地形ハイトマップ・曲率で着色した高さ場など、画素ごとに1つの高さ値を持つ結果を表示しているときに Ctrl+3 を押すと、その結果が別ウィンドウで立体の地形として開きます。

ここからは、マウスをドラッグすると視点がぐるっと回りホイールを回すと寄り引きできる――カラーマップを睨んで「たぶんここが凹んでいる」と目を凝らす代わりに、実際につまんで角度を変え、光の当たり方が変わるのを見ながら凹凸を確かめられるわけです。ステレオ深度の誤差が乗りやすい場所、地形ハイトマップの段差、曲率マップの尾根と谷――こういうものは、静止した1枚のカラー画像だと平坦に見えてしまうことがあります。同じデータを回して光を当て直すと、影のでき方でぐっと分かりやすくなる。「結果を静止画で眺める」から「結果を手に取って確かめる」への一段は、地味ですが実務では効きます(GPU の実オフスクリーンコンテキストが取れない環境では安全に無効化される best-effort 機能で、無ければ静かに使えないだけです)。高さは地形風のグラデーション(低=深青 → 緑 → 砂色 → 頂=白)で着色されるので、どこが高くどこが低いかは色でも読めます。

Studio 3D surface ビュー(イトカワ)

↑ Ctrl+3 の 3D surface ビュー ―― 小惑星イトカワの実形状モデル(JAXA はやぶさ / Gaskell モデル)を深度レンダした起伏。アプリ内ではこのシーンをそのままマウスドラッグで回し、ホイールで寄れます(画像は同一 GL シーンの renderToImage 静止画)。

メッシュも点群も回せる ―― 対話 3D ビューア(Ctrl+4)

3D surface(Ctrl+3)は高さ場専用でしたが、この総集編の作業の最終盤で、メッシュと点群をそのままマウスで回せる対話 3D ビューアCtrl+4、または View メニュー)を追加しました。左ドラッグで軌道回転、ホイールでズーム、Shift+ドラッグでパン、R でリセット、W でワイヤフレーム切替です。

Studio の対話 3D ビューア ―― イトカワ 49,152 面メッシュ

↑ 対話 3D ビューアでイトカワ実メッシュ(24,578 頂点・49,152 面)を表示したところ。マウスで回しながらランバート照明の当たり方で起伏を確かめられます。

実装は正直に書くと GPU を使わないソフトウェアラスタライザです。理由は消去法ではなく実測で選びました ―― GL 実装は CI のオフスクリーン環境でテスト経路が完全に死に、リモートデスクトップでも脆弱。ソフトウェア実装ならテストと実機が同一コード経路を通ります。実測は 20 万点で 66ms、100 万点で 349ms(480px)―― 100 万点はフル解像度では対話的と言えないので、ドラッグ/ズーム中は 25 万点への均一間引きプレビュー(HUD に正直表示)+離した瞬間フル再描画、という作りです。メッシュ描画も頂点+面重心のランバート splat で、塗り潰しレンダではありません(高品質静止画は既存の render3d.render_mesh の役目)。

(なお初版の奥行きソート反転バグは、公開前の敵対的レビューで実証・修正し、遮蔽を検証する回帰テストで固定済みです。)

領域⇔特徴量を行き来する ―― Feature Inspection(Ctrl+F5)

HDevelop の定番機能に、ラベリングした複数領域の特徴量を表で検分するツールがあります。同じものを Studio にも入れました(Ctrl+F5、Tools メニュー)。

Feature Inspection ―― 領域⇔特徴量テーブルの双方向対応

↑ 2D Feature Inspection ―― チェックリストで特徴量(面積・重心・真円度・離心率・グレー統計など、既存の regionprops/gray_features 実装を再利用)を選ぶと、行=領域・列=特徴量のソート可能テーブルに。行を選ぶと画像上の該当領域が amber でハイライトされ、逆に画像をクリックするとその領域の行へジャンプします。CSV コピー付き。

3D Feature Inspection ―― クラスタごとの特徴量+ビューアハイライト

↑ 同じダイアログの 3D タブ ―― 点群を euclidean_clusters でクラスタリングし、クラスタごとの点数・重心・extent・OBB 寸法(ops3d の既存 op の範囲のみ)を表に。行を選ぶと埋め込みの対話 3D ビューアで該当クラスタが強調表示されます。

スクリプト側からも同じ流儀で使えます。HDevelop の disp_image / disp_object_model_3d に相当する disp 系ディレクティブdisp_image (n) / disp_region (n) / disp_points3d ('file') / disp_mesh3d ('file'))を dev ウィンドウ体系に追加したので、プログラム中から「入力・中間・結果を別ウィンドウに並べる」が 2D でも 3D でも書けます(Python API は studio.disp_points3d(P) 等。副作用を持つ表示系なので、純変換の op レジストリとは意図的に分けてあり、HALCON カバレッジの数字にも算入していません)。

System settings ―― 設定ツリー

Tools ▸ System settings…Ctrl+,)は、カテゴリツリー形式の設定画面です。Execution(ワーカースレッド数・operator タイムアウト)、Windows(グラフィクス窓の上限)、Display(既定のカラーマップ・領域の描画方式)、Editor(フォントサイズ・Python Editor の実行インタプリタ)――と、HDevelop の set_system に相当する設定が1画面にまとまっています。Ctrl+PCommand palette からも、任意のアクションや任意のオペレータを名前でファジー検索して即実行できるので、メニューを辿らずキーボードだけで一通りの操作が完結します。

ギャラリーとヘルプ ―― 105 本の worked example と 265 op のリファレンス

3D 側は 105 本の worked example(実イトカワ点群・骨格ボリューム・合成データ)をギャラリーから選んでその場で Run でき、265 の 3D op それぞれに生成済みヘルプページ(シグネチャ・使い方・検証済みサンプルへのリンク・型が繋がる次の op)が付いています。

Studio 3D Examples ギャラリー

↑ 3D Examples ギャラリー ―― itokawa_curvature を選択して Run した直後(Output にグラウンドトゥルース検証つきの PASS)。

Studio 3D Operators リファレンス

↑ 3D Operators リファレンス ―― icp_point2plane のヘルプページ。

検査ラインの実務目線で1つ流れを追う

ここまでの機能が、実務でどう繋がるかを1つの流れで書いておきます。HDevelop で検査プログラムを組んだことがある人向けの想定です。

左パネルの OPERATORSshape_locatem1_measure_pos を検索し、ダブルクリックでパイプラインに挿入する。中央の SELECTED STAGE・KNOBS でつまみ a, b を動かしながら、右の IMAGE パネルで結果を都度確認する。しきい値が決まったら、Ctrl+E(Export)で --ops 文字列と Python 関数の両方を書き出し、そのまま自分の検査プログラムに埋め込む。途中でパラメータの当たりを付けるのに手間取ったら、変数ウォッチregion.sum()np.percentile(v, 95) を登録して、しきい値をスライダーで動かしながら統計量がどう変わるかをリアルタイムに見る。うまくいかない箇所があれば、ステージを右クリックして Run from here でそこだけをやり直す――HDevelop の Program ウィンドウでブレークポイントを置きながら検査プログラムを組んだことがある人には、この一連の流れはそのまま馴染みがあるはずです。

設計は Studio、実行はコードという分担は、HDevelop から HDevEngine へ書き出す流れと同じ発想です。試行錯誤は GUI で、量産ラインへの組み込みは Export した Python 関数か JSON で――両者が同じ op・同じパラメータを共有しているので、**「Studio で確認した通りに現場で動く」**ことが構造として保証されます。

この流れに、「AI に画像処理をさせられる」の節で紹介した RAG を重ねると、もう一段短くなります。Claude Code に「この画像から傷を検出するパイプラインを組んで、Studio で確認できるようにして」と頼めば、AI が docs/ops からパイプラインの候補を書き、Studio の JSON 形式で保存する――そこから先を人間が Studio 上で微調整する、というAI が叩き台を作り、人間が Studio で仕上げる分業も、同じ op・同じパラメータ空間を共有しているからこそ成立します。設計思想の節で書いた「統一インターフェース」が、Studio・AI・量産ラインという3つの現場を、同じ言葉で繋いでいるわけです。


紙面の科学館 ―― op で遊ぶ 151 の展示

ここで少し肩の力を抜いて、科学館・博物館の展示室を巡るつもりで眺められるコーナーを置いておきます。以下はすべて Fullseye の登録 op の実出力で、モックアップは1枚もありません。素材の来歴は 2 種類に分かれます:

  • 実データ:Smithsonian(CC0)・メトロポリタン美術館(CC0)・NASA(public domain)の公開データのみ。キャプションに出典リンクを付けています。(当初 CC-BY 表記で細胞顕微鏡データセットを 1 点使っていましたが、公式ページを確認したところ実際は CC BY-NC-SA で本リポジトリのライセンスと両立しないため、公開前に展示から外しました ―― こういう確認と撤回も honest disclosure の一部です。)
  • AI 生成の模擬データ:ライセンスがクリーンな実データを見つけにくい分野(医学画像など)は、画像生成 AI(Google gemini-2.5-flash-image)で素材を作り、画像内・キャプションの両方に「AI 生成」と明記しています。実在の標本・患者・スキャンではありません。

主役はあくまで処理の方です。各キャプションに使用 op を書いてあるので、「この絵はどの op で作れるのか」から逆引きできます。フル解像度と追加の展示は処理結果ギャラリー(docs/GALLERY.md)へ。

科学館ウィング ―― 画像処理の原理を「きれいな絵」で

距離変換の虹の波紋

距離変換の虹の波紋 ―― コイン写真を白黒に分け、「ふちから何ピクセル離れているか」を虹色で塗ると波紋のような等高線が浮かぶ。使用 op: otsu, fill_up, distance_transform

フーリエの世界

フーリエの世界 ―― 画像を周波数で見ると「どんな細かさの模様がどの向きにあるか」が光の点になる。規則正しい織り目は星座のように光る(織り目パネルのみ合成)。使用 op: fft_image

watershed ―― コインのぬりえ分割

watershed のぬりえ分割 ―― 水が低い所へ流れて溜まる様子をまねて、コインを 1 枚ずつ別の色に。使用 op: otsu, distance_transform, watersheds, colorize_labels

エッジの方位磁針

エッジの方位磁針 ―― 輪郭の向きを色相環の色で塗ると、同じ向きの線が同じ色に光る。使用 op: sobel_amp, sobel_dir

単純ルールから生まれる 6 つの宇宙

単純ルールから生まれる 6 つの宇宙 ―― 「となりのマスを見て自分の色を決める」だけのルールから、フラクタル・カオス・砂山マンダラ・樹枝・珊瑚もようが生まれる(シミュレーション画像)。使用 op: alife_wolfram1d, alife_sandpile, alife_dla, alife_lenia, alife_cyclic_ca

トリケラトプスのレントゲン写真

トリケラトプスのレントゲン写真 ―― Smithsonian の骨格標本実スキャン(CC0)をボクセルに詰めて最大値投影(MIP)すると、レントゲン写真そっくりになる。肋骨も角も写る。使用 op: voxelize, vol_gaussian, vol_mip

赤青メガネで飛び出すドラゴン

赤青メガネで飛び出すドラゴン ―― Stanford dragon 実スキャンを 2 視点からレンダして赤シアンで重ねたアナグリフ。赤青メガネで浮き上がります。使用 op: read_mesh, look_at, render_mesh

トリケラトプス山脈

トリケラトプス山脈 ―― 骨格標本を 60 万点の点群にして真上から標高地図を作ると、背骨が山脈、肋骨が尾根になる。ロボットが地形を読むのと同じ op です。使用 op: sample_surface, elevation_map, colorize_height

形が育つ・痩せる(モルフォロジー)

形が育つ・痩せる ―― 膨張(dilation)でコインがぷくぷく育って合体し、収縮(erosion)で痩せる。工場の画像検査でも使う基本の op です。使用 op: dilation_circle, erosion_circle

空間がぐにゃり ―― 3 つの変形アルゴリズム

空間がぐにゃり ―― 画像の下に見えないゴムのシートがあると思って、TPS / FFD / MLS という 3 つの流儀でつまんで引っぱった結果。使用 op: deform_tps, deform_ffd, deform_mls

恐竜の影絵から骨格を取り出す

恐竜の影絵から骨格を取り出す ―― トリケラトプス骨格標本の影絵から中心線(スケルトン)を 1 ピクセル幅で抽出。足・角・しっぽが針金細工のように残る。使用 op: sk_skeleton, distance_transform ほか。

博物館ウィング ―― 学問分野を横断する 30 の展示

続いて分野別の展示室です。医学・考古学・生物学・宇宙・古生物学・地質学・気象学・海洋学・植物学 ―― どの分野の画像にも、同じ op 体系がそのまま刺さることを見てもらうコーナーです。ここから先のキャプション表記は上と同じルール(実データは出典リンク、AI 生成は明記)です。

古生物学

アンモナイト化石の螺旋抽出

↑ アンモナイト化石(Smithsonian Open Access, CC0)の螺旋を canny で抽出。使用 op: rgb1_to_gray, canny, overlay_mask

ティラノサウルス生体復元の皮膚テクスチャ解析

↑ ティラノサウルス生体復元の皮膚テクスチャを std_filter / texture_laws で解析。素材は AI 生成(gemini-2.5-flash-image)の模擬データ(実在の標本ではありません)。

トリケラトプス生体復元の multi-Otsu 分類

↑ トリケラトプス生体復元を multi-Otsu で領域分類。素材は AI 生成の模擬データ。使用 op: xsk2_multiotsu, colorize_labels

羽毛恐竜の羽毛流れ解析

↑ 羽毛恐竜の羽毛の流れを Gabor フィルタで解析。素材は AI 生成の模擬データ。使用 op: sk_gabor, std_filter

アンモナイト断面の対数螺旋 FFT

↑ アンモナイト断面の対数螺旋を FFT スペクトルで観察。素材は AI 生成の模擬データ。使用 op: cv_clahe, cx_fft, cx_magnitude

三葉虫の体節を浮き彫り強調

↑ 三葉虫の体節を gray_tophat で浮き彫り強調。素材は AI 生成の模擬データ

宇宙

カリーナ星雲のフィラメント抽出

↑ カリーナ星雲(NASA/STScI Webb, public domain)のフィラメント構造を、本来は血管強調用の sk_frangi で抽出。医学の op が天文に刺さる例。

火星 Nili Patera 砂丘のテクスチャ解析

↑ 火星の砂丘(NASA/JPL-Caltech/Univ. of Arizona, public domain)のテクスチャを std_filter / texture_laws で解析。

ひまわり銀河の FFT スペクトル

↑ 渦巻銀河(NASA GSFC, public domain)の周波数構造を cx_fft で可視化。

医学(このブロックはすべて AI 生成の模擬データです)

胸部X線風画像の強調とエッジ抽出

↑ 胸部X線画像を cv_clahe + sobel_amp で強調・エッジ抽出。AI 生成の模擬データ(実在の患者・スキャンではありません)。

H&E 組織切片風画像の multi-Otsu 分類

↑ H&E 組織切片画像を multi-Otsu で組織構造分類。AI 生成の模擬データ

脳 MRI 風画像のコントラスト強調

↑ 脳 MRI 画像を cv_clahe + unsharp で組織コントラスト強調。AI 生成の模擬データ

血液塗抹風画像の血球計数

↑ 血液塗抹画像の血球を分割・計数(検出数 131)。AI 生成の模擬データ。使用 op: segment_objects(otsu), colorize_labels

解剖図風イラストの輪郭抽出

↑ 解剖図イラストの輪郭を canny で抽出。AI 生成の模擬データ

生物学

神経細胞の樹状突起トレース

↑ 神経細胞蛍光像の樹状突起を sk_frangi でトレース。AI 生成の模擬データ

珪藻の分割と計数

↑ 珪藻顕微鏡像を分割・計数(検出数 123)。AI 生成の模擬データ

深海アンコウの暗部増強

↑ 深海生物の暗部を cv_clahe で増強。AI 生成の模擬データ

蝶の翅鱗粉の周期構造解析

↑ 蝶の翅の鱗粉の周期構造を sk_gabor で解析。AI 生成の模擬データ

考古学

土器シルエットの楕円フーリエ記述子

↑ アンフォラ(メトロポリタン美術館 Open Access, CC0)のシルエットを楕円フーリエ記述子(EFD)で形状復元。2 → 8 → 32 高調波と増やすほど輪郭に吸い付いていく ―― 考古学の土器形状分類で実際に使われる手法です。使用 op: otsu, fourierdesc.elliptic_fourier, fourierdesc.reconstruct

石碑レリーフの浮き彫り強調

↑ アッシリアの石碑レリーフ(メトロポリタン美術館, CC0)の彫刻を gray_tophat で浮き彫り強調。

洞窟壁画の顔料強調(DStretch 手法)

↑ 洞窟壁画の消えかけた顔料を decorrelation stretch(岩絵調査の定番 DStretch と同系の手法)で強調。AI 生成の模擬データ。使用 op: principal_comp

楔形文字粘土板の刻印強調

↑ 楔形文字粘土板の文字刻印を gray_tophat で強調。AI 生成の模擬データ

地質学・気象学・海洋学・植物学

衛星画像の岩相 decorrelation stretch

↑ 衛星画像(NASA JSC, public domain)の岩相を decorrelation stretch(リモートセンシングの定番)で強調。

鉱物結晶のファセット稜線抽出

↑ アメジスト結晶のファセット稜線を canny で抽出。AI 生成の模擬データ

岩石薄片の鉱物粒子分類

↑ 岩石薄片(偏光顕微鏡風)を multi-Otsu で鉱物粒子に分類。AI 生成の模擬データ

ハリケーンの渦構造の勾配方向ホイール

↑ ハリケーン(NASA JSC, public domain)の渦構造を勾配方向ホイール(sobel_dir + colorize_flow)で可視化。

スーパーセル積乱雲の構造強調

↑ スーパーセル積乱雲を cv_clahe + unsharp で構造強調。AI 生成の模擬データ

サンゴ礁の被覆分類

↑ サンゴ礁を multi-Otsu で被覆分類(海洋調査風)。AI 生成の模擬データ

シダ葉脈の抽出

↑ シダの葉脈を sk_frangi で抽出。AI 生成の模擬データ

花粉 SEM 風画像の分割と計数

↑ 花粉 SEM 画像を分割・計数(検出数 41)。AI 生成の模擬データ

この 41 展示のうち、実データにはすべて出典とライセンス(詳細な帰属表はACADEMIC_ATTRIBUTION.md)を、AI 生成には全点にその旨を付けました。おまけをひとつ ―― この「多様な実データを流す」作業は、それ自体がバグ発見器でもありました。合成データでは表面化しなかった op の不具合が実データで 5 件見つかり、公開前にすべて修正済みです(発見の経緯と回帰テストは docs/KNOWN_ISSUES.md)。きれいな展示の裏で、テストにもなっている ―― という一石二鳥でした。


2D 古典オペレータ・ウィング ―― 教科書の op を、数字で確かめる

紙面の科学館 —— 2D 古典オペレータ・ウィング(14 の展示)

既存の「科学館ウィング(11 点)」「博物館ウィング(30 点)」と題材が重ならないよう、
古典的な 2-D オペレータだけで組んだ一角です。すべて Fullseye の登録 op の実出力で、
素材は合成か skimage.data(BSD / public domain)。キャプションの数字は生成時の実測値で、
docs/articles/assets/_wing2d_meta.json に生の配列が入っています。

並べ方は 3 通り: タイル(並べて比べるもの)、フリップブック GIF(同じ寸法で工程が
進むもの)、掃引 GIF(軸ラベルつきのグラフが主役のもの)。1 枚・1 本を 1 展示と数えています。

再生成: py -3.11 tools/gen_wing2d_gallery.py(展示名を指定するなら --subjects <name,...>)。

1. 形態学の 4 兄弟 —— どれが何を消すのか

形態学の 4 兄弟 —— どれが何を消すのか

形態学の 4 兄弟 —— どれが何を消すのか ―― 幅 2/4/6/8/10 px の棒と幅 2/4/6 px のスリットを刻んだ図形に、4 つの形態学 op を半径 1→4 px で当てた。膨張は面積を 39148→47296 px に増やし、収縮は 33212→25456 px に減らす。開は面積をほぼ保ったまま細い棒だけを落とし (r=1 で 4/6/8/10 px が生き残り、r=4 では 10 px だけ)、閉は細い隙間だけを埋める (r=1 で幅 2 px、r=4 で幅 2/4/6 px のスリットが消える)。使用 op: threshold, erosion_circle, dilation_circle, opening_circle, closing_circle, morph_grad

2. 周波数フィルタの効き

周波数フィルタの効き

周波数フィルタの効き ―― 同じ写真にローパス・ハイパス・バンドパスを当て、遮断周波数を 0.05→0.45 (正規化) で掃引した。ローパスの遮断を 0.05 から 0.45 へ上げると 元画像との PSNR は 22.33→36.13 dB。一方その通過帯に入っているスペクトルエネルギーは遮断 0.05 の時点ですでに 98.27% —— 「エネルギーのほとんどは低周波にあるのに、見た目は高周波が決めている」という画像の癖がそのまま数字に出る。帯域を落とせば応答は必ず符号を持つが、highpassbandpass_image はその 0 を 0.5 に写した [0,1] を返す —— この掃引の全 9 点で最小値は 0.0201 / 0.0205、負の画素は 0.0% だった (画素の約半分が無言で黒に潰れる実装ではもう無い)。使用 op: fft_image, lowpass, highpass, bandpass_image

3. ノイズ除去の比較 —— median / bilateral / NLM

ノイズ除去の比較 —— median / bilateral / NLM

ノイズ除去の比較 —— median / bilateral / NLM ―― 同じ写真に σ=0.020→0.220 の白色ノイズを乗せ、median・bilateral・non-local means を固定パラメータで当てて PSNR を実測した 6 パネル。弱いノイズ (σ=0.020) では bilateral が 30.00 dB で最良だが、強いノイズ (σ=0.220) では median が 23.09 dB で逆転する —— 「どれが一番強いか」はノイズ量と設定次第で、掃引の途中で順位が 2 度入れ替わった。ノイズ画像そのものは 34.04→14.34 dB。同じ画像を estimate_noise に渡すと 0.0263→0.1920 が返り、真値の 131%→87% にあたる —— 上端に張り付く点は 0/9 で、σ が 3 倍違えば返り値も違う (この op は σ そのものを返す契約になった)。使用 op: add_noise_white, median, bilateral, sk_nlm, estimate_noise

4. ヒストグラム整形 —— clahe の clip limit を振る

ヒストグラム整形 —— clahe の clip limit を振る

ヒストグラム整形 —— clahe の clip limit を振る ―― 文書画像のコントラストを 1.00→0.16 倍まで潰していき、equalizeclahe で戻せるかを追った。見どころは clahe の第 2 引数 b = clip limit(ビン平均カウントに対する倍率 256^b。b=0 → ×1 = 強調ゼロ、b=1 → ×256 = 切り取りが一度も効かない素の AHE、OpenCV 既定の clipLimit=40 は b≈0.665 相当)。入力の標準偏差が 0.2228→0.0356 まで落ちるとき、b=0.00 は 0.2169→0.0537、b=0.50 は 0.2403→0.1709、b=1.00 は 0.2379→0.2510。同じ 1 枚に対する b=0 と b=1 の画素差は最大 0.7169 まで開く —— つまみ 1 本で「入力の潰れをどこまで無視して持ち上げるか」が決まる。equalize は画像全体を 1 枚の写像で平坦化するので 0.2931→0.2994 と最後まで幅を保つが、その代わり照明ムラは残ったままだ。使用 op: equalize, clahe, gray_histo_abs, entropy_gray

5. 楕円フーリエ記述子 —— 何次で形が戻るか

楕円フーリエ記述子 —— 何次で形が戻るか

楕円フーリエ記述子 —— 何次で形が戻るか ―― 1557 点の輪郭を楕円フーリエ記述子に直し、高調波を 1 次から 24 次まで足しながら復元した。1 次 (楕円 1 個) では最近傍 RMS 誤差 25.39 px、15 次で 1 px を切り、24 次では 0.45 px。誤差が大きく落ちるのは 2・4・6 次を足したときで、偶数次を足したときの平均低下 1.955 px に対し奇数次では 0.134 px しか下がらない —— r = 146 + 40sin3θ + 20cos5θ + 12sin9θ という作り方が、閉曲線としては n±1 次(= 偶数次)に現れるためだ。使用 op: gen_region_polygon_filled, gen_contour_region_xld, elliptic_fourier, reconstruct

6. 対応点モーフ —— 単純合成との違い

対応点モーフ —— 単純合成との違い

対応点モーフ —— 単純合成との違い ―― 対応点 11 個 (輪郭の楕円上 8 点 + 両目 + 口) だけを与えて顔 A から顔 B へモーフさせた 6 パネル。対応点を使わない単純合成は途中で二重像になるが、piecewise affine と TPS は輪郭も目も口も対応させたまま連続的に動く。両端は入力を厳密に再現し (α=0 で A と PSNR 99.0 dB、α=1 で B と 99.0 dB = 完全一致の上限値)、2 つのワープ方式の差は α=0.5 で平均 0.00802 にとどまる。使用 op: morph (imagemorph), warp_piecewise_affine, warp_tps_image, blend

7. ブロブ解析 —— 真円度で粒を選り分ける

ブロブ解析 —— 真円度で粒を選り分ける

ブロブ解析 —— 真円度で粒を選り分ける ―― 円 8 個・楕円 1・四角 1・板 2・三角 1 を混ぜた合成シーンを二値化 → 穴埋め → ラベル付けし、blob_count が 13 個と数えた。真円度 (circularity) 0.85 をしきい値にすると採用 8 個 (真円度 0.912〜0.916)、不採用 5 個 (0.416〜0.797) にきれいに割れる —— 特徴空間の散布図でも 2 つの群がしきい値をまたいで重なっていない。5 コマ目の十字は area_center が返した中心で、この op は (面積比, 行, 列) の 3 成分を [0,1] 正規化して返す —— 画素に戻して独立に計算した重心と比べると差は最大 0.000 px、13 個すべてが自分の粒の上に乗った。使用 op: threshold, fill_up, blob_count, colorize_labels, circularity, eccentricity, rectangularity, area_center

8. サブピクセル計測 —— 画素より細かく測る

サブピクセル計測 —— 画素より細かく測る

サブピクセル計測 —— 画素より細かく測る ―― ガウスぼけしたエッジの真の位置を 0.05 px 刻みで 1 画素ぶん動かし、measure_pos の推定と「勾配が最大の画素」を比べた。サブピクセル推定の誤差は RMS 0.0119 px・最大 0.0170 px、画素単位の推定は RMS 0.282 px・最大 0.50 px。同じ画像・同じエッジで 24 倍の差が出る —— 画素の格子は、測れる細かさの限界ではない。使用 op: gen_measure_rectangle2, measure_pos (m1_measure_pos)

9. 形状マッチング —— 回っていても見つける

形状マッチング —— 回っていても見つける

形状マッチング —— 回っていても見つける ―― 96×96 px のテンプレートから作った形状モデルで、23° ずつ回した部品 (探索格子 5° の倍数を避けた角度) を 16 枚のシーンから探した。5° 刻みで角度も探索させると、角度の誤差は最大 2.0°(探索格子 5° の半分 = 2.5° がそもそもの下限)、位置の誤差は最大 0 px、スコアは最低でも 0.864。1 シーンあたり約 2.4 秒(CPU、72 角度ぶんの探索を含む)。使用 op: create_shape_model, find_shape_model (角度探索つき)

10. 帳票の傾き補正 → 二値化 → バーを数える

帳票の傾き補正 → 二値化 → バーを数える

帳票の傾き補正 → 二値化 → バーを数える ―― 合成の帳票を 0→42° と傾けながら、回転角を 0.5° 刻みで振って「行方向プロファイルの分散が最大になる角」を探した。推定誤差は全域で最大 0.0°(11° のときは真値どおり 11.0°)で、補正後の decode_barcode はどの傾きでも真値の 8 本を返す。補正しないと 30° で 7 本に落ち、42° では 5 本まで取りこぼす —— 前処理を 1 段挟むかどうかで、同じ op の答えが変わる。なお rotate_image は reshape=False + mode='reflect' なので、回すと四隅に元の文字が鏡映で折り返して写り込む(この展示では消していない)。使用 op: rotate_image, otsu, decode_barcode

11. 輪郭の当てはめと残差

輪郭の当てはめと残差

輪郭の当てはめと残差 ―― 縁が 72 px 欠けた円と直線に、輪郭からの当てはめを掛けた 6 コマ。輪郭の全点で当てると半径は真値 210.0 px に対し 206.95 px (誤差 -3.05 px、残差 RMS 12.96 px) —— 欠けの縁が当てはめを引っ張っており、残差 3σ を超える 91 点を落として当て直すと 209.19 px (誤差 -0.81 px、RMS 6.21 px) まで戻る。直線は真値 73.20° に対し 73.21°(誤差 +0.006°)で、「当てはまった値」より「合わなかった場所」の方が情報が多い。使用 op: threshold, opening_circle, gen_contour_region_xld, sobel_amp, fit_circle, fit_line

12. 色空間ツアー —— どの空間なら分けられるか

色空間ツアー —— どの空間なら分けられるか

色空間ツアー —— どの空間なら分けられるか ―― 同じ赤で塗った 2 つの円を、左は 0.35 倍・右は 1.0 倍の明るさで照らした合成シーンを 6 チャンネルで見た 9 パネル。1 本のしきい値で赤い 2 円を取り切れるかを IoU で測ると HSV の H (色相)・Lab の a (赤-緑) が 1.000 に届き、Lab の L (明るさ) は最良でも 0.250 —— 明るさを含むチャンネルでは、同じ色が照明で 2 つに割れてしまう。なお HSV の H は cv2 由来で 0..179 を 255 で割った値、つまり度÷510 で返る(純緑 120° が 0.2353 —— 実測して確かめた単位)。使用 op: trans_from_rgb, access_channel, rgb1_to_gray

13. テクスチャの見分け —— 特徴量で模様を分ける

テクスチャの見分け —— 特徴量で模様を分ける

テクスチャの見分け —— 特徴量で模様を分ける ―― 3 種類の模様を 64×64 px の小片 48 枚に切り分け、GLCM energy・entropy・標準偏差・ノイズ推定・4 方向の Gabor 応答の 8 個を特徴量にして leave-one-out の最近傍重心で分類したところ 47/48 = 97.9% が正解だった。見た目が似ていても、GLCM energy は 0.236 / 0.148 / 0.212 と離れている —— 「模様」は数字にできる。Gabor の 4 方向は、模様が向きを持つかどうかをそのまま映す: レンガは横目地が効いて θ=0°/45°/90°/135° の平均応答が 0.01072 / 0.00680 / 0.02997 / 0.00676 と θ=90° に偏り (最大÷最小 4.43 倍)、縦横が同居する織り目は 0.01763 と 0.01778 でほぼ同値、方向を持たない 1/f 粒状も同じ 1.35 倍にとどまる。向きで割れない織り目と粒状を分けているのは応答の絶対値で (0° の平均が 0.01763 対 0.01292)、これは gabor がカーネルの L1 ノルムで割る固定スケールを返すから残る量だ —— 画像ごとの最大値で割る正規化では、向きごと・模様ごとに別の数で割ってしまいこの差は消える。使用 op: cooc_feature_matrix, entropy_gray, gray_histo_abs, estimate_noise, gabor, sk_lbp

14. 回し続けると何が失われるか (リサンプリング損失)

回し続けると何が失われるか (リサンプリング損失)

回し続けると何が失われるか (リサンプリング損失) ―― 同じ画像に 10° の回転を 36 回かけると、幾何としては一周して元の向きに戻るのに、画素は戻らない。中央部だけで測っても元画像との PSNR は 26.81 dB、中央の「細かさ」(画像 − ローパスの標準偏差) は元の 64.4% まで落ちる(画像全体では 23.98 dB。その差の大半は端の処理 —— rotate_image は reshape=False + mode='reflect' —— によるもので補間の損失ではない)。ついでの実測として zoom_image_factor / zoom_image_size / rescale_img の 3 op は それぞれ別実装で、同じ入力 (a=0.9, b=0.5) に対する最大差は factor↔size 0.973 / factor↔rescale 0.966。使用 op: rotate_image, gauss_image, zoom_image_factor, zoom_image_size, rescale_img

3D 計測ウィング ―― ボクセルと点群を「測る」

紙面の科学館 ―― 3-D 計測ウィング

ボクセルと点群を 測る ための一角です。すべて Fullseye の登録 op の実出力で、素材は合成データのみ(実データ・AI 生成素材は使っていません)。図に焼き込んだ数字は 1 つ残らずその場の計算結果で、乱数は seed 20260902 固定なので再生成でバイト列が一致します。

断層(スライスを送る)ものは静止画にせず GIF にしてあります。各コマに位置・単位・実測値を焼いてあるので、止めた 1 コマだけでも読めます。

処理領域(domain)でメモリが 1/84 になる

処理領域(domain)でメモリが 1/84 になる ―― 192³ の視野に浮かぶ合成部品を輪切りで送りながら、元ボリューム・domain マスク・切り出し後・貼り戻しを並べた。前景は全体の 0.42 % しかないので、vol_crop_domain でメモリは 56.62 MB → 0.678 MB(1/83.5)、同じ vol_gradient_magnitude が触る voxel も 7,077,888 → 84,747(同じく 1/83.5)。壁時計の実行時間も実測しているが、再生成のたびに変わるので図には焼かず _wing3d_meta.json に置いた。vol_uncrop の貼り戻しは元と bit 一致。 使用 op: vol_bounding_box, vol_crop_domain, vol_reduce_domain, vol_uncrop, vol_gradient_magnitude

境界だけ持つと 6 % に痩せる

境界だけ持つと 6 % に痩せる ―― 中実の球(267,731 voxel)を vol_boundary で内側 1 層の殻にすると 6.1 %(16,418 voxel)まで痩せる。その殻を vol_boundary_points で mm 座標の点群にして fit_sphere3 に渡すと、中心誤差 0.000 mm(真値 (25.6, 25.6, 25.6) mm)。半径だけは -0.175 mm ずれる — 殻が「内側 1 層」だからで、これは消さずに図に書いてある。 使用 op: vol_boundary, vol_boundary_points, fit_sphere3

run-length で 1/71

run-length で 1/71 ―― 256³ の合成部品を run-length で持つと 1/71(16.78 MB → 0.237 MB、19,764 run)。しかも展開せずに体積 1,610,948 voxel を dense を一度も作らずに返し、集合演算(球 ∪ 軸 = 1,508,456 voxel)も run のまま解ける。decode の往復は bit 一致。所要時間も実測してあるが、壁時計は再生成のたびに変わるので図には焼かず _wing3d_meta.json に置いた。 使用 op: vol_rle_encode, vol_rle_decode, vol_rle_volume, vol_rle_bbox, vol_rle_centroid, vol_rle_union, vol_rle_intersect, vol_rle_difference

CT の「窓」で同じ体が 3 通りに見える

CT の「窓」で同じ体が 3 通りに見える ―― 同じ合成 HU ボリュームを vol_window_level の 3 つの窓で見る。軟部組織窓では体積の 67.1 % が黒へ潰れ肋骨は白飛びし、骨窓では白飛びが 0.0 % まで下がって骨梁が読め、肺野窓では黒潰れが 0.0 % で肺の中身が出る。下の折れ線が窓そのもの(HU → [0,1] の一次写像 + クリップ)。 使用 op: vol_window_level

Frangi 対 Sato ―― 否定対照(粒状度)を並べて初めて分かる

Frangi 対 Sato ―― 否定対照(粒状度)を並べて初めて分かる ―― 管 1 本と球 2 個だけの合成 CT に、管状度 2 種と粒状度 1 種を掛けた。vol_frangi は管を球より 1.26 倍強く出すが、vol_sato0.97 倍でほとんど区別しない。否定対照の vol_hessian_blobness0.32 倍 = 管より球を選び、向きがきれいに逆転する。「血管が光った」だけでは管状度の証明にならない、という当たり前を図にした。 使用 op: vol_frangi, vol_sato, vol_hessian_blobness

3-D スケルトンをグラフにする

3-D スケルトンをグラフにする ―― 合成した枝分かれ構造(8,690 voxel)を skeletonize_vol に通すと 192 voxel の 1 voxel 幅の針金になる(2.21 %)。そこから枝 4 本・分岐 1 か所・端点 4 点をグラフとして取り出した。白が分岐、ローズが端点、枝は連結成分ごとに色分け。ターンテーブルで1 周するとつながり方が読める。 使用 op: skeletonize_vol, skeleton_branches3d, skeleton_junctions3d, skeleton_endpoints3d

virtual probe で壁厚 2.000 mm(真値 2.000 mm)

virtual probe で壁厚 2.000 mm(真値 2.000 mm) ―― 外径 10.000 mm / 内径 8.000 mm の合成パイプにプローブを 1 本だけ刺す。vol_edge_probe が 4 つのエッジをサブサンプル精度で拾い、vol_wall_thickness が立ち上がり→立ち下がりの対から壁厚 2.0000 mm / 2.0000 mm(真値 2.000 mm)を返す。平滑化 sigma を 3.0 まで上げると 2.1252 mm (+6.3 %)に太る — ノイズ対策がそのまま寸法の偏りになる、という測定の基本も一緒に。 使用 op: vol_profile_line, vol_edge_probe, vol_wall_thickness

Richardson-Lucy ―― 前方一貫性 0.033x に対し真値 RMSE は 0.689x

Richardson-Lucy ―― 前方一貫性 0.033x に対し真値 RMSE は 0.689x ―― sigma 2.0 のガウス PSF でぼかした合成ボリュームを vol_richardson_lucy で反復復元する。復元をもう一度ぼかして観測と比べる前方一貫性は 0.033 倍まで一気に落ちるのに、真値との RMSE は 0.689 倍までしか下がらない。残っているのは球のふちの階段で、「観測をよく説明できた」ことは「真値に近い」ことではない ―― という反例をそのまま展示にした。 使用 op: vol_gaussian_psf, vol_richardson_lucy

visual hull ―― 影を重ねて形を削り出す

visual hull ―― 影を重ねて形を削り出す ―― L 字の合成物体を 16 方向から撮ったシルエットで visual_hull を彫る。1 枚では真の体積の 5.12 倍という柱状の塊だが、枚数を足すと 16 枚で 1.24 倍(IoU 0.755)まで縮む。ただし L 字の凹みは何枚重ねても埋まらない ―― これは実装の粗さではなく visual hull の原理的な限界で、収束先が真値でないことが図から読める。 使用 op: visualhull.look_at, synthesize_silhouette, visual_hull

外から抱く箱(OBB)と中に入る箱(inner_box3)

外から抱く箱(OBB)と中に入る箱(inner_box3) ―― z 軸まわりに 30° 傾けた合成直方体(13,617 voxel)に 3 つの箱を同時に描いてターンテーブルで 1 周させた。軸平行の AABB は voxel 数の 1.99 倍まで膨らむのに、obb(PCA で向きを合わせた外接箱)は 0.94 倍まで縮み、半幅は 19.99 / 10.00 / 8.00 voxel (真値 20 / 10 / 8)とほぼ真値。1 倍を切るのは、点群が voxel の「中心」の集まりでその外接を測っているから(縁の半 voxel が入らない)。逆に inner_box3 の最大内接箱は 0.32 倍まで痩せる。掴み幅を決めるなら OBB、中を部品が通るかを見るなら内接箱。 使用 op: obb, inner_box3, vol_bounding_box

点群レジストレーション ―― ICP 29 回 / GICP 5 回

点群レジストレーション ―― ICP 29 回 / GICP 5 回 ―― 合成した表面点群(3100 点)を 22° 回して平行移動したものを、icp_point2point_3d で戻す。初期 RMSE 3.804 が 29 反復で 1.8e-14 まで落ち、復元した姿勢の誤差は回転 1.7e-06 度・並進 5.6e-15。面の共分散を使う gicp は同じ答えに 5 反復で着く。 使用 op: icp_point2point_3d, gicp

異方性ボクセル ―― spacing を忘れると体積が 4.17 倍

異方性ボクセル ―― spacing を忘れると体積が 4.17 倍 ―― z だけ粗い spacing (1.5, 0.4, 0.4) mm/voxel でサンプリングした合成だ円体(真の体積 19301.9 mm³)。vol_region_props に spacing を渡せば 19273.2 mm³(-0.15 %)だが、渡し忘れると 80305(+316 %、4.17 倍)になる。例外は飛ばない。もっともらしい数字が静かに返るだけ、というのがこの展示の要点。 使用 op: vol_label, vol_region_props, vol_boundary_points

MIP と X 線投影のターンテーブル

MIP と X 線投影のターンテーブル ―― 合成 CT ボリューム(96³)を render_volume_projection で 1 周させた。左は最大値投影(MIP、骨窓)で光線上の最大値だけを拾うので骨が浮き、右は減衰積算(X 線)で厚みが出る。投影 投影は 72 枚(所要時間の実測は _wing3d_meta.json)。正規化の上限は全フレーム共通にしてある ―― 1 枚ごとに正規化すると回転中に明るさがちらついて、形の変化と見分けがつかなくなる。 使用 op: vol_window_level, render_volume_projection

距離変換で局所の太さを測る(最大内接半径 4.528 mm)

距離変換で局所の太さを測る(最大内接半径 4.528 mm) ―― 合成した 3 本の管に vol_distance_transform を掛けると、各ボクセルが「ふちから何 mm 離れているか」になる。その最大値が最大内接球の半径 = 局所の太さで、実測 4.5277 mm(真値 4.500 mm、差 +0.0277 mm — 離散格子でふちが半 voxel 内側に来るぶん)。虹の等高線は 0.5 mm ごと。 使用 op: vol_distance_transform

連結性の定義だけで殻の厚みが 1.9 倍変わる

連結性の定義だけで殻の厚みが 1.9 倍変わる ―― 半径 30 voxel の合成球(112,931 voxel)の殻を、vol_boundaryconnectivity(6 / 18 / 26)と side(inner / outer)だけ変えて 6 通り取った。面だけ触れる 6 近傍の内側殻は 9,170 voxel(8.12 %)、斜めの接触も数える 26 近傍の外側殻は 17,570 voxel(15.56 %)で、同じ形なのに 1.92 倍違う。「表面のボクセル数」という言い方が定義抜きでは意味を持たない、という 6 枚。手前半分を切って厚みが見えるようにしてある。 使用 op: vol_boundary

CT のかたまりが寸法になるまで(7 工程)

CT のかたまりが寸法になるまで(7 工程) ―― ノイズ付きの合成 CT(176³、spacing 0.6 mm)が寸法になるまでの 7 工程をコマ送りに束ねた。窓 → 二値化 → ラベリング(連結成分 1 個)→ 最大成分(5816.9 mm³、球形度 0.4702)→ vol_crop_domain でメモリ 1/7.7 → 細線化(枝 5 / 分岐 2 / 端点 4)→ 距離変換で最大内接半径 4.8374 mm(真値 4.800 mm)。各コマに工程名と進捗が焼いてあるので、止めた 1 コマでも読める。 使用 op: vol_window_level, vol_label, vol_region_props, vol_crop_domain, vol_uncrop, skeletonize_vol, skeleton_branches3d, skeleton_endpoints3d, skeleton_junctions3d, vol_distance_transform

断層を送る ―― z = 48 / 95 は 38.40 mm のこと

断層を送る ―― z = 48 / 95 は 38.40 mm のこと ―― 合成 CT(96×128×128、spacing (0.8, 0.3, 0.3) mm)を 1 スライスずつ 96 コマ送る。各コマに添字と物理位置の両方(z = 48 / 95 = 38.40 mm)と位置バーを焼いた。1 スライス送りは 0.80 mm、面内 1 画素は 0.30 mm = 0.37 倍なので、下の折れ線のとおり「添字を 1 つ動かす」は軸ごとに違う距離を意味する ―― 異方性 CT でいちばん踏みやすい段差。 使用 op: vol_window_level

3 直交断面(MPR)とクロスヘア

3 直交断面(MPR)とクロスヘア ―― 同じ 1 点を 3 方向から見る MPR。axial(vol[z])・coronal(vol[:, y, :])・sagittal(vol[:, :, x])を横に並べ、らせん状の目印を追いながら 3 本のクロスヘアを同時に動かした。各パネルにどの軸が横でどの軸が縦かを書き、+x に球・-y に横棒・+z にリングという非対称なランドマークを入れてある ―― 軸の入れ替わりや左右反転が起きたら、この 3 つの位置がずれて必ず露見する(3 直交断面そのものは配列の基本スライス、コントラストは vol_window_level、線は imagedraw の op)。 使用 op: vol_window_level, imagedraw.draw_line

斜めに切ると円が楕円になる(長径は 1/cos で伸びる)

斜めに切ると円が楕円になる(長径は 1/cos で伸びる) ―― 半径 5.00 mm の合成円柱を、切断面を 0° から 70° まで倒しながら切る(vol_rotate の逆回し)。短径は角度によらず 10.000 mm のままなのに、長径は 2r / cos θ に沿って伸び、70° では 29.238 mm = 2.92 倍になる。36 角度(0°〜70°)すべてで理論値との差は最大 0.0000 mm(0.00 画素)。「斜めの断面で測った直径」をそのまま寸法にしてはいけない、という一本。 使用 op: vol_rotate

CT の窓を掃引する ―― 見えるものは窓が決めている

CT の窓を掃引する ―― 見えるものは窓が決めている ―― 同じ 1 枚の断面に vol_window_level の窓だけを 70 通り当てる。center を動かすと明るさの基準が、width を動かすと捨てる範囲が変わる。各コマに center / width の実数値と、黒潰れ・白飛びの割合、6 つの組織が「いま何色に見えるか」を焼いた。軟部窓では骨が 1.00 で飽和し、骨窓では軟部と肺が 0 付近に沈む ―― どちらも情報を捨てている、というのが 1 本で見える。 使用 op: vol_window_level

等値面のしきい値で面が育ち、くびれ、割れる

等値面のしきい値で面が育ち、くびれ、割れる ―― 2 つの球をぼかして重ねた合成ボリュームに voxel_to_mesh(marching cubes)を掛け、level を 0.06 から 0.82 まで 40 段階で動かした。表面積は 6679 → 2842 voxel² へ縮み、level 0.742 を超えると 1 つだった面が 2 つに割れる。各コマに level・頂点数・三角形数・表面積・連結成分数を焼いてある。しきい値を書かない 3D 計測は再現できない、ということでもある。 使用 op: voxel_to_mesh, mesh_area

管に沿って切る ―― 軸に直交しないと内径が 1.13 倍に太る

管に沿って切る ―― 軸に直交しないと内径が 1.13 倍に太る ―― 28° 傾いた合成管(中央に狭窄)を 49 断面ぶん送る。軸に直交する断面で測った短径は真の内径をほぼそのまま返す(平均誤差 0.0206 mm)のに、素朴に z 方向へ切った断面の長径は 1/cos θ = 1.133 倍に伸びて平均 0.5776 mm ずれる。狭窄部では真値 2.801 mm が素朴断面では 3.217 mm ―― 狭窄が浅く見えてしまう。 使用 op: vol_rotate

天体スタッキング・ウィング ―― 理論の限界を、絵で見る

重ねると雑音は sqrt(N) で減る

重ねると雑音は sqrt(N) で減る ―― 合成星野なので真値が分かっており、雑音は残差そのもので測れる。1 枚の残差 RMS は 16.507 e-(空 200 + 読み出し 8 e- から予測される 16.248)で、64 枚まで倍々に重ねると改善は sqrt(N) から最大 1.1 % しか外れない。下段の右 2 枚は残差そのものを同じ尺度で塗ったもので、1 枚では画面いっぱいに散っていたものが 64 枚では見えなくなる ―― 星の位置には何も残っていない(発散配色。赤緑の対は使っていない)。使用 op: synth_frame_series, sigma_clip_stack, noise_sigma

lucky imaging —— 品質点で並べ替える

lucky imaging —— 品質点で並べ替える ―― 大気が良い瞬間ほど、同じ総フラックスが少ない画素に集まる。だから選別基準は「基準星のピーク割合 x 真円度」で、これは露出やゲインを変えても動かない。16 枚を点の高い順に並べたのが上位 8 枚で、点と FWHM の相関は*-0.964**(FWHM 3.29 〜 6.21 px)。青が採用、灰が不採用。使用 op: synth_frame_series, frame_quality, lucky_select。*

上位何 % を採るか —— 鋭さと雑音の取引

上位何 % を採るか —— 鋭さと雑音の取引 ―― 全部(16 枚)から上位 12 %(2 枚)まで絞ると、合成後の FWHM は 4.319 -> 3.294 px と 23.7 % 良くなる。ただし枚数が 8 分の 1 になるので背景の雑音 σ は 3.517 -> 8.675 e- と 2.47 倍に増える(sqrt(16/2) = 2.83 倍という予測とほぼ一致)。lucky imaging は「改善」ではなく取引であり、その両側を同じ図に出すのが正直な出し方。なお雑音の指標には「真値との残差 RMS」を使っていない —— この実験はわざとフレームごとに FWHM を変えているので、残差には PSF のずれまで入ってしまい、増えたのが雑音のせいか像が変わったせいか区別できない。使用 op: lucky_select, sigma_clip_stack, frame_quality

宇宙線の消え方 —— 尖りで見分ける / 枚数で見分ける

宇宙線の消え方 —— 尖りで見分ける / 枚数で見分ける ―― 宇宙線は光学系を通っていないので星より尖る。ラプラシアンを 2 倍標本化して微細構造と比べると、植えた 44 画素に対し 39 画素を検出して適合率 0.949 / 再現率 0.841 ―― 星の中心を 1 つも拾わないことが要点。合成なので「宇宙線だけ無い同じ観測」を作れて、正解からのずれそのものを測れる: 100 e- 以上ずれた画素は 44 -> 9、ずれの総量は 84 % 除去される。ここでフレームの最大値を指標にしてはいけない —— それは一番明るい星の値で、除去の前後で 7000 -> 7000 e- と動かない(再現率が 1 未満である限り、見逃した 1 画素が最大値を押さえ続ける)。枚数がある場合はもっと簡単で、8 枚を素直に平均しても宇宙線は 1/8 に薄まって残り正解から 1750 e- ずれるのに対し、κ-σ 合成は検出も置換もせずに 45 e-、フレーム間比較で先に除去すれば 7 e- になる。使用 op: synth_starfield, cosmic_ray_reject, cosmic_ray_reject_stack, sigma_clip_stack, star_detect

drizzle は面積を保存する

drizzle は面積を保存する ―― 入力画素を一回り縮めた「しずく」として出力格子へ面積比で撒くので、しずくが格子の内側にある限り総和は動かない。pixfrac 1.0 / 0.7 / 0.4 x 倍率 x1〜x4 の 12 通りすべてで相対誤差は最大 6.3e-15 ―― これは「ほぼ保存」ではなく倍精度の丸めそのもの。被覆マップ wht の平均が pixfrac の 2 乗にきっちり一致するのも、撒き方が面積で定義されていることの裏取りになる。入力の総和は 306035.5635 e-。使用 op: synth_frame_series, drizzle_resample

drizzle —— しずくを小さくすると像が立ち上がる

drizzle —— しずくを小さくすると像が立ち上がる ―― 真の FWHM 1.15 画素、つまりナイキストを破った星野を 24 枚、1.5 画素のディザで撮る。1 枚では FWHM 1.357 画素にしか見えず、そのまま平均すると 1.991 画素とかえって鈍る(ずれを平均するから)。同じずれを drizzle に渡すと pixfrac 1.0 / 0.6 / 0.3 で 1.574 / 1.450 / 1.399 入力画素まで立ち上がり、そのあいだ総フラックスは縁から出た 0.76 % 以外一切動かない。しずくを小さくするほど鋭くなる代わりに覆われない出力画素が出る(被覆 wht の最小が 0.041 まで下がる)—— これが drizzle の唯一の調整点。使用 op: synth_frame_series, drizzle_resample, sigma_clip_stack, frame_quality

間隔 1.6 画素の二重星

間隔 1.6 画素の二重星 ―― sigma 0.55 画素の星を 2 つ、1.6 画素だけ離して 24 枚ディザ撮影する。平均合成では 1 個しか立たないのに、同じ生データを drizzle x3 (pixfrac 0.4)に通すと 2 個に分かれる。解像度は「上げた」のではなく、ディザという形で既に撮れていた情報を捨てずに拾っただけ。「分かれた」を主観にしないため、対を横切る行の谷の深さも測ってある: 平均合成 0.0 %(谷が無い)に対し drizzle は 5.9 %。4 枚目は同じ drizzle の生の sci(被覆で割っていない像)で、そこに検出をかけると被覆の格子が 200 個の偽の星になる ―― 総フラックスを保存する像と、目で見る像は別の量である。使用 op: drizzle_resample, sigma_clip_stack, star_detect

σ クリップの破綻 —— 折れ目はちょうど 50 %

σ クリップの破綻 —— 折れ目はちょうど 50 % ―― 20 枚のうち先頭 k 枚に +900 e- の汚染を入れ、割合を 0 から 60 % まで上げていく。45 % までは誤差 -0.080 e- と、汚染ゼロのとき(-0.034 e-)と変わらない答えを返す。ところが ちょうど 50 % で誤差は +450.0 e-(汚染量のちょうど半分)、55 % で +900.0 e-(汚染量そのもの)に跳ぶ。これは実装の不具合ではなく中央値の破綻点そのもので、半数を超えた時点で中央値が汚染側の母集団に乗り、クリップは正しいフレームの方を捨てる(棄却率は 47.4 % のまま働いているのに、捨てる側が入れ替わっている)。最後のコマの折れ線がその証拠で、中央値そのものも同じ 50 % で折れる(55 % で +883.6 e-)一方、単純平均は最初から汚染に比例してずれ続ける(+495.0 e-)。直せない限界は、直せるふりをせずそのまま展示する。使用 op: synth_frame_series, sigma_clip_stack

位置合わせ —— 星は互いに見分けがつかない

位置合わせ —— 星は互いに見分けがつかない ―― 星野に記述子マッチングは効かない。星は全部同じ形なので Lowe の比検定がほとんど全部を捨ててしまう。代わりに使うのは配置の幾何 ―― 全ペアの差ベクトルを投票させ、最頻値を粗い平行移動とし、既存の 2-D 点対応 RANSAC で誤対応を落として Umeyama で当てはめる。9 枚・最大 6.0 画素のディザで、ずれの推定誤差は中央値 0.0359 画素(内点 中央値 33 対応、残差 RMS 0.112 画素)。位置合わせせずに平均すると FWHM 5.310 px、合わせてから平均すると 3.077 px。使用 op: star_detect, frame_align, align_frames, sigma_clip_stack, frame_quality

既知フラックスを測り返す

既知フラックスを測り返す ―― 合成星の総フラックスは 10000 e- とこちらが決めた値で、erf による画素の厳密積分で描いてあるので画像の総和もそれに一致する。半径 8 sigma の開口で測ると 4 つの尺度すべてで誤差 0.0000 % ―― 文字どおり測り返す。半径 3 sigma に絞ると -0.968 % 〜 -0.095 % の負のずれが残るが、これはバグではなく画素化である: 開口の縁の画素を「画素平均 x 面積比」で代表すると、円の内側ほど明るいぶん必ず少なく出る。ずれが sigma の2 乗で消える(sigma 1.0 -> 3.0 で 10.1 倍小さくなる)ことがその証拠。使用 op: synth_starfield, aperture_photometry

断層撮影ウィング ―― 投影から作る側

投影からボクセルまで ―― CT の一本道

投影からボクセルまで、CT の一本道 ―― ファントム → 投影 → サイノグラム → 再構成 → 窓 → 分離 → ボクセル → メッシュ の 8 工程。体積が閉形式で分かる部品(真値 16839 mm³)を 128 本の投影から作り直すと 16896 mm³(+0.3%)になった。再構成 nRMS 0.0177、メッシュ 67744 面、境界点群 27696 点。使用 op: radon_volume, fbp_volume, vol_window_level, vol_label, vol_region_props, marching_cubes, vol_boundary_points

投影数を増やすと像が立ち上がる

投影数を増やすと像が立ち上がる、が体積はそれを教えない ―― 同じ被写体を 8 / 16 / 32 / 64 / 128 本で撮り直す。16 本以降、再構成の nRMS は 0.2341 → 0.0334 と 7.0 倍良くなるのに、体積は +0.38% → +0.34% と 0.04% しか動かない ―― ストリークは物体のまわりに正負が対称に出るので、体積という 1 つの積分量では相殺して消えてしまう。8 本だけは別で、そこは体積 +3.4% も含めて指標そのものが信用できない領域(同じ部品を面内 128 画素で測り直すと -0.0% になり再現しない)。壊れを教えるのは体積ではなく連結成分の数(175 個 対 1 個)。使用 op: projection_angles, ellipse_sinogram, filtered_backprojection

投影数と体積誤差のタイル

同じものをタイルでも ―― 左上が真値、以下が 8 / 16 / 32 / 64 / 128 本。ラベルは再構成 nRMS と体積誤差。8 本ではストリークで頭蓋の内側がまったく読めず、16 本でもまだ縞が残る。ところが体積誤差のほうは 16 本ですでに +0.38% で、128 本の +0.34% と見分けがつかない ―― 絵が良くなっていく過程が、体積という 1 つの数字には現れない。使用 op: ellipse_phantom, ellipse_sinogram, filtered_backprojection

回転中心のずれ

回転中心が半画素ずれると、もう二重像になる ―― 0 / 0.5 / 1 / 2 画素。再構成の nRMS は 0.0250 → 0.0537 → 0.1016 → 0.1630。半画素で誤差が 2.1 倍になるが、見た目は「少し眠い画像」で、間違いには見えない。sinogram_center_of_rotation は重心の恒等式からこれを 0.0029 px の誤差で当てる。使用 op: sinogram_center_shift, sinogram_center_of_rotation

角度範囲が足りないとき

角度範囲が足りないと、特定の向きの輪郭だけが消える ―― 180 / 120 / 90 / 60 度。中心スライス定理どおり、撮らなかった角度の帯だけが空になる。30 度ごとの周波数保持率で見ると、90 度スキャンでは撮った側が 0.96 を保つのに撮らなかった側は 0.07 まで落ちる。全体がぼけるのではなく方向が消えるので、残った方向は鋭いままで、それが説得力を持ってしまう。使用 op: ellipse_sinogram, filtered_backprojection

ビームハードニング(カッピング偽像)

ビームハードニング ―― 一様な円板の中心がへこむ ―― 実際の X 線は単色ではないので、厚い経路を通った線ほどビームが硬くなり、線積分が経路長に比例しなくなる。一様な円板の中心/縁の比が 1.0006 → 0.9335 に沈み、beam_hardening_correct が 1.0006 に戻す。差分図(青=減った / 橙=増えた)が、沈んだのが中心だけであることを示す。使用 op: beam_hardening_apply, beam_hardening_correct

リング偽像

リング偽像 ―― 検出器 1 画素の狂いが 1 本の円になる ―― ゲインが g の検出器は対数を取ったあと どの角度でも同じ定数だけずれる。定数の列を逆投影すると回転軸まわりの完全な円になる。ゲインばらつき 2 % で nRMS が 0.0250 → 0.0643(2.6 倍)、ring_artifact_remove で 0.0358(被害の 72% を回復)。使用 op: ring_artifact_apply, ring_artifact_remove

体積の答え合わせ

体積の答え合わせ ―― 何が効いて、何が効かないか ―― 真値 16839 mm³(閉形式)、この格子で二値化しただけの天井が 16863 mm³。左は投影数 16→128 で振れ幅 8 mm³(8 本を含めると 522 mm³ になるが、その点は格子を変えると再現しない)、右は二値化しきい値 0.30→0.70 で振れ幅 533 mm³。しきい値の任意性のほうが 71 倍効くので、体積を報告するときに書くべきなのは「何本で撮ったか」より「どのしきい値で切ったか」。使用 op: radon_volume, fbp_volume, vol_label, vol_region_props

表現変換ウィング ―― 往復させて嘘を露見させる

表現変換ウィング ―― 展示キャプション原稿

生成元: tools/gen_wingconv_gallery.py(py -3.11 tools/gen_wingconv_gallery.py)。画像はすべて fullseye の op
(reprconv / imagedraw)と numpy 合成で描いており(matplotlib 不使用)、図に焼いた数値は
1 つ残らずその場で op を呼んで得た実測値である。乱数は seed 固定・幾何も固定なので
再生成でバイト列が一致する(--verify で検査)。

このウィングの主張は 1 つ ―― 変換の嘘は往復で露見する
変換 op は「入口の型」と「出口の型」の両方を主張するので、嘘をつく面が 2 つある。
だから主役は「A → B → A' を並べ、最後のコマに残差と誤差の数値を焼いた GIF」で、
可逆なものは残差が真っ黒 = 誤差 0不可逆なものは何がどれだけ落ちるかを数字で出す。

1. 可逆な変換 ―― 法線 ⇄ 方位・仰角[度]

可逆な変換 ―― 法線 ⇄ 方位・仰角[度]

可逆な変換 ―― 法線 ⇄ 方位・仰角[度] ―― 袋小路だった normals に出口を作った。方位 az と仰角 el(どちらも度)へ変換し、そこから組み直すと 9216 本の法線が max|Δ| = 2.289e-12(角度差 1.207e-06 度)で戻る。最後のコマの残差が真っ黒なのは「絵が暗い」のではなく 0..1 の固定スケールで 0 だからで、自動スケールにすると倍精度の丸めが模様に見えて可逆なのに壊れて見える。

2. 可逆な変換 ―― 主曲率 ⇄ 形状指数(臍点を含めて厳密)

可逆な変換 ―― 主曲率 ⇄ 形状指数(臍点を含めて厳密)

可逆な変換 ―― 主曲率 ⇄ 形状指数(臍点を含めて厳密) ―― 球・鞍・円柱・平面の 4 パッチ(9216 点。うち臍点・平面 4608 点)を形状指数 S と曲がり C へ移し、戻して max|Δ| = 2.220e-16。教科書の atan((k1+k2)/(k1-k2)) は臍点で 0 除算になるが、atan2 形で書けば球 S=+1・鞍 S=0・円柱 S=+0.5 が閉形式のまま全域で厳密に往復する。

3. 不可逆な変換 ―― keypoints ⇄ 画素格子(落ちる量を測る)

不可逆な変換 ―― keypoints ⇄ 画素格子(落ちる量を測る)

不可逆な変換 ―― keypoints ⇄ 画素格子(落ちる量を測る) ―― 4 px 間隔に置いた 900 点を計数画像へ焼いて拾い直すと、軸あたり RMS 0.2925 px(一様量子化の理論 1/√12 = 0.2887)、2-D 距離 RMS 0.4136 px(理論 √(2/12) = 0.4082)。ランダム配置なら 120 → 111 点に融合する ―― 量子化(ずれる)と融合(消える)は別の損失で、混ぜて 1 つの RMS にするとどちらがどれだけ効いたか言えなくなる。

4. 不可逆な変換 ―― 点群 → ガウシアン → 体積(質量で測る)

不可逆な変換 ―― 点群 → ガウシアン → 体積(質量で測る)

不可逆な変換 ―― 点群 → ガウシアン → 体積(質量で測る) ―― 産む op が 1 つも無かった gaussians に入口を作った。中心 mu は往復 max|Δ| = 0.000e+00 で bit 一致し、sigma と w は往復で消える「追加された情報」。体積へ焼くと 3σ の打ち切りで99.192% が理論値 —— 最初これを 3σ の 97.07% と書いたが、刻みを 1.0 → 0.125 と細かくすると箱の値へ収束して球へは近づかず、反証できた。

5. 表現をまたいで一周 ―― 何が残り、何が消えるか

表現をまたいで一周 ―― 何が残り、何が消えるか

表現をまたいで一周 ―― 何が残り、何が消えるか ―― voxel → mesh → points → gaussians → voxel。体積 5444 voxel の立体は mesh の段で中身を失い(3268 頂点 / 6584 面、表面積 2461.8)、points で接続と法線を失う。内部の充填率は100.0% → 38.2% で、戻ってきたのは立体ではなく殻。一方で重心は 1.2925 voxel しか動かない ―― 一致する指標と一致しない指標を両方出すのが正直な報告で、重心だけ見せると「一周して戻った」という嘘になる。★この主張は最大値投影では言えない(MIP は薄い殻でも中が詰まって見える。実際に一度そう描きかけた)ので、中心断面と内部の充填率で示している。

6. 死んだ型 flow が「見える」ようになった

死んだ型 flow が「見える」ようになった

死んだ型 flow が「見える」ようになった ―― flow は単入力で産む op も食う op も無い完全な孤島だった。密なシーンフロー [3, 24, 96, 96] を大きさ(voxel)と色相環(rgbimage)へ出す 2 つの出口を作り、色の意味の凡例を同じ図に焼いた。この repo の flow は (3,D,H,W) の密フローと (N,3) の散在フローが同じ型名で同居しているので、密用 ['flow_magnitude', 'flow_to_rgbimage'] と散在用 ['flow_speed', 'flow_apply'] でop を分け、相手の形は fail-closed にしてある。

7. 軸・単位・spacing の取り違えは例外を出さずに通る

軸・単位・spacing の取り違えは例外を出さずに通る

軸・単位・spacing の取り違えは例外を出さずに通る ―― (u,v) を (v,u) と読むと重心が 39.5 px ずれて 元図形との重なりは 0.0% まで落ち、spacing を既定のままにするとピークが [4, 5, 6] でなく[10, 12, 14] に立ち、π/6 rad を「度」として渡すと0.5236 度だけ回る。積算窓を 1 ms でなく 1 s と読めば計数は 1000 倍になる。どれも例外は出ず、有限で、もっともらしい絵が返る ―― だから op 名に軸を書き、単位を引数にした。

8. 死んだ語彙 ―― 産む op はあるのに、そこから先へ行けない型

死んだ語彙 ―― 産む op はあるのに、そこから先へ行けない型

死んだ語彙 ―― 産む op はあるのに、そこから先へ行けない型 ―― 台帳 707 op を「単入力かつ in 型 ≠ out 型 = 変換」で機械集計すると、他型へ一歩も出られない型が 15 個あった。reprconv の 42 op で 0 型に出口ができ、変換ペアは184 → 184 種、袋小路は 15 → 15 個。残した 9 型は埋めない理由を台帳に書いてある ―― 埋めないことも判断である。

Studio と 3D 表示ウィング ―― 見て気づく

Studio 画面 / 3D 表示ウィング —— 展示キャプション原稿(日本語)

生成元: tools/gen_wingstudio_gallery.py(再実行で全点を再生成)。
Studio 画面はすべて studio.build_window() が組み立てた実 UIwidget.grab()(オフスクリーン)で、モックアップはありません。
3D 展示は fullseye の op と numpy 合成だけで描いています(matplotlib 不使用、文字のみ Pillow)。数字はすべて実測値です。

このファイルは納品原稿です。記事 md への転記は手動で行ってください(記事本体は意図的に編集していません)。英語版は wingstudio.en.md


CT を回す —— 面と粒、同じ角度で

CT を回す —— 面と粒、同じ角度で

CT を回す —— 面と粒、同じ角度で —— 同梱の骨格 CT (20×97×28 voxel)を等値面 (mean+std = 0.5108) で三角形 9,710 枚 / 頂点 4,866 のメッシュにしたものと、同じ閾値の境界シェル 2,759 voxel を、同じ yaw・同じ仰角で並べて回しています。左は面、右は粒。同じ形が同じ向きに回ることが、軸を取り違えていない何よりの証拠になります(36 フレーム)。 使用 op / 機能: marching_cubes, phong_shade, vol_boundary, render_points_frame

wingstudio_volume_turntable.gif — 36 フレーム / 12 fps / 996×431 px / 1.12 MB / SHA-256 1cb0def25c830444


z スライスを 1 枚ずつ送る

z スライスを 1 枚ずつ送る

z スライスを 1 枚ずつ送る —— 同じ CT を z = 0 から 19 まで 1 枚ずつ送ります(全 20 フレーム、下のバーが現在位置)。右は全 z を潰した MIP。左の 1 枚には毎フレーム実測した骨占有率・最小/最大/平均を出しているので、端の 1 枚が欠けている/ 重複しているといった off-by-one はここで必ず露見します。拡大は最近傍 ×6(補間しない —— 画素の粗さ自体が情報)。 使用 op / 機能: vol_mip, apply_cmap, 最近傍整数拡大。

wingstudio_zslices.gif — 20 フレーム / 5 fps / 896×726 px / 1.30 MB / SHA-256 1241579b9480c167


点群を合わせる —— 初期ずれから収束まで

点群を合わせる —— 初期ずれから収束まで

点群を合わせる —— 初期ずれから収束まで —— 実データ(イトカワ表面 3,000 点)に既知の剛体ずれ 22 度 + 並進 42.451 と等方ノイズ σ = 1.2160 を入れ、trimmed ICP を 1 反復ずつ 48 回実行した実測の収束です。対応づけ前の素の点間距離平均 74.763 → 1 反復目 22.770 → 最終 1.754(13.0 倍改善)で、注入ノイズの σ にほぼ張り付いて止まります。曲線が下がりきっても橙が青に乗っていなければ「収束したのに合っていない」—— 数字だけでは見えない失敗が、絵にすると一目で分かります。 使用 op / 機能: registration.icp(trimmed), render_points_frame, imagedraw.draw_polyline

wingstudio_registration.gif — 48 フレーム / 6 fps / 972×500 px / 0.52 MB / SHA-256 995ef59ea259ded0


法線の色 —— 3D デバッグで最初に見る絵

法線の色 —— 3D デバッグで最初に見る絵

法線の色 —— 3D デバッグで最初に見る絵 —— itokawa_f0049152.stl (JAXA はやぶさ Gaskell 形状モデル)(三角形 49,152 枚 / 頂点 24,578、表面積 0.399)を表と裏 180 度から撮り、陰影と world 法線をそのまま RGB にした絵を並べました。world 法線は「色 = 向き」なので、面が滑らかに繋がっていれば色も滑らかに繋がります。ごま塩状にまだらなら巻き方向(向き付け)が壊れている合図。実測では外向き面 48,639 / 49,152 = 98.96 %(残り 1 % は非凸の小惑星に「重心から外向きか」という判定を当てたことによる取りこぼし)。被覆画素は表 38,540 px / 裏 39,686 px。 使用 op / 機能: render_mesh, phong_shade, world 法線の RGB 化。

wingstudio_normals.png — 1840×600 px / 339 kB / SHA-256 155b586afb9f5615


ライトフィールドの視点移動 —— 49 個のカメラで撮る

ライトフィールドの視点移動 —— 49 個のカメラで撮る

ライトフィールドの視点移動 —— 49 個のカメラで撮る —— 7×7 = 49 視点 × 128×128 画素の合成ライトフィールドで、アパーチャの周を1 周(全 24 フレーム)します。近いものほど大きく動く —— 中央視点との差がそのまま「どこが手前か」の絵になります。実測の最大視差は 21.33 px、EPI(行 y = 64)の線の傾きがそれに対応します。再合焦の分散は slope = 0 で 0.00682、slope = 3 で 0.01487。 使用 op / 機能: lf_synthesize, lf_subaperture, lf_epi, lf_refocus, lf_stats

wingstudio_lightfield.gif — 24 フレーム / 8 fps / 864×484 px / 2.33 MB / SHA-256 bcca4f45d63d9d65


深度マップを持ち上げて 3D にする

深度マップを持ち上げて 3D にする

深度マップを持ち上げて 3D にする —— itokawa_f0049152.stl を 200×200 px の深度画像にし、有効画素 9,715(24.3 %)だけを逆投影して立体に起こす過程です。深度は 0.7363〜0.8827。ここで効くのが 画素中心の規約 です —— render3dcamera.depth_to_pointscadmap も画素中心を整数添字に揃えたので、逆投影した点を投影し直すと残差 rms は 1.31e-14 px(= 丸め誤差)に収まります。うっかり +0.5 を足すと雲全体が 0.00229 world 単位、全点が同じ側へ系統的にずれます。 使用 op / 機能: render_mesh, camera.backproject, render_points_frame

wingstudio_depth3d.gif — 30 フレーム / 10 fps / 812×620 px / 0.68 MB / SHA-256 6ed4d91ac7009986


欠陥を CAD 面へ逆写像し、見えていない面を数える

欠陥を CAD 面へ逆写像し、見えていない面を数える

欠陥を CAD 面へ逆写像し、見えていない面を数える —— SDF から作った段付き部品(三角形 1,400 枚、表面積 8856.6)を 240×240 px の検査カメラで撮り、①見え方 ②画素 → CAD 面 ID ③画像上の欠陥ラベル 4 件の逆写像 ④見えた面(緑)/ 見えない面(赤)を並べました。命中画素 15,980(27.7 %)。カメラを向いている面積は 48.3 % ですが、塔が自分の台座を隠すため 実際に見えたのは 46.8 %(面数では 608 / 1,400 = 43.4 %)。表面点 26,000 でも可視 41.3 % / 遮蔽 58.7 % と一致します。欠陥 #3 #4 は CAD の外(命中 0)なので実面積 0 のまま残る —— 黙って消えないのが大事なところです。 使用 op / 機能: cad_pixel_to_surface, cad_defect_to_cad, cad_visible_faces, cad_surface_to_pixel

wingstudio_cadmap.gif — 24 フレーム / 10 fps / 1200×518 px / 0.50 MB / SHA-256 eda5aa159d5dd0c4


3D の処理領域 —— 切り出して、処理して、貼り戻す

3D の処理領域 —— 切り出して、処理して、貼り戻す

3D の処理領域 —— 切り出して、処理して、貼り戻す —— 20×97×28 の CT から y ∈ [20, 56) を margin 2 で切り出すと 20×40×28(offset (z,y,x) = (0, 18, 0))になります。その中だけ勾配を計算し、元の座標系へ貼り戻すまでを 4 段で 3D 表示しました(右は元の全体を灰色で重ねたもの)。往復の実測は 箱の外の最大値 0(厳密に 0)/ 箱の中の元との最大差 0(ビット一致)。貼り戻しで 1 voxel ずれても 2D の表では気づけませんが、重ねて回せば一発です。 使用 op / 機能: vol_crop_domain, vol_gradient_magnitude, vol_uncrop, vol_boundary

wingstudio_crop3d.gif — 36 フレーム / 8 fps / 976×491 px / 0.46 MB / SHA-256 3902cbec3f013592


F キーで 3D データの中を歩く(実 Studio 画面)

F キーで 3D データの中を歩く(実 Studio 画面)

F キーで 3D データの中を歩く(実 Studio 画面) —— 本物の Fullseye Studio(1280×800 px、オフスクリーン)にイトカワの実形状モデル(頂点 24,578 / 三角形 49,152、スプラット 73,730 点)を開き、実際の QKeyEvent で F → W で前進 → ドラッグで見回し → +/- で視野角 → A で左へ → R で入口 → F で軌道カメラへ、と操作した 24 フレームです。透視投影なので近づくほど手前が大きくなり、視野角を変えると遠近感そのものが変わります。1 タップ = 半径/50 = 0.00592 の 1 歩(既定 FOV 70 度、可変域 40〜100 度)。下端の細い帯はこの GIF の進行バーで、UI ではありません。 使用 op / 機能: Studio 3D ビューアの一人称モード(render_points_frame_fp)、viewer3d_project_persp

wingstudio_studio_walk.gif — 24 フレーム / 4 fps / 1280×800 px / 2.93 MB / SHA-256 bec27bc1ab57984b


軌道カメラで回す —— ボリュームをそのまま 3D ビューアで開く

軌道カメラで回す —— ボリュームをそのまま 3D ビューアで開く

軌道カメラで回す —— ボリュームをそのまま 3D ビューアで開く —— 同梱の骨格 CT(20×97×28)を Studio が「ボリュームファイル」として開く経路そのままです。Otsu 閾値 0.5389 で前景を取り、その 境界シェルだけ を 2,733 点の物理座標に落として(間引き 1/1)表示しています。回しているのは合成ではなく、実際の左ドラッグ(1 回 = yaw +12 度)を 30 回送った結果で、最終 yaw は 35 度。 使用 op / 機能: volume_to_shell_points(Otsu → 境界シェル)、Studio 3D ビューアの軌道カメラ。

wingstudio_studio_turntable.gif — 30 フレーム / 10 fps / 1280×800 px / 0.92 MB / SHA-256 02c1ff44094868fe


新しい族の op ヘルプを Studio の中で開く

新しい族の op ヘルプを Studio の中で開く

新しい族の op ヘルプを Studio の中で開く —— ライトフィールド → FMCW レンジドップラ → 四元数モノジェニック → 光子計数(SPAD)→ 音響ビームフォーミング → 干渉(角スペクトル伝搬)→ 3D の ICP・主曲率、と 8 ページを実際に開き、各ページを上から下までスクロールした 24 フレームです。ヘルプ本文は docs/ops/**/*.md から自動生成された実ファイル(2D 907 枚 / 3D 310 枚)。族別ディレクトリには合計 351 枚が生成済みで、そのうち Studio から開けるのは tb_* 型付き op 経由の 53 枚、残り 298 枚はまだ画面から辿れません(干渉は 9 枚中 0 枚)。 使用 op / 機能: Studio のヘルプダイアログ(op_help_html / op_help_html_3d)、tools/opdocs.py 生成の HTML。

wingstudio_studio_help.gif — 24 フレーム / 3 fps / 1000×720 px / 0.53 MB / SHA-256 49fc9ab4fffe092c


書いて、F5 で走らせて、結果が出るまで

書いて、F5 で走らせて、結果が出るまで

書いて、F5 で走らせて、結果が出るまで —— タブエディタに 18 行のコードを打ち込み、F5 で実行して出力コンソールを読み下すまでの 24 フレームです(1060×740 px のダイアログ)。実行はモックではなく本物の子プロセスで、ステータスは「PASS ✓ (exit 0)」。出力 6 行の末尾は foreground fraction = 0.2995 / objects = 21 / area min/median/max = 1118 / 1494 / 3084 —— コインの分割結果です。 使用 op / 機能: Studio の Python エディタ(タブ + F5 実行)、fullseye.apply, fullseye.segment_objects

wingstudio_studio_editor.gif — 24 フレーム / 6 fps / 1060×740 px / 0.36 MB / SHA-256 ce1154f194aeb759


900 超の op から目的の 1 個へ

900 超の op から目的の 1 個へ

900 超の op から目的の 1 個へ —— 検索欄に 1 文字ずつ「watershed」と打つと、917 個の一覧が 4 件まで絞れます(実測の内訳: (空):917 → w:80 → wa:11 → wat:4 → wate:4 → water:4 → waters:4 → watersh:4 → watershe:4 → watershed:4)。選ぶと in_sort → out_sort のシグネチャが右下に出る —— 型が見えるので、次に何を繋げるかがその場で分かります。最後に「cad」で引くと 0 件。 使用 op / 機能: Studio の演算子検索(名前 / HALCON 別名 / 分類 / docstring を横断)。

wingstudio_studio_opsearch.gif — 17 フレーム / 4 fps / 1280×800 px / 0.54 MB / SHA-256 7c1af28cc7a99e86


パイプラインを組む —— 型が合わないと Problems に出る

パイプラインを組む —— 型が合わないと Problems に出る

パイプラインを組む —— 型が合わないと Problems に出る —— coins サンプルに gaussian → otsu → opening_circle → sk_clear_border を1 段ずつ足していき、⑤でわざと region を受け取れない circularity_xld(contour 入力)を足します。すると Problems に 「stage 4 (sk_clear_border) outputs 'region' but circularity_xld expects 'contour'」と出る —— Fullseye は繋いだ後に落ちるのではなく、繋いだ瞬間に型の不一致を言います。⑥で外すと「no problems」に戻ります(全 24 フレーム)。 使用 op / 機能: Studio の Program パネル(HDevelop 風)+ Problems、engine.diagnose_stages

wingstudio_studio_pipeline.gif — 24 フレーム / 4 fps / 1280×800 px / 0.54 MB / SHA-256 e395b77263a3e87a


付録: この展示を作る過程で見つかった「見た目の異常」と、その後

可視化はバグ発見の道具でもある、という前提で作りました。ここに出す数字はすべて
実測です。報告した 8 件のうち 5 件は本体側で修正済み2 件は未解決
1 件は仕様どおりでした。修正済みは「こうだった → こう直った」の形で残します
(消してしまうと、なぜ今の形なのかが分からなくなるため)。

修正済み(5 件)

1. GIF の書き出しが「連続する同一フレーム」を 1 枚に畳んでいた

こうだった —— video.write_video の GIF 経路(Pillow)は完全に同一の連続フレームを
結合するので、静止の「間」を作るために同じ grab を並べると 18 枚書いて 6 枚しか
戻らない
。書き出し後に読み戻して枚数を突き合わせない限り気づけませんでした。

こう直った —— GIF は video._write_gif_all_frames が Pillow を直接駆動し、
重複フレームも 1 枚ずつ保存します(代償はファイルサイズ)。同じ再現で実測:

seq = [base] * 6 + [other] * 6 + [base] * 6      # 18 枚(連続同一の塊が 3 つ)
video.write_video(path, seq, fps=6)
# 実測: wrote 18 frames -> read back 18

本スクリプトの save_gif は、直ったあとも毎回読み戻して枚数を照合します
(検算を外す理由が無いため)。

2. ボリュームを 3D ビューアで開くと「横倒し」になっていた

こうだった —— studio.volume_to_shell_points(z, y, x) 順の点を返す一方、
消費側(render_points_frame / viewer3d_project)は 3 番目の成分を world の
上方向
として扱うため、スライス方向が画面の左右に寝ていました。既定の viridis
高さランプも同じ理由で x 添字を色にしていました。

こう直った —— この関数が「voxel の並び順 → ビューアの world」の境界になり、
world (x, y, z) を返します。spacing((sz, sy, sx))も添字と一緒に反転されます。

v = np.zeros((40, 8, 8)); v[:, 3:5, 3:5] = 1.0      # z 方向に伸びた棒
P, C, info = studio.volume_to_shell_points(v)
P.max(0) - P.min(0)      # 実測 [1.0, 1.0, 39.0](3 番目 = 上 が長い)
info["axis_order"]       # 実測 "xyz"(規約を表明する印)
studio.volume_to_shell_points(v, spacing=(2.0, 1.0, 1.0))   # 実測 [1.0, 1.0, 78.0]

展示「軌道カメラで回す」はこの経路そのものなので、図の向きも直っています。

3. 画素中心の規約が 2 つあり、繋ぐと半画素ずれていた

こうだった —— render3d.render_mesh は「添字 + 0.5」を画素中心としてレイを
飛ばし、camera.depth_to_points は整数添字を中心として逆投影していたので、
素直に繋ぐと雲全体が半画素ぶん、しかも全点が同じ側へずれました。

こう直った —— render3d / camera / cadmap整数添字という 1 つの規約に
揃い(主点も (w - 1) * 0.5)、逆投影 → 再投影が閉じます。この展示での実測:

測ったもの 実測
逆投影 → 再投影の残差 rms 1.31e-14 px(= 丸め誤差)
うっかり +0.5 を足したときの雲のずれ 0.00229 world 単位(= 半画素、fx = 241.42)

4. cadmap が「内向きに巻かれた閉メッシュ」を黙って受けていた

こうだった —— cull_backfaces=True(既定)だと本来の遮蔽面がカリングされて
光線が突き抜け、可視率が 0.857 と過大に出ました。「カメラを向いている面積」
0.517 を上回った時点で物理的にありえません(遮蔽は減らすことしかできない)—— それが
気づきの糸口でした。

こう直った —— 巻き方向を検める箇所が 1 つにまとまり、閉じているのに符号つき体積が
負なら 直したうえで winding_fixed で申告します。cad_visible_faces は既定で拒否、
strict=True なら 3 つとも ValueError。段付き部品(1,400 面、符号つき体積 ±37290.4)
で実測:

呼び方 内向きメッシュ 外向きメッシュ
cad_surface_to_pixel の可視率 0.4129(winding_fixed=True) 0.4129(winding_fixed=False)
cad_surface_to_pixel(strict=True) ValueError 0.4129
cad_visible_faces(既定) ValueError 608 面

ただし 呼ぶ側の注意は消えていません(z,y,x) -> (x,y,z)V[:, ::-1] は軸の
入れ替えではなく鏡映(行列式 -1)なので、座標だけ反転すると全三角形の巻きが
裏返ります。本スクリプトの voxel_mesh_to_world は面の巻きも同時に反転して
打ち消しています。

Vz, F = render3d.marching_cubes(vol, 0.0)         # 内側ボクセル 35,746
signed_volume(Vz, F)                     # 実測 +37294.7
signed_volume(Vz[:, ::-1], F)            # 実測 -37294.7  ← 内向きになった
signed_volume(Vz[:, ::-1], F[:, ::-1])   # 実測 +37294.7  ← 打ち消した

5. Problems の 1 行の中で stage 番号が 0 起点と 1 起点で混ざっていた

こうだった —— engine.diagnose_stages のメッセージは 0 起点、Studio の Problems の
見出しは 1 起点。同じ 1 行に別の番号体系が並び、読者を違う段へ案内していました。

! stage 5 (circularity_xld): stage 3 (sk_clear_border) outputs 'region' but ...

(sk_clear_border は Program パネルでも Problems の見出しでも 4 段目)

こう直った —— message は人が読む散文として 1 起点に統一され、機械が使う
index / prev_index(0 起点、行の選択にそのまま使える)と prev_op が別に載ります。
展示⑤の実測はこうなります:

! stage 5 (circularity_xld): stage 4 (sk_clear_border) outputs 'region' but circularity_xld expects 'contour'

未解決(2 件)

6. 新しい族の生成済みヘルプ 155 枚のうち 110 枚が画面から辿れない

studio_assets/op_help/<族>/tools/opdocs.py が生成した HTML が 155 枚あるのに、
Studio のヘルプ検索は 2D 名 + 3D 名しか引かないため、tb_* 型付き op として
登録された 45 枚しか開けません(今回の再生成でも同じ内訳です)。

生成済み tb_* 経由で開ける 開けない
acoustics 19 3 16
interferometry 9 0 9
lightfield 17 8 9
math 26 6 20
motionmag 9 2 7
optics 18 1 17
photon 17 6 11
quat 19 12 7
rangedoppler 8 4 4
specular 13 3 10
合計 155 45 110

あわせて、開ける 45 枚も「実行できる例」が空で、「同カテゴリ」欄は typed op が
1 カテゴリに同居しているため無関係な op が並びます —— 展示の図でそのまま見えます。

7. vol_mip の正規化が ops.py 本体には書かれていない

ops.RT["vol_mip"] は表示向けに [0,1] へ正規化した像を返すので、累積 MIP の
到達率の分母に使うと 100 % を超えます。同梱の骨格 CT(20×97×28、生の値域は
最大 1.2264)で実測:

分母に使ったもの 完全な累積 MIP の到達率
ops.RT["vol_mip"](vol, 0.0, 0.0) 122.64 %
vol.max(axis=0)(生の投影) 100.00 %

volops.pyvolio.py の module docstring には注記が入りましたが、
ops.py_vol_mip 本体と登録表には何も書かれていないので、ops.py だけを
読む人には見えません。op の挙動自体は「表示用なら正規化が正しい」ので、
これはバグではなく使い分けの明記漏れです。

仕様どおりだったもの(1 件)

  • 演算子ブラウザに「ツリー」は無い —— 実装は 1 枚のリスト + 検索欄 + 分類コンボで、
    ツリー表示はもともとありません。展示のキャプションも「一覧」と書いています。

再現性(今回、全点を 2 回生成して実測)

14 点中 11 点は SHA-256 まで一致し、3 点は一致しません。内訳:

展示 何が揺れるか 実測
studio_editor Studio が実行に使う一時ファイル名 scratch_<pid>.py が出力コンソールに出る 1 フレームの 8×27 px 領域だけが最大 191 階調ぶん変わる
studio_pipeline Pipeline パネルの段ごとの実測 ms(壁時計)が写り込む 一覧の枠内 444 px が変わる
studio_opsearch 描画タイミングのゆらぎ(局所的な文字の差は無し) 1,741 万画素中 13,317 px、最大 29 階調

残りの差は GIF のパレット再量子化(中央値 1〜2 階調)で、絵の内容は同一です。
studio_pipeline は今回 Problems パネルを前面に出したので、以前は写っていなかった
実測 ms が入り、bit 再現しなくなりました —— 主役(型不一致の 1 行)が見えることを
優先しています。

光学設計・検査ウィング ―― 買う前に「見えるか」を決める

光学設計・検査ウィング —— キャプション原稿

再生成: py -3.11 tools/gen_wingopt_gallery.py(展示単位なら --exhibits <name,...>)。
図に焼かれた数字はすべて optics / visiondesign / defectgen / visionlab を実際に呼んだ実測値で、決定的です(--verify で SHA-256 一致を確認できます)。

設計から判定までの一本道

設計から判定までの一本道

設計から判定までの一本道 ―― 「設計 → 限界 → 仮想の部品 → 撮像 → 検査 → 判定」の 6 工程を、1 コマずつ止めて読めるコマ送りにしました。系が決まると 16.264 µm/画素が確定し、そこから光学限界 32.53 µm(sampling 律速)が出て、120 µm の傷は 7.38 画素になり、最後に IoU 0.4228 で 検出と判定される —— 正解マスクは撮像でぼけても動かないので、この採点が成立します(判定は marginal)。 使用 op: system_geometry, resolving_power, system_feasibility, surface_texture, defect_scratch, composite_defect, defect_stats, image_formation, draw_polyline, draw_circle

静止フレームでも読めます(静止サムネ: wingopt_pipeline_flow_thumb.jpg)。6 フレーム / 700 ms/コマ / 940×514 px / 0.30 MB。

欠陥ジェネレータの見本帳

欠陥ジェネレータの見本帳

欠陥ジェネレータの見本帳 ―― 欠陥 5 種(scratch / pits / crack / blob / composite)を同じ系(16.264 µm/画素)で撮り、左列が撮れる画像、右列が画素完全な正解マスクです。マスクは撮像前の幾何から作るので、撮像でぼけても正解は動かず、注釈作業が存在しません —— 各行のマスク面積は実測で 682 / 949 / 441 / 2318 / 1749 画素、光学限界は 32.53 µm(sampling 律速)です。 使用 op: defect_scratch, defect_pits, defect_crack, defect_blob, surface_texture, composite_defect, defect_stats, image_formation

クリックで原寸 (998×882 px / 146 kB)。

律速の入れ替わり

律速の入れ替わり

律速の入れ替わり ―― 作動距離を 120 → 320 mm と掃くと、回折律速と標本化律速が入れ替わります。閉形式で解いた交点は WD 157.64 mm、そこでは 2 本の限界がどちらも 24.18 µm で一致します(倍率 0.28539)。記事本文の 44 段掃引が入れ替わりを最初に報告するのは 160.5 mm —— その差は物理ではなく格子の粗さです。 使用 op: system_geometry, resolving_power, thin_lens, draw_polyline, draw_line

静止フレームでも読めます(静止サムネ: wingopt_limit_crossover_thumb.jpg)。42 フレーム / 10 fps / 1000×474 px / 0.46 MB。

cos⁴ 則の周辺光量落ち

cos⁴ 則の周辺光量落ち

cos⁴ 則の周辺光量落ち ―― 焦点距離を 42 → 8 mm と短くすると半画角が 5.91° → 33.45° へ広がり、視野の角の明るさが 0.9789 → 0.4846(中心比)まで落ちます。右の曲線は relative_illumination の出力そのもので、左のマップは同じ cos⁴ をセンサ座標で評価したもの —— 独立な 2 経路の角の値が最大でも 0.0e+00 しか違いません(片方が壊れたら気付ける作りにしてあります)。 使用 op: relative_illumination, thin_lens, system_feasibility, draw_polyline

静止フレームでも読めます(静止サムネ: wingopt_cos4_falloff_thumb.jpg)。36 フレーム / 10 fps / 1000×494 px / 1.66 MB。

回折限界の MTF

回折限界の MTF

回折限界の MTF ―― F 値を f/1.4 から f/22.0 まで絞ると、カットオフ周波数 1/(λN) が 1299 → 83 cyc/mm へ下がります。左のバーは飾りではなく、右の曲線から読んだコントラストをそのまま振幅にして描いたもので、200 cyc/mm のバーは f/1.4 では 0.805 だったのが f/22.0 では 0.000 —— 完全に消えます。 使用 op: mtf_diffraction, draw_polyline, draw_markers

静止フレームでも読めます(静止サムネ: wingopt_mtf_thumb.jpg)。34 フレーム / 10 fps / 1000×536 px / 0.99 MB。

被写界深度と錯乱円

被写界深度と錯乱円

被写界深度と錯乱円 ―― 被写界深度はレンズの性質ではなく、許容錯乱円という「こちらの決め事」です。錯乱円を 1 画素から 10 画素へ広げると深度は 0.7435 mm → 7.4377 mm(比 10.0034)と、ほぼ厳密に比例して伸びます。記事のライトフィールドの利得表(6×6 で 6.0016 倍)はこの直線を 2 回読んだだけで、要求公差 1 mm が収まるのは錯乱円 1.345 画素からです。 使用 op: depth_of_field, draw_polyline, draw_line

静止フレームでも読めます(静止サムネ: wingopt_dof_coc_thumb.jpg)。37 フレーム / 10 fps / 1000×496 px / 0.44 MB。

横分解能 対 被写界深度

横分解能 対 被写界深度

横分解能 対 被写界深度 ―― 横分解能と被写界深度は独立な 2 軸です。60 µm の欠陥が解像できるのは f/7.82 まで、部品の1 mm 公差が収まるのは f/5.38 から —— 使える窓は f/5.38 〜 f/7.82 の帯だけです。これを 1 つの resolvable に畳むと「光学限界に未到達」と出てしまい、読んだ人はレンズを買いに行きます(直すべきは絞りか公差かフォーカス機構)。 使用 op: resolving_power, depth_of_field, system_geometry, draw_polyline

静止フレームでも読めます(静止サムネ: wingopt_res_vs_dof_thumb.jpg)。43 フレーム / 10 fps / 1000×548 px / 0.33 MB。

Airy パターンと Rayleigh 基準

Airy パターンと Rayleigh 基準

Airy パターンと Rayleigh 基準 ―― 円形瞳の Airy 像で 2 点を近づけていくと、谷は崖ではなく連続に浅くなります。第 1 暗環の実測位置は 3.760 µm(理論 1.2197λN = 3.757 µm)、Rayleigh 間隔 3.758 µm での谷は実測 0.7336(教科書の 0.735)で、谷がそもそも現れ始めるのは 3.000 µm からです。 使用 op: airy_pattern, draw_polyline, draw_line

静止フレームでも読めます(静止サムネ: wingopt_airy_rayleigh_thumb.jpg)。33 フレーム / 10 fps / 1000×516 px / 2.31 MB。

偏光で金属のテカりを消す

偏光で金属のテカりを消す

偏光で金属のテカりを消す ―― 鏡面反射(完全偏光)を Jones 行列で、拡散反射(無偏光)を Mueller 行列で通し、検光子を 0° → 180° で回します。鏡面成分の透過強度は Malus 則で 1.0000 → 0.0000(厳密に 0)、拡散成分は角度に依らず 0.5 のまま —— 飽和画素が 18.14 % → 0.00 % に減り、テカりに埋もれていた傷の IoU が 0.140 → 0.787 へ回復して検出に転じます。 使用 op: jones_element, jones_apply, stokes_from_jones, mueller_element, mueller_apply, defect_scratch, surface_texture, image_formation, draw_circle

静止フレームでも読めます(静止サムネ: wingopt_polarizer_thumb.jpg)。31 フレーム / 10 fps / 1000×492 px / 2.65 MB。

thin lens / ABCD 行列

thin lens / ABCD 行列

thin lens / ABCD 行列 ―― 物体距離を動かしながら ABCD 行列で 3 本の光線を追うと、共役面では B 要素が 0 になり、出射高さが入射角に依存しなくなります —— それが「結像している」の定義そのものです。センサは 42.424 mm に固定してあるので、物体が前後するとぼけ円が広がり、光線追跡がぼけ 1 画素以内と言う範囲 199.6〜200.4 mm は、独立な閉形式 depth_of_field の 199.629〜200.372 mm と格子の刻みぶんだけの差で一致します。 使用 op: abcd_matrix, abcd_trace, thin_lens, depth_of_field, draw_line

静止フレームでも読めます(静止サムネ: wingopt_abcd_rays_thumb.jpg)。39 フレーム / 10 fps / 1000×474 px / 0.53 MB。

検出限界マップ

検出限界マップ

検出限界マップ ―― 欠陥サイズ(横・対数)とコントラスト(縦)の平面で検出率を測ると、光学限界 32.53 µm(sampling 律速)は縦の直線として動かず、実際の検出境界(白線 = 実測 50 % 等高線)はコントラストだけで 53.2 → 27.7 µm と動きます。コントラスト 0.06 では 53 µm(限界の 1.64 倍)必要なのに、0.40 まで上げると 28 µm(0.85 倍)で足ります —— 13 段のうち 4 段は境界が限界よりに出ます(ここの検出は IoU ≥ 0.1 の当たり判定であって、2 画素に分かれて見えること = 解像ではありません)。右側はレンズの問題ではありません。 使用 op: render_part, system_geometry, resolving_power, draw_polyline, draw_line

クリックで原寸 (1028×488 px / 40 kB)。

照明を変えると何が見えるか

照明を変えると何が見えるか

照明を変えると何が見えるか ―― 同じ幾何の 60 µm の傷を、明視野風(明るい面に暗い傷)と暗視野風(暗い場に光る傷)で並べ、コントラストを掃きます。50 % 検出に届くのは明視野風が |contrast| 0.044、暗視野風が 0.018 で、光学限界 32.53 µm は両方とも余裕で超えています —— 差はレンズではなく見せ方です(これは defectgen の appearance モデル = 符号と露光であって、リング照明の光輸送計算ではありません)。 使用 op: render_part, defect_scratch, image_formation, draw_polyline

静止フレームでも読めます(静止サムネ: wingopt_illumination_thumb.jpg)。33 フレーム / 10 fps / 1000×502 px / 0.30 MB。

画素ピッチとサンプリング

画素ピッチとサンプリング

画素ピッチとサンプリング ―― 130 µm の傷を固定して画素ピッチだけを粗くすると、欠陥が 2 画素を割るのはピッチ 13.79 µm (Nyquist の境界)で、実測の 50 % 検出が保つのはピッチ 15.02 µm までです。拡大は最近傍なので見えている四角は本物の画素で、滑らかに見せるための補間は入れていません。 使用 op: render_part, system_geometry, resolving_power, draw_polyline

静止フレームでも読めます(静止サムネ: wingopt_pixel_pitch_thumb.jpg)。40 フレーム / 10 fps / 1000×502 px / 0.58 MB。


生成物一覧(実測)

展示 形式 画素 フレーム サイズ SHA-256(先頭 16)
設計から判定までの一本道 GIF 940×514 6 303 kB 46c1de110827b53c
欠陥ジェネレータの見本帳 PNG 998×882 1 146 kB c732c5100726f75c
律速の入れ替わり GIF 1000×474 42 459 kB 353cbabaa24686ab
cos⁴ 則の周辺光量落ち GIF 1000×494 36 1661 kB 50142cb5931e55a0
回折限界の MTF GIF 1000×536 34 991 kB b52ec1dd5cf66bd8
被写界深度と錯乱円 GIF 1000×496 37 439 kB 0f2b9c69b1bb6dc5
横分解能 対 被写界深度 GIF 1000×548 43 327 kB b89bed20b13b8978
Airy パターンと Rayleigh 基準 GIF 1000×516 33 2312 kB 5d8a032aef0b8560
偏光で金属のテカりを消す GIF 1000×492 31 2651 kB 7201c5f510b43e36
thin lens / ABCD 行列 GIF 1000×474 39 533 kB 9b69c483a02265f2
検出限界マップ PNG 1028×488 1 40 kB 81b870b0b2bbbd90
照明を変えると何が見えるか GIF 1000×502 33 297 kB 9de5ff51d03720e0
画素ピッチとサンプリング GIF 1000×502 40 577 kB 54e2158fdb88a94a

信号・音響・1D ウィング ―― 波形から異常を聞き取る

信号・音響・1D ウィング — 展示キャプション原稿

生成元: tools/gen_wing1d_gallery.py(py -3.11 tools/gen_wing1d_gallery.py)。
画像はすべて Fullseye の imagedraw op と numpy 合成で描いており(matplotlib 不使用)、
図に焼いた数値は 1 つ残らずその場で op を呼んで得た実測値である。乱数は seed 固定、
掃引格子も固定なので再生成でバイト列が一致する(--verify で検査)。

束ね方は tools/exhibit_tile.py の 3 種に従う ―― コマ送り GIF(flipbook
掃引と工程。各コマに工程名と i/N の進捗バーが焼いてあるので止めても意味が分かる)、
タイル(contact_sheet、同じ軸にパラメータ違いを当てた小さなプロットを束ねる)、
原寸 1 枚(主張そのもの・軸と数値が読めないと意味が無い図)。静止画の Markdown は
すべて サムネイル表示 + クリックで原寸 の形で出してある。

1. 欠陥周波数は生スペクトルに無い

欠陥周波数は生スペクトルに無い

欠陥周波数は生スペクトルに無い ―― 共振 3000 Hz を欠陥率 107 Hz で振幅変調した軸受信号(25600 Hz × 1 s、変調度 0.5)。上の生スペクトルは 107 Hz に 4.292e-16 しか無く、エネルギーは搬送波 1.000000 と側帯波 0.250000 / 0.250000(= m/2 ちょうど)に居る。下の包絡線スペクトルは同じ記録から 107.000000 Hz に振幅 0.499677 = 変調度そのものを返す(band_fraction 0.999853)。 使用 op: synthesize_bearing_signal, spectrum, envelope_spectrum

この図に焼いた実測値
{
  "rate_hz": 25600.0,
  "duration_s": 1.0,
  "carrier_hz": 3000.0,
  "defect_hz": 107.0,
  "modulation": 0.5,
  "resolution_hz": 1.0,
  "raw_amplitude_at_defect": 4.2916623928040632e-16,
  "raw_amplitude_at_carrier": 0.9999999999999983,
  "raw_sideband_lower": 0.2499999999999956,
  "raw_sideband_upper": 0.24999999999999925,
  "envelope_peak_freq": 107.0,
  "envelope_peak_amplitude": 0.4996770222507938,
  "envelope_band_fraction": 0.999853069632174,
  "envelope_prominence": 10018.617709142389
}

2. スペクトルカートシスが復調帯域を選ぶ

スペクトルカートシスが復調帯域を選ぶ

スペクトルカートシスが復調帯域を選ぶ ―― 共振の位置を人が知らないとき、どの帯域で復調するかを機械に決めさせる。STFT 平面(129 bin × 199 内側フレーム、全 203 フレームのうち)にスペクトル尖度を重ね、幅 800 Hz の復調帯域を掃引した。SK の最大は 3.1037 @ 2400 Hz(窓 64 = 2.50 ms、bin 400 Hz、推定器の標準偏差 0.1001)で、その帯域の band_fraction は 0.4495。帯域選びが効いていることが数で出ている: 掃引した 24 帯域のうち欠陥率を返すのは 9 本だけで、残り 15 本は 6〜428 Hz のもっともらしい別の数を返す(例外も NaN も出ない)。ピーク周波数だけでは区別できず、分けるのは band_fraction である ―― 当たりは 0.1732〜0.6830、外れは 0.1473〜0.1645。 使用 op: synthesize_bearing_signal, stft, spectral_kurtosis, envelope_spectrum

この図に焼いた実測値
{
  "sk_max_kurtosis": 3.1037019867062785,
  "sk_max_freq": 2400.0,
  "sk_win": 64,
  "sk_window_ms": 2.5,
  "sk_bin_hz": 400.0,
  "sk_frames": 1597,
  "sk_noise_sigma": 0.10009388204226968,
  "stft_bins": 129,
  "stft_interior_frames": 199,
  "stft_total_frames": 203,
  "band_width_hz": 800.0,
  "best_band_centre": 3034.782608695652,
  "best_band_fraction": 0.6829578565909229,
  "bands_total": 24,
  "bands_returning_defect_rate": 9,
  "bands_returning_something_else": 15,
  "miss_peak_freq_range": [
    6.0,
    428.0
  ],
  "hit_band_fraction_range": [
    0.17317467053263255,
    0.6829578565909229
  ],
  "miss_band_fraction_range": [
    0.14732009808588267,
    0.16450564153139283
  ],
  "sk_band_fraction": 0.4494574621219424,
  "sk_band_peak_freq": 107.0,
  "worst_band_fraction": 0.14732009808588267
}

3. 窓長を間違えると負の尖度が出る

窓長を間違えると負の尖度が出る

窓長を間違えると負の尖度が出る ―― 衝撃が 9.346 ms ごとに来る軸受信号(真の共振 3000 Hz)で窓長を 16 から 512 まで掃引した。窓が衝撃の間隔より長くなるとどのフレームにも衝撃が 1 個ずつ入り、その帯域は構成上「定常」に見える。窓 256(10.00 ms)で最大 SK は -0.1269 ―― 負の値を、共振から 9200 Hz 離れた 12200 Hz で報告する。例外は出ない。窓を掃引することはこの op の使い方の一部であって最適化ではない。 使用 op: synthesize_bearing_signal, spectral_kurtosis

この図に焼いた実測値
{
  "impact_period_ms": 9.345794392523365,
  "true_resonance_hz": 3000.0,
  "table": [
    {
      "win": 16,
      "ms": 0.625,
      "max": 29.57722851209217,
      "at": 6400.0,
      "bin": 1600.0,
      "frames": 6397
    },
    {
      "win": 24,
      "ms": 0.9375,
      "max": 19.135220536597547,
      "at": 1066.6666666666667,
      "bin": 1066.6666666666667,
      "frames": 4263
    },
    {
      "win": 32,
      "ms": 1.25,
      "max": 12.854675024003651,
      "at": 1600.0,
      "bin": 800.0,
      "frames": 3197
    },
    {
      "win": 48,
      "ms": 1.875,
      "max": 7.878291532367296,
      "at": 533.3333333333334,
      "bin": 533.3333333333334,
      "frames": 2130
    },
    {
      "win": 64,
      "ms": 2.5,
      "max": 5.379627792794402,
      "at": 2000.0,
      "bin": 400.0,
      "frames": 1597
    },
    {
      "win": 96,
      "ms": 3.75,
      "max": 2.9401849728142526,
      "at": 2400.0,
      "bin": 266.6666666666667,
      "frames": 1063
    },
    {
      "win": 128,
      "ms": 5.0,
      "max": 1.660833522213224,
      "at": 1600.0,
      "bin": 200.0,
      "frames": 797
    },
    {
      "win": 192,
      "ms": 7.5,
      "max": 0.45085212215713133,
      "at": 8666.666666666668,
      "bin": 133.33333333333334,
      "frames": 530
    },
    {
      "win": 256,
      "ms": 10.0,
      "max": -0.12685129658601135,
      "at": 12200.0,
      "bin": 100.0,
      "frames": 397
    },
    {
      "win": 384,
      "ms": 15.0,
      "max": -0.5784282950393185,
      "at": 266.6666666666667,
      "bin": 66.66666666666667,
      "frames": 263
    },
    {
      "win": 512,
      "ms": 20.0,
      "max": -0.4994481614669002,
      "at": 800.0,
      "bin": 50.0,
      "frames": 197
    }
  ],
  "negative_windows": [
    {
      "win": 256,
      "ms": 10.0,
      "max": -0.12685129658601135,
      "at": 12200.0
    },
    {
      "win": 384,
      "ms": 15.0,
      "max": -0.5784282950393185,
      "at": 266.6666666666667
    },
    {
      "win": 512,
      "ms": 20.0,
      "max": -0.4994481614669002,
      "at": 800.0
    }
  ]
}

4. 次数比分析 — 角度領域で立場が逆転する

次数比分析 — 角度領域で立場が逆転する

次数比分析 — 角度領域で立場が逆転する ―― 600 → 1800 rpm の走行記録(4 s、5000 Hz、次数 1.0 と 3.5、固定共振 400 Hz、計 79.9940 回転)を 1.2 s の窓で滑らせる。素朴なスペクトルでは次数 3.5 が 0.070203(真値 1.0 の 7 %)まで潰れ、−3 dB 幅は 66.50 Hz に広がる。角度領域に置き直すと同じ成分が 0.999371、幅 0 bin (0.00000 次数)。逆に 400 Hz の固定共振は次数軸では平均回転数で次数 20.00 へ散る(振幅 0.025386)。この逆転が診断そのもの。 使用 op: synthesize_speed_ramp, spectrum, angular_resample, order_spectrum

この図に焼いた実測値
{
  "rpm_start": 600.0,
  "rpm_end": 1800.0,
  "duration_s": 4.0,
  "rate_hz": 5000.0,
  "total_revolutions": 79.9940001,
  "ordinary_order35_amp": 0.07020339787092662,
  "ordinary_order35_hz": 101.5,
  "ordinary_order35_width_hz": 66.5,
  "order_spectrum_order35_amp": 0.9993710550504145,
  "order_spectrum_order35_width": 0.0,
  "order_spectrum_order35_bins": 1,
  "resonance_order_at_mean_rpm": 20.00050001250031,
  "resonance_amp_in_order_domain": 0.025386071643316462,
  "window_s": 1.2,
  "frames": 30,
  "shaft_hz_first": 12.999500000000001,
  "shaft_hz_last": 26.9995
}

5. 軸受の幾何から欠陥周波数

軸受の幾何から欠陥周波数

軸受の幾何から欠陥周波数 ―― 1800 rpm、ピッチ径 40 mm の軸受で、転動体数 → 接触角 → 転動体径の順に掃引した(36 フレーム)。BPFO は 84.0000 → 177.8261 Hz、BPFI は 126.0000 → 270.3260 Hz まで動く。全フレームで BPFO + BPFI − N·f_r の最大絶対値は 0.000e+00、BPFO − N·FTF は 0.000e+00 ―― float64 で厳密にゼロで、これは d と D を取り違えると即座に壊れる恒等式である。数表からではなく幾何から再導出しているので、こう書ける。 使用 op: bearing_defect_frequencies

この図に焼いた実測値
{
  "rpm": 1800.0,
  "pitch_diameter_mm": 40.0,
  "frames": 36,
  "first": {
    "n_elements": 7,
    "element_diameter": 8.0,
    "contact_angle_deg": 0.0,
    "ratio": 0.2,
    "shaft_hz": 30.0,
    "ftf_hz": 12.0,
    "bpfo_hz": 84.0,
    "bpfi_hz": 126.0,
    "bsf_hz": 72.0
  },
  "last": {
    "n_elements": 14,
    "element_diameter": 15.0,
    "contact_angle_deg": 40.0,
    "ratio": 0.28726666616961677,
    "shaft_hz": 30.0,
    "ftf_hz": 10.691000007455747,
    "bpfo_hz": 149.67400010438047,
    "bpfi_hz": 270.32599989561953,
    "bsf_hz": 36.69911450031176
  },
  "max_abs_identity_1": 0.0,
  "max_abs_identity_2": 0.0,
  "bpfo_range": [
    84.0,
    177.8261333890029
  ],
  "bpfi_range": [
    126.0,
    270.32599989561953
  ]
}

6. A 特性・C 特性の重み付け ―― 1 kHz は構成上ちょうど 0 dB

A 特性・C 特性の重み付け ―― 1 kHz は構成上ちょうど 0 dB

A 特性・C 特性の重み付け ―― 1 kHz は構成上ちょうど 0 dB ―― 重み付け曲線は公表オフセット定数を足すのではなく自身の 1 kHz 値で割って作ってあるので、A(1000) も C(1000) も丸めではなく Python の float として厳密に 0.0 になる(実測 == 0.0 は True / True)。純音を 34 点掃引して equivalent_level の重み付き差 L_A − L_Z を曲線値 A(f) と突き合わせると、最大差は 7.11e-15 dB(C 特性は 4.88e-15 dB)。振幅 1 の正弦の L_eq(Z) は閉形式 10log10(A²/2) = -3.010300 dB で、実測もその値。ただしこれは音が bin 中心(記録に整数周期入る)にある場合の話で、同じ音を 1 Hz ずらすと同じ差が 21.0 Hz で 2.86 dB まで開く(図の下段、赤い曲線)。矩形窓の漏れ込みが 1 kHz 付近では 0 dB で重み付けされるため、A 特性が急峻な低域ほど実際より大きい値が返る。例外も NaN も出ない。 使用 op: weighting_response, apply_weighting, equivalent_level

この図に焼いた実測値
{
  "a_at_1k": 0.0,
  "c_at_1k": 0.0,
  "a_at_1k_is_exact_zero": true,
  "c_at_1k_is_exact_zero": true,
  "leq_z_closed_form_db": -3.010299956639812,
  "leq_z_measured_range": [
    -3.010299956639841,
    -3.0102999566398
  ],
  "max_abs_a_mismatch_db": 7.105427357601002e-15,
  "max_abs_c_mismatch_db": 4.884981308350689e-15,
  "bin_hz": 2.0,
  "off_bin_offset_hz": 1.0,
  "off_bin_max_abs_a_mismatch_db": 2.860008933302616,
  "off_bin_worst_freq_hz": 21.0,
  "n_tones": 34,
  "rate_hz": 48000.0,
  "duration_s": 0.5,
  "sample_points": {
    "20.0": {
      "A": -50.39042947681086,
      "C": -6.218824484255237
    },
    "68.0": {
      "A": -24.957730538737856,
      "C": -0.7009469720092589
    },
    "228.0": {
      "A": -9.544886650342692,
      "C": -0.011744795957637682
    },
    "766.0": {
      "A": -0.9765777140127547,
      "C": 0.02141634305233592
    },
    "2584.0": {
      "A": 1.2696291823486323,
      "C": -0.3201711900758198
    },
    "8714.0": {
      "A": -1.6153870384934494,
      "C": -3.521450393344838
    }
  }
}

7. funct1d の解析真値

funct1d の解析真値

funct1d の解析真値 ―― 答えが先に分かっている入力だけで組んだ 1 枚。derivate_funct_1d(sin)/dx と cos の最大差は 1.008e-04(格子 dx = 0.024592、中心差分は 2 次なので残差は dx² で効く)。zero_crossings_funct_1d が返す 3 個の交差は、線形内挿すると 1.000000π, 2.000000π, 3.000000π ―― 整数倍からの最大ずれ 7.397e-08。減衰振動からは周期 0.199500 s(真値 0.200000)、半周期 0.100000 s(真値 0.100000)、時定数 0.406307 s(真値 0.4)、遅延 25 サンプル(真値 25、微分で白色化してから照合)が戻る。 使用 op: derivate_funct_1d, integrate_funct_1d, zero_crossings_funct_1d, local_min_max_funct_1d, smooth_funct_1d_gauss, abs_funct_1d, get_pair_funct_1d, distance_funct_1d, match_funct_1d_trans, create_funct_1d_array

この図に焼いた実測値
{
  "derivative_max_error": 0.00010078909493371757,
  "dx": 0.024591723315771374,
  "zero_crossing_indices": [
    127,
    255,
    383
  ],
  "zero_crossing_x_over_pi": [
    0.9999999260312996,
    2.0,
    3.000000073968701
  ],
  "zero_crossing_max_deviation": 7.39687009421175e-08,
  "round_trip_max_error": 0.000151179880499952,
  "period_s": 0.1995,
  "period_true_s": 0.2,
  "half_period_s": 0.1,
  "half_period_true_s": 0.1,
  "tau_s": 0.40630736789098154,
  "tau_true_s": 0.4,
  "match_shift": 25,
  "match_shift_true": 25,
  "match_score": 0.7996386353789152,
  "n_peaks": 5,
  "n_zero_crossings": 8
}

8. 平滑化のトレードオフ

平滑化のトレードオフ

平滑化のトレードオフ ―― 減衰 5 Hz 振動 + N(0, 0.06) にガウス平滑を掛け、σ を 31 段掃引した。生の信号は真値 6 個の極大に対して 196 個を報告する(local_min_max_funct_1d は狭義不等式で、雑音モデルを持たない)。RMS 誤差は σ = 3.219 で最小の 0.021952(生の 2.73 倍良い)になり、そのときピーク高さは真値から -2.77 %。掛けすぎると σ = 40.0 で RMS 誤差が 0.249561 まで悪化し、ピークは -59.56 % なまる。雑音は減るが極値はなまる ―― 最小点はあるが、無料ではない。 使用 op: smooth_funct_1d_gauss, local_min_max_funct_1d

この図に焼いた実測値
{
  "true_maxima": 6,
  "true_peak": 0.8851703018329985,
  "raw_rmse": 0.05997372648665996,
  "raw_maxima": 196,
  "raw_peak": 0.9290455796778364,
  "best_sigma": 3.2189538239993025,
  "best_rmse": 0.021951581267836598,
  "best_peak": 0.8606477705412539,
  "best_maxima": 12,
  "best_gain": 2.7320914040271584,
  "best_peak_loss_pct": -2.77037438343376,
  "over_sigma": 39.99999999999999,
  "over_rmse": 0.24956077599878743,
  "over_peak": 0.3579369203167514,
  "over_peak_loss_pct": -59.562931610387224,
  "frames": 32
}

9. サンプリングとエイリアシング

サンプリングとエイリアシング

サンプリングとエイリアシング ―― 300 Hz の純音は一度も変えず、サンプリング周波数だけを 1300 Hz から 340 Hz へ 31 段下げた(0.5 s 記録、bin 2 Hz)。fs = 596 Hz(Nyquist 298 Hz)から折り返しが始まり、最後は fs = 340 Hz で 40.00 Hz に振幅 1.000000 の線が立つ ―― 高さは満額のまま、周波数だけが嘘。全 31 段で実測ピークと折り返しの予測 |f − fs·k| の差は最大 0.000 Hz。Nyquist の線から右は、この記録に原理的に存在し得ない領域として焼いてある。 使用 op: spectrum

この図に焼いた実測値
{
  "true_tone_hz": 300.0,
  "duration_s": 0.5,
  "rate_first": 1300.0,
  "rate_last": 340.0,
  "n_rates": 31,
  "first_alias_rate": 596.0,
  "first_alias_nyquist": 298.0,
  "max_abs_prediction_error_hz": 5.684341886080802e-14,
  "bin_resolution_hz": 2.0,
  "last": {
    "fs": 340.0,
    "nyquist": 170.0,
    "peak_hz": 40.0,
    "peak_amp": 1.0000000000000007,
    "expected": 40.0
  },
  "table": [
    {
      "fs": 1300.0,
      "nyquist": 650.0,
      "peak_hz": 300.0,
      "expected": 300.0,
      "peak_amp": 0.9999999999999993
    },
    {
      "fs": 1172.0,
      "nyquist": 586.0,
      "peak_hz": 300.0,
      "expected": 300.0,
      "peak_amp": 1.0000000000000013
    },
    {
      "fs": 1044.0,
      "nyquist": 522.0,
      "peak_hz": 300.0,
      "expected": 300.0,
      "peak_amp": 0.9999999999999993
    },
    {
      "fs": 916.0,
      "nyquist": 458.0,
      "peak_hz": 300.0,
      "expected": 300.0,
      "peak_amp": 0.9999999999999996
    },
    {
      "fs": 788.0,
      "nyquist": 394.0,
      "peak_hz": 300.00000000000006,
      "expected": 300.0,
      "peak_amp": 1.0000000000000002
    },
    {
      "fs": 660.0,
      "nyquist": 330.0,
      "peak_hz": 300.0,
      "expected": 300.0,
      "peak_amp": 0.999999999999999
    },
    {
      "fs": 532.0,
      "nyquist": 266.0,
      "peak_hz": 232.0,
      "expected": 232.0,
      "peak_amp": 1.0000000000000009
    },
    {
      "fs": 404.0,
      "nyquist": 202.0,
      "peak_hz": 104.0,
      "expected": 104.0,
      "peak_amp": 0.9999999999999986
    }
  ]
}

10. 1D プロファイルはどこから来るか

1D プロファイルはどこから来るか

1D プロファイルはどこから来るか ―― 2D 画像の測定線(実写真 coins、373 サンプル、最強エッジは添字 220.0)、3D ボリュームのプローブ(92 サンプル、壁厚 14.00 / 17.00 / 14.00 voxel)、センサー時系列(500 サンプル、rms 0.2687、スペクトル重心 387.0 Hz)。3 本とも素の 1-D float64 で届くので、funct1d はアダプタ無しでそのまま食える。1D ウィングに専用の型を作らなかったのはこのためで ―― 任意の実数 1-D はどの計器から来ても本当に正当なプロファイルであり、型を切ると接続を失うだけ。 使用 op: line_profile, profile_stats, vol_profile_line, vol_wall_thickness, signal_features, create_funct_1d_array, num_points_funct_1d, x_range_funct_1d, y_range_funct_1d, zero_crossings_funct_1d, local_min_max_funct_1d

この図に焼いた実測値
{
  "image_source": "studio_assets/sample_images/coins.png (skimage coins, real photo)",
  "profile2d": {
    "n": 373,
    "min": 0.08627450980392157,
    "max": 0.9529411764705882,
    "mean": 0.54881984965568,
    "edge_at": 220.0
  },
  "profile3d": {
    "n": 92,
    "length_voxels": 91.0,
    "min": 0.08,
    "max": 0.83,
    "wall_thicknesses": [
      14.0,
      17.0,
      14.0
    ]
  },
  "sensor": {
    "n": 500,
    "rate_hz": 2000.0,
    "rms": 0.268716,
    "zcr": 0.366733,
    "crest_factor": 3.9493,
    "centroid_hz": 386.98,
    "peak_freq_hz": 300.0,
    "bandwidth_hz": 237.537
  },
  "funct1d": [
    {
      "name": "2D image, measurement line",
      "op": "measure.line_profile",
      "n": 373,
      "xr": [
        0.0,
        372.0
      ],
      "yr": [
        0.08627450980392157,
        0.9529411764705882
      ],
      "nzc": 0,
      "nmax": 92
    },
    {
      "name": "3D volume, probe line",
      "op": "volprobe.vol_profile_line",
      "n": 92,
      "xr": [
        0.0,
        91.0
      ],
      "yr": [
        0.08,
        0.83
      ],
      "nzc": 0,
      "nmax": 0
    },
    {
      "name": "sensor time series",
      "op": "acoustics.synthesize_bearing_signal",
      "n": 500,
      "xr": [
        0.0,
        499.0
      ],
      "yr": [
        -0.9770901925470433,
        1.0612360539292967
      ],
      "nzc": 183,
      "nmax": 112
    }
  ]
}

11. 極値検出と照合

極値検出と照合

極値検出と照合 ―― 既知の 4 点(60, 150, 245, 330)に立てたガウスピークへ雑音を σ = 0 から 0.42 まで 30 段加えた。local_min_max_funct_1d は狭義不等式なので、生の波形では極大が 4 個から 132 個へ暴発する。σ = 3 のガウス平滑と高さ 0.45 の門を通すと最後まで 6 個([58, 149, 243, 254, 329, 337])に落ち着く。match_funct_1d_trans は同じ長さの窓とテンプレートを突き合わせるかぎり、30 段のうち 12 段(σ 0.159 まで)で 4 点すべて lag = 0 を厳密に返す。 使用 op: smooth_funct_1d_gauss, local_min_max_funct_1d, match_funct_1d_trans

この図に焼いた実測値
{
  "true_centres": [
    60,
    150,
    245,
    330
  ],
  "peak_sigma_samples": 9.0,
  "template_length": 81,
  "n_frames": 30,
  "sigma_max": 0.42,
  "raw_maxima_first": 4,
  "raw_maxima_last": 132,
  "smoothed_maxima_last": 22,
  "accepted_last": 6,
  "positions_last": [
    58,
    149,
    243,
    254,
    329,
    337
  ],
  "exact_lag_levels": 12,
  "total_levels": 30,
  "exact_lag_up_to_sigma": 0.1593103448275862
}

12. 包絡線の端が切れると 76 % 間違う

包絡線の端が切れると 76 % 間違う

包絡線の端が切れると 76 % 間違う ―― 12 µm の走査(241 plane × 0.05 µm)の中で、表面を中央 6.0 µm から端の 0.30 µm まで 32 段歩かせた。中央では誤差 2.2e-14 µm。表面が 0.500 µm にあると csi_peak_position は 0.1190 µm を返す ―― 有限で、もっともらしく、76 % 間違っている。しかも包絡線の argmax は 241 plane 中の 2 番目、つまり内部なので「端に張り付いたら拒否」という素直な検査は発動しない(掃引の最悪点は 0.30 µm の 84 % で、そこでも argmax は plane 1)。中央値基準の端レベルが 0.0539 を超えた表面 2.69 µm から op は拒否に転じる(図の値は max_edge_envelope=1.0 で強制的に取り出したもの)。 使用 op: csi_signal_simulate, csi_envelope, csi_peak_position

この図に焼いた実測値
{
  "scan_planes": 241,
  "z_step_um": 0.05,
  "z_range_um": 12.0,
  "wavelength_um": 0.6,
  "n_frames": 32,
  "surface_first": 6.0,
  "surface_last": 0.3,
  "first_refusal_surface": 2.690323,
  "first_refusal_edge": 0.05392259854284297,
  "worst_surface": 0.3,
  "worst_returned": 0.04768769253057824,
  "worst_rel_pct": -84.10410248980725,
  "worst_argmax_plane": 1,
  "documented_surface": 0.5,
  "documented_returned": 0.11898968048241321,
  "documented_rel_pct": -76.20206390351736,
  "documented_edge": 0.636140666887029,
  "documented_argmax_plane": 2,
  "centred_error_um": 2.220446049250313e-14,
  "centred_edge": 0.0,
  "table": [
    {
      "surface": 6.0,
      "edge": 0.0,
      "returned": 6.000000000000022,
      "rel_pct": 3.7007434154171886e-13,
      "argmax": 120,
      "refused": false
    },
    {
      "surface": 5.080645,
      "edge": 0.0,
      "returned": 5.080647081606085,
      "rel_pct": 4.0971295686360194e-05,
      "argmax": 102,
      "refused": false
    },
    {
      "surface": 4.16129,
      "edge": 0.0,
      "returned": 4.161139025318186,
      "rel_pct": -0.0036280740302651357,
      "argmax": 83,
      "refused": false
    },
    {
      "surface": 3.241935,
      "edge": 0.0,
      "returned": 3.239315199806879,
      "rel_pct": -0.08080976926190077,
      "argmax": 65,
      "refused": false
    },
    {
      "surface": 2.322581,
      "edge": 0.06881176874572165,
      "returned": 2.31045038505397,
      "rel_pct": -0.522290285937501,
      "argmax": 46,
      "refused": true
    },
    {
      "surface": 1.403226,
      "edge": 0.3399177997568482,
      "returned": 1.359190567949887,
      "rel_pct": -3.1381567937105688,
      "argmax": 27,
      "refused": true
    },
    {
      "surface": 0.5,
      "edge": 0.636140666887029,
      "returned": 0.11898968048241321,
      "rel_pct": -76.20206390351736,
      "argmax": 2,
      "refused": true
    }
  ]
}

13. 欠陥周波数が出てくるまで(工程)

欠陥周波数が出てくるまで(工程)

欠陥周波数が出てくるまで(工程) ―― 幾何から出した外輪通過周波数 BPFO = 108.0000 Hz でわざと鳴らした軸受記録を、7 工程で診断まで持っていく。生スペクトルでは欠陥率の振幅は 1.19e-02 しか無く、目立つのは 3024 Hz の構造共振(0.1175)。スペクトル尖度(窓 64、最大 4.5956 @ 2000 Hz)が復調帯域 1600–2400 Hz を選び(真の共振 3000 Hz より 1000 Hz 低い ―― SK が返すのは帯域であって線ではない)、帯域通過 → 包絡線 → 変換で 108.0000 Hz。それが幾何の BPFO 108.0000 Hz と 0.0000 % で一致する。正直な内訳: この帯域の band_fraction は 0.2250 で、同じ帯域に通した白色雑音の 0.2348 と区別がつかない。分けるのは突出度のほうで、30582 対 2666 である(共振をまたぐ 2600–3400 Hz を人が選べば band_fraction は 0.8368 まで上がる)。dsp.bandpass + dsp.envelope + rfft で手組みした結果と op の返りは 0.0e+00 で一致した(作り直していない証拠)。 使用 op: bearing_defect_frequencies, synthesize_bearing_signal, spectrum, spectral_kurtosis, bandpass, envelope, envelope_spectrum

この図に焼いた実測値
{
  "rpm": 1800.0,
  "n_elements": 9,
  "element_diameter_mm": 8.0,
  "pitch_diameter_mm": 40.0,
  "bpfo_hz": 108.0,
  "bpfi_hz": 162.0,
  "ftf_hz": 12.0,
  "bsf_hz": 72.0,
  "synth_defect_hz": 108.0,
  "carrier_hz": 3000.0,
  "rate_hz": 25600.0,
  "duration_s": 1.0,
  "raw_amplitude_at_defect": 0.011914549427139143,
  "raw_peak_amplitude": 0.11751702164005307,
  "raw_peak_hz": 3024.0,
  "sk_max_kurtosis": 4.595572911742822,
  "sk_max_freq": 2000.0,
  "sk_bin_hz": 400.0,
  "sk_win": 64,
  "band_low_hz": 1600.0,
  "band_high_hz": 2400.0,
  "envelope_peak_freq": 108.0,
  "envelope_peak_amplitude": 0.04283185557071618,
  "envelope_band_fraction": 0.22500945540780717,
  "envelope_prominence": 30581.617076490267,
  "envelope_resolution_hz": 1.0,
  "control_band_fraction": 0.23476298878207671,
  "control_prominence": 2665.7791158181667,
  "control_peak_freq": 335.0,
  "resonance_band": [
    2600.0,
    3400.0
  ],
  "resonance_band_fraction": 0.8367515281311655,
  "resonance_band_peak_freq": 108.0,
  "resonance_band_peak_amplitude": 0.19825734899498038,
  "resonance_band_prominence": 11164.842724089745,
  "manual_vs_operator_max_abs_diff": 0.0,
  "closest_rate_name": "BPFO",
  "closest_rate_hz": 108.0,
  "closest_rate_error_pct": 0.0,
  "steps": 7
}

14. 分数オクターブ帯域 ―― 偶数分数には 1 kHz 帯域が無い

分数オクターブ帯域 ―― 偶数分数には 1 kHz 帯域が無い

分数オクターブ帯域 ―― 偶数分数には 1 kHz 帯域が無い ―― 振幅 0.7 の 1000 Hz 純音を、1/1・1/2・1/3・1/6・1/12・1/24 オクターブで測った 6 枚。帯域レベルはどの分数でも閉形式 10log10(A²/2) = -6.108339 dB を返す(最大差 0.0e+00 dB)。違うのはどの帯域がそれを報告するかで、fraction が奇数 [1, 3] では 1000.000 Hz ちょうどを中心とする帯域があるが、偶数 [2, 6, 12, 24] では指数のオフセットにより 1000 Hz が帯域になり、同じエネルギーが 1188.50 Hz, 944.06 Hz, 971.63 Hz, 1014.50 Hz を中心とする半端な帯域から報告される。定義であって不具合ではないが、「1 kHz でのレベル」を引用するときに知っていないと嘘になる。空の帯域は −inf ではなく床(−200 dB)に落ちる。 使用 op: octave_bands, octave_spectrum

この図に焼いた実測値
{
  "tone_hz": 1000.0,
  "tone_amplitude": 0.7,
  "rate_hz": 48000.0,
  "duration_s": 0.5,
  "closed_form_db": -6.108339156354676,
  "max_abs_diff_from_closed_db": 0.0,
  "fractions_with_exact_1k": [
    1,
    3
  ],
  "fractions_without_exact_1k": [
    2,
    6,
    12,
    24
  ],
  "table": [
    {
      "fraction": 1,
      "n_bands": 10,
      "max_level": -6.108339156354676,
      "max_center": 1000.0,
      "exact_1k": true,
      "diff_from_closed": 0.0,
      "clamped": 9,
      "total_level": -6.108339156354676,
      "nominal_at_max": 1000.0,
      "bandwidth_at_max": 704.5917602386166
    },
    {
      "fraction": 2,
      "n_bands": 20,
      "max_level": -6.108339156354676,
      "max_center": 1188.5022274370185,
      "exact_1k": false,
      "diff_from_closed": 0.0,
      "clamped": 19,
      "total_level": -6.108339156354676,
      "nominal_at_max": 1190.0,
      "bandwidth_at_max": 412.53754462275447
    },
    {
      "fraction": 3,
      "n_bands": 30,
      "max_level": -6.108339156354676,
      "max_center": 1000.0,
      "exact_1k": true,
      "diff_from_closed": 0.0,
      "clamped": 29,
      "total_level": -6.108339156354676,
      "nominal_at_max": 1000.0,
      "bandwidth_at_max": 230.76751616821775
    },
    {
      "fraction": 6,
      "n_bands": 59,
      "max_level": -6.108339156354676,
      "max_center": 944.0608762859234,
      "exact_1k": false,
      "diff_from_closed": 0.0,
      "clamped": 58,
      "total_level": -6.108339156354676,
      "nominal_at_max": 944.0,
      "bandwidth_at_max": 108.74906186625446
    },
    {
      "fraction": 12,
      "n_bands": 118,
      "max_level": -6.108339156354676,
      "max_center": 971.6279515771062,
      "exact_1k": false,
      "diff_from_closed": 0.0,
      "clamped": 117,
      "total_level": -6.108339156354676,
      "nominal_at_max": 972.0,
      "bandwidth_at_max": 55.939123714076686
    },
    {
      "fraction": 24,
      "n_bands": 237,
      "max_level": -6.108339156354676,
      "max_center": 1014.4952080687361,
      "exact_1k": false,
      "diff_from_closed": 0.0,
      "clamped": 236,
      "total_level": -6.108339156354676,
      "nominal_at_max": 1010.0,
      "bandwidth_at_max": 29.200527194428332
    }
  ]
}

ボクセルの色分けウィング ―― 3D のラベルは、切る前に色を付ける

ボクセルの色分けウィング ―― 展示キャプション原稿

生成元: tools/gen_wingvox_gallery.py(py -3.11 tools/gen_wingvox_gallery.py)。
画像はすべて fullseye の op(volcolor / volops / render3d)と numpy 合成で
描いており(matplotlib 不使用)、図に焼いた数値は 1 つ残らずその場で op を呼んで
得た実測値である。乱数は seed 固定・幾何も固定なので再生成でバイト列が一致する
(--verify で検査)。

このウィングの主張は 1 つ ―― 3-D のラベルは、切る前に色を付けなければならない
断面ごとに色を付けるとラベル番号が断面ごとに振り直され、同じ部品が層ごとに
別の色になる。展示 2 がその差を本数で示す。

束ね方は tools/exhibit_tile.py の 3 種に従う。静止画の Markdown は
すべて サムネイル表示 + クリックで原寸 の形で出してある。

1. 色分けしたボクセルの断面送り

色分けしたボクセルの断面送り

色分けしたボクセルの断面送り ―― 16 粒子を 26 連結でラベリングし、ボリュームのまま色を付けてから 24 枚の断面へ切り出した。1 つの粒子は最初から最後まで 1 色 (実測: 全 16 成分の色数が 1)。spacing (0.50, 0.20, 0.20) mm で 総体積 62.560 mm3。 使用 op: vol_label, vol_colorize_labels, vol_label_slice_rgb, vol_label_shape_stats, vol_label_palette

この図に焼いた実測値
{
 "components": 16,
 "slices": 24,
 "shape": [
  24,
  48,
  48
 ],
 "spacing_mm": [
  0.5,
  0.2,
  0.2
 ],
 "total_volume_mm3": 62.56,
 "colours_per_component": 1
}

2. ちらつきの対比 ―― 違うのは色を付ける順序だけ

ちらつきの対比 ―― 違うのは色を付ける順序だけ

ちらつきの対比 ―― 違うのは色を付ける順序だけ ―― 左は断面ごとに 2-D ラベリングして色を付けたもの。断面が変わるたびに番号が振り直されるので、20 / 24 断面で少なくとも 1 粒子の色が変わる ((粒子, 断面) の変化 62 / 108 組 = 57.4 %、16 粒子すべてが一度は変わる)。右はボリュームで色を付けてから切ったもので、変化は 0 断面 / 0 組。同じパレット・同じ seed で、違うのは順序だけである。 使用 op: vol_label, vol_label_color_flicker, vol_colorize_labels, vol_label_slice_rgb, colorize_labels

この図に焼いた実測値
{
 "components": 16,
 "slices": 24,
 "per_slice_changed_slices": 20,
 "per_slice_changed_pairs": 62,
 "pairs_checked": 108,
 "per_slice_changed_components": 16,
 "flicker_rate_pct": 57.4,
 "volume_changed_slices": 0,
 "volume_changed_pairs": 0,
 "burned_in_running_total": 20
}

3. 6 / 18 / 26 連結 ―― 近傍の定義が成分数を決める

6 / 18 / 26 連結 ―― 近傍の定義が成分数を決める

6 / 18 / 26 連結 ―― 近傍の定義が成分数を決める ―― 同じ 2 つの立方体でも、頂点 1 点だけで接している場合は 6 連結 2 成分 / 18 連結 2 成分 / 26 連結 1 成分、稜線で接している場合は 2 / 1 / 1 となる。色数は成分数にそのまま連動する ―― 融合すれば色が 1 つ減る。 使用 op: vol_label, vol_label_volume_render, vol_label_palette

この図に焼いた実測値
{
 "corner": {
  "6": 2,
  "18": 2,
  "26": 1
 },
 "edge": {
  "6": 2,
  "18": 1,
  "26": 1
 }
}

4. 体積でふるいにかけても、残った粒子の色は動かない

体積でふるいにかけても、残った粒子の色は動かない

体積でふるいにかけても、残った粒子の色は動かない ―― min_volume を 0 から 9.320 mm3 まで 17 段で上げ、粒子を 1 つずつ落としていく。落ちた粒子は背景になるが、残った粒子の色は 1 画素も変わらない(全 17 コマで実測・確認)。番号を振り直さない (relabel=False)からで、振り直すとパレットの行が動いて色は総取り替えになる。 使用 op: vol_label, vol_label_shape_stats, vol_select_labels, vol_label_volume_render, vol_colorize_labels

この図に焼いた実測値
{
 "components": 16,
 "steps": 17,
 "volumes_mm3": [
  0.56,
  0.56,
  0.66,
  0.66,
  1.62,
  1.88,
  1.88,
  3.42,
  3.42,
  3.48,
  5.14,
  5.76,
  5.76,
  9.22,
  9.22,
  9.32
 ],
 "all_colours_unchanged": true,
 "sweep": [
  {
   "min_volume_mm3": 0.0,
   "kept": 16,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 0.56,
   "kept": 16,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 0.56,
   "kept": 16,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 0.66,
   "kept": 14,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 0.66,
   "kept": 14,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 1.62,
   "kept": 12,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 1.88,
   "kept": 11,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 1.88,
   "kept": 11,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 3.42,
   "kept": 9,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 3.42,
   "kept": 9,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 3.48,
   "kept": 7,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 5.14,
   "kept": 6,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 5.76,
   "kept": 5,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 5.76,
   "kept": 5,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 9.22,
   "kept": 3,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 9.22,
   "kept": 3,
   "colours_unchanged": true
  },
  {
   "min_volume_mm3": 9.32,
   "kept": 1,
   "colours_unchanged": true
  }
 ]
}

5. 元の CT に色ラベルを重ねる ―― α を掃引する

元の CT に色ラベルを重ねる ―― α を掃引する

元の CT に色ラベルを重ねる ―― α を掃引する ―― 断面 z=8 で alpha を 0 から 1 へ往復させる。前景の平均変化は 0.0000 → 0.0262 と alpha に直線で比例し、背景の変化は alpha に依らず 0.0000(色はラベルの上にしか乗らない)。輪郭だけ塗る mode='boundary' なら前景 3128 ボクセルのうち 1648(52.7 %)しか塗らないので、下の構造が完全に見える。 使用 op: vol_label, vol_label_overlay, vol_label_slice_rgb

この図に焼いた実測値
{
 "slice": 8,
 "components": 16,
 "frames": 20,
 "sweep": [
  {
   "alpha": 0.0,
   "fg_mean_abs_diff": 0.0,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.1,
   "fg_mean_abs_diff": 0.0262,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.2,
   "fg_mean_abs_diff": 0.0524,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.3,
   "fg_mean_abs_diff": 0.0786,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.4,
   "fg_mean_abs_diff": 0.1048,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.5,
   "fg_mean_abs_diff": 0.131,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.6,
   "fg_mean_abs_diff": 0.1572,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.7,
   "fg_mean_abs_diff": 0.1834,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.8,
   "fg_mean_abs_diff": 0.2096,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.9,
   "fg_mean_abs_diff": 0.2358,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 1.0,
   "fg_mean_abs_diff": 0.262,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.9,
   "fg_mean_abs_diff": 0.2358,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.8,
   "fg_mean_abs_diff": 0.2096,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.7,
   "fg_mean_abs_diff": 0.1834,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.6,
   "fg_mean_abs_diff": 0.1572,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.5,
   "fg_mean_abs_diff": 0.131,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.4,
   "fg_mean_abs_diff": 0.1048,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.3,
   "fg_mean_abs_diff": 0.0786,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.2,
   "fg_mean_abs_diff": 0.0524,
   "bg_mean_abs_diff": 0.0
  },
  {
   "alpha": 0.1,
   "fg_mean_abs_diff": 0.0262,
   "bg_mean_abs_diff": 0.0
  }
 ],
 "bg_untouched_at_every_alpha": true,
 "boundary_voxels": 1648,
 "fill_voxels": 3128,
 "boundary_share_pct": 52.7
}

6. 色付きメッシュのターンテーブル

色付きメッシュのターンテーブル

色付きメッシュのターンテーブル ―― 16 個の成分それぞれの bbox 部分体に marching cubes をかけ、三角形 7088 枚のメッシュ 16 個にした。頂点は spacing (0.50, 0.20, 0.20) mm を掛けた物理座標で、render3d.render_mesh の z バッファで合成している。粒が縦に伸びて見えるのはそのほうが正しいからで、z の刻みが面内の 2.5 倍あるためである(展示 4 と同じ話)。色は断面図とまったく同じパレットの同じ行なので、切った絵と回した絵で同じ粒子を目で追える。 使用 op: vol_label, vol_labels_to_meshes, look_at, intrinsics_from_fov, render_mesh

この図に焼いた実測値
{
 "components": 16,
 "meshes": 16,
 "triangles": 7088,
 "azimuth_steps": 24,
 "render_px": 380,
 "spacing_mm": [
  0.5,
  0.2,
  0.2
 ],
 "colours_match_slices": true
}

7. 凡例つきの計測表 ―― どの色がどの粒子か

凡例つきの計測表 ―― どの色がどの粒子か

凡例つきの計測表 ―― どの色がどの粒子か ―― 色分けした図は、凡例が無ければ「きれいなだけ」で終わる。16 粒子の色見本・体積 mm3・全体比・等価直径・球形度・伸長度・視野端への接触を並べた。総体積 62.5600 mm3、比率の合計 1.000000。1 ボクセル = 0.020000 mm3。最大は #2ddc8a の 9.3200 mm3、最小は #15d4c9 の 0.5600 mm3。 使用 op: vol_label, vol_region_props, vol_label_shape_stats, vol_label_legend, vol_label_palette

この図に焼いた実測値
{
 "components": 16,
 "spacing_mm": [
  0.5,
  0.2,
  0.2
 ],
 "total_volume_mm3": 62.56,
 "share_sum": 1.0,
 "voxel_volume_mm3": 0.02,
 "largest": {
  "label": 5,
  "hex": "#2ddc8a",
  "volume_mm3": 9.32
 },
 "smallest": {
  "label": 16,
  "hex": "#15d4c9",
  "volume_mm3": 0.56
 },
 "sphericity_range": [
  0.587,
  0.662
 ]
}

設計思想(4本の柱)

Fullseye が「ただの関数の寄せ集め」にならないための背骨です。

  1. 正直さを構造に(honest by construction)
    hold-out は選択に使わない。カバレッジもベンチも主張でなく実測。限界は隠さず開示。良すぎる結果は内訳を疑う。
  2. 統一インターフェース(unified I/F)
    中身が自作でも OSS ラッパでも、使う側は同じ命名・シグネチャ規約で呼べる。しかも人間が書いて自然な APIであること ―― fs.stereo.SGM(num_disparities=128).compute(l, r) のような、補完が効いて読める書き味を目指しています(機械的な文字列ディスパッチは裏に隠す)。これは私が特にこだわった点です。
  3. 重い依存はぜんぶオプション
    numpy + scipy だけでコアが動く。OpenCV / scikit-image / torch / GPU は opt-in で、無ければその op だけが静かに無効化される(graceful degradation)。
  4. 公開知識からの再実装
    すべて論文・OSS などの公開知識から実装。特定の商用製品から派生させたものではありません(この一線は明確に引いています)。

4本柱と書きましたが、実際にはこれは4本が独立に立っているわけではなく、1本の背骨から4方向に枝が伸びているという感覚に近いです。もう少し掘っておきます。

**「正直さを構造に」**は、本記事のほぼ全ての節に顔を出す一番太い幹です。層②の hold-out 規律、HALCON カバレッジの章別開示、公開前夜の CI 章、バグ①〜⑥の追試ノート――どれも根っこは同じで、「良い数字だけを見せると、いずれ自分自身も騙される」という警戒心です。これは技術的な選択というより、開発を長く続けるための生活習慣に近いと思っています。1回だけ良い数字を出すのは簡単ですが、同じ規律を100回繰り返すのは、構造として強制しない限り続きません。

「統一インターフェース」にこだわったのは、Fullseye が最終的にAI の道具箱として使われることを狙っているからです。中身が自作 numpy なのか OpenCV のラッパなのかで呼び方が変わるライブラリは、人間にとっても AI にとっても覚えることが増える。呼び方が1つに揃っていれば、AI は「この処理をしたい」という意図から、迷わず正しい呼び出しに辿り着けます。fs.stereo.SGM(num_disparities=128).compute(l, r) のような書き味にこだわったのは、Python の補完機能がそのままドキュメントとして機能してほしいという考えからです。IDE が型を教えてくれるなら、それ自体がミニマルなオンボーディングになります。

「重い依存はぜんぶオプション」は、地味に見えて実は一番 honest disclosure と近い場所にある柱です。「torch が無いと動かない」と正直に言うのではなく、「torch が無くても numpy 経路で動く。torch があればもっと速い経路が使える」という段階的な正直さを選んでいます。これは「公開前夜」の章で語った CI のバグの多くが、この柱の運用の甘さ(optional 依存のガード漏れ)から生まれたことの裏返しでもあります。柱として掲げるのは簡単ですが、掲げた柱を実際に守れているかを検証するのは、また別の仕事だということを、あの CI の一件で思い知らされました。

コア numpy+scipy と optional extras の依存マップ

↑ この柱を絵にした依存マップ ―― 必須依存は numpy + scipy の 2 つだけで、OpenCV / scikit-image / torch / GUI / 点群 I/O / 産業 I/O はすべて opt-in の extras。pyproject.toml の実定義から機械生成しています(extras が増えたのに図が古いままなら生成スクリプトが fail する作り)。

「公開知識からの再実装」は、一番地味ですが一番譲れない一線です。産業用の商用ソフトウェアからコードを持ってくれば、表面的にはもっと早く"追いつく"ことができたかもしれません。それをしないのは、倫理的な一線であると同時に、長期的に見て自分の理解が深まる道でもあるからです。論文を読み、アルゴリズムを自分の手で書き直すという工程を飛ばさないことで、「なぜこの op がこの結果を返すのか」を本当に説明できる状態を保っています。


AI に画像処理をさせられる ―― RAG として使う

Status: PoC ―― RAG セットアップ自体は同梱・動作確認済み。AI に op を選ばせて自律実行させる運用はまだ実験段階です。

これは Fullseye の隠れた優位点です。前述の通り、約1000個の op はそれぞれ Markdown ノート(使い方・型契約・関連 op・参考文献つき)を持ち、しかも中身が説明できる古典アルゴリズムです。この2つがそろうと、AI コーディングアシスタント(Claude Code など)の RAG(Retrieval-Augmented Generation、検索拡張生成)知識ベースとしてそのまま機能します。

3段階でかみくだくと:

  1. ひとことで:「この画像から○○を検出して」と AI に頼むと、AI が Fullseye の op ドキュメントを引いて(retrieve)、適切な op を組み合わせたパイプラインを書いて実行してくれる。
  2. もう少し:AI は op の型(sort)を読めるので、「画像 → 領域 → 特徴量」と型が繋がる順に op を選べる。関連 op リンクをたどって代替案も出せる。深層学習の「ブラックボックスな1関数」と違い、各段が説明でき、途中結果を検査できるので、AI が誤った時もどこで外したかが分かる
  3. なぜ効くか:ドキュメントが機械可読な単一真実源(md=SoT)で、op が決定論的・型付き・contract-tested だから。AI にとって"引きやすく・組みやすく・検証しやすい"部品箱になっている。人間が Studio で試すのと、AI が RAG で組むのが、同じ op・同じドキュメントの上で成り立ちます。

SKILL.md が何を固定するか

「AI が Fullseye の op ドキュメントを引く」というのは、掛け声だけでは動きません。実際にどう固定しているかを書きます。

pip install fullseye した環境で fullseye-rag を実行すると、同梱スキル skills/fullseye-ops~/.claude/skills/fullseye-ops にコピーされます。このとき、スキル本体の SKILL.md にある FULLSEYE_REPO = という1行が、実行環境に応じたコーパスの場所に自動で書き換わります。これが何を意味するか――AI(Claude Code)は、どのプロジェクトフォルダで作業していても、「Fullseye の op コーパスはここにある」と迷わず分かるようになります。パスが固定されていなければ、AI は毎回リポジトリの場所を探し回ることになり、探せなければ何も引けません。1行のパス固定が、RAG が実用として機能するかどうかを分けています。

もう1つの fail-closed な工夫が、コーパスの実体(docs/ops または OP_CATALOG.md)が見つからない場合はインストール自体を拒否することです。既存のスキルがあれば上書き前にタイムスタンプ付きで退避してから更新するので、再インストール(=更新)が手編集を黙って消すこともありません。「一応入れておくけど中身が空」という中途半端な状態を作らない――入れるなら動く状態で入れる、動かないなら入れない、という規律です。

checkout モードと wheel モード、2つの入り方

fullseye-rag(実体は fullseye/rag_setup.py)は、インストールのされ方を見て、コーパスの中身を自動で変えます

  • checkout モード:git clone した、あるいは pip install -e . した環境では、パッケージの隣に docs/ops/INDEX.md が実在します。この場合は docs/ops の**フルコーパス(per-op ノート約1000枚)**がそのままピン留めされます。
  • wheel モード:PyPI から pip install fullseye しただけの環境では、docs/ops は同梱されません(コーパスはリポジトリの中身であって、wheel の中身ではないからです)。この場合は、パッケージに同梱された **OP_CATALOG.md(AI 向けの全 op 一枚カタログ)**がピン留めされ、SKILL.md には「フルの per-op ノートが欲しければリポジトリを clone してください」という案内が明示的に書き込まれます。

どちらのモードでコーパスの実体(docs/ops/INDEX.md または OP_CATALOG.md)が見つからなくても、インストールはその場で拒否されます――空のスキルを入れて後で「動かない」と困らせない、fail-closed の徹底です。

実際に聞いてみると、何が返ってくるか

具体的な使用感を、実際の質問例で書いておきます。pip install fullseye してから fullseye-rag を1回叩いておけば、以降はどのディレクトリで Claude Code を開いていても、次のようなやり取りが成立します。

人間:「この基板画像から欠陥っぽい傷を検出したい」

AI(fullseye-ops スキル経由)docs/ops を検索し、in:/out: の型が「画像 → 画像(強調)→ 領域(二値)→ 輪郭(計測)」と繋がる op を選定。例えば「ノイズ抑制に bilateralgaussian、エッジ強調に sobel_ampdog(Difference of Gaussians)、二値化に otsu か適応的しきい値、輪郭抽出に edges_sub_pixselect_contours」といった候補を、各 op の型契約と HALCON 別名を根拠に提示し、fullseye.run_pipeline(img, [...]) のコードをその場で書いて実行する。

もう1つ、工業寄りの例も挙げておきます。

人間:「この部品、決まった向きで置かれていないので、掴む前にまず位置と向きを知りたい」

AI(fullseye-ops スキル経由)docs/opsmatching カテゴリを引き、shape_locate(HALCON find_shape_model 相当、輪郭ベースの形状マッチング)か ncc_locate(HALCON find_ncc_model 相当、テンプレートマッチング)を候補として提示。型が image → match と繋がることを確認した上で、位置・角度を返すコードを書いて実行する。

どちらの例でも、AI が根拠にしているのは同じコーパス(docs/ops)と同じ型契約です。「検査ラインの欠陥検出」も「ロボットの位置決め」も、AI から見れば同じ RAG が答える同じ種類の質問でしかない――層①で紹介した op 体系が、Physical AI と産業ビジョンの両方に効いてくるのは、この RAG の仕組みのおかげでもあります。

この応答の裏でAIがやっているのは、ベクタ検索でも埋め込み類似度計算でもありません。docs/opsgrep で引き、各ノートの in:/out: 記載を読んで型を繋げているだけです(SKILL.md の「Retrieval recipes」がそのまま手順書)。だからこそ特別なベクタ DB や埋め込みサービスが要らず、grep できる環境ならそのまま RAG として機能するわけです。checkout 環境なら約1000枚の per-op ノートから、wheel 環境なら OP_CATALOG.md の一覧から、それぞれ根拠を引いてきます。提案されたコードを鵜呑みにする必要もありません。全 op に ground-truth 付きの worked example が付属しているので、AI 自身が py -3.11 examples/<id>.py を実行して「PASS」を確認してから返答する、という自己検証のループも組み込めます。

RAG コーパスの実物: per-op ノートと 3 ステップ

↑ RAG コーパスの実物 ―― docs/ops/2d/smoothing/bilateral.md の frontmatter(型契約 in:/out:、HALCON 別名、著者・ライセンス・版)と「型が繋がる次の op」リンク。右は AI がやっている 3 ステップで、③の PASS 行はこの図の生成時に worked example を実行して得た実出力。

もし AI が型の繋がらない op を誤って選んでしまったらどうなるか、も書いておきます。答えは「層①の型契約」の節に書いた通り単純です。run_pipeline が実行時に明示的に落ちます。AI にとってこれは、人間に対する不親切なエラーではなく、「ここで型が繋がっていない」という機械可読なフィードバックそのものです。AI はそのエラーメッセージを読んで、繋がる型の op に選び直す――という訂正のループが、人間が介入しなくても回ることがあります。深層学習の1関数が「なんとなく違う答えを返す」のと違い、型契約は間違いを黙って飲み込まず、その場で顕在化させるので、AI にとっても人間にとっても、どこで迷子になったかが追いやすくなっています。

fail-closed 設計 ―― コーパスが無ければ黙って諦めない

RAG まわりの設計にも、本記事で繰り返し出てくる fail-closed の哲学が貫かれています。コーパスの実体が見つからない環境ではインストールを拒否する。更新スクリプト(tools/update_fullseye.py)は、未コミットの変更があれば拒否し、--ff-only(fast-forward のみ)でしか進めず、RAG スキルはバックアップを取ってから更新し、Studio の設定には一切触れません。「AI 用の下地を強くするための更新が、人間の作業環境を壊す」という事故を、構造として起こさないようにしてあります。

つまり Fullseye は「人間が使うライブラリ」であると同時に、**「AI に画像処理を自由にさせるための下地」**でもある。説明できる古典ビジョンをスキルとして揃えたことが、そのまま AI との相性の良さに直結しています。

そして 推奨する運用はこの RAG 型です。その上で Studio を組み合わせると、AI エージェント(Claude Code など)が組んだパイプラインの結果を画像ウィンドウや 3D 表示画面として人間の目の前に出せる ―― コードと会話だけで閉じず、「AI が見ているもの」を人間が同じ画面で検査できる。狙っている位置づけは、Physical AI(ロボット知覚)の統合環境です。

具体的な絵で言うとこうです。Studio を横に開いた Claude Code に「このビンの中の部品を数えて、つまめる姿勢まで出して」と頼む → AI が op ノートを引いてセグメンテーション→3D 姿勢推定のパイプラインを書いて実行 → 結果が Studio の画像ウィンドウと 3D 表示に出る → 人間は画面を見て、おかしければ日本語でやり直しを頼む。会話と画面だけで画像処理が回る、という運用を狙って作ってあります(部品は全部この記事で紹介したもの ―― 約1000 op×型契約×機械可読ノート×Studio の描画層)。

ちなみにこの開発自体も、設計判断と方向決めは私が握りつつ、実装の相棒として Claude Code と組んで進めています。試してみたい方は作者の紹介リンクから始めてもらえると、この開発の継続にちょっと貢献できます(正直な開示:紹介リンクです)。

もうひとつ意識しているのが学術利用です。全 op が参考文献つきの機械可読ノート(md=SoT)を持ち、版が fingerprint で code に固定され、評価は hold-out で正直 ―― つまり引用でき、追試できる形を最初から作ってあります。リポジトリには CITATION.cff を置き、文献は実在する古典(DOI を捏造しない)だけを引いています。研究の道具として使われ、参照される ―― そうなったら嬉しい、という設計です。

学術利用と RAG 運用は、実は相性が良い組み合わせです。研究で新しい前処理パイプラインを試すとき、既存手法との比較に時間を取られがちですが、Fullseye なら AI に「この論文の手法に近い前処理を組んで」と頼めば、引用元が明記された opから候補を出してくれる。出てきたコードは worked example で検証済みの部品の組み合わせなので、「このベースラインは本当に正しく実装されているか」という不安を、ゼロから確かめ直す手間が減ります。研究の再現性という観点でも、md=SoT の構造がそのまま効いてくる場面です。


「正直さ」を仕組みにする ―― 実際にバグを捕まえた話

この開発では honest gate(正直ゲート)という考え方を随所に置いています。「期待挙動を数値で検証してから採用する」関門です。そして本当に効いているかは、バグを捕まえた実績で判断すべきです。

この総集編を準備する一連の作業だけで、品質チェックが確定バグを6件見つけました。 隠さず載せます。前半2件はテストのすり抜け方が、後半4件は「AI に敵対的レビューをさせ、その指摘を人手で一次検証する」という発見経路が、それぞれ教訓になっています。

バグ①:3D トポグラフィック分類の Hessian が非対称になっていた

地形の凸凹を「山・谷・尾根・鞍点」に分類する op で、ヘッセ行列(Hessian)の交差項np.gradient間違った軸から取っていました。np.gradient[∂/∂y, ∂/∂x] の順で返すのに、交差項 ∂²/∂x∂y のつもりで ∂²/∂y²(別の項の重複)を拾っていたのです。

  • 影響:軸に対して斜めの構造で、固有値が半減したり符号が飛んだりして、分類を誤る。
  • 確かめ方(追試可能):正しい分類は転置に対して同じになるはず(画像とその転置は「山・谷」が入れ替わらない)。実測すると、旧コードは 24×24 の斜め構造で 576 画素中 46 画素を誤分類、修正版は完全に転置対称になりました。
  • 対策:交差項を正しい軸から取り、転置対称性を回帰テストに固定

この「確かめ方」の選び方自体が、honest gate の考え方をよく表していると思います。ヘッセ行列の交差項が正しく計算できているかを直接検算するのは、数式を目で追うだけでは骨が折れます。それよりも、「正しければ絶対に成り立つはずの対称性」を1つ見つけて、それが崩れていないかを機械的に確認する方が、遥かに再現性のあるテストになります。軸を斜めに取り違えていれば転置対称性が崩れる、というのは、この op の数学的な性質から演繹的に導ける検算方法であり、単に「動いたから正しい」と判断するテストとは別物です。

バグ②:Studio の op ヘルプ・ドロップダウンが全 op で壊れていた

2D と 3D をまたいで op ヘルプを引けるようにした際、内部で (次元, 名前) のタプルを、表示関数(名前, 次元)そのまま展開して渡していた(引数が逆)。両方とも文字列なのでエラーにならず、どの op を選んでも空のカードが出る、というユーザーに見える不具合でした。

  • なぜテストをすり抜けたか:既存テストは表示関数を正しい引数順で直接呼んでいて、実際のドロップダウン操作(シグナル経由)を通していなかった。
  • 対策:引数順を修正し、実際のドロップダウン操作を再現する回帰テストを追加。さらに「同じ失敗は似た箇所で再発する」ので、同種の引数順ミスを全ディスパッチ箇所で洗い直し(他にないことを確認)。

この1件は、①(数式の軸の取り違え)とは種類が違う教訓を残しています。①は計算の中身が間違っていたバグでしたが、②は計算自体は正しく、GUI の配線(どの値をどの順番で渡すか)が間違っていたバグでした。しかも (次元, 名前)(名前, 次元) もどちらも文字列のペアなので、Python は型エラーを一切出しません。**「型としては合っているが、意味としては入れ替わっている」**という壊れ方は、層①で紹介した型契約(image/region/feature などの sort)では検出できない領域です。ユニットテストが関数を直接呼ぶだけでなく、実際の GUI 操作の経路(シグナル→スロット)を通すところまで踏み込まないと、この種の配線ミスは見つからない、という教訓です。

バグ③:PCA 姿勢推定が、約半分の確率で「何もしない回転」を黙って返していた

点群の主軸を合わせて姿勢(回転)を推定する op で、固有ベクトル分解(eigh)が返す枠の掌性(右手系か左手系か)を考慮していませんでした。数学的には、符号候補を作る行列の行列式が恒等的に +1 になってしまう構成で、左手系の枠を引くと4つの候補が全部棄却され、エラーも警告もなく恒等回転(=何もしない)にフォールバックしていたのです。

  • 実測:ランダムな点群と回転で 200 試行 → 92 回(46%)が恒等回転。修正後は 200/200 で機械精度(1e-15 台)の復元
  • なぜテストをすり抜けたか:既存テストの乱数 seed が、たまたま「運のいい 54%」側に落ちていた。1つの seed で通ったことは、半分の入力で壊れていないことを意味しない
  • 対策:分解直後に枠を右手系へ正準化。回帰テストはランダム 40 試行を掃く形にして、両方の掌性を必ず踏むようにしました。

バグ④:曲率の「絶対値」が32倍ずれていた(比率だけは合っていた)

曲面の曲がり具合(curvedness)を出す op が、利得 32 の勾配フィルタ(意図的な規約で、他の消費側は明示的に 32 で割って補正済み)と正規化済みのヘッセ行列を混ぜていました。曲面の形の分類(shape index)はなので正しく出る一方、絶対値だけが 1/32 ―― 「半径 R の球なら曲率 1/R」のはずが 1/(32R) になっていた。

  • 見つけ方:半径既知の合成球で絶対値を検算 ―― 実測 0.0022 vs 真値 0.0714、きっちり 32.2 倍差(利得の由来 2×4×4=32 と一致)。
  • 教訓:「比率のテスト」は通っていました。単位系・絶対値の真値まで検証して初めて捕まるバグがある。修正後は c·R = 1.004(球殻の実測)で、回帰テストも絶対値で固定しました。

バグ④修正後の検証: 球の曲率が 1/R に乗る(実測)

↑ 修正後の検証を本図のために再実測したもの。半径 R の合成球(2000 点)へ curvedness を実行すると、実測中央値が理論値 1/R の線に乗る(中央値×R = 1.009)。破線は修正前の系統誤差 1/(32R) ―― 比率だけ合って絶対値が 1/32 だった場所。

バグ③④の発見経路は①②と違い、AI に敵対的なコードレビューをさせ、その指摘を鵜呑みにせず人手で一次検証(数値再現)してから直す、という流れです。AI レビューの指摘は外れることも多いので、「指摘→自分で再現→初めて採用」の関門を必ず挟みます。

同じ流れの公開前の総ざらい(第2ラウンド)では、さらに2件の確定バグを捕まえて直しました。⑤点群法線の視点符号が逆(単一視点スキャン ―― まさに viewpoint 引数の本来の用途 ―― で全点の法線が裏返っていた。既存テストは絶対値 abs() で符号を吸収していて、③と同型の「コイン投げを隠すテスト」だった)。⑥メッシュ簡略化の境界保存が高圧縮比で自壊(悪条件になった二次形式の解が数エッジ長も飛んだ位置に置かれ、「境界を保つ」はずのリムが崩れていた。外れ位置ガードを強化し、崩れる条件そのものを回帰テストに固定)。どちらも「たまたま通るテスト」ではなく「符号つき・絶対値・壊れる条件」を検証するテストに置き換えたのが本質です。

バグ⑤・⑥も、①〜④と同じ粒度で「確かめ方」を残しておきます。

バグ⑤の確かめ方(追試可能)

点群の法線ベクトルは、本来「センサー(視点)から見て外向き」に揃っているべきものです。単一視点スキャン ―― 実世界なら1台のカメラや LiDAR で片面だけを見た点群 ―― では、viewpoint 引数がまさにこの「外向きを揃える」ために存在します。バグは、この符号合わせのロジックが逆を向いていたというもので、全点の法線が視点と反対側を向くという、見た目には気づきにくい壊れ方をしていました。

  • なぜテストをすり抜けたか:既存テストは法線の絶対値abs())だけを見ていました。法線が正しい向きでも逆向きでも、成分の絶対値は変わらないので、符号が反転していてもテストは緑のまま――バグ③(PCA の掌性)と全く同型の「コイン投げの片面しか見ていないテスト」でした。
  • 対策viewpoint からの符号を明示的に検証する回帰テストに置き換え、符号そのものをテスト対象にしました。絶対値で丸めてしまうと、多くの幾何演算で「向き」の情報がそっくり消えてしまう ―― これは③のときに得た教訓が、別の op でもう一度そのまま再現した形です。

バグ⑥の確かめ方(追試可能)

QEM(Quadric Error Metric)edge-collapse は、メッシュの三角形を間引きながら形状をなるべく保つ簡略化アルゴリズムです。各辺を潰す(collapse する)ときに、「潰した後の頂点をどこに置けば誤差が一番小さいか」を、二次形式(quadric)の最小化として解きます。バグは、高圧縮比(大幅に間引く設定)で、この二次形式の解が悪条件(ill-conditioned)になり、本来の位置から数エッジ長も離れた場所に頂点が飛んでしまう、というものでした。結果として、「境界(メッシュの縁)を保つ」はずのリム部分が、圧縮率を上げるほど崩れていく。

  • 見つけ方:この op は本記事の mesh_decimate.png(「3D をここまで自作している話」の節で紹介した画像)がまさに舞台で、素朴なランダム間引き・既存の decimate_qem・境界保存版の decimate_qem_manifold を並べて比較しています。高圧縮比の条件を敵対的レビューで疑い、実際に境界の崩れを確認したのが発見の起点でした。
  • 対策:二次形式の解が妥当な範囲から外れていないかを確認する外れ位置ガードを追加し、崩れが再現する高圧縮比の条件そのものを回帰テストに固定しました。「境界を保つ」という設計意図は正しくても、数値的に悪条件になる領域では別途ガードが要る ―― これは honest gate が最適化アルゴリズムの"効かなくなる端"を教えてくれた例です。

⑤も⑥も、「たまたま通っていたテストの前提」を疑い、符号・絶対値・悪条件という具体的な壊れ方に置き換えて検証し直した、というのが本質です。

この6件は、「良い結果だけ見せる」記事なら絶対に載らない話です。でも私は、品質保証が本当に動いている証拠として、こういう話こそ残す価値があると思っています。


公開前夜 ―― CI が 80 個の地雷を掘り当てた話

ここまで読んで、「この人はずいぶん自信満々に語るな」と思われたかもしれません。ここからは正反対の話です。この総集編を仕上げていた同じセッションで、PyPI 公開に向けて GitHub Actions の CI を初めて本格的に回したところ、フルスイートで大量のテストが落ちました。最終的にはゼロまで潰しましたが、その過程で見つけた地雷の多くは、「ローカルでは何の問題も起きていなかった」種類のものでした。honest disclosure の家訓に従い、祝祭的にではなく、淡々と面白く、実際に何が起きたかを記録しておきます。CI の失敗は恥ではなく、品質ゲートが実際に働いた証拠です。

なぜ今まで CI を本気で回していなかったのか、を先に正直に書いておきます。個人開発では、ローカルのフルスイートが通っていれば十分という誘惑が常にあります。自分の PC は自分が一番よく知っている環境で、依存関係も GPU もいつも同じ状態で揃っている。CI を整備する手間は、機能を1つ増やす手間と比べて地味で後回しにしやすい。しかし PyPI に公開するということは、自分がまったく知らない環境で、自分が想定していない組み合わせのまま動くことを約束するのと同じです。ローカルという1点だけで検証された安心感は、公開の瞬間に何の保証にもならない――今回の一連の出来事は、そのギャップを身をもって埋める作業でした。

第1波:ローカルに"常在"する依存が、CI で初めて剥がされる

最初にぶつかったのは、初回 CI(GitHub Actions、ubuntu、torch 無し環境)でフルスイートが大量に失敗するという壁でした。原因を辿ると、feat_harris / feat_spin / feat_shot / feat_fpfh といった 3D 特徴記述子のモジュールが、ファイル冒頭で torch を無条件に import していました。torch がインストールされていない環境では、この import 文の時点でエラーになり、import ops3d ―― 3D レジストリ全体の読み込み ―― が即死します。1個のモジュールの不注意が、3D op 265個ぶんの読み込みを道連れにしていたわけです。

私の開発機には torch が常駐しているので、この問題はローカルでは一度も表面化しませんでした。ここで得た教訓を言葉にすると――「ローカルに常在する optional 依存は、CI が初めて剥がしてくれる」。自分の PC で「動く」ことと、「動く前提が正しい」ことは別物です。修正は、torch の import をガードした上で、実際に使おうとした瞬間に「fullseye[gpu] を入れてください」と明確な ImportError を出すようにする、というものでした。1点だけ例外を作っています。is_tensor という判定関数だけは、torch が無い環境でも素直に False を返すようにしてあります。これは numpy 経路の入力ガード(「これは torch tensor か? 違うなら numpy として扱う」という分岐)に使われているためで、ここで例外を投げてしまうと numpy だけの環境でまともに動くものまで壊れてしまうからです。

第2波:残り9件 ―― 比率は通る、絶対値と符号で捕まる

第1波を片付けても、まだ9件が残りました。ここからは1件ずつ、性質の違う壊れ方です。

(a) numpy 2.x で np.cross の2Dベクトル対応が削除された。 numpy のメジャーバージョンアップで、2次元ベクトル同士の外積(np.cross、本来3次元用だが2次元でも動くようになっていた挙動)が非サポートになりました。実際に2次元ベクトルを渡していた箇所を洗い出すと、全体で約180箇所ある np.cross 呼び出しのうち、該当したのはわずか2箇所――どちらも輪郭の Douglas-Peucker(線分近似)系のコードでした。修正は、その2箇所だけを明示的なスカラー外積 z = ax·by − ay·bx に置き換える、という局所的なものです。この1件は、依存ライブラリ側の**破壊的変更(breaking change)**がテストで検出できた例で、「自分のコードは変えていないのに CI が落ちる」という、原因の所在が一見分かりにくいタイプの壊れ方でもありました。180箇所のうち2箇所だけを洗い出せたのは、エラーメッセージが指す該当行から辿ったからで、全箇所を目視で総ざらいしたわけではありません。

(b) fit_plane_3d が CI 上でだけ nan を返す。 完全に平らな点群に対して平面フィッティングを行うと、点群の共分散行列の最小固有値が理論上ゼロになります。ここで、固有値からある量の平方根を取る計算があり、ローカルの BLAS(基本線形代数ライブラリ)実装ではその固有値が +ε(ごく小さな正の値)側に丸められる一方、CI のランナーが使う BLAS 実装では −1e-16 側(ごく小さな負の値)に丸まることがある。負の数の平方根は nan になります。同じ数式・同じ入力でも、丸め誤差の"向き"は実行環境の BLAS 実装に依存する――これは完全にローカルでは再現しない類の壊れ方でした。修正は max(w, 0) で固有値を非負にクランプする、シンプルだが効くガードです。この1件は、開発機とCIランナーとでBLASの実装そのものが違うことに起因していたため、ローカルでどれだけ同じテストを回しても再現しません。「理論上ゼロになるべき値」をコードの中で扱うときは、実際には厳密にゼロにはならないという前提でガードを書いておく必要がある、という一般則がここから導けます。

(c) カメラの縮退判定(純回転ペアの拒否)が Python 3.12 でだけすり抜ける。 2台のカメラで三角測量する際、もしカメラが並進(位置の移動)せず回転しかしていない(縮退したステレオペア)なら、三角測量した点は理論上無限遠に飛ぶはずです。ところが調べていくと、無限遠に飛んだ点は再投影(3D点をもう一度2D画像に射影し直す計算)をすると、なぜか元の画素にぴったり一致してしまうことが分かりました。つまり「再投影誤差」という、一見自然に見える指標では、この縮退ケースを検出できないのです。最終的な修正は、視差(disparity、両カメラの見ている向きのズレ)の存在そのものを検定するという方法でした。両画像の視線ベクトル(カメラから見た方向)の集合を、単一の回転(Kabsch アルゴリズムによる最適回転合わせ)でぴったり重ね合わせようとし、その残差の中央値を見る。縮退している(純回転)場合はこの残差が 3.5×10⁻¹⁶、健全な場合は 1.8×10⁻²――実に14桁のマージンで判定できることが分かりました。再投影誤差という一見もっともらしい指標が実は使えず、視差の存在検定という別の切り口に変えて初めて確実に判定できた、という点で、この1件は個人的に一番面白かったデバッグです。

なぜ「再投影誤差」が縮退を見抜けないのかを、3段階でかみくだいておきます。①ひとことで:無限遠にある点は、どの角度から撮っても同じ画素に写ります。②もう少し:三角測量が失敗して点が無限遠に飛んでも、その"間違った"点をもう一度カメラに再投影すると、無限遠点特有の性質のせいで、偶然にも元の画素とぴったり一致してしまう。つまり「入力と出力を一周させて誤差を測る」という、多くの場面で有効な検証手段が、この状況に限っては測るべきものを測れていないのです。③なぜ視差の検定なら見抜けるか:視差とは「両カメラが同じ点を別の角度から見ている」ということそのものの証拠です。カメラが純粋に回転しかしていなければ、両カメラの視線ベクトルは単一の回転だけで完全に重なり合うはずで、そこにズレ(視差)が残らない。再投影誤差が「答え合わせ」だとすれば、視差の存在検定は「そもそも問題が成立しているか」を先に確かめる、一段手前のチェックだった、という言い方もできます。

カメラ縮退検定の 14 桁マージン(実測)

↑ 視差の存在検定を本図のために再実測したもの。縮退ペア(純回転)の Kabsch 残差中央値は 4.9×10⁻¹⁶、健全ペアは 1.5×10⁻² ―― 約 13.5 桁のマージンで、本文の 3.5×10⁻¹⁶ / 1.8×10⁻² と同オーダー。しきい値 1e-9 はどちらからも遠い。

(d) zig/GCC の UBSan が、float→int キャストの溢れを見逃す。 C コード生成のテストで使っている UBSan(Undefined Behavior Sanitizer、未定義動作を検出するツール)には、コンパイラによって検出できる項目に差があることが分かりました。GCC の -fsanitize=undefined は、float から int へのキャストで値が溢れるケース(float-cast-overflow)を含んでいません(clang は含む)。さらに厄介なことに、GCC は no-recover(1件目のエラーで即座に止める指定)も無視する挙動を見せました。この差は WSL 上の実 GCC 13.3 で再現し、明示的に -fsanitize=float-cast-overflow -fsanitize-trap=... というフラグを足すことで修正しました。「サニタイザを使っている」だけでは足りず、そのサニタイザがコンパイラ・バージョンによって何を検出し、何を検出しないかまで確認しないと、抜け穴が残るという教訓です。これは Fullseye の C コード生成(op の c_stmt から実際に C コードを吐いて、Python 版と bit-compare する仕組み)を支えるテストの一部で、「生成した C コードが未定義動作を踏んでいないか」を機械的に確認する層です。サニタイザという道具そのものを信頼するのではなく、その道具の守備範囲まで含めて検証する必要がある、という一段抽象度の高い教訓でもあります。

(e) 共有 CI ランナーでは、疎行列ソルバが密行列より遅い。 性能を上げるはずの疎行列(sparse matrix)ソルバが、共有 CI ランナー上では密行列(dense)より遅いという逆転が実測されました(実測 1.8〜3.2秒 vs 0.2〜0.3秒)。これはバグではなく、CI の共有マシンではハードウェアや負荷が専用機と違うという当たり前の事実です。ここから、「性能の主張は専用機でだけ検証する」という規律を再確認しました。本記事の「性能の正直な話(GPU)」の節と同じ精神です。共有ランナーは他の無数のジョブと物理マシンを取り合っているため、キャッシュのヒット率もメモリ帯域も、専用機とは別物になります。これ自体は直すべきバグではなく、「CI 上のタイミング測定を性能の主張に使わない」という運用ルール側で対処する種類の話です。テストの合否判定にタイミングを使わない(別途、専用機のベンチマークで別管理する)という設計判断も、この一件から得ています。

最終波:「テストが全部通る」と「pip install してそのまま動く」は別の関門

第2波まで潰して CI 全 green になり、PyPI に v0.1.0 を公開しました。ここで安心して終わりにしても良さそうなものですが、公開した直後にもう一段の検証――クリーンな venv(まっさらな Python 仮想環境)に pip install fullseye して動かしてみる――を行いました。これが最後のバグを捕まえました。

recon3d(3D 再構成のモジュール)が、scikit-image無条件に import していたのです。scikit-image は extras 側の依存で、素の pip install fullseye(numpy + scipy だけ)では入りません。結果、import ops3d が、素のインストールでは即死する――第1波の torch のときと全く同じ形のバグが、今度は torch でなく scikit-image で、しかもCI では検出されず、公開後の手動検証で初めて見つかったという点が重要です。CI のフルスイートは opencv/skimage などの extras を入れた環境で回っていたため、このクラスのバグを CI 自体が見逃していました。修正して v0.1.1即日公開し、今度はクリーン venv で全スモークテストを実行して確認しました――0.1.1、numpy 2.4.6、502 op、パイプライン実行、ops3d 265 op、console scripts のすべてが揃って PASS。

「テストが全部通っても、pip install した素の環境で動くかどうかは別の検証」――これが最終波で刻まれた教訓です。

見逃した理由は単純で、CI のフルスイートは extras 込み環境(scikit-image が常在)で回っていたため、この無条件 import は一度も失敗しようがなかったからです。素のインストールを検証する core-minimal ジョブの対象を v0.1.1 で広げ、このクラスは以後 CI 自身が捕まえます。

数字で見る推移、そして教訓の言語化

CI 失敗テスト数の推移 約80→9→1→0

↑ 失敗テスト数の推移(約80 → 9 → 1 → 0)。第1波=torch の無条件 import、第2波=絶対値と符号で捕まる9件、最終波=クリーン venv 検証で見つけた scikit-image の無条件 import。数値は本文記載の実績値。

失敗テスト数の推移は、約80 → 9 → 1 → 0 という3波(+最終確認1件)でした。フルスイートはローカルで 6224 passed。CI 自体は Python 3.10 / 3.11 / 3.12 のマトリクスに加えて、「numpy + scipy だけで動く」という主張を毎回実際に実行して確かめる最小構成のジョブを独立に持っています(このジョブこそが、最終波の scikit-image バグと同じクラスの事故を、次からは CI 自身が捕まえられるようにする、という意味を持ちます)。

この一連の出来事から、言葉にしておきたい教訓が4つあります。

  1. ローカルに常在する optional 依存と、gitignore された実行時データは、CI が初めて剥がしてくれる。 自分の環境で動くことは、動く前提が正しいことを意味しません。
  2. 比率のテストは通る。絶対値と単位系で捕まる。 これは本記事のバグ④(曲率の絶対値が32倍ずれていた話)と全く同じ形の教訓が、CI という別の文脈でもう一度姿を現したものです。
  3. 厳密なゼロ判定は、BLAS の丸め方向次第で壊れる。 「理論上ゼロになるはず」という前提でコードを書くときは、実行環境によって符号が反転しうることを疑う必要があります。
  4. 「テスト green」と「pip で動く」は別の関門。 テストスイートが検証している依存関係の組み合わせと、実際にエンドユーザーが手に入れる組み合わせが違えば、テストは無力です。

面白いのは、①と④が同じ形の事故だということです。torch を無条件 import していたバグ(第1波)と、scikit-image を無条件 import していたバグ(最終波)は、原因もコードの場所も違うのに、**「optional のはずの依存が、うっかり必須になっている」**という骨格は完全に同じでした。1回目で得た教訓(ガードする・明確なエラーを出す)を全箇所に横展開したつもりでも、新しいモジュールを1つ足せば、同じ穴がまた開きうる。これは本記事の随所で触れている「バグ1件見つけたら同じクラスのバグが兄弟コードに眠っていないか疑う」という家訓を、CI というスケールの大きな舞台でもう一度学び直した、という言い方もできます。1つの修正で終わりにせず、同じ形の穴を塞ぐ仕組み(このケースでは「numpy+scipy だけで動く」ことを毎回実行して確かめる最小構成ジョブ)を CI 自体に埋め込むところまでやって、ようやく再発防止と呼べます。

こうした話は、正直に言えば「格好悪い」と感じる人もいるかもしれません。それでも私は、CI が実際に地雷を掘り当てたという記録そのものが、品質ゲートが本当に機能している証拠だと思っています。良い数字だけを見せる記事なら、この章は存在しません。


性能の正直な話(GPU)

Status: Verified(RTX 5090 実測) ―― ただし対応 op のカバレッジは途上です(次章参照)。

「速い」も正直に言います。既定の1枚ずつの経路は scipy / OpenCV。バッチ化した torch の速い経路(--device cuda)は、重い・ベクトル化できる op を加速します。

  • CPU 上では、重い op で 約 1.6〜2.2 倍。ただし軽い画素演算では逆に遅くなる(テンソル変換のオーバーヘッドが乗るため)。
  • GPU(RTX 5090 実機)では、対応済みの op は実測でしっかり速いです:密画素並列の accel 26 op(median 60x・percentile 46.6x など)、3D ボリューム演算 ~64x、形状マッチング一式 44〜88x(検出位置は CPU と一致)、NCC マッチング 4〜6x(argmax 位置は完全一致)、E2E の GPU 常駐パイプライン(転送 1 回で op 連鎖)で CPU 比 12.6x。しかも「CPU 実装と interior 誤差 5e-3 未満で一致する op しか GPU に載せない」忠実性ゲートつきの数字です。
  • ただし現状の正直な限界はカバレッジ:GPU 対応はまだ約1000 op の一部で、全体には対応しきれていません。未対応 op が混ざるパイプラインは CPU 区間として動きます(壊れはしない)。

「全部速くなる」とは書きません。どこで速く(実測つき)、どこがまだ CPU かまで書くのが honest disclosure です。


限界と、これから(隠さず)

Status: Roadmap / Design proposal ―― ここから先は現状の限界と、実装前の構想を含みます。

  • HALCON カバレッジはまだ 42.5%。 OCR・Classification・System 系はほぼ空。画像・幾何の芯を厚くする方針は変えませんが、"半分未満"は事実です。
  • 統一 API はまだ移行の途中。 画像レジストリ・アルゴリズム・知覚 facade で歴史的に規約が分かれていて、Qt 風の自然な API へ段階的に載せ替え中です。
  • GPU 化はカバレッジが途上。 使える部分は既にあり実機(RTX 5090)実測も出ています(下の「性能の正直な話」参照)が、約1000 op 全体にはまだ対応しきれていませんCPU の数字を GPU の数字と偽らないのが規律です。
  • 3D 可視化の Studio 統合も、既存ビューア(Open3D / RViz2)連携を軸に育てている最中。

それぞれについて、もう少し正直に補足しておきます。

HALCON カバレッジの 42.5% は、「HALCON カバレッジの物語」の節に書いた通り意図して絞った分母の上での数字ですが、それでも Filtering / Morphology / Regions / Segmentation の芯を厚くする作業自体は、まだ終わりが見えているわけではありません。特に Matching(テンプレート・変形マッチング)や 3D Reconstruction のような、単一の fn(v, a, b) では表現しづらい machinery 寄りの章は、型契約をどう拡張するかという設計判断が絡むため、単純に op を足せば伸びる、という性質のものではありません。

統一 API の移行については、正直に言うと歴史的な経緯の後始末という側面が大きいです。imgevolve 時代の命名規則と、Physical AI 知覚スタックを育てる過程で足した facade の命名規則が、完全には揃っていません。設計思想の節に書いた「補完が効いて読める書き味」を全域で実現するには、既存の呼び出し側コードを壊さずに段階的に寄せていく必要があり、これは機能追加よりも地道な作業です。

GPU 化については、使える部分は既にあって実測も速い(「性能の正直な話(GPU)」の節の RTX 5090 実測参照)、ただ約1000 op の全体にはまだ対応しきれていない、というのが正確な現状です。GPU 対応 op は「CPU 実装と interior 誤差 5e-3 未満で一致するものだけ載せる」という忠実性ゲートを通しており、速くなる代わりに答えが変わる、という事故を作らないことを優先しています。カバレッジを実際に使われる op の頻度順で広げていくのがこれからの仕事です。

やることは山積みですが、土台(型付き op・正直な評価・md=真実源のドキュメント・10982 テスト)は据わりました。ここから網羅と自然 API を伸ばしていきます。


計測モダリティを増やす ―― 8 つの op 族で何ができるか

ここからは、公開後に足した 8 族 111 op の話です。どれも「新しいから足した」のではなく、既存のやり方がどこで壊れるかを同じ土俵で測ってから足しています。型付きカタログは 511 op、進化レジストリは 847 op になりました(pip install -U fullseye)。

何ができるようになったか

できること 入口の関数
コヒーレンス走査干渉 段差・表面粗さを、位相の巻き戻しに頼らず測る csi_stack_simulate / csi_height_map
音響・振動診断 回転機械の欠陥周波数を、共振に埋もれた状態から取り出す envelope_spectrum / order_spectrum
ライトフィールド 1 回の露光から、後でピント面と視点を変える lf_from_mla / lf_refocus / lf_depth_from_focus
光子計数・時間分解 光を粒として数える。距離・蛍光寿命・Poisson 誤差棒 tcspc_simulate / dtof_depth / photon_uncertainty
四元数画像 色空間の 3 次元回転(チャンネル毎のゲインでは書けない操作) rgb_to_quaternion / quat_color_rotate
FMCW レンジ-ドップラー 距離・速度・到来角を 1 つの立方体から同時に出す fmcw_beat_simulate / range_doppler_map
鏡面反射の分離 テカりを落とす。多灯の遮蔽に耐える法線復元 specular_diffuse_split / photometric_stereo_robust
モーション増幅 肉眼で見えない 0.2 画素の振動を測る・見せる displacement_series / motion_magnify

以下、族ごとに「何が見えるか」と「どこで壊れるか」を並べます。壊れる場所の方が、たいてい役に立ちます。引数の一覧と単位は Studio の op ヘルプと docs/ops/<族>/guides/ の 24 本のガイドにあるので、ここでは入口の 1〜2 行だけ置きます。

段差を測る ―― 位相シフト法が λ/4 ちょうどで壊れる

白色光の干渉は、縞の位相ではなく包絡線のピーク位置で高さを決めます。位相シフト法(既存の主流)と同じ入力で比べると、破綻点が閉形式どおりに出ます。

真の段差 位相シフト法 コヒーレンス法
0.15 µm 誤差 0.0000 誤差 0.0000
0.20 µm −0.1000 を返す(−λ/2) 誤差 0.0000
0.30 µm −0.0000 を返す(−λ/2) 誤差 0.0000
1.00 µm +0.1000 を返す(−3λ/2) 誤差 0.0000

壊れ始めは λ/4 = 0.15 µm ちょうど。誤差は常に λ/2 の整数倍です ―― 縞の次数が飛ぶので、例外も NaN も警告も出ない、構造を持った嘘になります。λ を 0.8 µm に変えると破綻点も 0.20 µm へ移動します。λ/4 を越える段差があるなら、位相シフト法は使えません。

h = fs.csi_height_map(stack, z_step_um=0.05, wavelength_um=0.6, mode="gaussian")

単位はすべて µmz_step_um走査の実ステップと一致していることが前提です(ここがずれると高さが比例してずれ、やはり例外は出ません)。買う前の限界は csi_design、走査したくない場合は chromatic_confocal_height がスペクトルのピーク波長だけから高さを返します。

回っている機械の異常を聞く ―― 欠陥周波数は生スペクトルに存在しない

転がり軸受の外輪剥離は、欠陥周波数で鳴りません。数 kHz の構造共振が、200 Hz 以下の欠陥周波数で振幅変調されて届きます。だから生のスペクトルをいくら眺めても出てきません。

見る場所 107 Hz の振幅
生スペクトル 4.3e-16 ← 成分として存在しない
包絡線スペクトル 0.499677(ピーク位置 107.000000 Hz)

(生スペクトルの値は dsp.spectrum(...)[1] * 2/N = 片側振幅に直した後の数。生の返りは 5.493328e-12(同じ系で搬送波 12800 / 側帯波 3200)で、比は同じ訂正(2026-09-02): 初出版はこの × 2/N を書き落としていました。)

sk  = fs.spectral_kurtosis(x, 25600.0)                      # 復調帯域を機械に選ばせる
env = fs.envelope_spectrum(x, 25600.0, sk["band_lo"], sk["band_hi"])
print(env["peak_freq"], env["band_fraction"])               # 107.0 と ~1.0 = 本物

幾何から出した特徴周波数(bearing_defect_frequencies(1800.0, 9, 8.0, 40.0) → 外輪 108.0 Hz / 内輪 162.0 Hz)と突き合わせれば、どの部位かまで決まります。回転速度が変わる信号では、素朴なスペクトルは 7 % に潰れて 66.5 Hz に広がります。角度領域へ移す order_spectrum なら 0.999371 / 幅 0 bin です。

この族で一番危ないのは配列ではなくスカラです。 同じ録音を rate=48000 として読むと、欠陥は 107 Hz ではなく 200.625 Hz と報告されます。やはり例外は出ません。だから防御は rate の側に置いてあります ―― 任意の実 1 次元配列は本当に妥当な音響信号なので、型を分けても守るものが無いからです。

一度の露光から、後でピント面を変える

ライトフィールドは 4 次元 (視点 V, 視点 U, 高さ H, 幅 W) を扱います。既存のステレオ(2 眼)やフォーカススタック(実カメラの合焦)とは入力が違うので、型で分けてあります。

sharp = fs.lf_refocus(lf, slope=2.0)                        # 後からピント面を選ぶ
slope, conf = fs.lf_depth_from_focus(lf, np.linspace(-4.0, 4.0, 81))
z = fs.lf_disparity_to_depth(slope, focal_px=..., baseline=...)   # mm へ

生の MLA 画像からの復号(lf_from_mla / lf_to_mla)は往復がビット一致、視差ゼロ(無限遠)は無言の inf ではなく ValueError、カメラの設計は lf_plenoptic_design です。

得るものと失うものが同じ計算から出ます。 リフォーカスによる被写界深度の利得は、角度分解能にぴたりと一致します。

角度分解能 被写界深度(画素基準) 被写界深度(リフォーカス後)
6×6 1.656 mm 9.939 mm 6.0016
8×8 1.656 mm 13.254 mm 8.0038
10×10 1.656 mm 16.573 mm 10.0075

比はハードコードではなく、許容錯乱円を変えて depth_of_field を 2 回呼んだ結果です(6.0016 の 0.0016 はその 2 回の差)。代償は同じ表の裏側で、空間分解能は 2048×2448 が 341×408 / 256×306 / 204×244 に落ちます。買う前に、この 2 列を並べて見てください。

光を「数える」領域 ―― 誤差棒が校正なしで出る

光子計数は、光が連続した明るさではなく数えられる粒になる領域です。ここでは Poisson 分布が「分散 = 平均」なので、誤差棒が校正なしで出ます。既存の aug_read_noise(加法ガウスの読み出し雑音)とは雑音モデルが違い、両者が出会うのは Anscombe の一般化形だけです。

clean = fs.tcspc_background_subtract(hist, "median")        # 屋外の日射を引く
print(fs.dtof_depth(clean, bin_ps=100.0, mode="gaussian"))  # ≈ 3.0 m
sigma = fs.photon_uncertainty(counts)                       # sqrt(N) ― 校正不要の誤差棒

photon_statistics(counts)["fano_factor"] が 1.0 付近なら、本当に Poisson です。SPAD のデッドタイム補正は教科書式 m = n/(1+nτ)ビット単位で一致し、逆変換の往復誤差は 1.7e-16。dToF の距離推定は 1.5 m を注文して 1.5000003 m が返ります。

壊れ方が 1 つあります。 デッドタイムは検出器のレート流(Hz)に効くもので、時間 bin ヒストグラム(カウント)に bin ごとに掛かるものではありません。ヒストグラムを spad_deadtime_correct に渡すと、例外も出ず、恒等写像に限りなく近い値が返ります(相対変化 1.1e-4)。本物の計数レート(1e3〜1e7 Hz)なら 33.3 % 変わるところです。飽和も fail-closed も一度も踏まれない。 だから型を分けました ―― 分けないと、この「もっともらしい通過」は永久に検出されません。

「4 元数画像も使えたら面白いか」への正直な答え

面白いことはできます。ただし理由は 1 つだけです。

複素数の画素は 2 次元値で、回転軸が 1 本しかありません。四元数の純虚部 (0,R,G,B) は 3 次元ベクトルで、q·x·q*色空間の 3 次元回転になります。純赤を青軸まわりに 90 度回して緑にする操作は、どんなチャンネルごとのゲインでも作れません(最良の対角近似でも ‖P−diag(P)‖₂ = 0.6667 ずれる)。ここが唯一の本物の能力差です。

import fullseye as fs

q = fs.rgb_to_quaternion(rgb)
turned = fs.quaternion_to_rgb(fs.quat_color_rotate(q, (0.0, 0.0, 1.0), 1.57))
chroma = fs.quat_color_filter(q, (1.0, 1.0, 1.0), "remove")  # 灰色軸を落とす
spec = fs.qft2(q, "left"); back = fs.iqft2(spec, "left")     # side は必須・往復は同じ side で

それ以外は売り込めませんでした。

  • 同じ回転は 3×3 行列でもできます(2.22e-16 で一致)。四元数固有の利得は表現の閉性だけ。
  • 四元数フーリエ変換は何も買いません。 チャンネルごとの FFT で組み直したものと 1.14e-13 で一致し、しかも 2.4 倍遅い。本当に四元数固有なのは「左からと右からが別物」という点だけです。
  • Riesz / モノジェニック信号は、同じ日に作った複素ステアラブル版に負けました。 同一オクターブに 2 方位があると誤差 1.30e-01 対 4.4e-16。2 オクターブ離すと両方機械精度になるので、原因は「単一平面波モデルが radial band では偽」と特定できます。25 % の画素が rank 0 になり、理論上の勝ち(斜め格子)も実現しませんでした。勝ちは 2 つだけ ―― 雑音下で約 2 倍、増幅で 2.21 倍速い。

使い分けはこうです: 狭帯域の被写体なら riesz_displacement_series、広帯域なら次節の motion_magnify。色空間の回転が要るなら四元数以外に選択肢はありません。

遠い・速い・テカる・微振動 ―― 残り 3 族

FMCW レンジ-ドップラー(8 op) は距離・速度・到来角を 1 つのビート立方体から出します。窓関数の効果が実測に出ます ―― −45 dB の標的は矩形窓では −24.57 dB の漏れに埋没し、hann で −43.56 dB に復活します。

rdmap = fs.range_doppler_map(fs.fmcw_window_apply(cube, "hann"), normalize=True)
peaks = fs.range_doppler_peaks(rdmap, dr, dv, n_peaks=2)["peaks"]   # dr, dv = bin 幅

dr, dv を渡さないと、返り値の range_m は「メートル」ではなく「ビン番号」です(3.0 と 3.5131928671875)。単位の落とし穴はここ 1 箇所に集約してあります。

鏡面反射の分離(13 op) は、テカりを色で落とす(1 材質・白色光源)か、多灯で法線を復元します。

破綻するのはハイライト汚染で、遮蔽ではありません。 正の外れ値 +3.0 を 8 灯中 4 灯に足すと ransac 65.4 度 / median 7.4 度(1〜3 灯なら 0.0001 度)。中央値の破綻点がちょうど 50 % なので、4/8 で崩れるのは理論どおりです。

一方遮蔽は解けます。measured 0 は n·L ≤ 0 という不等式なのに線形解法が等式として読むのが原因で、しかも「アルベド 0」の退化解は潰れた全フレームを厳密に再現するため、遮蔽灯の集合が無矛盾な仮説として真の解を上回れてしまいます。判定を「当てはめが許容差内で説明でき、かつ測定値自身が同じ許容差より 0 から離れている」に変えると:

遮蔽灯 修正前 修正後
0〜3 灯 0.0001 度 0.0001 度
4 灯 70.5 度(なお「8/8 全部を信じた」と報告) 0.000115 度
6 灯(生きた灯 2 本) 8.99 度(答えを返す) 全画素 NaN(解けないと言う)

最後の行が要点です。生きた灯 2 本は未知数 3・式 2 の劣決定で、原理的に解けません。

訂正(2026-09-02): 初出版はここを「破綻点は 4 灯の遮蔽」と書いていました。数字(70.5 度)は本物でしたが、原因はモデルの読み違いで、直したら遮蔽は解けるようになりました。

モーション増幅(9 op) は、0.2 画素の振動を測り、見せます。崖の位置が閉形式で決まるのがこの族の要点です ―― 位相基準は c·J₀(k·A) に従うので、第一零点 2.4048/k = 3.0619 画素を越える振幅では測定が反転します。

print(abs(fs.displacement_series(clip, 3.0, 5.0, 32.0)[:, 0]).max())   # 0.2003 px(真値 0.2)
r = fs.motion_magnify(clip, alpha=8.0, f_lo=3.0, f_hi=5.0, fps=32.0)   # 帯域 [Hz], fps
print(r["motion_snr_change_db"], r["band_power_ratio"])                # -2.18 dB, 0.629

増幅の代償は同じ返り値の中にあります。SNR は必ず下がります(上がることはありません)。band_power_ratio が 1 から離れるほど、線形の仮定から外れています。

大きい 3D データを持ち歩かない ―― 領域・境界・RLE

512³ の CT を全部 RAM に載せるのは、たいていの場合ただの無駄です。「どこを計算するか」「何を保持するか」「一度に載せる量」の 3 つを別々の道具で削ります。

part, offset = fs.vol_crop_domain(vol, mask)     # ① どこを計算するか
back = fs.vol_uncrop(heavy_3d_op(part), offset, vol.shape)    # 元の座標系へ厳密に戻す
pts  = fs.vol_boundary_points(mask, spacing=(0.5, 0.2, 0.2))  # ② 殻だけ、(z,y,x) mm/voxel
print(fs.vol_rle_volume(fs.vol_rle_encode(mask)))             # ③ dense を作らず voxel 数

実測は、96³ の合成 CT から球だけ切り出してメモリ 1/34、中実の球を殻だけにして 19 %、384³ の部品マスクを RLE にして dense の 1/145。RLE のまま体積とバウンディングボックスを出すと 300〜1000 倍速、和・積・差の集合演算は 192³ 同士で 3.1 ms です。

殻にすると半径がわずかに小さく出るのは正しい挙動です。 痩せた点群のまま球フィットを閉じると中心誤差 0.000 mm、半径は 4.33 / 4.5 mm ―― 内側境界のボクセル中心は表面より半ボクセル内側にいるからです。この系統誤差は例の中に明記してあります。「小さく出た」を精度の問題だと思って追いかけないでください。

3D 側にはこの期間で CT windowing(軟部窓では骨が飽和し、骨窓では軟部が背景と同化する、を機械検証)、幾何変換、virtual probe(プローブ 1 本でパイプ壁厚 2.042 mm / 真値 2.000 mm)、3D FFT フィルタ、Richardson–Lucy デコンボリューションも入りました。RL については docstring に書いた「10 反復で RMSE 半減」が実測 0.81x だったので、半減しない理由(残差は縁の階段が支配し、RL はそこをゆっくりしか直せない)ごと書き直し、代わりに RL が本当に最適化している量 ―― 推定を再ぼかすと観測に 0.021x で一致する「前方一貫性」―― を主張として固定しました。docstring の数字は、実測してから書いてください。

見るほうも変わりました。 Studio の 3D ビューアは F キーで一人称モードに入ります ―― WASD で歩き、ドラッグで見回し、ホイールで歩く速さを変え、R で入口に戻る。臨場感の核は透視投影(近いものは大きく、遠いものは小さく)で、この投影は自作のソフトウェア描画なので、ピンホールカメラの解析式と 10⁻¹³ の精度で一致することを独立に検証してあります。CT の中を歩いて欠陥を探す、点群の遺跡スキャンの中を歩く ―― 「データを見る」が「データの中に立つ」に変わると、気づくものが違います。

骨格をグラフとして読む、1D をひとつの目録に

**骨格(スケルトン)**は fs.apply(mask, "em_skeleton")(Eckhardt–Maderlechner 法 1993 のクリーンルーム実装)で細線化し、junctions_skeleton / r2_endpoints_skeleton で分岐点と端点を、3D 側は skeleton_junctions3d / skeleton_endpoints3d / skeleton_prune3d / skeleton_branches3d でノード・枝・端点を返します。太いボリュームを渡すと Lee 法(1994)で細線化してから読みます。voxel 骨格のグラフ化は血管・多孔質・根系のネットワーク解析向けで、2D と 3D が同じ語彙になっています。

実装の検証は、文献が公表している EM93 の実行結果との画素単位の突き合わせです(検定形状 1 = 724/724 画素で差分ゼロ、残る 2 形状も公表画素数 2434/3895 と一致)。この突き合わせはそのまま回帰テストに焼き込んであります。HALCON 実機との直接照合だけは未実施です(手元にライセンスがないため)。

粘菌ネットワークの骨格グラフ抽出

粘菌の管ネットワークから骨格グラフを取り出す ―― 粘菌(モジホコリ)の数理モデル(流量の多い管が太り、使われない管が退化する適応ネットワーク)を育て、em_skeleton(シアン)と junctions_skeleton(白 = 分岐点)で読んだもの。左は探索中の網目、右は収束後 ―― 最短路 1 本だけが残る。使用 op: binary_threshold, em_skeleton, junctions_skeleton, dilation_circle。この粘菌シミュレーションは別に進めている「進化する脳」研究の実験データそのもので、そちらはいずれ別の記事で書きます。

1D 側は「足りない」のではなく「繋がっていなかった」という状態でした。1D 関数の平滑化・微分・極値・照合(23 関数)に対応する実装がリポジトリの中で完全な孤児になっていて ―― どこからも import されず、パッケージ登録もテストもカタログもなし ―― しかも facade の対応表は既にそのモジュールを参照していました。つまり PyPI からインストールした版では該当 23 op が ModuleNotFoundError で全滅していたわけです(出荷バグとして修正済み)。

いまは信号処理 16 関数と合わせて 1D 統一目録 37 op。解析真値のテスト 41 件(sin の微分 ≈ cos、ゼロ交差 = kπ、減衰振動から周期・時定数・遅延を真値どおり復元)で固定してあります。2D の measure1d・3D の probe・センサー系列は、どれも「プロファイル (x,y) を取り出す」で合流し、その後段がこの目録です。

ギャップ監査の規律: 「無い op」を探すだけでは足りません。「あるのに世界に接続されていない op」―― 実装されテストに値するのに、目録・パッケージ・facade のどこかで鎖が切れているもの ―― を探してください。これで 23+16 個の関数が息を吹き返しました。

部品を買う前に「見えるか」を確かめる ―― 仮想マシンビジョン環境

検査装置の設計で最初に決まるのは「この構成で、何 µm の欠陥から見えるのか」です。カタログの数字を眺めていても出てきません。ここでは画像ではなく限界を返す層を作りました。

まず動画から

欠陥サイズを 20→400 µm で掃引し、光学限界と実測の検出開始点を並べたデモ

左が仮想の部品を撮った画像、右が検査器の答えと正解マスクの重ね合わせです。欠陥を 20 µm から 400 µm まで 48 段階で大きくしていくと、あるところで急に検出が始まります。画面の数字は全部その場で計算した実測値です。

この系(f=35 mm・作動距離 200 mm・f/4・画素ピッチ 3.45 µm・2448×2048)の答えはこうです。

出どころ
物体側の画素サイズ 16.264 µm/画素 ガウス結像式からの倍率 0.21212
標本化の限界(Nyquist) 32.53 µm 画素ピッチの 2 倍を物体側へ
回折の限界(Airy 基準) 30.67 µm 2.44λN(1+m)、実効 F 値 4.848
光学限界(律速は標本化) 32.53 µm 上の 2 つのうち大きい方
実測の検出開始 45.80 µm(2.82 画素) 5 seed × 48 サイズの掃引

光学限界の 1.41 倍が要る、というのがこの構成の答えです。この比が読み方の全てです ―― 大きければアルゴリズムか照明の問題、1 倍に近ければレンズの問題。切り分けがここで決まります。

作動距離を 120→700 mm で掃引し、同じ 100 µm の欠陥が見えなくなる様子

もう 1 本は逆向きの掃引です。欠陥を 100 µm に固定して、作動距離だけを 120 mm から 700 mm へ動かす。画素サイズが 8.38 → 65.55 µm に膨らみ、光学限界が 18.40 → 131.13 µm へ悪化します。見どころが 3 つ ―― 律速が途中で入れ替わる(160.5 mm を境に回折律速から標本化律速へ)、光学限界が 100 µm を追い越すのが約 551.6 mm(ここから先は原理的に解けない帯)、そして実際に検出が保つのは約 403.3 mm まで。原理の限界より 150 mm ほど手前で、実力の限界が先に来ます。

使い方 ―― 「何 µm から見えるか」を出す

import numpy as np, fullseye as fs

system = fs.VisionSystem(focal_mm=35.0, working_distance_mm=200.0,
                         pixel_pitch_um=3.45, width_px=2448, height_px=2048,
                         f_number=4.0, depth_tolerance_mm=1.0)

sweep = fs.inspection_sweep(system, np.linspace(20.0, 400.0, 48),
                            kind="scratch", seeds=5, contrast=-0.25)
print(fs.detection_report(sweep))

defect_um_gridµmdepth_tolerance_mm は「部品がフォーカス面からどれだけ外れうるか」の mm です。detector= に自分の検査関数を渡せば、自分のアルゴリズムの検出開始点が同じ土俵で出ます。kindscratch / pits / crack などで、欠陥は確率幾何(傷はランダムウォーク、孔食は点過程、割れは分岐する経路)で生成されます ―― この見方の利点は絵が綺麗なことではなく、画素完全な正解マスクが定義から無料で出てくることです。注釈作業が存在しません。

層は 3 つで、visiondesign が閉形式の光学(ガウス結像式・Nyquist・Airy 基準・錯乱円・cos⁴ 則)、defectgen が欠陥生成、visionlab が「設計 → 限界 → 部品を作る → 撮る → 検査する → 判定」を一本に繋ぎます。レンダラを持たないという制約が設計を決めました ―― モンテカルロ光輸送は依存方針にも計算方針にも合いません。分解能・被写界深度・周辺光量・コントラスト伝達はどれも数十年前から教科書にある閉形式なので、実装はそれらの配置にすぎません。

光学限界と検出限界を、1 つの判定に畳まない

この層で見つけた最悪のバグは、レポートが「光学限界に未到達」と書いていたのに、レンズはちゃんと解像していたというものでした。resolvable という 1 つの判定に、横分解能被写界深度という独立な 2 軸を畳んでいたのが原因です。

ある構成では横分解能の限界が 30.7 µm で 60 µm の欠陥は余裕で解像できているのに、被写界深度 0.52 mm が要求公差 1.00 mm に足りないせいで、どのサイズも resolvable に格上げされない。そして見出しには「光学限界に未到達」と出る。これを読んだ人はレンズを買いに行きますが、直すべきは絞りか公差かフォーカス機構です。

いまは必ず分けて出ます。

optical limit  : 30.7 um (diffraction-limited)
detection limit: 60.0 um
depth of field : 0.52 mm < 1.00 mm required — resolvable laterally, but the part drifts out of focus
  -> nothing is rated "resolvable" for that reason alone; the lens is not the thing to change.

同じ性質の不具合がもう 1 つありました。掃引の集計で、1 画素未満で描けなかったものと外から渡された検査器が例外を投げたものを、検出率 0 % に混ぜていたのです。いまは unrenderabledetector_failed を別々に数え、評価できた試行が 0 なら検出率は 0% ではなく None を返します。

規律: 「できなかった」を集計するときは、できなかった理由を混ぜない。混ぜると、設計やアルゴリズムが不当に責められます。閉形式で必ず数値が出る量を「未到達」と書ける場所はありません。

op のネタを、業界の一次情報から取る

新しい op のネタ元として、産業ビジョンの展示会とアワードを一次情報で読む手順書(docs/INDUSTRY_SIGNALS.md)を用意しました。論文コーパスとは判定軸が違うのが理由です。査読者が見るのは「学術的に新規か」、審査員が見るのは「今まさに産業が金を払う価値があると判断したか」。後者は仕様が確定していて検証すべき数値があるという意味になります。

在庫確認の罠も手順に書いてあります。キーワード 1 つで「0 件」と結論してはいけません ―― hyperspectspectral で引いて「ハイパースペクトルは無い」と書きかけましたが、実体は spec_ 接頭辞の 14 op でした。結局「有る / 無い」の 2 値ではなく「有るが未登録」「有るが規約外」を含む 4 状態で記録することにしています。

そして却下したものを 13 件、理由つきで保存してあります。カメラの伝送インターフェースは「op ではなく転送層」で恒久的に対象外、エッジ AI プロセッサはハードウェア、学習ベースの欠陥分類は重い依存が方針違反でかつ閉形式の真値が作れない、光輸送レンダラは正直に「できない」。この記録が無いと、次回また同じ往復をします。

品質保証の第三層 ―― op を鎖にして揺さぶる

単体テストと敵対的検証の次に、op を繋いだときだけ現れるバグを探す層を置きました。そして、その層自体が嘘をつくことを知りました。この節は、同じ仕組みを自分のライブラリに置く人向けの手順です。

仕組みと、走らせ方

型付きプール + ランダム連鎖 + 署名収束。 各 op の宣言(in 型 → out 型)に従ってプールから引数を引き、ランダムに op を連鎖させ(拡散)、産物をプールに戻す。例外や異常はすべて (種別, op, 例外, メッセージ) の署名に潰して数える(収束)。

署名の分類が心臓部です。

種別 意味
CONTRACT 文書化された ValueError = 白
SUSPECT 生の TypeError / IndexError = 契約の穴
TYPEMISS 宣言と違う型を返した = 型の嘘
NONFINITE NaN / Inf の無言戻り
GROWTH 産物がプール上限を超えた(黙って捨てずに記録する)
py -3.11 tools/chain_fuzz.py --chains 1500 --length 6 --seed 7001 \
    --coverage-out coverage.json --explore 0.3
py -3.11 tools/chain_fuzz.py --minimize findings.jsonl --only fit_zernike   # 最小再現へ削る
py -3.11 tools/chain_fuzz.py --replay 7001 --script "op_a,op_b,op_c"        # 再現の確認

--coverage-out が本節の主役です。--explore は「まだプールに無い型を産む op を優先する確率」で、0 が一様(旧挙動)。

ファザー自身も現場で 2 回鍛えられました。 初走行は拡大系 op の連鎖でプールの体積が指数増殖し、34 GB の voxel に半径 1 のモルフォロジーが 20 分貼り付く「ハングもどき」で停止。次は 4 万制御点の TPS フィットが内部で 12 GB の密行列を確保して停止 ―― こちらはプール上限では防げません。入力は小さいのに内部割当だけが巨大になる、というのは敵対的検証の独立した家系です。同じパターンで PSF 生成(σ に桁違いの値が紛れると 64 GB)、CPD 対応行列も一掃し、op 側に fail-closed の上限を置き、TPS 変形はチャンク化してビット一致のまま有界メモリにしました。

成果の代表を 1 つ。fit_zernike が稀に全係数 NaN を返す件は、単体では再現せず 3 波にわたって「グレー」のまま持ち越していました。連鎖トレースで条件が判明します ―― float64 では有限な 1e39 級の値(増幅系連鎖の産物)が、torch 経路の float32 キャストで inf に化ける。すると grid_samplelstsq が NaN を無言で返す。修正は「キャスト後の有限性を検証する」ヘルパ 1 個ですが、同じキャストは 20 箇所あるので、1e39 を実際に流し込んで確認し、無言 NaN を出した 4 op だけを直しました(sobel3d などは実測で無事だったので触っていません)。

もう 1 つは「inf はバグではなく、契約かどうか」という整理です。 sdf_subtract の inf 出力は、出所が esdf の「全部自由空間なら +inf」という文書化された契約で、min/max 代数がそれを数学的に正確に伝播していただけ ―― 無実です。ところが同じ family の sdf_smooth_union だけは内部が算術(inf−inf、inf×0)なので inf 入力で全 NaN ―― こちらは本物のバグでした(ブレンド帯 |a−b|<k が inf で退化することを使い、厳密に min へ退化させて修正)。「非有限を出した op」を責めるのではなく「非有限の出所と契約」を追ってください。

「発見ゼロ」は頑健さではない ―― 4 つの偽装と、その検出手順

この節がこの記事で一番、他のプロジェクトにそのまま移せる部分です。 ファザーの「発見ゼロ」は、次の 4 つの形で未実行を頑健さに偽装します。同じ形は 1 つのプロジェクトで 4 回踏みました。

1. 既定値つきの引数を一切上書きできない。 既定値がプールの寸法と噛み合わない op は毎回 ValueError で弾かれ、一度も実行されないまま「発見ゼロ」に数えられます。実例: lf_from_mla の既定 angular=(5,5) は 32×32 を割り切れず、1200 連鎖で一度も走っていませんでした。
検出: op 名で狙い撃ちするヒントだけ既定値も上書きできるようにする(名前レベルのヒントを既定値に効かせると既存 op の挙動が一斉に変わるので、そちらは据え置き)。

2. カバレッジが数しか出ない。 「304/417」では、残る 113 が頑健だから発見が無いのかそもそも到達不能なのかが分かりません。
検出: 族ごとの内訳を出す。出した瞬間、光子計数族が 10/17 ―― fail-closed が効きすぎて 7 op が一度も実行されていないと分かりました(汎用 signal プールは負値を持つ正弦波で、光子カウントは非負が契約なので毎回きれいに拒否されていた)。守りが完璧に働いた結果、その守りの奥が一度も試されない。

3. 署名がメッセージ中の数値で分裂する。 良いエラーメッセージほど「負の bin が 127 個、最小 −1.176」のようなその実行固有の数を含みます。素の文字列で同一視すると、同じ 1 件が毎回別署名になる。署名が 99 → 238 に膨れた増分のほぼ全部がこれでした。
検出: 署名を作る前に数値を伏せる。238 → 40 に収束します。

4. 必須引数を束縛できず、記録も残さずスキップ。 3D 族が 1500 連鎖で 310 中 204 しか実行されていない理由を掘ったら、op が頑健だったのではなくファザーが黙って飛ばしていました。

必須引数を束縛できず黙ってスキップ      70
入力型がプールに無い(連鎖で作る必要)    21
束縛は通る(単に引かれなかった)          9

足りなかったのはカメラ内部行列、姿勢、RANSAC の閾値、voxel 化の範囲と解像度といった、ごく普通の引数でした。ヒントを足して経路を開いた瞬間、これまで一度も動いていなかったところから本物の型不整合が 5 件出ます。

project_points        out 宣言 image2d → 実際は (uv(160,2), depth(160,)) のタプル
alpha_shape_boundary  out 宣言 points  → 実際は添字 (38,) int64
segment_rigid_motions out 宣言 labels  → 実際は dict{labels, motions}
pnp_ransac            in 宣言 image2d  → 実際の第2引数は 2-D 点 → 生 IndexError
surface 型            多項式モデルと B-spline モデルを同じ名前に畳んでいた

台帳全体の健全性検査を書いたら、同じクラスがさらに 7 件。直せなかった 3 件は理由つきで一覧に明記し、「ちょうどこの集合だけ」を検査するようにしてあります ―― 直したのに残っていても、新しい乖離が増えても落ちます。

到達不能かどうかは、推測せず不動点で解けます。 初期プールの型から始めて、入力が揃う op の出力型を足し、閉じるまで回す。実測すると 434 op 中、構造的に到達不能なのは 1 件だけでした。残りは全部「引かれなかっただけ」です。

走らせた結果はこうなります。

== 拡散 1500 連鎖 x len 6(seed 7001, 45s)
== op カバレッジ: 321/434
== 族ごとの到達: 1d 34/34  2d 12/12  3d 204/310  lightfield 17/17  math 20/26  optics 17/18  photon 17/17
== 発見(生): {'CONTRACT': 284} / 署名数 40

型を分ける判断基準 ―― 混ぜたら例外か、もっともらしく間違うか

「同じ形だから同じ型でいい」は、静かに間違う系を作ります。判断基準は 1 つです。

混ぜたときに例外が出るなら、同じ型でいい。もっともらしく間違った数字が返るなら、分ける。

いちばん強い例は偏光でした。偏光子の掃引と多灯のライトスタックは、どちらも (N,H,W) の非負配列で構造的に区別がつきません。 そして混ぜると両方向とも黙って間違います

  • 本物のライトスタックを偏光分離に渡すと、シーンに偏光子も偏光光もないのに 偏光度 5.4 % を捏造して返す(無作為な光源配置 50 個中 50 個が受理)。
  • 逆に本物の偏光掃引を測光ステレオに渡すと、真の法線が (0,0,1) の平らな面に対して 34 度ずれた法線を残差 21 % で返す。同じ op が本物の測光データなら 0.000115 度です。差は 296,000 倍(独立に測り直して 35.15 度 対 0.000000 度)。

例外も NaN も出ない、自信を持った嘘です。同じ判断を、時間軸が先頭の動画 (T,H,W) と空間ボクセル、到達時刻立方体 (H,W,T)、色の四元数とモノジェニック信号、複素のビート立方体と実の光子ヒストグラム、z 走査スタックでも行いました ―― 実測で 6 回

逆に分けなかった例が音響信号です。任意の実 1 次元配列は本当に妥当な音響信号なので、型を宣言しても嘘にならない代わりに守るものが無い。危険は配列ではなくスカラの側にあったので、防御をそちらへ置きました。

分けるときの落とし穴が 1 つあります ―― 入口の op と消費側の op は、必ず同じモードへ一緒に置いてください。 消費側だけ足すと、誰もその型を産まないので永久に到達不能な死んだ語彙が増えるだけです(ファザーで踏んだのと同じ罠)。

進化型のアルゴリズム開発環境 ―― 語彙とワークロード

Status: PoC ―― 4 課題での実証です。汎用の自動設計はここでも主張しません。

層② の続きです。語彙を 511 op に広げても、それを使う仕事が無ければ空回りします。 ここはその実測です。

語彙を足しても、増えていなかった

カタログに 34 op 足した直後、進化探索の語彙は 1 つも増えていませんでした。 橋渡し op が 0 件。理由は 2 段階でした。

まず、新しい型に対応する進化側の型が無い。これは足せば済みます。ところが足しても足りなくて、その族の入口(画像 → ライトフィールド、深度 → 到達時刻立方体)が既存の画像型を入力に取る。既定語彙に入れると既存型の候補リストが動き、ゲノムから op への写像が変わって過去のチャンピオンが黙って書き換わります

結論は前節と同じ「入口と消費側を必ず一緒に、同じモードへ置く」。既定語彙は 801 op のまま 1 つも動かさず(写像の不変性を保つ)、広い語彙(opt-in)側で 873 → 890 op、新族の橋は 5 → 22 本にしました。この不変量はテストで固定してあります ――「その型を産む op が語彙に無いのに、食う op だけある」状態を検出したら落ちます。

課題が古い型しか受け付けていなかった

進化ループを回すと、落選理由の筆頭がこれでした。

落選  5  課題が受け付けない入力型(histcube)
落選  4  課題が受け付けない入力型(lightfield)
落選  2  課題が受け付けない入力型(counts)

課題 12 件がすべて古い型だったのです。新しい型の課題を 4 つ足しました。真値はどれも前方モデルから厳密に作れます(答えを知ったうえで入力を合成する)。

locked holdout で測る ―― 勝ち 2 件、負け 1 件

py -3.11 robust.py --problem photon_denoise --seeds 3 --gens 12 --pop 12 --isolate \
    --workdir out/rb_2026_09_02_A      # workdir は空であること(理由は後述)

robust.py は N seed を独立に回し、選択は必ず TRAIN 側で行い、locked holdout(champion に対して一度だけ採点する、本当に触れていない分割)を報告します。seed 間のばらつきも一緒に出ます。

課題 恒等 手(既存 op 単体の最良) 進化 手比 seed 間 std 手超え
光子ヒストグラムのデノイズ 0.4174 0.5536 0.7845 +41.7 % 0.0608 3/3
振動している場所の地図 0.0000 0.7163 0.8941 +24.8 % 0.0006 3/3
ライトフィールドの視差地図 0.0000 0.5219 0.5465 +4.7 % 0.0224 1/3
鏡面反射の除去 0.4905 0.8343 0.6277 −24.8 % 0.1900 0/3

同じ表を観測用の分割で取ると、こうなります。

課題 恒等 進化 手比
光子ヒストグラムのデノイズ 0.3265 0.4433 0.7944 +79.2 %
振動している場所の地図 0.0000 0.6791 0.8783 +29.3 %
ライトフィールドの視差地図 0.0000 0.4882 0.5075 +4.0 %
鏡面反射の除去 0.4422 0.8730 0.7761 −11.1 %

光子の手比が +41.7 % と +79.2 % に割れます。 しかも恒等写像の値まで 0.4174 対 0.3265 と動く ―― op を 1 つも適用していないのに動くのですから、これは性能ではなく抽出そのものの揺れ(1 split 8 件)です。片側だけの表を出すと、抽出運を性能として報告することになります。

光子計数の champion は 光子族だけで閉じた合成でした。

irf_convolve → background_subtract → deadtime_correct

新しい族が「単体で使える op」ではなく、op を繋いだ手順として価値を出した最初の例です。

負けた 1 件の読み方が、この表で一番役に立ちます。 鏡面反射の除去で進化が見つけた champion は、四元数族を経由する族またぎの経路でした(色画像 → 四元数 → 色空間回転 → 色画像)。観測用の分割では 0.7761 と手の 0.8730 に迫って見えたのに、触れていない分割では 0.6277。seed 間のばらつきも標準偏差 0.190 で、3 seed のうち手を超えたものは 1 つもありません。勝った振動地図は逆に、触れていない分割のばらつきが標準偏差 0.0006 で 3/3 が手を超えました。

規律: 観測用の分割だけを見ると「惜しい」と読みます。locked holdout とばらつきを同時に出さない限り、勝ったかどうかは決まりません。

訂正(2026-09-02): この表の初出版は、1 行の中で基準線と champion を別の抽出から取っていました
(基準線が cfg seed 1〜2、champion が cfg seed 0。しかも課題ごとに seed が違う)。
原因は robust.py が基準線ファイルの不在を null のまま素通ししていたことで、例外は出ません。
いまは進化を始める前に自分で基準線を測り、null が残れば abort します。
上の表は測り直した値で、成果物に残っていた進化値と 4 桁一致します。
なお workdir に基準線 JSON があると evolve.run が初期集団を差し替えて結果が変わるので、
上のコマンドは空ディレクトリを指定しています。

「正直さ」を仕組みにする(続き)

前半の同名の章の続きです。ここでは規律を検査に落とした話を 3 つ。

来歴の規律を、文章から検査へ

「同じ名前で実装すると問題ないか?」という問いへの答えは、コードが独立に書かれていても、来歴は書いたものが全てです。実際、新しいモジュールの docstring に商用製品名が「このモジュールが存在する理由」として書き込まれる事故が起きました。コードは公開文献から独立に起こしたものでしたが、それでも由来の記録は汚れます。

規律を三分法に書き直しました。

用途 判定 理由
自分たちのもの(モジュール名・op 名・API 名・「これが存在する理由」)に他社名を付ける 禁止 実際にどう書いたかに関わらず、偽の来歴記録を作る
相互運用の識別子(機材の driver を選ぶ文字列、別ツールから来た人向けの別名表) 許可 向こうに実在するものの事実上の名前。消しても独立性は上がらず可用性だけ下がる
出典表記つきの引用(調査記録で「どの賞が誰に出たか」を URL つきで書く) 許可 これは引用であり、消すと検証不能になる。帰属は盗用の反対

判定基準は「その名前は自分たちのものを指しているか、向こうのものを指しているか」。向こうのものを出典つきで指すのは引用で、向こうのものを自分たちに貼るのが、絶対にやってはいけないことです。

旧版の規則は「文書のどこにも書くな」でしたが、リポジトリ自身がそれを守っていませんでした。 守れない規則は監査もできません。実態に合う形へ直したうえで、機械検査を書きました ―― モジュール名・関数名・モジュール docstring・公開 docstring の 4 面を走査し、免除には理由の記述を必須にしてあります。

py -3.11 -m pytest tests/test_provenance_naming.py -q      # 7 passed

ゲートを作ったら、そのゲート自身を敵対的に検証する

GPU 加速は「CPU の正解と数値一致した op だけ載せる」という忠実性ゲートで運用しています。そのゲートに 3 つの穴がありました。同じことを自分で書く人のために、全部並べます。

1. reflect パディングは torch と scipy で別物です。 端のピクセルを複製するかしないかが違います(scipy の既定は reflect、torch の同名は反射の起点が 1 画素ずれる)。この差の修正が一部のカーネルにしか当たっておらず、直したら sobel・median など 7 op が端まで完全一致になりました。さらに「原理的に忠実化できない」と結論して除外していた DoG(差分ガウシアン)も、真犯人がこのパディングでした。移植で端が合わないときは、まずパディング規約を疑ってください。

2. ゲートが 1 パラメータ点でしか試験していませんでした。 大カーネル時に検査マージンへ端がめり込みます。5 点スイープ + カーネル半径連動マージンへ強化しました。

3. 強化版にも第二の穴。 平坦(定数)画像で、正規化が float32 の丸め雑音をフルスケールに増幅します。 派手な壊れ方をします ―― 定数入力で canny が全面前景化する。検査画像に定数画像と量子化画像を加えたところまでで、全 90 写像が一致しています。

別名 op の機械探索にも同じ罠がありました。 実装が同一で名前だけ違う op を、全未対応 op × 全 GPU カーネルの総当たりで掘ったのですが、最初にテスト画像へ塩ノイズの二値画像を使ったら「収縮すると全部消える」縮退で偽の一致が量産されました ―― 全ゼロ同士は何とでも一致します。 構造化した blob 画像に替えて再探索し、5 パラメータ点の実測で確認できた 42 件だけを採用(灰色画像で別動作をする 1 件は数値が一致していても棄却)。これで出荷レシピ 20 本の GPU カバレッジは 45 % → 79 % になりました。

数字が信用できるかを決める 5 つの規律

同じ期間に自分と AI の誤りを 5 件記録しました。物語は要らないので、規則の形にします。

  1. 比較は必ず同一 split で取る。 手の基準線 0.3945 と進化の 0.546 を並べて「+38 %」と書きかけましたが、この 2 つは別の抽出で測った数字でした。同じ分割で測り直すと +2.0 %。違う抽出どうしを比べると 20 倍近く盛れます。
  2. 手の基準線は「最初に思いついた手」ではなく「既存で最良の手」。 昇格ゲートが全探索して、置いた手より 2 倍以上強い op があると示しました。弱い方を基準にすると、課題が実際より易しく見えます。
  3. 型の判定は、型ではなく形で書く。 pose の検査を isinstance(np.ndarray) で書いたら 6 件挙がり、うち 4 件は自分の述語の誤りでした ―― GPU 対応の op は torch のテンソルを返すのが約束だからです。形で判定し直すと、残った 3 件は本物でした(1 つは pose を名乗りながら R も t も持っていなかった)。
  4. 自分に有利な数字こそ先に測り直す。 「四元数の合成は 10 万回でドリフト 0.0、行列は 4.4e-10」という四元数優位の報告がありました。その 4.4e-10 は、pose_quat の別のバグ(正規化で分母に 1e-12 を足していた)が非直交な行列を食わせていた結果です。正しくは 4.33e-14 ―― 4 桁小さく、ただの丸め誤差で、優位ではありません。
  5. コメントに書いた数字は、書いた時点では未測定である。 ガイドを書く過程で未実測値に気づき、実測表に差し替えました。実測すると高 pad 側では書いていたより悪く(純白色雑音が存在しない強い過渡 +4.09 を報告)、低 pad 側では軽い。捏造した 1 組の数字では、このスケール依存性は伝わりません。

公開直後の API に、28 関数の配線漏れがあった

四元数の在庫確認で、四元数・双対四元数の関数が 28 個実在するのに、どちらの facade からも 1 つも引けない状態だと分かりました。配線した直後、3 件の無言の誤りが出ます。

  • quat_normalize([0,0,0,0])[0,0,0,0] を返し、回転行列にすると単位行列になる ―― 「回転が定義できない」が「回転しない」に化ける。
  • 軸が零ベクトルの回転要求が、ノルム 0.878 の非単位四元数になる。
  • |RᵀR − I| = 4.0e-12 ―― 丸め誤差ではなく一方向の縮みで、合成を重ねるほど溜まる。

原因は norm + 1e-12 で割っていたことでした。零長を fail-closed にして厳密除算に直すと、直交性のずれは 4.4e-16 になります。切り分けで分かったのは、4.0e-12 という数字は quat_to_hom_mat3d が内部で quat_normalize を呼ぶため ε が二重に効いていたからで、外側の式だけ再現しても現行コードでは出ないことでした。

同じクラスの取りこぼしは、兄弟コードごと一掃してください。 サイズ上限が float64 昇格の後に効いていて防ぐはずの確保を防げていなかった件も、2 モジュールで直しました(0 バイトの view で 0.0000 秒の拒否を確認)。

8 族あわせて、敵対的検証で見つけて直した実バグは 30 件超。すべて「例外が出る」ではなく「黙って間違った数字を返す」型です ―― 角度が正弦で折り返されて 95 度と 85 度がビット完全に同一になる、開口の無いアレイが −90 度を自信を持って返す、パッドを跨ぐフレームが最も衝撃的に見えて存在しない過渡を報告する、端で切れた包絡線が 76 % 間違った高さを返す、波長の nm/µm 取り違えが完全に無症状。どれも、走らせて測らなければ見えないものです。

いま、何が手に入るか

pip install -U fullseye で入るものを、能力の側から並べておきます。

使いたいもの 入口 覚えておく数字
段差・表面形状(位相の巻き戻し無し) csi_stack_simulate / csi_height_map 位相シフト法は λ/4 で壊れる
回転機械の欠陥診断 envelope_spectrum / order_spectrum 欠陥周波数は生スペクトルに無い
後からピント・視点を変える lf_refocus / lf_depth_from_focus 利得 = 角度分解能、代償は空間分解能
光子レベルの計測と誤差棒 tcspc_simulate / dtof_depth / photon_uncertainty Poisson は分散 = 平均(校正不要)
色空間の 3 次元回転 rgb_to_quaternion / quat_color_rotate 四元数固有の利得はこれだけ
距離・速度・到来角 fmcw_beat_simulate / range_doppler_map bin 幅を渡さないと単位はビン番号
テカり除去・多灯の法線復元 specular_diffuse_split / photometric_stereo_robust 遮蔽は解ける。破綻点はハイライト汚染 4/8 灯
見えない振動を測る・見せる displacement_series / motion_magnify 崖は 3.0619 画素(J₀ 第一零点)
大きい 3D を持ち歩かない vol_crop_domain / vol_boundary / vol_rle_encode 1/34・19 %・1/145
骨格をグラフとして読む apply(mask, "em_skeleton") / junctions_skeleton / skeleton_branches3d EM93 と画素単位で一致
1D プロファイルの解析 derivate_funct_1d / zero_crossings_funct_1d ほか 37 op 2D measure1d・3D probe と合流
連鎖バグを掘る tools/chain_fuzz.py --coverage-out カバレッジの内訳を必ず見る
パイプラインを進化で作る robust.py --problem <name> locked holdout とばらつきを同時に

族ごとの詳しい使い方(単位・破綻条件・既存手法との比較)は docs/ops/<族>/guides/ に 24 本のガイドがあります。テストは 8,169 件が全緑です。

2026-09-04 の拡張 ―― 全体像はここまで広がった

この記事の初版(9/2)から 2 日で、技術の全体像がかなり伸びました。op 数は 2D 870 → 877、3D 344 → 346(レジストリ実測)ですが、増えたのは op の数より「層」です。要点だけ、実測の数字つきで:

何が増えたか 中身 実測
精度ユニオン型ストレージ PrecisionUnion 配列をタイルに切り、各タイルを局所値域が要る最小ビット深さ {0,1,2,4,8,16} で保持(真のビットパック)。N-D 対応・save/loadapply/run_pipeline がそのまま受け、ユニオン→ユニオンで閉じる op は復号せず遅延実行(点アフィン・clip・threshold・集合演算 4 種・max/min・feature 3 種) ラベルボリューム (64,128,128) で 15.9x 無損失、深度 f32 で 3.9x、ディスク 378x。threshold 後 616x、集合演算は dense 比 ~12x 速く結果 400〜1300x 小。自然画像では 0.98x で勝たない(正直に報告)
exact geometry predicates orient2d/orient3d/incircle/insphere を Shewchuk 流 2 段適応(float フィルタ → Fraction 厳密)。stdlib+numpy のみ naive float は線上補間点で 約 19% 誤符号、adaptive は厳密解と完全一致。凸包を堅牢化
robust 幾何判定 geompred point-in-polygon/tetrahedron/polytope(内外 3 値)、Delaunay 検査、メッシュ向き検査 near-edge で naive winding が 8.64% 食い違う
実解剖の手骨格 MyoSuite myo_sim(Apache-2.0)の骨メッシュ 27 個を MJCF から stdlib で組み立て MuJoCo FK と 6e-11 m で一致、指長順は解剖学どおり(上の手骨 hero)
レンダラの注入口 vertex_normals=(SDF 勾配法線)・vertex_albedo=(頂点色) この記事の hero と改善過程がそのもの
画像点検 232 アセットを寸法表→実画像で点検 hero 3 点を 1280px に、手骨を実骨格に
構造化光スキャナ absolute_phase(粗い絶対推定で 2π 次数を確定)+ triangulate_column(視線 × コラム平面、閉形式)。相補 Gray + 位相シフトの撮影合成から復号・三角測量まで op で閉じる 静物(奥行き 102 mm)で RMSE 0.036 mm・中央値 0.017 mm、Gray 復号の誤り 0/36,011 px。零点は Gray のみ 0.073 mm・位相のみは桁違い

読み方の変化として大事なのは 1 点だけです。「ユニオン→ユニオンで閉じる演算が勝つ」。復号して dense 配列を作る op は numpy にかないませんが、ヘッダ(定数タイル・値域)で決まる部分が大半になる演算では、メモリと速度が同時に桁で変わります。「発見ゼロ」が「未実行」でないことも、同じ日に台帳リング満杯で落ちた自分のテストを直しながら再確認しました。

次の材質拡張(鏡・ガラスのレイトレーシング、CD の回折虹、薄膜干渉、ヘアライン)は着手前です。できたら改善過程ごと、この記事に足します。

2026-09-19 の拡張 ―― 検査ワークフロー層と、その前段

op は 931(レジストリ実測)まで来ましたが、この回で増やしたのは op の数より 「ライン担当が最初に触る形」 です。op 1 本ずつは呼べても、「フォルダを指して、前処理と計測を決め、仕様で良否を付け、集計とレポートをもらう」形になっていませんでした。全部 既存 op の配線(glue)で、新しいアルゴリズムはひとつも無い のが要点です。数字は tests / examples の実測。

何が増えたか 中身 実測
計測 → 仕様照合 → 根拠つき判定 judge 計測 dict と仕様 dict(min/max / nominal±tol / eq / in)から PLC 語彙(ok/ng/error)の Verdict を作る。欠損キー・NaN・数値でない値は error、仕様の綴り間違いは ValueError(誤字の仕様が「何も検査しない」で通ると永久に全部 ok になる) 出口 signal_verdict(PLC コイル one-hot)にそのまま渡せることをテストで固定。テスト 20
フォルダ一括検査 inspect_batch 決定的順序で列挙 → recipe(run_pipeline)→ measure(計測 op を束ねた callable)→ judge。各行に入力の sha256・計測・Verdict・所要時間。数値列は EWMA で工程管理、.xlsx/.md/.jsonl に書き分け、監査ログ(JSON Lines)に追記。1 枚の失敗は error 行でバッチを止めない 0 枚は ValueError(「0 枚検査して全部 ok」は報告にならない)。テスト 16
ゴールデン比較 compare_to_golden 位相相関で整数並進を合わせ(max_shift 超えは合わせず align_ok=0)→ 差分 → 閾値 → 連結成分 → 計測 dict。巻き込んだ縁は数えない。golden_measure / golden_spec で上の 2 つに差し込める 6 枚のロットで異物と大ずれの 2 枚だけ ng。テスト 14
配備前の検定 inspection_fixture 既知の良品/不良品セットで「良品が全部 ok かつ 不良品が全部 ng」を証明し、spec_margins が仕様キーごとの余裕(限界までの距離、負なら超過)を出す。見逃し・過検出・error を行で返す 締めすぎ → 過検出 6、緩めすぎ → 見逃し 4、壊れた 1 枚 → failed。テスト 8
Excel レポート save_xlsx_report report(Markdown)/ save_json と同じ (見出し, value, sort) から .xlsx(表・点群・スカラはセル、画像はサムネイル)。openpyxl は optional jsonio=機械 / mdio=読む / xlsxio=配る の 3 系統
MCP の型付き結果 fullseye_apply / pipeline / inspect の小さな型付き結果に JSON 封筒 + Markdown を自動添付(大きい配列は付けない) MCP 越しでも bit 一致で戻せる
レンズ歪みの推定 estimate_distortion 直線パターン(plumb-line)から放射 k1..k3・接線 p1,p2 を最小二乗で。基板なし。camera_calibration(Zhang)も facade に露出 歪ませた合成格子から係数を回収(往復をテストで固定)
SPC の 5 本目 spc_ewma Roberts 1959 の EWMA、時間依存の管理限界は閉形式 上の一括検査の系列にそのまま流れる
フラクタル次元 fractal_dimension ボックスカウント(Minkowski–Bouligand)。シェルピンスキーで log3/log2 ≈ 1.585 表面の粗さ・粒状感の 1 数字

読み方として大事なのは 1 点です。判定の入口(judge)と出口(signal_verdict)が同じ語彙を持ったので、「測る → 判定する → PLC に出す → 監査に残す」が 1 本で閉じたこと。責任の所在(どの入力の bit 列から、どの仕様で、なぜ ng になったか)が行ごとに残ります。

この回に踏んだ落とし穴も残しておきます。op やモジュールを 1 本足すたびに、隠れた門(日本語ヘルプ・RAG ガイドのノート枚数・README の op 数・翻訳の指紋・wheel の py-modules・DOCS 表の一覧・図の台帳)が 8 種以上あり、部分集合の検査では必ず取りこぼしました。結論は単純で、足す回は push 前にローカルで全部回す(約 45 分)。CI が長い(約 90 分)ので、少し実装しては push して待つのをやめ、複数機能を機能別 commit で溜めて 1 回で押す運用に変えました。

2026-09-20 の拡張 ―― 第三者 AI に使い込ませて、指摘を 1 件ずつ検証した回

この回は op を足していません。代わりに、外部の AI(GenSpark)に 0.2.0 を core(pip install fullseye)と all(fullseye[all])の 2 環境で使い込ませ、実行出力つきの報告を 54 通(指摘 197 件、N1〜N197)受け取りました。やったことは単純で、1 件ずつ現 master で再現し、バグ側は直してテスト・example・堅牢性ノートを同じ commit に、設計側は「変えない理由」を表に残す。報告の中には後から本人が撤回したもの(「87 op が恒等になる」は入力の種類違いの fallback、「Insert ボタンが無い」は見落とし)もあり、第三者の指摘は探針であって判定ではない ―― 再現してから採る、が全部です。

この回の直しは 0.2.1 として出しました(pip install -U fullseyeRelease v0.2.1、Zenodo の版 DOI 10.5281/zenodo.22851588)。新しい op は無く、型付き契約の語彙も変えていないので patch 版です。

何を直したか 直す前 直した後
「無い」と「入っていない」 backend の optional 依存が入っていない op も、綴り違いの op も同じ KeyError: unknown operator 同梱の索引(module / requires)から MissingBackendError(KeyError の派生)が 不足 extra と pip install "fullseye[...]" を言う。本当に無い名前は fullseye hasop_find を案内
run_pipeline の書き方 タプル・dict・文字列の混在で分かりにくい TypeError 5 形("a,b" / 名前 / タプル / (name, {"a":..}) / dict)が同じ結果、外した形は 原因を指す TypeError(引数の順が逆、未知のノブ、段が名前で始まらない)
変換の無い入力 文字列配列・複素配列が fallback 方針では「全 0」で通る 方針に依らず TypeError。落とし先の無い入力を 0 にするのは fallback でなく嘘
空・極小の入力 271 op が scipy / numpy の生の例外(axis 1 is out of bounds)で落ちる 0 要素は門で 1 文に。極小画像で op の中から出る例外には op 名と入力の形を注記(型も文も変えない)
非有限の出力 NaN / inf の出力を黙って有限化 台帳に non_finite_output: N of M を記録、on_error="raise" では停止。設計で NaN を返す op(tb_fly_tau_from_expansion)だけ免除台帳
CLI --version が無い、help の例が checkout の綴り、parity が argparse 衝突、samples の action 必須、coverage が wheel で FileNotFoundError 全部直し。呼ばれ方(fullseye / py -3.11 imgevolve.py)に合わせて help の例文が変わる
Studio の実行キー Ctrl+R 非対応、F5 は未適用の編集を無視して古い pipeline を走らせる F5 / Ctrl+R で 未適用の編集を先に適用してから実行。1 op 実行が fallback したらタイトルに「FALLBACK」
engine.load をインスタンスで e = FullseyeEngine(); e.load(p) が classmethod の戻り値を捨て、空エンジンが入力をそのまま返す インスタンスに読み込む(from_dict / from_ops も)。dict 段を受理、不正な段・範囲外の uptoValueErrorto_python に coding 行と --ops 文字列
空の op 名・狭い float apply(img, "")lowpass に解決して走る(halcon 別名 "" に一致)。float16 / float32 が契約の float64 に昇格されず、scipy が float16 を拒んで入力のコピーが返る 空名は unknown。float16 / float32 は 無損失で float64 に昇格(記録しない)
pose helper に行列 pose_to_hom_mat3d の出力(4×4)を自分に戻せない 6/7 ベクトル・3×3・4×4 を受ける
n-ary の一覧とつまみ add_image 等 17 本は呼べるのに op_names() に無い。a / b が効くかは文でしか分からない。CLI から 2 入力の op を呼べない op_names(include_nary=True)(既定は不変)、list_ops() の各行に knobs(実測 461 op、未計測は None)、fullseye apply add_image a.png out.png --input2 b.png
台帳の引き方(第 15〜22 報) op_producers("gaussian") が黙って空、op_presets("no_such"){}op_run("write_wav") が配列をファイル名にして stdlib の Wave_write が「Exception ignored」を吐く、種を作れない型に None を渡して IndexError 未知の型・op 名・op は ValueError(op_sorts() が型名の一覧)、パス引数は台帳でデータ扱いしない、op_run は「no built-in sample for input sort 'lab' — pass it explicitly」と言う
画像 I/O(第 18・19 報) write_image が cv2 の False を捨てて ファイルが無いのに無言(ppm・親ディレクトリ不在・書けない拡張子)、read_image は何でも FileNotFoundError、uint16 を書くと 8 bit に潰れる(読みは 16 bit を保つのに) facade は imgio に委譲。書けない先は 1 文で止める、読めない理由を分ける(ディレクトリは IsADirectoryError)、uint16 は 16 bit のまま、float は既定 8 bit と文書化し depth=16 / "float" で無損失、8 bit の量子化は四捨五入(誤差 1/255 → 1/510)
配列でない画像(第 26 報) apply("abc", "gaussian") が既定の fallback 方針で "abc" をそのまま返す(str / dict / スカラーは ndarray でないので検査を素通り) raster を取る op には配列にしてから同じ検査、0 次元は方針に依らず TypeError
名前解決の可視性(第 20 報) 同じ HALCON 別名を複数 op が名乗り(387 別名中 68)、どれが走るかが行から見えない。list_ops(sort="IMAGE") が 0 行 規則は元から一つ(完全一致 → 明示表)。行に halcon_peers、正規 op の無い衝突は明示表を必須にする門、sort / search は大小とアクセントを畳み、未知の sort は ValueError
段の書き方は 1 つの規則で(第 53 報、0.2.2) 同じ段 {"op": "otsu"}run_pipeline では走るのに、diagnose_stages は「unknown operator {'op': 'otsu'}」、fullseye.Pipelineunhashable type: 'dict'。段を読む場所が 4 つあり、門は 2 つにしか無かった op 名の取り出しを engine.stage_name の 1 本に。4 つの入口が同じ段を同じ名前に読むことを門で固定、壊れた段は検証器の error 行に
MCP の検索面にクラスが混ざる(第 41 報、0.2.2) 「callable なら op」でクラス 41 個(Image / Pipeline / MissingBackendError / TcpChannel …)が fullseye_search_ops に op として出ていた クラスとモジュールを除く(facade 1,069 → 1,028、実関数は 1 本も減らない)。「対応表に無い 474 件」は HALCON 対応表との比較で、残る 460 件は実関数
台帳の取りこぼし・studio の --help・名前空間(第 43・44・50 報、0.2.2) 環状バッファが黙って古い事象を捨て件数が合わない。fullseye-studio --help が表示の無い Linux で SIGABRT。fullseye.os が通る fallback_overflow() で捨てた数を返す(恒等式を門に)。--help / --version は Qt を起こさず答え、表示が無ければ 1 文で止まる。import の道具を facade から消す
strict_mode が経路の一部しか厳密にしていない(第 55 報 = 0.2.1 の再測定、0.2.2) with strict_mode(): apply(gray, "access_channel") が例外にならず台帳に記録して素通り(on_error="raise" なら止まる)。op_run(img, name) が unhashable、python -m fullseye が無い、wheel の fullseye index が site-packages に 3 つ目の索引を書く strict は on_error=None のとき raise と同じ(全経路)。op_run は引数順と apply を案内、python -m fullseye = CLI、index は同梱索引との差を理由つきで言い wheel の中に書かない
器・入力不足・空列の成功(残り候補の再現、0.2.2) apply(Image(img), …) が「scalar Image」で止まる。op_run("blend_mode", rgb) が生の TypeError。fullseye algo run quicksort--seq 無しで [] を印字して rc 0。op_finddoc が空 Image.array に剥がす。入力不足は「missing input(s) top (rgb) — this op takes base, top」。--seq 無しは例文つきで拒む。doc はノートの 1 行目で埋める

変えなかった設計(理由つきで残したもの、20 件超): 既定の on_error="fallback"(産業ラインでは 1 枚の失敗でバッチを止めない。厳格にするなら FULLSEYE_ON_ERROR=raise)/ 警告は op ごとに 1 度(台帳 fullseye.fallbacks() が全件)/ ノブ a,b は [0,1](進化のゲノム表現。範囲外は記録して clamp)/ 3-D 入力を 2-D op が受ける件は既知の課題として台帳に / 索引の段の合算(登録 931 + n-ary 17 + 台帳 1048)/ op_names() の既定に n-ary を混ぜない / apply2 を足さない ―― など。

落とし穴を 1 つ。手元(Windows)で全部緑にして push した最初の CI が Linux で赤になりました。原因は os.path.basename実行 OS の区切りしか知らないこと ―― テストに書いた Windows 形のパス(...\Scripts\fullseye.exe) が Linux では丸ごと 1 要素になり、「fullseye で始まらない」と判定された。OS が解釈する文字列(区切り・大小文字)をテストに埋めるときは、その解釈が両 OS で同じかを push 前に問う、が教訓です。

2026-09-20 の拡張(その 2)―― コネクトームを「数えて・揺らして・回して・見る」族

バグの回を閉じたあと、次の版の方向として コネクトーム(神経の結合表)の開発基盤 を足し始めました。結合表は重みつき有向グラフで、この道具箱の画像・点群・信号のどの族にも収まらない。そこで新しい型語彙を 2 つ(conn_graph = n×n の重みつき有向隣接行列、synapse_table = (m, 3) の pre / post / count)だけ足し、族 conngraph に 23 op を置きました(numpy だけ、networkx / scipy.sparse に依存しない)。シナプス表 → 隣接行列、次数を保ったまま辺を繋ぎ替える帰無モデル、次数表・クラスタ係数・媒介中心性・Laplacian スペクトル・成分・モジュラリティ・rich club・3 点モチーフ、そして結合行列をそのまま reservoir(echo state network) にして閉形式の ridge で読み出す計算。真値はリング・スター・完全グラフ・2 クリークの閉形式で門にしてあります。

最初の PoC は正直な「効いていない」で終わりました。幼虫の完全コネクトーム(Winding 2023、2,952 ニューロン)を固定の reservoir にして MNIST の部分集合を読むと 91.9 %(生画素の ridge は 77.6 %)—— ここまでは先行研究のとおり。ただし先行研究に無かった対照を置くと、次数を保って辺を繋ぎ替えたグラフで 91.6 %、同じ密度の乱数で 91.9 %。読み出しが使っているのは reservoir という仕組みで、進化が決めた配線ではない。

それでも配線に構造が無いわけではない —— 精度という 1 つの数では見えないだけです。そこで同じ reservoir を 見る ことに使いました。MaleCNS(雄の全中枢神経系)で soma の座標を持つニューロンのうちシナプス総数の上位 3,000 体の部分グラフ(344,719 辺)に、右の視葉だけへ刺激を入れ、活動が伝わる様子を脳と VNC の立体を回しながら描く。隣は次数保存 shuffle に 同じ刺激・同じ入力行列

右視葉への刺激が配線を伝わる様子 ―― 左: コネクトーム、右: 次数保存 shuffle(灰 = 全 141,781 soma、橙 = 右、青 = 左、輝度の尺度は全コマで 1 つ)

↑ 左では活動が視葉に留まりながら中枢へにじみ、右では 1 步で脳も VNC も点く。

同じ瞬間を背側・側面・体軸方向の 3 方向から同時に(上段コネクトーム、下段 shuffle)

↑ 回すと奥行きが分かり、3 方向を並べると同じ瞬間の広がりを比べられる(points_activity_video(views=...))。

各ニューロンが初めて点いたステップ(黄 = 0、橙 = 3、青 = 6 以降、灰 = 36 步で点かない): 左コネクトーム、右 shuffle

数字で言うと(graph_activity_spread / graph_activation_latency): コネクトームでは刺激の重心からの活動の平均距離が 88 → 230 µm を 17 步かけて 伸び、点く順は視葉(潜時 0)→ 中枢(2)→ 下行(2)で、36 步では VNC に届かない。shuffle は 3 步で 300 µm に散り、遠い 1/4 のニューロンの 93 % が最初の周期内に点く(コネクトームは 0 %)。読み出し精度では見えなかった配線の空間構造が、動きでは見える。

このために足した op が 3 つ(graph_activation_latency / graph_activity_spread / points_activity_video)と、reservoir_states明示の入力行列 W_in(決まったニューロン群に刺激を入れる)。尺度は 3 op とも全体で 1 つ —— ノードごとに伸ばすと動かないノードの丸め屑が「点いた」になり、コマごとに伸ばすと動いていないものがちらつく。生データ(1 GB の feather)は repo に入れず、手元にあれば部分グラフを作ってキャッシュ、無ければ距離依存の合成の代替で同じ経路を走らせて DATA: synthetic surrogate と印字します(展示館の生物ウィングに 2 点)。

個眼を指でなぞると、応答が配線を伝わる

次に解像度を上げました。MaleCNS の注釈には視葉ニューロンごとに六角柱(網膜の個眼 1 つに対応する柱)が付いています。右眼の 892 柱それぞれの視葉ニューロン(柱ごとに上位 3 体)と中枢・下行のハブ 1,400 体で部分グラフ(4,076 体・150,487 辺)を作り、個眼 1 つ分の刺激を入れる。刺激する柱を眼の一行に沿って動かすと、応答が視葉の中を同じ向きに動きます。

刺激する個眼の柱を眼の一行に沿って動かす ―― 左: 複眼(黄 = 刺激した柱)、右: 脳(背側 | 側面、上段コネクトーム、下段 shuffle)

数字にすると、刺激した柱の座標と応答重心の相関は コネクトームで −0.92、次数保存 shuffle(同じ入力行列)で +0.01。網膜部位対応(retinotopy)は配線にあって、次数には無い。像も通せます: 縦縞が視野を横切る像を fly_hex_resample で個眼に落とし、個眼の明るさをそのまま柱の刺激にすると、応答が縞を追う。

縦縞が視野を横切る像を複眼に通す ―― 左: 複眼が見る像、右: 脳の応答(背側 | 側面)

Studio では Tools ▸ Compound eye → brain が同じ部品で対話的に動きます: マウスが指す個眼の柱を刺激し、ドラッグで視点を変え、Studio に開いている画像を眼に通し、shuffle と切り替える(新モジュール eyebrain。柱と個眼の対応は六角座標の正規化による近似で、生データも部分グラフも repo には入れません)。「サンプルを動かした人がびっくりする体験」を全 PoC の合格線にする、と決めた最初の 1 本です。

膜はラベルの境界にしか無いはず ―― EM 校正のセカンドオピニオン

コネクトームの上流には、電子顕微鏡の連続断面からニューロンを切り出す自動分割があります。そこに残る融合(2 細胞が 1 つの id)と分断(1 細胞が 2 つの id)を人が探して直す校正が、コネクトーム作りでいちばん人手のかかる工程で、先行研究はどれも深層学習で候補を出します。Fullseye は学習なしで同じ 2 種類を数える族 emproof(7 op)を切りました。前提は 1 つ ―― 細胞膜(暗い稜線)は本来ラベルの境界にしか無い。内部を横切る膜の弦は融合の疑い(閉じた輪 = ミトコンドリアは穴で除く)、膜の無い境界は分断の疑い。

評価断面 1 枚: 生 EM | 膜応答 | 重ね描き(白 = ラベル境界、マゼンタ = 融合の疑い、シアン = 分断の疑い、黄 = 仕込んだ答え)

CREMI sample A の正解ラベルに人工の融合・分断を仕込み、閾値を前半 6 枚の断面で選んで後半 6 枚で測ると、分断は AUC 1.00、融合は面積で揃えた負例に対して 0.83(乱数 0.53、面積だけ 0.70)。融合は弱い、と正直に書いておきます ―― 内部に膜の多い細胞が弦と同じ形で高く出る。それより大事な発見は評価の罠でした: 仕込んだ融合は「大きな 2 ラベルの和」なので、面積で揃えずに測ると**「大きいラベル = 怪しい」だけで 0.87 が出てしまう**。閾値を選ぶ断面と測る断面を分ける holdout_threshold を op にしたのはそのためです。

断面を z に積んだ立方体の上で疑わしい箇所を回す(マゼンタ = 融合、シアン = 分断、明るさ = スコア、灰 = 全境界)

動画を空間 × 時間の立方体として見る ―― ハエの脳の断面も同じ op で

Video Summagator(Nguyen・Niu・Liu、ACM CHI 2012)は、動画を (x, y, t) の立方体にして、動かない背景を薄く・動く物体を濃く描き、切ったり回したりして場面へ飛ぶ道具でした。Fullseye は同じことを新族 videocube(6 op、numpy + scipy のみ)で再実装しました。時間差分の大きさを不透明度に、前から後ろへの α 合成で軌跡を時刻の色(青 = 始め → 赤 = 終わり)に塗る。断面(x–t のスリットスキャン)では、筋の最初の行が出現時刻、傾きが速度です。

監視カメラ風の合成クリップの立方体を回す(3 つの物体 = 3 本の軌跡、色 = 時刻、灰 = 静止した背景)

真値のある合成クリップ(通過の行・時刻・速度が既知の 3 物体)で、スリットスキャンから読んだ出現時刻と速度は真値と一致し(±0 フレーム)、代表フレーム(video_summary_keyframes)は 3 物体すべての出現直後を捉えます(乱数で 4 枚選ぶと平均 0.21 物体)。同じ op にハエの脳の EM 連続断面(CREMI sample A)を入れると、先頭軸を時刻でなく奥行きと読むだけで、膜が奥行きの色で塗られた管になって神経突起が断面を貫いて走ります。

ハエの脳の EM 断面 32 枚を積んだ立方体(膜 = 不透明、色 = 奥行き)| 1 断面 | x–z の切り直し

回転は video_write_gif でそのまま GIF に書けます(使い回しの出口)。Studio では Tools ▸ Video cube がドラッグで回転、断面のスライダ、断面をクリックでそのフレームへ、と対話的に動き、.npy / GIF / 動画 / .hdf のスタックを開けます。

生きている組織の 3D+t を古典手法だけで短い 3D 動画像に ―― 増幅・流れ・補間・高さ場

動画生成 AI は「もっともらしい動き」を発明します。新族 live4d(14 op、numpy + scipy のみ)は内容を発明しない代わりに、実在する動きを見える形にする 4 つの道を用意し、どれも真値つきの合成系列で数字に固定しました。目に見えない 0.1 voxel の拍動を Eulerian の線形拡大(Wu ら、SIGGRAPH 2012 の 3 次元版)で 8 倍にすると読み取りは 7.89 倍、分かれる塊の 3 次元 Lucas–Kanade の流れは真値 ±0.75 voxel/frame に対し +0.776 / −0.776、粒子の軌跡は時刻の色で 1 枚の立体に、焦点掃引の時系列からは高さ場の動画(誤差 0.31 枚)。

半径が 0.1 voxel だけ拍動する殻を、時間を進めながら回す。左 = 実測のまま(拍動は見えない)、右 = volseq_magnify_motion で 8 倍に

補間(volseq_interpolate_flow)は 2 倍のレートで作った系列を半分に間引いて埋め、抜いた真のフレームと比べました。1 コマの動きが塊の大きさの 2 倍なら RMSE 0.0016(線形ブレンドは 0.0207)で圧勝ですが、動きが約 0.8 σ より小さいとブレンドで足り、warp の再標本化ぶんだけ負けます —— 勝つ領域と負ける領域をそのまま図にしてあります。補間は実測の間を埋める道具で、無いものを発明する道具ではありません。Cell Tracking Challenge の生きた細胞の 3D+t も同じ経路で回ります(生データは commit しません)。

動きの量子化 ―― 脳から筋へ、命令の次元はどこで落ちるか

人は手を上げるとき筋肉を 1 本ずつ意識しません。脳の何万もの状態は体を動かす段階で少数の命令に畳まれているはずで、ハエではその場所が配線に見えます: MaleCNS v1.0 で中枢脳の介在 32,164 体 → 下行ニューロン(DN)1,314 体の細い首 → 腹髄の介在 13,161 体 → 運動ニューロン(MN)708 体。conngraph に足した 4 op(graph_layer_propagate / graph_block_shuffle / states_participation_ratio / states_layer_dimension)で、この部分グラフに乱数の疎な刺激 400 通りを前向きに通し、各層の状態の実効次元(participation ratio)を読みました。

1 つの刺激が脳(青)→ 下行ニューロン(黄)→ 腹髄(緑)→ 運動ニューロン(赤)と soma の位置の上を流れる

疎な発火では 282 > 61 > 8.7 > 2.4 と単調に落ち、脳の状態から MN と同じ 708 列を抜いても 249 なので層の大きさのせいではありません。各受け手の入力重みを保って送り手だけ混ぜた対照では MN が 22 残る —— 腹髄 → 筋の圧縮は配線の特異性、首(DN)の段は対照と同じで収束そのものでした。正直な内訳として、生の PR は少数の刺激が MN を強く駆動する裾の重さにも引かれるので、各刺激を単位ノルムに揃えた向きだけの次元も測り、それでも実配線 25 vs 対照 54。同じ数式を Physical AI に当てると、G1 ヒューマノイドの RL 歩行・走行の関節軌道は 2.4〜4.1、ダンス 9、格闘 12、evis の筋活動 73 本は 6〜9。桁の比較であって同一性の主張ではありません。

3D プリンタの層検査 ―― 形 → 層 → 経路 → 画像 の往復を自分で閉じる

3D プリンタのデータは「形(メッシュ)→ 層(スライス)→ 経路(G-code)→ 印刷中の層画像」と流れます。新族 printpath(11 op、numpy + 標準ライブラリ)はこの往復を既存の語彙(mesh / voxel / table / image2d)だけで閉じるので、真値を自分で仕込めます。角穴つきの箱と歯車状の柱を 0.2 mm で切った層の面積は閉形式と一致(穴だけの層は空)、輪郭を周回する G-code の押し出し量は「周長 × 線幅 × 層厚 / フィラメント断面積」と厳密一致、G-code(相対座標・相対 E・リトラクト・G92・インチ)と 3MF は読み書きで往復します。

期待の層画像 / 欠陥を注入した観測 / 符号つきの欠陥図(+ = 欠け、− = はみ出し)

層検査は gcode_layer_image の期待像と観測像を print_layer_defect_map で比べ、注入した欠け 6 か所・はみ出し 6 か所(1〜4 mm)を許容 3 px(0.3 mm)で再現率 0.96・偽陽性 0 で捕まえました。カメラを 0.2 mm ずらすと再現率 0.90・精度 0.95 に落ちる(ずれの分だけ欠陥の縁を許容に食われる)ので、許容を振った曲線をそのまま出しています。

MICrONS の脳の波 ―― 1 mm³ の視覚野で、配線は実測の応答をどこまで説明するか

MICrONS(マウス V1 + 高次視覚野の約 1 mm³)は、同じニューロンについて電子顕微鏡の配線と 2 光子の活動の両方を持つ唯一級のデータです。Ding ら 2025(Nature)の公開表 —— 12,894 体の soma 位置と自然動画への試行平均応答 120 コマ、校正済みの軸索 148 本から出る 1.69 M 対(結合 8,128 / 軸索と樹状突起が触れているのに結合していない ADP 287 k / 同領域 1.40 M)—— を、生データを commit せずに読みました。

実測の脳の波: 12,894 体の soma に自然動画への応答を載せ、各細胞が自分の上位 1/4 にいる瞬間だけ点けて 1 mm³ を回す(V1 青 / RL 黄 / AL 緑)

like-to-like は再現できました: 信号相関は 結合 0.071 > 触れている 0.045 > 同領域 0.025 で、軸索ごとに札を混ぜる置換帰無に対して 15 SD。触れている対と結合した対は soma 間距離が同じ(289 vs 295 µm)なので、これは距離を揃えた比較です。配線は近接以上を足します: 各軸索の応答と「相手の応答の平均」の相関(同じ 120 コマ上の類似度で、holdout の予測ではありません)は 結合相手 0.24 > 同数の触れている相手 0.18 > 同数の同領域 0.12 で、軸索ごとの対で 73 % が結合相手を勝たせました。

軸索ごとの「結合相手で予測」vs「触れている相手で予測」(対角より上 = 配線が勝つ)

conngraph の reservoir に 148 本の実測応答を流した「配線の波」は、1 コマ遅れの post の状態と実測の相関 0.085 で、次数保存シャッフル 20 本(平均 0.048、最大 0.053)の全部より高く、利得を 0.3 / 3 倍にしても順序は同じ —— ただし正直に薄い数字です。校正済みの軸索 148 本から post 1 体あたり 1.8 本しか入力のない一段のグラフで、波の広がり(軸索からの平均距離 316 µm)は対照と同じでした。空間の局所性は候補の集合(ADP)に既に入っていて、誰を選ぶかには入っていません。実装上の罠を 1 つ: reservoir は 1 ステップで 1 段進むので、post の状態は 1 コマずらして比べないと相関がほぼ 0 になります(族ガイドに記録)。

まとめ

Fullseye は、説明できる古典ビジョンのアルゴリズムを「スキル」として約1000個持ち歩き、それを

  • 即使うか(apply / pipeline)
  • 進化で設計するか(evolve、正直な評価つき)
  • ロボットの目として使うか(Physical AI 知覚スタック)

を1本の型付きインターフェースの裏で選べる、numpy ネイティブの自作ライブラリです。HALCON を物差しに 42.5%(実測)テスト 10982 件Markdown を単一真実源にしたドキュメント体系、そして HDevelop 風の Studio。深い依存は全部オプションで、numpy + scipy だけでコアが動きます

一番伝えたいのは、規模でも網羅率でもなく、「正直さを仕組みにする」 という姿勢です。hold-out を選択に使わない。カバレッジは実測で開示する。バグを見つけたら、それを見つけられた品質保証ごと記事に残す。派手さより、中身が説明でき、追試でき、長く保守できること ―― それを地道に積んでいます。

「公開前夜」の章に書いた CI の話も、根っこはここに繋がっています。約80件から0件まで潰す過程を隠さず載せたのは、品質保証が実際に機能している証拠を、良い数字と同じ扱いで残すという規律の実践です。

もう1つ付け加えるなら、Fullseye は産業ビジョンの系譜と Physical AI の両方に足場を持つライブラリです。「工場の検査ラインで使われてきた処理が pip 一発で」の節に書いた通り、欠陥検出・サブピクセル計測・shape matching・ブロブ解析といった検査ラインの定番は、そのまま Physical AI のロボット知覚(ばら積みピッキングの物体分離、LiDAR クラスタリング、把持点検出)にも転用できます。これは狙って両立させたというより、「型で繋がる op の体系」を1つ作った結果として、自然に両方の現場に届くようになったという言い方の方が正確です。

公開したてなので、まだ多少の不具合はあるかもしれません。それでも「まず触って、回して、確かめられる」ようには作ってあるので、気軽に使ってみてください(おかしな挙動を見つけたら Issues で教えてもらえたら喜びます)。導入は前半の「使ってみる」節から5分ほどで。いちばんおいしい使い方は Claude Code+fullseye-rag の組み合わせ(AI の道具箱化)です。Claude Code が未導入なら作者の紹介リンクからどうぞ。


次回に続く

次回の主役の顔見せをひとつ(クリックで動画再生):

evis の 700 筋活性ヒートマップ ―― 実物理再シミュレーション中の実活性で腱の色が変わる(クリックで mp4 再生)

↑ ▶ Fullseye の"お客さん第一号" evis の体そのもの ―― 700 本の腱の色を実物理再シミュレーション中の実活性(d.act)で毎フレーム更新した筋活性ヒートマップ(腕挙上→歩行追従)。これは evis 側プロジェクトの実験映像の再エンコードで、Fullseye の処理は入っていません ―― この体に「目」を供給するのが Fullseye 層③の役割です。

この記事は"地図"でした。次はこの地図の各所を歩きます。候補は:

  • op を1つ足すと何が起きるか ―― レジストリ・進化・コード生成・ドキュメントが自動で追従する仕組みの中身。
  • HALCON 42.5% をどう伸ばすか ―― 未カバー operator を honest gate で1つずつ埋める実装記。
  • Physical AI の目 ―― ステレオから 6DoF 姿勢、そして 700 筋で"つまむ"まで、evis の視覚パイプラインを通しで。
  • CI が掘り当てた地雷たちの技術的続報 ―― 「公開前夜」章の各バグを、修正コミットとテストの書き方まで踏み込んで。

今回、当初の予定よりだいぶ長くなりました。層①の型契約から、6つのセンサー、Studio の実務動線、AI の RAG 運用、そして CI が掘り当てた地雷まで――1本の記事に詰め込んだのは、Fullseye という1つのライブラリの中で、これらが全部同じ設計判断の上に乗っていることを、切り分けずに見せたかったからです。個々のトピックだけを見ると地味な工夫の積み重ねですが、並べてみると「正直さを構造にする」という1本の筋が通っていることが、伝わっていれば嬉しいです。

読んでくださってありがとうございました。「ここをもっと詳しく」があれば、それを次の一本にします。

良かったら、いいね♡ をクリックして下さい。次の一本を書く励みになります。


1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?