画像認識と物体検出を学び直す Log#7 | 特徴点マッチングで2枚の写真から同じ場所を見つける ― 別の物を撮った写真でも「同じ」と判定される
title: "画像認識と物体検出を学び直す Log#7 | 特徴点マッチングで2枚の写真から同じ場所を見つける ― 別の物を撮った写真でも「同じ」と判定される" slug: same-place-different-object
2枚の写真を並べて、片方のこの点と、もう片方のこの点が同じ場所だと結ぶ。特徴点マッチングと呼ばれる処理がやっているのはこれで、パノラマ合成も、複数枚からの立体復元も、カメラが自分の位置を知る仕組みも、全部ここから始まる。
本を1冊マットの上に置いて、角度を変えながら9セット撮った。ORB と SIFT の2つで対応を取ったら、正対と15度傾けた写真の間で2608件の対応が付いた。数字としては悪くない。
ただし、本を退けてマットだけを撮った写真とマッチングしても、934件が付いた。同じものが1つも写っていない2枚で、である。さらに RANSAC という手法にかけると、そのうち867件が「この変換に合う」と判定された。inlier 率 92.8%。この数字は、正しく撮った8組を含む全14組の中で最も高い。
対応が付いた数は、正しさを何も示していなかった。
特徴点マッチングは色を使わずに2枚の写真を結ぶ
これまでのこのシリーズは色を追ってきた。赤い塊を探す、青い塊を探す。色は分かりやすい代わりに、照明が変わると崩れるし、「赤い部分」としか言えないので、2枚の写真で「この赤の、この一点」を対応づけることができない。
特徴点マッチングは別の見方をする。画像の中から、周りと見え方が違って目印になる点を探す。角、模様の交差、細かい凹凸。そういう点を1枚目から数百個、2枚目からも数百個拾って、それぞれの点の周りの見え方を数値の並びに変換する。あとは数値どうしを総当たりで比べて、一番近いものを同じ場所とみなす。
手順にすると4段階になる。
同じ対象を、条件を変えて2枚撮る
両方の画像から特徴点を検出する(点の座標が数百から数千個ずつ出る)
各点の周りの見え方を数値の並び(記述子)にする
1枚目の点それぞれについて、2枚目で一番近い数値の点を探す
問題は5段階目にある。総当たりで探すと、必ず何かは見つかる。見つかったから正しいわけではない。
次の Log#8 は射影変換(斜めから撮った平面を、真上から見た座標に直す計算)を扱う。その計算には「2枚の画像で対応する点の組」が入力として要る。Log#7 はその入力を作る回にあたる。
撮影対象は本1冊。表紙 240×180mm を定規で実測した
特徴点は模様の変化を手がかりにするので、単色で角の少ない図形からはほとんど出ない。前回 Log#6 で使った印刷図形シート(白地に単色の丸や四角)は使えない。
赤ブロックも使えない。単色でつるっとしていて、面の中に模様がない。特徴点として拾えるのは角と辺のあたりだけで、しかも立方体の角はどれも似た見え方なので、2枚目のどの角と対応するかが決まらず誤対応になる。さらに Log#8 が射影変換である以上、対象は平面である必要がある。ブロックは立体なので次の回の入力として使えない。
条件は2つだけになる。平らであること。模様が繰り返さないこと。手元にあった本を使った。表紙に文字と写真とイラストが入っていて、繰り返しがなく、厚みがあって平面が保てる。
表紙の外形はマットの目盛りで実測した。左端が約14.5cm、右端が約38.5cmの位置で、横 240mm。上端が約23.5cm、下端が約5.5cmで、縦 180mm。厚みは25mm。
この寸法確定には手間取った。最初 80×240mm と申告があり、写真と縦横比が合わなかった。マットの目盛りを拡大した画像で1辺ずつ確認して、240×180mm に確定させた。ここで数字を確定させないと、後の真値の物差しが全部ずれる。
撮ったのは9セット。
ラベル | 内容 |
|---|---|
flat_0 | 正対(基準セット) |
tilt_15 / tilt_30 / tilt_45 | 本の片側の下に物を挟んで傾けた |
flat_15 / flat_30 / flat_45 | 本をマットの上で回転させた(表紙は正対のまま) |
flat_far | 距離を変えた |
mat_0 | 本を退けてマットだけ |
tilt と flat の区別が後で効いてくる。tilt は面が台形に潰れる。flat は画像が回るだけで、面は真上を向いたまま。この2種類は特徴点マッチングに対して別の負荷をかける。
撮影スクリプトは Log#6 版から保存先と meta 項目だけを変えた
前シリーズから引き継いだ取得条件(1920×1080 / AVFOUNDATION / BGR / PNG / 連続5フレーム)と、カメラ解決の処理はそのまま使う。変えたのは保存先を Log07 にすること、ラベルを引数で受け取ること、meta.json に記録する項目を対象物の寸法に置き換えることの3点だけ。
# capture_top_frames_log07.py - 上カメラから連続5フレームをPNGで保存する
# capture_top_frames_log06.py の Log07 派生版。
# 保存先を Log07/{label}_{YYYYMMDD_HHMMSS}/ サブディレクトリに変更。
# meta.json の被写体項目を book_cover 系に置き換え。
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, json, sys, os, time
from datetime import datetime
if len(sys.argv) < 2:
sys.exit("[ABORT] label 引数が必要です。例: flat_0 / flat_15 / flat_30 / flat_45 / flat_far")
LABEL = sys.argv[1]
BASE_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log07"
WARMUP_FRAMES = 60
N_SAVE = 5
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
stem_base = LABEL + "_" + datetime.now().strftime("%Y%m%d_%H%M%S")
SAVE_DIR = os.path.join(BASE_DIR, stem_base)
if os.path.exists(SAVE_DIR):
sys.exit("[ABORT] 同名ディレクトリが既にあります: " + SAVE_DIR)
os.makedirs(SAVE_DIR)
sys.path.insert(0, os.path.expanduser("~/r2r2r-scripts/dynamic_tracking_limits/place/place_modules"))
import camera_resolver
index = camera_resolver.find_camera_index(camera_resolver.TOP_NAME_KEYS)
cap = cv2.VideoCapture(index, cv2.CAP_AVFOUNDATION)
if not cap.isOpened():
sys.exit("[ABORT] カメラを開けません index=" + str(index))
backend = cap.getBackendName()
for _ in range(WARMUP_FRAMES):
cap.read()
time.sleep(0.03)
saved = []
frame = None
for i in range(N_SAVE):
ok, frame = cap.read()
if not ok:
cap.release()
sys.exit("[ABORT] フレーム取得失敗 i=" + str(i))
png = os.path.join(SAVE_DIR, "f" + str(i) + ".png")
if os.path.exists(png):
cap.release()
sys.exit("[ABORT] 同名ファイルが既にあります: " + png)
cv2.imwrite(png, frame)
saved.append(os.path.basename(png))
time.sleep(0.2)
cap.release()
meta = {
"label": LABEL,
"files": saved,
"frame_count": len(saved),
"camera_index_resolved": index,
"backend": backend,
"frame_width": int(frame.shape[1]),
"frame_height": int(frame.shape[0]),
"channels": int(frame.shape[2]),
"dtype": str(frame.dtype),
"warmup_frames": WARMUP_FRAMES,
"captured_at": datetime.now().isoformat(),
"arm_pose": "power off / physical pose unchanged (not commanded)",
"object_name": "book_cover",
"cover_w_mm": 240.0,
"cover_h_mm": 180.0,
"object_thickness_mm": 25.0,
"angle_deg_measured": None,
"note": "cv2.VideoCapture から取得した BGR フレームを PNG 無圧縮で保存。カメラアプリ経由ではない。",
}
with open(os.path.join(SAVE_DIR, "meta.json"), "w") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
print("[OK] " + str(len(saved)) + " 枚保存: " + SAVE_DIR)
print("[OK] 解像度 " + str(frame.shape[1]) + "x" + str(frame.shape[0]) + " backend=" + backend)実行結果と meta.json の中身。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 ~/r2r2r-scripts/capture_top_frames_log07.py flat_0
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log07/flat_0_20260802_072613
[OK] 解像度 1920x1080 backend=AVFOUNDATION
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % cat "$(ls -td /Volumes/KIOXIA/r2r2r-data/vision/captured/Log07/flat_0_* | head -1)/meta.json"
{
"label": "flat_0",
"files": [
"f0.png",
"f1.png",
"f2.png",
"f3.png",
"f4.png"
],
"frame_count": 5,
"camera_index_resolved": 2,
"backend": "AVFOUNDATION",
"frame_width": 1920,
"frame_height": 1080,
"channels": 3,
"dtype": "uint8",
"warmup_frames": 60,
"captured_at": "2026-08-02T07:41:54.128870",
"arm_pose": "power off / physical pose unchanged (not commanded)",
"object_name": "book_cover",
"cover_w_mm": 240.0,
"cover_h_mm": 180.0,
"object_thickness_mm": 25.0,
"angle_deg_measured": null,
"note": "cv2.VideoCapture から取得した BGR フレームを PNG 無圧縮で保存。カメラアプリ経由ではない。"
}%
grep FRAME_WIDTH で Log#6 版を確認したところ、解像度の明示指定は入っていなかった。Log#6 の 1920×1080 は Continuity Camera のデフォルトがそのまま出た結果だったことになる。Log#7 版も同じ状態に揃えたので、条件は一致している。
なお前シリーズから「PNG 無圧縮」と記述してきたが、cv2.imwrite に圧縮レベルの指定は入っていない。OpenCV のデフォルト(レベル1)で書かれている。PNG は可逆圧縮なので画素値は変わらないが、記述としては「PNG(可逆)」が正しい。
ORB は5000点、SIFT は2503点を検出した
マッチングのスクリプト。knnMatch で最近傍2つを取り、比率テストの値を計算する。ここで重要なのは、比率テストを通らなかった対応も捨てずに全件 CSV に書いていること。閾値を変えたときに何がどう入れ替わるかを、後から見られるようにするため。
# match_features_log07.py - Log07 の2つの撮影セットから特徴点対応を CSV に出す
# ORB / SIFT を切り替え可能。knnMatch(k=2) の全対応を ratio 付きで出力する
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, csv, sys, os
RATIO_THRESH = 0.75
if len(sys.argv) < 4:
sys.exit("[ABORT] 引数が必要です: setA setB detector(orb|sift)")
SET_A = sys.argv[1]
SET_B = sys.argv[2]
DETECTOR = sys.argv[3].lower()
if DETECTOR not in ("orb", "sift"):
sys.exit("[ABORT] detector は orb または sift のいずれかを指定してください: " + DETECTOR)
CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log07"
OUT_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
path_a = os.path.join(CAPTURED_BASE, SET_A, "f0.png")
path_b = os.path.join(CAPTURED_BASE, SET_B, "f0.png")
for p in (path_a, path_b):
if not os.path.exists(p):
sys.exit("[ABORT] 入力が見つかりません: " + p)
os.makedirs(OUT_DIR, exist_ok=True)
out_csv = os.path.join(OUT_DIR, "matches_" + SET_A + "__" + SET_B + "_" + DETECTOR + ".csv")
if os.path.exists(out_csv):
sys.exit("[ABORT] 出力先に同名ファイルがあります: " + out_csv)
img_a = cv2.imread(path_a, cv2.IMREAD_GRAYSCALE)
img_b = cv2.imread(path_b, cv2.IMREAD_GRAYSCALE)
if img_a is None:
sys.exit("[ABORT] 画像を読めません: " + path_a)
if img_b is None:
sys.exit("[ABORT] 画像を読めません: " + path_b)
if DETECTOR == "orb":
detector = cv2.ORB_create(nfeatures=5000)
norm = cv2.NORM_HAMMING
else:
detector = cv2.SIFT_create(nfeatures=5000)
norm = cv2.NORM_L2
kp_a, desc_a = detector.detectAndCompute(img_a, None)
kp_b, desc_b = detector.detectAndCompute(img_b, None)
if desc_a is None or desc_b is None or len(kp_a) < 2 or len(kp_b) < 2:
sys.exit("[ABORT] 特徴点/記述子が不足: A=" + str(len(kp_a)) + " B=" + str(len(kp_b)))
matcher = cv2.BFMatcher(norm, crossCheck=False)
knn = matcher.knnMatch(desc_a, desc_b, k=2)
rows = []
ratio_pass_count = 0
for pair_id, pair in enumerate(knn):
if len(pair) < 2:
continue
m, n = pair[0], pair[1]
a = kp_a[m.queryIdx]
b = kp_b[m.trainIdx]
d1 = float(m.distance)
d2 = float(n.distance)
ratio = d1 / d2 if d2 > 0 else float("inf")
ratio_pass = 1 if ratio < RATIO_THRESH else 0
if ratio_pass:
ratio_pass_count += 1
rows.append([
pair_id, DETECTOR, SET_A, SET_B,
float(a.pt[0]), float(a.pt[1]), float(a.size), float(a.angle), float(a.response),
float(b.pt[0]), float(b.pt[1]), float(b.size), float(b.angle), float(b.response),
d1, d2, ratio, ratio_pass,
])
with open(out_csv, "w", newline="") as f:
w = csv.writer(f)
w.writerow([
"pair_id", "detector", "setA", "setB",
"ax_px", "ay_px", "a_size", "a_angle", "a_response",
"bx_px", "by_px", "b_size", "b_angle", "b_response",
"desc_distance", "second_distance", "ratio", "ratio_pass",
])
w.writerows(rows)
print("[OK] keypoints A=" + str(len(kp_a)) + " B=" + str(len(kp_b)))
print("[OK] knnMatch 対応数=" + str(len(knn)) + " (2NN 取得できた行数=" + str(len(rows)) + ")")
print("[OK] ratio_pass=1 の数=" + str(ratio_pass_count) + " (RATIO_THRESH=" + str(RATIO_THRESH) + ")")
print("[OK] CSV: " + out_csv)
最初の2本を走らせた結果。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 ~/r2r2r-scripts/match_features_log07.py flat_0_20260802_074143 tilt_15_20260802_080141 orb
[OK] keypoints A=5000 B=5000
[OK] knnMatch 対応数=5000 (2NN 取得できた行数=5000)
[OK] ratio_pass=1 の数=2608 (RATIO_THRESH=0.75)
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/matches_flat_0_20260802_074143__tilt_15_20260802_080141_orb.csv
(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 ~/r2r2r-scripts/match_features_log07.py flat_0_20260802_074143 tilt_15_20260802_080141 sift
[OK] keypoints A=2503 B=2609
[OK] knnMatch 対応数=2503 (2NN 取得できた行数=2503)
[OK] ratio_pass=1 の数=1037 (RATIO_THRESH=0.75)
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/matches_flat_0_20260802_074143__tilt_15_20260802_080141_sift.csvORB は指定した上限 5000 に張り付いている。この画像なら5000個以上の候補があるということで、上限を上げれば数が変わる。SIFT は同じ 5000 を指定しても 2503。コントラストの閾値で足切りされるので上限に達しない。
残り12本も同様に走らせて、16本の CSV がそろった。全16本の通過数を並べる。
セット | ORB 通過 | SIFT 通過 |
|---|---|---|
tilt_15 | 2608 | 1037 |
tilt_30 | 2361 | 972 |
tilt_45 | 1879 | 950 |
flat_15 | 2778 | 1083 |
flat_30 | 2677 | 1017 |
flat_45 | 2598 | 1054 |
flat_far | 1686 | 819 |
mat_0 | 934 | 436 |
mat_0 の 934 がこの時点で目に付く。本が写っていない画像なのに、tilt_45 の半分に達している。
通過数は正しさを示さないので、外側に物差しが要る
比率テストが見ているのは「1番近い候補が、2番目よりどれだけ近いか」だけ。どちらも間違っていても、片方が突出して近ければ通過する。通過数は「対応が付いた数」であって「正しかった数」ではない。
正しさを測るには、マッチングとは無関係の経路で正解を用意する必要がある。ここで四隅が出てくる。
本の表紙は平らな面なので、2枚の画像の間には面全体に共通する1つの変換が存在する。これをホモグラフィと呼ぶ。中身は3×3の数字の表で、点の座標を入れると移った先の座標が出てくる。左上の点も右下の点も、同じ計算式で移せる。
四隅が4組あれば、この変換が1つに決まる。決まれば「1枚目の点Aは、2枚目のここに来るはず」が計算できる。マッチングが「点Aは点Bに対応する」と言っているとき、計算した位置と点Bを比べれば、正しいかどうかが判定できる。
この四隅を自動で取ろうとして、7回失敗した。
表紙の四隅を自動検出する試みは7回とも別の理由で失敗した
失敗の記録を全部残す。7回とも原因が違う。
1回目:明るさで切ったら、テーブル面が最大になった

表紙は白、マットは黒。OTSU 二値化して面積最大の輪郭を取れば表紙が出るはずだった。出たのは画像の四隅。
[OK] detected=True
[OK] vertex_count=4
[OK] corners_px=[[0.0, 0.0], [1919.0, 0.0], [1919.0, 1079.0], [0.0, 1079.0]]
[OK] contour_area_px=2057690.0
面積 2,057,690px は画像全体 2,073,600px のほぼ全部。掴んだのはマットの外側の白いテーブル面だった。画面にマット以外の領域が写っていることを勘定に入れていなかった。2回目:画像の縁に接する輪郭を除外したら、表紙まで落ちた
テーブル面は必ず画像の縁に接する。表紙は画面内に収まっている。だから縁に接する輪郭を除外すれば分離できるはずだった。
[OK] detected=False
[OK] vertex_count=6
[OK] contour_area_px=8.0面積 8px。表紙とテーブル面が白領域として繋がっていたので、1つの輪郭として丸ごと落ちた。1回目の面積が画像のほぼ全部だった時点で、表紙がその中に含まれている可能性に気づくべきだった。
3回目:マット領域に限定したら、目盛り線が繋がった

先に暗い領域(マット)を取り、その内側だけで明るい塊を探す方式に変えた。四隅は出たが、左辺644px・右辺363px と非対称になった。
[OK] detected=True
[OK] vertex_count=4
[OK] corners_px=[[432.0, 317.0], [1255.0, 497.0], [1253.0, 860.0], [430.0, 961.0]]
[OK] contour_area_px=311850.5面積 311,850px は計算値(451×338 = 約152,000px)の2倍。確認画像を見ると、表紙の輪郭からマットの目盛り線が左下と左上に伸びていた。目盛りは白っぽいので二値化で白になり、表紙と地続きになる。
4回目:opening で細線を消したら、表紙の中身に負けた

モルフォロジーの opening(細い線を削ってから元の太さに戻す処理)を 15×15 のカーネルで挟んだ。目盛りは消えた。今度は輪郭が表紙の内側にえぐれた。
表紙には黒い円や濃い色のイラストが印刷されている。二値化ではそこが黒になる。それが表紙の縁に接している場所で、輪郭が内側に食い込む。
5回目:minAreaRect にしたら、寸法が合わない

えぐれても外接矩形なら外形に一致するはずだった。
[OK] detected=True
[OK] vertex_count=8
[OK] corners_px=[[769.0, 496.0], [1193.9998779296875, 496.0], [1193.9998779296875, 863.0], [768.9999694824219, 863.0]]
[OK] contour_area_px=126030.0
aspect_ratio 1.1580381734220289
area_ratio 0.8080143552446525
rect 366.99993896484375 424.99993896484375425×367px は mm に直すと 226×195mm。真値 240×180mm と合わない。縦横比 1.158 に対し真値は 1.333。右端の黒い帯が矩形の外に出ていた。minAreaRect が返すのは「入力点群を囲む最小矩形」であって「欠ける前の物体の外形」ではない。右端の点が失われていれば、右端を推測して広げてはくれない。
6回目:Canny で取ったら、ROI 全体を掴んだ
明るさが駄目ならエッジで、と切り替えた。Canny(50,150) の後に 5×5 で2回膨張させた。
[OK] detected=True
[OK] vertex_count=10
[OK] corners_px=[[410.0, 0.0], [1619.999755859375, 0.0], [1619.999755859375, 980.9998779296875], [409.99999999999994, 980.9998779296875]]
[OK] contour_area_px=1050122.5
aspect_ratio 1.233435174745354
rect 980.9998779296875 1209.999755859375
mm 521.8084457072806 643.6168914145612521×643mm。マット全体である。膨張でマットの目盛りと表紙のエッジが全部繋がり、エッジの網目になった。
7回目:候補を全部集めて条件で絞ったら、条件を満たすものがゼロだった
「面積最大を選ぶ」という基準そのものが表紙を選ぶ条件になっていない、と考えて方式を変えた。明るさ経路とエッジ経路の両方から候補を集め、縦横比 1.333±0.15 かつ長辺 451px±20% で絞る。
[OK] detected=False
[OK] vertex_count=0
[OK] contour_area_px=0.0
candidates 21 -> 0
aspect 0.0
rect 0.0 0.021個中0個。落ちた候補の中身を全部出した。
src long short aspect area a_ok l_ok
B 1209.7 981.0 1.233 1186711 True False
A 561.0 479.0 1.171 268719 False False
A 662.0 305.0 2.170 201910 False False
A 425.0 367.0 1.158 155975 False True
A 342.0 55.7 6.138 19059 False False
A 126.1 124.6 1.012 15716 False False
B 104.6 58.0 1.802 6068 False False
B 94.0 43.0 2.186 4042 False False
A 92.8 40.8 2.276 3785 False False
A 62.0 38.0 1.632 2356 False False
A 52.0 27.0 1.926 1404 False False
A 37.0 29.0 1.276 1073 True False
A 33.0 32.0 1.031 1056 False False
A 28.0 18.0 1.556 504 False False
A 20.0 17.0 1.176 340 False False
A 18.0 17.0 1.059 306 False False
A 21.0 14.0 1.500 294 False False
B 14.0 14.0 1.000 196 False False
B 11.0 11.0 1.000 121 False False
B 12.0 10.0 1.200 120 True False
B 5.0 4.0 1.250 20 True False21個の中に、451×338px 前後の候補が1つもない。これは条件の設定が厳しすぎたのではなく、候補生成の段階で表紙の外形が生成されていないということだった。閾値をどう緩めても改善しない。
ここまでの検出スクリプトの最終形を全文載せる。7回の試行が積み重なった状態のものである。
# detect_cover_corners_log07.py - 表紙の四隅を輪郭検出で自動抽出し JSON に出す
# 表紙=白、マット=黒 を前提に OTSU 二値化 → 最大輪郭 → 多角形近似
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, json, sys, os
from datetime import datetime
CANNY_LOW = 50
CANNY_HIGH = 150
EXPECTED_ASPECT = 1.333
ASPECT_TOL = 0.15
EXPECTED_LONG_PX = 451.0
LONG_TOL_RATIO = 0.20
if len(sys.argv) < 2:
sys.exit("[ABORT] 引数が必要です: set_dir")
SET_DIR = sys.argv[1]
CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log07"
OUT_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
in_path = os.path.join(CAPTURED_BASE, SET_DIR, "f0.png")
if not os.path.exists(in_path):
sys.exit("[ABORT] 入力が見つかりません: " + in_path)
os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, "corners_" + SET_DIR + ".json")
if os.path.exists(out_path):
sys.exit("[ABORT] 出力先に同名ファイルがあります: " + out_path)
debug_dir = os.path.join(OUT_DIR, "debug_" + SET_DIR)
if os.path.exists(debug_dir):
sys.exit("[ABORT] debug ディレクトリが既にあります: " + debug_dir)
os.makedirs(debug_dir)
img = cv2.imread(in_path, cv2.IMREAD_GRAYSCALE)
if img is None:
sys.exit("[ABORT] 画像を読めません: " + in_path)
img_color = cv2.imread(in_path)
if img_color is None:
sys.exit("[ABORT] カラー画像を読めません: " + in_path)
thresh_value, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
cv2.imwrite(os.path.join(debug_dir, "01_binary.png"), binary)
image_width = int(img.shape[1])
image_height = int(img.shape[0])
mat_binary = cv2.bitwise_not(binary)
mat_contours, _ = cv2.findContours(mat_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours_total = len(mat_contours)
detected = False
vertex_count = 0
corners_px = None
area = 0.0
mat_bbox = None
mat_area = 0.0
rect_w_px = 0.0
rect_h_px = 0.0
rect_angle_deg = 0.0
aspect_ratio = 0.0
area_ratio = 0.0
candidates_total = 0
candidates_after_filter = 0
candidate_log = []
if mat_contours:
mat_contour = max(mat_contours, key=cv2.contourArea)
mat_area = float(cv2.contourArea(mat_contour))
mx, my, mw, mh = cv2.boundingRect(mat_contour)
mat_bbox = [int(mx), int(my), int(mw), int(mh)]
img_mat = img_color.copy()
cv2.rectangle(img_mat, (mx, my), (mx + mw, my + mh), (0, 255, 0), 3)
cv2.imwrite(os.path.join(debug_dir, "02_mat_bbox.png"), img_mat)
roi = img[my:my + mh, mx:mx + mw]
_, roi_binary = cv2.threshold(roi, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
kernel_a = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
roi_binary = cv2.morphologyEx(roi_binary, cv2.MORPH_OPEN, kernel_a)
cv2.imwrite(os.path.join(debug_dir, "01b_roi_binary.png"), roi_binary)
contours_a, _ = cv2.findContours(roi_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
blurred = cv2.GaussianBlur(roi, (5, 5), 0)
edges = cv2.Canny(blurred, CANNY_LOW, CANNY_HIGH)
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
edges = cv2.dilate(edges, dilate_kernel, iterations=1)
cv2.imwrite(os.path.join(debug_dir, "01c_roi_edges.png"), edges)
contours_b, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
all_candidates = [(c, "A") for c in contours_a] + [(c, "B") for c in contours_b]
candidates_total = len(all_candidates)
filtered = []
for c, source_tag in all_candidates:
if len(c) < 3:
candidate_log.append({
"source": source_tag + "_skip",
"long_side_px": 0.0,
"short_side_px": 0.0,
"aspect_ratio": 0.0,
"rect_area_px": 0.0,
"aspect_ok": False,
"long_ok": False,
})
continue
r = cv2.minAreaRect(c)
(_, _), (rw, rh), _ = r
long_side = max(rw, rh)
short_side = min(rw, rh)
rect_area_c = long_side * short_side
if short_side <= 0:
ar = 0.0
aspect_ok = False
long_ok = False
else:
ar = long_side / short_side
aspect_ok = abs(ar - EXPECTED_ASPECT) <= ASPECT_TOL
long_ok = abs(long_side - EXPECTED_LONG_PX) / EXPECTED_LONG_PX <= LONG_TOL_RATIO
candidate_log.append({
"source": source_tag,
"long_side_px": float(long_side),
"short_side_px": float(short_side),
"aspect_ratio": float(ar),
"rect_area_px": float(rect_area_c),
"aspect_ok": bool(aspect_ok),
"long_ok": bool(long_ok),
})
if aspect_ok and long_ok:
filtered.append((c, r, rect_area_c))
candidates_after_filter = len(filtered)
candidate_log.sort(key=lambda e: e["rect_area_px"], reverse=True)
img_all_candidates = img_color.copy()
for c, _tag in all_candidates:
if len(c) < 3:
continue
r = cv2.minAreaRect(c)
b = cv2.boxPoints(r)
sb = b + [[mx, my]]
cv2.drawContours(img_all_candidates, [sb.astype(int)], -1, (255, 255, 255), 1)
cv2.imwrite(os.path.join(debug_dir, "07_all_candidates.png"), img_all_candidates)
if filtered:
contour, rect, chosen_rect_area = max(filtered, key=lambda t: t[2])
area = float(cv2.contourArea(contour))
img_cover = img_color.copy()
shifted_contour = contour + [[mx, my]]
cv2.drawContours(img_cover, [shifted_contour], -1, (0, 0, 255), 3)
cv2.imwrite(os.path.join(debug_dir, "03_cover_contour.png"), img_cover)
epsilon = 0.02 * cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, epsilon, True)
vertex_count = int(len(approx))
(_, _), (rw, rh), rangle = rect
rect_w_px = float(rw)
rect_h_px = float(rh)
rect_angle_deg = float(rangle)
long_side = max(rect_w_px, rect_h_px)
short_side = min(rect_w_px, rect_h_px)
aspect_ratio = long_side / short_side if short_side > 0 else 0.0
rect_area = rect_w_px * rect_h_px
area_ratio = area / rect_area if rect_area > 0 else 0.0
box = cv2.boxPoints(rect)
pts = [(float(p[0]) + mx, float(p[1]) + my) for p in box]
img_minarearect = img_color.copy()
shifted_box = box + [[mx, my]]
cv2.drawContours(img_minarearect, [shifted_box.astype(int)], -1, (255, 0, 255), 3)
cv2.imwrite(os.path.join(debug_dir, "05_minarearect.png"), img_minarearect)
img_candidates = img_color.copy()
for (_c, _r, _a) in filtered:
b = cv2.boxPoints(_r)
sb = b + [[mx, my]]
cv2.drawContours(img_candidates, [sb.astype(int)], -1, (0, 255, 255), 2)
chosen_box = cv2.boxPoints(rect)
chosen_shifted = chosen_box + [[mx, my]]
cv2.drawContours(img_candidates, [chosen_shifted.astype(int)], -1, (0, 0, 255), 3)
cv2.imwrite(os.path.join(debug_dir, "06_candidates.png"), img_candidates)
detected = True
tl = min(pts, key=lambda p: p[0] + p[1])
br = max(pts, key=lambda p: p[0] + p[1])
tr = max(pts, key=lambda p: p[0] - p[1])
bl = min(pts, key=lambda p: p[0] - p[1])
corners_px = [[tl[0], tl[1]], [tr[0], tr[1]], [br[0], br[1]], [bl[0], bl[1]]]
img_corners = img_color.copy()
corner_colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255)]
corner_labels = ["TL", "TR", "BR", "BL"]
for pt, color, label in zip(corners_px, corner_colors, corner_labels):
cx, cy = int(pt[0]), int(pt[1])
cv2.circle(img_corners, (cx, cy), 10, color, -1)
cv2.putText(img_corners, label, (cx + 15, cy), cv2.FONT_HERSHEY_SIMPLEX, 1.0, color, 2)
cv2.imwrite(os.path.join(debug_dir, "04_corners.png"), img_corners)
result = {
"set_dir": SET_DIR,
"detected": detected,
"vertex_count": vertex_count,
"corners_px": corners_px,
"contour_area_px": area,
"contours_total": contours_total,
"mat_bbox": mat_bbox,
"mat_contour_area_px": mat_area,
"canny_low": CANNY_LOW,
"canny_high": CANNY_HIGH,
"candidates_total": candidates_total,
"candidates_after_filter": candidates_after_filter,
"candidate_log": candidate_log,
"filter_expected_aspect": EXPECTED_ASPECT,
"filter_aspect_tol": ASPECT_TOL,
"filter_expected_long_px": EXPECTED_LONG_PX,
"filter_long_tol_ratio": LONG_TOL_RATIO,
"rect_w_px": rect_w_px,
"rect_h_px": rect_h_px,
"rect_angle_deg": rect_angle_deg,
"aspect_ratio": aspect_ratio,
"area_ratio": area_ratio,
"image_width": image_width,
"image_height": image_height,
"threshold_value": float(thresh_value),
"detected_at": datetime.now().isoformat(),
}
with open(out_path, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print("[OK] detected=" + str(detected))
print("[OK] vertex_count=" + str(vertex_count))
if detected:
print("[OK] corners_px=" + str(corners_px))
print("[OK] contour_area_px=" + str(area))
print("[OK] JSON: " + out_path)
print("[OK] debug: " + debug_dir)HoughLinesP は360本の直線を検出し、上位25本は全部マットの目盛りだった

輪郭が閉じないなら直線で取る。表紙の4辺は直線なので、途切れていても辺として存在するはずだった。
# detect_cover_edges_log07.py - HoughLinesP で表紙の辺候補を集め JSON に出す
# 輪郭が閉じないケース用。四隅の推定は行わず、直線の分類までを記録する
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, json, sys, os, math
import numpy as np
from datetime import datetime
HOUGH_THRESHOLD = 80
MIN_LINE_LENGTH = 150
MAX_LINE_GAP = 20
ANGLE_TOL = 20
if len(sys.argv) < 2:
sys.exit("[ABORT] 引数が必要です: set_dir")
SET_DIR = sys.argv[1]
CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log07"
OUT_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
in_path = os.path.join(CAPTURED_BASE, SET_DIR, "f0.png")
if not os.path.exists(in_path):
sys.exit("[ABORT] 入力が見つかりません: " + in_path)
os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, "edges_" + SET_DIR + ".json")
if os.path.exists(out_path):
sys.exit("[ABORT] 出力先に同名ファイルがあります: " + out_path)
debug_dir = os.path.join(OUT_DIR, "debug_edges_" + SET_DIR)
if os.path.exists(debug_dir):
sys.exit("[ABORT] debug ディレクトリが既にあります: " + debug_dir)
os.makedirs(debug_dir)
img = cv2.imread(in_path, cv2.IMREAD_GRAYSCALE)
if img is None:
sys.exit("[ABORT] 画像を読めません: " + in_path)
img_color = cv2.imread(in_path)
if img_color is None:
sys.exit("[ABORT] カラー画像を読めません: " + in_path)
_, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mat_binary = cv2.bitwise_not(binary)
mat_contours, _ = cv2.findContours(mat_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not mat_contours:
sys.exit("[ABORT] マット領域が見つかりません")
mat_contour = max(mat_contours, key=cv2.contourArea)
mx, my, mw, mh = cv2.boundingRect(mat_contour)
mat_bbox = [int(mx), int(my), int(mw), int(mh)]
roi = img[my:my + mh, mx:mx + mw]
blurred = cv2.GaussianBlur(roi, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150)
cv2.imwrite(os.path.join(debug_dir, "01_edges.png"), edges)
lines = cv2.HoughLinesP(
edges, 1, np.pi / 180,
threshold=HOUGH_THRESHOLD,
minLineLength=MIN_LINE_LENGTH,
maxLineGap=MAX_LINE_GAP,
)
lines_records = []
group_h_count = 0
group_v_count = 0
img_lines_all = img_color.copy()
img_lines_group = img_color.copy()
if lines is not None:
for line in lines:
x1, y1, x2, y2 = line[0]
length_px = float(math.hypot(x2 - x1, y2 - y1))
angle_deg = math.degrees(math.atan2(y2 - y1, x2 - x1)) % 180.0
if angle_deg < ANGLE_TOL or angle_deg > 180.0 - ANGLE_TOL:
group = "h"
group_h_count += 1
elif abs(angle_deg - 90.0) <= ANGLE_TOL:
group = "v"
group_v_count += 1
else:
group = "none"
gx1, gy1 = int(x1) + mx, int(y1) + my
gx2, gy2 = int(x2) + mx, int(y2) + my
cv2.line(img_lines_all, (gx1, gy1), (gx2, gy2), (255, 255, 255), 1)
if group == "h":
color = (0, 255, 0)
elif group == "v":
color = (0, 0, 255)
else:
color = (128, 128, 128)
cv2.line(img_lines_group, (gx1, gy1), (gx2, gy2), color, 2)
lines_records.append({
"x1": int(gx1),
"y1": int(gy1),
"x2": int(gx2),
"y2": int(gy2),
"length_px": length_px,
"angle_deg": angle_deg,
"group": group,
})
cv2.imwrite(os.path.join(debug_dir, "02_lines_all.png"), img_lines_all)
cv2.imwrite(os.path.join(debug_dir, "03_lines_group.png"), img_lines_group)
lines_records.sort(key=lambda r: r["length_px"], reverse=True)
lines_total = len(lines_records)
result = {
"set_dir": SET_DIR,
"mat_bbox": mat_bbox,
"lines_total": lines_total,
"group_h_count": group_h_count,
"group_v_count": group_v_count,
"hough_threshold": HOUGH_THRESHOLD,
"min_line_length": MIN_LINE_LENGTH,
"max_line_gap": MAX_LINE_GAP,
"angle_tol": ANGLE_TOL,
"lines": lines_records,
"detected_at": datetime.now().isoformat(),
}
with open(out_path, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print("[OK] lines_total=" + str(lines_total))
print("[OK] group_h=" + str(group_h_count) + " group_v=" + str(group_v_count))
print("[OK] JSON: " + out_path)
print("[OK] debug: " + debug_dir)結果。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/detect_cover_edges_log07.py flat_0_20260802_074143
[OK] lines_total=360
[OK] group_h=227 group_v=128
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/edges_flat_0_20260802_074143.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_edges_flat_0_20260802_074143
total 360 h 227 v 128
grp len ang x1 y1 x2 y2
h 918.0 0.0 487 903 1405 903
v 871.0 90.0 507 981 507 110
h 808.0 0.0 558 942 1366 942
h 777.0 0.0 780 917 1557 917
h 765.0 0.0 643 922 1408 922
h 720.0 0.0 799 434 1519 434
v 702.0 90.0 488 962 488 260
h 698.0 0.0 762 397 1460 397
v 681.0 90.0 683 980 683 299
v 681.0 90.0 763 980 763 299
v 678.0 90.0 685 980 685 302
v 677.0 90.0 741 980 741 303
v 677.0 90.0 724 980 724 303
v 676.0 90.0 702 980 702 304
v 676.0 90.0 705 980 705 304
v 674.0 90.0 585 980 585 306
v 672.0 90.0 605 980 605 308
v 672.0 90.0 608 980 608 308
v 670.0 90.0 588 980 588 310
v 661.0 90.0 666 980 666 319
v 661.0 90.0 663 980 663 319
v 660.0 90.0 644 980 644 320
v 660.0 90.0 646 980 646 320
v 657.0 90.0 469 767 469 110
v 657.0 90.0 508 767 508 110360本の上位25本が全部マットの目盛り。最長は 918px でマットを横断している。表紙の辺(451px と 338px)は1本も上位に入らない。
マットの格子は画面いっぱいに走るので、長さでは必ず目盛りが勝つ。長さ順に選ぶ限り、表紙は出てこない。
8回目に外部レビューへ相談した
ここで codex に相談した。8つの試行の内容と結果を全部渡して、筋の悪かった箇所と現実的な方法を聞いた。
返ってきた回答の結論は、アルゴリズムの改良ではなく実験設計の変更だった。
四隅検出アルゴリズムをこれ以上複雑にするより、真値を独立に作る実験設計へ切り替えるのが妥当です。今回の9セット程度なら、第一候補は「四隅を手動アノテーションする」です。
失敗の構造についての指摘が3つあった。
1つ目。二値化は「同程度の明るさを持つ連結領域」を作る処理であり、「物体の所属」を判定しない。表紙が背景と連結した時点で、この路線は終わっていた。2回目で諦めてよい方向だったという判断。
2つ目。minAreaRect が復元できるのは「入力点群を囲む最小矩形」であって「欠ける前の物体の外形」ではない。425×367px という結果は、アルゴリズムとしては点群を正しく囲んでいる。入力点群が表紙全体を表していないことが問題だった。
3つ目。Hough 変換は「長い直線」を見つけるが、「本の辺」を見つけるわけではない。格子を持つカッターマットは、Hough 変換にとって本よりはるかに強い対象になる。
まとめとして、筋が悪かったのは個々の OpenCV 関数ではなく、3回目以降も「単一画像の低水準な形状・明暗だけで意味的に表紙を識別する」という前提を維持した点だと指摘された。
そしてもう1つ、当初考えていた RANSAC 案も否定された。
RANSACで得たホモグラフィを、その同じマッチングの正誤判定に使うのは避けるべきです。
理由は2つ。固定カメラでマットが動いていないなら、マット上の正しい対応はほぼ恒等変換(何も変えない変換)に従う。一方、本が動いた画像間では本上の対応は別の変換に従う。これは単純な外れ値混入ではなく、2つの有効な平面モデルが競合する問題になる。RANSAC は「本らしい方」を選ぶのではなく、誤差閾値内で最大の合意集合を持つモデルを選ぶ。
もう1つは自己参照。同じ対応点集合から変換を推定してその対応点を評価すれば、「その対応点が互いに整合するか」は測れても「実際に同じ物理点か」は保証できない。格子のような反復模様では、間違った対応群が一貫した変換を構成することさえある。
さらに、mm↔px 変換という設計自体も過剰だと指摘された。傾斜した平面では画像全体に共通する単一の px/mm は存在しない。遠近投影により位置と方向で局所的な縮尺が変わる。必要なのはスカラーの換算率ではなくホモグラフィである。
7回の失敗のあいだ「1.88px/mm で換算して 451px」と計算し続けていたが、その前提が成り立つのは正対の画像だけだった。
手動クリックの揺れは平均2.71px・最大7.21px だった
四隅は手でクリックすることにした。画像を表示して、左上・右上・右下・左下の順に打つ。
# pick_cover_corners_log07.py - 撮影画像を表示し、人がクリックした四隅を JSON に保存する
# 表紙の四隅の真値は手動アノテーションで作る(評価対象と真値生成器を分離するため)
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, json, sys, os
from datetime import datetime
WIN_INIT_W = 1600
WIN_INIT_H = 900
if len(sys.argv) < 3:
sys.exit("[ABORT] 引数が必要です: set_dir round")
SET_DIR = sys.argv[1]
ROUND = sys.argv[2]
CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log07"
OUT_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
in_path = os.path.join(CAPTURED_BASE, SET_DIR, "f0.png")
if not os.path.exists(in_path):
sys.exit("[ABORT] 入力が見つかりません: " + in_path)
os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, "corners_manual_" + SET_DIR + "_" + ROUND + ".json")
if os.path.exists(out_path):
sys.exit("[ABORT] 出力先に同名 JSON があります: " + out_path)
debug_dir = os.path.join(OUT_DIR, "debug_manual_" + SET_DIR)
os.makedirs(debug_dir, exist_ok=True)
debug_png = os.path.join(debug_dir, "corners_" + ROUND + ".png")
if os.path.exists(debug_png):
sys.exit("[ABORT] 出力先に同名 PNG があります: " + debug_png)
img_color = cv2.imread(in_path)
if img_color is None:
sys.exit("[ABORT] 画像を読めません: " + in_path)
image_height, image_width = img_color.shape[:2]
corner_colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255)]
corner_labels = ["TL", "TR", "BR", "BL"]
clicks = []
def render(pts):
canvas = img_color.copy()
for i, (x, y) in enumerate(pts):
cv2.circle(canvas, (x, y), 6, corner_colors[i], -1)
cv2.putText(canvas, corner_labels[i], (x + 10, y), cv2.FONT_HERSHEY_SIMPLEX, 1.0, corner_colors[i], 2)
return canvas
def on_mouse(event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN:
if len(clicks) < 4:
clicks.append((int(x), int(y)))
print("[" + str(len(clicks)) + "/4] " + corner_labels[len(clicks) - 1] + " (" + str(x) + ", " + str(y) + ")")
win_name = SET_DIR + " - " + ROUND
cv2.namedWindow(win_name, cv2.WINDOW_NORMAL)
cv2.resizeWindow(win_name, WIN_INIT_W, WIN_INIT_H)
cv2.setMouseCallback(win_name, on_mouse)
print("左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止")
last_count = -1
while True:
if last_count != len(clicks):
cv2.imshow(win_name, render(clicks))
last_count = len(clicks)
key = cv2.waitKey(20) & 0xFF
if key == 255:
continue
if key == ord('u'):
if clicks:
popped = clicks.pop()
print("[UNDO] " + str(popped) + " を取り消し (残り " + str(len(clicks)) + "/4)")
last_count = -1
elif key == ord('r'):
if clicks:
clicks.clear()
print("[RESET] 全取消")
last_count = -1
elif key == ord('s'):
if len(clicks) == 4:
break
print("[WARN] まだ " + str(len(clicks)) + "/4 点です")
elif key == ord('q'):
print("[QUIT] 保存せず終了")
cv2.destroyAllWindows()
sys.exit(0)
cv2.destroyAllWindows()
corners_px = [[int(p[0]), int(p[1])] for p in clicks]
result = {
"set_dir": SET_DIR,
"round": ROUND,
"corners_px": corners_px,
"image_width": int(image_width),
"image_height": int(image_height),
"clicked_at": datetime.now().isoformat(),
}
with open(out_path, "w") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
debug_img = img_color.copy()
for i, (x, y) in enumerate(clicks):
cv2.circle(debug_img, (x, y), 6, corner_colors[i], -1)
cv2.putText(debug_img, corner_labels[i], (x + 10, y), cv2.FONT_HERSHEY_SIMPLEX, 1.0, corner_colors[i], 2)
for i in range(4):
p1 = clicks[i]
p2 = clicks[(i + 1) % 4]
cv2.line(debug_img, p1, p2, (255, 0, 255), 2)
cv2.imwrite(debug_png, debug_img)
print("[OK] corners_px=" + str(corners_px))
print("[OK] JSON: " + out_path)
print("[OK] debug: " + debug_png)8セットを2回ずつ打った。同じ画像を2回打つのは、真値そのものがどれだけ揺れるかを測るため。この揺れが分からないと、後で使う判定閾値に根拠が付かない。
r1 の全ログ。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_0_20260802_074143 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (773, 496)
[2/4] TR (1259, 497)
[3/4] BR (1257, 855)
[4/4] BL (770, 850)
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_15_20260802_080141 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (714, 505)
[2/4] TR (1216, 498)
[3/4] BR (1216, 850)
[4/4] BL (729, 859)
[OK] corners_px=[[714, 505], [1216, 498], [1216, 850], [729, 859]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_15_20260802_080141_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_15_20260802_080141/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_30_20260802_080340 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (693, 461)
[2/4] TR (1215, 459)
[3/4] BR (1200, 806)
[4/4] BL (713, 806)
[OK] corners_px=[[693, 461], [1215, 459], [1200, 806], [713, 806]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_30_20260802_080340_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_30_20260802_080340/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_45_20260802_080427 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (709, 549)
[2/4] TR (1249, 547)
[3/4] BR (1226, 870)
[4/4] BL (736, 876)
[OK] corners_px=[[709, 549], [1249, 547], [1226, 870], [736, 876]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_45_20260802_080427_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_45_20260802_080427/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_15_20260802_075620 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (736, 539)
[2/4] TR (1215, 454)
[3/4] BR (1273, 806)
[4/4] BL (796, 882)
[OK] corners_px=[[736, 539], [1215, 454], [1273, 806], [796, 882]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_15_20260802_075620_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_15_20260802_075620/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_30_20260802_075640 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (703, 588)
[2/4] TR (1165, 425)
[3/4] BR (1282, 762)
[4/4] BL (820, 922)
[OK] corners_px=[[703, 588], [1165, 425], [1282, 762], [820, 922]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_30_20260802_075640_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_30_20260802_075640/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_45_20260802_075700 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (693, 666)
[2/4] TR (1108, 409)
[3/4] BR (1297, 711)
[4/4] BL (885, 969)
[OK] corners_px=[[693, 666], [1108, 409], [1297, 711], [885, 969]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_45_20260802_075700_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_45_20260802_075700/corners_r1.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_far_20260802_075810 r1
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (786, 512)
[2/4] TR (1213, 511)
[3/4] BR (1213, 818)
[4/4] BL (785, 822)
[OK] corners_px=[[786, 512], [1213, 511], [1213, 818], [785, 822]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_far_20260802_075810_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_far_20260802_075810/corners_r1.png
r2 の全ログ。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_0_20260802_074143 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (772, 497)
[2/4] TR (1258, 500)
[3/4] BR (1256, 855)
[4/4] BL (770, 847)
[OK] corners_px=[[772, 497], [1258, 500], [1256, 855], [770, 847]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_0_20260802_074143_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_0_20260802_074143/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_15_20260802_080141 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (714, 509)
[2/4] TR (1216, 504)
[3/4] BR (1214, 849)
[4/4] BL (728, 857)
[OK] corners_px=[[714, 509], [1216, 504], [1214, 849], [728, 857]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_15_20260802_080141_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_15_20260802_080141/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_30_20260802_080340 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (692, 461)
[2/4] TR (1215, 462)
[3/4] BR (1200, 803)
[4/4] BL (711, 809)
[OK] corners_px=[[692, 461], [1215, 462], [1200, 803], [711, 809]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_30_20260802_080340_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_30_20260802_080340/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py tilt_45_20260802_080427 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (708, 551)
[2/4] TR (1249, 547)
[3/4] BR (1225, 872)
[4/4] BL (737, 880)
[OK] corners_px=[[708, 551], [1249, 547], [1225, 872], [737, 880]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_tilt_45_20260802_080427_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_tilt_45_20260802_080427/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_15_20260802_075620 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (736, 538)
[2/4] TR (1213, 457)
[3/4] BR (1273, 806)
[4/4] BL (792, 888)
[OK] corners_px=[[736, 538], [1213, 457], [1273, 806], [792, 888]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_15_20260802_075620_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_15_20260802_075620/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_30_20260802_075640 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (703, 588)
[2/4] TR (1162, 427)
[3/4] BR (1278, 760)
[4/4] BL (819, 924)
[OK] corners_px=[[703, 588], [1162, 427], [1278, 760], [819, 924]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_30_20260802_075640_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_30_20260802_075640/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_45_20260802_075700 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (693, 668)
[2/4] TR (1108, 411)
[3/4] BR (1294, 710)
[4/4] BL (881, 968)
[OK] corners_px=[[693, 668], [1108, 411], [1294, 710], [881, 968]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_45_20260802_075700_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_45_20260802_075700/corners_r2.png
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/pick_cover_corners_log07.py flat_far_20260802_075810 r2
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL (784, 511)
[2/4] TR (1210, 509)
[3/4] BR (1214, 820)
[4/4] BL (783, 820)
[OK] corners_px=[[784, 511], [1210, 509], [1214, 820], [783, 820]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/corners_manual_flat_far_20260802_075810_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/debug_manual_flat_far_20260802_075810/corners_r2.pngr1 と r2 の差を計算した。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 -c "
import json, math, glob, os
base='/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/'
sets=sorted(set(os.path.basename(p).replace('corners_manual_','').rsplit('_r',1)[0] for p in glob.glob(base+'corners_manual_*_r1.json')))
alld=[]
print('%-32s %6s %6s %6s %6s' % ('set','TL','TR','BR','BL'))
for s in sets:
a=json.load(open(base+'corners_manual_'+s+'_r1.json'))['corners_px']
b=json.load(open(base+'corners_manual_'+s+'_r2.json'))['corners_px']
ds=[math.dist(p,q) for p,q in zip(a,b)]
alld+=ds
print('%-32s %6.1f %6.1f %6.1f %6.1f' % (s,ds[0],ds[1],ds[2],ds[3]))
n=len(alld); m=sum(alld)/n
sd=(sum((x-m)**2 for x in alld)/n)**0.5
print()
print('n=%d mean=%.2f px sd=%.2f px max=%.2f px' % (n,m,sd,max(alld)))
"
set TL TR BR BL
flat_0_20260802_074143 1.4 3.2 1.0 3.0
flat_15_20260802_075620 1.0 3.6 0.0 7.2
flat_30_20260802_075640 0.0 3.6 4.5 2.2
flat_45_20260802_075700 2.0 2.0 3.2 4.1
flat_far_20260802_075810 2.2 3.6 2.2 2.8
tilt_15_20260802_080141 4.0 6.0 2.2 2.2
tilt_30_20260802_080340 1.0 3.0 3.0 3.6
tilt_45_20260802_080427 2.2 0.0 2.2 4.1
n=32 mean=2.71 px sd=1.57 px max=7.21 px平均 2.71px、標準偏差 1.57px、最大 7.21px。
2回目を打つ前は「ほぼ同じになるだろう」と考えていた。1〜2px を想定していたが、実測は倍近く出た。測らずに閾値3px を採用していたら、真値の揺れとほぼ同じ幅で判定することになっていた。
この実測から、正解判定の閾値を 8px に決めた。真値の揺れの最大値 7.21px を上回る値。これより小さいずれは、真値の精度では区別できない。
通過した2608件のうち、正しかったのは1595件だった
真値ホモグラフィを作って、各対応を判定するスクリプト。判定に使うのは手動四隅から作った変換だけ。RANSAC は別枠で、推定の評価としてのみ走らせる。
# evaluate_matches_log07.py - 手動四隅から真値ホモグラフィを作り、対応の正誤判定と統計を出す
# 評価対象(マッチング)と真値生成器(手動四隅)を分離する設計
# 判定に RANSAC は使わない。RANSAC は別途、推定の評価として走らせる
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, csv, sys, os, math, json
import numpy as np
from datetime import datetime
REPROJ_THRESH = 8.0
if len(sys.argv) < 4:
sys.exit("[ABORT] 引数が必要です: setA setB detector")
SET_A = sys.argv[1]
SET_B = sys.argv[2]
DETECTOR = sys.argv[3]
BASE = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
def load_corners(set_dir, round_id):
path = os.path.join(BASE, "corners_manual_" + set_dir + "_" + round_id + ".json")
if not os.path.exists(path):
sys.exit("[ABORT] 四隅 JSON が見つかりません: " + path)
with open(path) as f:
data = json.load(f)
return np.array(data["corners_px"], dtype=np.float64)
a_r1 = load_corners(SET_A, "r1")
a_r2 = load_corners(SET_A, "r2")
b_r1 = load_corners(SET_B, "r1")
b_r2 = load_corners(SET_B, "r2")
corners_A = ((a_r1 + a_r2) / 2.0).astype(np.float32)
corners_B = ((b_r1 + b_r2) / 2.0).astype(np.float32)
H_gt = cv2.getPerspectiveTransform(corners_A, corners_B)
H_gt_inv = np.linalg.inv(H_gt)
matches_csv = os.path.join(BASE, "matches_" + SET_A + "__" + SET_B + "_" + DETECTOR + ".csv")
if not os.path.exists(matches_csv):
sys.exit("[ABORT] matches CSV が見つかりません: " + matches_csv)
eval_csv = os.path.join(BASE, "eval_" + SET_A + "__" + SET_B + "_" + DETECTOR + ".csv")
if os.path.exists(eval_csv):
sys.exit("[ABORT] 出力先に同名ファイルがあります: " + eval_csv)
summary_csv = os.path.join(BASE, "eval_summary.csv")
def project(H, x, y):
v = H @ np.array([x, y, 1.0])
return v[0] / v[2], v[1] / v[2]
rows = []
raw_matches = 0
ratio_pass_count = 0
correct_all = 0
correct_ratio_pass = 0
errs_correct = []
pts_a = []
pts_b = []
ransac_pair_ids = []
with open(matches_csv) as f:
reader = csv.DictReader(f)
for row in reader:
pair_id = int(row["pair_id"])
ax = float(row["ax_px"])
ay = float(row["ay_px"])
bx = float(row["bx_px"])
by = float(row["by_px"])
ratio = float(row["ratio"])
ratio_pass = int(row["ratio_pass"])
bx_est, by_est = project(H_gt, ax, ay)
e_fwd = math.hypot(bx - bx_est, by - by_est)
ax_est, ay_est = project(H_gt_inv, bx, by)
e_bwd = math.hypot(ax - ax_est, ay - ay_est)
e_sym = math.sqrt(e_fwd * e_fwd + e_bwd * e_bwd)
correct = 1 if e_sym <= REPROJ_THRESH else 0
raw_matches += 1
if ratio_pass == 1:
ratio_pass_count += 1
pts_a.append([ax, ay])
pts_b.append([bx, by])
ransac_pair_ids.append(pair_id)
if correct == 1:
correct_all += 1
errs_correct.append(e_sym)
if ratio_pass == 1 and correct == 1:
correct_ratio_pass += 1
rows.append({
"pair_id": pair_id,
"detector": DETECTOR,
"setA": SET_A,
"setB": SET_B,
"ax_px": ax,
"ay_px": ay,
"bx_px": bx,
"by_px": by,
"ratio": ratio,
"ratio_pass": ratio_pass,
"e_fwd": e_fwd,
"e_bwd": e_bwd,
"e_sym": e_sym,
"correct": correct,
"ransac_inlier": "",
})
precision_raw = correct_all / raw_matches if raw_matches > 0 else 0.0
precision_ratio = correct_ratio_pass / ratio_pass_count if ratio_pass_count > 0 else 0.0
if errs_correct:
err_median_correct = float(np.median(errs_correct))
err_p90_correct = float(np.percentile(errs_correct, 90))
else:
err_median_correct = ""
err_p90_correct = ""
ransac_inliers = ""
ransac_precision = ""
corner_error_px = ""
if len(pts_a) >= 4:
arr_a = np.array(pts_a, dtype=np.float32)
arr_b = np.array(pts_b, dtype=np.float32)
H_est, mask = cv2.findHomography(arr_a, arr_b, cv2.RANSAC, 5.0)
if H_est is not None and mask is not None:
mask_flat = mask.flatten()
ransac_inliers = int(mask_flat.sum())
inlier_map = {ransac_pair_ids[i]: int(mask_flat[i]) for i in range(len(ransac_pair_ids))}
for r in rows:
if r["pair_id"] in inlier_map:
r["ransac_inlier"] = inlier_map[r["pair_id"]]
else:
r["ransac_inlier"] = 0
inlier_correct = sum(1 for r in rows if r["ransac_inlier"] == 1 and r["correct"] == 1)
ransac_precision = inlier_correct / ransac_inliers if ransac_inliers > 0 else 0.0
gt_pts = []
est_pts = []
for pt in corners_A:
gx, gy = project(H_gt, float(pt[0]), float(pt[1]))
ex, ey = project(H_est, float(pt[0]), float(pt[1]))
gt_pts.append((gx, gy))
est_pts.append((ex, ey))
dists = [math.hypot(g[0] - e[0], g[1] - e[1]) for g, e in zip(gt_pts, est_pts)]
corner_error_px = float(np.mean(dists))
eval_header = [
"pair_id", "detector", "setA", "setB", "ax_px", "ay_px", "bx_px", "by_px",
"ratio", "ratio_pass", "e_fwd", "e_bwd", "e_sym", "correct", "ransac_inlier",
]
with open(eval_csv, "w", newline="") as f:
w = csv.writer(f)
w.writerow(eval_header)
for r in rows:
w.writerow([r[k] for k in eval_header])
summary_header = [
"setA", "setB", "detector", "reproj_thresh", "raw_matches", "ratio_pass_count",
"correct_all", "correct_ratio_pass", "precision_raw", "precision_ratio",
"err_median_correct", "err_p90_correct", "ransac_inliers", "ransac_precision",
"corner_error_px", "evaluated_at",
]
summary_row = [
SET_A, SET_B, DETECTOR, REPROJ_THRESH, raw_matches, ratio_pass_count,
correct_all, correct_ratio_pass, precision_raw, precision_ratio,
err_median_correct, err_p90_correct, ransac_inliers, ransac_precision,
corner_error_px, datetime.now().isoformat(),
]
if os.path.exists(summary_csv):
with open(summary_csv) as f:
first = f.readline().rstrip("\r\n")
expected = ",".join(summary_header)
if first != expected:
sys.exit("[ABORT-APPEND] eval_summary.csv のヘッダが一致しません: " + summary_csv)
with open(summary_csv, "a", newline="") as f:
w = csv.writer(f)
w.writerow(summary_row)
else:
with open(summary_csv, "w", newline="") as f:
w = csv.writer(f)
w.writerow(summary_header)
w.writerow(summary_row)
print("[OK] raw_matches=" + str(raw_matches))
print("[OK] ratio_pass_count=" + str(ratio_pass_count))
print("[OK] correct_all=" + str(correct_all))
print("[OK] correct_ratio_pass=" + str(correct_ratio_pass))
print("[OK] precision_raw=" + str(precision_raw))
print("[OK] precision_ratio=" + str(precision_ratio))
print("[OK] err_median_correct=" + str(err_median_correct))
print("[OK] err_p90_correct=" + str(err_p90_correct))
print("[OK] ransac_inliers=" + str(ransac_inliers))
print("[OK] ransac_precision=" + str(ransac_precision))
print("[OK] corner_error_px=" + str(corner_error_px))
print("[OK] per-match CSV: " + eval_csv)
print("[OK] summary CSV: " + summary_csv)判定に使っているのは双方向の transfer error。1枚目の点を変換で2枚目に移した位置と、マッチングが言う点との距離(e_fwd)。逆に2枚目の点を逆変換で1枚目に戻した位置と、元の点との距離(e_bwd)。両方を二乗和の平方根で合成した e_sym を判定に使う。片方向だけだと画像の端で誤差の出方が偏るため。
14組の実行ログを全量載せる。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/evaluate_matches_log07.py flat_0_20260802_074143 tilt_15_20260802_080141 orb
[OK] raw_matches=5000
[OK] ratio_pass_count=2608
[OK] correct_all=2557
[OK] correct_ratio_pass=1595
[OK] precision_raw=0.5114
[OK] precision_ratio=0.611579754601227
[OK] err_median_correct=3.1012361388687326
[OK] err_p90_correct=5.577199027370589
[OK] ransac_inliers=1598
[OK] ransac_precision=0.9937421777221527
[OK] corner_error_px=2.5206396158366213
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_15_20260802_080141_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=1037
[OK] correct_all=645
[OK] correct_ratio_pass=590
[OK] precision_raw=0.25769077107471033
[OK] precision_ratio=0.5689488910318226
[OK] err_median_correct=2.224407639708051
[OK] err_p90_correct=4.175023983674551
[OK] ransac_inliers=589
[OK] ransac_precision=1.0
[OK] corner_error_px=2.478604959988808
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_15_20260802_080141_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=2361
[OK] correct_all=2315
[OK] correct_ratio_pass=1338
[OK] precision_raw=0.463
[OK] precision_ratio=0.5667090216010165
[OK] err_median_correct=4.634452368193363
[OK] err_p90_correct=6.843872455478599
[OK] ransac_inliers=1372
[OK] ransac_precision=0.9715743440233237
[OK] corner_error_px=3.059314130074033
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_30_20260802_080340_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=972
[OK] correct_all=587
[OK] correct_ratio_pass=540
[OK] precision_raw=0.23451857770675189
[OK] precision_ratio=0.5555555555555556
[OK] err_median_correct=3.7510006174975707
[OK] err_p90_correct=5.856670914180797
[OK] ransac_inliers=543
[OK] ransac_precision=0.994475138121547
[OK] corner_error_px=2.8359516608454767
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_30_20260802_080340_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=1879
[OK] correct_all=935
[OK] correct_ratio_pass=399
[OK] precision_raw=0.187
[OK] precision_ratio=0.2123469930814263
[OK] err_median_correct=6.069035762478462
[OK] err_p90_correct=7.662739745484409
[OK] ransac_inliers=875
[OK] ransac_precision=0.0
[OK] corner_error_px=55.04942206779499
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_45_20260802_080427_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=950
[OK] correct_all=331
[OK] correct_ratio_pass=274
[OK] precision_raw=0.13224131042748702
[OK] precision_ratio=0.28842105263157897
[OK] err_median_correct=6.515657756552135
[OK] err_p90_correct=7.659913042374021
[OK] ransac_inliers=466
[OK] ransac_precision=0.5793991416309013
[OK] corner_error_px=4.62661222871924
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__tilt_45_20260802_080427_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=2778
[OK] correct_all=2681
[OK] correct_ratio_pass=1725
[OK] precision_raw=0.5362
[OK] precision_ratio=0.6209503239740821
[OK] err_median_correct=2.112202616310683
[OK] err_p90_correct=4.599748800362189
[OK] ransac_inliers=1724
[OK] ransac_precision=0.9976798143851509
[OK] corner_error_px=1.873386564308005
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_15_20260802_075620_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=1083
[OK] correct_all=663
[OK] correct_ratio_pass=606
[OK] precision_raw=0.26488214143028366
[OK] precision_ratio=0.5595567867036011
[OK] err_median_correct=1.3337715520966518
[OK] err_p90_correct=2.481614621994593
[OK] ransac_inliers=606
[OK] ransac_precision=1.0
[OK] corner_error_px=1.7412013230995536
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_15_20260802_075620_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=2677
[OK] correct_all=2604
[OK] correct_ratio_pass=1620
[OK] precision_raw=0.5208
[OK] precision_ratio=0.6051550242809115
[OK] err_median_correct=2.364938507777297
[OK] err_p90_correct=5.157241196324834
[OK] ransac_inliers=1621
[OK] ransac_precision=0.9975323874151758
[OK] corner_error_px=2.805419833978557
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_30_20260802_075640_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=1017
[OK] correct_all=606
[OK] correct_ratio_pass=552
[OK] precision_raw=0.24210946863763483
[OK] precision_ratio=0.5427728613569321
[OK] err_median_correct=1.888816056064604
[OK] err_p90_correct=3.713391824474523
[OK] ransac_inliers=553
[OK] ransac_precision=0.9981916817359855
[OK] corner_error_px=2.809484475398533
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_30_20260802_075640_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=2598
[OK] correct_all=2475
[OK] correct_ratio_pass=1539
[OK] precision_raw=0.495
[OK] precision_ratio=0.5923787528868361
[OK] err_median_correct=3.0154331466286037
[OK] err_p90_correct=5.534939007186762
[OK] ransac_inliers=1544
[OK] ransac_precision=0.9922279792746114
[OK] corner_error_px=2.869579730831627
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_45_20260802_075700_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=1054
[OK] correct_all=656
[OK] correct_ratio_pass=583
[OK] precision_raw=0.26208549740311626
[OK] precision_ratio=0.5531309297912713
[OK] err_median_correct=2.4874012856269863
[OK] err_p90_correct=3.770220575755781
[OK] ransac_inliers=585
[OK] ransac_precision=0.9965811965811966
[OK] corner_error_px=2.4730199054576043
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_45_20260802_075700_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=1686
[OK] correct_all=2370
[OK] correct_ratio_pass=1118
[OK] precision_raw=0.474
[OK] precision_ratio=0.6631079478054567
[OK] err_median_correct=2.768222666860652
[OK] err_p90_correct=5.465151753147055
[OK] ransac_inliers=1354
[OK] ransac_precision=0.6624815361890695
[OK] corner_error_px=5.1060184392388575
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_far_20260802_075810_orb.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=819
[OK] correct_all=507
[OK] correct_ratio_pass=465
[OK] precision_raw=0.20255693168198163
[OK] precision_ratio=0.5677655677655677
[OK] err_median_correct=1.8482923891349885
[OK] err_p90_correct=4.052256465134638
[OK] ransac_inliers=467
[OK] ransac_precision=0.9957173447537473
[OK] corner_error_px=1.8007519807309864
[OK] per-match CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_flat_0_20260802_074143__flat_far_20260802_075810_sift.csv
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/eval_summary.csv14組を表にまとめる。
セット | 検出器 | 通過数 | 正解数 | precision | RANSAC後 | corner_err (px) |
|---|---|---|---|---|---|---|
tilt_15 | ORB | 2608 | 1595 | 0.612 | 0.994 | 2.52 |
tilt_15 | SIFT | 1037 | 590 | 0.569 | 1.000 | 2.48 |
tilt_30 | ORB | 2361 | 1338 | 0.567 | 0.972 | 3.06 |
tilt_30 | SIFT | 972 | 540 | 0.556 | 0.994 | 2.84 |
tilt_45 | ORB | 1879 | 399 | 0.212 | 0.000 | 55.05 |
tilt_45 | SIFT | 950 | 274 | 0.288 | 0.579 | 4.63 |
flat_15 | ORB | 2778 | 1725 | 0.621 | 0.998 | 1.87 |
flat_15 | SIFT | 1083 | 606 | 0.560 | 1.000 | 1.74 |
flat_30 | ORB | 2677 | 1620 | 0.605 | 0.998 | 2.81 |
flat_30 | SIFT | 1017 | 552 | 0.543 | 0.998 | 2.81 |
flat_45 | ORB | 2598 | 1539 | 0.592 | 0.992 | 2.87 |
flat_45 | SIFT | 1054 | 583 | 0.553 | 0.997 | 2.47 |
flat_far | ORB | 1686 | 1118 | 0.663 | 0.662 | 5.11 |
flat_far | SIFT | 819 | 465 | 0.568 | 0.996 | 1.80 |
一番上の行が最初に出た数字だった。通過した2608件のうち、正しかったのは1595件。precision 0.612。4割は誤対応である。
同時に、corner_error_px が 2.52 という数字も出た。マッチングから推定した変換で表紙の四隅を移すと、真値の位置から平均 2.52px しかずれない。手動クリックの揺れが平均 2.71px なので、推定の誤差が人手の誤差を下回っている。7回の自動検出に失敗して手作業に切り替えたが、その手作業より、マッチングのほうが正確に面の位置を出していた。
面内回転では precision が 0.621 から 0.592 にしか落ちない
flat 群(本をマットの上で回転させたセット)を縦に読む。ORB で 15度 0.621、30度 0.605、45度 0.592。45度まで回しても 3ポイントしか落ちない。SIFT も 0.560 → 0.543 → 0.553 でほぼ横ばい。
ORB も SIFT も回転不変性を持つように設計されている。特徴点を検出したとき、その点の周りの明るさの分布から「この点の向き」を決め、その向きを基準にして記述子を作る。だから画像が回っても、記述子は同じ値になる。実測がそれを裏付けた。
tilt 群(本の片側を持ち上げて傾けたセット)は違う。ORB で 15度 0.612、30度 0.567、45度 0.212。45度で3分の1に落ちる。
傾けると面が台形に潰れる。奥側は縮み、手前側は伸びる。点の周りの模様そのものが変形するので、向きを揃えても記述子が一致しない。回転不変性では吸収できない変化になる。
flat_far(距離を変えたセット)は ORB 0.663 で、むしろ tilt_15 より高い。ただし raw の通過数が 1686 と少ない。大きさが変わると比率テストを通る数は減るが、通ったものの質は落ちていない。
tilt_45 の ORB で RANSAC が完全に破綻し、四隅が55px ずれた
表の中で1行だけ異常な値が並んでいる。
tilt_45 ORB 通過1879 正解399 precision 0.212 RANSAC後 0.000 corner_err 55.05RANSAC 後の precision がゼロ。inlier として875件が選ばれているのに、そのうち真値で正しいと判定されたものが1件もない。四隅を推定変換で移すと平均55px ずれる。
同じ画像で SIFT は corner_error 4.63px。破綻していない。
何が起きたかを確かめるため、真値を使わず RANSAC だけを走らせるスクリプトを追加した。推定された変換行列の中身を見るためである。
# ransac_only_log07.py - 真値ホモグラフィを使わず RANSAC だけを走らせて inlier 数を出す
# 本が写っていないセットでも inlier が出ることを直接示すための計測器
# アーム・daemon には触らない読み取り専用スクリプト
import cv2, csv, sys, os
import numpy as np
from datetime import datetime
if len(sys.argv) < 4:
sys.exit("[ABORT] 引数が必要です: setA setB detector")
SET_A = sys.argv[1]
SET_B = sys.argv[2]
DETECTOR = sys.argv[3]
BASE = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07"
def ensure_ssd_mounted():
if not os.path.ismount("/Volumes/KIOXIA"):
sys.exit("[ABORT] KIOXIA が未マウント。ローカルには書きません。")
ensure_ssd_mounted()
matches_csv = os.path.join(BASE, "matches_" + SET_A + "__" + SET_B + "_" + DETECTOR + ".csv")
if not os.path.exists(matches_csv):
sys.exit("[ABORT] matches CSV が見つかりません: " + matches_csv)
summary_csv = os.path.join(BASE, "ransac_only_summary.csv")
raw_matches = 0
pts_a = []
pts_b = []
with open(matches_csv) as f:
reader = csv.DictReader(f)
for row in reader:
raw_matches += 1
if int(row["ratio_pass"]) == 1:
pts_a.append([float(row["ax_px"]), float(row["ay_px"])])
pts_b.append([float(row["bx_px"]), float(row["by_px"])])
ratio_pass_count = len(pts_a)
ransac_inliers = ""
inlier_ratio = ""
h_est_found = 0
h_est_flat = ""
if ratio_pass_count >= 4:
arr_a = np.array(pts_a, dtype=np.float32)
arr_b = np.array(pts_b, dtype=np.float32)
H_est, mask = cv2.findHomography(arr_a, arr_b, cv2.RANSAC, 5.0)
if H_est is not None:
h_est_found = 1
h_est_flat = ";".join(str(float(v)) for v in H_est.flatten())
if mask is not None:
mask_flat = mask.flatten()
ransac_inliers = int(mask_flat.sum())
inlier_ratio = ransac_inliers / ratio_pass_count if ratio_pass_count > 0 else 0.0
summary_header = [
"setA", "setB", "detector", "raw_matches", "ratio_pass_count",
"ransac_inliers", "inlier_ratio", "h_est_found", "h_est_flat", "evaluated_at",
]
summary_row = [
SET_A, SET_B, DETECTOR, raw_matches, ratio_pass_count,
ransac_inliers, inlier_ratio, h_est_found, h_est_flat, datetime.now().isoformat(),
]
if os.path.exists(summary_csv):
with open(summary_csv) as f:
first = f.readline().rstrip("\r\n")
expected = ",".join(summary_header)
if first != expected:
sys.exit("[ABORT-APPEND] ransac_only_summary.csv のヘッダが一致しません: " + summary_csv)
with open(summary_csv, "a", newline="") as f:
w = csv.writer(f)
w.writerow(summary_row)
else:
with open(summary_csv, "w", newline="") as f:
w = csv.writer(f)
w.writerow(summary_header)
w.writerow(summary_row)
print("[OK] raw_matches=" + str(raw_matches))
print("[OK] ratio_pass_count=" + str(ratio_pass_count))
print("[OK] ransac_inliers=" + str(ransac_inliers))
print("[OK] inlier_ratio=" + str(inlier_ratio))
print("[OK] h_est_found=" + str(h_est_found))
print("[OK] h_est_flat=" + str(h_est_flat))
print("[OK] summary CSV: " + summary_csv)
4本の実行ログ。
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/ransac_only_log07.py flat_0_20260802_074143 mat_0_20260802_080524 orb
[OK] raw_matches=5000
[OK] ratio_pass_count=934
[OK] ransac_inliers=867
[OK] inlier_ratio=0.9282655246252677
[OK] h_est_found=1
[OK] h_est_flat=1.0106497250013238;0.010028896300691014;-3.8260561420424524;0.0004172191253912504;1.010628093978199;-1.924724276696593;5.161229691166665e-06;1.540198173033442e-05;1.0
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/ransac_only_summary.csv
[OK] raw_matches=2503
[OK] ratio_pass_count=436
[OK] ransac_inliers=337
[OK] inlier_ratio=0.7729357798165137
[OK] h_est_found=1
[OK] h_est_flat=0.9995031392235104;0.00026987706748335144;1.1653553928001512;-0.00013101286833195323;0.999850236612416;-0.784731714400223;-1.0423998443355849e-07;1.9361284843367394e-07;1.0
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/ransac_only_summary.csv
(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log07 % python3 ~/r2r2r-scripts/ransac_only_log07.py flat_0_20260802_074143 tilt_45_20260802_080427 orb
[OK] raw_matches=5000
[OK] ratio_pass_count=1879
[OK] ransac_inliers=875
[OK] inlier_ratio=0.4656732304417243
[OK] h_est_found=1
[OK] h_est_flat=0.9940094540250924;-0.008117501325698967;2.6875049791329393;-0.0014081254959660042;0.9914421401316396;0.22182624644938867;-3.957189922083577e-06;-1.2674760615929608e-05;1.0
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/ransac_only_summary.csv
[OK] raw_matches=5000
[OK] ratio_pass_count=2608
[OK] ransac_inliers=1598
[OK] inlier_ratio=0.6127300613496932
[OK] h_est_found=1
[OK] h_est_flat=1.0851400534894104;0.08615489879754522;-132.79871170739827;-0.016596332223940642;1.099052539917087;-3.3079859745257854;1.1293710917350547e-05;7.05222131018809e-05;1.0
[OK] summary CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log07/ransac_only_summary.csv
変換行列の主要な値を並べる。対角の2つは拡大率、3列目の上2つは平行移動量にあたる。
組 | 対角(1,1) | 対角(2,2) | 平行移動x | 平行移動y | 判定 |
|---|---|---|---|---|---|
tilt_15 ORB | 1.0851 | 1.0991 | -132.80 | -3.31 | 正常 |
tilt_45 ORB | 0.9940 | 0.9914 | 2.69 | 0.22 | 破綻 |
mat_0 ORB | 1.0106 | 1.0106 | -3.83 | -1.92 | ― |
mat_0 SIFT | 0.9995 | 0.9999 | 1.17 | -0.78 | ― |
破綻した tilt_45 の ORB は、対角が 0.994 / 0.991、平行移動が 2.69 / 0.22。恒等変換(対角が1で他がゼロの、何も変えない変換)にほぼ一致している。本は45度傾いているのに、推定された変換は「何も動いていない」と言っている。
正常だった tilt_15 の ORB は、対角 1.085 / 1.099、平行移動 -132.80。明確に恒等ではない。
これで破綻の正体が分かる。画像にはマットの面と表紙の面が両方写っている。本だけを傾けたので、表紙の点は動き、マットの点は動いていない。マット上の対応は恒等変換に従い、表紙上の対応は別の変換に従う。2つの有効な平面モデルが競合している。
RANSAC は「本らしい方」を選ぶ仕組みを持っていない。誤差閾値内で最大の合意集合を持つモデルを選ぶだけである。本を大きく傾けると表紙側の対応が減り、マット側が多数派になる。すると恒等変換が採用される。
codex が事前に指摘していたとおりの現象だった。
本が写っていない写真とも867件が「合った」
mat_0 は本を退けてマットだけを撮ったセットである。基準画像 flat_0 との間に、正しい対応は原理的に存在しない。同じものが写っていない。
それでも ORB で934件が比率テストを通過し、そのうち867件(92.8%)が RANSAC の inlier になった。
inlier 率 0.928 は、正しく撮った8組を含む全組の中で最高値である。tilt_15 の 0.613、tilt_45 の 0.466 を大きく上回る。
組 | 通過数 | inlier | inlier率 |
|---|---|---|---|
mat_0 ORB | 934 | 867 | 0.928 |
mat_0 SIFT | 436 | 337 | 0.773 |
tilt_15 ORB | 2608 | 1598 | 0.613 |
tilt_45 ORB | 1879 | 875 | 0.466 |
理由は tilt_45 の破綻と同じである。マットは動いていないので、マット上の点は完璧に恒等変換に従う。変換行列も対角 1.0106 / 1.0106、平行移動 -3.83 / -1.92 で、ほぼ恒等になっている。
正しい対応が1件も無い組が、inlier 率で最高点を取る。数字だけを見て判断していたら、最良の結果と読んでいた。
4つの結論と、Log#8 に渡すもの
実測から言えることを並べる。
1. 比率テストの通過数は正しさを示さない。 mat_0 で934件が通過した。同じものが写っていない2枚である。
2. RANSAC の inlier 数も正しさを示さない。 mat_0 で867件、inlier 率 92.8%。全14組で最高値だった。
3. 破綻は「別の平面が多数派になる」形で起きる。 兆候は推定された変換行列に出る。恒等変換に近ければ、動いていない面(マット)を追っている。tilt_45 の ORB は対角 0.994/0.991、平行移動 2.69px。正常な tilt_15 は対角 1.085/1.099、平行移動 -132.80px。
4. 面内回転には強く、傾きには弱い。 回転は 0.621 → 0.592(45度でも3ポイント減)。傾きは 0.612 → 0.212(45度で3分の1)。45度で ORB は破綻し(corner_error 55.05px)、SIFT は持ちこたえた(4.63px)。
設計上の学びが2つある。
ひとつは、採点基準を採点される側から作ってはいけないということ。RANSAC の結果で正誤を判定しようとしていたが、それでは inlier 875件を成功と読み、55px のずれに気づかない。手動四隅という独立した経路を持ったから、破綻が見えた。
もうひとつは、自動検出に7回失敗した理由。マットは表紙とほぼ同じ性質を全部持っている。直線で構成され、四角形で、明暗のコントラストがあり、画面内に大きく写っている。同じ性質を持つものを、性質で区別しようとしていた。
Log#8 は射影変換で、斜めから撮った平面を真上から見た座標に直す。入力になるのは Log#7 で作った対応点である。保存形式は eval_{setA}__{setB}_{detector}.csv に決まった。列に correct(真値による正誤判定)と ransac_inlier を持っているので、Log#8 は correct=1 の行だけを読めばよい。
未測定のまま残ったものもある。傾き 15/30/45 は狙い値で、実測角度を出していない。真値ホモグラフィから逆算できるので、Log#8 で扱う。
