画像認識と物体検出を学び直す Log#8 | 斜めから撮った紙を真上から見た形に直す ― 傾きを調べにいって物差しの誤差を見つけた

斜めから撮った写真は、写っている物の形が歪む。四角い紙が台形に写る。この歪みを計算で直して、真上から撮ったのと同じ画像を作れるか。それが今回の主題になる。

やってみた結果、狙っていた「傾きが大きいほど測定がずれる」は起きなかった。代わりに、前回までずっと乗っていた測り方そのものの誤差が出てきた。赤い図形の輪郭が、図形の大きさによらず 0.41〜0.48mm の一定幅で内側に削れている。この削れが面積の誤差として 13〜15% を占めていて、傾きの影響はその下に埋もれていた。

傾いた平面には共通の px/mm が存在しないだった

Log#6 では、写真から図形の大きさを測るために「1mm が何ピクセルか」という換算率を使った。実測で 1.8809〜1.8885 px/mm という値が出て、紙の置き位置で少し動くことも分かった。

この換算率は、真上から撮ったときにしか使えない。斜めから撮ると、手前と奥で1mmあたりのピクセル数が変わる。遠くにある部分ほど小さく写るからで、ひとつの数字では表せない。Log#7 の最後に外部レビュー(codex)から受けた指摘がこれだった。

必要なのはスカラーの換算率ではなく、ホモグラフィ(平面と平面の対応を表す3×3の行列。斜めから見た平面上の点が、真上から見たときのどこに来るかを一括で決める)である、という指摘。

そこで先に、斜めの写真を真上から見た形に直す手順を用意する。直してしまえば、そこから先は Log#6 で使った換算率がそのまま使える。これが第2部「位置を合わせる」の2本目にあたる。

撮影対象に Log#6 の印刷図形シートを選んだ理由

計画表の指定は「格子模様を斜めから」だった。ただし作業机のカッターマットは貼り付けてあり、傾けられない。選択肢は3つあった。

案

内容

真値の用意

A

Log#7 の tilt セット(本の表紙)をそのまま使う

既にある

B

格子を印刷した紙を新規に撮る

新規に作る必要がある

C

Log#6 の印刷図形シートを傾けて撮る

既にある

案Cを採った。理由は3つ。

真値(正しい寸法)が既に手元にあること。log06_shapes_truth.csv に20図形の設計寸法が入っていて、A4 300dpi・100%印刷を定規で実測確認済み(100.0mm リファレンス線)。新しく真値を作る工程が要らない。

Log#6 の測定結果と直接つなげられること。同じ図形を、正対で測った値と、斜めから撮って直した値で比べられる。

そして Log#7 の教訓に沿うこと。Log#7 では、採点基準を採点される側から作ろうとして失敗した。RANSAC の inlier 数(変換に合致すると判定された対応点の数)を成功の証拠に使いかけて、55px ずれた結果を成功と読みかけた。今回は、変換を作る経路と正しさを測る経路を最初から分ける。

具体的には、紙の四隅で変換を作り、紙の中の図形で採点する。四隅は変換の材料、図形は採点対象。両者は別の場所にあるので、変換がうまくいっていなければ図形の位置が合わなくなる。

撮影スクリプト capture_top_frames_log08.py と4条件

撮影は Log#7 版の派生で、保存先と meta.json の項目だけを変えた。傾きの条件(どの辺を持ち上げたか、何を挟んだか、その厚みは何mmか)を meta に残す形にしてある。

tilt_target_deg というキー名にしているのは、これが狙い値であって実測ではないことを名前で示すため。Log#7 で flat_15 というラベルが面内回転なのか傾きなのか後から分からなくなった経緯があり、同じ型の事故を防いでいる。

以下が撮影スクリプトの全文。

python

# capture_top_frames_log08.py - 上カメラから連続5フレームをPNGで保存する
# capture_top_frames_log07.py の Log08 派生版。
# 保存先を Log08/{label}_{YYYYMMDD_HHMMSS}/ サブディレクトリに変更。
# meta.json の被写体項目を log06_shapes_red シート + 傾斜(raise)条件に置き換え。
# アーム・daemon には触らない読み取り専用スクリプト

import cv2, json, sys, os, time, argparse
from datetime import datetime

parser = argparse.ArgumentParser(
    description="上カメラから連続5フレームをPNG保存する (Log08)"
)
parser.add_argument("--label", required=True,
                    choices=["flat_0", "tilt_15", "tilt_30", "tilt_45"],
                    help="計測条件ラベル")
parser.add_argument("--raise-side", choices=["top", "bottom", "left", "right"],
                    help="シートを持ち上げた辺。flat_0 以外では必須")
parser.add_argument("--raise-object", help="持ち上げに使った物体名。flat_0 以外では必須")
parser.add_argument("--raise-height-mm", type=float,
                    help="持ち上げ高さ(mm)。flat_0 以外では必須")
args = parser.parse_args()

LABEL = args.label
RAISE_SIDE = args.raise_side
RAISE_OBJECT = args.raise_object
RAISE_HEIGHT_MM = args.raise_height_mm

if LABEL != "flat_0":
    missing = []
    if RAISE_SIDE is None:
        missing.append("--raise-side")
    if RAISE_OBJECT is None:
        missing.append("--raise-object")
    if RAISE_HEIGHT_MM is None:
        missing.append("--raise-height-mm")
    if missing:
        sys.exit("[ABORT] label=" + LABEL + " では raise 系引数が必須です。不足: " + ", ".join(missing))

TILT_TARGET_DEG = int(LABEL.split("_")[1])

BASE_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log08"
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)",
    "sheet": "log06_shapes_red",
    "sheet_w_mm": 210,
    "sheet_h_mm": 297,
    "print_scale_verified_mm": 100.0,
    "tilt_target_deg": TILT_TARGET_DEG,
    "raise_side": RAISE_SIDE,
    "raise_object": RAISE_OBJECT,
    "raise_height_mm": RAISE_HEIGHT_MM,
    "note": "cv2.VideoCapture から取得した BGR フレームを PNG(可逆)で保存。cv2.imwrite に圧縮レベル指定はなく OpenCV デフォルト。カメラアプリ経由ではない。",
}
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)

シートは横向き(長辺が左右、短辺が奥手前)に机へ置き、奥側の辺だけを持ち上げた。手前の辺は接地させたまま動かさない。4条件とも同じ位置に置き、挟む物だけを差し替えている。

実行ログは以下。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 capture_top_frames_log08.py --label flat_0
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log08/flat_0_20260807_082025
[OK] 解像度 1920x1080  backend=AVFOUNDATION

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 capture_top_frames_log08.py --label tilt_15 --raise-side top --raise-object "工具箱" --raise-height-mm 30
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log08/tilt_15_20260807_082312
[OK] 解像度 1920x1080  backend=AVFOUNDATION

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 capture_top_frames_log08.py --label tilt_30 --raise-side top --raise-object "白箱" --raise-height-mm 55
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log08/tilt_30_20260807_082518
[OK] 解像度 1920x1080  backend=AVFOUNDATION

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 capture_top_frames_log08.py --label tilt_45 --raise-side top --raise-object "白箱2段" --raise-height-mm 85
[OK] 5 枚保存: /Volumes/KIOXIA/r2r2r-data/vision/captured/Log08/tilt_45_20260807_082710
[OK] 解像度 1920x1080  backend=AVFOUNDATION

ラベルの数字(15/30/45)は狙い値で、実際に挟んだ物の厚みから計算すると別の値になる。A4 の短辺 210mm を持ち上げる場合、210 × sin(角度) が必要な高さになるので、狙い15度なら 54mm、30度なら 105mm、45度なら 148mm。手元にあった物で組んだ結果、実際は 30 / 55 / 85mm になった。

ラベル

挟んだ物

高さ

計算上の傾き

flat_0

なし

0mm

0度

tilt_15

工具箱

30mm

約8度

tilt_30

白箱

55mm

約15度

tilt_45

白箱2段

85mm

約24度

狙い値と実際が離れていること自体は問題にしない。4条件が等間隔に近い階段になっていれば、傾きが増えたときに何が起きるかは見える。ラベルは狙い値のまま tilt_target_deg として meta に残し、混同しないようにしてある。

撮影データが4条件とも別物であることは md5 で確認した。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro Log08 % for d in */; do echo "--- $d"; md5 -q "$d/f0.png"; python3 -c "import json;m=json.load(open('$d/meta.json'));print(m['label'],m['raise_object'],m['raise_height_mm'],m['captured_at'])"; done
--- flat_0_20260807_082025/
8c845a0968774636563e5ef3fb3bd5d5
flat_0 None None 2026-08-07T08:20:33.768567
--- tilt_15_20260807_082312/
2d193e5bca8d9d7c60a35d3732d79934
tilt_15 工具箱 30.0 2026-08-07T08:23:20.015302
--- tilt_30_20260807_082518/
370a04fb96b358497100efd43d89f37d
tilt_30 白箱 55.0 2026-08-07T08:25:26.159480
--- tilt_45_20260807_082710/
c24f58bbd646ce5d666c5086cdbc2d2e
tilt_45 白箱2段 85.0 2026-08-07T08:27:17.702130

紙の四隅を2回クリックして揺れを 3.39px と測った

変換を作るには、元の画像上の4点と、直した画像上の4点の対応が要る。元の画像側は紙の四隅を手でクリックして取る。

自動検出は使わなかった。Log#7 で表紙の四隅を自動検出しようとして7回失敗した経緯があり、そのとき分かったのは、マットと紙が同じ低水準の性質(直線的な輪郭、コントラスト差)を持っていて機械的に分離しにくいこと。手でクリックすれば確実に紙の角が取れる。

ただし手でクリックすると毎回位置が微妙に変わる。この揺れの大きさを知っておかないと、後で「どれくらいのずれまでを正解とするか」の基準が作れない。そこで各セット2回ずつクリックして、r1 と r2 の差を測る。

クリック順は TL(奥左)→ TR(奥右)→ BR(手前右)→ BL(手前左)に固定した。順序が混ざると平均を取ったときに別々の角が混ざるので、出力 JSON に corner_order として順序も書き込んでいる。

以下が四隅クリックスクリプトの全文。

python

# pick_sheet_corners_log08.py - 撮影画像を表示し、人がクリックした紙の四隅を JSON に保存する
# pick_cover_corners_log07.py の Log08 派生版。
# 対象は A4 シート(log06_shapes_red)の「紙の四隅」。図形の角ではない。
# ここで取った四隅から射影変換(ホモグラフィ)を求めるため、クリック順を TL→TR→BR→BL に固定する。
# アーム・daemon には触らない読み取り専用スクリプト

import cv2, json, sys, os, argparse
from datetime import datetime

WIN_INIT_W = 1600
WIN_INIT_H = 900

parser = argparse.ArgumentParser(
    description="Log08 撮影画像から A4 シートの紙四隅を手動クリックで取得する"
)
parser.add_argument("--set-dir", required=True,
                    help="Log08 配下のディレクトリ名(例: flat_0_20260807_082025)")
parser.add_argument("--round", type=int, default=1,
                    help="ラウンド番号。出力ファイル名の r{N} に入る(デフォルト 1)")
args = parser.parse_args()

SET_DIR = args.set_dir
ROUND = args.round

CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log08"
OUT_DIR = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08"

SHEET_W_MM = 210
SHEET_H_MM = 297

CORNER_ORDER = ["top_left", "top_right", "bottom_right", "bottom_left"]

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)

meta_path = os.path.join(CAPTURED_BASE, SET_DIR, "meta.json")
if not os.path.exists(meta_path):
    sys.exit("[ABORT] meta.json が見つかりません: " + meta_path)
with open(meta_path) as f:
    meta = json.load(f)
LABEL = meta.get("label")
if not LABEL:
    sys.exit("[ABORT] meta.json に label がありません: " + meta_path)

os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, "corners_sheet_" + LABEL + "_r" + str(ROUND) + ".json")
if os.path.exists(out_path):
    sys.exit("[ABORT] 出力先に同名 JSON があります: " + out_path)

debug_dir = os.path.join(OUT_DIR, "debug_sheet_" + SET_DIR)
os.makedirs(debug_dir, exist_ok=True)
debug_png = os.path.join(debug_dir, "corners_r" + str(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"]
# 画面表示は OpenCV の Hershey フォントが日本語非対応のため英字。日本語はコンソール側に出す。
corner_hint_en = [
    "TOP-LEFT of SHEET (far side, left)",
    "TOP-RIGHT of SHEET (far side, right)",
    "BOTTOM-RIGHT of SHEET (near side, right)",
    "BOTTOM-LEFT of SHEET (near side, left)",
]
corner_hint_ja = [
    "紙の左上(奥側の左)",
    "紙の右上(奥側の右)",
    "紙の右下(手前側の右)",
    "紙の左下(手前側の左)",
]

clicks = []

def draw_banner(canvas, text):
    font = cv2.FONT_HERSHEY_SIMPLEX
    scale = 1.1
    thickness = 2
    (tw, th), baseline = cv2.getTextSize(text, font, scale, thickness)
    cv2.rectangle(canvas, (0, 0), (tw + 30, th + baseline + 24), (0, 0, 0), -1)
    cv2.putText(canvas, text, (15, th + 14), font, scale, (255, 255, 255), thickness)

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)
    n = len(pts)
    if n < 4:
        banner = "[" + str(n + 1) + "/4] CLICK " + corner_labels[n] + " : " + corner_hint_en[n]
    else:
        banner = "[4/4] DONE - press 's' to save"
    draw_banner(canvas, banner)
    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)))
            i = len(clicks) - 1
            print("[" + str(len(clicks)) + "/4] " + corner_labels[i] + " " + corner_hint_ja[i]
                  + " (" + str(x) + ", " + str(y) + ")")

win_name = SET_DIR + " - " + LABEL + " - r" + str(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("対象: 紙(A4シート)の四隅。図形の角ではない。")
print("クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)")
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 = [[float(p[0]), float(p[1])] for p in clicks]

result = {
    "set_dir": SET_DIR,
    "label": LABEL,
    "image": "f0.png",
    "round": ROUND,
    "corner_order": CORNER_ORDER,
    "corners_px": corners_px,
    "clicked_at": datetime.now().isoformat(),
    "sheet_w_mm": SHEET_W_MM,
    "sheet_h_mm": SHEET_H_MM,
}

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)

4セット × 2ラウンドで8回実行した。全量のログは以下。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir flat_0_20260807_082025
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (732, 493)
[2/4] TR 紙の右上(奥側の右) (1298, 494)
[3/4] BR 紙の右下(手前側の右) (1304, 899)
[4/4] BL 紙の左下(手前側の左) (728, 901)
[OK] corners_px=[[732.0, 493.0], [1298.0, 494.0], [1304.0, 899.0], [728.0, 901.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_flat_0_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_flat_0_20260807_082025/corners_r1.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir flat_0_20260807_082025 --round 2
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (732, 491)
[2/4] TR 紙の右上(奥側の右) (1297, 493)
[3/4] BR 紙の右下(手前側の右) (1301, 896)
[4/4] BL 紙の左下(手前側の左) (730, 901)
[OK] corners_px=[[732.0, 491.0], [1297.0, 493.0], [1301.0, 896.0], [730.0, 901.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_flat_0_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_flat_0_20260807_082025/corners_r2.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_15_20260807_082312
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (720, 465)
[2/4] TR 紙の右上(奥側の右) (1312, 461)
[3/4] BR 紙の右下(手前側の右) (1302, 867)
[4/4] BL 紙の左下(手前側の左) (729, 873)
[OK] corners_px=[[720.0, 465.0], [1312.0, 461.0], [1302.0, 867.0], [729.0, 873.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_15_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_15_20260807_082312/corners_r1.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_15_20260807_082312 --round 2
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (717, 465)
[2/4] TR 紙の右上(奥側の右) (1313, 464)
[3/4] BR 紙の右下(手前側の右) (1305, 865)
[4/4] BL 紙の左下(手前側の左) (729, 868)
[OK] corners_px=[[717.0, 465.0], [1313.0, 464.0], [1305.0, 865.0], [729.0, 868.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_15_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_15_20260807_082312/corners_r2.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_30_20260807_082518
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (717, 425)
[2/4] TR 紙の右上(奥側の右) (1346, 434)
[3/4] BR 紙の右下(手前側の右) (1311, 820)
[4/4] BL 紙の左下(手前側の左) (733, 826)
[OK] corners_px=[[717.0, 425.0], [1346.0, 434.0], [1311.0, 820.0], [733.0, 826.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_30_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_30_20260807_082518/corners_r1.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_30_20260807_082518 --round 2
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (716, 427)
[2/4] TR 紙の右上(奥側の右) (1340, 434)
[3/4] BR 紙の右下(手前側の右) (1309, 825)
[4/4] BL 紙の左下(手前側の左) (734, 826)
[OK] corners_px=[[716.0, 427.0], [1340.0, 434.0], [1309.0, 825.0], [734.0, 826.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_30_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_30_20260807_082518/corners_r2.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_45_20260807_082710
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (649, 453)
[2/4] TR 紙の右上(奥側の右) (1331, 470)
[3/4] BR 紙の右下(手前側の右) (1273, 821)
[4/4] BL 紙の左下(手前側の左) (696, 823)
[OK] corners_px=[[649.0, 453.0], [1331.0, 470.0], [1273.0, 821.0], [696.0, 823.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_45_r1.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_45_20260807_082710/corners_r1.png

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 pick_sheet_corners_log08.py --set-dir tilt_45_20260807_082710 --round 2
対象: 紙(A4シート)の四隅。図形の角ではない。
クリック順: 1=紙の左上(奥左) 2=紙の右上(奥右) 3=紙の右下(手前右) 4=紙の左下(手前左)
左クリックで TL→TR→BR→BL の順に4点。u=1点取消 / r=全取消 / s=保存 / q=中止
[1/4] TL 紙の左上(奥側の左) (653, 456)
[2/4] TR 紙の右上(奥側の右) (1329, 466)
[3/4] BR 紙の右下(手前側の右) (1275, 819)
[4/4] BL 紙の左下(手前側の左) (698, 821)
[OK] corners_px=[[653.0, 456.0], [1329.0, 466.0], [1275.0, 819.0], [698.0, 821.0]]
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/corners_sheet_tilt_45_r2.json
[OK] debug: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/debug_sheet_tilt_45_20260807_082710/corners_r2.png

r1 と r2 の差(同じ角を2回押したときの距離)は以下。

セット

TL

TR

BR

BL

flat_0

2.00

1.41

4.24

2.00

tilt_15

3.00

3.16

3.61

5.00

tilt_30

2.24

6.00

5.39

1.00

tilt_45

5.00

4.47

2.83

2.83

16点の平均は 3.39px、最大は 6.00px(tilt_30 の TR)。Log#7 で本の表紙の四隅を測ったときは平均 2.71px・最大 7.21px だったので、同じ水準に収まっている。紙の角のほうが表紙の角より鋭いはずだが、傾けた分だけ角がカメラから遠くなって相殺されている。

変換にはこの r1 と r2 の平均を使う。平均前の値も出力 JSON に残してある。

四隅の辺長から、下辺が動かず上辺だけ伸びたことが読める

クリックした四隅から各辺の長さを計算すると、傾けた操作がそのまま数字に出る。

セット

上辺px

下辺px

上/下

右辺px

左辺px

flat_0

565.50

573.51

0.9860

404.03

409.01

tilt_15

594.01

574.52

1.0339

403.60

405.64

tilt_30

626.55

576.51

1.0868

389.90

400.36

tilt_45

679.13

577.00

1.1770

356.43

370.37

下辺は 573.51 → 577.00 でほぼ動かず、上辺だけが 565.50 → 679.13 に伸びている。奥側を持ち上げたので、奥が近くなって大きく写る。手前は接地させたまま動かしていないので変わらない。「一辺を接地させたまま反対側だけ持ち上げる」という置き方が守れていた証拠になる。

左右の辺が 409 → 370 と短くなっているのは、紙が奥へ倒れた分だけ縦方向が画像上で潰れたため。

射影変換とアフィン変換を同じ四隅から作る rectify_sheet_log08.py

直した画像のサイズは 840×1188px にした。A4 の 210×297mm を 4 px/mm で表したもので、Log#6 で使った rectify_sheets_log06.py と同じ土俵に揃えている。1px = 0.25mm。

同じ四隅から2種類の変換を作る。

射影変換(ホモグラフィ) は3×3の行列で、四隅4点すべてを使う。台形を長方形に戻せる。

アフィン変換 は2×3の行列で、対応点は3点までしか使えない。3点だけでは「平行線は平行のまま」という制約から逃げられないので、遠近で潰れた台形を長方形に戻すことが原理的にできない。

この2つを同じ画像・同じ四隅から作って並べれば、「アフィンでは足りない」が理屈でなく絵と数字で言える。アフィン側は意図的に「直らない見本」として残している。

以下が変換スクリプトの全文。

python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Log08 の撮影画像を、紙の四隅から求めた射影変換で A4 正対に直す(読み取り専用)。

比較のためアフィン変換版も同時に出力する。射影は四隅4点を使うので台形(遠近)を
直せるが、アフィンは3点しか使えないため台形は直らない。その差を目で見るための
2枚出力であり、アフィン側は「直らない見本」として残す。

出力サイズは rectify_sheets_log06.py と同じ 4 px/mm・A4縦(840x1188)に揃える。
アーム・daemon には触らない。
"""

import os
import sys
import json
import argparse
from datetime import datetime

import cv2
import numpy as np

CAPTURED_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log08"
MEASURE_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08"

A4_W_MM, A4_H_MM = 210.0, 297.0
PX_PER_MM = 4.0
DST_W = int(round(A4_W_MM * PX_PER_MM))   # 840
DST_H = int(round(A4_H_MM * PX_PER_MM))   # 1188

GRID_STEP_MM = 10.0
GRID_STEP_PX = int(round(GRID_STEP_MM * PX_PER_MM))   # 40
GRID_COLOR = (0, 255, 0)

# 焦点距離は未校正。画角からの当たりで置いた近似値であり実測ではない。
FOCAL_SCALE = 1.2

CORNER_ORDER = ["top_left", "top_right", "bottom_right", "bottom_left"]

# 紙は横向き(長辺が左右、短辺が奥手前)に置かれている。一方、出力は
# measure_contours_log06.build_design_points() が縦置き基準の設計座標を返す都合で
# 840x1188 の縦向きに保つ必要がある。そこで四隅を90度回して縦長の枠へ写す。
# 紙の左上(画像上の奥左)が直した画像では右上に来る対応。周回の向きは元の四隅と
# 同じ時計回りで、反射は含まない(純粋な90度回転)。
SHEET_ORIENTATION = "landscape"
CORNER_MAPPING = {
    "top_left": [DST_W - 1, 0],
    "top_right": [DST_W - 1, DST_H - 1],
    "bottom_right": [0, DST_H - 1],
    "bottom_left": [0, 0],
}


def abort(msg):
    sys.exit("[ABORT] " + msg)


def ensure_ssd_mounted():
    if not os.path.ismount("/Volumes/KIOXIA"):
        abort("KIOXIA が未マウント。ローカルには書きません。")


def load_corners(path):
    """corners_sheet_*.json を読み、4x2 の float 配列を返す。"""
    if not os.path.exists(path):
        abort("四隅 JSON が見つかりません: " + path)
    with open(path) as f:
        data = json.load(f)
    pts = data.get("corners_px")
    if not isinstance(pts, list) or len(pts) != 4:
        abort("corners_px が 4点ではありません: " + path)
    for p in pts:
        if not isinstance(p, list) or len(p) != 2:
            abort("corners_px の要素が [x, y] ではありません: " + path)
    order = data.get("corner_order")
    if order is not None and order != CORNER_ORDER:
        abort("corner_order が想定と異なります(平均化すると点が混ざる): " + path)
    return np.array(pts, dtype=np.float64)


def edge_lengths(c):
    """平均四隅から各辺の実測px長を返す。c は TL, TR, BR, BL の順。"""
    tl, tr, br, bl = c[0], c[1], c[2], c[3]
    return {
        "top": float(np.linalg.norm(tr - tl)),
        "right": float(np.linalg.norm(br - tr)),
        "bottom": float(np.linalg.norm(br - bl)),
        "left": float(np.linalg.norm(tl - bl)),
    }


def tilt_candidates(H, K):
    """射影変換行列から平面の傾き角の候補を全て出す。

    decomposeHomographyMat は解を最大4つ返す。ここでは絞り込まず全候補を残し、
    どれを採るかの判断は後段に回す。傾き角は R の z 軸が正対方向から何度ずれたか
    (arccos R[2,2])で定義する。
    """
    num, rots, trans, norms = cv2.decomposeHomographyMat(H, K)
    out = []
    for i in range(num):
        R = np.asarray(rots[i], dtype=np.float64)
        cos_tilt = float(np.clip(R[2, 2], -1.0, 1.0))
        tilt_deg = float(np.degrees(np.arccos(cos_tilt)))
        cos_rot = float(np.clip((float(np.trace(R)) - 1.0) / 2.0, -1.0, 1.0))
        rot_deg = float(np.degrees(np.arccos(cos_rot)))
        out.append({
            "index": i,
            "tilt_angle_deg": tilt_deg,
            "rotation_angle_deg": rot_deg,
            "plane_normal": [float(v) for v in np.asarray(norms[i]).reshape(3)],
            "translation": [float(v) for v in np.asarray(trans[i]).reshape(3)],
        })
    return out


parser = argparse.ArgumentParser(
    description="Log08 撮影画像を紙の四隅から A4 正対に直す(射影/アフィン比較つき)"
)
parser.add_argument("--set-dir", required=True,
                    help="Log08 配下のディレクトリ名(例: tilt_45_20260807_082710)")
args = parser.parse_args()

SET_DIR = args.set_dir

ensure_ssd_mounted()

in_path = os.path.join(CAPTURED_BASE, SET_DIR, "f0.png")
if not os.path.exists(in_path):
    abort("入力画像が見つかりません: " + in_path)

meta_path = os.path.join(CAPTURED_BASE, SET_DIR, "meta.json")
if not os.path.exists(meta_path):
    abort("meta.json が見つかりません: " + meta_path)
with open(meta_path) as f:
    meta = json.load(f)
LABEL = meta.get("label")
if not LABEL:
    abort("meta.json に label がありません: " + meta_path)

r1_path = os.path.join(MEASURE_BASE, "corners_sheet_" + LABEL + "_r1.json")
r2_path = os.path.join(MEASURE_BASE, "corners_sheet_" + LABEL + "_r2.json")
corners_r1 = load_corners(r1_path)
corners_r2 = load_corners(r2_path)

# クリック揺れを均すため r1 と r2 の平均を採る。平均前の値も JSON に残す。
corners_mean = (corners_r1 + corners_r2) / 2.0

out_dir = os.path.join(MEASURE_BASE, "rectify_" + LABEL)
if os.path.exists(out_dir):
    abort("出力先が既に存在します(上書きしません): " + out_dir)
os.makedirs(out_dir)

img = cv2.imread(in_path)
if img is None:
    abort("画像を読めません: " + in_path)
img_h, img_w = img.shape[:2]

src4 = corners_mean.astype(np.float32)
dst4 = np.array([CORNER_MAPPING[k] for k in CORNER_ORDER], dtype=np.float32)

H = cv2.getPerspectiveTransform(src4, dst4)
rect_homography = cv2.warpPerspective(img, H, (DST_W, DST_H), flags=cv2.INTER_LINEAR)

# アフィンは 2x3 行列で、使える対応点は3点まで。四隅のうち BL を捨てている。
# 3点だけでは「平行線が平行のまま」という拘束から逃げられないため、遠近で潰れた
# 台形を長方形に戻すことは原理的にできない。これがアフィンで傾きを直せない理由その
# ものなので、比較用として意図的にこの形で残す。
src3 = corners_mean[:3].astype(np.float32)
dst3 = dst4[:3].copy()
M = cv2.getAffineTransform(src3, dst3)
rect_affine = cv2.warpAffine(img, M, (DST_W, DST_H), flags=cv2.INTER_LINEAR)

overlay = rect_homography.copy()
for x in range(0, DST_W, GRID_STEP_PX):
    cv2.line(overlay, (x, 0), (x, DST_H - 1), GRID_COLOR, 1)
for y in range(0, DST_H, GRID_STEP_PX):
    cv2.line(overlay, (0, y), (DST_W - 1, y), GRID_COLOR, 1)

png_homography = os.path.join(out_dir, "rect_homography.png")
png_affine = os.path.join(out_dir, "rect_affine.png")
png_overlay = os.path.join(out_dir, "overlay_grid.png")
cv2.imwrite(png_homography, rect_homography)
cv2.imwrite(png_affine, rect_affine)
cv2.imwrite(png_overlay, overlay)

fx = fy = img_w * FOCAL_SCALE
cx, cy = img_w / 2.0, img_h / 2.0
K = np.array([[fx, 0.0, cx],
              [0.0, fy, cy],
              [0.0, 0.0, 1.0]], dtype=np.float64)

candidates = tilt_candidates(H, K)

edges = edge_lengths(corners_mean)
ratio_tb = edges["top"] / edges["bottom"] if edges["bottom"] != 0 else None

result = {
    "set_dir": SET_DIR,
    "label": LABEL,
    "corner_order": CORNER_ORDER,
    "sheet_orientation": SHEET_ORIENTATION,
    "corner_mapping": CORNER_MAPPING,
    "corners_r1": corners_r1.tolist(),
    "corners_r2": corners_r2.tolist(),
    "corners_mean": corners_mean.tolist(),
    "H_homography": H.tolist(),
    "M_affine": M.tolist(),
    "dst_size": [DST_W, DST_H],
    "px_per_mm": PX_PER_MM,
    "src_image_size": [img_w, img_h],
    "camera_matrix_approx": K.tolist(),
    "camera_matrix_approx_note":
        "校正値ではない近似。fx=fy=画像幅*1.2、cx,cy=画像中心。"
        "decomposeHomographyMat に渡すために置いた仮値であり、"
        "この K と、正対画像を同じ K のカメラ像とみなす仮定の両方が近似。"
        "tilt_angle_candidates_deg は絶対値としては信用せず、条件間の比較に使う。",
    "tilt_angle_candidates_deg": candidates,
    "tilt_angle_note":
        "decomposeHomographyMat の全解を絞り込まず列挙している。"
        "tilt_angle_deg は arccos(R[2,2])(正対からの傾き)。",
    "edge_len_px": edges,
    "edge_ratio_top_bottom": ratio_tb,
    "grid_step_mm": GRID_STEP_MM,
    "created_at": datetime.now().isoformat(),
}

json_path = os.path.join(out_dir, "rectify_meta.json")
with open(json_path, "w") as f:
    json.dump(result, f, ensure_ascii=False, indent=2)

print("[OK] label=" + LABEL + "  出力先: " + out_dir)
print("[OK] " + str(DST_W) + "x" + str(DST_H) + " (" + str(PX_PER_MM) + " px/mm)")
print("[OK] 辺長px 上=%.2f 右=%.2f 下=%.2f 左=%.2f  上/下=%.4f"
      % (edges["top"], edges["right"], edges["bottom"], edges["left"], ratio_tb))
print("[OK] 傾き角候補 " + str(len(candidates)) + " 件: "
      + ", ".join("%.2f" % c["tilt_angle_deg"] for c in candidates))
print("[OK] JSON: " + json_path)

紙の四隅で変換を作り、紙の中の図形で採点する

直した画像が正しいかを測るスクリプトを別に作る。ここで守ったのは、変換を作った材料(紙の四隅)とは違う場所(紙の中の図形)で採点すること。

Log#6 には図形の位置と種類から変換を当てはめ直す identify() という処理があるが、これは使わない。使うと図形自身から変換を作り直すことになり、採点基準が採点される側から出てしまう。

直した画像は 4 px/mm で A4 の設計座標そのものなので、検出した図形の重心を mm に直せば、設計座標の位置と直接比べられる。対応づけは相互最近傍(お互いに一番近い相手同士を組にする)で、許容距離は 10mm に置いた。図形の間隔が横20mm・縦58mmなので、その半分より内側になる。

赤の色抽出・輪郭抽出・特徴量の算出は measure_contours_log06.py を exec で読み込んで再利用している。書き直すと Log#6 と条件が変わって比較にならない。

以下が測定スクリプトの全文。

python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""整直済み Log08 画像の赤図形を測り、Log06 の真値と突き合わせて誤差を出す(読み取り専用)。

変換(射影)は紙の四隅から作り、採点はその紙の中の図形で行う。作る経路と採点する
経路が分かれているので、図形自身から変換を当てはめ直す measure_contours_log06.identify()
は使わない。整直画像は 4 px/mm・A4 210x297mm の設計座標系そのものなので、検出重心の
mm 位置を設計座標に最近傍で対応づける。

赤の HSV 閾値・モルフォロジ・輪郭特徴量は measure_contours_log06.py を exec で再利用し、
書き直さない(analyze_rotation_log06.py / rectify_sheets_log06.py と同じ方式)。
アーム・daemon には触らない。
"""

import os
import sys
import csv
import math
import argparse

import cv2

MEASURE_SRC = os.path.expanduser("~/r2r2r-scripts/measure_contours_log06.py")
TRUTH_CSV = os.path.expanduser("~/Downloads/log06_shapes_truth.csv")
MEASURE_BASE = "/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08"

PX_PER_MM = 4.0

# 対応づけの許容距離。設計上の図形間隔は横20mm・縦58mmなので、その半分より内側に採る。
MATCH_MAX_DIST_MM = 10.0

TRANSFORMS = [("homography", "rect_homography.png"),
              ("affine", "rect_affine.png")]

COLS = [
    "label", "transform", "shape_id", "shape_kind",
    "area_px", "area_mm2", "truth_area_mm2", "area_err_mm2", "area_err_pct",
    "w_mm", "h_mm", "truth_w_mm", "truth_h_mm", "w_err_mm", "h_err_mm",
    "cx_mm", "cy_mm",
    # 以下2列は指定の列一覧にない追加分。対応が取れなかった図形を黙って捨てないために要る。
    "match_status", "match_dist_mm",
]


def abort(msg):
    sys.exit("[ABORT] " + msg)


def ensure_ssd_mounted():
    if not os.path.ismount("/Volumes/KIOXIA"):
        abort("KIOXIA が未マウント。ローカルには書きません。")


def truth_wh_mm(p):
    """size_mm から外接矩形の真値 (w, h) mm を出す。

    導出規則は設計元画像 log06_shapes_red.png の実測で確認済み:
      circle "d=40"   -> 40 x 40
      square "a=35"   -> 35 x 35
      rect   "57x22"  -> 前の数字が幅(X)、後ろが高さ(Y)(5個とも横長)
      triangle "a=53" -> 正三角形。幅 a、高さ a*sqrt(3)/2
    """
    s = p["size_mm"]
    shape = p["shape"]
    if shape == "circle" or shape == "square":
        v = float(s.split("=")[1])
        return v, v
    if shape == "rect":
        a, b = s.split("x")
        return float(a), float(b)
    if shape == "triangle":
        a = float(s.split("=")[1])
        return a, a * math.sqrt(3.0) / 2.0
    abort("未知の shape: " + str(shape))


def expected_centroid_mm(p):
    """設計座標を、面積重心が来るべき位置に直して返す。

    設計の y_mm は外接矩形の中心。正三角形(頂点が上)の面積重心は外接矩形中心より
    h/6 = a*sqrt(3)/12 だけ下に来る。設計元画像での実測は 0.1435*a(幾何値 0.1443*a)で
    向きも下方向と確認済み。他の形状は中心と重心が一致する。
    """
    if p["shape"] == "triangle":
        a = float(p["size_mm"].split("=")[1])
        return p["x_mm"], p["y_mm"] + a * math.sqrt(3.0) / 12.0
    return p["x_mm"], p["y_mm"]


def load_log06_namespace():
    if not os.path.exists(MEASURE_SRC):
        abort("再利用元が見つかりません: " + MEASURE_SRC)
    if not os.path.exists(TRUTH_CSV):
        abort("真値CSVが見つかりません: " + TRUTH_CSV
              + "\n  探す場所: ~/Downloads/log06_shapes_truth.csv"
              + "\n  measure_contours_log06.py の定数 TRUTH_CSV も同じパスを指しています。"
              + "\n  別の場所にある場合は、そのパスへコピーするか TRUTH_CSV を確認してください。")
    src = open(MEASURE_SRC).read().replace(
        'if __name__ == "__main__":\n    main()', "")
    ns = {}
    exec(compile(src, MEASURE_SRC, "exec"), ns)
    return ns


def measure_image(ns, path):
    """1枚の整直画像から、検出図形ごとの実測値(mm換算済み)を返す。"""
    img = cv2.imread(path)
    if img is None:
        abort("画像を読めません: " + path)
    mask, _k = ns["morph"](ns["make_mask_red"](img), img.shape[1])
    feats = ns["extract_features"](mask)
    out = []
    for fe in feats:
        out.append({
            "area_px": float(fe["contour_area"]),
            "area_mm2": float(fe["contour_area"]) / (PX_PER_MM * PX_PER_MM),
            "w_mm": float(fe["brect_w"]) / PX_PER_MM,
            "h_mm": float(fe["brect_h"]) / PX_PER_MM,
            "cx_mm": float(fe["cx_moment"]) / PX_PER_MM,
            "cy_mm": float(fe["cy_moment"]) / PX_PER_MM,
        })
    return out


def match(design_pts, meas):
    """設計点と検出図形を相互最近傍で対応づける。

    距離の小さい組から確定させ、どちらか一方が埋まっていれば飛ばす。許容距離を超えた
    ものは対応させない。対応の付かなかった設計点・検出図形は捨てずに呼び出し側へ返す。
    """
    pairs = []
    for i, p in enumerate(design_pts):
        ex, ey = expected_centroid_mm(p)
        for j, m in enumerate(meas):
            d = math.hypot(m["cx_mm"] - ex, m["cy_mm"] - ey)
            if d <= MATCH_MAX_DIST_MM:
                pairs.append((d, i, j))
    pairs.sort()

    used_p, used_m, matched = set(), set(), []
    for d, i, j in pairs:
        if i in used_p or j in used_m:
            continue
        used_p.add(i)
        used_m.add(j)
        matched.append((i, j, d))

    unmatched_p = [i for i in range(len(design_pts)) if i not in used_p]
    unmatched_m = [j for j in range(len(meas)) if j not in used_m]
    return matched, unmatched_p, unmatched_m


def num(v, nd=4):
    return "" if v is None else round(float(v), nd)


def rows_for(label, transform, design_pts, meas):
    matched, un_p, un_m = match(design_pts, meas)
    rows = []

    for i, j, d in sorted(matched, key=lambda t: design_pts[t[0]]["index"]):
        p, m = design_pts[i], meas[j]
        tw, th = truth_wh_mm(p)
        ta = p["true_area_mm2"]
        rows.append({
            "label": label, "transform": transform,
            "shape_id": p["index"], "shape_kind": p["shape"],
            "area_px": num(m["area_px"], 2), "area_mm2": num(m["area_mm2"]),
            "truth_area_mm2": num(ta),
            "area_err_mm2": num(m["area_mm2"] - ta),
            "area_err_pct": num((m["area_mm2"] - ta) / ta * 100.0 if ta else None),
            "w_mm": num(m["w_mm"]), "h_mm": num(m["h_mm"]),
            "truth_w_mm": num(tw), "truth_h_mm": num(th),
            "w_err_mm": num(m["w_mm"] - tw), "h_err_mm": num(m["h_mm"] - th),
            "cx_mm": num(m["cx_mm"]), "cy_mm": num(m["cy_mm"]),
            "match_status": "matched", "match_dist_mm": num(d),
        })

    # 真値側に対応する検出が無かったもの(見落とし)。行は残す。
    for i in un_p:
        p = design_pts[i]
        tw, th = truth_wh_mm(p)
        rows.append({
            "label": label, "transform": transform,
            "shape_id": p["index"], "shape_kind": p["shape"],
            "area_px": "", "area_mm2": "", "truth_area_mm2": num(p["true_area_mm2"]),
            "area_err_mm2": "", "area_err_pct": "",
            "w_mm": "", "h_mm": "", "truth_w_mm": num(tw), "truth_h_mm": num(th),
            "w_err_mm": "", "h_err_mm": "", "cx_mm": "", "cy_mm": "",
            "match_status": "truth_unmatched", "match_dist_mm": "",
        })

    # 真値に対応の無い検出(ノイズ・写り込み)。これも捨てずに残す。
    for j in un_m:
        m = meas[j]
        rows.append({
            "label": label, "transform": transform,
            "shape_id": "", "shape_kind": "",
            "area_px": num(m["area_px"], 2), "area_mm2": num(m["area_mm2"]),
            "truth_area_mm2": "", "area_err_mm2": "", "area_err_pct": "",
            "w_mm": num(m["w_mm"]), "h_mm": num(m["h_mm"]),
            "truth_w_mm": "", "truth_h_mm": "",
            "w_err_mm": "", "h_err_mm": "",
            "cx_mm": num(m["cx_mm"]), "cy_mm": num(m["cy_mm"]),
            "match_status": "extra_detection", "match_dist_mm": "",
        })

    return rows, len(matched), len(un_p), len(un_m)


parser = argparse.ArgumentParser(
    description="整直済み Log08 画像の赤図形を Log06 真値と突き合わせて誤差を出す"
)
parser.add_argument("--label", required=True,
                    choices=["flat_0", "tilt_15", "tilt_30", "tilt_45"],
                    help="計測条件ラベル")
args = parser.parse_args()

LABEL = args.label

ensure_ssd_mounted()

rect_dir = os.path.join(MEASURE_BASE, "rectify_" + LABEL)
if not os.path.isdir(rect_dir):
    abort("整直結果のディレクトリがありません: " + rect_dir
          + "\n  先に rectify_sheet_log08.py を実行してください。")

img_paths = {}
for name, fname in TRANSFORMS:
    p = os.path.join(rect_dir, fname)
    if not os.path.exists(p):
        abort("整直画像がありません: " + p)
    img_paths[name] = p

out_csv = os.path.join(MEASURE_BASE, "measure_" + LABEL + ".csv")
if os.path.exists(out_csv):
    abort("出力先に同名 CSV があります(上書きしません): " + out_csv)

ns = load_log06_namespace()
design_pts = ns["build_design_points"]()

all_rows = []
summary = []
for name, _fname in TRANSFORMS:
    meas = measure_image(ns, img_paths[name])
    rows, n_ok, n_up, n_um = rows_for(LABEL, name, design_pts, meas)
    all_rows.extend(rows)
    summary.append((name, len(meas), n_ok, n_up, n_um))

with open(out_csv, "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=COLS)
    w.writeheader()
    for r in all_rows:
        w.writerow(r)

print("[OK] label=" + LABEL)
for name, n_det, n_ok, n_up, n_um in summary:
    print("[OK] %-10s 検出=%2d  対応=%2d/20  真値側未対応=%d  余分な検出=%d"
          % (name, n_det, n_ok, n_up, n_um))
print("[OK] CSV: " + out_csv + "  (" + str(len(all_rows)) + " 行)")

実行前に立てた5つの予測

Log#5・Log#6・Log#7 で「予測を書いてから実行する」形式が3回とも機能したので、今回も踏襲した。

#

予測

根拠

1

直した画像で図形の寸法は、傾きが増えるほど真値からずれる

奥側が引き伸ばされ、元画像での1px が実寸で大きい範囲を担うため

2

flat_0 の誤差は 0.5mm 未満に収まる

クリック揺れ 3.39px を 4px/mm に換算すると約0.85mm。正対なら位置による偏りがない

3

tilt_45 の誤差は flat_0 の 2〜3倍

上辺/下辺比が 1.177 なので、画像内で縮尺が最大18%変わる

4

誤差はシート内の場所で偏る。奥側の図形ほど大きい

遠近投影で奥ほど元画像の画素が粗いため

5

アフィン変換では台形が直せず、傾きが増えるほど射影変換との差が開く

アフィンは平行線を平行のまま保つので、上辺と下辺の長さが違う形を正方形に戻せない

縦置き前提で仕様を出して 8/20 になった

最初の実行で、20図形のうち8個しか対応が取れなかった。しかも正対の flat_0 でも8個。傾きの話をする前の段階でつまずいている。

検出座標を出して確認した。

--- matched ---
0 circle 39.9522 52.9032 5.466
1 square 39.9872 114.1419 5.0857
2 rect 39.9951 161.7587 5.9628
3 triangle 39.9824 229.8509 5.0809
12 circle 156.2055 52.3008 2.9561
13 square 157.2763 113.9943 2.4885
14 rect 158.515 161.7735 4.7713
15 triangle 160.7872 230.3162 8.143
--- extra_detection (検出されたが対応せず) ---
81.1094 229.9284 1123.5938
115.4974 230.1273 1164.8125
81.0022 161.7153 361.25
115.5003 161.8731 345.5312
81.1233 114.2341 123.6875
115.4962 114.0186 128.0625
81.0218 79.9236 40.25
39.9653 80.1461 40.25
156.6126 79.6947 44.6875
115.5 79.9121 36.6875
81.1094 52.6064 19.7188
115.616 52.6339 17.7188

縦位置(2列目)は設計と揃っている。合っていないのは横位置だけで、しかも端の2列は合っていて中央の2列だけ 14mm ずれている。

原因は、直す先の枠を A4 縦(840×1188)と決めていたこと。紙は横向き(長辺が左右)に置いてあった。横置きの紙を縦長の枠へ押し込んでいたので、縦方向に無理やり引き伸ばされていた。

四隅の辺長比を計算すれば分かることだった。上辺565.5px ÷ 左辺409.0px = 1.38。A4 縦置きなら 210÷297 = 0.71 になるはずで、出た値は横置きの 297÷210 = 1.41 に近い。

撮影画像は見ていたのに、置き方を確認せずに縦置きと決めて仕様を書いた。

左右反転・上下反転を経て 20/20 になった

紙が横置きだと分かったので、四隅を90度回して縦長の枠へ写す形に変えた。ここで2回外している。

1回目の修正では、対応が 8/20 から 0/20 に悪化した。検出座標を面積とともに並べたところ、面積が最大の図形(約1130mm²)が x=162mm 付近に、最小(約18mm²)が x=37mm 付近に来ていた。設計は逆で、最大が x=45mm、最小が x=175mm。左右が反転していた。

2回目の修正で左右を入れ替えたところ、外部レビュー側(Claude Code)から、この対応は鏡像を含むという指摘が入った。四隅の周回の向きを shoelace 公式(多角形の頂点を順にたどって面積を出す式。符号が回転の向きを示す)で測ると、元の TL→TR→BR→BL は正(時計回り)なのに、送り先が負になっていた。向きが逆なら反射が入る。

撮影画像でカメラ側のミラーがないことを確認した(紙の文字が鏡文字になっていない、マットの目盛りが左から右へ増えている)ので、反射の出どころは対応の組み方だった。

3回目の修正で、周回の向きを揃えたうえで左右も合う対応にした。

    "top_left":     [839, 0],
    "top_right":    [839, 1187],
    "bottom_right": [0, 1187],
    "bottom_left":  [0, 0],

検算の出力。

corners_mean (TL→TR→BR→BL) = [[732.0, 492.0], [1297.5, 493.5], [1302.5, 897.5], [729.0, 901.0]]

shoelace(corners_mean) = +463005.5   → 時計回り
shoelace(dst4)         = +1991786.0  → 時計回り
符号一致(=反射なし): True

この状態で flat_0 を走らせて 20/20 になった。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 rectify_sheet_log08.py --set-dir flat_0_20260807_082025
[OK] label=flat_0  出力先: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_flat_0
[OK] 840x1188 (4.0 px/mm)
[OK] 辺長px 上=565.50 右=404.03 下=573.51 左=409.01  上/下=0.9860
[OK] 傾き角候補 4 件: 4.56, 4.56, 4.58, 4.58
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_flat_0/rectify_meta.json
[OK] label=flat_0
[OK] homography 検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] affine     検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/measure_flat_0.csv  (40 行)

修正のたびに、その前の出力は archive/log08_rectify_portrait_20260807/ と archive/log08_rectify_v2_20260807/ にコピー退避してから作り直している。

この3回の外し方には共通点がある。1回目は撮影画像を見ずに置き方を決めた。2回目は検出座標の左右だけ見て向きを決めた。3回目でようやく、周回の向きという判定できる量を持ち込んだ。目で見て「合っていそう」で進めた2回は外れ、数値で判定できる量を用意した回で通っている。

射影変換は全条件 20/20、アフィンは 20→15→5 に崩れた

残り3セットを走らせた結果。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 rectify_sheet_log08.py --set-dir tilt_15_20260807_082312
[OK] label=tilt_15  出力先: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_15
[OK] 840x1188 (4.0 px/mm)
[OK] 辺長px 上=594.01 右=403.60 下=574.52 左=405.64  上/下=1.0339
[OK] 傾き角候補 4 件: 8.57, 8.57, 3.10, 3.10
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_15/rectify_meta.json
[OK] label=tilt_30  出力先: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_30
[OK] 840x1188 (4.0 px/mm)
[OK] 辺長px 上=626.55 右=389.90 下=576.51 左=400.36  上/下=1.0868
[OK] 傾き角候補 4 件: 25.34, 25.34, 1.86, 1.86
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_30/rectify_meta.json
[OK] label=tilt_45  出力先: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_45
[OK] 840x1188 (4.0 px/mm)
[OK] 辺長px 上=679.13 右=356.43 下=577.00 左=370.37  上/下=1.1770
[OK] 傾き角候補 4 件: 41.99, 41.99, 1.40, 1.40
[OK] JSON: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/rectify_tilt_45/rectify_meta.json
[OK] label=tilt_15
[OK] homography 検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] affine     検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/measure_tilt_15.csv  (40 行)
[OK] label=tilt_30
[OK] homography 検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] affine     検出=20  対応=15/20  真値側未対応=5  余分な検出=5
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/measure_tilt_30.csv  (45 行)
[OK] label=tilt_45
[OK] homography 検出=20  対応=20/20  真値側未対応=0  余分な検出=0
[OK] affine     検出=20  対応= 5/20  真値側未対応=15  余分な検出=15
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/measure_tilt_45.csv  (55 行)

セット

上/下

homography

affine

flat_0

0.9860

20/20

20/20

tilt_15

1.0339

20/20

20/20

tilt_30

1.0868

20/20

15/20

tilt_45

1.1770

20/20

5/20

画像認識と物体検出を学び直す Log#8 | 斜めから撮った紙を真上から見た形に直す ― 傾きを調べにいって物差しの誤差を見つけた|射影変換は全条件 20/20、アフィンは 20→15→5 に崩れた

画像認識と物体検出を学び直す Log#8 | 斜めから撮った紙を真上から見た形に直す ― 傾きを調べにいって物差しの誤差を見つけた|射影変換は全条件 20/20、アフィンは 20→15→5 に崩れた

画像認識と物体検出を学び直す Log#8 | 斜めから撮った紙を真上から見た形に直す ― 傾きを調べにいって物差しの誤差を見つけた|射影変換は全条件 20/20、アフィンは 20→15→5 に崩れた

射影変換は全条件で20個そろえた。アフィンは 20 → 20 → 15 → 5 と崩れた。予測5が数字で出ている。

崩れ方は面積誤差にも出る。

セット

homography 面積誤差率 平均

affine 面積誤差率 平均

flat_0

13.83%

12.76%

tilt_15

14.42%

14.96%

tilt_30

14.74%

15.93%

tilt_45

13.29%

42.71%

tilt_45 のアフィンは、対応が取れた5個ですら平均42.71%ずれている。数が減るだけでなく、残ったものも直っていない。

傾き角の候補(decomposeHomographyMat が返す解)は、大きいほうが 4.56 → 8.57 → 25.34 → 41.99 と単調に増えた。ただしカメラ行列を近似値で置いているうえ、直した先が 4px/mm で決め打ちした人工的な座標系なので、この分解が前提とする「両方ともカメラで撮った像」という条件が成り立っていない。順序は一致するが絶対値としては使えない。正対の flat_0 で 4.56 度が出ていることがその証拠になる。

面積誤差率は全条件 13〜15% で動かなかった

予測1が外れた。

セット

homography 面積誤差率 平均

最大

flat_0

13.83%

26.10%

tilt_15

14.42%

26.10%

tilt_30

14.74%

21.47%

tilt_45

13.29%

18.76%

傾きが増えても増えていない。むしろ tilt_45 のほうが flat_0 より小さい。予測3「tilt_45 は flat_0 の2〜3倍」も成立しない。

一方、外接矩形の幅の誤差は動いている。

セット

幅誤差 平均

幅誤差 最大

高さ誤差 平均

flat_0

1.14mm

3.00mm

0.67mm

tilt_15

1.20mm

3.25mm

0.76mm

tilt_30

1.44mm

5.25mm

0.68mm

tilt_45

1.46mm

4.75mm

0.65mm

面積は傾きに反応しないのに、幅は 1.14 → 1.46mm と増える。指標によって感度が違う。

予測2「flat_0 の誤差は 0.5mm 未満」も外れている。幅誤差の平均が 1.14mm で、倍以上ある。

測り方そのものが持つ誤差を measure_baseline_log08.py で測った

面積誤差率が全条件で13〜15%に張り付くなら、その13〜15%は傾き以外から来ている。撮影を挟まずに同じ測り方を通せば、その量だけを取り出せる。

印刷用の設計元画像(2480×3508px)をそのまま 840×1188 に縮小して、同じ検出・同じ突き合わせに通す。写真を1枚も使わないので、ここで出る誤差は測り方が持っている分だけになる。

縮小の補間は INTER_AREA と INTER_LINEAR の両方を出した。本測定の warpPerspective は INTER_LINEAR なので、対応するのは後者。

以下が基準線スクリプトの全文。

python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""面積誤差の基準線(下駄)を測る(読み取り専用)。

measure_rectified_log08.py の結果は全条件で面積誤差率が 13〜15% に張り付き、傾きの
影響が埋もれている。原因は撮影ではなく採点経路そのもの、すなわち 4 px/mm への縮小と
モルフォロジ収縮による系統誤差。そこで撮影を挟まず、設計元画像を 840x1188 に落とす
だけで同じ経路に通し、その系統誤差を単独で測る。ここで出た誤差率が、各条件の誤差から
引くべき下駄になる。

採点経路は measure_rectified_log08.py の定義部を exec で読み込んで共有する。同じ関数を
書き写すと片方だけ直したときに下駄と本測定がずれるため、実体を1つに保つ。
リサイズ補間は INTER_AREA と INTER_LINEAR の両方を出す(warpPerspective は INTER_LINEAR
なので、本測定と対応するのは後者)。
アーム・daemon には触らない。
"""

import os
import sys
import csv
import tempfile

import cv2

RECT_MEASURE_SRC = os.path.expanduser("~/r2r2r-scripts/measure_rectified_log08.py")
GEN_IMG = os.path.expanduser("~/Downloads/log06_shapes_red.png")

A4_W_MM, A4_H_MM = 210.0, 297.0

# (label列の値, transform列の値, cv2の補間フラグ)
JOBS = [
    ("baseline_area", "INTER_AREA", cv2.INTER_AREA),
    ("baseline_linear", "INTER_LINEAR", cv2.INTER_LINEAR),
]


def load_rect_measure_namespace():
    """measure_rectified_log08.py の定義部だけを読み込む。

    argparse 以降(引数解析・KIOXIA参照・CSV出力)は切り落とすので、読み込み自体では
    何も実行されない。既存スクリプトは読むだけで改変しない。
    """
    if not os.path.exists(RECT_MEASURE_SRC):
        sys.exit("[ABORT] 再利用元が見つかりません: " + RECT_MEASURE_SRC)
    src = open(RECT_MEASURE_SRC).read()
    marker = "parser = argparse.ArgumentParser("
    if marker not in src:
        sys.exit("[ABORT] 再利用元の構造が想定と異なります(argparse の開始が見つからない): "
                 + RECT_MEASURE_SRC)
    ns = {}
    exec(compile(src[:src.index(marker)], RECT_MEASURE_SRC, "exec"), ns)
    for key in ("COLS", "PX_PER_MM", "MEASURE_BASE", "abort", "ensure_ssd_mounted",
                "load_log06_namespace", "measure_image", "rows_for"):
        if key not in ns:
            sys.exit("[ABORT] 再利用元に " + key + " がありません: " + RECT_MEASURE_SRC)
    return ns


rm = load_rect_measure_namespace()

abort = rm["abort"]
COLS = rm["COLS"]
PX_PER_MM = rm["PX_PER_MM"]
DST_W = int(round(A4_W_MM * PX_PER_MM))   # 840
DST_H = int(round(A4_H_MM * PX_PER_MM))   # 1188

rm["ensure_ssd_mounted"]()

if not os.path.exists(GEN_IMG):
    abort("設計元画像が見つかりません: " + GEN_IMG
          + "\n  探す場所: ~/Downloads/log06_shapes_red.png"
          + "\n  measure_contours_log06.py の定数 GEN_IMG も同じパスを指しています。")

out_csv = os.path.join(rm["MEASURE_BASE"], "measure_baseline.csv")
if os.path.exists(out_csv):
    abort("出力先に同名 CSV があります(上書きしません): " + out_csv)

gen = cv2.imread(GEN_IMG)
if gen is None:
    abort("設計元画像を読めません: " + GEN_IMG)
gen_h, gen_w = gen.shape[:2]

ns06 = rm["load_log06_namespace"]()
design_pts = ns06["build_design_points"]()

all_rows = []
summary = []
# リサイズ結果は測るためだけの中間物なので一時ディレクトリに置き、終了時に消す。
with tempfile.TemporaryDirectory(prefix="baseline_log08_") as tmpdir:
    for label, tf_name, interp in JOBS:
        resized = cv2.resize(gen, (DST_W, DST_H), interpolation=interp)
        tmp_png = os.path.join(tmpdir, label + ".png")
        cv2.imwrite(tmp_png, resized)

        meas = rm["measure_image"](ns06, tmp_png)
        rows, n_ok, n_up, n_um = rm["rows_for"](label, tf_name, design_pts, meas)
        all_rows.extend(rows)
        summary.append((label, tf_name, len(meas), n_ok, n_up, n_um, rows))

with open(out_csv, "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=COLS)
    w.writeheader()
    for r in all_rows:
        w.writerow(r)

print("[OK] 設計元画像 " + str(gen_w) + "x" + str(gen_h)
      + " → " + str(DST_W) + "x" + str(DST_H) + " (" + str(PX_PER_MM) + " px/mm)")
for label, tf_name, n_det, n_ok, n_up, n_um, rows in summary:
    pcts = [float(r["area_err_pct"]) for r in rows
            if r["match_status"] == "matched" and r["area_err_pct"] != ""]
    if pcts:
        print("[OK] %-16s %-12s 検出=%2d 対応=%2d/20 未対応=%d 余分=%d  "
              "面積誤差率 平均%+.2f%% 最小%+.2f%% 最大%+.2f%%"
              % (label, tf_name, n_det, n_ok, n_up, n_um,
                 sum(pcts) / len(pcts), min(pcts), max(pcts)))
    else:
        print("[OK] %-16s %-12s 検出=%2d 対応=%2d/20 未対応=%d 余分=%d  (対応なし)"
              % (label, tf_name, n_det, n_ok, n_up, n_um))
print("[OK] CSV: " + out_csv + "  (" + str(len(all_rows)) + " 行)")

実行結果。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 measure_baseline_log08.py
[OK] 設計元画像 2480x3508 → 840x1188 (4.0 px/mm)
[OK] baseline_area    INTER_AREA   検出=20 対応=20/20 未対応=0 余分=0  面積誤差率 平均-4.55% 最小-11.20% 最大-0.84%
[OK] baseline_linear  INTER_LINEAR 検出=20 対応=20/20 未対応=0 余分=0  面積誤差率 平均-4.71% 最小-11.20% 最大-0.91%
[OK] CSV: /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/measure_baseline.csv  (40 行)

補間の違いは平均で 0.16 ポイントしかない。縮小方法の選択は主因ではなかった。

この基準線は一律ではなく、図形の大きさに強く依存する。

面積段

INTER_LINEAR の誤差率

1257mm²

−0.91%

400mm²

−2.48%

144mm²

−4.12%

49mm²

−7.02%

25mm²

−9.75%

大きい図形なら約1%、最小段で約10%。定数を引くのではなく、図形ごとに引く必要がある。

図形ごとに引いた残差は以下。

shape_id

種類

真値mm²

基準線%

flat_0%

tilt_15%

tilt_30%

tilt_45%

0

circle

1256.6

−0.91

−8.66

−7.43

−9.32

−12.50

1

square

1225.0

−1.42

−7.86

−7.46

−7.92

−12.42

2

rect

1254.0

−1.57

−7.11

−7.51

−6.99

−9.51

3

triangle

1216.3

−1.21

−6.93

−6.87

−4.57

−6.79

4

circle

380.1

−1.68

−10.34

−11.96

−16.51

−12.73

5

square

400.0

−2.48

−10.03

−12.59

−17.98

−13.40

6

rect

384.0

−2.85

−9.43

−14.10

−17.91

−12.71

7

triangle

389.7

−2.42

−8.01

−13.22

−18.02

−15.56

8

circle

153.9

−2.60

−12.28

−12.79

−14.35

−11.86

9

square

144.0

−4.12

−11.48

−11.74

−16.10

−12.26

10

rect

142.5

−4.61

−9.67

−12.52

−16.54

−16.60

11

triangle

140.3

−4.75

−10.79

−14.11

−18.50

−16.40

12

circle

50.3

−4.63

−19.43

−16.07

−15.01

−10.29

13

square

49.0

−7.02

−16.96

−16.52

−12.37

−11.99

14

rect

46.2

−6.93

−16.46

−13.56

−9.77

−18.76

15

triangle

52.4

−7.20

−14.05

−14.47

−14.17

−11.85

16

circle

23.8

−6.08

−26.08

−24.10

−21.47

−15.03

17

square

25.0

−9.75

−21.50

−21.50

−19.50

−13.38

18

rect

24.0

−11.20

−23.44

−23.70

−16.54

−13.15

19

triangle

24.4

−10.70

−26.10

−26.10

−21.22

−18.66

基準線を引いた残差の集計。

flat_0    基準線を引いた残差 平均  -9.12%  最小 -19.99%  最大  -5.07%
tilt_15   基準線を引いた残差 平均  -9.71%  最小 -18.02%  最大  -5.67%
tilt_30   基準線を引いた残差 平均 -10.03%  最小 -15.60%  最大  -2.84%
tilt_45   基準線を引いた残差 平均  -8.59%  最小 -13.14%  最大  -1.95%

引いても横ばい。予測1は下駄を引いた後でも外れている。面積は傾きに鈍感な指標だった。

赤い図形の輪郭は 0.41〜0.48mm 一定の幅で削れていた

残差には予測になかった構造がある。20図形 × 4条件の80件すべてで符号がマイナス。検出面積が真値より小さい。ばらつきではなく一方向の偏りになっている。

一方向に縮む原因として、輪郭が一定の幅で内側に寄っている可能性を検算した。印刷された赤の縁は、紙の白へ向かって数画素かけて薄くなる。色で切り分けると、その縁が赤と判定されず外側から削れる。削れる幅が図形の大きさによらず一定なら、面積の減り方は「周囲長 × 削れ幅」になる。

真値の周囲長は設計データが持っているので、面積の不足を周囲長で割れば削れ幅が出る。図形によらず同じ値になれば、この説明が成立する。

(base) shimazueiichi@shimadzueiichinoMacBook-Pro r2r2r-scripts % python3 -c "
import csv,statistics,os
src=open(os.path.expanduser('~/r2r2r-scripts/measure_contours_log06.py')).read().replace('if __name__ == \"__main__\":\n    main()','')
ns={}; exec(compile(src,'m','exec'),ns)
peri={str(p['index']):p['true_perimeter_mm'] for p in ns['build_design_points']()}
B='/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log08/'
for lab in ['flat_0','tilt_15','tilt_30','tilt_45']:
    ds=[]
    for r in csv.DictReader(open(B+'measure_%s.csv'%lab)):
        if r['transform']=='homography' and r['match_status']=='matched':
            err=float(r['area_err_mm2']); P=peri[r['shape_id']]
            ds.append(-err/P)
    print('%-9s 推定削れ幅 平均%.4fmm 標準偏差%.4fmm 最小%.4f 最大%.4f'%(lab,statistics.mean(ds),statistics.stdev(ds),min(ds),max(ds)))
"
flat_0    推定削れ幅 平均0.4058mm 標準偏差0.1688mm 最小0.2231 最大0.8662
tilt_15   推定削れ幅 平均0.4341mm 標準偏差0.1714mm 最小0.2060 最大0.7431
tilt_30   推定削れ幅 平均0.4824mm 標準偏差0.2648mm 最小0.1484 最大0.9319
tilt_45   推定削れ幅 平均0.4736mm 標準偏差0.3095mm 最小0.1435 最大1.2497

削れ幅は4条件とも 0.41〜0.48mm。図形の面積が 24mm² から 1257mm² まで50倍違うのに、周囲長で割った値が揃う。面積の不足は「輪郭が一定幅で内側に寄っている」で説明がつく。

4 px/mm では 0.45mm は約1.8px。1〜2画素分の縁が落ちている。

これは傾きとは無関係で、Log#6 で正対の図形を測ったときにも同じだけ乗っていたことになる。面積を絶対値で使う場面では、この分を見込む必要がある。

傾きは削れ幅の平均でなくばらつきに出た

同じ表を、平均ではなく標準偏差で見ると傾きの影響が出ている。

セット

削れ幅 平均

標準偏差

最小

最大

flat_0

0.4058mm

0.1688mm

0.2231

0.8662

tilt_15

0.4341mm

0.1714mm

0.2060

0.7431

tilt_30

0.4824mm

0.2648mm

0.1484

0.9319

tilt_45

0.4736mm

0.3095mm

0.1435

1.2497

平均は 0.41 → 0.47 とわずかに増える程度だが、標準偏差は 0.169 → 0.310 と倍近くになる。最大値も 0.87 → 1.25mm。

傾けると、削れ方が画像の場所によって違ってくる。傾きの影響は「全体が均一にずれる」ではなく「場所ごとの差が開く」形で出た。

削れ幅の勾配は x 方向にあるが、位置とサイズが分離できない

場所による差を、直した画像上の座標で分けて見る。

紙は横置きで奥側を持ち上げたので、元画像の奥行き方向は直した画像の横方向(x)に対応する。

セット      列(cx_mm)ごとの推定削れ幅 mm
flat_0    x 50:0.662 x100:0.457 x125:0.329 x150:0.290 x175:0.291
tilt_15   x 50:0.629 x100:0.619 x125:0.376 x150:0.262 x175:0.285
tilt_30   x 50:0.632 x100:0.842 x125:0.478 x150:0.223 x175:0.237
tilt_45   x 50:0.903 x100:0.650 x125:0.414 x150:0.222 x175:0.180

セット      行(cy_mm)ごとの推定削れ幅 mm
flat_0    y 60:0.522 y120:0.420 y150:0.348 y210:0.283 y240:0.530
tilt_15   y 60:0.500 y120:0.438 y150:0.402 y210:0.363 y240:0.526
tilt_30   y 60:0.588 y120:0.507 y150:0.422 y210:0.413
tilt_45   y 60:0.555 y120:0.500 y150:0.437 y210:0.402

x 方向には勾配がある。x50 側が常に大きく、x175 側が小さい。しかも両端の比は 2.28 → 2.21 → 2.67 → 5.02 と、傾きとともに開く。

y 方向には単調な勾配がない。flat_0 では 0.522 → 0.283 → 0.530 と途中で戻る。

予測4「誤差はシート内の場所で偏る。奥側の図形ほど大きい」は、方向としては当たった。ただし当たり方が予測と違う。flat_0 の時点で既に 2.28 倍の勾配がある。 正対でも勾配があるなら、この勾配の主因は傾きではない。傾きは勾配を 2.28 から 5.02 へ拡大しただけになる。

正対でも x 方向に勾配が出る理由は、この列が図形サイズの列でもあるため。x50 は最大の図形(1250mm²)、x175 は最小(24mm²)が並んでいる。x の勾配とサイズの勾配が同じ方向を向いていて、分離できない。

Log#6 のシートはサイズで列を分ける設計になっているので、同じサイズの図形が横に並んでいない。位置による差だけを取り出すには、同じサイズの図形を横一列に並べたシートが要る。このシートの設計では、位置とサイズが交絡している。

予測5つの当たり外れ

#

予測

結果

1

傾きが増えるほど寸法がずれる

外れ。面積誤差率は全条件13〜15%で横ばい。基準線を引いた残差も −8.6〜−10.0% で横ばい

2

flat_0 の誤差は 0.5mm 未満

外れ。幅誤差の平均が 1.14mm

3

tilt_45 の誤差は flat_0 の2〜3倍

外れ。面積誤差率は flat_0 13.83% に対し tilt_45 13.29%

4

誤差はシート内の場所で偏る

当たり。ただし正対でも 2.28 倍の勾配があり、主因は傾きではなくサイズとの交絡

5

アフィンでは台形が直せず、傾きが増えるほど差が開く

当たり。対応数 20→20→15→5、面積誤差率 12.76%→42.71%

当たり1つ、外れ3つ、当たったが理由が違うもの1つ。

予測を外した内容には共通点がある。1・2・3はどれも「傾きが誤差の主因だ」という前提から出ていた。実際に主因だったのは測り方が持っている誤差で、その大きさを先に測っていなかった。基準線を測ってから予測を立てていれば、面積が傾きに鈍感なことは事前に読めた。

測定データの全量

measure_flat_0.csv。

label,transform,shape_id,shape_kind,area_px,area_mm2,truth_area_mm2,area_err_mm2,area_err_pct,w_mm,h_mm,truth_w_mm,truth_h_mm,w_err_mm,h_err_mm,cx_mm,cy_mm,match_status,match_dist_mm
flat_0,homography,0,circle,18364.5,1147.7812,1256.637,-108.8557,-8.6625,38.5,38.5,40.0,40.0,-1.5,-1.5,47.2838,56.57,matched,2.7714
flat_0,homography,1,square,18058.5,1128.6562,1225.0,-96.3438,-7.8648,33.75,34.25,35.0,35.0,-1.25,-0.75,47.2595,114.6406,matched,2.2879
flat_0,homography,2,rect,18637.5,1164.8438,1254.0,-89.1562,-7.1097,55.25,21.5,57.0,22.0,-1.75,-0.5,47.0653,163.3839,matched,2.6224
flat_0,homography,3,triangle,18113.5,1132.0938,1216.333,-84.2393,-6.9257,50.0,44.25,53.0,45.8993,-3.0,-1.6493,46.911,227.4963,matched,3.6874
flat_0,homography,4,circle,5453.5,340.8438,380.133,-39.2892,-10.3357,20.75,21.25,22.0,22.0,-1.25,-0.75,95.4234,56.5901,matched,1.6455
flat_0,homography,5,square,5758.0,359.875,400.0,-40.125,-10.0312,19.0,19.5,20.0,20.0,-1.0,-0.5,95.3812,114.6463,matched,0.52
flat_0,homography,6,rect,5564.5,347.7812,384.0,-36.2188,-9.432,31.0,11.75,32.0,12.0,-1.0,-0.25,95.349,163.4296,matched,1.6087
flat_0,homography,7,triangle,5736.0,358.5,389.711,-31.211,-8.0088,27.5,24.75,30.0,25.9808,-2.5,-1.2308,95.3839,224.2822,matched,3.072
flat_0,homography,8,circle,2160.5,135.0312,153.938,-18.9067,-12.2821,13.25,13.5,14.0,14.0,-0.75,-0.5,129.0614,56.565,matched,1.8249
flat_0,homography,9,square,2039.5,127.4688,144.0,-16.5312,-11.48,11.75,11.75,12.0,12.0,-0.25,-0.25,129.0834,114.7974,matched,0.9388
flat_0,homography,10,rect,2059.5,128.7188,142.5,-13.7812,-9.6711,18.5,7.5,19.0,7.5,-0.5,0.0,129.0962,163.4255,matched,1.8154
flat_0,homography,11,triangle,2002.5,125.1562,140.296,-15.1397,-10.7913,16.0,14.5,18.0,15.5885,-2.0,-1.0885,129.1972,222.6281,matched,3.0766
flat_0,homography,12,circle,648.0,40.5,50.265,-9.765,-19.427,7.25,7.25,8.0,8.0,-0.75,-0.75,153.093,56.527,matched,2.443
flat_0,homography,13,square,651.0,40.6875,49.0,-8.3125,-16.9643,6.75,6.75,7.0,7.0,-0.25,-0.25,153.2926,114.7283,matched,1.7289
flat_0,homography,14,rect,617.5,38.5938,46.2,-7.6063,-16.4637,10.75,4.0,11.0,4.2,-0.25,-0.2,153.2626,163.3805,matched,2.3751
flat_0,homography,15,triangle,720.5,45.0312,52.395,-7.3638,-14.0543,9.25,8.5,11.0,9.5263,-1.75,-1.0263,153.3965,221.6455,matched,3.3508
flat_0,homography,16,circle,281.0,17.5625,23.758,-6.1955,-26.0775,5.0,4.75,5.5,5.5,-0.5,-0.75,172.3559,56.5405,matched,3.0601
flat_0,homography,17,square,314.0,19.625,25.0,-5.375,-21.5,4.75,4.75,5.0,5.0,-0.25,-0.25,172.5387,114.7321,matched,2.4758
flat_0,homography,18,rect,294.0,18.375,24.0,-5.625,-23.4375,7.5,3.0,8.0,3.0,-0.5,0.0,172.5488,163.4528,matched,2.8987
flat_0,homography,19,triangle,288.0,18.0,24.357,-6.357,-26.0993,5.75,5.25,7.5,6.4952,-1.75,-1.2452,172.7504,221.072,matched,3.7581
flat_0,affine,0,circle,19048.5,1190.5312,1256.637,-66.1057,-5.2605,39.0,39.25,40.0,40.0,-1.0,-0.75,46.1494,54.4894,matched,1.2577
flat_0,affine,1,square,18535.0,1158.4375,1225.0,-66.5625,-5.4337,34.5,35.0,35.0,35.0,-0.5,0.0,46.5058,113.5474,matched,2.0922
flat_0,affine,2,rect,19059.0,1191.1875,1254.0,-62.8125,-5.009,56.0,22.25,57.0,22.0,-1.0,0.25,46.6386,162.7991,matched,2.7439
flat_0,affine,3,triangle,18392.0,1149.5,1216.333,-66.833,-5.4946,50.5,44.25,53.0,45.8993,-2.5,-1.6493,46.949,227.369,matched,3.8162
flat_0,affine,4,circle,5594.5,349.6562,380.133,-30.4767,-8.0174,21.0,21.5,22.0,22.0,-1.0,-0.5,95.0101,55.2967,matched,0.2969
flat_0,affine,5,square,5890.5,368.1562,400.0,-31.8438,-7.9609,19.25,19.75,20.0,20.0,-0.75,-0.25,95.2771,114.2137,matched,0.8337
flat_0,affine,6,rect,5645.0,352.8125,384.0,-31.1875,-8.1217,31.0,12.0,32.0,12.0,-1.0,0.0,95.3623,163.2479,matched,1.7892
flat_0,affine,7,triangle,5740.0,358.75,389.711,-30.961,-7.9446,27.5,24.5,30.0,25.9808,-2.5,-1.4808,95.7224,224.3516,matched,3.0649
flat_0,affine,8,circle,2199.5,137.4688,153.938,-16.4692,-10.6986,13.25,13.5,14.0,14.0,-0.75,-0.5,128.9755,55.8249,matched,1.3153
flat_0,affine,9,square,2021.0,126.3125,144.0,-17.6875,-12.283,11.5,11.5,12.0,12.0,-0.5,-0.5,129.125,114.625,matched,0.952
flat_0,affine,10,rect,2073.5,129.5938,142.5,-12.9062,-9.057,18.5,7.5,19.0,7.5,-0.5,0.0,129.393,163.6039,matched,1.5223
flat_0,affine,11,triangle,2008.5,125.5312,140.296,-14.7647,-10.524,16.0,14.5,18.0,15.5885,-2.0,-1.0885,129.6564,222.9157,matched,2.7043
flat_0,affine,12,circle,661.0,41.3125,50.265,-8.9525,-17.8106,7.5,7.5,8.0,8.0,-0.5,-0.5,153.123,56.1682,matched,2.2108
flat_0,affine,13,square,649.0,40.5625,49.0,-8.4375,-17.2194,6.5,7.0,7.0,7.0,-0.5,0.0,153.3867,114.9298,matched,1.6148
flat_0,affine,14,rect,637.5,39.8438,46.2,-6.3563,-13.7581,10.75,4.25,11.0,4.2,-0.25,0.05,153.5031,163.7913,matched,1.924
flat_0,affine,15,triangle,710.0,44.375,52.395,-8.02,-15.3068,9.0,8.5,11.0,9.5263,-2.0,-1.0263,153.8308,221.9927,matched,2.8463
flat_0,affine,16,circle,288.5,18.0312,23.758,-5.7267,-24.1045,5.0,5.0,5.5,5.5,-0.5,-0.5,172.4141,56.4809,matched,2.98
flat_0,affine,17,square,325.0,20.3125,25.0,-4.6875,-18.75,4.75,5.0,5.0,5.0,-0.25,0.0,172.7285,115.1181,matched,2.2746
flat_0,affine,18,rect,285.5,17.8438,24.0,-6.1562,-25.651,7.5,2.75,8.0,3.0,-0.5,-0.25,172.832,163.9953,matched,2.3895
flat_0,affine,19,triangle,285.0,17.8125,24.357,-6.5445,-26.8691,5.5,5.25,7.5,6.4952,-2.0,-1.2452,173.0864,221.5643,matched,3.1628

measure_tilt_15.csv。

label,transform,shape_id,shape_kind,area_px,area_mm2,truth_area_mm2,area_err_mm2,area_err_pct,w_mm,h_mm,truth_w_mm,truth_h_mm,w_err_mm,h_err_mm,cx_mm,cy_mm,match_status,match_dist_mm
tilt_15,homography,0,circle,18612.0,1163.25,1256.637,-93.387,-7.4315,39.0,38.5,40.0,40.0,-1.0,-1.5,48.7651,57.6014,matched,4.5764
tilt_15,homography,1,square,18138.0,1133.625,1225.0,-91.375,-7.4592,34.5,33.75,35.0,35.0,-0.5,-1.25,48.7751,115.3028,matched,3.7872
tilt_15,homography,2,rect,18558.0,1159.875,1254.0,-94.125,-7.506,56.0,21.25,57.0,22.0,-1.0,-0.75,48.5732,163.4149,matched,3.909
tilt_15,homography,3,triangle,18123.5,1132.7188,1216.333,-83.6143,-6.8743,50.75,44.0,53.0,45.8993,-2.25,-1.8993,48.8883,226.5994,matched,5.6147
tilt_15,homography,4,circle,5355.0,334.6875,380.133,-45.4455,-11.9552,20.5,21.0,22.0,22.0,-1.5,-1.0,96.7244,57.57,matched,3.0949
tilt_15,homography,5,square,5594.5,349.6562,400.0,-50.3438,-12.5859,18.75,19.25,20.0,20.0,-1.25,-0.75,96.7103,115.274,matched,1.7321
tilt_15,homography,6,rect,5278.0,329.875,384.0,-54.125,-14.0951,30.0,11.75,32.0,12.0,-2.0,-0.25,96.7573,163.493,matched,2.3149
tilt_15,homography,7,triangle,5411.0,338.1875,389.711,-51.5235,-13.221,26.75,24.25,30.0,25.9808,-3.25,-1.7308,96.9381,223.5491,matched,4.2488
tilt_15,homography,8,circle,2148.0,134.25,153.938,-19.688,-12.7896,13.0,13.5,14.0,14.0,-1.0,-0.5,129.5823,57.2395,matched,2.2781
tilt_15,homography,9,square,2033.5,127.0938,144.0,-16.9062,-11.7405,11.5,11.75,12.0,12.0,-0.5,-0.25,129.4239,115.2827,matched,0.6417
tilt_15,homography,10,rect,1994.5,124.6562,142.5,-17.8438,-12.5219,18.0,7.5,19.0,7.5,-1.0,0.0,129.4251,163.8514,matched,1.2844
tilt_15,homography,11,triangle,1928.0,120.5,140.296,-19.796,-14.1102,15.5,14.5,18.0,15.5885,-2.5,-1.0885,129.4994,222.6358,matched,3.0043
tilt_15,homography,12,circle,675.0,42.1875,50.265,-8.0775,-16.0698,7.25,7.5,8.0,8.0,-0.75,-0.5,153.26,57.0583,matched,2.6952
tilt_15,homography,13,square,654.5,40.9062,49.0,-8.0938,-16.5179,6.75,6.75,7.0,7.0,-0.25,-0.25,153.2331,115.283,matched,1.7894
tilt_15,homography,14,rect,639.0,39.9375,46.2,-6.2625,-13.5552,10.5,4.25,11.0,4.2,-0.5,0.05,153.3491,163.9941,matched,1.9332
tilt_15,homography,15,triangle,717.0,44.8125,52.395,-7.5825,-14.4718,9.0,8.5,11.0,9.5263,-2.0,-1.0263,153.3968,221.9791,matched,3.0619
tilt_15,homography,16,circle,288.5,18.0312,23.758,-5.7267,-24.1045,5.0,4.75,5.5,5.5,-0.5,-0.75,172.6277,57.0033,matched,3.105
tilt_15,homography,17,square,314.0,19.625,25.0,-5.375,-21.5,4.75,4.75,5.0,5.0,-0.25,-0.25,172.7532,115.2883,matched,2.2652
tilt_15,homography,18,rect,293.0,18.3125,24.0,-5.6875,-23.6979,7.75,2.75,8.0,3.0,-0.25,-0.25,172.7871,163.9923,matched,2.4315
tilt_15,homography,19,triangle,288.0,18.0,24.357,-6.357,-26.0993,5.75,5.25,7.5,6.4952,-1.75,-1.2452,172.9478,221.3385,matched,3.4265
tilt_15,affine,0,circle,17936.0,1121.0,1256.637,-135.637,-10.7937,38.5,37.5,40.0,40.0,-1.5,-2.5,46.9258,63.9082,matched,9.114
tilt_15,affine,1,square,17439.0,1089.9375,1225.0,-135.0625,-11.0255,34.0,33.75,35.0,35.0,-1.0,-1.25,47.2098,120.2747,matched,5.7189
tilt_15,affine,2,rect,17846.5,1115.4062,1254.0,-138.5938,-11.0521,55.0,21.75,57.0,22.0,-2.0,-0.25,47.1378,167.1615,matched,3.0401
tilt_15,affine,3,triangle,17310.0,1081.875,1216.333,-134.458,-11.0544,49.75,43.0,53.0,45.8993,-3.25,-2.8993,47.5437,228.6374,matched,3.2435
tilt_15,affine,4,circle,5280.5,330.0312,380.133,-50.1017,-13.1801,20.75,21.0,22.0,22.0,-1.25,-1.0,94.5359,62.0972,matched,7.1124
tilt_15,affine,5,square,5522.5,345.1562,400.0,-54.8438,-13.7109,18.75,19.5,20.0,20.0,-1.25,-0.5,94.5052,118.9501,matched,3.9809
tilt_15,affine,6,rect,5218.0,326.125,384.0,-57.875,-15.0716,30.0,11.75,32.0,12.0,-2.0,-0.25,94.822,166.2771,matched,1.2895
tilt_15,affine,7,triangle,5288.5,330.5312,389.711,-59.1798,-15.1855,26.5,23.75,30.0,25.9808,-3.5,-2.2308,95.0608,225.1196,matched,2.2113
tilt_15,affine,8,circle,2151.0,134.4375,153.938,-19.5005,-12.6678,13.25,13.25,14.0,14.0,-0.75,-0.75,127.5628,60.5542,matched,6.0654
tilt_15,affine,9,square,2016.0,126.0,144.0,-18.0,-12.5,11.75,11.75,12.0,12.0,-0.25,-0.25,127.5915,117.9614,matched,3.8172
tilt_15,affine,10,rect,1974.5,123.4062,142.5,-19.0938,-13.3991,18.0,7.25,19.0,7.5,-1.0,-0.25,127.6021,165.9894,matched,2.594
tilt_15,affine,11,triangle,1921.5,120.0938,140.296,-20.2022,-14.3997,15.75,14.0,18.0,15.5885,-2.25,-1.5885,127.7555,223.8775,matched,2.8281
tilt_15,affine,12,circle,685.5,42.8438,50.265,-7.4213,-14.7642,7.5,7.5,8.0,8.0,-0.5,-0.5,151.6452,59.4205,matched,5.5494
tilt_15,affine,13,square,645.5,40.3438,49.0,-8.6562,-17.6658,6.75,6.75,7.0,7.0,-0.25,-0.25,151.733,117.3452,matched,4.0216
tilt_15,affine,14,rect,627.5,39.2188,46.2,-6.9813,-15.1109,10.75,4.25,11.0,4.2,-0.25,0.05,151.7768,165.6049,matched,3.2794
tilt_15,affine,15,triangle,724.0,45.25,52.395,-7.145,-13.6368,9.25,8.5,11.0,9.5263,-1.75,-1.0263,151.9472,222.9685,matched,3.4557
tilt_15,affine,16,circle,299.0,18.6875,23.758,-5.0705,-21.3423,5.0,5.0,5.5,5.5,-0.5,-0.5,171.4228,58.6807,matched,5.1327
tilt_15,affine,17,square,323.5,20.2188,25.0,-4.7812,-19.125,5.0,4.75,5.0,5.0,0.0,-0.25,171.5697,116.7854,matched,3.8671
tilt_15,affine,18,rect,312.0,19.5,24.0,-4.5,-18.75,7.75,3.0,8.0,3.0,-0.25,0.0,171.7631,165.1583,matched,3.2408
tilt_15,affine,19,triangle,293.0,18.3125,24.357,-6.0445,-24.8163,6.0,5.25,7.5,6.4952,-1.5,-1.2452,171.8702,222.1128,matched,3.6981

measure_tilt_30.csv。

label,transform,shape_id,shape_kind,area_px,area_mm2,truth_area_mm2,area_err_mm2,area_err_pct,w_mm,h_mm,truth_w_mm,truth_h_mm,w_err_mm,h_err_mm,cx_mm,cy_mm,match_status,match_dist_mm
tilt_30,homography,0,circle,18232.5,1139.5312,1256.637,-117.1057,-9.319,38.75,37.75,40.0,40.0,-1.25,-2.25,49.8665,55.4988,matched,4.892
tilt_30,homography,1,square,18048.5,1128.0312,1225.0,-96.9688,-7.9158,34.5,33.5,35.0,35.0,-0.5,-1.5,50.3472,112.4198,matched,5.9372
tilt_30,homography,2,rect,18662.0,1166.375,1254.0,-87.625,-6.9876,56.25,21.75,57.0,22.0,-0.75,-0.25,49.9868,160.0852,matched,7.0017
tilt_30,homography,3,triangle,18571.0,1160.6875,1216.333,-55.6455,-4.5749,52.0,44.5,53.0,45.8993,-1.0,-1.3993,50.9405,223.1674,matched,9.5539
tilt_30,homography,4,circle,5078.0,317.375,380.133,-62.758,-16.5095,19.75,20.75,22.0,22.0,-2.25,-1.25,96.2721,54.9059,matched,1.2755
tilt_30,homography,5,square,5249.5,328.0938,400.0,-71.9062,-17.9766,17.75,19.25,20.0,20.0,-2.25,-0.75,96.4553,112.2155,matched,3.1419
tilt_30,homography,6,rect,5043.5,315.2188,384.0,-68.7812,-17.9118,28.5,12.0,32.0,12.0,-3.5,0.0,97.2765,160.2388,matched,5.2774
tilt_30,homography,7,triangle,5112.0,319.5,389.711,-70.211,-18.0162,24.75,24.75,30.0,25.9808,-5.25,-1.2308,98.0909,220.3279,matched,7.6541
tilt_30,homography,8,circle,2109.5,131.8438,153.938,-22.0942,-14.3527,13.0,13.5,14.0,14.0,-1.0,-0.5,127.8413,54.3591,matched,2.2519
tilt_30,homography,9,square,1933.0,120.8125,144.0,-23.1875,-16.1024,11.0,11.75,12.0,12.0,-1.0,-0.25,127.6337,112.1341,matched,3.7165
tilt_30,homography,10,rect,1903.0,118.9375,142.5,-23.5625,-16.5351,17.25,7.5,19.0,7.5,-1.75,0.0,128.1001,160.7949,matched,4.6144
tilt_30,homography,11,triangle,1829.5,114.3438,140.296,-25.9522,-18.4982,14.75,14.5,18.0,15.5885,-3.25,-1.0885,128.5076,220.0758,matched,5.7204
tilt_30,homography,12,circle,683.5,42.7188,50.265,-7.5463,-15.0129,7.5,7.5,8.0,8.0,-0.5,-0.5,151.8263,54.2471,matched,3.2618
tilt_30,homography,13,square,687.0,42.9375,49.0,-6.0625,-12.3724,7.0,6.75,7.0,7.0,0.0,-0.25,151.6286,112.2391,matched,4.3576
tilt_30,homography,14,rect,667.0,41.6875,46.2,-4.5125,-9.7673,11.0,4.25,11.0,4.2,0.0,0.05,151.5905,160.9947,matched,5.26
tilt_30,homography,15,triangle,719.5,44.9688,52.395,-7.4263,-14.1736,9.25,8.5,11.0,9.5263,-1.75,-1.0263,151.7442,219.7048,matched,5.8689
tilt_30,homography,16,circle,298.5,18.6562,23.758,-5.1017,-21.4738,5.0,5.25,5.5,5.5,-0.5,-0.25,171.8347,54.4264,matched,3.2168
tilt_30,homography,17,square,322.0,20.125,25.0,-4.875,-19.5,4.75,4.75,5.0,5.0,-0.25,-0.25,171.75,112.25,matched,4.2573
tilt_30,homography,18,rect,320.5,20.0312,24.0,-3.9688,-16.5365,7.75,3.0,8.0,3.0,-0.25,0.0,171.7552,160.8695,matched,5.2526
tilt_30,homography,19,triangle,307.0,19.1875,24.357,-5.1695,-21.2239,5.75,5.75,7.5,6.4952,-1.75,-0.7452,171.5423,219.2191,matched,5.9673
tilt_30,affine,2,rect,16898.0,1056.125,1254.0,-197.875,-15.7795,55.0,23.0,57.0,22.0,-2.0,1.0,45.1712,170.6257,matched,5.6283
tilt_30,affine,3,triangle,16535.5,1033.4688,1216.333,-182.8643,-15.0341,49.75,41.5,53.0,45.8993,-3.25,-4.3993,46.8868,229.1799,matched,2.3918
tilt_30,affine,5,square,5085.0,317.8125,400.0,-82.1875,-20.5469,18.0,19.25,20.0,20.0,-2.0,-0.75,90.1074,122.5143,matched,8.9667
tilt_30,affine,6,rect,4833.5,302.0938,384.0,-81.9062,-21.3298,28.5,12.0,32.0,12.0,-3.5,0.0,91.5076,168.4111,matched,4.8819
tilt_30,affine,7,triangle,4820.0,301.25,389.711,-88.461,-22.6991,25.0,23.0,30.0,25.9808,-5.0,-2.9808,93.002,225.301,matched,2.8477
tilt_30,affine,9,square,1963.0,122.6875,144.0,-21.3125,-14.8003,11.5,11.75,12.0,12.0,-0.5,-0.25,121.9542,120.2667,matched,9.6163
tilt_30,affine,10,rect,1896.0,118.5,142.5,-24.0,-16.8421,17.75,7.5,19.0,7.5,-1.25,0.0,122.8603,167.4022,matched,7.533
tilt_30,affine,11,triangle,1795.5,112.2188,140.296,-28.0772,-20.0129,15.0,13.75,18.0,15.5885,-3.0,-1.8385,123.7704,224.1817,matched,6.3886
tilt_30,affine,13,square,712.0,44.5,49.0,-4.5,-9.1837,7.25,7.0,7.0,7.0,0.25,0.0,147.0021,118.6394,matched,8.787
tilt_30,affine,14,rect,665.5,41.5938,46.2,-4.6063,-9.9702,11.25,4.5,11.0,4.2,0.25,0.3,147.2649,166.3915,matched,7.8593
tilt_30,affine,15,triangle,727.0,45.4375,52.395,-6.9575,-13.2789,9.5,8.25,11.0,9.5263,-1.5,-1.2763,147.7634,223.2008,matched,7.3683
tilt_30,affine,16,circle,326.0,20.375,23.758,-3.383,-14.2394,5.5,5.0,5.5,5.5,0.0,-0.5,168.1964,59.5625,matched,8.1917
tilt_30,affine,17,square,350.0,21.875,25.0,-3.125,-12.5,5.5,5.0,5.0,5.0,0.5,0.0,168.3907,117.1679,matched,6.9557
tilt_30,affine,18,rect,327.5,20.4688,24.0,-3.5312,-14.7135,8.5,3.0,8.0,3.0,0.5,0.0,168.6036,165.1277,matched,6.3977
tilt_30,affine,19,triangle,319.5,19.9688,24.357,-4.3882,-18.0164,6.25,5.75,7.5,6.4952,-1.25,-0.7452,168.5995,222.1212,matched,6.6943
tilt_30,affine,0,circle,,,1256.637,,,,,40.0,40.0,,,,,truth_unmatched,
tilt_30,affine,1,square,,,1225.0,,,,,35.0,35.0,,,,,truth_unmatched,
tilt_30,affine,4,circle,,,380.133,,,,,22.0,22.0,,,,,truth_unmatched,
tilt_30,affine,8,circle,,,153.938,,,,,14.0,14.0,,,,,truth_unmatched,
tilt_30,affine,12,circle,,,50.265,,,,,8.0,8.0,,,,,truth_unmatched,
tilt_30,affine,,,16558.5,1034.9062,,,,34.25,33.75,,,,,44.5361,125.822,extra_detection,
tilt_30,affine,,,726.5,45.4062,,,,8.0,7.5,,,,,146.8369,61.3046,extra_detection,
tilt_30,affine,,,2164.0,135.25,,,,13.5,13.25,,,,,121.6635,63.7003,extra_detection,
tilt_30,affine,,,4998.0,312.375,,,,19.75,20.25,,,,,89.2684,67.1734,extra_detection,
tilt_30,affine,,,16995.0,1062.1875,,,,38.0,36.0,,,,,43.1908,71.9013,extra_detection,

measure_tilt_45.csv。

label,transform,shape_id,shape_kind,area_px,area_mm2,truth_area_mm2,area_err_mm2,area_err_pct,w_mm,h_mm,truth_w_mm,truth_h_mm,w_err_mm,h_err_mm,cx_mm,cy_mm,match_status,match_dist_mm
tilt_45,homography,0,circle,17593.5,1099.5938,1256.637,-157.0432,-12.4971,37.0,38.0,40.0,40.0,-3.0,-2.0,44.8491,55.217,matched,0.2644
tilt_45,homography,1,square,17166.5,1072.9062,1225.0,-152.0938,-12.4158,33.5,34.0,35.0,35.0,-1.5,-1.0,46.7041,112.6814,matched,2.8775
tilt_45,homography,2,rect,18155.0,1134.6875,1254.0,-119.3125,-9.5146,54.25,21.5,57.0,22.0,-2.75,-0.5,48.1779,160.8902,matched,5.1952
tilt_45,homography,3,triangle,18140.5,1133.7812,1216.333,-82.5518,-6.7869,50.75,44.25,53.0,45.8993,-2.25,-1.6493,50.0663,224.8726,matched,7.684
tilt_45,homography,4,circle,5308.0,331.75,380.133,-48.383,-12.7279,20.25,21.25,22.0,22.0,-1.75,-0.75,91.2529,54.5251,matched,3.7771
tilt_45,homography,5,square,5542.5,346.4062,400.0,-53.5938,-13.3984,19.5,19.5,20.0,20.0,-0.5,-0.5,92.7334,112.2142,matched,3.5914
tilt_45,homography,6,rect,5363.0,335.1875,384.0,-48.8125,-12.7116,29.75,11.75,32.0,12.0,-2.25,-0.25,94.9318,160.7607,matched,4.2398
tilt_45,homography,7,triangle,5265.0,329.0625,389.711,-60.6485,-15.5624,25.25,24.5,30.0,25.9808,-4.75,-1.4808,96.7572,221.7026,matched,5.8955
tilt_45,homography,8,circle,2171.0,135.6875,153.938,-18.2505,-11.8557,13.25,13.25,14.0,14.0,-0.75,-0.75,123.9603,54.0606,matched,6.1123
tilt_45,homography,9,square,2021.5,126.3438,144.0,-17.6562,-12.2613,11.75,11.75,12.0,12.0,-0.25,-0.25,124.8182,111.9784,matched,5.9984
tilt_45,homography,10,rect,1901.5,118.8438,142.5,-23.6562,-16.6009,17.0,7.5,19.0,7.5,-2.0,0.0,126.6189,160.8116,matched,5.3829
tilt_45,homography,11,triangle,1876.5,117.2812,140.296,-23.0147,-16.4044,14.75,14.5,18.0,15.5885,-3.25,-1.0885,127.7141,220.6415,matched,5.4583
tilt_45,homography,12,circle,721.5,45.0938,50.265,-5.1713,-10.288,8.0,7.5,8.0,8.0,0.0,-0.5,148.5449,54.056,matched,6.5238
tilt_45,homography,13,square,690.0,43.125,49.0,-5.875,-11.9898,6.75,7.0,7.0,7.0,-0.25,0.0,148.9987,111.8703,matched,6.7684
tilt_45,homography,14,rect,600.5,37.5312,46.2,-8.6688,-18.7635,10.5,4.0,11.0,4.2,-0.5,-0.2,149.7875,160.8725,matched,6.6488
tilt_45,homography,15,triangle,739.0,46.1875,52.395,-6.2075,-11.8475,9.5,8.75,11.0,9.5263,-1.5,-0.7763,150.8474,219.928,matched,6.2415
tilt_45,homography,16,circle,323.0,20.1875,23.758,-3.5705,-15.0286,5.25,5.25,5.5,5.5,-0.25,-0.25,169.0562,54.2624,matched,5.9894
tilt_45,homography,17,square,346.5,21.6562,25.0,-3.3438,-13.375,5.0,5.0,5.0,5.0,0.0,0.0,169.6216,111.8068,matched,6.2549
tilt_45,homography,18,rect,333.5,20.8438,24.0,-3.1562,-13.151,8.25,3.0,8.0,3.0,0.25,0.0,170.3353,160.6423,matched,6.3835
tilt_45,homography,19,triangle,317.0,19.8125,24.357,-4.5445,-18.6579,6.0,5.5,7.5,6.4952,-1.5,-0.9952,170.7885,219.2551,matched,6.4063
tilt_45,affine,3,triangle,14419.5,901.2188,1216.333,-315.1143,-25.9069,46.5,39.0,53.0,45.8993,-6.5,-6.8993,43.0077,235.362,matched,5.116
tilt_45,affine,7,triangle,4669.5,291.8438,389.711,-97.8673,-25.1128,25.0,22.25,30.0,25.9808,-5.0,-3.7308,87.3527,230.3979,matched,8.2397
tilt_45,affine,13,square,387.5,24.2188,49.0,-24.7812,-50.574,5.75,5.0,7.0,7.0,-1.25,-2.0,162.9824,120.8734,matched,9.9104
tilt_45,affine,14,rect,351.5,21.9688,46.2,-24.2313,-52.4486,9.25,3.5,11.0,4.2,-1.75,-0.7,164.1023,168.1649,matched,9.6368
tilt_45,affine,15,triangle,339.5,21.2188,52.395,-31.1763,-59.5023,6.75,5.5,11.0,9.5263,-4.25,-4.0263,164.9305,224.1424,matched,9.9405
tilt_45,affine,0,circle,,,1256.637,,,,,40.0,40.0,,,,,truth_unmatched,
tilt_45,affine,1,square,,,1225.0,,,,,35.0,35.0,,,,,truth_unmatched,
tilt_45,affine,2,rect,,,1254.0,,,,,57.0,22.0,,,,,truth_unmatched,
tilt_45,affine,4,circle,,,380.133,,,,,22.0,22.0,,,,,truth_unmatched,
tilt_45,affine,5,square,,,400.0,,,,,20.0,20.0,,,,,truth_unmatched,
tilt_45,affine,6,rect,,,384.0,,,,,32.0,12.0,,,,,truth_unmatched,
tilt_45,affine,8,circle,,,153.938,,,,,14.0,14.0,,,,,truth_unmatched,
tilt_45,affine,9,square,,,144.0,,,,,12.0,12.0,,,,,truth_unmatched,
tilt_45,affine,10,rect,,,142.5,,,,,19.0,7.5,,,,,truth_unmatched,
tilt_45,affine,11,triangle,,,140.296,,,,,18.0,15.5885,,,,,truth_unmatched,
tilt_45,affine,12,circle,,,50.265,,,,,8.0,8.0,,,,,truth_unmatched,
tilt_45,affine,16,circle,,,23.758,,,,,5.5,5.5,,,,,truth_unmatched,
tilt_45,affine,17,square,,,25.0,,,,,5.0,5.0,,,,,truth_unmatched,
tilt_45,affine,18,rect,,,24.0,,,,,8.0,3.0,,,,,truth_unmatched,
tilt_45,affine,19,triangle,,,24.357,,,,,7.5,6.4952,,,,,truth_unmatched,
tilt_45,affine,,,740.0,46.25,,,,9.75,8.5,,,,,143.1472,225.8497,extra_detection,
tilt_45,affine,,,1772.0,110.75,,,,15.25,13.5,,,,,118.6998,227.7631,extra_detection,
tilt_45,affine,,,4846.0,302.875,,,,30.0,13.0,,,,,84.6779,175.6783,extra_detection,
tilt_45,affine,,,1870.5,116.9062,,,,18.0,8.25,,,,,116.8628,172.7121,extra_detection,
tilt_45,affine,,,639.0,39.9375,,,,11.25,4.75,,,,,141.5112,170.4574,extra_detection,
tilt_45,affine,,,14710.0,919.375,,,,50.5,23.25,,,,,40.2094,179.9524,extra_detection,
tilt_45,affine,,,5094.0,318.375,,,,19.75,19.5,,,,,81.5122,131.7325,extra_detection,
tilt_45,affine,,,1991.5,124.4688,,,,12.25,12.25,,,,,114.2933,127.2821,extra_detection,
tilt_45,affine,,,14135.5,883.4688,,,,31.75,33.25,,,,,37.336,137.7217,extra_detection,
tilt_45,affine,,,722.5,45.1562,,,,7.5,7.0,,,,,140.1181,123.8732,extra_detection,
tilt_45,affine,,,14784.5,924.0312,,,,34.75,34.75,,,,,34.2847,86.7865,extra_detection,
tilt_45,affine,,,4965.0,310.3125,,,,20.5,20.0,,,,,78.9796,78.627,extra_detection,
tilt_45,affine,,,2199.5,137.4688,,,,14.0,13.25,,,,,112.5807,72.5144,extra_detection,
tilt_45,affine,,,782.0,48.875,,,,8.75,7.75,,,,,139.0241,68.0286,extra_detection,
tilt_45,affine,,,360.0,22.5,,,,6.0,5.25,,,,,161.9288,64.3366,extra_detection,

measure_baseline.csv。

label,transform,shape_id,shape_kind,area_px,area_mm2,truth_area_mm2,area_err_mm2,area_err_pct,w_mm,h_mm,truth_w_mm,truth_h_mm,w_err_mm,h_err_mm,cx_mm,cy_mm,match_status,match_dist_mm
baseline_area,INTER_AREA,0,circle,19937.0,1246.0625,1256.637,-10.5745,-0.8415,40.0,40.0,40.0,40.0,0.0,0.0,44.8764,54.8798,matched,0.1724
baseline_area,INTER_AREA,1,square,19321.0,1207.5625,1225.0,-17.4375,-1.4235,35.0,35.0,35.0,35.0,0.0,0.0,44.875,114.875,matched,0.1768
baseline_area,INTER_AREA,2,rect,19749.0,1234.3125,1254.0,-19.6875,-1.57,57.0,22.0,57.0,22.0,0.0,0.0,44.875,164.875,matched,0.1768
baseline_area,INTER_AREA,3,triangle,19236.0,1202.25,1216.333,-14.083,-1.1578,52.5,45.25,53.0,45.8993,-0.5,-0.6493,44.8732,230.5418,matched,0.1666
baseline_area,INTER_AREA,4,circle,5992.0,374.5,380.133,-5.633,-1.4818,22.0,22.0,22.0,22.0,0.0,0.0,94.8734,54.8828,matched,0.1725
baseline_area,INTER_AREA,5,square,6241.0,390.0625,400.0,-9.9375,-2.4844,20.0,20.0,20.0,20.0,0.0,0.0,94.875,114.875,matched,0.1768
baseline_area,INTER_AREA,6,rect,5969.0,373.0625,384.0,-10.9375,-2.8483,32.0,12.0,32.0,12.0,0.0,0.0,94.875,164.875,matched,0.1768
baseline_area,INTER_AREA,7,triangle,6092.5,380.7812,389.711,-8.9298,-2.2914,29.5,25.5,30.0,25.9808,-0.5,-0.4808,94.8743,227.188,matched,0.1898
baseline_area,INTER_AREA,8,circle,2402.0,150.125,153.938,-3.813,-2.477,14.0,14.0,14.0,14.0,0.0,0.0,129.876,54.8777,matched,0.1742
baseline_area,INTER_AREA,9,square,2209.0,138.0625,144.0,-5.9375,-4.1233,12.0,12.0,12.0,12.0,0.0,0.0,129.875,114.875,matched,0.1768
baseline_area,INTER_AREA,10,rect,2175.0,135.9375,142.5,-6.5625,-4.6053,19.0,7.5,19.0,7.5,0.0,0.0,129.875,164.875,matched,0.1768
baseline_area,INTER_AREA,11,triangle,2143.0,133.9375,140.296,-6.3585,-4.5322,17.5,15.0,18.0,15.5885,-0.5,-0.5885,129.8715,225.4273,matched,0.2138
baseline_area,INTER_AREA,12,circle,767.0,47.9375,50.265,-2.3275,-4.6305,8.0,8.0,8.0,8.0,0.0,0.0,154.875,54.875,matched,0.1768
baseline_area,INTER_AREA,13,square,729.0,45.5625,49.0,-3.4375,-7.0153,7.0,7.0,7.0,7.0,0.0,0.0,154.875,114.875,matched,0.1768
baseline_area,INTER_AREA,14,rect,688.0,43.0,46.2,-3.2,-6.9264,11.0,4.25,11.0,4.2,0.0,0.05,154.875,165.0,matched,0.125
baseline_area,INTER_AREA,15,triangle,781.0,48.8125,52.395,-3.5825,-6.8375,10.5,9.0,11.0,9.5263,-0.5,-0.5263,154.8724,224.4396,matched,0.1955
baseline_area,INTER_AREA,16,circle,361.0,22.5625,23.758,-1.1955,-5.032,5.5,5.5,5.5,5.5,0.0,0.0,174.875,54.875,matched,0.1768
baseline_area,INTER_AREA,17,square,361.0,22.5625,25.0,-2.4375,-9.75,5.0,5.0,5.0,5.0,0.0,0.0,174.875,114.875,matched,0.1768
baseline_area,INTER_AREA,18,rect,341.0,21.3125,24.0,-2.6875,-11.1979,8.0,3.0,8.0,3.0,0.0,0.0,174.875,164.875,matched,0.1768
baseline_area,INTER_AREA,19,triangle,352.0,22.0,24.357,-2.357,-9.6769,7.0,6.0,7.5,6.4952,-0.5,-0.4952,174.8835,223.9464,matched,0.1792
baseline_linear,INTER_LINEAR,0,circle,19924.0,1245.25,1256.637,-11.387,-0.9061,40.0,40.0,40.0,40.0,0.0,0.0,44.876,54.879,matched,0.1733
baseline_linear,INTER_LINEAR,1,square,19321.0,1207.5625,1225.0,-17.4375,-1.4235,35.0,35.0,35.0,35.0,0.0,0.0,44.875,114.875,matched,0.1768
baseline_linear,INTER_LINEAR,2,rect,19749.0,1234.3125,1254.0,-19.6875,-1.57,57.0,22.0,57.0,22.0,0.0,0.0,44.875,164.875,matched,0.1768
baseline_linear,INTER_LINEAR,3,triangle,19226.0,1201.625,1216.333,-14.708,-1.2092,52.5,45.25,53.0,45.8993,-0.5,-0.6493,44.873,230.544,matched,0.1653
baseline_linear,INTER_LINEAR,4,circle,5980.0,373.75,380.133,-6.383,-1.6791,22.0,22.0,22.0,22.0,0.0,0.0,94.87,54.8755,matched,0.18
baseline_linear,INTER_LINEAR,5,square,6241.0,390.0625,400.0,-9.9375,-2.4844,20.0,20.0,20.0,20.0,0.0,0.0,94.875,114.875,matched,0.1768
baseline_linear,INTER_LINEAR,6,rect,5969.0,373.0625,384.0,-10.9375,-2.8483,32.0,12.0,32.0,12.0,0.0,0.0,94.875,164.875,matched,0.1768
baseline_linear,INTER_LINEAR,7,triangle,6084.5,380.2812,389.711,-9.4298,-2.4197,29.5,25.5,30.0,25.9808,-0.5,-0.4808,94.8772,227.1934,matched,0.1838
baseline_linear,INTER_LINEAR,8,circle,2399.0,149.9375,153.938,-4.0005,-2.5988,14.0,14.0,14.0,14.0,0.0,0.0,129.8806,54.875,matched,0.1728
baseline_linear,INTER_LINEAR,9,square,2209.0,138.0625,144.0,-5.9375,-4.1233,12.0,12.0,12.0,12.0,0.0,0.0,129.875,114.875,matched,0.1768
baseline_linear,INTER_LINEAR,10,rect,2175.0,135.9375,142.5,-6.5625,-4.6053,19.0,7.5,19.0,7.5,0.0,0.0,129.875,164.875,matched,0.1768
baseline_linear,INTER_LINEAR,11,triangle,2138.0,133.625,140.296,-6.671,-4.7549,17.5,15.0,18.0,15.5885,-0.5,-0.5885,129.8763,225.4379,matched,0.2024
baseline_linear,INTER_LINEAR,12,circle,767.0,47.9375,50.265,-2.3275,-4.6305,8.0,8.0,8.0,8.0,0.0,0.0,154.875,54.875,matched,0.1768
baseline_linear,INTER_LINEAR,13,square,729.0,45.5625,49.0,-3.4375,-7.0153,7.0,7.0,7.0,7.0,0.0,0.0,154.875,114.875,matched,0.1768
baseline_linear,INTER_LINEAR,14,rect,688.0,43.0,46.2,-3.2,-6.9264,11.0,4.25,11.0,4.2,0.0,0.05,154.875,165.0,matched,0.125
baseline_linear,INTER_LINEAR,15,triangle,778.0,48.625,52.395,-3.77,-7.1953,10.5,9.0,11.0,9.5263,-0.5,-0.5263,154.875,224.4454,matched,0.1894
baseline_linear,INTER_LINEAR,16,circle,357.0,22.3125,23.758,-1.4455,-6.0843,5.5,5.5,5.5,5.5,0.0,0.0,174.889,54.889,matched,0.157
baseline_linear,INTER_LINEAR,17,square,361.0,22.5625,25.0,-2.4375,-9.75,5.0,5.0,5.0,5.0,0.0,0.0,174.875,114.875,matched,0.1768
baseline_linear,INTER_LINEAR,18,rect,341.0,21.3125,24.0,-2.6875,-11.1979,8.0,3.0,8.0,3.0,0.0,0.0,174.875,164.875,matched,0.1768
baseline_linear,INTER_LINEAR,19,triangle,348.0,21.75,24.357,-2.607,-10.7033,7.0,5.75,7.5,6.4952,-0.5,-0.7452,174.875,223.9741,matched,0.1655

tilt_45 の射影変換の行列と、その meta。

json

{
  "set_dir": "tilt_45_20260807_082710",
  "label": "tilt_45",
  "corner_order": ["top_left", "top_right", "bottom_right", "bottom_left"],
  "sheet_orientation": "landscape",
  "corner_mapping": {
    "top_left": [839, 0],
    "top_right": [839, 1187],
    "bottom_right": [0, 1187],
    "bottom_left": [0, 0]
  },
  "corners_r1": [[649.0, 453.0], [1331.0, 470.0], [1273.0, 821.0], [696.0, 823.0]],
  "corners_r2": [[653.0, 456.0], [1329.0, 466.0], [1275.0, 819.0], [698.0, 821.0]],
  "corners_mean": [[651.0, 454.5], [1330.0, 468.0], [1274.0, 820.0], [697.0, 822.0]],
  "H_homography": [
    [-0.006469958698703434, -1.8665830845758953, 1538.8408567343824],
    [1.3716761100402926, -0.17169279200504367, -814.9267736699381],
    [-4.5270583562462254e-05, -0.00033568907285985386, 1.0]
  ],
  "M_affine": [
    [0.04724020286615179, -2.376007240453112, 1888.1419187200745],
    [1.7426469361538848, 0.2772392852972083, -1260.46841060376]
  ],
  "dst_size": [840, 1188],
  "px_per_mm": 4.0,
  "src_image_size": [1920, 1080],
  "camera_matrix_approx": [[2304.0, 0.0, 960.0], [0.0, 2304.0, 540.0], [0.0, 0.0, 1.0]],
  "edge_len_px": {
    "top": 679.1341914526171,
    "right": 356.4267105591274,
    "bottom": 577.0034661940949,
    "left": 370.3677226757213
  },
  "edge_ratio_top_bottom": 1.1770019267512806,
  "grid_step_mm": 10.0,
  "created_at": "2026-08-07T11:39:39.094453"
}

Log#8 で確定したこと

論点

結果

射影変換の対応数

全条件 20/20

アフィンの対応数

20 / 20 / 15 / 5(傾きで崩れる)

クリックの揺れ

平均 3.39px / 最大 6.00px(n=16)

面積誤差率(生)

全条件 13〜15% で横ばい

縮小の基準線

−4.71%(サイズ依存・最大 −11.2%)

基準線を引いた残差

−8.6〜−10.0% で横ばい

輪郭の削れ幅

0.41〜0.48mm・図形サイズによらず一定

削れ幅のばらつき

標準偏差 0.169 → 0.310(傾きで倍増)

位置による勾配

x方向のみ・両端比 2.28 → 5.02

位置とサイズの交絡

このシート設計では分離できない

傾き角の逆算

近似カメラ行列では絶対値が取れない(正対で 4.56 度)

Log#7 が残した宿題(傾いた平面には共通の px/mm が存在しない)には、射影変換で答えが出た。傾けても図形20個が正しい位置に来るので、直した後なら Log#6 の換算率が使える。

そのうえで、面積を絶対値で扱う場面には別の制約があることが分かった。輪郭が 0.45mm 程度削れる。これは傾きとは無関係で、Log#6 の正対測定にも同じだけ乗っていた。

次にやること

削れ幅を減らすか、見込んで補正するかは、まだ手をつけていない。色の切り分け方(HSV閾値)を変えれば削れ幅は動くはずだが、緩めると背景を拾う方向に振れる。どこで釣り合うかは測っていない。

位置とサイズの交絡は、シートを作り直せば解ける。同じサイズの図形を横一列に並べれば、位置による差だけが取り出せる。

第2部「位置を合わせる」はここで完結する。次は第3部の Log#9「検出の当たり外れを数字にする ― IoU・Precision・Recall・mAP」で、使う画像は生成(正解の枠が既知)になる。