画像認識と物体検出を学び直す Log#4 | 光が変わると、赤検出が青を拾う

ロボットに「赤いブロックを拾って」と教えるとき、最初にぶつかるのが「そもそも、どれが赤なのか」をどう判断させるかという問題になる。人間は一目で赤を赤と分かるが、コンピュータが見ているのは色ではなく数字の並びでしかない。この記事は、その一番素朴な方法——色を数字の線引きで選ぶやり方——が、光が変わった瞬間にどう崩れるかを、同じ被写体を光だけ変えて撮った4枚の写真で確かめた記録になる。

先に結論を3つ置く。ひとつ、白い光で撮った理想的な条件でさえ、赤と赤以外は「鮮やかさ」と「明るさ」の線引きでは分けられない。ふたつ、白い光で合わせた赤検出を明るさ違いの写真に当てると、青ブロックの上の面がまるごと赤と判定される。みっつ、オレンジの光にすると、赤検出(RGB方式)が画面の96%を赤と判定する。以下、基礎から順に見ていく。

コンピュータは色を数字の3つ組で見ている

写真の1画素は、赤(R)・緑(G)・青(B)の3つの数字で表される。それぞれ0から255までの明るさで、たとえば (220, 40, 40) なら「赤が強く、緑と青は弱い」で、人間の目には赤く見える。画面のすべての色は、この3つの数字の組み合わせでできている。これが RGB という表し方になる。

コンピュータには「赤」という概念がない。あるのは3つの数字だけ。だから「赤いものを選べ」という命令は、「R が G より◯以上大きく、かつ R が B より◯以上大きい画素を選べ」のような、数字の条件に翻訳しなければ動かない。この◯にあたる境目の数字を閾値(しきいち)と呼ぶ。閾値は「ここより上なら赤、下なら赤じゃない」という線引きのことだと思えばいい。

RGB とは別に、HSV という見方がある

RGB は「光の三原色をどれだけ混ぜたか」で色を表すが、これは人間の「赤っぽい/青っぽい」という感覚とはずれている。そこでよく使われるのが HSV という別の表し方になる。同じ色を、次の3つの軸で表す。

HSV は色相・彩度・明度の3つで色を表す。色相(H, Hue)は色合いそのもの——赤っぽいか、青っぽいか、緑っぽいか。円環状の角度(0〜360度)で表し、赤は0度付近、青は240度付近に来る。彩度(S, Saturation)は色の鮮やかさ——同じ赤でも、ビビッドな赤か、くすんだ灰色っぽい赤か。明度(V, Value)は明るさ——同じ赤でも、明るい赤か、暗い赤か。

RGB と HSV は、同じ1つの色を別の座標で表しているだけで、電卓のように相互変換できる。この記事の実験では、RGB の式と HSV の式の両方で「赤を選ぶ」「青を選ぶ」を試し、光が変わったときにどちらがどう壊れるかを比べている。

実験に使った4枚——被写体は同じ、光だけ違う

赤と青のブロックを白い紙の上に置き、机の上で撮った。重要なのは、4枚とも同じブロック・同じ紙・同じ配置で、変えたのは光だけという点になる。

IMG_7913

これが基準になる白い光(IMG_7913)。この記事の閾値は、すべてこの1枚だけから決めている。ここで決めた線引きを、残り3枚にそのまま当てて、何が起きるかを見る。

IMG_7911

暗い白(IMG_7911)。光の色は白のまま、明るさだけを落とした。

IMG_7912

明るい白(IMG_7912)。光の色は白のまま、明るさだけを上げた。

IMG_7914

オレンジの光(IMG_7914)。明るさではなく、光の色そのものを変えた。紙も机も全体がオレンジに寄っているのが分かる。

この4枚を、白い光で決めた1組の閾値で判定させる。以下が3つの発見になる。

発見1:白い光でさえ、鮮やかさと明るさでは赤と青を分けられない

閾値を決める段階で、すでに壁にぶつかった。白い光の基準写真から赤の閾値を計算させたところ、彩度(鮮やかさ)と明度(明るさ)の2つで警告が出た。閾値を書き出した thresholds_white.json の警告部分がこれになる。

赤について、拾うべき赤画素の彩度の下限が 0.747、拾うべきでない画素の彩度の上限が 0.800。下限より上限のほうが大きい。これは、赤ブロックの中で最も鮮やかさが低い画素より、赤以外の中で最も鮮やかな画素のほうが鮮やかだ、という意味になる。線をどこに引いても、赤を取りこぼすか、赤以外を拾うかのどちらかが必ず起きる。明度も同じで、下限 0.259 に対して上限 0.631 だった。

なぜこうなるか。写真の基準の白(IMG_7913)で、赤ブロックの上面(真上を向いた面)と側面(手前に傾いた面)を見比べると、同じ1個のブロックの中で赤の濃さがまるで違う。上面は光を強く受けて白っぽく浅い赤、側面は光が弱く濃い赤。鮮やかさも明るさも、1個のブロックの中でこれだけ幅がある。一方で青ブロックの上面も光を受けて明るく、鮮やかさが落ちている。こうして「赤の浅いところ」と「青の明るいところ」が、鮮やかさ・明るさの数直線上で重なってしまう。

救いは色相(色合い)だった。色相だけは警告が出ず、赤(3.7度付近)と青(206度付近)できれいに離れていた。同じブロックの上面と側面で濃さは変わっても、色合いは赤のまま保たれる。だから色を分けたいなら、鮮やかさや明るさではなく色相を使うしかない——これが最初の結論になる。ただし、その色相を使った検出も万能ではないことが、次の発見で分かる。

発見2:明るさが変わると、赤検出(HSV方式)が青の上面を赤と判定する

白い光で合わせた色相ベースの赤検出を、明るさ違いの写真にそのまま当てると、青ブロックの上面がまるごと赤と判定された。青の領域を赤の式で拾ってしまった画素数が exp_e_blue.csv になる。各領域は1万画素。

基準の白(IMG_7913)では、赤の HSV 式が青の上面を拾った数は0。ところが同じ式のまま、暗い白(IMG_7911)にすると9997画素、明るい白(IMG_7912)にすると10000画素——つまり青の上面のほぼ全部、あるいは全部が「赤」と判定された。変えたのは光の明るさだけで、閾値は1文字も動かしていない。

しかも、同じ青でも side(青の側面, blue_front_left)は全条件で0のまま。壊れたのは青の上面だけになる。写真で確かめると理由が見える。明るい白(IMG_7912)と暗い白(IMG_7911)の青ブロックの上面は、光が強く当たって色が抜け、白っぽく、わずかに赤みを帯びた灰色に見える。純粋な青には見えない。この「色抜けした上面」の色相が、基準からずれて赤の範囲に滑り込んだ。側面は青のまま残るので化けない。

面ごとに光の当たり方が違うから、面ごとに色抜けの量が違い、面ごとに壊れる——「ブロック単位で赤か青か」という素朴な発想では捉えられない壊れ方になる。

発見3:オレンジの光にすると、赤検出(RGB方式)が画面の96%を赤と判定する

光の色そのものを変えると、今度は RGB 方式の赤検出が画面全体を飲み込んだ。4枚それぞれで、画面全体(full_frame)のうち各式が拾った割合が実験Cの結果になる。

オレンジの光(IMG_7914)の行だけ、赤 RGB 式が 0.963——画面の96.3%を赤と判定した。白系の3枚はどれも2%前後なので、桁が違う。オレンジの紙も机も、ほぼ全部が「赤」に飲み込まれた。オレンジの光は画面全体を赤方向に押すので、「R が G より大きく、R が B より大きい」という RGB の素朴な条件が、紙にも机にも当たってしまう。

対になる数字も見ておく。同じオレンジ光で、赤の HSV 式は 0.008——0.8%しか拾わなかった。RGB が96%誤爆する同じ写真で、HSV はほとんど誤爆しない。HSV は色相(色合い)を見るので、オレンジと赤の色合いの差で紙を弾ける。RGB は明るさと色が混ざった数字なので、オレンジの明るさに引きずられて全部拾う。

2つの式は、逆の壊れ方をする

発見2と発見3を並べると、面白いことに気づく。壊れる式が逆になっている。

明るさが変わったとき(発見2)は、HSV の赤検出が青の上面を拾って壊れ、RGB は青を1画素も拾わなかった。色が変わったとき=オレンジ光(発見3)は、RGB の赤検出が画面の96%を拾って壊れ、HSV は0.8%で耐えた。同じ「赤を検出する」式でも、RGB と HSV は弱点が別の場所にある。明るさの変化には HSV が弱く、色かぶりには RGB が弱い。

どちらかを選べば安全という話ではない。片方は明るさで壊れ、もう片方は色かぶりで壊れる。素朴な閾値による色検出は、光が変わる現実の環境では、どう組んでも single の弱点を抱える。ここから先、照明を固定するのか、光の変化を打ち消す前処理を入れるのか、そもそも色以外の手がかりを併用するのか——という設計の話に入っていくことになる。まずは「閾値がどう壊れるか」を、動かせない画素数として押さえた。

使ったコードの全文

以下が実験に使った measure_threshold_log04.py の全文になる。5つの実験(A: 閾値の刻み測定 / B: 生成画像での検証 / C: 実写4枚×領域×4式 / D: 1変数ずつ振る掃引 / E: 実験Cから青の誤検出を抜き出し)を1本で順に走らせる。閾値は白い光の IMG_7913 からのみ1度だけ決め、thresholds_white.json に書き出して以降は読むだけにしている。色変換のヘルパは前回(Log#3)のものをそのまま流用している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""measure_threshold_log04.py — Log#4 閾値5実験.

色の閾値 (画素をある数値の線で 2 グループに分ける処理) が、光の変化で何を
拾い間違えるかを測る。5 実験を 1 本で順に走らせる。

閾値式 (固定):
  RGB赤:  (R-G) > t_rg  and  (R-B) > t_rb
  RGB青:  (B-R) > t_br  and  (B-G) > t_bg
  HSV赤:  (H < h_red_hi or H > h_red_lo) and S > s_red and V > v_red
  HSV青:  h_blue_lo < H < h_blue_hi and S > s_blue and V > v_blue

H/S/V は _rgb_to_hsv_formula() (Log#3) の値を使う (H:0〜360, S:0〜1, V:0〜1)。
OpenCV / Pillow の HSV は実験 A の刻み測定のみに使い、B〜E の判定には使わない。

閾値は「白い光」= IMG_7913 のみから 1 度だけ決めて thresholds_white.json に
書き出し、以降の実験はこれを読むだけで再計算しない。

Inputs (すべて既存・読み取りのみ・コピーしない):
  /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7911.JPG  暗い白
  /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7912.JPG  明るい白
  /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7913.JPG  白 (基準)
  /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7914.JPG  オレンジ
  /Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/measure_regions.json
  /Volumes/KIOXIA/r2r2r-data/vision/generated/Log02/base_image.png
  /Volumes/KIOXIA/r2r2r-data/vision/generated/Log02/base_image_spec.json

Outputs (既存があれば ABORT・上書き禁止):
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/thresholds_white.json
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/exp_a_step_resolution.csv
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/exp_b_generated_counts.csv
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/exp_c_region_hits.csv
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/exp_d_sweep.csv
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/exp_e_blue.csv
  /Volumes/KIOXIA/r2r2r-data/vision/measurements/Log04/run_meta_threshold.json
  /Volumes/KIOXIA/r2r2r-data/vision/generated/Log04/orange_shift_spec.json
  /Volumes/KIOXIA/r2r2r-data/vision/generated/Log04/orange_shifted.png
"""
from __future__ import annotations

import csv
import io
import json
import sys
from datetime import datetime
from pathlib import Path

import numpy as np
from PIL import Image

try:
    import cv2
    _OPENCV_VERSION = cv2.__version__
except Exception:
    cv2 = None
    _OPENCV_VERSION = None

# ---- Log#3 のヘルパをそのまま流用 (書き直さない) --------------------------
_THIS_DIR = Path(__file__).resolve().parent
if str(_THIS_DIR) not in sys.path:
    sys.path.insert(0, str(_THIS_DIR))

from measure_hsv_log03 import (
    ensure_ssd_mounted,
    _rgb_to_hsv_formula,
    _circmean_circstd_deg,
    _wrap_delta_deg,
    _md5,
    _icc_profile_name,
    _abort,
)


# ---- 定数 --------------------------------------------------------------------
KIOXIA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
CAP_DIR = KIOXIA_ROOT / "vision" / "captured" / "Log03"      # 参照のみ (コピーしない)
GEN_LOG02 = KIOXIA_ROOT / "vision" / "generated" / "Log02"    # 参照のみ
GEN_LOG04 = KIOXIA_ROOT / "vision" / "generated" / "Log04"    # 書き込み先
MEAS_LOG04 = KIOXIA_ROOT / "vision" / "measurements" / "Log04"  # 書き込み先

REGIONS_JSON = CAP_DIR / "measure_regions.json"
BASE_PNG = GEN_LOG02 / "base_image.png"
BASE_SPEC = GEN_LOG02 / "base_image_spec.json"

IMG_7911 = "IMG_7911.JPG"
IMG_7912 = "IMG_7912.JPG"
IMG_7913 = "IMG_7913.JPG"
IMG_7914 = "IMG_7914.JPG"
CAPTURED_ORDER = [IMG_7911, IMG_7912, IMG_7913, IMG_7914]

# 引き継ぎ書記載の md5. 一致しなければ [ABORT].
EXPECTED_MD5 = {
    IMG_7911: "2b1192f6fbc414c9cb0ca47d7fef924e",
    IMG_7912: "47a59644472e498591f49cfdba6cbfa4",
    IMG_7913: "cb9d0627d1c3d5c0074b5efe434e546d",
    IMG_7914: "dfa4e0b8ad162546755c1e83ef82d328",
}

# 出力ファイル
THRESH_JSON = MEAS_LOG04 / "thresholds_white.json"
OUT_EXP_A = MEAS_LOG04 / "exp_a_step_resolution.csv"
OUT_EXP_B = MEAS_LOG04 / "exp_b_generated_counts.csv"
OUT_EXP_C = MEAS_LOG04 / "exp_c_region_hits.csv"
OUT_EXP_D = MEAS_LOG04 / "exp_d_sweep.csv"
OUT_EXP_E = MEAS_LOG04 / "exp_e_blue.csv"
OUT_META = MEAS_LOG04 / "run_meta_threshold.json"
ORANGE_PNG = GEN_LOG04 / "orange_shifted.png"
ORANGE_SPEC = GEN_LOG04 / "orange_shift_spec.json"

# 領域グループ
RED_REGIONS = ["red_top", "red_front_left"]
BLUE_REGIONS = ["blue_top", "blue_front_left"]
DESK_REGIONS = ["desk_top_center", "desk_upper_right", "desk_lower_right"]
PAPER_REGIONS = ["paper"]
NON_TARGET_RED = BLUE_REGIONS + PAPER_REGIONS + DESK_REGIONS
NON_TARGET_BLUE = RED_REGIONS + PAPER_REGIONS + DESK_REGIONS


# ---- HSV formula ベクトル化版 (_rgb_to_hsv_formula と数値一致) ---------------
def _rgb_to_hsv_formula_np(rgb_arr: np.ndarray) -> tuple:
    """(..., 3) uint8 RGB を受け、_rgb_to_hsv_formula と同じ式で
    H (0..360), S (0..1), V (0..1) の numpy 配列を返す. 百万画素規模の
    per-pixel loop を避けるためのベクトル化。値は IEEE754 の範囲で一致."""
    arr = rgb_arr.astype(np.float64) / 255.0
    r = arr[..., 0]
    g = arr[..., 1]
    b = arr[..., 2]
    cmax = arr.max(axis=-1)
    cmin = arr.min(axis=-1)
    delta = cmax - cmin
    zero = (delta == 0.0)
    is_r = (cmax == r) & (~zero)
    is_g = (cmax == g) & (~zero) & (~is_r)
    is_b = (cmax == b) & (~zero) & (~is_r) & (~is_g)
    safe_delta = np.where(delta != 0.0, delta, 1.0)
    with np.errstate(divide="ignore", invalid="ignore"):
        h_r = 60.0 * (((g - b) / safe_delta) % 6.0)
        h_g = 60.0 * (((b - r) / safe_delta) + 2.0)
        h_b = 60.0 * (((r - g) / safe_delta) + 4.0)
    h = np.zeros_like(cmax)
    h = np.where(is_r, h_r, h)
    h = np.where(is_g, h_g, h)
    h = np.where(is_b, h_b, h)
    h = h % 360.0
    safe_cmax = np.where(cmax > 0.0, cmax, 1.0)
    s = np.where(cmax > 0.0, delta / safe_cmax, 0.0)
    v = cmax
    return h, s, v


# ---- 4 式 (thresholds は 1 度だけ derive してこれらに渡す) -------------------
def eval_red_rgb(rgb, t_rg, t_rb):
    r = rgb[..., 0].astype(np.int32)
    g = rgb[..., 1].astype(np.int32)
    b = rgb[..., 2].astype(np.int32)
    return ((r - g) > t_rg) & ((r - b) > t_rb)


def eval_blue_rgb(rgb, t_br, t_bg):
    r = rgb[..., 0].astype(np.int32)
    g = rgb[..., 1].astype(np.int32)
    b = rgb[..., 2].astype(np.int32)
    return ((b - r) > t_br) & ((b - g) > t_bg)


def eval_red_hsv(h, s, v, h_lo, h_hi, s_red, v_red):
    # 赤は 0/360 をまたぐ: H < h_hi or H > h_lo
    return ((h < h_hi) | (h > h_lo)) & (s > s_red) & (v > v_red)


def eval_blue_hsv(h, s, v, h_lo, h_hi, s_blue, v_blue):
    # 青は範囲内: h_lo < H < h_hi
    return (h > h_lo) & (h < h_hi) & (s > s_blue) & (v > v_blue)


# ---- 汎用 --------------------------------------------------------------------
def _extract_patch(arr, nx, ny, patch_px):
    h, w = arr.shape[:2]
    cx = int(float(nx) * w)
    cy = int(float(ny) * h)
    half = patch_px // 2
    x0 = cx - half; y0 = cy - half
    x1 = x0 + patch_px; y1 = y0 + patch_px
    if x0 < 0 or y0 < 0 or x1 > w or y1 > h:
        _abort(f"[ABORT] patch out of range: center=({cx},{cy}) "
               f"patch={patch_px} image=({w},{h})")
    return arr[y0:y1, x0:x1, :], (x0, y0, x1, y1)


def _write_csv(path, cols, rows):
    with path.open("w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols)
        w.writeheader()
        for r in rows:
            w.writerow(r)


def _guard_overwrite(paths):
    for p in paths:
        p.parent.mkdir(parents=True, exist_ok=True)
    for p in paths:
        if p.exists():
            _abort(f"[ABORT] output exists (no overwrite): {p}")


def _load_captured(name):
    """1 枚を md5 照合しつつ読む. (rgb_arr, icc_name, md5) を返す."""
    path = CAP_DIR / name
    if not path.is_file():
        _abort(f"[ABORT] captured image missing: {path}")
    actual = _md5(path)
    expected = EXPECTED_MD5[name]
    if actual != expected:
        _abort(f"[ABORT] md5 mismatch for {name}: "
               f"expected={expected} actual={actual}")
    with Image.open(path) as im_raw:
        arr = np.asarray(im_raw.convert("RGB"))
        icc = _icc_profile_name(im_raw)
    return arr, icc, actual


# ---- 閾値決定 (IMG_7913 のみ・実験より前に一度だけ) --------------------------
def _midpoint(a, b):
    return (float(a) + float(b)) / 2.0


def _derive_thresholds(img_7913, regions_doc):
    """IMG_7913 の 8 領域から 12 個の閾値を決める.
    返り値 (thresholds_dict, warnings_list, details_dict)."""
    patch_px = int(regions_doc.get("patch_size_px", 100))
    regions_spec = regions_doc.get("regions", {})
    if not regions_spec:
        _abort("[ABORT] measure_regions.json 'regions' が空")

    def get_pixels(names):
        arrs = []
        for n in names:
            if n not in regions_spec:
                _abort(f"[ABORT] region '{n}' not in measure_regions.json")
            s = regions_spec[n]
            patch, _ = _extract_patch(img_7913, s["nx"], s["ny"], patch_px)
            arrs.append(patch.reshape(-1, 3))
        return np.concatenate(arrs, axis=0)

    red_t = get_pixels(RED_REGIONS)
    red_nt = get_pixels(NON_TARGET_RED)
    blue_t = get_pixels(BLUE_REGIONS)
    blue_nt = get_pixels(NON_TARGET_BLUE)

    warnings = []
    details = {}

    def rg_midpoint(target_vals, nontarget_vals, key):
        a = float(np.percentile(target_vals, 1))
        b = float(np.percentile(nontarget_vals, 99))
        val = _midpoint(a, b)
        details[key] = {"target_p1": a, "nontarget_p99": b, "midpoint": val}
        if a <= b:
            warnings.append(
                f"{key}: target_p1={a:.4f} <= nontarget_p99={b:.4f}. "
                "白い光でも1本の線で分けられない (midpoint をそのまま採用して続行)")
        return val

    # RGB 赤
    t_rg = rg_midpoint(red_t[:, 0].astype(np.int32) - red_t[:, 1].astype(np.int32),
                       red_nt[:, 0].astype(np.int32) - red_nt[:, 1].astype(np.int32),
                       "t_rg")
    t_rb = rg_midpoint(red_t[:, 0].astype(np.int32) - red_t[:, 2].astype(np.int32),
                       red_nt[:, 0].astype(np.int32) - red_nt[:, 2].astype(np.int32),
                       "t_rb")
    # RGB 青
    t_br = rg_midpoint(blue_t[:, 2].astype(np.int32) - blue_t[:, 0].astype(np.int32),
                       blue_nt[:, 2].astype(np.int32) - blue_nt[:, 0].astype(np.int32),
                       "t_br")
    t_bg = rg_midpoint(blue_t[:, 2].astype(np.int32) - blue_t[:, 1].astype(np.int32),
                       blue_nt[:, 2].astype(np.int32) - blue_nt[:, 1].astype(np.int32),
                       "t_bg")

    # HSV (target / nontarget の HSV をベクトル化変換)
    rt_h, rt_s, rt_v = _rgb_to_hsv_formula_np(red_t)
    rnt_h, rnt_s, rnt_v = _rgb_to_hsv_formula_np(red_nt)
    bt_h, bt_s, bt_v = _rgb_to_hsv_formula_np(blue_t)
    bnt_h, bnt_s, bnt_v = _rgb_to_hsv_formula_np(blue_nt)

    # HSV 赤 hue: circmean + delta 1/99 パーセンタイル
    rt_h_flat = rt_h.flatten()
    center_red = _circmean_circstd_deg(rt_h_flat)[0]
    deltas_red = np.array([_wrap_delta_deg(float(h) - center_red)
                           for h in rt_h_flat])
    dl_r = float(np.percentile(deltas_red, 1))
    dr_r = float(np.percentile(deltas_red, 99))
    h_red_lo = (center_red + dl_r) % 360.0
    h_red_hi = (center_red + dr_r) % 360.0
    details["h_red"] = {"center_deg": center_red,
                        "delta_p1": dl_r, "delta_p99": dr_r,
                        "h_lo": h_red_lo, "h_hi": h_red_hi}

    s_red = rg_midpoint(rt_s.flatten(), rnt_s.flatten(), "s_red")
    v_red = rg_midpoint(rt_v.flatten(), rnt_v.flatten(), "v_red")

    # HSV 青 hue: 同様 (青は 0/360 またぎなし)
    bt_h_flat = bt_h.flatten()
    center_blue = _circmean_circstd_deg(bt_h_flat)[0]
    deltas_blue = np.array([_wrap_delta_deg(float(h) - center_blue)
                            for h in bt_h_flat])
    dl_b = float(np.percentile(deltas_blue, 1))
    dr_b = float(np.percentile(deltas_blue, 99))
    h_blue_lo = (center_blue + dl_b) % 360.0
    h_blue_hi = (center_blue + dr_b) % 360.0
    details["h_blue"] = {"center_deg": center_blue,
                         "delta_p1": dl_b, "delta_p99": dr_b,
                         "h_lo": h_blue_lo, "h_hi": h_blue_hi}

    s_blue = rg_midpoint(bt_s.flatten(), bnt_s.flatten(), "s_blue")
    v_blue = rg_midpoint(bt_v.flatten(), bnt_v.flatten(), "v_blue")

    T = {
        "t_rg": t_rg, "t_rb": t_rb,
        "h_red_lo": h_red_lo, "h_red_hi": h_red_hi,
        "s_red": s_red, "v_red": v_red,
        "t_br": t_br, "t_bg": t_bg,
        "h_blue_lo": h_blue_lo, "h_blue_hi": h_blue_hi,
        "s_blue": s_blue, "v_blue": v_blue,
    }
    return T, warnings, details


# ---- 実験 A: 閾値 1 刻みで hit 変化回数 ---------------------------------------
EXP_A_COLS = ["representation", "dimension",
              "min", "max", "step", "n_thresholds",
              "n_distinct_hit_counts", "n_transitions"]


def _run_exp_a(base_rgb):
    """base_image の全画素に対し 3 表現 × 3 次元で刻み測定."""
    h_f, s_f, v_f = _rgb_to_hsv_formula_np(base_rgb)
    if cv2 is None:
        _abort("[ABORT] cv2 が import できない。実験 A の opencv 表現に必要。")
    hsv_cv = cv2.cvtColor(base_rgb, cv2.COLOR_RGB2HSV)
    h_cv, s_cv, v_cv = hsv_cv[..., 0], hsv_cv[..., 1], hsv_cv[..., 2]
    im_pil_hsv = np.asarray(Image.fromarray(base_rgb, mode="RGB").convert("HSV"))
    h_pil, s_pil, v_pil = im_pil_hsv[..., 0], im_pil_hsv[..., 1], im_pil_hsv[..., 2]

    configs = [
        ("formula", "H", h_f, 0.0, 360.0, 1.0),
        ("formula", "S", s_f, 0.0, 1.0, 0.01),
        ("formula", "V", v_f, 0.0, 1.0, 0.01),
        ("opencv", "H", h_cv, 0, 179, 1),
        ("opencv", "S", s_cv, 0, 255, 1),
        ("opencv", "V", v_cv, 0, 255, 1),
        ("pillow", "H", h_pil, 0, 255, 1),
        ("pillow", "S", s_pil, 0, 255, 1),
        ("pillow", "V", v_pil, 0, 255, 1),
    ]
    rows = []
    for rep, dim, arr, mn, mx, step in configs:
        flat = arr.flatten()
        if isinstance(step, float):
            n = int(round((mx - mn) / step)) + 1
            ts = np.linspace(mn, mx, n)
        else:
            ts = np.arange(mn, mx + 1, step)
        counts = np.array([int((flat > t).sum()) for t in ts])
        n_transitions = int((np.diff(counts) != 0).sum())
        n_distinct = int(len(np.unique(counts)))
        rows.append({
            "representation": rep,
            "dimension": dim,
            "min": mn,
            "max": mx,
            "step": step,
            "n_thresholds": len(ts),
            "n_distinct_hit_counts": n_distinct,
            "n_transitions": n_transitions,
        })
    return rows


# ---- 実験 B: 生成画像 × 4 式 × 各パッチの hit -------------------------------
EXP_B_COLS = ["image", "patch", "expected_r", "expected_g", "expected_b",
              "x0", "y0", "x1", "y1", "n_pixels",
              "red_rgb_hits", "blue_rgb_hits",
              "red_hsv_hits", "blue_hsv_hits",
              "red_rgb_rate", "blue_rgb_rate",
              "red_hsv_rate", "blue_hsv_rate"]


def _apply_all_formulas(rgb_arr, T):
    h, s, v = _rgb_to_hsv_formula_np(rgb_arr)
    return {
        "red_rgb": eval_red_rgb(rgb_arr, T["t_rg"], T["t_rb"]),
        "blue_rgb": eval_blue_rgb(rgb_arr, T["t_br"], T["t_bg"]),
        "red_hsv": eval_red_hsv(h, s, v, T["h_red_lo"], T["h_red_hi"],
                                T["s_red"], T["v_red"]),
        "blue_hsv": eval_blue_hsv(h, s, v, T["h_blue_lo"], T["h_blue_hi"],
                                  T["s_blue"], T["v_blue"]),
    }


def _run_exp_b(base_rgb, orange_rgb, spec, T):
    rows = []
    for image_name, arr in [("base_image.png", base_rgb),
                            ("orange_shifted.png", orange_rgb)]:
        masks = _apply_all_formulas(arr, T)
        for patch in spec.get("patches", []):
            name = patch["name"]
            r, g, b = [int(v) for v in patch["rgb"]]
            x0 = int(patch["x0"]); y0 = int(patch["y0"])
            x1 = int(patch["x1"]); y1 = int(patch["y1"])
            n = (y1 - y0) * (x1 - x0)
            rr = int(masks["red_rgb"][y0:y1, x0:x1].sum())
            br = int(masks["blue_rgb"][y0:y1, x0:x1].sum())
            rh = int(masks["red_hsv"][y0:y1, x0:x1].sum())
            bh = int(masks["blue_hsv"][y0:y1, x0:x1].sum())
            rows.append({
                "image": image_name, "patch": name,
                "expected_r": r, "expected_g": g, "expected_b": b,
                "x0": x0, "y0": y0, "x1": x1, "y1": y1,
                "n_pixels": n,
                "red_rgb_hits": rr, "blue_rgb_hits": br,
                "red_hsv_hits": rh, "blue_hsv_hits": bh,
                "red_rgb_rate": f"{rr / n:.6f}",
                "blue_rgb_rate": f"{br / n:.6f}",
                "red_hsv_rate": f"{rh / n:.6f}",
                "blue_hsv_rate": f"{bh / n:.6f}",
            })
    return rows


# ---- 実験 C: 実写 4 枚 × (8 領域 + full_frame) × 4 式 ------------------------
EXP_C_COLS = ["image", "region", "n_pixels",
              "red_rgb_hits", "red_rgb_rate",
              "blue_rgb_hits", "blue_rgb_rate",
              "red_hsv_hits", "red_hsv_rate",
              "blue_hsv_hits", "blue_hsv_rate"]


def _run_exp_c(all_captured, regions_doc, T):
    patch_px = int(regions_doc.get("patch_size_px", 100))
    regions_spec = regions_doc.get("regions", {})
    rows = []
    for name in CAPTURED_ORDER:
        arr = all_captured[name]
        masks = _apply_all_formulas(arr, T)
        for region_name, spec in regions_spec.items():
            _, (x0, y0, x1, y1) = _extract_patch(arr, spec["nx"], spec["ny"], patch_px)
            n = (y1 - y0) * (x1 - x0)
            rr = int(masks["red_rgb"][y0:y1, x0:x1].sum())
            br = int(masks["blue_rgb"][y0:y1, x0:x1].sum())
            rh = int(masks["red_hsv"][y0:y1, x0:x1].sum())
            bh = int(masks["blue_hsv"][y0:y1, x0:x1].sum())
            rows.append({
                "image": name, "region": region_name, "n_pixels": n,
                "red_rgb_hits": rr, "red_rgb_rate": f"{rr / n:.6f}",
                "blue_rgb_hits": br, "blue_rgb_rate": f"{br / n:.6f}",
                "red_hsv_hits": rh, "red_hsv_rate": f"{rh / n:.6f}",
                "blue_hsv_hits": bh, "blue_hsv_rate": f"{bh / n:.6f}",
            })
        # full frame
        H, W = arr.shape[:2]
        n_ff = H * W
        rr = int(masks["red_rgb"].sum())
        br = int(masks["blue_rgb"].sum())
        rh = int(masks["red_hsv"].sum())
        bh = int(masks["blue_hsv"].sum())
        rows.append({
            "image": name, "region": "full_frame", "n_pixels": n_ff,
            "red_rgb_hits": rr, "red_rgb_rate": f"{rr / n_ff:.6f}",
            "blue_rgb_hits": br, "blue_rgb_rate": f"{br / n_ff:.6f}",
            "red_hsv_hits": rh, "red_hsv_rate": f"{rh / n_ff:.6f}",
            "blue_hsv_hits": bh, "blue_hsv_rate": f"{bh / n_ff:.6f}",
        })
    return rows


# ---- 実験 D: 1 変数ずつ振る (IMG_7913・4 領域グループの hit_rate) -----------
EXP_D_COLS = ["variable", "formula_affected", "value",
              "red_region_rate", "desk_region_rate",
              "paper_rate", "blue_region_rate"]


def _run_exp_d(img_7913, regions_doc, T):
    patch_px = int(regions_doc.get("patch_size_px", 100))
    regions_spec = regions_doc.get("regions", {})

    def group_pixels(names):
        arrs = []
        for n in names:
            s = regions_spec[n]
            patch, _ = _extract_patch(img_7913, s["nx"], s["ny"], patch_px)
            arrs.append(patch.reshape(-1, 3))
        return np.concatenate(arrs, axis=0)

    red_px = group_pixels(RED_REGIONS)
    desk_px = group_pixels(DESK_REGIONS)
    paper_px = group_pixels(PAPER_REGIONS)
    blue_px = group_pixels(BLUE_REGIONS)
    red_hsv = _rgb_to_hsv_formula_np(red_px)
    desk_hsv = _rgb_to_hsv_formula_np(desk_px)
    paper_hsv = _rgb_to_hsv_formula_np(paper_px)
    blue_hsv = _rgb_to_hsv_formula_np(blue_px)

    groups = [
        ("red_region", red_px, red_hsv),
        ("desk_region", desk_px, desk_hsv),
        ("paper", paper_px, paper_hsv),
        ("blue_region", blue_px, blue_hsv),
    ]

    def rates(formula_key, params):
        out = {}
        for gname, gpx, ghsv in groups:
            gh, gs, gv = ghsv
            if formula_key == "red_rgb":
                mask = eval_red_rgb(gpx, params["t_rg"], params["t_rb"])
            elif formula_key == "blue_rgb":
                mask = eval_blue_rgb(gpx, params["t_br"], params["t_bg"])
            elif formula_key == "red_hsv":
                mask = eval_red_hsv(gh, gs, gv,
                                    params["h_red_lo"], params["h_red_hi"],
                                    params["s_red"], params["v_red"])
            else:
                mask = eval_blue_hsv(gh, gs, gv,
                                     params["h_blue_lo"], params["h_blue_hi"],
                                     params["s_blue"], params["v_blue"])
            out[gname] = float(mask.sum()) / float(len(gpx))
        return out

    # 刻み: 実験 A の最小刻みより細かくしない.
    #   RGB diff (t_*): 整数 1 刻み (-255..255)
    #   HSV H (h_*):    1.0 deg 刻み (0..360)
    #   HSV S/V (*_red, *_blue): 0.01 刻み (0..1)
    sweeps = [
        ("t_rg", "red_rgb",  list(range(-255, 256, 1))),
        ("t_rb", "red_rgb",  list(range(-255, 256, 1))),
        ("t_br", "blue_rgb", list(range(-255, 256, 1))),
        ("t_bg", "blue_rgb", list(range(-255, 256, 1))),
        ("h_red_lo", "red_hsv",  list(np.arange(0.0, 360.0 + 1e-9, 1.0))),
        ("h_red_hi", "red_hsv",  list(np.arange(0.0, 360.0 + 1e-9, 1.0))),
        ("h_blue_lo", "blue_hsv", list(np.arange(0.0, 360.0 + 1e-9, 1.0))),
        ("h_blue_hi", "blue_hsv", list(np.arange(0.0, 360.0 + 1e-9, 1.0))),
        ("s_red", "red_hsv",  list(np.arange(0.0, 1.0 + 1e-9, 0.01))),
        ("v_red", "red_hsv",  list(np.arange(0.0, 1.0 + 1e-9, 0.01))),
        ("s_blue", "blue_hsv", list(np.arange(0.0, 1.0 + 1e-9, 0.01))),
        ("v_blue", "blue_hsv", list(np.arange(0.0, 1.0 + 1e-9, 0.01))),
    ]

    rows = []
    for var_name, formula_key, values in sweeps:
        for val in values:
            params = dict(T)
            params[var_name] = float(val)
            r = rates(formula_key, params)
            rows.append({
                "variable": var_name,
                "formula_affected": formula_key,
                "value": (int(val) if isinstance(val, (int, np.integer))
                          else f"{float(val):.4f}"),
                "red_region_rate": f"{r['red_region']:.6f}",
                "desk_region_rate": f"{r['desk_region']:.6f}",
                "paper_rate": f"{r['paper']:.6f}",
                "blue_region_rate": f"{r['blue_region']:.6f}",
            })
    return rows


# ---- 実験 E: 実験 C から抜き出し (赤の式で青領域が拾われた数) ---------------
EXP_E_COLS = ["image", "region", "n_pixels",
              "red_rgb_hits", "red_rgb_rate",
              "red_hsv_hits", "red_hsv_rate"]


def _run_exp_e(exp_c_rows):
    rows = []
    for r in exp_c_rows:
        if r["region"] in BLUE_REGIONS:
            rows.append({
                "image": r["image"],
                "region": r["region"],
                "n_pixels": r["n_pixels"],
                "red_rgb_hits": r["red_rgb_hits"],
                "red_rgb_rate": r["red_rgb_rate"],
                "red_hsv_hits": r["red_hsv_hits"],
                "red_hsv_rate": r["red_hsv_rate"],
            })
    return rows


# ---- オレンジ倍率 (paper 領域の 4914/4913 比) --------------------------------
def _compute_orange_shift(img_7913, img_7914, regions_doc, md5_from, md5_to):
    patch_px = int(regions_doc.get("patch_size_px", 100))
    regions_spec = regions_doc.get("regions", {})
    paper = regions_spec.get("paper")
    if paper is None:
        _abort("[ABORT] measure_regions.json に 'paper' が無い")
    p_from, _ = _extract_patch(img_7913, paper["nx"], paper["ny"], patch_px)
    p_to, _ = _extract_patch(img_7914, paper["nx"], paper["ny"], patch_px)
    m_from = p_from.reshape(-1, 3).mean(axis=0).astype(np.float64)
    m_to = p_to.reshape(-1, 3).mean(axis=0).astype(np.float64)
    ratios = m_to / m_from  # 丸めない
    return {
        "source_from": IMG_7913, "source_from_md5": md5_from,
        "source_to": IMG_7914, "source_to_md5": md5_to,
        "region_used": "paper",
        "region_nx": float(paper["nx"]), "region_ny": float(paper["ny"]),
        "patch_px": patch_px,
        "mean_r_from": float(m_from[0]),
        "mean_g_from": float(m_from[1]),
        "mean_b_from": float(m_from[2]),
        "mean_r_to": float(m_to[0]),
        "mean_g_to": float(m_to[1]),
        "mean_b_to": float(m_to[2]),
        "ratio_r": float(ratios[0]),
        "ratio_g": float(ratios[1]),
        "ratio_b": float(ratios[2]),
    }


def _apply_orange_shift(base_rgb, ratio_r, ratio_g, ratio_b):
    arr = base_rgb.astype(np.float64)
    arr[..., 0] = arr[..., 0] * ratio_r
    arr[..., 1] = arr[..., 1] * ratio_g
    arr[..., 2] = arr[..., 2] * ratio_b
    return np.clip(arr, 0, 255).astype(np.uint8)


# ---- main --------------------------------------------------------------------
def main() -> None:
    ensure_ssd_mounted()

    # 入力パス確認
    for p in (REGIONS_JSON, BASE_PNG, BASE_SPEC):
        if not p.is_file():
            _abort(f"[ABORT] input missing: {p}")
    for name in CAPTURED_ORDER:
        p = CAP_DIR / name
        if not p.is_file():
            _abort(f"[ABORT] captured missing: {p}")

    # 出力上書きガード (全出力を先にチェック)
    _guard_overwrite([THRESH_JSON, OUT_EXP_A, OUT_EXP_B, OUT_EXP_C, OUT_EXP_D,
                      OUT_EXP_E, OUT_META, ORANGE_PNG, ORANGE_SPEC])

    # spec / regions.json
    spec = json.loads(BASE_SPEC.read_text(encoding="utf-8"))
    regions_doc = json.loads(REGIONS_JSON.read_text(encoding="utf-8"))

    # base_image (Log02 生成画像) 読み込み
    import hashlib
    with Image.open(BASE_PNG) as im_raw:
        base_rgb = np.asarray(im_raw.convert("RGB"))
        base_icc = _icc_profile_name(im_raw)
    base_pixel_sha256 = hashlib.sha256(base_rgb.tobytes()).hexdigest()
    spec_pixel_sha256 = spec.get("pixel_sha256", "")

    # --- 閾値決定 (IMG_7913 のみ) ---
    arr_7913, icc_7913, md5_7913 = _load_captured(IMG_7913)
    T, warnings, details = _derive_thresholds(arr_7913, regions_doc)
    THRESH_JSON.write_text(json.dumps({
        "derived_from": IMG_7913,
        "source_md5": md5_7913,
        "target_regions_red": RED_REGIONS,
        "non_target_regions_red": NON_TARGET_RED,
        "target_regions_blue": BLUE_REGIONS,
        "non_target_regions_blue": NON_TARGET_BLUE,
        "thresholds": T,
        "warnings": warnings,
        "derivation_details": details,
    }, ensure_ascii=False, indent=2), encoding="utf-8")

    # --- オレンジ倍率 & shifted PNG ---
    arr_7914, icc_7914, md5_7914 = _load_captured(IMG_7914)
    shift_meta = _compute_orange_shift(arr_7913, arr_7914, regions_doc,
                                       md5_7913, md5_7914)
    orange_rgb = _apply_orange_shift(base_rgb,
                                     shift_meta["ratio_r"],
                                     shift_meta["ratio_g"],
                                     shift_meta["ratio_b"])
    Image.fromarray(orange_rgb, mode="RGB").save(str(ORANGE_PNG), format="PNG")
    orange_pixel_sha256 = hashlib.sha256(orange_rgb.tobytes()).hexdigest()
    shift_meta["base_image"] = str(BASE_PNG)
    shift_meta["base_image_pixel_sha256"] = base_pixel_sha256
    shift_meta["output_image"] = str(ORANGE_PNG)
    shift_meta["output_pixel_sha256"] = orange_pixel_sha256
    shift_meta["note"] = ("orange_shifted = clip(base * (r,g,b), 0, 255) を "
                          "各チャネルに適用. ratio_* は丸めない.")
    ORANGE_SPEC.write_text(json.dumps(shift_meta, ensure_ascii=False, indent=2),
                           encoding="utf-8")

    # --- 残り 2 枚を読む (md5 照合) ---
    arr_7911, icc_7911, md5_7911 = _load_captured(IMG_7911)
    arr_7912, icc_7912, md5_7912 = _load_captured(IMG_7912)
    all_captured = {
        IMG_7911: arr_7911, IMG_7912: arr_7912,
        IMG_7913: arr_7913, IMG_7914: arr_7914,
    }
    all_icc = {
        IMG_7911: icc_7911, IMG_7912: icc_7912,
        IMG_7913: icc_7913, IMG_7914: icc_7914,
    }
    all_md5 = {
        IMG_7911: md5_7911, IMG_7912: md5_7912,
        IMG_7913: md5_7913, IMG_7914: md5_7914,
    }

    # --- 実験 A / B / C / D / E ---
    exp_a_rows = _run_exp_a(base_rgb)
    exp_b_rows = _run_exp_b(base_rgb, orange_rgb, spec, T)
    exp_c_rows = _run_exp_c(all_captured, regions_doc, T)
    exp_d_rows = _run_exp_d(arr_7913, regions_doc, T)
    exp_e_rows = _run_exp_e(exp_c_rows)

    _write_csv(OUT_EXP_A, EXP_A_COLS, exp_a_rows)
    _write_csv(OUT_EXP_B, EXP_B_COLS, exp_b_rows)
    _write_csv(OUT_EXP_C, EXP_C_COLS, exp_c_rows)
    _write_csv(OUT_EXP_D, EXP_D_COLS, exp_d_rows)
    _write_csv(OUT_EXP_E, EXP_E_COLS, exp_e_rows)

    # --- run_meta_threshold.json ---
    script_path = Path(__file__).resolve()
    meta = {
        "script_name": script_path.name,
        "script_path": str(script_path),
        "script_md5": _md5(script_path),
        "executed_at": datetime.now().astimezone().isoformat(),
        "python_version": sys.version,
        "library_versions": {
            "numpy": np.__version__,
            "Pillow": Image.__version__,
            "opencv": _OPENCV_VERSION,
        },
        "input_files": {
            "captured": [
                {"name": n, "path": str(CAP_DIR / n),
                 "md5_expected": EXPECTED_MD5[n],
                 "md5_actual": all_md5[n],
                 "icc_profile_name": all_icc[n]}
                for n in CAPTURED_ORDER
            ],
            "regions_json": {
                "path": str(REGIONS_JSON),
                "md5": _md5(REGIONS_JSON),
            },
            "base_image_spec_json": {
                "path": str(BASE_SPEC),
                "md5": _md5(BASE_SPEC),
            },
            "base_image_png": {
                "path": str(BASE_PNG),
                "pixel_sha256": base_pixel_sha256,
                "spec_pixel_sha256": spec_pixel_sha256,
                "pixel_sha256_match": (base_pixel_sha256 == spec_pixel_sha256),
                "icc_profile_name": base_icc,
            },
        },
        "output_files": [
            str(THRESH_JSON.name),
            str(OUT_EXP_A.name), str(OUT_EXP_B.name),
            str(OUT_EXP_C.name), str(OUT_EXP_D.name),
            str(OUT_EXP_E.name), str(OUT_META.name),
            f"generated/Log04/{ORANGE_PNG.name}",
            f"generated/Log04/{ORANGE_SPEC.name}",
        ],
        "notes": {
            "thresholds_source_image": IMG_7913,
            "hsv_scale_used": "H=deg[0,360), S=[0,1], V=[0,1]",
            "color_conversion": "none (Display P3 のまま)",
            "orange_shift_output_pixel_sha256": orange_pixel_sha256,
        },
    }
    OUT_META.write_text(json.dumps(meta, ensure_ascii=False, indent=2),
                        encoding="utf-8")

    print(f"exp_a rows={len(exp_a_rows)}")
    print(f"exp_b rows={len(exp_b_rows)}")
    print(f"exp_c rows={len(exp_c_rows)}")
    print(f"exp_d rows={len(exp_d_rows)}")
    print(f"exp_e rows={len(exp_e_rows)}")


if __name__ == "__main__":
    main()

決まった閾値:thresholds_white.json

白い光の IMG_7913 だけから決めた12個の閾値と、その導出の詳細がこれになる。warnings に、彩度・明度が分離できなかった4件(発見1)がそのまま記録されている。

{
  "derived_from": "IMG_7913.JPG",
  "source_md5": "cb9d0627d1c3d5c0074b5efe434e546d",
  "target_regions_red": [
    "red_top",
    "red_front_left"
  ],
  "non_target_regions_red": [
    "blue_top",
    "blue_front_left",
    "paper",
    "desk_top_center",
    "desk_upper_right",
    "desk_lower_right"
  ],
  "target_regions_blue": [
    "blue_top",
    "blue_front_left"
  ],
  "non_target_regions_blue": [
    "red_top",
    "red_front_left",
    "paper",
    "desk_top_center",
    "desk_upper_right",
    "desk_lower_right"
  ],
  "thresholds": {
    "t_rg": 33.5,
    "t_rb": 44.0,
    "h_red_lo": 2.1428571428571415,
    "h_red_hi": 4.363636363636377,
    "s_red": 0.7733333333333333,
    "v_red": 0.44509803921568625,
    "t_br": 1.5,
    "t_bg": 4.0,
    "h_blue_lo": 203.47826086956522,
    "h_blue_hi": 206.66666666666669,
    "s_blue": 0.6779543197616683,
    "v_blue": 0.403921568627451
  },
  "warnings": [
    "s_red: target_p1=0.7467 <= nontarget_p99=0.8000. 白い光でも1本の線で分けられない (midpoint をそのまま採用して続行)",
    "v_red: target_p1=0.2588 <= nontarget_p99=0.6314. 白い光でも1本の線で分けられない (midpoint をそのまま採用して続行)",
    "s_blue: target_p1=0.4717 <= nontarget_p99=0.8842. 白い光でも1本の線で分けられない (midpoint をそのまま採用して続行)",
    "v_blue: target_p1=0.1765 <= nontarget_p99=0.6314. 白い光でも1本の線で分けられない (midpoint をそのまま採用して続行)"
  ],
  "derivation_details": {
    "t_rg": {
      "target_p1": 51.0,
      "nontarget_p99": 16.0,
      "midpoint": 33.5
    },
    "t_rb": {
      "target_p1": 54.0,
      "nontarget_p99": 34.0,
      "midpoint": 44.0
    },
    "t_br": {
      "target_p1": 25.0,
      "nontarget_p99": -22.0,
      "midpoint": 1.5
    },
    "t_bg": {
      "target_p1": 10.0,
      "nontarget_p99": -2.0,
      "midpoint": 4.0
    },
    "h_red": {
      "center_deg": 3.660788570694794,
      "delta_p1": -1.5179314278376523,
      "delta_p99": 0.7028477929415828,
      "h_lo": 2.1428571428571415,
      "h_hi": 4.363636363636377
    },
    "s_red": {
      "target_p1": 0.7466666666666666,
      "nontarget_p99": 0.8,
      "midpoint": 0.7733333333333333
    },
    "v_red": {
      "target_p1": 0.25882352941176473,
      "nontarget_p99": 0.6313725490196078,
      "midpoint": 0.44509803921568625
    },
    "h_blue": {
      "center_deg": 206.4740994190936,
      "delta_p1": -2.995838549528372,
      "delta_p99": 0.19256724757309485,
      "h_lo": 203.47826086956522,
      "h_hi": 206.66666666666669
    },
    "s_blue": {
      "target_p1": 0.47169811320754723,
      "nontarget_p99": 0.8842105263157894,
      "midpoint": 0.6779543197616683
    },
    "v_blue": {
      "target_p1": 0.17647058823529413,
      "nontarget_p99": 0.6313725490196078,
      "midpoint": 0.403921568627451
    }
  }
}

発見2の生ログ:exp_e_blue.csv

赤の式で青領域を拾った画素数。red_hsv_hits の列が、基準の白で0、明るさ違いで9997・10000に跳ねている。

image,region,n_pixels,red_rgb_hits,red_rgb_rate,red_hsv_hits,red_hsv_rate
IMG_7911.JPG,blue_top,10000,0,0.000000,9997,0.999700
IMG_7911.JPG,blue_front_left,10000,0,0.000000,0,0.000000
IMG_7912.JPG,blue_top,10000,0,0.000000,10000,1.000000
IMG_7912.JPG,blue_front_left,10000,0,0.000000,0,0.000000
IMG_7913.JPG,blue_top,10000,0,0.000000,0,0.000000
IMG_7913.JPG,blue_front_left,10000,0,0.000000,0,0.000000
IMG_7914.JPG,blue_top,10000,0,0.000000,0,0.000000
IMG_7914.JPG,blue_front_left,10000,0,0.000000,0,0.000000

発見3の生ログ:exp_c_region_hits.csv(画面全体の行)

画面全体(full_frame)のうち各式が拾った割合。オレンジ光の行で red_rgb_rate だけが 0.963 に跳ねている。

image,red_rgb_rate,red_hsv_rate,blue_rgb_rate,blue_hsv_rate
IMG_7911.JPG,0.021,0.023,0.033,0.010
IMG_7912.JPG,0.023,0.027,0.032,0.017
IMG_7913.JPG,0.019,0.000,0.030,0.000
IMG_7914.JPG,0.963,0.008,0.000,0.000

オレンジ倍率の記録:orange_shift_spec.json

オレンジ光が各色をどれだけ変えるかは、基準の白と実際のオレンジ写真の紙の領域から実測した。青が 0.57倍に落ちる(オレンジ光は青を殺す)のが数字に出ている。倍率は丸めず、算出元の平均値と両画像のハッシュも残している。

{
  "source_from": "IMG_7913.JPG",
  "source_from_md5": "cb9d0627d1c3d5c0074b5efe434e546d",
  "source_to": "IMG_7914.JPG",
  "source_to_md5": "dfa4e0b8ad162546755c1e83ef82d328",
  "region_used": "paper",
  "region_nx": 0.15,
  "region_ny": 0.55,
  "patch_px": 100,
  "mean_r_from": 157.2336,
  "mean_g_from": 148.1302,
  "mean_b_from": 133.3556,
  "mean_r_to": 170.4164,
  "mean_g_to": 124.0556,
  "mean_b_to": 76.039,
  "ratio_r": 1.083842130435225,
  "ratio_g": 0.8374767603095115,
  "ratio_b": 0.5701972770547318,
  "base_image": "/Volumes/KIOXIA/r2r2r-data/vision/generated/Log02/base_image.png",
  "base_image_pixel_sha256": "05a8472d19a181c2fd98f9a45f756b61d904c3b04c066aec2d701298460d84b7",
  "output_image": "/Volumes/KIOXIA/r2r2r-data/vision/generated/Log04/orange_shifted.png",
  "output_pixel_sha256": "511524996bbb34d2941a7e1b06840bb56fb8ef69b78541b89fef7f79ce45b030",
  "note": "orange_shifted = clip(base * (r,g,b), 0, 255) を 各チャネルに適用. ratio_* は丸めない."
}

実行ログ

スクリプトを実行すると、5実験それぞれの行数を出して完了する。KIOXIA SSD がマウントされ、出力先の上書きガードを通過して初めてここまで走る。

exp_a rows=9
exp_b rows=14
exp_c rows=36
exp_d rows=3892
exp_e rows=8