値が 2 種類しかない板(背景 0.30・明部 0.90、20x20 = 400 px)で、fullseye.apply(im, "otsu") が 4,096 px 全部を前景にする(期待 400)。例外も警告も出ない。
独立の 2 実装と突き合わせた(2026-09-25 実測):
| 実装 | しきい値 | 前景 |
|---|---|---|
skimage threshold_otsu |
0.301172 | 400 |
OpenCV THRESH_OTSU |
76/255 = 0.2980 | 400 |
fullseye otsu |
(ビン 76 の中点) 0.2988 | 4,096 |
同じ絵を 0.00 / 1.00 で作ると 400 で正しい。つまり「値が 2 種類だから」ではなく、背景の値がビンの境界のどちら側に落ちるかで答えが変わる。雑音を載せた現実的な板でも 902 px(真値 900)と 2 px 漏れる。
ops.py の実装は 256 ビンのヒストグラムでクラス間分散を最大化したあと、
return (x > mids[int(np.argmax(sb))]).astype(np.float64)
と argmax ビンの中点でしきい値を取る。ところが argmax が指すのは背景の山を含むビンである(そのビンから上では ω が動かないので、分散が同値で並ぶ先頭が選ばれる)。中点はそのビンの内側なので、同じビンに居る背景画素の一部(または全部)が > を満たしてしまう。背景が 0.30 のときはビン 76 = [0.296875, 0.300781) の中点 0.298828 < 0.30 で、背景が丸ごと前景側に入る。
これは、同じ関数の中に既に書かれている原則に反する:
大津のしきい値はアフィン変換に等変であるべきなので、これは仕様ではなく穴。
{0,1} を {0.3,0.9} にアフィン変換しただけで答えが 400 から 4,096 に変わっている。
★試験の入力に必ず雑音が載っていた。 雑音があると背景が多数のビンに散らばるので、argmax ビンの中点が背景の大半より上に来て「だいたい合う」(902 対 900)。feedback_random_test_data_hides_structural_defects の型そのもので、構造のある入力を 1 本混ぜていれば出ていた。この一件は、接続層のサンプル(line_handshake)が「合成の板」を素直に平らに作ったことで出た。
★直しに入って分かったこと —— 同じ式を 4 か所に別々に書いてあり、4 つとも中点で切っていた。
| 面 | 誰が踏むか | 書き方 |
|---|---|---|
ops.py _otsu |
fullseye.apply(im, "otsu") / パイプラインの中核 op |
mids[k] |
accel.py _otsu |
GPU 常駐経路(「core の逐語移植」と書いてある。誤りも逐語だった) | mids[k] |
detect.py _otsu_mask |
segment_objects() —— 利用者が最初に踏む高水準 API |
mids[k] |
fscript.py _b_binary_threshold |
FScript の組み込み binary_threshold |
(k + 0.5) / 256 |
3 面目がいちばん質が悪い。平らな板を segment_objects に渡すと、画像全体が 1 つの
連結成分になり「面積 4,096、重心は画像中心」というもっともらしい記録が 1 件返る。
例外も警告も無いので、数を見ている人には「対象が 1 個見つかった」としか読めない。
★4 面目は綴りで探しても出てこなかった。 mids[ を grep して 3 面を直し、念のため
「同じ問いに答える入口」を数え直したところ、FScript が中点を (k + 0.5) / 256 と
別の書き方で持っていた。欠陥を綴りで数えると、綴りが違う面だけが残る
(feedback_a_fix_leaves_the_twin_surface_open / feedback_search_all_tiers_before_declaring_a_gap)。
しきい値を argmax ビンの上端(edges[k+1])で取り、x >= t を前景とする。
argmax ビンの画素は全部背景側に落ちるので、skimage / OpenCV と一致する。4 面とも直した。
分ける山が無い入力は、ビン格子に決めさせない。 定数画像は占有ビンが 1 つで、
そもそもしきい値が定義できない。ここで素朴に edges[1] を使うと、[0,1] の中では
docstring の約束(「0 なら背景、0 より大きければ前景」)が保たれる一方、範囲外の定数
(例 5.0)は背景に落ちて約束が入力の範囲によって変わる。占有ビンが 1 つのときは
x > 0 と明示した —— 直す前は 5.0 の定数が背景、0.5 の定数が前景という一貫しない
答えだった(この非一貫は、直すまで誰も書いていなかった)。
| 板(雑音なし) | 直す前 | 直した後 | skimage | OpenCV |
|---|---|---|---|---|
| 0.30 / 0.90 | 4,096 | 400 | 400 | 400 |
| 0.00 / 1.00 | 400 | 400 | 400 | 400 |
| 0.10 / 0.60 | 400 | 400 | 400 | 400 |
| 0.25 / 0.75 | 400 | 400 | 400 | 400 |
実写系の画像では変化画素は中央値 0.000% / 最大 0.485%(測定 2026-09-25)。 壊れていたのは平らな絵だけで、壊れ方が全面だった——「たまに少し違う」ではなく 「合成の板でだけ全部前景」。検査の分野でいちばん出やすい絵がそれである。
tests/test_otsu_threshold_2026_09_26.py。雑音を載せない板で 4 面とも 400 px を
要求し、skimage と OpenCV の 2 つの独立実装と数を突き合わせ、アフィン等変性
(otsu(0.6v+0.3) == otsu(v))を主張ごとに置く。加えて門を壊して確かめる試験
(test_the_midpoint_spelling_is_what_the_gate_catches)で、中点の式に戻すと板の門が
落ちることを固定した。落ちない門は「直した」の証拠にならない。