# 画像認識の記事の中身を表紙で伝える ― サムネイル13本を実写と数字に

- 記録: LOG472（ひとり研究所の裏側）
- 公開: 2026/11/8
- 記事のページ: https://pixelstomotors.com/record/vision-thumbnails-real-photo-one-color
- サイト: Pixels to Motors（ADAPT DESIGN / 島津 栄一） https://pixelstomotors.com/
- 要約: 画像認識と物体検出のシリーズの記事サムネイル13枚を、その回で撮った実写を白黒にし、測った数字と色1つを重ねる形に作り直した。一覧のカードだけで、各回が何を確かめ何が分かったかを伝えるため。

> AI が読むための記事の全文です（コード・実行ログを含む）。引用するときは上の「記事のページ」の住所を使ってください。

---

**画像認識と物体検出のシリーズの記事サムネイル13枚を、その回で撮った実写を白黒にし、その回で測った数字と、色を1つだけ重ねる形に作り直した。** 記事一覧のカードを見ただけで、各回が何を確かめて何が分かったかを、写真と数字で読み取れるようにするためだ。たとえば光の色を変える回なら、同じ青い面の色相（色合いを角度で表した数字）が 206° から 51° に動いたことが、2枚の写真と数字でカードに出る。

作り直したことで、次のことが変わった。

- シリーズのページ（pixelstomotors.com/series7 ）を開いた人が、記事を開く前に、各回で使った写真と、その回で測った数字を見られる。前のサムネイルは多くが図形を描いた絵で、何を測ったかは記事を開くまで分からなかった
- カードの数字は、記事の実測値か、写真から計算し直した値だけにした。光の色を変えたときに赤の判定が画面の何割を拾うかは、記事と同じしきい値で計算し直して 96.3% になり、記事の値と一致した
- 13枚は、スクリプト1本（make.py）で作り直せる。2コアの CPU で、13枚を通しで作って約5秒。前のサムネイルは1枚ずつ HTML で手で組んでいて、作るのにかかった時間は測っていない

サムネイルは、記事一覧のカードの上に出る絵のこと。画像認識と物体検出のシリーズは、ロボットのカメラが物を見つける仕組みを下の層から学び直す記録で、どの回も、机の上のロボットアーム（mechArm 270）やブロックを撮った写真で1つずつ測っている。ところがカードの絵は、灰色の四角や丸を並べた記号の絵や、英語の見出しと図を組んだ白黒の図が多かった。写真で確かめているシリーズなのに、カードには写真がない。並べると、どれも同じ種類の絵に見えた。

![作り直す前のシリーズのページの記録の一覧。英語の見出しと、図形を描いた白黒の絵が並んでいる](https://pixelstomotors.com/media/LOG472_01_series7_before_upper.png)

![作り直す前の一覧の下の段。円や四角、時計の針のような図形の絵が続いている](https://pixelstomotors.com/media/LOG472_02_series7_before_lower.png)

やったことの全体は次のとおり。

| 順 | やったこと | なぜ | 結果 |
|---|---|---|---|
| 1 | トップのカードと同じ形（同じ写真を4列に並べ、左から処理が進む）で3枚を試作 | トップのシリーズのカードは、赤い箱の写真が処理されていく4列の図になっている | 3枚とも同じ並べ方で、赤が多すぎた |
| 2 | 並べ方を回ごとに変え、写真を白黒にして色を1か所だけにした3枚を作り直した | 「全部縦4つでなくていい」「赤は差し色程度」と指示を受けた | この方向で進めることになった |
| 3 | 残りの回にも広げ、差し色を回ごとに選んだ | 「差し色は赤に絞らなくていい」と指示を受けた | Log#1〜#12 の12枚を公開 |
| 4 | LOG236 のサムネイルも同じ形にした | LOG236 だけ、SAM 3 の画面の写真のままだった | LOG236 を13枚目として公開 |

このサムネイルの形には、5つの考えがある。

1. 写真を主役にする。画像認識のシリーズはどの回も写真で測っているので、測った写真そのものをカードに出す
2. 写真は白黒にし、色は1つだけにする。画像認識のシリーズでは、色そのものが調べる対象になる（赤いブロック、青い面）。写真を全部カラーにすると、どの色を見ればいいのかが分からなくなる
3. 数字は1つだけにし、測った値だけを書く。カードに置く数字は、その回でいちばん大事な結果1つ
4. 並べ方は回ごとに変える。一覧ではカードが横に並ぶので、同じ並べ方が続くと、どれも同じに見える
5. 英語は小さなラベルだけにする。見出しの代わりに、写真と数字に語らせる

ここから下は、作業の記録になる。


## 作業ログ ― トップのカードと同じ4列の形で3枚を試作した

最初は、トップのシリーズのカードに形を合わせた。トップのカード（tools/series_cards/series7.py で作っている）は、赤い箱の写真を4列に並べ、左から 画素 → 白黒 → しきい値 → 輪郭 と処理が進む順に見せる図になっている。同じ形で、Log#2（画素の数字）・Log#5（モルフォロジー）・Log#6（円形度）の3枚を作った。

![試作1。同じ写真を4列に並べ、左から処理が進む形の3枚。Log#2 は3列目が濃い赤のベタ、Log#5 は写真・しきい値・オープニング・クロージングの4列、Log#6 は写真・白黒・しきい値・輪郭の4列](https://pixelstomotors.com/media/LOG472_03_trial1_four_columns.jpg)

作って並べると、3枚とも縦4列で同じに見えた。Log#2 の3列目は、1画素の色をそのまま列全体に塗ったので、カードの4分の1が赤になった。これを見て、次の指示を受けた。

> 全部縦4つの分け方でなくていいよ。バリエーションを作ってください。赤は差し色程度の扱いにしてください。

差し色は、白黒やモノクロの画面の中で、1か所だけに使う色のこと。目がそこへ行く。


## 作業ログ ― 並べ方を回ごとに変え、写真を白黒にして色を1か所にした

3枚を、並べ方の違う形で作り直した。写真はどれも白黒にし、色はその回で見てほしい所だけに残した。

| 回 | 並べ方 | 色を残した所 |
|---|---|---|
| Log#2 画素の数字 | 左に写真、右に写真の一部を6×6のマスに拡大し、マスごとに赤の値を書く | 拡大したマスのうち、本当の色で塗った真ん中の1マス |
| Log#5 モルフォロジー | 上に写真の横長の帯、下にしきい値・オープニング・クロージングの3枚 | 写真の中の赤いブロックだけ |
| Log#6 円形度 | 左半分が写真、右半分が輪郭の図と数字 | 写真の中の測った円と、図の重心の十字 |

![試作2。左から、写真の一部を拡大していく Log#2、写真の帯と3枚の Log#5、左右2分割の Log#6。写真は白黒で、赤は1枚に1か所だけ](https://pixelstomotors.com/media/LOG472_04_trial2_three_layouts.jpg)

- ねらい：一覧で、どのカードも同じ種類の絵に見えないようにする。色で、その回の主役を指す
- 変わったこと：3枚とも並べ方が違う。赤はカードの中の1か所だけになった（試作1の Log#2 は、カードの4分の1が赤だった）
- 見込める効果：一覧に並べたとき、隣のカードと区別しやすくなる見込み。カードから記事を開く数が変わるかは、サイトの計測で確かめる

この方向で残りの回にも広げることになり、続けて次の指示を受けた。

> 差し色は赤に絞らないで他の色でもOK

赤いブロックが主役の回は赤のままにし、青い面の回は青、オレンジの光の回はオレンジのように、その回の中身に合った色を1つ選ぶことにした。


## 作業ログ ― 残りの回のサムネイルを、回の中身に合わせて作った

12枚それぞれで、その回の中身に合う並べ方と色を決めた。素材は、その回の記事で撮った写真か、実機の録画から取ったコマ。Log#10 だけは、記事で使った生成画像（AI に作らせた資源ごみのトレイの画像）をそのまま使った。

| 回 | 並べ方 | 差し色（どこに） | 入れた数字（どこから） | 素材 |
|---|---|---|---|---|
| #1 9つの層 | 写真＋9つの層の積み上げ | 黄（前倒しして3番目に学ぶ評価指標の層） | なし | 真上のカメラの写真（Log05） |
| #2 画素の数字 | 写真の一部を拡大していく | 赤（本当の色の1マス） | R 115 / G 17 / B 16（写真のそのマスから計算） | 赤い箱の写真（Log02 IMG_7901） |
| #3 光の色と色相 | 2枚を左右に並べる | 青（白い光の下の色相の数字） | 206° → 51°（記事の実測 205.9° → 50.9°） | 同じ配置の2枚（Log03 IMG_7913・7914） |
| #4 赤の判定の失敗 | 大きな判定結果＋4条件の写真の列 | オレンジ（オレンジの光の写真と割合） | 96.3%（記事と同じしきい値で計算し直し、一致） | 光だけ変えた4枚（Log03 IMG_7911〜7914） |
| #5 モルフォロジー | 写真の帯＋3枚 | 赤（写真の中のブロック） | 36 → 1（この絵のために計算） | 真上のカメラの写真（Log05） |
| #6 円形度 | 左右2分割 | 赤（測った円と重心） | 0.898（写真の輪郭から計算。記事の値と同じ） | 30°傾けた図形の紙（Log06） |
| #7 特徴点マッチング | 2枚を対応の線で結ぶ | 緑（対応の線と数） | 867 / 934（記事の実測） | 本の写真と、本のないマットの写真（Log07） |
| #8 射影変換 | 斜めの写真 → 真上から見た形 | 水色（紙の4つの角） | なし | 斜めに置いた紙（Log08 tilt_30） |
| #9 用語の5段 | 用語の5段＋写真の帯 | 黄（土台の第0段） | なし | 真上のカメラの写真（Log05） |
| #10 SAM 3 | 生成画像に4つの枠 | 青緑（同じ物とされた4本） | same 4（記事の結果） | 記事で使った生成画像（Log10） |
| #11 持ち上がったか | 2コマを並べる | 赤（写真の中のブロック） | なし | 実機の録画の440・560コマ目 |
| #12 答えの6つの形 | 写真＋6つの答えの格子 | 黄（見出しの2行目） | なし | 真上のカメラの写真と、公開中の6枚 |

![作り直した12枚。左上から Log#1〜Log#12 の順。並べ方は回ごとに違い、写真は白黒で、色はそれぞれ1か所だけ](https://pixelstomotors.com/media/LOG472_05_final_twelve.jpg)

数字を入れた回は、その数字がどこから来たかを決めてから入れた。Log#4 の 96.3% は、記事と同じしきい値（白い光の写真1枚から決めた thresholds_white.json）で、4枚の写真の全画素を数え直した。白い光の3枚が 1.9%・2.1%・2.3%、オレンジの光の1枚が 96.3% で、記事の値と一致した。Log#5 の「36 → 1」は記事の数字ではない。この絵のために、赤のしきい値をゆるめてマットの目盛りも拾うようにし、塊を数えた（36個）。5×5のオープニング（小さな粒を消す処理）をかけると、ブロックの1個だけが残った。

Log#7 の線は、[ORB](https://docs.opencv.org/4.x/d1/d89/tutorial_py_orb.html)（画像から目印になる点を拾い、2枚の間で対応を探す OpenCV の手法）で2枚の間を実際に結び、上位400本から70本を選んで描いた。カードの「867 / 934」は記事の実測で、線の本数とは別のもの。

### LOG236 のサムネイルを、映り込みにかかったマスクを主役にした

LOG236（SAM 3 で器具を切り出したら、トレイの映り込みまでマスクされた記録）だけ、サムネイルが SAM 3 の操作画面を撮った写真のままだった。同じシリーズの一覧に並ぶので、これも同じ形にそろえるよう指示を受けた。

[SAM 3](https://ai.meta.com/sam3/) は Meta が公開している、言葉やクリックで指定した物の形を切り出すモデル。LOG236 の記事に載せた動画は、SAM 3 が器具に色の輪郭（マスク）を付けた画面を録ったもの。その110コマ目で、ステンレスのトレイに映った像にまで青いマスクが付いている。このコマを白黒にし、映り込みのマスクだけ青を残した。右には「MASKED A MIRROR IMAGE.」と置いた。

![LOG236 のサムネイルの前と後。左が前の、SAM 3 の操作画面の写真。右が後の、トレイの映り込みにかかった青いマスクだけ色を残した白黒のコマ](https://pixelstomotors.com/media/LOG472_06_log236_before_after.jpg)

- ねらい：記事で分かったこと（映り込みも「ある」と見る）を、カードの絵の中で見せる
- 変わったこと：操作画面の全体ではなく、映り込みにマスクが付いた1か所が主役になった
- 見込める効果：一覧で、操作画面の写真よりも、何が起きた記事なのかが伝わる見込み

### 作り直した後のシリーズのページ

![作り直した後のシリーズのページの記録の一覧。LOG459・LOG325・LOG317 のカードに、白黒の実写と、色1つと数字が出ている](https://pixelstomotors.com/media/LOG472_07_series7_after_upper.png)

![一覧の下の段。用語の5段、斜めの紙を真上に直した絵、2枚を線で結んだ絵、円形度、モルフォロジー、オレンジの光の判定が並ぶ](https://pixelstomotors.com/media/LOG472_08_series7_after_lower.png)

[図解: LOG472_cover_intent.html](https://pixelstomotors.com/embeds/LOG472_cover_intent.html)

画像認識のシリーズのサムネイルは、写真に色を1つ足す形になった。記事の決まり（article_rules.md）のサムネイルの節はモノクロのままで、シリーズごとにサムネイルを変える見直しを別に進めている。それが終わったら書き直す。この記事はサイトの作業の記録なので、ひとり研究所の裏側に置き、サムネイルは今の決まりどおりモノクロの HTML で作った（前と後の Log#3 を白黒で並べた）。


## 作業ログ ― サムネイルをサイトに入れる仕組み

画像認識のシリーズのサムネイルは、HTML の中に図を組む形だった（public/thumbs/ の HTML を、一覧のカードが iframe で読み込む）。作り直した絵は画像なので、HTML の中身を「画像を1枚見せるだけ」に書き換え、画像は public/thumbs/img/LOG{N}.webp に置いた。webp は、jpg より軽い画像の形式。カードの仕組み（RecordCard.astro）は変えていない。

LOG236 は Studio から移した記事で、サムネイルが HTML ではなく画像（cover）になっている。src/data/cards.json と src/data/records/sam3-mirror-reflection-grasp-problem.json の cover を、/media/s-1920x1080_f24658f9-fd06-4c0b-9c47-a870ad20dea0.webp から /thumbs/img/LOG236.webp に書き換えた。前の絵は R2 に残っている。

SNS にリンクを貼ったときに出る画像（public/og/）は、サムネイルの HTML から [Playwright](https://playwright.dev/)（ブラウザを動かすための道具）で書き出している。12本とも tools/render_og.py で書き出し直した。LOG236 は cover の画像がそのまま SNS の画像になる。

### 動かしたコマンドと出力

```
$ cd tools/vision_thumbs && time python3 make.py
LOG 236
LOG 298
LOG 299
LOG 300
301 rates {'BASE': np.float64(0.019272982148106157), 'DIM': np.float64(0.020775430156630553), 'BRIGHT': np.float64(0.02252646814898799), 'ORANGE': np.float64(0.9633497496220711)}
LOG 301
LOG 302
302 36 1
LOG 304
304 0.8978910238079594
LOG 309
309 orb matches 512
LOG 310
LOG 313
LOG 317
LOG 325
LOG 459

real	0m4.943s
user	0m4.788s
sys	0m0.578s
```

CPU は2コア（nproc で 2）。Python 3.13.18、OpenCV 5.0.0、NumPy 2.5.3、Pillow 12.3.0。

```
$ python3 tools/vision_thumbs/publish.py
298 r-image-recognition-object-detection-overview
299 r-where-pixel-values-change
300 r-hue-holds-color-breaks
301 r-rgb-hsv-threshold-lighting-failure
302 r-morphology-kernel-fits-or-vanishes
304 r-what-circularity-actually-measures
309 r-same-place-different-object
310 r-tilted-plane-to-flat
313 r-vocabulary-of-modern-vision
317 r-sam3-first-look-sorting-line
325 r-decisions-api-grasp-check
459 LOG459

$ for n in r-image-recognition-object-detection-overview r-where-pixel-values-change r-hue-holds-color-breaks r-rgb-hsv-threshold-lighting-failure r-morphology-kernel-fits-or-vanishes r-what-circularity-actually-measures r-same-place-different-object r-tilted-plane-to-flat r-vocabulary-of-modern-vision r-sam3-first-look-sorting-line r-decisions-api-grasp-check LOG459; do python3 tools/render_og.py $n 2>&1 | tail -1; done
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/
書き出し 1 件 → public/og/

$ npm run build 2>&1 | tail -1
16:28:22 [build] Complete!
```

公開した commit は、5013a43（12本）、b781c44（途中の PNG を git の対象から外す）、9fe6449（LOG236）。前の HTML のサムネイルは git の履歴に残っている。

### make.py（全文）― 13枚のサムネイルを作る

```python
"""画像認識と物体検出（/series7）の記事サムネイル12本を作る（2026-11-08）。
トップのカードと同じく、実写の写真と、その回で計算した結果を組み合わせる。並べ方は回ごとに変え、差し色は1枚に1色だけ。
数字はすべて記事の実測値か、この中で写真から計算した値。

素材（KIOXIA の r2r2r-data と、記事の作業フォルダ）は Mac 連携で /mnt/user-data/uploads/ に写してから動かす。
  python3 make.py            # 12本を out/ に PNG で書き出す
出力は out/LOG{N}.png（1920×1080）。webp にして public/thumbs/img/ へ置く（publish.py）。
"""
import os, json, cv2, numpy as np
from PIL import Image, ImageOps, ImageDraw, ImageFont
U = '/mnt/user-data/uploads/KIOXIA/r2r2r-data/'
V = U + 'vision/captured/'
SITE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
W, H = 1920, 1080
BK = (17, 17, 17); WH = (241, 240, 236); MID = (128, 128, 124); DK = (40, 40, 40)
RED = (224, 38, 44); BLUE = (30, 120, 210); AMBER = (240, 168, 0); ORANGE = (240, 120, 30)
GREEN = (60, 200, 120); CYAN = (0, 170, 200); TEAL = (0, 160, 150)
MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'
IB = '/usr/share/fonts/opentype/inter/Inter-ExtraBold.otf'
os.makedirs('out', exist_ok=True)

def F(p, s): return ImageFont.truetype(p, s)
def load(p): return np.array(ImageOps.exif_transpose(Image.open(p)).convert('RGB'))
def gray(a): g = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY); return np.stack([g] * 3, -1)
def fit(a, w, h, interp=None):
    if interp is None: interp = cv2.INTER_AREA if a.shape[1] > w else cv2.INTER_LANCZOS4
    return cv2.resize(a, (w, h), interpolation=interp)
def cropc(a, cx, cy, w, h):
    x0 = int(max(0, min(a.shape[1] - w, cx - w / 2))); y0 = int(max(0, min(a.shape[0] - h, cy - h / 2)))
    return a[y0:y0 + h, x0:x0 + w], (x0, y0)
def redmask(img, smin=110, vmin=50):
    h = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
    return (((h[..., 0] < 8) | (h[..., 0] > 170)) & (h[..., 1] > smin) & (h[..., 2] > vmin)).astype(np.uint8)
def biggest(m):
    n, l, s, _ = cv2.connectedComponentsWithStats(m.astype(np.uint8)); return (l == 1 + np.argmax(s[1:, 4])).astype(np.uint8)
def label(d, x, y, num, name, col):
    d.text((x, y), num, font=F(MONO, 30), fill=col); d.text((x, y + 42), name, font=F(MONO, 38), fill=col)
def dashed(d, box, col, width=3, dl=16):
    x0, y0, x1, y1 = box
    for a, b in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]:
        a = np.array(a, float); b = np.array(b, float); n = max(2, int(np.linalg.norm(b - a) // dl))
        for j in range(0, n, 2):
            q0 = a + (b - a) * j / n; q1 = a + (b - a) * (j + 1) / n; d.line([tuple(q0), tuple(q1)], fill=col, width=width)
def save(im, n): im.save(f'out/LOG{n}.png'); print('LOG', n)

# ---------- Log#1 LOG298：9つの層（写真 ＋ 層の積み上げ。評価指標の層だけ差し色）
def log298():
    src = load(V + 'Log05/mat_white_ref_20260729_211605_f0.png')
    ph, _ = cropc(src, 960, 470, 860, 840); ph = fit(gray(ph), 1000, 1080)  # 写真の幅 1000
    im = Image.new('RGB', (W, H), WH); im.paste(Image.fromarray(ph), (0, 0)); d = ImageDraw.Draw(im)
    names = ['PIXELS & COLOR', 'CLASSICAL CV', 'CNN', 'DETECTION', 'SEGMENTATION & POSE', 'METRICS', 'DATA & TRAINING', 'LANGUAGE', 'RUNTIME']
    x0, x1, top, rh, gap = 1080, 1840, 90, 88, 8
    for i, nm in enumerate(names):            # 下が第1層
        y = top + (8 - i) * (rh + gap)
        hi = (i == 5)
        d.rectangle([x0, y, x1, y + rh], fill=AMBER if hi else (DK if i < 2 else (210, 209, 204)))
        col = BK if hi or i >= 2 else WH
        d.text((x0 + 28, y + 24), f'{i + 1}', font=F(MONO, 36), fill=col)
        d.text((x0 + 100, y + 24), nm, font=F(MONO, 36), fill=col)
    d.text((x0, top + 9 * (rh + gap) + 20), 'NINE LAYERS · METRICS LEARNED 3RD', font=F(MONO, 28), fill=BK)
    label(d, 40, 940, '01', 'PHOTO', WH)
    save(im, 298)

# ---------- Log#2 LOG299：写真の中の1画素を拡大していく（写真 ＋ 拡大したマス）
def log299():
    src = load(V + 'Log02/IMG_7901.JPG'); m = biggest(redmask(src)); ys, xs = np.nonzero(m); cx, cy = int(xs.mean()), int(ys.mean())
    s = 1080 / 1500; w0 = int(1000 / s)
    x0s, y0s = cx - w0 // 2 + 80, cy - 750
    ph = gray(fit(src[y0s:y0s + 1500, x0s:x0s + w0], 1000, 1080))
    im = Image.new('RGB', (W, H), WH); im.paste(Image.fromarray(ph), (0, 0)); d = ImageDraw.Draw(im)
    zx, zy, zs = 480, 520, 110; n = 6; X0, Y0, c = 1100, 90, 130
    d.rectangle([zx, zy, zx + zs, zy + zs], outline=RED, width=5)
    d.line([zx + zs, zy, X0, Y0], fill=RED, width=2); d.line([zx + zs, zy + zs, X0, Y0 + n * c - 4], fill=RED, width=2)
    sx = int(x0s + zx / s); sy = int(y0s + zy / s); sz = int(zs / s)
    cells = fit(src[sy:sy + sz, sx:sx + sz], n, n, cv2.INTER_AREA)
    for r in range(n):
        for k in range(n):
            R, G, B = [int(v) for v in cells[r, k]]; g = int(0.299 * R + 0.587 * G + 0.114 * B)
            fill = (R, G, B) if (r, k) == (2, 2) else (g, g, g)
            d.rectangle([X0 + k * c, Y0 + r * c, X0 + k * c + c - 4, Y0 + r * c + c - 4], fill=fill)
            d.text((X0 + k * c + 18, Y0 + r * c + 44), f'{R:3d}', font=F(MONO, 36), fill=WH if g < 130 or (r, k) == (2, 2) else BK)
    R, G, B = [int(v) for v in cells[2, 2]]
    d.text((X0, 930), f'R {R}   G {G}   B {B}', font=F(MONO, 38), fill=BK)
    d.text((X0, 985), 'ONE PIXEL = THREE NUMBERS', font=F(MONO, 28), fill=MID)
    label(d, 40, 940, '01', 'PHOTO', WH)
    save(im, 299)

# ---------- Log#3 LOG300：同じ青い面の色相が、光の色だけで 206° → 51°（2枚を並べる）
def log300():
    reg = json.load(open(V + 'Log03/measure_regions.json'))['regions']['blue_front_left']
    im = Image.new('RGB', (W, H), BK); d = ImageDraw.Draw(im)
    for i, (fn, hue, nm) in enumerate([('IMG_7913.JPG', '206°', 'COOL LIGHT'), ('IMG_7914.JPG', '51°', 'WARM LIGHT')]):
        src = load(V + 'Log03/' + fn); px, py = int(reg['nx'] * src.shape[1]), int(reg['ny'] * src.shape[0])
        cw, ch = 1100, 1000
        cr, (ox, oy) = cropc(src, px + 60, py - 220, cw, ch)
        ph = fit(cr, 940, 855); im.paste(Image.fromarray(ph), (i * 980, 0))
        k = 940 / cw; qx, qy = i * 980 + (px - ox) * k, (py - oy) * k; q = 100 * k / 2 + 6
        d.rectangle([qx - q, qy - q, qx + q, qy + q], outline=WH, width=5)
        d.text((i * 980 + 40, 880), hue, font=F(IB, 120), fill=BLUE if i == 0 else WH)
        d.text((i * 980 + 40 + d.textlength(hue, font=F(IB, 120)) + 30, 950), nm, font=F(MONO, 34), fill=WH)
        d.text((i * 980 + 40, 1030), 'HUE OF THE SAME BLUE FACE', font=F(MONO, 22), fill=MID)
    d.text((860, 905), '→', font=F(IB, 90), fill=WH)
    save(im, 300)

# ---------- Log#4 LOG301：オレンジの光で、RGB の赤検出が画面の96%を拾う（大きなマスク ＋ 4条件の帯）
def log301():
    t = json.load(open(U + 'vision/measurements/Log04/thresholds_white.json'))['thresholds']
    conds = [('IMG_7913.JPG', 'BASE'), ('IMG_7911.JPG', 'DIM'), ('IMG_7912.JPG', 'BRIGHT'), ('IMG_7914.JPG', 'ORANGE')]
    rates = {}; masks = {}; photos = {}
    for fn, nm in conds:
        a = load(V + 'Log03/' + fn).astype(int)
        m = ((a[..., 0] - a[..., 1] > t['t_rg']) & (a[..., 0] - a[..., 2] > t['t_rb']))
        rates[nm] = m.mean(); masks[nm] = m; photos[nm] = a.astype(np.uint8)
    print('301 rates', rates)
    im = Image.new('RGB', (W, H), BK); d = ImageDraw.Draw(im)
    mk = masks['ORANGE'].astype(np.uint8) * 255
    big = fit(np.stack([mk] * 3, -1), 1360, 1020); big = np.where(big > 127, np.array(WH), np.array(BK)).astype(np.uint8)
    im.paste(Image.fromarray(big), (30, 30))
    d.text((80, 80), f'{rates["ORANGE"] * 100:.1f}%', font=F(IB, 160), fill=BK)
    d.text((86, 280), 'OF THE FRAME READ AS RED', font=F(MONO, 34), fill=BK)
    d.text((86, 960), 'RGB RULE · TUNED UNDER WHITE LIGHT', font=F(MONO, 26), fill=BK)
    x, tw, th = 1430, 460, 230
    for i, (fn, nm) in enumerate(conds):
        y = 30 + i * (th + 33)
        im.paste(Image.fromarray(fit(photos[nm], tw, int(tw * 3024 / 4032))[:th]), (x, y))
        hi = nm == 'ORANGE'
        if hi: d.rectangle([x - 6, y - 6, x + tw + 5, y + th + 5], outline=ORANGE, width=6)
        d.rectangle([x, y + th - 46, x + tw, y + th], fill=BK)
        d.text((x + 14, y + th - 40), f'{nm:<7}{rates[nm] * 100:5.1f}%', font=F(MONO, 30), fill=ORANGE if hi else WH)
    save(im, 301)

# ---------- Log#5 LOG302：モルフォロジー（上に写真の帯、下に3枚と 36 → 1）
def log302():
    src = load(V + 'Log05/mat_white_ref_20260729_211605_f0.png')
    m = biggest(redmask(src)); ys, xs = np.nonzero(m); cx, cy = int(xs.mean()), int(ys.mean())
    im = Image.new('RGB', (W, H), BK); d = ImageDraw.Draw(im)
    band = fit(src[cy - 170:cy + 170, cx - 420:cx + 540], 1920, 680, cv2.INTER_LANCZOS4); g = gray(band).astype(float)
    rm = cv2.morphologyEx(redmask(band, 80, 50) * 255, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
    rm = cv2.GaussianBlur(rm, (0, 0), 2)[..., None] / 255.
    band = (band * rm + g * (1 - rm)).astype(np.uint8)
    im.paste(Image.fromarray(band[170:600]), (0, 0))
    label(d, 40, 330, '01', 'PHOTO', BK)
    crop = src[cy - 90:cy + 90, cx - 150:cx + 150]
    raw = redmask(crop, 60, 60); op = cv2.morphologyEx(raw, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
    cl = cv2.morphologyEx(op, cv2.MORPH_CLOSE, np.ones((15, 15), np.uint8))
    nr = cv2.connectedComponents(raw)[0] - 1; no = cv2.connectedComponents(op)[0] - 1
    pw, phh, gap, x, y = 560, 340, 40, 40, 600
    for i, (mm, nm, num) in enumerate([(raw, 'THRESHOLD', '02'), (op, 'OPENING', '03'), (cl, 'CLOSING', '04')]):
        p = fit(mm * 255, pw, phh, cv2.INTER_NEAREST)
        im.paste(Image.fromarray(np.where(p[..., None] > 0, np.array(WH), np.array(DK)).astype(np.uint8)), (x + i * (pw + gap), y))
        d.text((x + i * (pw + gap), y + phh + 24), num + '  ' + nm, font=F(MONO, 34), fill=WH)
    s = f'{nr} → {no}'
    d.text((40, 462), s, font=F(IB, 96), fill=WH)
    d.text((40 + d.textlength(s, font=F(IB, 96)) + 28, 500), 'BLOBS AFTER OPENING', font=F(MONO, 34), fill=WH)
    save(im, 302); print('302', nr, no)

# ---------- Log#6 LOG304：円形度（左に写真、右に輪郭と数字）
def log304():
    src = load(V + 'Log06/shapes_red_rot30_20260731_190825_f0.png')
    r = redmask(src, 90, 60); n, l, s, ce = cv2.connectedComponentsWithStats(r); i = 1 + np.argmax(s[1:, 4]); cx, cy = [int(v) for v in ce[i]]
    big = (l == i).astype(np.uint8)
    h0 = 640; w0 = int(h0 * 960 / 1080); x0 = cx - w0 // 2 - 120; y0 = cy - h0 // 2 - 140
    ph = src[y0:y0 + h0, x0:x0 + w0]; bm = cv2.dilate(big[y0:y0 + h0, x0:x0 + w0], np.ones((5, 5), np.uint8))[..., None]
    ph = fit((ph * bm + gray(ph) * (1 - bm)).astype(np.uint8), 960, 1080, cv2.INTER_LANCZOS4)
    im = Image.new('RGB', (W, H), WH); im.paste(Image.fromarray(ph), (0, 0)); d = ImageDraw.Draw(im)
    label(d, 40, 940, '01', 'PHOTO', BK)
    c0 = max(cv2.findContours(big * 255, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)[0], key=cv2.contourArea)
    circ = 4 * np.pi * cv2.contourArea(c0) / cv2.arcLength(c0, True) ** 2
    bx, by, bw, bh = cv2.boundingRect(c0); k = 440 / max(bw, bh); ox, oy = 1440 - bw * k / 2, 470 - bh * k / 2
    pts = [(ox + (p[0][0] - bx) * k, oy + (p[0][1] - by) * k) for p in c0]
    d.line(pts + [pts[0]], fill=BK, width=6)
    dashed(d, (ox - 20, oy - 20, ox + bw * k + 20, oy + bh * k + 20), MID)
    M = cv2.moments(c0); mx = ox + (M['m10'] / M['m00'] - bx) * k; my = oy + (M['m01'] / M['m00'] - by) * k
    d.line([(mx - 22, my), (mx + 22, my)], fill=RED, width=5); d.line([(mx, my - 22), (mx, my + 22)], fill=RED, width=5)
    d.text((1060, 800), f'{circ:.3f}', font=F(IB, 150), fill=BK); d.text((1066, 980), 'CIRCULARITY  (1.000 = CIRCLE)', font=F(MONO, 30), fill=BK)
    save(im, 304); print('304', circ)

# ---------- Log#7 LOG309：本の写真と、本のないマットの写真が「同じ」と結ばれる（2枚 ＋ 対応の線）
def log309():
    a = load(V + 'Log07/flat_0_20260802_072613/f0.png'); b = load(V + 'Log07/mat_0_20260802_080524/f0.png')
    ca, _ = cropc(a, 860, 560, 700, 760); cb, _ = cropc(b, 860, 560, 700, 760)
    orb = cv2.ORB_create(3000)
    ka, da = orb.detectAndCompute(cv2.cvtColor(ca, cv2.COLOR_RGB2GRAY), None); kb, db = orb.detectAndCompute(cv2.cvtColor(cb, cv2.COLOR_RGB2GRAY), None)
    ms = sorted(cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True).match(da, db), key=lambda m: m.distance)
    pw, ph = 880, 955; k = pw / 700
    im = Image.new('RGB', (W, H), BK); d = ImageDraw.Draw(im)
    im.paste(Image.fromarray(fit(gray(ca), pw, ph)), (40, 40)); im.paste(Image.fromarray(fit(gray(cb), pw, ph)), (1000, 40))
    rng = np.random.default_rng(0); sel = [ms[i] for i in sorted(rng.choice(min(len(ms), 400), 70, replace=False))]
    for m in sel:
        p = ka[m.queryIdx].pt; q = kb[m.trainIdx].pt
        p = (40 + p[0] * k, 40 + p[1] * k); q = (1000 + q[0] * k, 40 + q[1] * k)
        d.line([p, q], fill=GREEN, width=2); d.ellipse([p[0] - 5, p[1] - 5, p[0] + 5, p[1] + 5], fill=GREEN); d.ellipse([q[0] - 5, q[1] - 5, q[0] + 5, q[1] + 5], fill=GREEN)
    d.rectangle([1000, 40 + ph - 210, 1000 + pw, 40 + ph], fill=BK)
    d.text((1030, 40 + ph - 200), '867 / 934', font=F(IB, 110), fill=GREEN)
    d.text((1036, 40 + ph - 60), 'INLIERS · NOTHING IN COMMON', font=F(MONO, 30), fill=WH)
    for x, t in [(40, 'BOOK'), (1000, 'EMPTY MAT')]:
        d.rectangle([x, 40, x + 20 + d.textlength(t, font=F(MONO, 34)) + 20, 100], fill=BK); d.text((x + 20, 50), t, font=F(MONO, 34), fill=WH)
    save(im, 309); print('309 orb matches', len(ms))

# ---------- Log#8 LOG310：斜めの紙を真上から見た形に（写真の4点 → 直した形）
def log310():
    src = load(V + 'Log08/tilt_30_20260807_082518/f0.png')
    g = cv2.cvtColor(src, cv2.COLOR_RGB2GRAY); _, wm = cv2.threshold(g, 200, 255, cv2.THRESH_BINARY)
    wm[:, :500] = 0; wm[:, 1500:] = 0   # マットの外の白い机を除く
    wm = cv2.morphologyEx(wm, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8))
    c = max(cv2.findContours(wm, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0], key=cv2.contourArea)
    ap = cv2.approxPolyDP(c, 0.02 * cv2.arcLength(c, True), True).reshape(-1, 2).astype(np.float32)
    if len(ap) != 4: ap = cv2.boxPoints(cv2.minAreaRect(c)).astype(np.float32)
    s_ = ap.sum(1); df = np.diff(ap, axis=1).ravel()
    q = np.array([ap[np.argmin(s_)], ap[np.argmin(df)], ap[np.argmax(s_)], ap[np.argmax(df)]], np.float32)  # 左上・右上・右下・左下
    wv = max(np.linalg.norm(q[0] - q[1]), np.linalg.norm(q[3] - q[2])); hv = max(np.linalg.norm(q[0] - q[3]), np.linalg.norm(q[1] - q[2]))
    landscape = wv > hv; ow, oh = (900, int(900 * 210 / 297)) if landscape else (int(900 * 210 / 297), 900)
    Hm = cv2.getPerspectiveTransform(q, np.array([[0, 0], [ow, 0], [ow, oh], [0, oh]], np.float32))
    flat = cv2.warpPerspective(src, Hm, (ow, oh))
    im = Image.new('RGB', (W, H), WH); d = ImageDraw.Draw(im)
    cr, (ox, oy) = cropc(src, q[:, 0].mean(), q[:, 1].mean(), 1000, 1080 * 1000 // 1000)
    k = 1000 / cr.shape[1]; phc = fit(gray(cr), 1000, int(cr.shape[0] * k))[:1080]
    im.paste(Image.fromarray(phc), (0, 0))
    pts = [((p[0] - ox) * k, (p[1] - oy) * k) for p in q]
    d.line(pts + [pts[0]], fill=CYAN, width=4)
    for p in pts: d.ellipse([p[0] - 14, p[1] - 14, p[0] + 14, p[1] + 14], fill=CYAN, outline=BK, width=3)
    d.rectangle([0, 920, 420, 1080], fill=WH); label(d, 40, 940, '01', 'TILTED PLANE', BK)
    fw = 760; fh = int(oh * fw / ow); fl = fit(gray(flat), fw, fh)
    fx, fy = 1100, (1080 - fh) // 2 - 60
    im.paste(Image.fromarray(fl), (fx, fy))
    d.rectangle([fx, fy, fx + fw, fy + fh], outline=BK, width=3)
    d.text((1100, fy + fh + 40), '02', font=F(MONO, 30), fill=BK); d.text((1100, fy + fh + 82), 'FRONT-PARALLEL · 4 POINTS', font=F(MONO, 38), fill=BK)
    d.text((1020, 470), '→', font=F(IB, 70), fill=BK)
    save(im, 310)

# ---------- Log#9 LOG313：用語の5段（上に段の表、下に写真の帯）
def log313():
    src = load(V + 'Log05/mat_white_ref_20260729_211605_f0.png')
    im = Image.new('RGB', (W, H), WH); d = ImageDraw.Draw(im)
    band = gray(fit(src[545:745, 380:1540], 1920, 331))
    im.paste(Image.fromarray(band[:330]), (0, 750))
    rows = [('4', 'TOOLS', 'ROBOFLOW · HUGGING FACE'), ('3', 'FIELD', 'OCCLUSION · POINT CLOUD · NIR'),
            ('2', 'MODELS', 'DINOv3 · YOLO26 · SAM 3 · DEPTH ANYTHING'), ('1', 'OUTPUTS', 'DETECT · SEGMENT · DEPTH'),
            ('0', 'BASICS', 'MODEL · PARAMETER · LOSS · BACKBONE')]
    y = 50
    for num, h, ws in rows:
        hi = num == '0'
        if hi: d.rectangle([40, y - 10, 1880, y + 110], fill=BK)
        col = WH if hi else BK
        d.text((70, y + 18), num, font=F(IB, 64), fill=AMBER if hi else col)
        d.text((170, y + 30), h, font=F(MONO, 40), fill=col)
        d.text((520, y + 30), ws, font=F(MONO, 40), fill=col)
        y += 136
    d.text((40, 712), 'FIVE LAYERS OF WORDS · NO CODE THIS TIME', font=F(MONO, 28), fill=MID)
    save(im, 313)

# ---------- Log#10 LOG317：ガラス瓶とペットボトルが同じ4本（生成画像に4つの枠）
def log317():
    src = load(U + 'vision/generated/Log10/B_tray_similar.png')  # 記事で使った生成画像
    k = 1080 / src.shape[0]; w = int(src.shape[1] * k)
    im = Image.new('RGB', (W, H), BK); im.paste(Image.fromarray(fit(gray(src), w, 1080)), (W - w, 0)); d = ImageDraw.Draw(im)
    ox = W - w
    boxes = [(440, 100, 690, 530), (975, 115, 1345, 552), (140, 478, 472, 978), (712, 402, 1028, 975)]
    for x0, y0, x1, y1 in boxes:
        d.rectangle([ox + x0 * k, y0 * k, ox + x1 * k, y1 * k], outline=TEAL, width=6)
    lx = 50
    d.text((lx, 90), '"glass"', font=F(MONO, 52), fill=WH); d.text((lx, 170), '"plastic"', font=F(MONO, 52), fill=WH)
    d.text((lx, 330), '= same 4', font=F(IB, 82), fill=TEAL)
    d.text((lx, 960), 'SAM 3 · TEXT PROMPT', font=F(MONO, 30), fill=MID); d.text((lx, 1000), 'GENERATED IMAGE', font=F(MONO, 26), fill=MID)
    save(im, 317)

# ---------- Log#11 LOG325：持ち上げる前と後が、真上からは同じに見える（2コマ）
def log325():
    cap = cv2.VideoCapture(U + 'runs/run_pick_place/run_0001_20261019_195120/recording/top.mp4')
    def fr(i): cap.set(1, i); ok, f = cap.read(); return cv2.cvtColor(f, cv2.COLOR_BGR2RGB)
    a, b = fr(440), fr(560)
    im = Image.new('RGB', (W, H), WH); d = ImageDraw.Draw(im)
    for i, (f, nm) in enumerate([(a, 'BEFORE LIFT'), (b, 'AFTER LIFT')]):
        r = redmask(f, 110, 60); ys, xs = np.nonzero(biggest(r)); cx, cy = xs.mean(), ys.mean()
        cr, _ = cropc(f, cx, cy - 60, 420, 470)
        g = gray(cr).astype(float); rm = cv2.GaussianBlur(cv2.dilate(redmask(cr, 110, 60) * 255, np.ones((5, 5), np.uint8)), (0, 0), 1.5)[..., None] / 255.
        cr = (cr * rm + g * (1 - rm)).astype(np.uint8)
        im.paste(Image.fromarray(fit(cr, 900, 1007)), (40 + i * 940, 0))
        d.rectangle([40 + i * 940, 880, 40 + i * 940 + 900, 1007], fill=BK)
        d.text((80 + i * 940, 905), f'0{i + 1}', font=F(MONO, 30), fill=WH); d.text((80 + i * 940, 945), nm, font=F(MONO, 38), fill=WH)
    d.text((40, 1025), 'TOP CAMERA · HEIGHT IS NOT IN THE PICTURE', font=F(MONO, 30), fill=BK)
    d.text((1540, 900), '= ?', font=F(IB, 100), fill=WH)
    save(im, 325)

# ---------- Log#12 LOG459：1枚の写真と6つの答え（大きい写真 ＋ 6枚の格子）
def log459():
    src = load(V + 'Log05/mat_orange_20260729_212044_f0.png')
    cr, _ = cropc(src, 960, 500, 700, 1000)
    im = Image.new('RGB', (W, H), BK); im.paste(Image.fromarray(fit(cr, 756, 1080)), (0, 0)); d = ImageDraw.Draw(im)
    label(d, 40, 940, '00', 'PHOTO', WH)
    tiles = ['cls', 'det', 'seg', 'pose', 'trk', 'dep']; names = ['CLASSIFY', 'DETECT', 'SEGMENT', 'POSE', 'TRACK', 'DEPTH']
    tw, th = 368, 245
    for i, (t, nm) in enumerate(zip(tiles, names)):
        a = np.array(Image.open(f'{SITE}/public/site/vision/{t}.webp').convert('RGB'))
        x = 790 + (i % 3) * (tw + 12); y = 30 + (i // 3) * (th + 120)
        if i >= 3: y += 0
        im.paste(Image.fromarray(fit(a, tw, th)), (x, y))
        d.text((x, y + th + 18), f'0{i + 1}  {nm}', font=F(MONO, 30), fill=AMBER if i == 0 else WH)
    d.text((790, 800), 'ONE PHOTO', font=F(IB, 96), fill=WH); d.text((790, 910), 'SIX ANSWERS', font=F(IB, 96), fill=AMBER)
    save(im, 459)

# ---------- LOG236：SAM 3 が、ステンレスのトレイに映った像までマスクした（大きな1コマ ＋ 右に言葉）
def log236():
    # 記事の動画（R2 の media/s-1280x720_0e7779e9-0f3a-45fd-aa67-b76c819faabb.mp4。SAM 3 のマスクが焼き込まれた画面）の 110 コマ目
    cap = cv2.VideoCapture(os.environ.get('LOG236_MP4', '/tmp/claude-0/s/l236/s-1280x720_0e7779e9-0f3a-45fd-aa67-b76c819faabb.mp4'))
    cap.set(1, 110); ok, f = cap.read(); f = cv2.cvtColor(f, cv2.COLOR_BGR2RGB)
    hsv = cv2.cvtColor(f, cv2.COLOR_RGB2HSV)
    blue = ((hsv[..., 0] > 85) & (hsv[..., 0] < 125) & (hsv[..., 1] > 70) & (hsv[..., 2] > 150)).astype(np.uint8)
    blue[:540, :] = 0; blue[:, :650] = 0; blue[:, 990:] = 0                       # 映り込みに付いたマスクだけ
    blue = cv2.dilate(blue, np.ones((3, 3), np.uint8)); m = cv2.GaussianBlur(blue.astype(float), (0, 0), 1)[..., None]
    f = (f * m + gray(f) * (1 - m)).astype(np.uint8)
    x0 = 250; cr = f[:, x0:x0 + 867]   # 右下の飾り（✦）は外す
    im = Image.new('RGB', (W, H), BK); im.paste(Image.fromarray(fit(cr, 1300, 1080)), (0, 0)); d = ImageDraw.Draw(im)
    ys, xs = np.nonzero(blue); k = 1300 / 867
    bx0, by0, bx1, by1 = (xs.min() - x0) * k - 24, ys.min() * k - 24, (xs.max() - x0) * k + 24, ys.max() * k + 24
    dashed(d, (bx0, by0, bx1, by1), WH, width=4)
    d.rectangle([bx0, by0 - 52, bx0 + 270, by0], fill=WH); d.text((bx0 + 14, by0 - 46), 'REFLECTION', font=F(MONO, 32), fill=BK)
    x = 1370
    d.text((x, 90), 'SAM 3 · STEEL TRAY', font=F(MONO, 30), fill=MID)
    d.text((x, 300), 'MASKED', font=F(IB, 96), fill=WH)
    d.text((x, 410), 'A MIRROR', font=F(IB, 96), fill=BLUE)
    d.text((x, 520), 'IMAGE.', font=F(IB, 96), fill=WH)
    d.text((x, 900), 'SHAPE ≠ SOMETHING', font=F(MONO, 30), fill=WH); d.text((x, 945), 'TO GRASP', font=F(MONO, 30), fill=WH)
    save(im, 236)


if __name__ == '__main__':
    import sys
    fns = {k[3:]: v for k, v in globals().items() if k.startswith('log') and callable(v)}
    for k in (sys.argv[1:] or sorted(fns)): fns[k]()
```

### publish.py（全文）― webp にしてサムネイルの HTML を書き換える

```python
"""LOG236 は Studio から移した記事でサムネイルが画像（cover）のため、src/data/cards.json と records/ の cover を /thumbs/img/LOG236.webp に向けてある（前の絵は R2 の s-1920x1080_f24658f9-….webp）。
make.py の out/LOG{N}.png を webp にして public/thumbs/img/ に置き、記事のサムネイルの HTML を、その画像を見せるだけの形に書き換える（2026-11-08）。
前の HTML のサムネイルは git の履歴に残っている。書き換えたあと tools/render_og.py で SNS 用の画像も作り直す。"""
import os
from PIL import Image
SITE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HERE = os.path.dirname(os.path.abspath(__file__))
THUMB = {298: 'r-image-recognition-object-detection-overview', 299: 'r-where-pixel-values-change', 300: 'r-hue-holds-color-breaks',
         301: 'r-rgb-hsv-threshold-lighting-failure', 302: 'r-morphology-kernel-fits-or-vanishes', 304: 'r-what-circularity-actually-measures',
         309: 'r-same-place-different-object', 310: 'r-tilted-plane-to-flat', 313: 'r-vocabulary-of-modern-vision',
         317: 'r-sam3-first-look-sorting-line', 325: 'r-decisions-api-grasp-check', 459: 'LOG459'}
HTML = '''<!DOCTYPE html>
<!-- 画像認識シリーズのサムネイル。画像は tools/vision_thumbs/make.py で作る（2026-11-08） -->
<html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>*{{margin:0;padding:0}}html,body{{background:#111;height:100%;overflow:hidden}}img{{display:block;width:100%;height:100%;object-fit:cover}}</style>
</head><body><img src="img/LOG{n}.webp" alt=""></body></html>
'''
os.makedirs(f'{SITE}/public/thumbs/img', exist_ok=True)
for n, name in THUMB.items():
    Image.open(f'{HERE}/out/LOG{n}.png').convert('RGB').save(f'{SITE}/public/thumbs/img/LOG{n}.webp', quality=86)
    open(f'{SITE}/public/thumbs/{name}.html', 'w').write(HTML.format(n=n))
    print(n, name)
```

### 使った素材の場所

| 回 | 素材のパス |
|---|---|
| #1・#5・#9 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log05/mat_white_ref_20260729_211605_f0.png |
| #12 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log05/mat_orange_20260729_212044_f0.png と p2m-site の public/site/vision/{cls,det,seg,pose,trk,dep}.webp |
| #2 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log02/IMG_7901.JPG |
| #3 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7913.JPG・IMG_7914.JPG と measure_regions.json |
| #4 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7911.JPG〜IMG_7914.JPG と /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/thresholds_white.json |
| #6 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log06/shapes_red_rot30_20260731_190825_f0.png |
| #7 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log07/flat_0_20260802_072613/f0.png・mat_0_20260802_080524/f0.png |
| #8 | /Volumes/KIOXIA/r2r2r-data/vision/captured/Log08/tilt_30_20260807_082518/f0.png |
| #10 | /Volumes/KIOXIA/r2r2r-data/vision/generated/Log10/B_tray_similar.png |
| #11 | /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_20261019_195120/recording/top.mp4（440・560コマ目） |
| LOG236 | R2 の media/s-1280x720_0e7779e9-0f3a-45fd-aa67-b76c819faabb.mp4（110コマ目） |
