画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類

Log#10 では Meta の SAM 3 に生成画像を渡し、言葉で物を切り出せるかを見た。この回は、OpenAI が2026年10月6日に全開発者へ公開した Decisions API を、このプロジェクトの実機の写真で試す。Log#10 の終わりで予告した「画像F の誤検出」と「Gemini Robotics ER 2」は次の回以降に回し、IoU・Precision・Recall・mAP の回はさらにその後になる。

このプロジェクトでは、ロボットアーム(mechArm 270 Pi)が赤いブロックを掴んで白い箱へ置く動作を繰り返している。掴めたか・置けたかは、真上のカメラで撮った写真を画像を読める AI に渡して判定させている(VLM-as-Reward と呼んでいる方式)。Decisions API は「決めておいた選択肢の中から答えを選ぶ」ことに絞った API で、この判定の置き換え候補になる。

試した結果、Decisions API は「爪の先の間に赤いブロックがあるか」をほぼ0%か100%で答え分けた。一方で「ブロックが床から持ち上がったか」は、聞き方を3通り変え、写真を2枚比べさせても判定できなかった。真上のカメラからは高さが写らないためで、モデルの性能ではなく入力の情報不足だった。

そこから話が進み、実機の実行ログ640試行を読み直して、実際に起きた失敗を種類ごとに数えた。画像で見分けるべき失敗は全体の約4割で、残りは関節が目標に届かない・安全チェックで止まるといった、画像判定では解決しない失敗だった。最後に、画像判定に渡す選択肢の最初の案を作った。

OpenAI の Decisions API は、決めておいた選択肢から答えを選ぶ専用の窓口

Decisions API は、2026年9月29日の OpenAI DevDay で限定公開され、10月6日に全開発者向けのパブリックベータになった。使えるモデルは gpt-6-luna だけで、送り先は POST /v1/decisions という専用の住所になる。

普通の対話用 API(Responses API)は文章を生成して返す。Decisions API は文章を書かず、決まった形の答えだけを返す。OpenAI は、同じ gpt-6-luna を Responses API で呼ぶより約10倍速いと説明している。

1回の問い合わせは3つの部品でできている。

部品

中身

model

判定するモデル。現時点では gpt-6-luna のみ

input

判定の材料。文字列、または文字と画像を並べたメッセージ

questions

何を判定するか。質問の種類・指示文・選択肢を書く

質問の種類は3つある。

種類

何を聞くか

返ってくるもの

predicate

ある文が正しいか(例: 爪がブロックを挟んでいる)

0〜1 の確率

choice

決めた選択肢のどれに当たるか(例: 掴んでいる/空/掴み損ね)

選ばれた値と、選択肢ごとの確率

score

順序のある段階のどこか(例: 損傷なし・軽微・重大)

段階の番号を確率で重み付けした平均

画像は base64(画像のデータを文字の列に変換したもの)で問い合わせの中に直接埋め込む。画像の URL を渡す方式は受け付けない。料金は入力100万トークンあたり0.10ドルで、出力には課金されない。

OpenAI の管理画面(platform.openai.com)の左メニューに「Decisions」があり、そこからコードを書かずに試せる Playground(お試し画面)が開く。

【画像】DAY325_01_platform_home.png

OpenAI Platform のホーム画面。左メニューに Decisions と Agents が New 付きで並ぶ

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|OpenAI の Decisions API は、決めておいた選択肢から答えを選ぶ専用の窓口

Decisions の Playground。左に指示文と質問の種類、右下に判定させる入力欄がある

Playground には「Mochi the Cat(猫のモチがくつろいでいるか)」などのテンプレートが4つ用意されている。左の Prompt 欄に判定させたい文を書き、右下の入力欄に文章や画像を入れて「Run decision」を押すと、右上に確率が出る。

Playground で画像10枚と猫の文章を一度に送ると、10枚まとめて1問として判定された

最初は Playground で試した。質問の種類は predicate、指示文は日本語で「グリッパーの爪が閉じていて、赤いブロックを爪の間でしっかり挟んでいる」とした。グリッパーはアームの先端にある、2本の爪で物を挟む部品のこと。

テスト用の画像10枚(選び方は次の節に書く)を入力欄に一度に添付して実行すると、結果は3%だった。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|Playground で画像10枚と猫の文章を一度に送ると、10枚まとめて1問として判定された

画像10枚を一度に添付し、テンプレートの猫の文章も残ったまま実行した画面。結果は3%、サーバー処理4024ミリ秒

この画面には問題が2つあった。1つは、10枚を一度に入れると「10枚全体を見て、爪がブロックを挟んでいるか」という1つの質問になること。失敗した画像も混ざっているので、低い数字が出るのは当然だった。もう1つは、テンプレートの猫の文章(Mochi is curled up in a sunny spot...)が入力欄に残ったままで、判定材料に一緒に送られていたこと。処理時間も、画像10枚分で4024ミリ秒かかっている。

猫の文章を消し、画像を1枚ずつに分けて判定し直した。掴めている画像 ok_01 は2%、同じく掴めている ok_02 は85%になった。1枚だけにすると、処理時間は477〜810ミリ秒まで縮んだ。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|Playground で画像10枚と猫の文章を一度に送ると、10枚まとめて1問として判定された

掴めている画像 ok_01 を1枚だけ判定した画面。結果は2%

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|Playground で画像10枚と猫の文章を一度に送ると、10枚まとめて1問として判定された

掴めている画像 ok_02 を1枚だけ判定した画面。結果は85%、サーバー処理477ミリ秒

同じ「掴めている」写真なのに、2%と85%に分かれた。ok_01 の写真を見ると、赤いブロックが爪の下に隠れて、赤い部分がほんの少ししか写っていない。真上のカメラからは、人の目で見ても掴めているかどうか判断しにくい写真だった。ここから先は、10枚を自動で順番に判定するスクリプトに切り替えた。

テスト画像は KIOXIA の実行ログから、掴めた5枚と1回目に失敗した5枚を選んだ

実機の写真は、外付けSSD(KIOXIA)の r2r2r-data/runs/run_pick_place/ に、1試行ごとのフォルダで残っている。1つのフォルダには、試行の記録(run_xxxx.json)、段階ごとの状態を1行ずつ書いたログ(telemetry.jsonl)、それぞれの段階で撮った写真が入っている。

写真の種類は次のとおり。

ファイル名の末尾

いつ撮ったか

_stage4_pick_tip.jpg

爪がブロックの位置まで下りた直後(爪はまだ開いている)

_pick.jpg

爪を閉じて持ち上げた後

_pick_a2.jpg、_pick_a3.jpg

掴み直しの2回目・3回目の、持ち上げた後

_place_side.jpg

箱の上で、横から撮った写真

_place.jpg

箱に置いて腕を戻した後、真上から撮った写真

正解を決めるために、掴み直し(_pick_a2.jpg)が記録されている試行に目を付けた。掴み直しが起きたということは、1回目は失敗と判定されている。その試行の1回目の _pick.jpg を「失敗」の画像とした。成功の画像は、最終的に箱へ置けた試行の _pick.jpg から選んだ。

名前

元の試行

中身

ok_01

run_0001_20260710_204830

掴めている。ブロックが爪の下に隠れ、赤がわずかしか見えない

ok_02

run_0001_20260713_101053

掴めている

ok_03

run_0001_20260712_115432

掴めている

ok_04

run_0001_20260713_170039

掴めている

ok_05

run_0001_20260711_190901

掴めている

ng_01

run_0001_20260626_213806

爪の間が空っぽ

ng_02

run_0001_20260628_075611

爪が開いたまま、ブロックが爪の間に写っている

ng_03

run_0001_20260713_103746

爪が開いたまま、ブロックが爪の間に写っている

ng_04

run_0001_20260711_121325

爪の間に赤がわずかに見える

ng_05

run_0001_20260626_183828

爪の間が空っぽ

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|テスト画像は KIOXIA の実行ログから、掴めた5枚と1回目に失敗した5枚を選んだ

テスト画像10枚。上段が掴めた5枚(ok_01〜05)、下段が1回目に失敗した5枚(ng_01〜05)

元の写真は1920×1080画素で、グリッパーは画面のごく一部にしか写っていない。そこで、爪の先と手首に貼ってある青い丸シールを目印に、グリッパーの周りだけを切り抜いた版も作った(切り抜きのコードは後の「コード(全量)」の節に載せた)。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|テスト画像は KIOXIA の実行ログから、掴めた5枚と1回目に失敗した5枚を選んだ

グリッパー周辺を切り抜いた10枚。上段が ok、下段が ng

既存の判定ログには、理由文と結果が食い違う記録が8件中7件あった

テスト画像を選ぶ途中で、既存の判定の記録を読んだ。telemetry.jsonl の verdict.pick には、画像を読む AI(記録上は claude-sonnet-4-6)が出した「掴めたか」の結果、理由文、確信度(0.82 など)が残っている。

ところが、結果が「失敗」の記録8件のうち7件は、理由文に「グリッパーの爪の間に赤ブロックが挟まれており」と、掴めている内容が書かれていた。run_0001_20260712_190312 の写真を開くと、実際に掴めている。

後で全件を読んで分かったが、これは判定 AI の誤りではなかった。AI が「掴めた」と答えた後に、ブロックとグリッパーの距離を計算する別のチェックが結果を「失敗」に書き換えていた(記録上は dist_override: True)。このため、既存の「失敗」ラベルをそのまま正解として使うと、正解自体がずれる。テスト画像は、写真を目で見て中身を確かめたうえで選んだ。

v1:日本語の「しっかり挟んでいる」は、掴めている画像にも低い確率を返した

10枚を自動で判定するスクリプト(v1)を書き、Mac のターミナルで実行した。判定はクラウド上で行われるので、API キー(OpenAI のサービスを呼ぶための鍵)を環境変数に入れてから動かす。指示文は Playground と同じ日本語、質問の種類は predicate、確率が50%以上なら「掴めている」と数えた。

画像

正解

元画像の確率

元画像の処理時間

切り抜きの確率

切り抜きの処理時間

ng_01

失敗

0%

2944ms

0%

369ms

ng_02

失敗

2%

938ms

34%

542ms

ng_03

失敗

20%

1398ms

11%

507ms

ng_04

失敗

0%

691ms

1%

387ms

ng_05

失敗

0%

870ms

0%

444ms

ok_01

成功

2%

692ms

15%

445ms

ok_02

成功

85%

715ms

27%

861ms

ok_03

成功

10%

908ms

20%

455ms

ok_04

成功

39%

647ms

53%

369ms

ok_05

成功

3%

664ms

9%

425ms

正解数は元画像・切り抜きともに10枚中6枚だった。ただしこの6枚は、失敗の5枚を全部当てて、成功の5枚をほとんど外した結果になる。見分けられているのではなく、ほぼ全部に「いいえ」と答えている。

原因は、指示文の「しっかり挟んでいる」にあると考えた。真上からの写真では挟み具合(力の入り方)は写らない。写っていないことを聞かれたモデルが、慎重に「いいえ」寄りに答えていた。

処理時間は、切り抜き(平均480ms)のほうが元画像(平均1,047ms)より短かった。小さい画像のほうが速い。元画像の最初の1枚(ng_01)だけが2,944msと長く、残りは1,398ms以下だった。

v2:英語で「指先の間に赤いブロックが見えるか」と聞くと0%か100%に分かれた

v2 では、1回の問い合わせに3つの質問を並べた。Decisions API は1回で複数の質問を受け付けるので、同じ画像に対する聞き方の違いを1回の実行で比べられる。

質問

種類

指示文の要点

A

predicate

v1 と同じ日本語(比べるための基準)

B

predicate

英語。青い丸シールの付いた2つの指先の間に、赤いブロックが見えるか(爪で一部隠れていてもよい)。腕の赤い配線は無視する

C

choice

英語の3択。holding(指先の間で掴んでいる)/empty(指先の間に何もない)/missed(ブロックは近くにあるが、爪が開いている・触れていない)

B は「しっかり」を外し、写真に写っていることだけを聞いた。腕には赤い配線が通っていて、既存の判定でも配線をブロックと見間違えた記録があったので、無視するよう書き添えた。C は、2択だと「いいえ」に偏るなら、3つから選ばせれば偏りが減るかを見るための質問になる。

画像

正解

元画像 A

元画像 B

元画像 C(holding の確率)

元画像 C の選択

切り抜き A

切り抜き B

切り抜き C(holding)

切り抜き C の選択

ng_01

失敗

0%

0%

0%

empty

0%

0%

0%

empty

ng_02

失敗

2%

100%

96%

holding

34%

100%

95%

holding

ng_03

失敗

20%

100%

96%

holding

11%

100%

90%

holding

ng_04

失敗

0%

0%

0%

empty

1%

0%

0%

empty

ng_05

失敗

0%

0%

0%

empty

0%

0%

0%

empty

ok_01

成功

2%

97%

39%

empty

15%

100%

95%

holding

ok_02

成功

85%

100%

100%

holding

27%

100%

96%

holding

ok_03

成功

10%

100%

91%

holding

20%

100%

99%

holding

ok_04

成功

39%

100%

99%

holding

53%

100%

96%

holding

ok_05

成功

3%

100%

100%

holding

9%

100%

76%

holding

質問

元画像の正解数

切り抜きの正解数

A(日本語・しっかり)

6/10

6/10

B(英語・見えるか)

8/10

8/10

C(3択)

7/10

8/10

B は、ブロックが指先の間にある画像に97〜100%、ない画像に0%を返した。中間の値がほとんど出ていない。「指先の間に赤いブロックがあるか」という、写真に写っていることについては、はっきり答えている。

外れたのは ng_02 と ng_03 の2枚で、B でも C でも同じように外れた。どちらも、爪は開いたままなのに、ブロックが爪と爪の間に写っている写真になる。真上から見ると、爪の間にブロックがあるという点では成功の写真と同じに見える。違いは、ブロックが爪と一緒に持ち上がったか、床に残ったかで、それは真上からの写真には写らない。

C に用意した missed(掴み損ね)は、20回の判定で一度も選ばれなかった。

v3:掴む直前と持ち上げ後の2枚を比べても、ng_02 と ng_03 は見分けられなかった

既存の判定は、1枚目(爪がブロックまで下りた直後の _stage4_pick_tip.jpg)と2枚目(持ち上げた後の _pick.jpg)を並べて渡し、「ブロックが爪と一緒に動いたか」を見ていた。高さが写らないなら、2枚の位置の変化で補えるかもしれない。v3 では同じ2枚を Decisions API に渡した。

テスト10件それぞれについて、2枚の組を images/pairs/ に用意した。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|v3:掴む直前と持ち上げ後の2枚を比べても、ng_02 と ng_03 は見分けられなかった

ng_02 の2枚。左が持ち上げる前、右が持ち上げた後。真上からはほとんど同じに見える

組

正解

D(ブロックが爪と一緒に動いたか)

E(picked の確率)

E の選択

処理時間

ng_01

失敗

0%

1%

left_behind

808ms

ng_02

失敗

94%

95%

picked

752ms

ng_03

失敗

94%

99%

picked

853ms

ng_04

失敗

8%

44%

picked

788ms

ng_05

失敗

6%

7%

left_behind

1051ms

ok_01

成功

82%

77%

picked

930ms

ok_02

成功

95%

100%

picked

1344ms

ok_03

成功

95%

99%

picked

1592ms

ok_04

成功

95%

100%

picked

1003ms

ok_05

成功

95%

99%

picked

786ms

D・E ともに正解は10組中8組で、外れたのはまた ng_02 と ng_03 だった。v1 で2%しか出なかった ok_01 は、2枚比較では82%まで上がった。

ng_03 の telemetry.jsonl で撮影の時刻を確かめた。爪を閉じる指令が10:38:24、持ち上げが10:38:25、_pick.jpg の撮影が10:38:26で、写真は持ち上げた後に撮られている。撮るタイミングは正しい。持ち上げる高さが小さいので、真上から見ると1枚目と2枚目でブロックの位置がほとんど変わらず、「爪と一緒に動いた」と判定されていた。

ng_02・ng_03 は、指示文を3通りに変えても、写真を2枚にしても、同じように外れた。指示文の工夫で届く範囲はここまでで、写真に情報が入っていないことは判定できない、という結論になった。

telemetry の gripper は開度の実測ではなく、開け閉めの指令値だった

v2 の結果を見た時点では、「爪が閉じきったか、ブロックに当たって止まったか」はグリッパーの開度のセンサー値で補えると提案していた。v3 の後で telemetry.jsonl を確認すると、gripper の欄は 0(開け)と 1(閉じろ)の指令値で、実際に爪がどこまで閉じたかを測った値ではなかった。今のログからは、センサー値で補うことはできない。

mechArm には、グリッパーの実際の開度を読み出す get_gripper_value() という関数がある。空振りなら爪は閉じきり、掴めていればブロックの幅で止まるはずなので、これを記録するように変えれば、写真を使わずに判別できる可能性がある。

図解は、v1〜v3 の各質問について、10枚の確率を成功(ok)と失敗(ng)に分けて並べたもの。質問を切り替えると並びが変わり、しきい値(何%以上を「掴めている」とするか)を動かすと、何枚が正解になるかが変わる。B と C と v3 では、どこにしきい値を置いても ng_02 と ng_03 が成功の側に残る。

Decisions API はロボットの制御には使えず、区切りごとの判断に使える

テストの範囲で分かったことを、速度と精度に分けて書く。

項目

結果

1回の判定にかかる時間

画像1枚で0.36〜1.4秒(スクリプトの最初の1回だけ2.9秒)。画像2枚の比較は0.75〜1.6秒。Playground の表示では、これに通信で0.25〜0.54秒が加わる

写っていることの判定

指先の間にブロックがあるか、をほぼ0%か100%で答え分けた

写っていないことの判定

床から持ち上がったか、はどの聞き方でも判定できなかった

指示文による差

同じ ok_01 の写真で、v1 は2%、v2 の B は97%になった

費用

料金表から計算すると、画像20枚の判定で1円未満

アームを動かす処理は、何層かに分けて考えると、どこに使えるかがはっきりする。

層

必要な速さ

Decisions API

担い手

制御(関節やモーターを動かす)

数ミリ秒ごと、カメラの1コマごと

使えない

手元のコード(現在のビジュアルサーボ=カメラ映像で位置を合わせながら動かす仕組み)

認識(物の位置を追う)

数十ミリ秒

向かない

手元の画像処理(OpenCV の色検出、YOLO などの検出器)

判断(成功したか、次に何をするか)

区切りごとに1秒以内

使える

Decisions API

計画・対話(作業の指示、音声)

数秒以上

使える

Responses API、音声の Live API

毎コマの制御に入れると、1回ごとに1秒近く止まり、その待ち時間も毎回ばらつくので、動きが成り立たない。使えるのは「掴む → 確かめる → 置く」の、確かめるところになる。

使うときの注意点も残しておく。通信が切れると判断が止まるので、つながらないときは「止まって人を呼ぶ」側に倒す作りが前提になる。確率は外れることがある前提で、しきい値を決め、自信が低いときは人に回す。料金は安いが、パブリックベータなので仕様が変わる可能性はある。

AI はテスト済みの動作関数から選ぶだけにして、動きはコードで正確に出す

ここで、設計の方向について問いかけがあった。

ロボットの制御そのままでは使えないので、画像からどのような判断が必要なのかに使って、実際の動作コードを作成してもらい、アームの動きそのものはコードで正確に動かすといった設計が現実的かな?

方向は同じだが、「AI に動作コードを作ってもらう」は、いつ作るかで分けたほうが安全だと返した。開発するときに AI がコードを書き、人が確かめ、実機でテストしてから使うのは、現在の Claude Code での作り方そのままで問題ない。一方、動いている最中に AI がその場でコードを書いて実行すると、毎回違うコードが動くことになり、事前に確かめようがない。LOG83 で、AI に任せたスクリプトがデータを壊した経験がある。

動いている最中の AI の役割は、選ぶことだけにする。

カメラ → Decisions API「今どの状態か?」
          ↓ choice(選択肢は人が事前に決めておく)
   [掴めた] → place()        ← テスト済みの関数
   [空振り] → retry_pick()   ← テスト済みの関数
   [不明]   → stop_and_call_human()
          ↓
   コードが関節の可動範囲と安全チェックを確認して正確に動かす

AI はテスト済みの動作関数から1つを選ぶだけで、動きそのものは作らない。関節の角度が安全な範囲かは、コード側の仕組み(実行ログにある joint_limit_ok や guard_pass)が確かめる。自信が低いときは、必ず「止まって人を呼ぶ」を選ぶ。Decisions API の choice は、この「事前に決めた選択肢から1つ選ぶ」ための機能なので、この作りにそのまま合う。

事前に選択肢を決めておくのは必須そうですね(いろんなパターンを作成するにはデジタル学習などが必要だけど)

選択肢は最初から全部そろえる必要はない、と返した。最初は3〜4個と「その他(人を呼ぶ)」で始め、「その他」に落ちた写真を全部残しておく。たまったら人が見て分け、多いものだけを新しい選択肢にし、そのときに対応する動作関数も作ってテストする。こうすると、選択肢が増える順番が、そのまま本番で実際に起きた失敗の順番になる。

デジタルツイン(実機と同じ配置をシミュレーターの中に作ったもの)が役に立つのは、実機では集めにくい失敗、たとえば落下や衝突、ブロックが倒れる場面を、安全に大量に作るときになる。ただし、シミュレーターの画像と実機の写真は見た目が違うので、判定の最終確認は実機の写真で行う。

そして、素材はすでにある。run_pick_place には、写真と記録付きの試行が残っている。デジタルツインの前に、このログを分けるだけで、実際にどんな失敗が起きていたかの選択肢の最初の案が作れる。それを実際にやった結果が次の節になる。

実機640試行の失敗を20の種類に分けると、画像で見分けるべきものは約4割だった

run_pick_place の記録を全部読んだ。対象は、2026年6月19日から7月20日までに実機で動かした640試行(run_xxxx.json の588件と、初期の pick_then_place_auto の52件)になる。動かさずに計画だけを確かめた dry-run の記録は除いた。

1試行ごとに、どこで何が起きて終わったかを、20の種類(成功2・失敗18)に振り分けた。手がかりにしたのは3つ。試行が止まった理由(abort の段階と理由文)、既存の判定 AI の結果(judgments と pick_attempts)、それに真上のカメラの写真を測った結果になる。手で止めた29試行は数から外し、611試行で集計した。

グループ

試行数

割合

中身

成功

233

38%

1回目で成功 230、掴み直しで回復 3

動かす(目標の姿勢に届かない)

106

17%

持ち上げ 38、置いた後の退避 23、置き位置 20、把持位置 17、HOME 8

置く(箱の外に落ちた)

98

16%

箱の近くまで運んだが外 59、運ばれず把持位置の近くに残った 39

計画(送信前の安全チェックで停止)

89

15%

届かない位置・関節の可動範囲の外

システム

29

5%

判定 AI の呼び出しエラー 25、制御プログラムに接続できない 4

掴む(掴み損ね)

27

4%

位置ずれ 13、距離チェックで却下 9、判別困難 4、爪の間 1

見る(ブロックが見つからない)

24

4%

開始時に赤ブロックを検出できない

ハード

5

1%

グリッパーの開け閉めに失敗

画像で見分けるべき失敗は「見る・掴む・置く」の149件で、失敗378件の約4割になる。残りの6割は、関節が目標の角度に届かない、届かない位置を安全チェックが止めた、判定 AI の呼び出しが失敗した、といったもので、画像判定を入れても解決しない。関節の角度(エンコーダの値)、動きの計画、運用の問題になる。

赤ブロックと青マーカーの位置を画像から測って、掴み損ねを4種類に分けた

掴み損ねの中身を分けるのに、最初は既存の判定 AI の理由文の言葉で振り分けた。「離れ」「外側」なら位置ずれ、「残」「留ま」なら爪の位置は合っているが床に残った、という分け方になる。ところが「床に残った」に入った写真を並べると、大半はブロックが爪の横にはっきり写っていた。理由文の言葉と写真の中身が合っていなかった。

そこで写真から直接測ることにした。赤い画素がまとまった一番大きな塊を赤ブロック、グリッパーに貼った3つの青い丸シールをグリッパーの位置とし、2つの位置関係で分ける。

  • 赤い判定は、R(赤)が110より大きく、R が G(緑)より60以上、B(青)より40以上大きい画素とした。1画素ずつではなく8画素四方のマス目で数え、マスの4割以上が赤いときだけ赤いマスとする。腕に沿って通っている細い赤い配線は、マスを埋めきれないので数に入らない

  • 青い丸シールも同じように4画素四方のマス目で数え、丸シールくらいの大きさの塊だけを残す。マットの左外に置いてあった本の表紙の青を拾ったことがあったので、画面の左右300画素は除いた

  • 赤ブロックが3つの丸シールの外接矩形の近くにあれば「爪の間」、離れていれば「爪の外」、赤いマスが12個未満で矩形の中にあれば「赤がわずかに見えるだけ」、赤い塊がなければ「写っていない」とした

分け直した結果、掴み1回ごとに数えると、511回の掴みのうち、成功405、位置ずれ(ブロックが爪の外に見える)34、爪の間に赤がわずかに見えるだけ11、爪の間にブロックが見える5、ブロックが写っていない4、判定 AI は成功と答えたが距離チェックで却下27、判定 AI の呼び出しエラー25になった。掴み損ねで一番多いのは、ブロックの横に爪が下りる位置ずれだった。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|赤ブロックと青マーカーの位置を画像から測って、掴み損ねを4種類に分けた

位置ずれ:ブロックが爪の外(横)に写っている

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|赤ブロックと青マーカーの位置を画像から測って、掴み損ねを4種類に分けた

ブロックが写っていない:爪の間が空で、ブロックも見えない

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|赤ブロックと青マーカーの位置を画像から測って、掴み損ねを4種類に分けた

判別困難:爪の間に赤がわずかに見えるだけ

距離チェックで却下された27回の写真は、見る限り爪の間にブロックがあるものが多い。判定 AI の「掴めた」が正しく、距離チェックのしきい値のほうが厳しすぎる可能性がある。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|赤ブロックと青マーカーの位置を画像から測って、掴み損ねを4種類に分けた

判定 AI は成功、距離チェックで却下された掴み。写真では爪の間にブロックが見えるものが多い

置き失敗98件のうち39件は、ブロックが運ばれていなかった

置き失敗の98件は、どれも「ブロックが箱の外にある」と判定された試行になる。その中身を分けるため、爪がブロックまで下りた直後の写真(_stage4_pick_tip.jpg)と、置いた後の写真(_place.jpg)で、赤ブロックの位置が何画素動いたかを測った。300画素未満なら「運ばれていない」、それ以上なら「運ばれたが箱の外に落ちた」とした。

59件は、箱のすぐそばまで運ばれてから外に落ちていた。置く位置のずれになる。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|置き失敗98件のうち39件は、ブロックが運ばれていなかった

箱の近くまで運ばれたが、外に落ちたブロック

残りの39件は、ブロックがほとんど動いていなかった。掴みの判定は「成功」だったのに、ブロックは掴もうとした場所の近くに残ったまま、アームだけが箱まで行って戻っていた。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|置き失敗98件のうち39件は、ブロックが運ばれていなかった

掴めた判定のまま、ブロックが把持位置の近くに残っていた例

これは、v2・v3 で ng_02・ng_03 を見分けられなかったのと同じ問題になる。真上のカメラでは持ち上がったかが写らないので、判定 AI が「掴めた」と答え、実際には持ち上がっていなかった。テストで見つけた弱点が、本番の運用でも39回起きていた。

この39件の中には、人がブロックを手で戻している写真も1枚あった。動かした量の計算だけでは、こうした場面も「運ばれていない」に入る。

横カメラの「失敗」86件は、離す前に撮っていたことによる食い違いだった

集計の途中で、真上のカメラの判定は「箱に入った」なのに、横のカメラの判定は「入っていない」という食い違いが86件あった。86件のうちの1件、run_0001_20260706_080956 の2枚を並べた。

画像認識と物体検出を学び直す Log#11 | Decisions API は爪の間のブロックを0%か100%で見分けたが、持ち上がったかは判定できなかった ― 把持判定テストと実機640試行の失敗分類|横カメラの「失敗」86件は、離す前に撮っていたことによる食い違いだった

左:横カメラの place_side.jpg。ブロックはまだ爪に挟まれて、箱の縁より上にある。右:真上カメラの place.jpg。ブロックは箱の中にある

横の写真は、ブロックを離す前、爪で挟んだまま箱の上にいる瞬間に撮られていた。横の判定 AI は、写っているとおりに「まだ箱に入っていない」と答えていただけで、失敗ではない。撮るタイミングを離した後に変えれば、この食い違いはなくなる。

同時に、この写真は横のカメラにはブロックの高さがはっきり写ることも示している。爪に挟まれたブロックが、箱の縁より上にあることが一目で分かる。持ち上げた後に横のカメラで1枚撮れば、39件の「運ばれていない」を、その場で見分けられる。

画像判定の選択肢は、開始時・持ち上げ後・置いた後の3か所に置く

640試行の分類をもとに、画像判定に渡す選択肢の最初の案を作った。区切りごとに choice の質問を1つ置き、どれにも当てはまらないときや自信が低いときは、止まって人を呼ぶ。

① 開始時(真上カメラ) — 根拠:見る 24件

選択肢

中身

次の動き

block_visible

赤ブロックがマットの上に見える

掴みに行く

no_block

見えない

人を呼ぶ

② 持ち上げた後(横カメラを推奨、現状は真上カメラ) — 根拠:掴む 27件、運ばれていない 39件

選択肢

中身

次の動き

held_lifted

ブロックが爪に挟まれ、マットから浮いている

置きに行く

offset

ブロックが爪の外(横)に見える

位置を直して掴み直す

empty

爪が空で、ブロックも見えない

人を呼ぶ

unclear

爪の間に何かあるが、判別できない

人を呼ぶ

③ 置いた後(真上カメラ) — 根拠:置く 98件

選択肢

中身

次の動き

in_box

箱の中

次の試行へ

outside_near_box

箱のすぐ外

置く位置の補正値を記録する

not_carried

掴もうとした場所の近くに残っている

②の判定の誤りとして記録する

件数は少ないが、作業エリアに人の手が入っている写真が2枚あった(掴み損ねの1枚と、運ばれていないの1枚)。選択肢の候補として記録しておく。

分類は自動で行ったので、境目の場面では揺れがある。たとえば、ブロックが爪の先に触れているくらいの位置だと、「爪の間」と「爪の外」のどちらに入るかが写真ごとに変わる。種類ごとの一覧画像で中身を目で確かめたが、正確な正解ラベルではなく、選択肢を決めるための目安として使う。

テストと分類のコード(全量)

実行したコードを、データを作った順に載せる。テストのスクリプトは、最初はフォルダの直下に置いて python3 run_decisions.py で実行し、すべての判定が終わった後にフォルダを整理して scripts/ へ移した。載せているのは、移した後の場所に合わせて読み込み先と保存先を直した版になる。判定の処理そのものは、実行したときと同じ。

切り抜き画像を作るコード(青い丸シールを目印にグリッパー周辺を切り出す)

python

from PIL import Image
import numpy as np, glob, os
for f in sorted(glob.glob('*.jpg')):
    im=Image.open(f).convert('RGB'); a=np.asarray(im).astype(int)
    r,g,b=a[...,0],a[...,1],a[...,2]
    m=(b>170)&(r<90)&(g>60)&(g<170)&(b-r>110)
    ys,xs=np.nonzero(m)
    if len(xs)<50: print(f,'no markers'); continue
    cx,cy=(xs.min()+xs.max())//2,(ys.min()+ys.max())//2
    half=max(xs.max()-xs.min(),ys.max()-ys.min())//2+90
    half=max(half,220)
    box=(max(cx-half,0),max(cy-half,0),min(cx+half,im.width),min(cy+half,im.height))
    im.crop(box).save('crop/'+f, quality=95)
    print(f,box)

2枚比較用の画像の組を作るコマンド

bash

cd "$HOME/mnt/r2r2r-data/runs" && out=experiments/decisions_test/pairs && mkdir -p $out && for f in experiments/decisions_test/*.jpg; do n=$(basename $f .jpg); r=${n#*_*_}; d=run_pick_place/run_0001_$r; cp $d/run_0001_${r}_stage4_pick_tip.jpg $out/${n}_1before.jpg && cp $d/run_0001_${r}_pick.jpg $out/${n}_2after.jpg || echo "missing $r"; done; ls $out | head -30

scripts/run_decisions_v1.py(日本語の指示文1つで判定)

python

#!/usr/bin/env python3
"""Decisions API で decisions_test の画像を一括判定する。
使い方: export OPENAI_API_KEY=sk-... のあと  python3 scripts/run_decisions_v1.py
元画像と crop/ の両方を判定し、結果を results/results_v1.csv に保存する。"""
import base64, csv, json, os, sys, time, urllib.request
from pathlib import Path

PROMPT = "グリッパーの爪が閉じていて、赤いブロックを爪の間でしっかり挟んでいる"
KEY = os.environ.get("OPENAI_API_KEY")
if not KEY:
    sys.exit("OPENAI_API_KEY が未設定です: export OPENAI_API_KEY=sk-...")
HERE = Path(__file__).resolve().parent.parent
IMG = HERE / "images"
OUT = HERE / "results"

def decide(img: Path):
    b64 = base64.b64encode(img.read_bytes()).decode()
    body = {
        "model": "gpt-6-luna",
        "input": [{"role": "user", "content": [
            {"type": "input_image", "image_url": f"data:image/jpeg;base64,{b64}"}]}],
        "questions": [{"type": "predicate", "name": "held", "instructions": PROMPT}],
    }
    req = urllib.request.Request("https://api.openai.com/v1/decisions",
        data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    t = time.time()
    with urllib.request.urlopen(req, timeout=60) as r:
        res = json.load(r)
    ms = int((time.time() - t) * 1000)
    a = res["answers"][0]
    return (a.get("probability") if a["type"] == "predicate" else None), ms

rows = []
for sub in ["full", "crop"]:
    for img in sorted((IMG / sub).glob("*.jpg")):
        if img.name.startswith("._"):
            continue
        try:
            p, ms = decide(img)
        except Exception as e:
            p, ms = None, 0
            print(f"ERROR {img.name}: {e}")
        label = "ok" if img.name.startswith("ok") else "ng"
        ver = sub
        hit = None if p is None else ((p >= 0.5) == (label == "ok"))
        rows.append([ver, img.name, label, p, ms, hit])
        print(f"{ver:4} {img.name:28} 正解={label}  確率={'-' if p is None else f'{p:.0%}':>4}  "
              f"{ms}ms  {'○' if hit else '×' if hit is not None else '-'}")

with open(OUT / "results_v1.csv", "w", newline="") as f:
    w = csv.writer(f); w.writerow(["version", "file", "label", "probability", "ms", "correct"]); w.writerows(rows)
for ver in ["full", "crop"]:
    r = [x for x in rows if x[0] == ver and x[5] is not None]
    if r: print(f"{ver}: 正解 {sum(x[5] for x in r)}/{len(r)}")
print("results/results_v1.csv に保存しました")

scripts/run_decisions_v2.py(指示文3つを1回の問い合わせで比べる)

python

#!/usr/bin/env python3
"""Decisions API: 指示文3パターンを1リクエストで同時に比較する(v2)。
使い方: export OPENAI_API_KEY=sk-... のあと python3 scripts/run_decisions_v2.py
元画像と crop/ を判定し results/results_v2.csv に保存。v1(最初の版)は run_decisions_v1.py。"""
import base64, csv, json, os, sys, time, urllib.request
from pathlib import Path

KEY = os.environ.get("OPENAI_API_KEY") or sys.exit("export OPENAI_API_KEY=sk-... を先に実行してください")
HERE = Path(__file__).resolve().parent.parent
IMG = HERE / "images"
OUT = HERE / "results"

QUESTIONS = [
    # A: 最初の日本語(比較用)
    {"type": "predicate", "name": "A_ja_strict",
     "instructions": "グリッパーの爪が閉じていて、赤いブロックを爪の間でしっかり挟んでいる"},
    # B: 英語・見えるものだけを聞く(「しっかり」を外す、青マーカーを目印に)
    {"type": "predicate", "name": "B_en_visible",
     "instructions": ("Top-down photo of a robot gripper. The two fingertips are marked with blue dots. "
                      "Is a red block visible between the two fingertips (even partly hidden by the fingers)? "
                      "Ignore any red wires on the arm.")},
    # C: 3択で相対比較(「いいえ」への偏りを避ける)
    {"type": "choice", "name": "C_choice",
     "instructions": ("Top-down photo of a robot gripper whose fingertips are marked with blue dots. "
                      "What is the state of the gripper? Ignore red wires on the arm."),
     "choices": [
         {"value": "holding", "description": "A red block is between the fingertips, gripped by the fingers."},
         {"value": "empty", "description": "Nothing is between the fingertips."},
         {"value": "missed", "description": "A red block is near the gripper but the fingers are open around it or not touching it."},
     ]},
]

def decide(img: Path):
    b64 = base64.b64encode(img.read_bytes()).decode()
    body = {"model": "gpt-6-luna",
            "input": [{"role": "user", "content": [
                {"type": "input_image", "image_url": f"data:image/jpeg;base64,{b64}"}]}],
            "questions": QUESTIONS}
    req = urllib.request.Request("https://api.openai.com/v1/decisions", data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    t = time.time()
    with urllib.request.urlopen(req, timeout=60) as r:
        res = json.load(r)
    ms = int((time.time() - t) * 1000)
    out = {}
    for a in res["answers"]:
        if a["type"] == "predicate":
            out[a["name"]] = a["probability"]
        elif a["type"] == "choice":
            probs = {p["value"]: p["probability"] for p in a.get("probabilities", [])}
            out[a["name"]] = probs.get("holding")
            out["C_pick"] = a["choice"]
        else:
            out[a.get("name", "?")] = None
    return out, ms

names = ["A_ja_strict", "B_en_visible", "C_choice"]
rows = []
print(f"{'ver':4} {'file':28} 正解  {'A':>5} {'B':>5} {'C(hold)':>8} C選択     ms")
for sub in ["full", "crop"]:
    for img in sorted((IMG / sub).glob("*.jpg")):
        if img.name.startswith("._"):
            continue
        label = "ok" if img.name.startswith("ok") else "ng"
        ver = sub
        try:
            o, ms = decide(img)
        except Exception as e:
            print(f"ERROR {img.name}: {e}"); continue
        f = lambda v: "-" if v is None else f"{v:.0%}"
        print(f"{ver:4} {img.name:28} {label}   {f(o.get('A_ja_strict')):>5} {f(o.get('B_en_visible')):>5} "
              f"{f(o.get('C_choice')):>8} {o.get('C_pick',''):8} {ms}")
        rows.append([ver, img.name, label] + [o.get(n) for n in names] + [o.get("C_pick"), ms])

with open(OUT / "results_v2.csv", "w", newline="") as fh:
    w = csv.writer(fh); w.writerow(["version", "file", "label"] + names + ["C_pick", "ms"]); w.writerows(rows)

print("\n--- 閾値50%での正解数 / ok・ngが分離できているか(okの最小値 > ngの最大値なら完全分離) ---")
for ver in ["full", "crop"]:
    for i, n in enumerate(names):
        r = [x for x in rows if x[0] == ver and x[3 + i] is not None]
        if not r: continue
        hit = sum((x[3 + i] >= 0.5) == (x[2] == "ok") for x in r)
        okmin = min(x[3 + i] for x in r if x[2] == "ok"); ngmax = max(x[3 + i] for x in r if x[2] == "ng")
        print(f"{ver:4} {n:13} 正解 {hit}/{len(r)}   ok最小 {okmin:.0%} / ng最大 {ngmax:.0%}  {'完全分離' if okmin > ngmax else '重なりあり'}")
print("results/results_v2.csv に保存しました")

scripts/run_decisions_v3.py(掴む直前と持ち上げ後の2枚を比べる)

python

#!/usr/bin/env python3
"""Decisions API v3: 掴む直前(1枚目)と持ち上げ後(2枚目)の2枚を1リクエストで比較する。
使い方: export OPENAI_API_KEY=sk-... のあと python3 scripts/run_decisions_v3.py
pairs/ の画像を使い results/results_v3.csv に保存。"""
import base64, csv, json, os, sys, time, urllib.request
from pathlib import Path

KEY = os.environ.get("OPENAI_API_KEY") or sys.exit("export OPENAI_API_KEY=sk-... を先に実行してください")
HERE = Path(__file__).resolve().parent.parent
OUT = HERE / "results"
PAIRS = HERE / "images" / "pairs"

CTX = ("Two top-down photos of the same robot arm. Image 1: the gripper has just reached the red block. "
       "Image 2: the arm has tried to lift the block. The gripper fingertips are marked with blue dots. "
       "From the top you cannot see height, so compare the two images. Ignore red wires on the arm.")

QUESTIONS = [
    {"type": "predicate", "name": "D_moved_with",
     "instructions": ("Between image 1 and image 2, did the red block move together with the gripper, "
                      "staying in the same place relative to the fingertips, while the fingers closed on it?")},
    {"type": "choice", "name": "E_outcome",
     "instructions": "What happened in image 2 compared with image 1?",
     "choices": [
         {"value": "picked", "description": "The fingers closed on the red block and it moved with the gripper."},
         {"value": "left_behind", "description": "The red block stayed where it was on the mat; the gripper moved without it or the fingers stayed open."},
         {"value": "no_block", "description": "There is no red block between the fingertips in image 2."},
     ]},
]

def img_part(p: Path):
    return {"type": "input_image", "image_url": "data:image/jpeg;base64," + base64.b64encode(p.read_bytes()).decode()}

def decide(before: Path, after: Path):
    body = {"model": "gpt-6-luna",
            "input": [{"role": "user", "content": [
                {"type": "input_text", "text": CTX},
                {"type": "input_text", "text": "Image 1 (before lift):"}, img_part(before),
                {"type": "input_text", "text": "Image 2 (after lift):"}, img_part(after)]}],
            "questions": QUESTIONS}
    req = urllib.request.Request("https://api.openai.com/v1/decisions", data=json.dumps(body).encode(),
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    t = time.time()
    with urllib.request.urlopen(req, timeout=60) as r:
        res = json.load(r)
    ms = int((time.time() - t) * 1000)
    out = {}
    for a in res["answers"]:
        if a["type"] == "predicate":
            out["D"] = a["probability"]
        elif a["type"] == "choice":
            out["E"] = {p["value"]: p["probability"] for p in a.get("probabilities", [])}.get("picked")
            out["E_pick"] = a["choice"]
    return out, ms

rows = []
print(f"{'pair':24} 正解  {'D':>5} {'E(picked)':>9} E選択        ms")
for before in sorted(PAIRS.glob("*_1before.jpg")):
    if before.name.startswith("._"): continue
    after = before.with_name(before.name.replace("_1before", "_2after"))
    name = before.name.replace("_1before.jpg", "")
    label = "ok" if name.startswith("ok") else "ng"
    try:
        o, ms = decide(before, after)
    except Exception as e:
        print(f"ERROR {name}: {e}"); continue
    f = lambda v: "-" if v is None else f"{v:.0%}"
    print(f"{name:24} {label}   {f(o.get('D')):>5} {f(o.get('E')):>9} {o.get('E_pick',''):12} {ms}")
    rows.append([name, label, o.get("D"), o.get("E"), o.get("E_pick"), ms])

with open(OUT / "results_v3.csv", "w", newline="") as fh:
    w = csv.writer(fh); w.writerow(["pair", "label", "D_moved_with", "E_picked", "E_pick", "ms"]); w.writerows(rows)

print("\n--- 閾値50%での正解数 / 分離 ---")
for i, n in [(2, "D_moved_with"), (3, "E_picked")]:
    r = [x for x in rows if x[i] is not None]
    if not r: continue
    hit = sum((x[i] >= 0.5) == (x[1] == "ok") for x in r)
    okmin = min(x[i] for x in r if x[1] == "ok"); ngmax = max(x[i] for x in r if x[1] == "ng")
    print(f"{n:13} 正解 {hit}/{len(r)}   ok最小 {okmin:.0%} / ng最大 {ngmax:.0%}  {'完全分離' if okmin > ngmax else '重なりあり'}")
print("results/results_v3.csv に保存しました")

scripts/image_features.py(赤ブロックと青い丸シールの位置を写真から測る)

python

"""上カメラ画像から赤ブロックと青マーカー(グリッパー)の位置を測る。numpy と Pillow のみ使用。"""
import numpy as np
from PIL import Image

CELL = 8  # 8px 単位のマス目で判定(細い赤配線を除外するため)

def _clusters(grid):
    h, w = grid.shape; seen = np.zeros_like(grid, bool); out = []
    for y, x in zip(*np.nonzero(grid)):
        if seen[y, x]: continue
        stack = [(y, x)]; seen[y, x] = True; pts = []
        while stack:
            cy, cx = stack.pop(); pts.append((cy, cx))
            for ny, nx in ((cy+1,cx),(cy-1,cx),(cy,cx+1),(cy,cx-1)):
                if 0 <= ny < h and 0 <= nx < w and grid[ny, nx] and not seen[ny, nx]:
                    seen[ny, nx] = True; stack.append((ny, nx))
        out.append(pts)
    return out

def _load(path):
    a = np.asarray(Image.open(path).convert("RGB")).astype(np.int16)
    return a[..., 0], a[..., 1], a[..., 2]

def red_block(path):
    """最大の赤い塊の中心 (x, y) とマス数(8px単位)。見つからなければ None。"""
    r, g, b = _load(path)
    m = (r > 110) & (r - g > 60) & (r - b > 40)
    H, W = m.shape; h, w = H // CELL, W // CELL
    dens = m[:h*CELL, :w*CELL].reshape(h, CELL, w, CELL).mean(axis=(1, 3))
    cl = _clusters(dens > 0.4)
    if not cl: return None
    big = max(cl, key=len)
    if len(big) < 4: return None
    ys, xs = zip(*big)
    return (float(np.mean(xs) * CELL + CELL / 2), float(np.mean(ys) * CELL + CELL / 2), len(big))

def blue_markers_box(path):
    """グリッパー上の青マーカー(丸シール3つ)の外接矩形 (x0, y0, x1, y1)。見つからなければ None。"""
    r, g, b = _load(path)
    m = (b > 150) & (b - r > 90) & (b - g > 30)
    C = 4; H, W = m.shape; h, w = H // C, W // C
    dens = m[:h*C, :w*C].reshape(h, C, w, C).mean(axis=(1, 3))
    dots = []
    for c in _clusters(dens > 0.4):
        if 3 <= len(c) <= 60:                      # 丸シール程度の大きさだけ残す
            ys, xs = zip(*c); x, y = np.mean(xs) * C, np.mean(ys) * C
            if 300 <= x <= W - 300:                # マット外(左右端の本や小物)を除外
                dots.append((x, y))
    if not dots: return None
    if len(dots) > 3:                              # グリッパーらしい3点(間隔80〜400px)で最もまとまった組
        import itertools
        def spread(t): return max(((a[0]-b[0])**2 + (a[1]-b[1])**2) ** 0.5 for a, b in itertools.combinations(t, 2))
        cands = [t for t in itertools.combinations(dots, 3) if 80 <= spread(t) <= 400]
        if not cands: return None
        dots = list(min(cands, key=spread))
    xs = [d[0] for d in dots]; ys = [d[1] for d in dots]
    return (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))

def block_vs_gripper(path):
    """赤ブロックとグリッパーの位置関係: 'between'(爪の間)/ 'outside'(爪の外)/ 'not_visible'(写っていない)/ 'no_gripper'"""
    rb = red_block(path); bb = blue_markers_box(path)
    if bb is None: return "no_gripper", rb, bb
    if rb is None: return "not_visible", rb, bb
    size = max(bb[2] - bb[0], bb[3] - bb[1], 120)
    cx, cy = (bb[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2
    # グリッパー中心から赤ブロックまでの距離(マーカー群の大きさで正規化)
    d = ((rb[0] - cx) ** 2 + (rb[1] - cy) ** 2) ** 0.5 / size
    inside = bb[0] - 0.15*size <= rb[0] <= bb[2] + 0.15*size and bb[1] - 0.15*size <= rb[1] <= bb[3] + 0.15*size
    if rb[2] < 12:
        return ("partial" if inside else "not_visible"), rb, bb
    return ("between" if inside and d < 0.75 else "outside"), rb, bb

def block_shift_px(path_before, path_after):
    """2枚の上カメラ画像で赤ブロックが何px動いたか。判定不能なら None。"""
    a, b = red_block(path_before), red_block(path_after)
    if not a or not b or a[2] < 12 or b[2] < 12: return None
    return ((a[0]-b[0])**2 + (a[1]-b[1])**2) ** 0.5

scripts/classify_runs.py(640試行を失敗の種類に分ける)

python

#!/usr/bin/env python3
"""run_pick_place の実行ログを読み、試行ごとの結果を「失敗の種類」に分類する。
元データは読むだけで変更しない。
使い方: failure_taxonomy フォルダで  python3 scripts/classify_runs.py
出力: data/trials.csv(試行ごと), data/pick_attempts.csv(掴み1回ごと), data/summary.json,
      samples/<種類>/ に代表画像のコピーと一覧画像 _overview.jpg
分類の根拠: 停止理由(abort)・旧判定AIの結果(judgments / pick_attempts)・上カメラ画像の実測
           (赤ブロックと青マーカーの位置関係、置き前後のブロック移動量)。
"""
import csv, glob, json, shutil, collections, sys
sys.dont_write_bytecode = True
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import image_features as F

HERE = Path(__file__).resolve().parent.parent
SRC = HERE.parent.parent / "run_pick_place"
DATA = HERE / "data"; SAMPLES = HERE / "samples"
N_SAMPLES = 6

# id: (名前, 検知の担当, グループ)
CATS = {
    "OK":          ("成功(1回目で掴んで箱に置けた)", "-", "成功"),
    "OK_RETRY":    ("成功(掴み直しで回復)", "-", "成功"),
    "V1_NODETECT": ("赤ブロック未検出(開始時)", "画像判定", "見る"),
    "P1_OUTSIDE":  ("掴み損ね:ブロックが爪の外に見える(位置ずれ)", "画像判定", "掴む"),
    "P2_BETWEEN":  ("掴み損ね判定:ブロックは爪の間に見える(持ち上がらず/判定ミス)", "画像判定+横カメラ/開度", "掴む"),
    "P3_PARTIAL":  ("掴み損ね判定:爪の間に赤がわずかに見えるだけ(判別困難)", "画像判定+横カメラ/開度", "掴む"),
    "P4_NOTVISIBLE": ("掴み損ね:ブロックが写っていない(弾いた・画面外)", "画像判定", "掴む"),
    "P5_DISTNG":   ("判定AIは成功、距離チェックで却下", "幾何チェック", "掴む"),
    "L1_NOT_CARRIED": ("掴めた判定だったが、ブロックは運ばれず把持位置の近くに残った", "画像判定(2枚比較)", "置く"),
    "L3_MISS_BOX": ("箱の近くまで運んだが外に落ちた", "画像判定", "置く"),
    "L0_OUTSIDE":  ("箱の外(位置を測れず)", "画像判定", "置く"),
    "M1_HOME":     ("HOME姿勢への到達失敗", "動作(エンコーダ)", "動かす"),
    "M2_REACH_PICK": ("把持位置への到達失敗", "動作(エンコーダ)", "動かす"),
    "M3_LIFT":     ("持ち上げ姿勢への到達失敗", "動作(エンコーダ)", "動かす"),
    "M4_REACH_PLACE": ("置き位置への到達失敗", "動作(エンコーダ)", "動かす"),
    "M5_RETREAT":  ("置いた後の退避失敗", "動作(エンコーダ)", "動かす"),
    "G1_GUARD":    ("送信前ガードで停止(届かない位置・関節リミット)", "計画ガード", "計画"),
    "H1_GRIPPER":  ("グリッパー開閉失敗", "ハード", "ハード"),
    "S1_JUDGE_ERR": ("判定AIの呼び出し失敗(API エラー等)", "システム", "システム"),
    "S2_DAEMON":   ("ロボット制御デーモンに接続できない", "システム", "システム"),
    "X_MANUAL":    ("手動中断(集計対象外)", "-", "対象外"),
    "U_UNKNOWN":   ("分類不能", "-", "対象外"),
}
PICK_MISS = {"outside": "P1_OUTSIDE", "between": "P2_BETWEEN", "partial": "P3_PARTIAL", "not_visible": "P4_NOTVISIBLE"}

def img(d, stem, kind, k=1):
    p = d / (f"{stem}_{kind}.jpg" if k == 1 else f"{stem}_{kind}_a{k}.jpg")
    return p if p.exists() else None

def text_pick_type(reason):
    r = reason or ""
    if any(k in r for k in ["存在しない", "見当たらない", "何も掴んで"]): return "P4_NOTVISIBLE"
    if any(k in r for k in ["離れ", "外側", "単独", "隣", "横に"]): return "P1_OUTSIDE"
    return "P2_BETWEEN"

def pick_attempt_type(a, image):
    v = a.get("verdict")
    if v == "yes": return "OK", ""
    if v == "unknown": return "S1_JUDGE_ERR", ""
    if a.get("vlm_verdict") == "yes": return "P5_DISTNG", (F.block_vs_gripper(image)[0] if image else "")
    if image:
        rel = F.block_vs_gripper(image)[0]
        if rel in PICK_MISS: return PICK_MISS[rel], rel
    return text_pick_type(a.get("reason")), "text"

def classify_run(j, d, stem, att_types):
    ab = j.get("abort") or {}; ast, ar = str(ab.get("stage", "")), str(ab.get("reason", ""))
    att = j.get("pick_attempts") or []; jud = j.get("judgments") or {}; place = jud.get("place") or {}
    flags, image, extra = [], None, ""
    for s in j.get("stage_failures") or []:
        if s.get("kind") == "not_reached": flags.append(f"非致命の到達誤差:{s.get('label')}")
    if place.get("verdict") == "yes" and (jud.get("place_side") or {}).get("verdict") == "no":
        flags.append("上カメラ成功・横カメラ失敗の食い違い")

    if "daemon ping" in ar: return "S2_DAEMON", flags, None, ""
    if "KeyboardInterrupt" in ar: return "X_MANUAL", flags, None, ""
    if ast == "1" or "HOME 到達失敗" in ar: return "M1_HOME", flags, None, ""
    if ast == "2" and "未検出" in ar: return "V1_NODETECT", flags, None, ""
    if "ガード" in ar and ast in ("3", "4"): return "G1_GUARD", flags, None, ""
    if "到達失敗" in ar and ast in ("3", "4"): return "M2_REACH_PICK", flags, None, ""
    if "グリッパー" in ar or any(s.get("kind") == "gripper_close_fail" for s in j.get("stage_failures") or []):
        return "H1_GRIPPER", flags, None, ""
    if att and not any(a.get("verdict") == "yes" for a in att):
        k = len(att); t, extra = att_types[-1]
        return t, flags, img(d, stem, "pick", k), extra
    kok = next((i for i, a in enumerate(att, 1) if a.get("verdict") == "yes"), 1)
    if ast == "6" or "LIFT" in ar: return "M3_LIFT", flags, img(d, stem, "pick", kok), ""
    if "ガード" in ar and ast in ("7", "8"): return "G1_GUARD", flags, None, ""
    if "到達失敗" in ar and ast in ("7", "8"): return "M4_REACH_PLACE", flags, None, ""
    pv = place.get("verdict"); pimg = img(d, stem, "place")
    if pv == "no":
        tip = img(d, stem, "stage4_pick_tip", kok)
        shift = F.block_shift_px(tip, pimg) if (tip and pimg) else None
        if shift is None: return "L0_OUTSIDE", flags, pimg, ""
        extra = f"ブロック移動量 {shift:.0f}px"
        if shift < 300: return "L1_NOT_CARRIED", flags, pimg, extra
        return "L3_MISS_BOX", flags, pimg, extra
    if pv == "unknown":
        return ("M5_RETREAT" if "swept_retreat" in str(place.get("reason")) else "S1_JUDGE_ERR"), flags, None, ""
    if pv == "yes":
        if ast == "10": flags.append("置いた後の戻り到達失敗")
        return ("OK_RETRY" if kok > 1 else "OK"), flags, pimg, ""
    if j.get("result") == "pick_failed": return "S1_JUDGE_ERR", flags, None, ""
    return "U_UNKNOWN", flags, None, ""

def classify_ptp(j):
    at, reason, note = j.get("aborted_at"), j.get("abort_reason"), j.get("note")
    if reason == "KeyboardInterrupt": return "X_MANUAL"
    if at == "lift": return "M3_LIFT"
    if at == "relay_pre_place": return "M4_REACH_PLACE"
    if at == "home_pre": return "M1_HOME"
    if at == "auto_select": return "G1_GUARD"
    if note == "full_cycle_done":
        pj = j.get("place_judgment") or {}
        return "L0_OUTSIDE" if isinstance(pj, dict) and pj.get("verdict") == "no" else "OK"
    return "U_UNKNOWN"

trials, attempts = [], []
for f in sorted(glob.glob(str(SRC / "run_*/run_*.json"))):
    try: j = json.load(open(f))
    except Exception: continue
    if j.get("dry_run"): continue
    d, stem = Path(f).parent, Path(f).stem
    att = j.get("pick_attempts") or []; att_types = []
    for i, a in enumerate(att, 1):
        im = img(d, stem, "pick", i); t, how = pick_attempt_type(a, im); att_types.append((t, how))
        note = "赤い配線の誤認の可能性" if a.get("verdict") == "no" and any(w in (a.get("reason") or "") for w in ["ワイヤー", "配線"]) else ""
        attempts.append(dict(trial=stem, attempt=i, verdict=a.get("verdict"), vlm_verdict=a.get("vlm_verdict"),
                             type=t, name=CATS[t][0], image_relation=how, note=note,
                             reason=a.get("reason", ""), image=str(im) if im else ""))
    cat, flags, image, extra = classify_run(j, d, stem, att_types)
    trials.append(dict(source="run_pick_place", trial=stem, started=j.get("started_at", "")[:19], category=cat,
                       group=CATS[cat][2], name=CATS[cat][0], detector=CATS[cat][1], n_pick_attempts=len(att),
                       abort=f"{(j.get('abort') or {}).get('stage','')}:{(j.get('abort') or {}).get('reason','')}".strip(":"),
                       detail=extra, flags=" / ".join(flags), image=str(image) if image else ""))
for f in sorted(glob.glob(str(SRC / "pick_then_place_auto_*/*.json"))):
    try: j = json.load(open(f))
    except Exception: continue
    if j.get("dry_run"): continue
    cat = classify_ptp(j)
    trials.append(dict(source="pick_then_place_auto", trial=f"{Path(f).parent.name}/{Path(f).stem}",
                       started=str(j.get("timestamp", ""))[:19], category=cat, group=CATS[cat][2], name=CATS[cat][0],
                       detector=CATS[cat][1], n_pick_attempts="",
                       abort=f"{j.get('aborted_at') or ''}:{j.get('abort_reason') or ''}".strip(":"),
                       detail="", flags="", image=""))

DATA.mkdir(exist_ok=True)
for name, rows in [("trials.csv", trials), ("pick_attempts.csv", attempts)]:
    with open(DATA / name, "w", newline="", encoding="utf-8-sig") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0].keys())); w.writeheader(); w.writerows(rows)

cnt = collections.Counter(t["category"] for t in trials)
acnt = collections.Counter(a["type"] for a in attempts)
fl = collections.Counter(x for t in trials for x in t["flags"].split(" / ") if x)
json.dump({"trials_total": len(trials), "excluded_manual": cnt.get("X_MANUAL", 0),
           "by_category": {k: {"name": v[0], "detector": v[1], "group": v[2], "trials": cnt.get(k, 0),
                               "pick_attempts": acnt.get(k, 0)} for k, v in CATS.items()},
           "flags": dict(fl.most_common())},
          open(DATA / "summary.json", "w"), ensure_ascii=False, indent=2)

# 代表画像: 画像で見分ける種類だけ。掴み系は「掴み1回ごと」から選ぶ
SAMPLES.mkdir(exist_ok=True)
from PIL import Image, ImageDraw
for k in ["OK", "P1_OUTSIDE", "P2_BETWEEN", "P3_PARTIAL", "P4_NOTVISIBLE", "P5_DISTNG", "L1_NOT_CARRIED", "L3_MISS_BOX"]:
    src = [a for a in attempts if a["type"] == k and a["image"]] if k.startswith("P") else \
          [t for t in trials if t["category"] == k and t["image"]]
    if not src: continue
    out = SAMPLES / k; out.mkdir(exist_ok=True)
    step = max(1, len(src) // N_SAMPLES); chosen = src[::step][:N_SAMPLES]
    for r in chosen: shutil.copy2(r["image"], out / Path(r["image"]).name)
    W, H, cols = 480, 270, 3; rows_ = (len(chosen) + cols - 1) // cols
    sheet = Image.new("RGB", (W * cols, (H + 22) * rows_), "white"); dr = ImageDraw.Draw(sheet)
    for i, r in enumerate(chosen):
        x, y = (i % cols) * W, (i // cols) * (H + 22)
        sheet.paste(Image.open(r["image"]).convert("RGB").resize((W, H)), (x, y + 22))
        dr.text((x + 4, y + 4), Path(r["image"]).name.replace("run_0001_", ""), fill="black")
    sheet.save(out / "_overview.jpg", quality=85)

for k, v in CATS.items():
    print(f"{k:15} 試行 {cnt.get(k,0):4}  掴み回 {acnt.get(k,0):4}  {v[0]}")
print("flags:", dict(fl.most_common(6)))

実行ログ(全量)

切り抜き画像を作ったときの出力(切り抜いた範囲の座標)

ng_01_20260626_213806.jpg (np.int64(343), np.int64(510), np.int64(783), np.int64(950))
ng_02_20260628_075611.jpg (np.int64(361), np.int64(509), np.int64(801), np.int64(949))
ng_03_20260713_103746.jpg (np.int64(538), np.int64(470), np.int64(978), np.int64(910))
ng_04_20260711_121325.jpg (np.int64(360), np.int64(468), np.int64(800), np.int64(908))
ng_05_20260626_183828.jpg (np.int64(380), np.int64(521), np.int64(820), np.int64(961))
ok_01_20260710_204830.jpg (np.int64(534), np.int64(525), np.int64(974), np.int64(965))
ok_02_20260713_101053.jpg (np.int64(526), np.int64(475), np.int64(966), np.int64(915))
ok_03_20260712_115432.jpg (np.int64(357), np.int64(402), np.int64(797), np.int64(842))
ok_04_20260713_170039.jpg (np.int64(419), np.int64(458), np.int64(859), np.int64(898))
ok_05_20260711_190901.jpg (np.int64(437), np.int64(341), np.int64(877), np.int64(781))

v1 の実行結果

python3 run_decisions.py
full ng_01_20260626_213806.jpg    正解=ng  確率=  0%  2944ms  ○
full ng_02_20260628_075611.jpg    正解=ng  確率=  2%  938ms  ○
full ng_03_20260713_103746.jpg    正解=ng  確率= 20%  1398ms  ○
full ng_04_20260711_121325.jpg    正解=ng  確率=  0%  691ms  ○
full ng_05_20260626_183828.jpg    正解=ng  確率=  0%  870ms  ○
full ok_01_20260710_204830.jpg    正解=ok  確率=  2%  692ms  ×
full ok_02_20260713_101053.jpg    正解=ok  確率= 85%  715ms  ○
full ok_03_20260712_115432.jpg    正解=ok  確率= 10%  908ms  ×
full ok_04_20260713_170039.jpg    正解=ok  確率= 39%  647ms  ×
full ok_05_20260711_190901.jpg    正解=ok  確率=  3%  664ms  ×
crop ng_01_20260626_213806.jpg    正解=ng  確率=  0%  369ms  ○
crop ng_02_20260628_075611.jpg    正解=ng  確率= 34%  542ms  ○
crop ng_03_20260713_103746.jpg    正解=ng  確率= 11%  507ms  ○
crop ng_04_20260711_121325.jpg    正解=ng  確率=  1%  387ms  ○
crop ng_05_20260626_183828.jpg    正解=ng  確率=  0%  444ms  ○
crop ok_01_20260710_204830.jpg    正解=ok  確率= 15%  445ms  ×
crop ok_02_20260713_101053.jpg    正解=ok  確率= 27%  861ms  ×
crop ok_03_20260712_115432.jpg    正解=ok  確率= 20%  455ms  ×
crop ok_04_20260713_170039.jpg    正解=ok  確率= 53%  369ms  ○
crop ok_05_20260711_190901.jpg    正解=ok  確率=  9%  425ms  ×
full: 正解 6/10
crop: 正解 6/10
results.csv に保存しました
(base) shimazueiichi@shimadzueiichinoMacBook-Pro decisions_test %

v2 の実行結果

python3 run_decisions.py
ver  file                         正解      A     B  C(hold) C選択     ms
full ng_01_20260626_213806.jpg    ng      0%    0%       0% empty    782
full ng_02_20260628_075611.jpg    ng      2%  100%      96% holding  985
full ng_03_20260713_103746.jpg    ng     20%  100%      96% holding  597
full ng_04_20260711_121325.jpg    ng      0%    0%       0% empty    747
full ng_05_20260626_183828.jpg    ng      0%    0%       0% empty    551
full ok_01_20260710_204830.jpg    ok      2%   97%      39% empty    590
full ok_02_20260713_101053.jpg    ok     85%  100%     100% holding  558
full ok_03_20260712_115432.jpg    ok     10%  100%      91% holding  570
full ok_04_20260713_170039.jpg    ok     39%  100%      99% holding  596
full ok_05_20260711_190901.jpg    ok      3%  100%     100% holding  751
crop ng_01_20260626_213806.jpg    ng      0%    0%       0% empty    535
crop ng_02_20260628_075611.jpg    ng     34%  100%      95% holding  619
crop ng_03_20260713_103746.jpg    ng     11%  100%      90% holding  404
crop ng_04_20260711_121325.jpg    ng      1%    0%       0% empty    363
crop ng_05_20260626_183828.jpg    ng      0%    0%       0% empty    439
crop ok_01_20260710_204830.jpg    ok     15%  100%      95% holding  358
crop ok_02_20260713_101053.jpg    ok     27%  100%      96% holding  397
crop ok_03_20260712_115432.jpg    ok     20%  100%      99% holding  360
crop ok_04_20260713_170039.jpg    ok     53%  100%      96% holding  697
crop ok_05_20260711_190901.jpg    ok      9%  100%      76% holding  507

--- 閾値50%での正解数 / ok・ngが分離できているか(okの最小値 > ngの最大値なら完全分離) ---
full A_ja_strict   正解 6/10   ok最小 2% / ng最大 20%  重なりあり
full B_en_visible  正解 8/10   ok最小 97% / ng最大 100%  重なりあり
full C_choice      正解 7/10   ok最小 39% / ng最大 96%  重なりあり
crop A_ja_strict   正解 6/10   ok最小 9% / ng最大 34%  重なりあり
crop B_en_visible  正解 8/10   ok最小 100% / ng最大 100%  重なりあり
crop C_choice      正解 8/10   ok最小 76% / ng最大 95%  重なりあり
results_v2.csv に保存しました
(base) shimazueiichi@shimadzueiichinoMacBook-Pro decisions_test %

v3 の実行結果

pair                     正解      D E(picked) E選択        ms
ng_01_20260626_213806    ng      0%        1% left_behind  808
ng_02_20260628_075611    ng     94%       95% picked       752
ng_03_20260713_103746    ng     94%       99% picked       853
ng_04_20260711_121325    ng      8%       44% picked       788
ng_05_20260626_183828    ng      6%        7% left_behind  1051
ok_01_20260710_204830    ok     82%       77% picked       930
ok_02_20260713_101053    ok     95%      100% picked       1344
ok_03_20260712_115432    ok     95%       99% picked       1592
ok_04_20260713_170039    ok     95%      100% picked       1003
ok_05_20260711_190901    ok     95%       99% picked       786

--- 閾値50%での正解数 / 分離 ---
D_moved_with  正解 8/10   ok最小 82% / ng最大 94%  重なりあり
E_picked      正解 8/10   ok最小 77% / ng最大 99%  重なりあり
results_v3.csv に保存しました
(base) shimazueiichi@shimadzueiichinoMacBook-Pro decisions_test %

ng_03(run_0001_20260713_103746)の段階ごとの時刻と gripper の値

01:37:47 stage1 gripper= None
01:38:13 stage2 gripper= 0
01:38:19 stage3_approach_pick gripper= 0
01:38:20 stage4_pick gripper= 0
01:38:22 stage4_pick gripper= 0
01:38:24 stage5 gripper= 1
01:38:25 stage6 gripper= 1
01:38:26 stage6 gripper= 1
01:39:00 stage2_redetect gripper= 0
01:39:04 stage3_approach_pick_a2 gripper= 0
01:39:06 stage4_pick_a2 gripper= 0
01:39:07 stage4_pick_a2 gripper= 0
01:39:09 stage5_a2 gripper= 1
01:39:14 stage6_a2 gripper= 1
01:39:16 stage6_a2 gripper= 1
01:39:49 stage2_redetect_a2 gripper= 0
01:39:53 stage3_approach_pick_a3 gripper= 0
01:39:54 stage4_pick_a3 gripper= 0
01:39:55 stage4_pick_a3 gripper= 0
01:39:57 stage5_a3 gripper= 1
01:40:02 stage6_a3 gripper= 1
01:40:03 stage6_a3 gripper= 1
01:40:10 stage7_approach_place gripper= 1
01:40:10 stage7_approach_place gripper= 1
01:40:11 stage8_place gripper= 1
01:40:29 stage8_5_replace_align gripper= 1
01:40:35 stage8_5_replace_align gripper= 1
01:40:49 stage9 gripper= 0
01:40:50 stage9b_judge_place gripper= 0
01:40:52 stage9b_judge_place gripper= 0
01:41:01 stage10 gripper= 0

時刻は作業環境の時計(UTC)で表示されている。日本時間では9時間足した10:37:47〜10:41:01 になる。写真のファイルの更新時刻は、_stage4_pick_tip.jpg が 01:38:22、_pick.jpg が 01:38:26 だった。

640試行の分類の出力

OK              試行  230  掴み回  405  成功(1回目で掴んで箱に置けた)
OK_RETRY        試行    3  掴み回    0  成功(掴み直しで回復)
V1_NODETECT     試行   24  掴み回    0  赤ブロック未検出(開始時)
P1_OUTSIDE      試行   13  掴み回   34  掴み損ね:ブロックが爪の外に見える(位置ずれ)
P2_BETWEEN      試行    1  掴み回    5  掴み損ね判定:ブロックは爪の間に見える(持ち上がらず/判定ミス)
P3_PARTIAL      試行    4  掴み回   11  掴み損ね判定:爪の間に赤がわずかに見えるだけ(判別困難)
P4_NOTVISIBLE   試行    0  掴み回    4  掴み損ね:ブロックが写っていない(弾いた・画面外)
P5_DISTNG       試行    9  掴み回   27  判定AIは成功、距離チェックで却下
L1_NOT_CARRIED  試行   39  掴み回    0  掴めた判定だったが、ブロックは運ばれず把持位置の近くに残った
L3_MISS_BOX     試行   59  掴み回    0  箱の近くまで運んだが外に落ちた
L0_OUTSIDE      試行    0  掴み回    0  箱の外(位置を測れず)
M1_HOME         試行    8  掴み回    0  HOME姿勢への到達失敗
M2_REACH_PICK   試行   17  掴み回    0  把持位置への到達失敗
M3_LIFT         試行   38  掴み回    0  持ち上げ姿勢への到達失敗
M4_REACH_PLACE  試行   20  掴み回    0  置き位置への到達失敗
M5_RETREAT      試行   23  掴み回    0  置いた後の退避失敗
G1_GUARD        試行   89  掴み回    0  送信前ガードで停止(届かない位置・関節リミット)
H1_GRIPPER      試行    5  掴み回    0  グリッパー開閉失敗
S1_JUDGE_ERR    試行   25  掴み回   25  判定AIの呼び出し失敗(API エラー等)
S2_DAEMON       試行    4  掴み回    0  ロボット制御デーモンに接続できない
X_MANUAL        試行   29  掴み回    0  手動中断(集計対象外)
U_UNKNOWN       試行    0  掴み回    0  分類不能
flags: {'非致命の到達誤差:stage6_lift': 97, '上カメラ成功・横カメラ失敗の食い違い': 86, '非致命の到達誤差:stage10_lift_back': 79, '非致命の到達誤差:stage7_approach_place': 44, '非致命の到達誤差:stage4_pick': 31, '非致命の到達誤差:stage10_to_home': 22}

flags は、試行を止めはしなかったが記録に残っていた出来事の数になる。「非致命の到達誤差」は、関節が許容範囲までは届かなかったが、試行は続けた回数。

試行ごとの分類(640行)は data/trials.csv、掴み1回ごとの分類(511行)は data/pick_attempts.csv に残した。同じフォルダで python3 scripts/classify_runs.py を実行すると、同じ結果が作り直される。

実行条件

項目

内容

実行日

2026-10-10

API

OpenAI Decisions API(パブリックベータ)、POST /v1/decisions

モデル

gpt-6-luna

実行環境

Mac のターミナル、Python 標準ライブラリの urllib(OpenAI の SDK は使っていない)

画像

真上カメラの写真 1920×1080画素、JPEG。切り抜き版は約440×440画素

しきい値

50%以上を「掴めている」として正解数を数えた

分類の対象

runs/run_pick_place/ の実機試行 640件(2026-06-19〜07-20、dry-run を除く)

保存先は、MASTER の保存先テーブルで「実機の単体実験」の置き場所になっている runs/experiments/ の下にした。

/Volumes/KIOXIA/r2r2r-data/runs/experiments/decisions_test/
├── README.md
├── images/
│   ├── full/      元画像10枚
│   ├── crop/      切り抜き10枚
│   └── pairs/     持ち上げ前後の組10組
├── scripts/       run_decisions_v1.py, v2.py, v3.py
└── results/       results_v1.csv, v2.csv, v3.csv

/Volumes/KIOXIA/r2r2r-data/runs/experiments/failure_taxonomy/
├── README.md
├── data/          trials.csv, pick_attempts.csv, summary.json
├── samples/       種類ごとの代表画像と _overview.jpg
└── scripts/       classify_runs.py, image_features.py

参照した資料

この回で分かったこと

論点

分かったこと

Decisions API の形

決めた選択肢から答えを選ぶ専用の API。predicate・choice・score の3種類。画像は base64 で埋め込む

写っていることの判定

「指先の間に赤いブロックがあるか」は、ほぼ0%か100%で答え分けた

写っていないことの判定

「床から持ち上がったか」は、聞き方を3通り変えても、2枚比較でも判定できなかった

指示文の影響

同じ ok_01 の写真で、日本語の「しっかり挟んでいる」は2%、英語の「見えるか」は97%

速度

画像1枚で0.36〜1.4秒(最初の1回は2.9秒)。切り抜いた小さい画像のほうが速い。毎コマの制御には使えない

使いどころ

「掴む → 確かめる → 置く」の確かめるところ。AI はテスト済みの関数から選ぶだけにする

実機640試行の失敗

画像で見分けるべき失敗は約4割。残りは到達失敗・安全チェック・システムの問題

運ばれていない39件

掴めた判定のまま、ブロックが掴もうとした場所に残っていた。テストで見つけた弱点と同じ

横カメラの食い違い86件

離す前に撮っていたための食い違いで、失敗ではない

既存の判定ログ

判定 AI の「掴めた」を距離チェックが「失敗」に書き換えた記録が27回ある

結論の範囲

テストは10枚、分類は自動。選択肢を決めるための目安として使う

次は持ち上げ後と離した後に横カメラで撮り、同じスクリプトで判定し直す

次に実機を動かすときに変えることは2つある。

1つ目は、横のカメラで撮るタイミング。いまは箱の上でブロックを挟んだまま撮っているので、ブロックを離した後に変える。あわせて、持ち上げた直後(stage6)にも横から1枚撮る。横からなら、ブロックがマットから浮いているかが写る。真上からは判定できなかった ng_02・ng_03 のような場面を、写っていることを聞く質問に変えられる。

2つ目は、get_gripper_value() でグリッパーの実際の開度を記録すること。空振りなら爪は閉じきり、掴めていればブロックの幅で止まるはずなので、写真を使わずに判別できるかを確かめる。

横カメラの写真が10件ほど集まったら、同じ decisions_test のスクリプトで判定し直す。そこで「持ち上げた後」の選択肢が成り立てば、640試行の分類から作った選択肢の案を、そのまま実機の判定に組み込める。