画像認識と物体検出を学び直す Log#3 | シーリングライトのオレンジの光の下で青いブロックが色を失う
前回、照明を変えると写真の赤・緑・青の3つの数字が全部動くことを実測した。青いブロックの上面では、照明を変えただけで赤の値が30から0まで落ちた。ただしこのとき、明るさが変わったのか、光の色が変わったのかを分けられていなかった。3つの数字が同時に動いたという事実しか残っていない。
その動きを「明るさの変化」と「色味の変化」に分けられないか。色をそう分けて表す方法が HSV と呼ばれるもので、色相・彩度・明度の3つで色を書く。明るさが変わっても「何色か」は保たれる、という性質がよく紹介される。
これが実際の写真で成り立つかを確かめた。結果を先に書くと、半分だけ成り立った。明るさだけを変えたときは色相がほとんど動かなかった。光の色だけを変えたときは、赤いブロックが4度しか動かないのに、青いブロックの暗い面は155度動いた。205.9度から50.9度へ。青として測れていた面が、オレンジ寄りの色として読まれた。
天井の照明には、明るさのつまみと色のつまみが別々にある
シーリングライトのリモコンを見ると、ボタンが2種類ある。ひとつは明るさを変えるボタン、もうひとつは光の色を変えるボタン。この2つは別々のつまみで、片方を動かしてももう片方は変わらない。
調光は、光の量を変える。強くすれば部屋全体が明るくなり、弱くすれば暗くなる。光の色は変わらない。
調色は、光そのものの色を変える。暖かいオレンジ寄りの光にも、青白い光にもできる。明るさは変わらない。
この2つは、目で見たときに混ざって感じられる。オレンジっぽい光にすると「暗くなった気がする」し、青白い光にすると「明るくなった気がする」。実際には光の量は変わっていないのに、そう感じる。目で区別できないものは、数字で区別する。それがこの回でやることになる。
調色のつまみが動かしているのは、色温度と呼ばれる数字で、単位はK(ケルビン)と書く。
色温度 | 呼び名 | どんな光か |
|---|---|---|
2700K前後 | 電球色 | オレンジっぽい。夕方の室内、白熱電球 |
5000K前後 | 昼白色 | 白に近い。晴れた日の日中 |
6500K前後 | 昼光色 | 青白い。曇り空の下 |
数字が小さいほどオレンジ寄り、大きいほど青白くなる。直感とは逆に感じるかもしれない。青白いほうが冷たい色に見えるのに、数字は大きい。これは金属を熱したときの色の変化から来ている呼び方で、見た目の暖かさとは関係がない。
調光と調色は、写真の3つの数字を別の形で動かす
ここがこの回の測定の中心になる。
調光を下げると、赤・緑・青の3つが揃って下がる。光の量が半分になれば、返ってくる光もおおよそ半分になる。3つの数字は下がるが、互いの比はあまり変わらない。青いブロックは、暗くても青いままになる。
調色を変えると、3つの数字が別々の向きに動く。電球色に寄せれば赤が増えて青が減る。青白い光に寄せれば青が増えて赤が減る。互いの比が変わる。
ここで HSV が効いてくる。
H(色相)は、何色かを角度で表す。0度が赤、120度が緑、240度が青
S(彩度)は、鮮やかさ。0なら灰色、1なら純色
V(明度)は、明るさ
このうち H は、赤・緑・青の比から決まる。3つの数字を一律に0.8倍しても比は変わらないので、H は動かない。この性質から、次の予想が立つ。
調光を変えても比は変わらない。だから H は動かないはず
調色を変えると比が変わる。だから H は動くはず
「はず」と書いたのは、まだ測っていないから。この予想が実際の写真で成り立つかを確かめる。
なお、調色を変えると明るさも少し動くことがある。LED照明の多くは、色の違う2種類のLEDを混ぜて色温度を作っていて、混ぜる比率を変えると光の量も動く。これは白いコピー用紙を一緒に写しておけば後から確認できる。
生成画像では色相が動かないので、照明に強い証拠にならない
ここで先に潰しておく穴がある。
画像を自分で作って、全画素を0.8倍して色相を測り、動かなかったとする。それは「HSV は明るさに強い」の証拠になるだろうか。ならない。H は比から決まると決まっているので、一律に定数倍すれば H が動かないのは式でそう決まっているだけで、照明の話とは別になる。
だから生成画像に任せる役割を2つに限定した。
変換が正しく動いているかの検算。既知の赤・緑・青を HSV にして、手計算と合うか
整数に丸める過程でどれだけ誤差が出るか。理屈上ゼロのはずの色相のずれが、実際は丸めで何度出るか
照明に強いかどうかは実写でしか言えない。この切り分けが、この回の測定の骨組みになる。
最初に撮った4枚は、レンズが途中で切り替わっていて使えなかった
机に赤いブロック・青いブロック・白いコピー用紙を置き、iPhone を固定して4枚撮った。明るさだけを変えた2枚(IMG_7907・IMG_7908)と、光の色だけを変えた2枚(IMG_7909・IMG_7910)。露出を固定して、カメラが照明の変化を打ち消さないようにした。
写真を見るかぎり、何もおかしくなかった。EXIF を読んで初めて分かった。
ファイル | レンズ | ISO | F値 | シャッター | デジタルズーム |
|---|---|---|---|---|---|
IMG_7907.JPG | 2.22mm | 100 | 2.2 | 1/100 | 1.776 |
IMG_7908.JPG | 6.86mm | 80 | 1.78 | 1/100 | なし |
IMG_7909.JPG | 6.86mm | 80 | 1.78 | 1/100 | なし |
IMG_7910.JPG | 6.86mm | 80 | 1.78 | 1/100 | なし |
IMG_7907 だけレンズが違う。iPhone は 0.5倍(超広角 2.22mm)と 1倍(メイン 6.86mm)で物理的に別のカメラを使う。1枚目だけ超広角にデジタルズームをかけた状態で撮れていた。レンズが切り替わると露出の固定も外れるので、ISO と F値も一緒に変わっている。
画角も違っていた。赤ブロックの外接枠を測ると、IMG_7907 が 579×672画素で中心 (1965, 1101)、IMG_7908 が 609×702画素で中心 (1906, 1158)。大きさが約5パーセント、位置が約60画素ずれている。4枚で同じ測定領域を使えない。
明るさは確かに落ちていて、白い紙の値が189から102になっていた。ただしレンズと露出設定も同時に変わったので、そのうちどれだけが照明の変化かを分けられない。前回、自動露出がシャッター速度とISOを動かして照明の変化を打ち消していたのと、同じ型の失敗になる。撮り直した。
撮り直した4枚はレンズ・ISO・F値・シャッター速度が同一だった
ズームを固定してから露出を固定し、撮り直した。

シャッター | ISO | F値 | レンズ | デジタルズーム | |
|---|---|---|---|---|---|
IMG_7911〜7914 | 1/50 | 200 | 2.2 | 2.22mm | 1.776 |
セット内だけでなく4枚すべてでカメラの状態が同じになった。前回のシリーズと同じ超広角レンズなので、比べるときにも都合がいい。
画角も揃った。赤ブロックの外接枠の中心が (2214, 1032) / (2213, 1034) / (2213, 1033) で、ずれは2画素以内。カメラも被写体も動いていない。4枚で同じ測定領域を使える。
明るいほうの画像で数字が振り切れていないかも見た。最大値は240・230・245(IMG_7911)で、IMG_7912 で赤が255に達した画素が0.01パーセント。測定に影響する量ではない。
セットの対応はこうなる。
ファイル | セット | 変えたつまみ | 結果 |
|---|---|---|---|
IMG_7911.JPG | 3 | 調光 | 暗いほう(画像全体の明るさ 162.3) |
IMG_7912.JPG | 3 | 調光 | 明るいほう(176.0) |
IMG_7913.JPG | 4 | 調色 | 白いほう(R145.5 G134.4 B121.5) |
IMG_7914.JPG | 4 | 調色 | オレンジ寄り(R157.7 G113.4 B68.5) |
明るいほうと暗いほうの区別は、記憶ではなく画素から決めた。手順としては明るい状態から先に撮るつもりだったが、測ると IMG_7912 のほうが明るい。どちらのつまみを動かしたかは操作の記録が正で、その結果どちらへ動いたかは測定値で決める。この2つを分けておけば、記憶違いが測定に混ざらない。
測定する8箇所を、面の内側であることを計算で保証して置いた
前回と同じ8箇所を測る。白い紙、机の3箇所、青ブロックの上面と手前左面、赤ブロックの上面と手前左面。
前回は赤ブロックの枠が面の境界にはみ出していて、後から直した。同じことが起きないように、今回は目分量で置かずに計算で決めた。
手順はこうなる。IMG_7912 で赤と青の領域を色で切り出し、明るさで上面と手前左面に分け、それぞれの面について境界からいちばん遠い点を距離変換で求める。この点なら、面の内側であることが距離の値として保証される。
面 | 中心からの内接半径 |
|---|---|
red_top | 153画素 |
red_front_left | 119画素 |
blue_top | 146画素 |
blue_front_left | 150画素 |
測定に使う枠は100×100画素なので、中心から50画素あれば足りる。どの面も余裕をもって内側に収まっている。
白い紙と机の4箇所は、画角が変わっても同じ面の上に乗っていたので、前回の座標をそのまま使った。
確定した座標がこれになる。
領域 | nx | ny | 決め方 |
|---|---|---|---|
paper | 0.1500 | 0.5500 | 前回から変更なし |
desk_top_center | 0.6200 | 0.1200 | 前回から変更なし |
desk_upper_right | 0.8600 | 0.2800 | 前回から変更なし |
desk_lower_right | 0.8800 | 0.9000 | 前回から変更なし |
blue_top | 0.7359 | 0.5552 | 距離変換で取り直し |
blue_front_left | 0.7039 | 0.6518 | 距離変換で取り直し |
red_top | 0.5585 | 0.3003 | 距離変換で取り直し |
red_front_left | 0.5248 | 0.3819 | 距離変換で取り直し |

枠が面の境界をまたいでいれば、枠の中のばらつきが跳ね上がる。4枚すべてで確認した。数字は各領域の標準偏差のうち、赤・緑・青で最も大きいもの。
領域 | 7911 | 7912 | 7913 | 7914 |
|---|---|---|---|---|
paper | 1.33 | 0.85 | 2.18 | 1.96 |
desk_top_center | 1.38 | 1.30 | 2.05 | 1.92 |
desk_upper_right | 1.96 | 1.12 | 3.76 | 3.05 |
desk_lower_right | 2.05 | 2.05 | 2.60 | 3.14 |
blue_top | 1.07 | 1.29 | 1.31 | 1.63 |
blue_front_left | 1.42 | 1.89 | 1.88 | 1.81 |
red_top | 0.95 | 1.87 | 1.34 | 1.52 |
red_front_left | 1.12 | 1.53 | 2.06 | 1.82 |
最大でも3.76。前回、机で1.3から1.7だったのと同じ桁で、境界をまたいだときのような跳ね方はしていない。8箇所とも、4枚すべてで面の内側に乗っている。
領域定義はこの JSON にまとめて、撮影画像と同じフォルダに置いた。座標をコードに書かずにファイルへ出しておくと、測り直すときに何を変えたかが差分で残る。
json
{
"log": "Log03",
"patch_size_px": 100,
"coordinate_system": "normalized (x/width, y/height), patch is centered on the point",
"applies_to": [
"IMG_7911.JPG",
"IMG_7912.JPG",
"IMG_7913.JPG",
"IMG_7914.JPG"
],
"framing_note": "IMG_7911-7914 share identical lens/exposure; red-block bbox centers agree within 2px, so one region set covers all four",
"regions": {
"paper": { "nx": 0.15, "ny": 0.55 },
"desk_top_center": { "nx": 0.62, "ny": 0.12 },
"desk_upper_right": { "nx": 0.86, "ny": 0.28 },
"desk_lower_right": { "nx": 0.88, "ny": 0.9 },
"blue_top": { "nx": 0.7359, "ny": 0.5552 },
"blue_front_left": { "nx": 0.7039, "ny": 0.6518 },
"red_top": { "nx": 0.5585, "ny": 0.3003 },
"red_front_left": { "nx": 0.5248, "ny": 0.3819 }
},
"source": {
"region_names": "same eight regions as Log#2",
"block_face_points": "derived from IMG_7912 by distance transform on the colour mask (most interior point of each face); inscribed radius 119-153px, so the 100x100 patch fits inside every face",
"background_points": "normalized coordinates carried over unchanged from Log#2"
}
}領域の色相を単純平均すると、赤で必ず壊れる
測定スクリプトを書く前に決めたことが4つある。どれも、決めずに書くと後から数字が信用できなくなる。
色相は角度なので、単純に平均してはいけない。 0度と359度の平均を普通に取ると180度になる。180度は真逆の色で、元の2つのどちらでもない。赤は色相が0の近くにあるので、枠の中の画素が359度側と1度側に散らばる。赤い面で単純平均を使うと必ず壊れる。
そこで円平均を使う。各画素の色相を単位ベクトルに直し、ベクトルとして平均してから角度に戻す。ばらつきも円統計で出す。平均ベクトルの長さを R として、sqrt(-2*log(R)) を度に直したものを円標準偏差にする。
彩度が低い面の色相は当てにならない。 白い紙のように鮮やかさがほとんど無い面では、色相の値は出るが意味を持たない。灰色に近い面では、わずかな数字の揺れが色相の大きな振れに化ける。判定できるように、枠の中で彩度が25/255 未満だった画素の割合を列に持たせた。
色空間のスケールを揃える。 OpenCV は色相を0〜179、彩度と明度を0〜255で持つ。Pillow の convert("HSV") は3つとも0〜255。同じ画素でも別の数字になる。出力する列では色相を度(0〜360)、彩度を0〜1、明度を0〜255に統一し、統一前の生の値も併記する。前回「読むソフトで値が変わる」を扱った流れがそのまま続く。
カラープロファイルの変換はしない。 撮影画像は Display P3 という色の入れ物で保存されている。sRGB へ変換してから HSV にする道もあるが、変換すると仮定が1つ増える。この回では保存されている値をそのまま読み、プロファイル名だけ記録に残す。
測定スクリプトの全文
4つの実験を1本のスクリプトで走らせる。生成画像での検算、丸め誤差の測定、実写8領域の測定、セット内の差分。出力先は測定データ用の外付けSSDで、書き込む前にマウントを確認し、同名ファイルがあれば上書きせずに中断する。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""measure_hsv_log03.py
Log#3 HSV 測定. 4 実験を出す:
1. exp_hsv_generated.csv — 生成画像の既知 RGB を OpenCV / Pillow / 式で HSV 化.
2. exp_hsv_scale.csv — 生成画像の全画素を一律 1.0/0.8/0.6/0.4 倍したときの
各色 H/S のずれ (丸め誤差の下限).
3. region_hsv_captured.csv — 実写 4 枚 × 8 領域. H は円平均・円標準偏差.
4. region_hsv_delta.csv — セット内 2 枚の ΔH/ΔS/ΔV (ΔH は -180..+180).
色変換 (ICC) は行わない. Display P3 のまま扱う. ImageCms は
プロファイル名の読み取りにのみ使う.
画像の読み込みは Pillow. HSV 変換のうち OpenCV 経路は np.asarray した
RGB 配列を cv2.cvtColor(..., COLOR_RGB2HSV) に渡す (cv2.imread は使わない).
Inputs:
/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7911.JPG ... 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/Log03/exp_hsv_generated.csv
/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log03/exp_hsv_scale.csv
/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log03/region_hsv_captured.csv
/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log03/region_hsv_delta.csv
/Volumes/KIOXIA/r2r2r-data/vision/measurements/Log03/run_meta_hsv.json
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
import math
import sys
from datetime import datetime
from pathlib import Path
import numpy as np
from PIL import Image, ImageCms
try:
import cv2
_OPENCV_VERSION = cv2.__version__
except Exception:
cv2 = None
_OPENCV_VERSION = None
# ---- 定数 --------------------------------------------------------------------
KIOXIA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
INPUT_CAP_DIR = KIOXIA_ROOT / "vision" / "captured" / "Log03"
INPUT_GEN_DIR = KIOXIA_ROOT / "vision" / "generated" / "Log02"
OUTPUT_DIR = KIOXIA_ROOT / "vision" / "measurements" / "Log03"
REGIONS_JSON = INPUT_CAP_DIR / "measure_regions.json"
GEN_PNG = INPUT_GEN_DIR / "base_image.png"
GEN_SPEC = INPUT_GEN_DIR / "base_image_spec.json"
CAPTURED_IMAGES = ["IMG_7911.JPG", "IMG_7912.JPG", "IMG_7913.JPG", "IMG_7914.JPG"]
# セット/条件マッピング (仕様: 3=調光のみ・4=調色のみ).
# 記事側の capture_conditions.md 更新に合わせてラベルを変える場合は
# ここだけ書き換える. CSV の condition 列に出す.
SET_CONDITION_BY_IMAGE = {
"IMG_7911.JPG": (3, "dim_darker"),
"IMG_7912.JPG": (3, "dim_brighter"),
"IMG_7913.JPG": (4, "color_cooler"),
"IMG_7914.JPG": (4, "color_warmer"),
}
# 差分の from → to ペア.
DELTA_PAIRS = [
(3, "IMG_7911.JPG", "IMG_7912.JPG"),
(4, "IMG_7913.JPG", "IMG_7914.JPG"),
]
# 実験 2 の倍率.
SCALES = [1.0, 0.83, 0.61, 0.37]
# 彩度が低い画素の閾値 (S を 0..255 スケールで 25 未満 = 0..1 で 25/255 未満).
LOW_SAT_THRESH_255 = 25
# 出力ファイル名.
OUT_EXP1 = "exp_hsv_generated.csv"
OUT_EXP2 = "exp_hsv_scale.csv"
OUT_EXP3 = "region_hsv_captured.csv"
OUT_EXP4 = "region_hsv_delta.csv"
OUT_META = "run_meta_hsv.json"
# CSV 列.
EXP1_COLS = [
"color_name", "r", "g", "b",
"h_raw_cv", "s_raw_cv", "v_raw_cv",
"h_raw_pil", "s_raw_pil", "v_raw_pil",
"h_deg_cv", "s_norm_cv", "v_255_cv",
"h_deg_pil", "s_norm_pil", "v_255_pil",
"h_deg_formula", "s_norm_formula", "v_255_formula",
"dh_cv_formula", "dh_pil_formula",
]
EXP2_COLS = [
"color_name", "scale", "r", "g", "b",
"h_deg", "s_norm", "v_255",
"dh_from_scale1_deg", "ds_from_scale1",
"h_deg_formula", "s_norm_formula",
"dh_formula_from_scale1_deg", "ds_formula_from_scale1",
]
EXP3_COLS = [
"image", "set_no", "condition", "region",
"nx", "ny", "patch_px",
"mean_r", "mean_g", "mean_b",
"std_r", "std_g", "std_b",
"h_deg_circmean", "h_deg_circstd",
"s_mean", "s_std", "v_mean", "v_std",
"h_deg_from_meanrgb", "s_from_meanrgb", "v_from_meanrgb",
"low_sat_ratio", "n_pixels",
]
EXP4_COLS = [
"set_no", "condition_from", "condition_to", "region",
"h_deg_from", "h_deg_to", "dh_deg",
"s_from", "s_to", "ds",
"v_from", "v_to", "dv", "dv_ratio",
"low_sat_ratio_from", "low_sat_ratio_to",
]
# ---- 共通ヘルパ ---------------------------------------------------------------
def _abort(msg: str, code: int = 1) -> None:
print(msg, file=sys.stderr)
sys.exit(code)
def ensure_ssd_mounted() -> None:
"""KIOXIA 未マウントなら ABORT (ローカル fallback しない)."""
if not KIOXIA_ROOT.exists():
_abort(f"[ABORT] KIOXIA 未マウント: {KIOXIA_ROOT} が存在しない。"
"ローカル fallback しない。")
def _md5(path: Path) -> str:
h = hashlib.md5()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _icc_profile_name(im: Image.Image) -> str:
"""ICC プロファイル記述子を取得. UTF-16 対策で ImageCms を使う."""
icc = im.info.get("icc_profile")
if not icc:
return ""
try:
prof = ImageCms.ImageCmsProfile(io.BytesIO(icc))
return ImageCms.getProfileDescription(prof).strip()
except Exception:
return ""
# ---- HSV 変換 ----------------------------------------------------------------
def _rgb_to_hsv_formula(r: float, g: float, b: float) -> tuple:
"""r,g,b in [0,255] (float 可). 返り値 (h_deg [0,360), s_norm [0,1], v_255 [0,255]).
HSV の定義式 (Foley / ITU 相当・OpenCV/Pillow が使っているものと同じ) に従う."""
rn = float(r) / 255.0
gn = float(g) / 255.0
bn = float(b) / 255.0
cmax = max(rn, gn, bn)
cmin = min(rn, gn, bn)
delta = cmax - cmin
if delta == 0.0:
h = 0.0
elif cmax == rn:
h = 60.0 * (((gn - bn) / delta) % 6.0)
elif cmax == gn:
h = 60.0 * (((bn - rn) / delta) + 2.0)
else: # cmax == bn
h = 60.0 * (((rn - gn) / delta) + 4.0)
# h を [0, 360) に折り返す (% 6.0 の負値対策).
h = h % 360.0
s = 0.0 if cmax == 0.0 else (delta / cmax)
v255 = cmax * 255.0
return h, s, v255
def _cv_hsv_single(r: int, g: int, b: int) -> tuple:
"""1 画素を cv2.cvtColor で HSV 化. 生の値 (H:0-179, S:0-255, V:0-255) を返す."""
px = np.array([[[int(r), int(g), int(b)]]], dtype=np.uint8)
hsv = cv2.cvtColor(px, cv2.COLOR_RGB2HSV)
return int(hsv[0, 0, 0]), int(hsv[0, 0, 1]), int(hsv[0, 0, 2])
def _pil_hsv_single(r: int, g: int, b: int) -> tuple:
"""1 画素を Pillow convert("HSV") で HSV 化. 生の値 (all 0-255) を返す."""
im = Image.new("RGB", (1, 1), (int(r), int(g), int(b)))
h, s, v = im.convert("HSV").getpixel((0, 0))
return int(h), int(s), int(v)
def _wrap_delta_deg(d: float) -> float:
"""d を [-180, +180] に折り返す (350° → 10° の差は +20°・-340° ではない)."""
return ((float(d) + 180.0) % 360.0) - 180.0
def _circmean_circstd_deg(h_deg_arr: np.ndarray) -> tuple:
"""h_deg_arr: 1D array in [0, 360). 円平均・円標準偏差 (どちらも度)."""
theta = np.radians(h_deg_arr.astype(np.float64))
c = float(np.mean(np.cos(theta)))
s = float(np.mean(np.sin(theta)))
mean_deg = (math.degrees(math.atan2(s, c))) % 360.0
R = math.sqrt(c * c + s * s)
if R <= 0.0:
# 全画素が完全に対称位置に散っている極端ケース.
return mean_deg, float("nan")
R = min(R, 1.0) # 数値誤差で > 1 になる保険.
# 分散が実質 0 のときは log(1) = 0 で std = 0. R=1 でも problem なし.
if R >= 1.0:
return mean_deg, 0.0
std_rad = math.sqrt(-2.0 * math.log(R))
return mean_deg, math.degrees(std_rad)
# ---- 実験 1 ------------------------------------------------------------------
def run_exp1(spec: dict) -> list:
"""spec["patches"] の各色を 3 経路で HSV 化. 行のリストを返す."""
rows = []
for patch in spec.get("patches", []):
name = patch["name"]
r, g, b = [int(v) for v in patch["rgb"]]
h_cv_raw, s_cv_raw, v_cv_raw = _cv_hsv_single(r, g, b)
h_pil_raw, s_pil_raw, v_pil_raw = _pil_hsv_single(r, g, b)
h_fm, s_fm, v_fm = _rgb_to_hsv_formula(r, g, b)
h_deg_cv = h_cv_raw * 2.0 # 0-179 → 0-358 (2° 刻み).
s_norm_cv = s_cv_raw / 255.0
v_255_cv = float(v_cv_raw)
h_deg_pil = h_pil_raw / 255.0 * 360.0
s_norm_pil = s_pil_raw / 255.0
v_255_pil = float(v_pil_raw)
dh_cv_fm = _wrap_delta_deg(h_deg_cv - h_fm)
dh_pil_fm = _wrap_delta_deg(h_deg_pil - h_fm)
rows.append({
"color_name": name,
"r": r, "g": g, "b": b,
"h_raw_cv": h_cv_raw, "s_raw_cv": s_cv_raw, "v_raw_cv": v_cv_raw,
"h_raw_pil": h_pil_raw, "s_raw_pil": s_pil_raw, "v_raw_pil": v_pil_raw,
"h_deg_cv": f"{h_deg_cv:.3f}",
"s_norm_cv": f"{s_norm_cv:.4f}",
"v_255_cv": f"{v_255_cv:.3f}",
"h_deg_pil": f"{h_deg_pil:.3f}",
"s_norm_pil": f"{s_norm_pil:.4f}",
"v_255_pil": f"{v_255_pil:.3f}",
"h_deg_formula": f"{h_fm:.3f}",
"s_norm_formula": f"{s_fm:.4f}",
"v_255_formula": f"{v_fm:.3f}",
"dh_cv_formula": f"{dh_cv_fm:.3f}",
"dh_pil_formula": f"{dh_pil_fm:.3f}",
})
return rows
# ---- 実験 2 ------------------------------------------------------------------
def run_exp2(base_gen: np.ndarray, spec: dict) -> list:
"""生成画像全画素を一律スケールした上で各色を測る. scale=1.0 との差も入れる.
OpenCV 経路 (H 整数 0-179 = 2° 刻み) と 式経路 (float) の両方を出す."""
# まず scale=1.0 で各色の基準値を出す (base_gen 側の中心画素).
baseline_cv: dict = {} # color_name -> (h_deg, s_norm, v_255)
baseline_fm: dict = {} # color_name -> (h_deg, s_norm, v_255)
scaled_by = {}
for scale in SCALES:
scaled = np.clip(base_gen.astype(np.float32) * float(scale), 0, 255).astype(np.uint8)
scaled_by[scale] = scaled
rows = []
for patch in spec.get("patches", []):
name = patch["name"]
x0 = int(patch["x0"]); y0 = int(patch["y0"])
x1 = int(patch["x1"]); y1 = int(patch["y1"])
cx = (x0 + x1) // 2
cy = (y0 + y1) // 2
for scale in SCALES:
r, g, b = [int(v) for v in scaled_by[scale][cy, cx]]
h_cv_raw, s_cv_raw, v_cv_raw = _cv_hsv_single(r, g, b)
h_deg = h_cv_raw * 2.0
s_norm = s_cv_raw / 255.0
v_255 = float(v_cv_raw)
h_fm, s_fm, _v_fm = _rgb_to_hsv_formula(r, g, b)
if scale == 1.0:
baseline_cv[name] = (h_deg, s_norm, v_255)
baseline_fm[name] = (h_fm, s_fm, _v_fm)
dh_str = "0.000"
ds_str = "0.0000"
dh_fm_str = "0.000"
ds_fm_str = "0.0000"
else:
b_h, b_s, _ = baseline_cv[name]
dh = _wrap_delta_deg(h_deg - b_h)
ds = s_norm - b_s
dh_str = f"{dh:.3f}"
ds_str = f"{ds:.4f}"
b_h_fm, b_s_fm, _ = baseline_fm[name]
dh_fm = _wrap_delta_deg(h_fm - b_h_fm)
ds_fm = s_fm - b_s_fm
dh_fm_str = f"{dh_fm:.3f}"
ds_fm_str = f"{ds_fm:.4f}"
rows.append({
"color_name": name,
"scale": f"{scale:.2f}",
"r": r, "g": g, "b": b,
"h_deg": f"{h_deg:.3f}",
"s_norm": f"{s_norm:.4f}",
"v_255": f"{v_255:.3f}",
"dh_from_scale1_deg": dh_str,
"ds_from_scale1": ds_str,
"h_deg_formula": f"{h_fm:.3f}",
"s_norm_formula": f"{s_fm:.4f}",
"dh_formula_from_scale1_deg": dh_fm_str,
"ds_formula_from_scale1": ds_fm_str,
})
return rows
# ---- 実験 3 / 4 --------------------------------------------------------------
def _crop_patch(arr: np.ndarray, x_frac: float, y_frac: float,
patch: int, region: str, image_name: str) -> tuple:
"""中心 (int(x*W), int(y*H)) ± patch/2 の RGB パッチを返す.
はみ出しは中断 (静かに縮小しない). 戻り値 (patch_arr, x0, y0)."""
h, w = arr.shape[:2]
cx = int(x_frac * w)
cy = int(y_frac * h)
half = patch // 2
x0 = cx - half
y0 = cy - half
x1 = x0 + patch
y1 = y0 + patch
if x0 < 0 or y0 < 0 or x1 > w or y1 > h:
_abort(
f"[ABORT] patch が画像範囲外: image={image_name} region={region} "
f"center=({cx},{cy}) patch={patch} image_size=({w},{h}) "
f"x=[{x0},{x1}) y=[{y0},{y1})")
return arr[y0:y1, x0:x1, :], x0, y0
def _measure_region_hsv(patch_rgb: np.ndarray) -> dict:
"""1 領域の HSV 統計. patch_rgb: (H,W,3) uint8 RGB. 統計は dict で返す."""
# ピクセル単位 HSV (cv2, RGB2HSV): H 0-179, S/V 0-255.
hsv = cv2.cvtColor(patch_rgb, cv2.COLOR_RGB2HSV)
h_raw = hsv[..., 0].astype(np.float64)
s_raw = hsv[..., 1].astype(np.float64)
v_raw = hsv[..., 2].astype(np.float64)
h_deg_flat = (h_raw * 2.0).flatten()
h_circmean, h_circstd = _circmean_circstd_deg(h_deg_flat)
s_norm = s_raw / 255.0
v_255 = v_raw
# 平均 RGB → HSV (formula・小数のまま).
mean_r = float(patch_rgb[..., 0].mean())
mean_g = float(patch_rgb[..., 1].mean())
mean_b = float(patch_rgb[..., 2].mean())
std_r = float(patch_rgb[..., 0].std())
std_g = float(patch_rgb[..., 1].std())
std_b = float(patch_rgb[..., 2].std())
h_mr, s_mr, v_mr = _rgb_to_hsv_formula(mean_r, mean_g, mean_b)
low_sat = float((s_raw < LOW_SAT_THRESH_255).sum()) / float(s_raw.size)
n_pix = int(patch_rgb.shape[0] * patch_rgb.shape[1])
return {
"mean_r": mean_r, "mean_g": mean_g, "mean_b": mean_b,
"std_r": std_r, "std_g": std_g, "std_b": std_b,
"h_deg_circmean": h_circmean, "h_deg_circstd": h_circstd,
"s_mean": float(s_norm.mean()), "s_std": float(s_norm.std()),
"v_mean": float(v_255.mean()), "v_std": float(v_255.std()),
"h_deg_from_meanrgb": h_mr,
"s_from_meanrgb": s_mr,
"v_from_meanrgb": v_mr,
"low_sat_ratio": low_sat,
"n_pixels": n_pix,
}
def run_exp3_and_meta(regions_doc: dict) -> tuple:
"""実写 4 枚 × 8 領域の HSV 統計と、meta 用 (ICC 名 / EXIF は取らない).
領域は 4 枚共通の 1 組 (regions_doc["regions"][region_name] = {nx, ny}).
戻り値 (rows, per_image_info)."""
patch_px = int(regions_doc.get("patch_size_px", 100))
region_map = regions_doc.get("regions", {})
if not region_map:
_abort("[ABORT] regions JSON の 'regions' が空")
for region_name, s in region_map.items():
if not (isinstance(s, dict) and "nx" in s and "ny" in s):
_abort(f"[ABORT] regions['{region_name}'] に nx/ny が無い: {s}")
rows = []
per_image = {} # image_name -> {region -> stats dict}
per_image_icc = {}
for name in CAPTURED_IMAGES:
img_path = INPUT_CAP_DIR / name
if not img_path.is_file():
_abort(f"[ABORT] 画像が無い: {img_path}")
with Image.open(img_path) as im_raw:
im = im_raw.convert("RGB")
arr = np.asarray(im)
per_image_icc[name] = _icc_profile_name(im_raw)
set_no, condition = SET_CONDITION_BY_IMAGE[name]
per_image[name] = {}
for region_name, spec in region_map.items():
x_frac = float(spec["nx"])
y_frac = float(spec["ny"])
patch_arr, _x0, _y0 = _crop_patch(
arr, x_frac, y_frac, patch_px, region_name, name)
stats = _measure_region_hsv(patch_arr)
per_image[name][region_name] = stats
def _fmt_nan(v, nd):
return "" if (isinstance(v, float) and math.isnan(v)) else f"{v:.{nd}f}"
rows.append({
"image": name,
"set_no": set_no,
"condition": condition,
"region": region_name,
"nx": f"{x_frac:.4f}",
"ny": f"{y_frac:.4f}",
"patch_px": patch_px,
"mean_r": f"{stats['mean_r']:.3f}",
"mean_g": f"{stats['mean_g']:.3f}",
"mean_b": f"{stats['mean_b']:.3f}",
"std_r": f"{stats['std_r']:.3f}",
"std_g": f"{stats['std_g']:.3f}",
"std_b": f"{stats['std_b']:.3f}",
"h_deg_circmean": f"{stats['h_deg_circmean']:.3f}",
"h_deg_circstd": _fmt_nan(stats["h_deg_circstd"], 3),
"s_mean": f"{stats['s_mean']:.4f}",
"s_std": f"{stats['s_std']:.4f}",
"v_mean": f"{stats['v_mean']:.3f}",
"v_std": f"{stats['v_std']:.3f}",
"h_deg_from_meanrgb": f"{stats['h_deg_from_meanrgb']:.3f}",
"s_from_meanrgb": f"{stats['s_from_meanrgb']:.4f}",
"v_from_meanrgb": f"{stats['v_from_meanrgb']:.3f}",
"low_sat_ratio": f"{stats['low_sat_ratio']:.4f}",
"n_pixels": stats["n_pixels"],
})
return rows, per_image, per_image_icc
def run_exp4(per_image: dict, regions_doc: dict) -> list:
"""セット内 2 枚の差分. per_image[img_name][region_name] = stats."""
rows = []
for set_no, img_from, img_to in DELTA_PAIRS:
_, cond_from = SET_CONDITION_BY_IMAGE[img_from]
_, cond_to = SET_CONDITION_BY_IMAGE[img_to]
# region 順序は JSON 順 (4 枚共通の 1 組) を採用.
regions_order = list(regions_doc["regions"].keys())
for region_name in regions_order:
a = per_image[img_from][region_name]
b = per_image[img_to][region_name]
h_from = a["h_deg_circmean"]
h_to = b["h_deg_circmean"]
dh = _wrap_delta_deg(h_to - h_from)
s_from = a["s_mean"]; s_to = b["s_mean"]
v_from = a["v_mean"]; v_to = b["v_mean"]
dv = v_to - v_from
dv_ratio = (dv / v_from) if v_from != 0 else float("nan")
def _fmt_nan(v, nd):
return "" if (isinstance(v, float) and math.isnan(v)) else f"{v:.{nd}f}"
rows.append({
"set_no": set_no,
"condition_from": cond_from,
"condition_to": cond_to,
"region": region_name,
"h_deg_from": f"{h_from:.3f}",
"h_deg_to": f"{h_to:.3f}",
"dh_deg": f"{dh:.3f}",
"s_from": f"{s_from:.4f}",
"s_to": f"{s_to:.4f}",
"ds": f"{(s_to - s_from):.4f}",
"v_from": f"{v_from:.3f}",
"v_to": f"{v_to:.3f}",
"dv": f"{dv:.3f}",
"dv_ratio": _fmt_nan(dv_ratio, 4),
"low_sat_ratio_from": f"{a['low_sat_ratio']:.4f}",
"low_sat_ratio_to": f"{b['low_sat_ratio']:.4f}",
})
return rows
# ---- I/O ---------------------------------------------------------------------
def _write_csv(path: Path, cols: list, rows: list) -> None:
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 main() -> None:
ensure_ssd_mounted()
if cv2 is None:
_abort("[ABORT] cv2 (opencv-python) が import できない。HSV 変換に必要。")
if not INPUT_CAP_DIR.is_dir():
_abort(f"[ABORT] 入力ディレクトリなし: {INPUT_CAP_DIR}")
if not REGIONS_JSON.is_file():
_abort(f"[ABORT] regions JSON なし: {REGIONS_JSON}")
if not GEN_PNG.is_file():
_abort(f"[ABORT] 生成画像なし: {GEN_PNG}")
if not GEN_SPEC.is_file():
_abort(f"[ABORT] spec なし: {GEN_SPEC}")
# 出力ファイル存在確認 (上書き禁止).
out_paths = {
"exp1": OUTPUT_DIR / OUT_EXP1,
"exp2": OUTPUT_DIR / OUT_EXP2,
"exp3": OUTPUT_DIR / OUT_EXP3,
"exp4": OUTPUT_DIR / OUT_EXP4,
"meta": OUTPUT_DIR / OUT_META,
}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for p in out_paths.values():
if p.exists():
_abort(f"[ABORT] 既存ファイルあり (上書き禁止): {p}")
# 入力読み込み.
spec = json.loads(GEN_SPEC.read_text(encoding="utf-8"))
regions_doc = json.loads(REGIONS_JSON.read_text(encoding="utf-8"))
with Image.open(GEN_PNG) as im_gen_raw:
gen_arr = np.asarray(im_gen_raw.convert("RGB"))
gen_icc = _icc_profile_name(im_gen_raw)
gen_pixel_sha256 = hashlib.sha256(gen_arr.tobytes()).hexdigest()
# 実験.
exp1_rows = run_exp1(spec)
exp2_rows = run_exp2(gen_arr, spec)
exp3_rows, per_image, per_image_icc = run_exp3_and_meta(regions_doc)
exp4_rows = run_exp4(per_image, regions_doc)
# 書き出し.
_write_csv(out_paths["exp1"], EXP1_COLS, exp1_rows)
_write_csv(out_paths["exp2"], EXP2_COLS, exp2_rows)
_write_csv(out_paths["exp3"], EXP3_COLS, exp3_rows)
_write_csv(out_paths["exp4"], EXP4_COLS, exp4_rows)
# meta.
input_files = []
for name in CAPTURED_IMAGES:
p = INPUT_CAP_DIR / name
input_files.append({
"path": str(p),
"md5": _md5(p),
"size_bytes": p.stat().st_size,
"icc_profile_name": per_image_icc.get(name, ""),
})
input_files.append({
"path": str(REGIONS_JSON),
"md5": _md5(REGIONS_JSON),
"size_bytes": REGIONS_JSON.stat().st_size,
})
input_files.append({
"path": str(GEN_PNG),
"pixel_sha256": gen_pixel_sha256,
"spec_pixel_sha256": spec.get("pixel_sha256", ""),
"pixel_sha256_match": (gen_pixel_sha256 == spec.get("pixel_sha256", "")),
"size_bytes": GEN_PNG.stat().st_size,
"icc_profile_name": gen_icc,
})
input_files.append({
"path": str(GEN_SPEC),
"md5": _md5(GEN_SPEC),
"size_bytes": GEN_SPEC.stat().st_size,
})
meta = {
"script_name": Path(__file__).name,
"script_path": str(Path(__file__).resolve()),
"script_md5": _md5(Path(__file__).resolve()),
"executed_at": datetime.now().astimezone().isoformat(),
"python_version": sys.version,
"library_versions": {
"numpy": np.__version__,
"Pillow": Image.__version__,
"opencv": _OPENCV_VERSION,
},
"input_files": input_files,
"output_files": [OUT_EXP1, OUT_EXP2, OUT_EXP3, OUT_EXP4, OUT_META],
"notes": {
"hsv_scales_normalized": "H=deg[0,360), S=[0,1], V=[0,255]",
"low_sat_threshold_255": LOW_SAT_THRESH_255,
"color_conversion": "none (Display P3 のまま)",
},
}
with out_paths["meta"].open("w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
# 標準出力 (合否コメントなし・行数のみ).
print(f"exp1 rows={len(exp1_rows)}")
print(f"exp2 rows={len(exp2_rows)}")
print(f"exp3 rows={len(exp3_rows)}")
print(f"exp4 rows={len(exp4_rows)}")
if __name__ == "__main__":
main()生成画像の検算で、OpenCVとPillowの色相が0.3度ずれた
まず変換そのものが正しく動いているかを見る。既知の赤・緑・青などを、OpenCV・Pillow・式の3通りで HSV に直し、値を突き合わせた。
color_name,r,g,b,h_raw_cv,s_raw_cv,v_raw_cv,h_raw_pil,s_raw_pil,v_raw_pil,h_deg_cv,s_norm_cv,v_255_cv,h_deg_pil,s_norm_pil,v_255_pil,h_deg_formula,s_norm_formula,v_255_formula,dh_cv_formula,dh_pil_formula
red,255,0,0,0,255,255,0,255,255,0.000,1.0000,255.000,0.000,1.0000,255.000,0.000,1.0000,255.000,0.000,0.000
green,0,255,0,60,255,255,85,255,255,120.000,1.0000,255.000,120.000,1.0000,255.000,120.000,1.0000,255.000,0.000,0.000
blue,0,0,255,120,255,255,170,255,255,240.000,1.0000,255.000,240.000,1.0000,255.000,240.000,1.0000,255.000,0.000,0.000
white,255,255,255,0,0,255,0,0,255,0.000,0.0000,255.000,0.000,0.0000,255.000,0.000,0.0000,255.000,0.000,0.000
black,0,0,0,0,0,0,0,0,0,0.000,0.0000,0.000,0.000,0.0000,0.000,0.000,0.0000,0.000,0.000,0.000
grey128,128,128,128,0,0,128,0,0,128,0.000,0.0000,128.000,0.000,0.0000,128.000,0.000,0.0000,128.000,0.000,0.000
brown,200,120,60,13,179,200,18,178,200,26.000,0.7020,200.000,25.412,0.6980,200.000,25.714,0.7000,200.000,0.286,-0.303赤・緑・青・白・黒・灰は3経路とも完全に一致した。ずれたのは brown だけになる。
経路 | 色相(度) | 式との差 |
|---|---|---|
式 | 25.714 | 0 |
OpenCV | 26.000 | +0.286 |
Pillow | 25.412 | −0.303 |
同じ画素(R=200 G=120 B=60)を同じ色空間に直しているのに、通したライブラリで0.6度の幅がある。生の値を見ると理由が分かる。OpenCV は色相を整数13として持ち、度に直すと26.000。Pillow は整数18として持ち、度に直すと25.412。どちらも整数で保存しているが、目盛りの細かさが違う。
なぜ brown だけがずれたのかも、値を見れば分かる。赤・緑・青は3つの数字のうち2つが0で、白・黒・灰は3つが同じ値。どちらも色相が目盛りのちょうど上に乗る。brown だけが3つとも違う値で、目盛りの間に落ちた。
OpenCVの色相は2度刻みしかないので、丸め誤差を測れない
次に、生成画像の全画素を一律に定数倍して、色相がどれだけずれるかを測る。理屈上は動かないので、動いた分がそのまま丸め誤差になる。
最初は0.8倍・0.6倍・0.4倍で測った。色相のずれが全行で0.000になった。倍率と対象の色の組み合わせが悪く、掛け算の結果がすべて整数に着地していた。255×0.8は204、200×0.6は120。小数が出ないので、丸める場面がそもそも無い。
倍率を半端な値に変えた。0.83倍・0.61倍・0.37倍。それでも OpenCV 経路のずれは全行0.000のままだった。
原因は倍率ではなかった。OpenCV は色相を0〜179の整数で持つ。度に直すと2倍なので、表せる色相は0度・2度・4度と偶数だけになる。2度より小さい変化は表現できず、必ず0として出る。 丸め誤差を測るつもりで、ライブラリの目盛りの粗さを測っていた。
証拠は出力に揃っている。この表の色相が全部偶数になっている。式の経路を列に足して測り直した。
color_name,scale,r,g,b,h_deg,s_norm,v_255,dh_from_scale1_deg,ds_from_scale1,h_deg_formula,s_norm_formula,dh_formula_from_scale1_deg,ds_formula_from_scale1
red,1.00,255,0,0,0.000,1.0000,255.000,0.000,0.0000,0.000,1.0000,0.000,0.0000
red,0.83,211,0,0,0.000,1.0000,211.000,0.000,0.0000,0.000,1.0000,0.000,0.0000
red,0.61,155,0,0,0.000,1.0000,155.000,0.000,0.0000,0.000,1.0000,0.000,0.0000
red,0.37,94,0,0,0.000,1.0000,94.000,0.000,0.0000,0.000,1.0000,0.000,0.0000
green,1.00,0,255,0,120.000,1.0000,255.000,0.000,0.0000,120.000,1.0000,0.000,0.0000
green,0.83,0,211,0,120.000,1.0000,211.000,0.000,0.0000,120.000,1.0000,0.000,0.0000
green,0.61,0,155,0,120.000,1.0000,155.000,0.000,0.0000,120.000,1.0000,0.000,0.0000
green,0.37,0,94,0,120.000,1.0000,94.000,0.000,0.0000,120.000,1.0000,0.000,0.0000
blue,1.00,0,0,255,240.000,1.0000,255.000,0.000,0.0000,240.000,1.0000,0.000,0.0000
blue,0.83,0,0,211,240.000,1.0000,211.000,0.000,0.0000,240.000,1.0000,0.000,0.0000
blue,0.61,0,0,155,240.000,1.0000,155.000,0.000,0.0000,240.000,1.0000,0.000,0.0000
blue,0.37,0,0,94,240.000,1.0000,94.000,0.000,0.0000,240.000,1.0000,0.000,0.0000
white,1.00,255,255,255,0.000,0.0000,255.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
white,0.83,211,211,211,0.000,0.0000,211.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
white,0.61,155,155,155,0.000,0.0000,155.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
white,0.37,94,94,94,0.000,0.0000,94.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
black,1.00,0,0,0,0.000,0.0000,0.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
black,0.83,0,0,0,0.000,0.0000,0.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
black,0.61,0,0,0,0.000,0.0000,0.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
black,0.37,0,0,0,0.000,0.0000,0.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
grey128,1.00,128,128,128,0.000,0.0000,128.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
grey128,0.83,106,106,106,0.000,0.0000,106.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
grey128,0.61,78,78,78,0.000,0.0000,78.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
grey128,0.37,47,47,47,0.000,0.0000,47.000,0.000,0.0000,0.000,0.0000,0.000,0.0000
brown,1.00,200,120,60,26.000,0.7020,200.000,0.000,0.0000,25.714,0.7000,0.000,0.0000
brown,0.83,166,99,49,26.000,0.7059,166.000,0.000,0.0039,25.641,0.7048,-0.073,0.0048
brown,0.61,122,73,36,26.000,0.7059,122.000,0.000,0.0039,25.814,0.7049,0.100,0.0049
brown,0.37,74,44,22,26.000,0.7020,74.000,0.000,0.0000,25.385,0.7027,-0.330,0.0027式の経路(右端の4列)で誤差が出た。出たのは brown だけになる。
倍率 | RGB | 色相(度) | 1.0倍との差 |
|---|---|---|---|
1.00 | 200,120,60 | 25.714 | 0 |
0.83 | 166,99,49 | 25.641 | −0.073 |
0.61 | 122,73,36 | 25.814 | +0.100 |
0.37 | 74,44,22 | 25.385 | −0.330 |
赤・緑・青・白・黒・灰は全倍率で0.000のまま。3つの数字のうち2つが0か、3つが同値なので、掛けて丸めても比が変わらない。丸め誤差が出るのは brown のように3つとも違う色だけになる。
明るさだけを変えたときに色相が動く量の下限は0.33度。 暗くするほど誤差が大きくなる。残っている段数が減るので、丸めの影響が相対的に大きくなる。
この0.33度が、実写で「色相が動いた」と言えるかどうかの基準線になる。
明るさだけを変えると、色相は0.1〜3.5度しか動かなかった
実写8領域の測定に入る。まず、明るさだけを変えたセット(IMG_7911 → IMG_7912)。
面 | 色相の差(度) | 彩度の差 | 明度の差 | 彩度が低い画素の割合 |
|---|---|---|---|---|
blue_top | −2.514 | +0.114 | +23.4% | 0% → 0% |
blue_front_left | −0.126 | +0.056 | +22.1% | 0% → 0% |
red_top | +1.995 | +0.140 | +27.6% | 0% → 0% |
red_front_left | +3.512 | +0.220 | +26.2% | 0% → 0% |
paper | −11.271 | +0.064 | +12.3% | 100% → 0.6% |
desk_top_center | −15.507 | +0.069 | +13.5% | 100% → 0% |
desk_upper_right | −8.140 | +0.056 | +9.2% | 100% → 0% |
desk_lower_right | −23.063 | +0.059 | +10.3% | 100% → 0% |
色のある4面では、色相が0.126度から3.512度しか動かなかった。同じとき明度は22から28パーセント動いている。変化のほとんどが明度側に行き、色相にはあまり移っていない。 予想したとおりの分離が起きている。
ただし色相は完全には止まっていない。赤の手前左面の3.512度は、丸め誤差0.33度の11倍ある。枠の中のばらつき(円標準偏差)と比べても大きい。
面 | 色相の差 | 円標準偏差(暗) | 円標準偏差(明) |
|---|---|---|---|
blue_top | −2.514 | 0.936 | 0.501 |
blue_front_left | −0.126 | 0.405 | 0.283 |
red_top | +1.995 | 0.000 | 0.143 |
red_front_left | +3.512 | 0.860 | 0.063 |
blue_front_left を除いて、色相の差は枠の中のばらつきの2倍から25倍ある。測定の揺れでは説明できない。blue_front_left の0.126度だけは丸め誤差0.33度を下回るので、この面は動かなかったと言える。
正直に書いておくことがある。同じ照明条件で2枚撮った比較を持っていないので、シャッターを切り直したときの揺れがどれだけあるかは測れていない。円標準偏差は1枚の中のばらつきなので、誤差の下限にあたる。3.5度がカメラの撮り直しの揺れを超えているかは、この4枚からは言えない。
もうひとつ、分離しきれていないものがある。彩度は明るさから独立していない。 明るくすると赤の手前左面で+0.220、上面で+0.140上がった。HSV は明るさと色相を分けたが、彩度までは切り離せていない。
光の色だけを変えると、青いブロックの色相は155度動いた
次に、光の色だけを変えたセット(IMG_7913 → IMG_7914)。
面 | 色相の差(度) | 彩度の差 | 明度の差 |
|---|---|---|---|
red_top | +4.428 | +0.069 | +23.3% |
red_front_left | +6.257 | +0.133 | +21.1% |
blue_top | −26.000 | −0.489 | −43.9% |
blue_front_left | −155.025 | −0.226 | −29.2% |
paper | −7.509 | +0.403 | +8.4% |
desk_top_center | −2.875 | +0.393 | +8.2% |
desk_upper_right | −2.613 | +0.384 | +8.9% |
desk_lower_right | −1.462 | +0.413 | +8.6% |
同じ照明の変化で、赤は4度から6度しか動かず、青は26度動き、青の暗い面は155度動いた。
blue_front_left の色相は205.925度から50.900度になっている。青として測れていた面が、オレンジ寄りの色として読まれた。彩度も0.512から0.285に落ちている。
理由は数学ではなく物理にある。オレンジの光には青い成分がほとんど入っていない。だから青い物体は反射する光が減り、色として残らない。もともと暗かった面ほど残る光が少ないので、上面(26度)より手前左面(155度)のほうが大きく壊れた。
白い紙と机の彩度が0.15前後から0.55前後へ上がった。白い紙が白でなくなっている。
なお、この調色セットでも中性面の明度が8パーセント前後上がっている。明るさのつまみは触っていないので、LEDの混ぜ方が変わったぶん。基礎知識の節で「調色だけ変えたつもりが明るさも変わることがある」と書いた分が、8パーセントとして測れた。
白い紙と机の色相は、数字が出ても読んではいけない
明るさだけを変えたセットで、白い紙と机の色相が8度から23度振れている。色のある面より大きく動いているように見える。これは読んではいけない。
暗いほうの画像(IMG_7911)では、この4面の画素が100パーセントとも彩度25/255未満だった。彩度がほぼ無い面では、色相は数字としては出るが意味を持たない。赤・緑・青の差がわずかしかないところで、その差の向きを角度に直しているので、1か2の揺れが何十度にも化ける。
設計で入れた low_sat_ratio の列がこれを拾っている。ただし判定としては雑だった。明るいほうの画像(IMG_7912)では紙の割合が0.6パーセントまで落ちていて、一見すると読めるようになったように見える。実際は彩度が0.0997で、閾値の25/255 = 0.098をわずかに超えただけになる。閾値のすぐ上と下に落ちているので、100パーセントと0.6パーセントの差は実質的な差ではない。 どちらの紙も色相は読めない。
割合だけで判定せず、彩度の値そのものを併記して見るほうがいい。
JPEGが枠の中の色を一つの値に潰していた
測定表を眺めていて、数字の並びがおかしいことに気づいた。IMG_7911 の red_top を見る。
mean_r=121.558 mean_g=22.558 mean_b=17.558
std_r=0.948 std_g=0.948 std_b=0.9483つの平均の小数部が全部 .558 で、標準偏差が3つとも 0.948 で一致している。これは枠の1万画素すべてで、緑が赤より99小さく、青が赤より104小さいという関係が厳密に成り立っていることを意味する。明るさだけが画素ごとに違い、色の差は完全に一定になっている。
JPEG が色の情報を明るさより粗い解像度で保存しているため、平坦な面では枠の中の色が1つの値に潰れる。だから red_top の色相の円標準偏差が0.000になった。desk_upper_right(IMG_7911)でも赤と緑の標準偏差が1.787で一致していて、同じことが起きている。
色相のばらつきが小さいのは、色が安定しているからではなく、保存の段階で潰れているから。 前回の「保存で数字が変わる」が、こういう形で効いてくる。
同じ型の話がもう1箇所ある。IMG_7914 の blue_top。
mean_r=31.974 mean_g=45.230 mean_b=45.230
h_deg_circmean=180.000 h_deg_circstd=0.000緑と青が小数第3位まで完全に一致している。緑と青が同じ値のとき、色相は定義上ちょうど180度になる。この180.000は測れた色相ではなく、色が無くなった状態を指している。 青いブロックがオレンジの光の下で青い成分を失い、緑と青が同じ値まで落ちた結果になる。
blue_front_left(IMG_7914)は円標準偏差が8.094度で、他の面の10倍から30倍ばらついている。155度動いたという数字は、この不安定さと一緒に読む必要がある。
実写8領域の測定値の全量
4枚それぞれの8領域について、赤・緑・青の平均と標準偏差、色相の円平均と円標準偏差、彩度と明度の平均と標準偏差、平均RGBから直接変換した値、彩度が低い画素の割合を出している。
image,set_no,condition,region,nx,ny,patch_px,mean_r,mean_g,mean_b,std_r,std_g,std_b,h_deg_circmean,h_deg_circstd,s_mean,s_std,v_mean,v_std,h_deg_from_meanrgb,s_from_meanrgb,v_from_meanrgb,low_sat_ratio,n_pixels
IMG_7911.JPG,3,dim_darker,paper,0.1500,0.5500,100,177.432,176.768,171.597,1.262,1.325,1.240,53.322,7.463,0.0353,0.0005,177.599,1.239,53.172,0.0329,177.432,1.0000,10000
IMG_7911.JPG,3,dim_darker,desk_top_center,0.6200,0.1200,100,174.189,174.178,162.174,1.376,1.348,1.350,59.965,0.999,0.0690,0.0022,174.196,1.369,59.946,0.0690,174.189,1.0000,10000
IMG_7911.JPG,3,dim_darker,desk_upper_right,0.8600,0.2800,100,163.377,161.377,148.646,1.787,1.787,1.957,52.000,0.000,0.0902,0.0030,163.377,1.787,51.854,0.0902,163.377,1.0000,10000
IMG_7911.JPG,3,dim_darker,desk_lower_right,0.8800,0.9000,100,159.781,161.293,150.538,2.052,2.042,2.000,67.564,4.304,0.0665,0.0027,161.293,2.042,68.433,0.0667,161.293,1.0000,10000
IMG_7911.JPG,3,dim_darker,blue_top,0.7359,0.5552,100,14.065,71.423,117.697,1.021,1.071,0.947,206.649,0.936,0.8812,0.0080,117.697,0.947,206.791,0.8805,117.697,0.0000,10000
IMG_7911.JPG,3,dim_darker,blue_front_left,0.7039,0.6518,100,26.551,54.094,75.255,1.419,1.149,1.181,206.086,0.405,0.6476,0.0144,75.255,1.181,206.069,0.6472,75.255,0.0000,10000
IMG_7911.JPG,3,dim_darker,red_top,0.5585,0.3003,100,121.558,22.558,17.558,0.948,0.948,0.948,2.000,0.000,0.8549,0.0067,121.558,0.948,2.885,0.8556,121.558,0.0000,10000
IMG_7911.JPG,3,dim_darker,red_front_left,0.5248,0.3819,100,82.487,22.977,22.023,0.883,1.045,1.116,0.490,0.860,0.7328,0.0124,82.487,0.883,0.946,0.7330,82.487,0.0000,10000
IMG_7912.JPG,3,dim_brighter,paper,0.1500,0.5500,100,199.473,193.493,179.486,0.849,0.821,0.823,42.051,0.637,0.0997,0.0021,199.473,0.849,42.046,0.1002,199.473,0.0064,10000
IMG_7912.JPG,3,dim_brighter,desk_top_center,0.6200,0.1200,100,197.749,190.576,170.509,1.096,1.304,1.033,44.458,2.707,0.1379,0.0039,197.749,1.096,44.200,0.1378,197.749,0.0000,10000
IMG_7912.JPG,3,dim_brighter,desk_upper_right,0.8600,0.2800,100,178.458,171.385,152.375,1.081,1.117,1.119,43.860,0.599,0.1458,0.0020,178.458,1.081,43.730,0.1462,178.458,0.0000,10000
IMG_7912.JPG,3,dim_brighter,desk_lower_right,0.8800,0.9000,100,177.967,172.105,155.794,1.925,1.912,2.054,44.501,1.473,0.1252,0.0041,177.967,1.925,44.139,0.1246,177.967,0.0000,10000
IMG_7912.JPG,3,dim_brighter,blue_top,0.7359,0.5552,100,0.652,85.291,145.265,0.694,1.064,1.288,204.135,0.501,0.9953,0.0047,145.265,1.288,204.883,0.9955,145.265,0.0000,10000
IMG_7912.JPG,3,dim_brighter,blue_front_left,0.7039,0.6518,100,27.159,64.266,91.886,1.892,1.693,1.615,205.959,0.283,0.7039,0.0169,91.886,1.615,205.603,0.7044,91.886,0.0000,10000
IMG_7912.JPG,3,dim_brighter,red_top,0.5585,0.3003,100,155.114,10.583,0.770,1.870,1.062,0.825,3.995,0.143,0.9947,0.0053,155.114,1.870,3.815,0.9950,155.114,0.0000,10000
IMG_7912.JPG,3,dim_brighter,red_front_left,0.5248,0.3819,100,104.119,11.746,4.871,1.270,1.289,1.532,4.002,0.063,0.9532,0.0146,104.119,1.270,4.156,0.9532,104.119,0.0000,10000
IMG_7913.JPG,4,color_cooler,paper,0.1500,0.5500,100,157.234,148.130,133.356,2.133,2.176,2.122,37.509,1.687,0.1515,0.0039,157.234,2.133,37.125,0.1519,157.234,0.0000,10000
IMG_7913.JPG,4,color_cooler,desk_top_center,0.6200,0.1200,100,152.818,141.103,125.224,1.942,2.055,1.976,34.329,2.056,0.1806,0.0058,152.818,1.942,34.526,0.1806,152.818,0.0000,10000
IMG_7913.JPG,4,color_cooler,desk_upper_right,0.8600,0.2800,100,144.660,131.019,114.502,3.764,2.915,2.547,33.439,1.487,0.2082,0.0096,144.660,3.764,32.859,0.2085,144.660,0.0000,10000
IMG_7913.JPG,4,color_cooler,desk_lower_right,0.8800,0.9000,100,137.229,127.264,115.157,2.522,2.503,2.601,32.213,1.111,0.1611,0.0045,137.229,2.522,32.912,0.1608,137.229,0.0000,10000
IMG_7913.JPG,4,color_cooler,blue_top,0.7359,0.5552,100,17.529,52.580,80.555,1.306,1.311,1.299,206.000,0.000,0.7824,0.0126,80.555,1.299,206.631,0.7824,80.555,0.0000,10000
IMG_7913.JPG,4,color_cooler,blue_front_left,0.7039,0.6518,100,24.039,38.086,49.058,1.875,1.854,1.862,205.925,0.519,0.5115,0.0204,49.058,1.862,206.312,0.5100,49.058,0.0000,10000
IMG_7913.JPG,4,color_cooler,red_top,0.5585,0.3003,100,96.444,17.959,13.077,1.340,1.333,1.338,3.366,0.930,0.8644,0.0121,96.444,1.340,3.514,0.8644,96.444,0.0000,10000
IMG_7913.JPG,4,color_cooler,red_front_left,0.5248,0.3819,100,70.040,18.589,15.101,2.061,1.721,1.735,3.784,0.655,0.7851,0.0189,70.040,2.061,3.809,0.7844,70.040,0.0000,10000
IMG_7914.JPG,4,color_warmer,paper,0.1500,0.5500,100,170.416,124.056,76.039,1.845,1.918,1.956,30.000,0.000,0.5543,0.0076,170.416,1.845,30.526,0.5538,170.416,0.0000,10000
IMG_7914.JPG,4,color_warmer,desk_top_center,0.6200,0.1200,100,165.288,120.045,70.553,1.916,1.620,1.616,31.454,0.891,0.5731,0.0065,165.288,1.916,31.345,0.5732,165.288,0.0000,10000
IMG_7914.JPG,4,color_warmer,desk_upper_right,0.8600,0.2800,100,157.560,112.184,64.273,2.353,3.051,2.539,30.826,0.985,0.5917,0.0108,157.560,2.353,30.815,0.5921,157.560,0.0000,10000
IMG_7914.JPG,4,color_warmer,desk_lower_right,0.8800,0.9000,100,148.999,106.751,63.359,3.136,3.080,3.057,30.751,0.969,0.5742,0.0117,148.999,3.136,30.400,0.5748,148.999,0.0000,10000
IMG_7914.JPG,4,color_warmer,blue_top,0.7359,0.5552,100,31.974,45.230,45.230,1.628,1.191,1.191,180.000,0.000,0.2936,0.0231,45.230,1.191,180.000,0.2931,45.230,0.0000,10000
IMG_7914.JPG,4,color_warmer,blue_front_left,0.7039,0.6518,100,34.755,33.049,24.832,1.805,1.472,1.663,50.900,8.094,0.2853,0.0392,34.757,1.804,49.686,0.2855,34.755,0.0000,10000
IMG_7914.JPG,4,color_warmer,red_top,0.5585,0.3003,100,118.872,20.872,8.061,1.415,1.410,1.521,7.794,0.607,0.9329,0.0119,118.872,1.415,6.937,0.9322,118.872,0.0000,10000
IMG_7914.JPG,4,color_warmer,red_front_left,0.5248,0.3819,100,84.843,19.405,6.973,1.819,1.490,1.446,10.040,0.290,0.9179,0.0161,84.843,1.819,9.579,0.9178,84.843,0.0000,10000セット内2枚の差分がこれになる。色相の差は−180度から+180度に折り返してある。350度から10度への変化は+20度であって−340度ではない。
set_no,condition_from,condition_to,region,h_deg_from,h_deg_to,dh_deg,s_from,s_to,ds,v_from,v_to,dv,dv_ratio,low_sat_ratio_from,low_sat_ratio_to
3,dim_darker,dim_brighter,paper,53.322,42.051,-11.271,0.0353,0.0997,0.0644,177.599,199.473,21.874,0.1232,1.0000,0.0064
3,dim_darker,dim_brighter,desk_top_center,59.965,44.458,-15.507,0.0690,0.1379,0.0689,174.196,197.749,23.553,0.1352,1.0000,0.0000
3,dim_darker,dim_brighter,desk_upper_right,52.000,43.860,-8.140,0.0902,0.1458,0.0556,163.377,178.458,15.081,0.0923,1.0000,0.0000
3,dim_darker,dim_brighter,desk_lower_right,67.564,44.501,-23.063,0.0665,0.1252,0.0587,161.293,177.967,16.674,0.1034,1.0000,0.0000
3,dim_darker,dim_brighter,blue_top,206.649,204.135,-2.514,0.8812,0.9953,0.1140,117.697,145.265,27.568,0.2342,0.0000,0.0000
3,dim_darker,dim_brighter,blue_front_left,206.086,205.959,-0.126,0.6476,0.7039,0.0563,75.255,91.886,16.631,0.2210,0.0000,0.0000
3,dim_darker,dim_brighter,red_top,2.000,3.995,1.995,0.8549,0.9947,0.1398,121.558,155.114,33.555,0.2760,0.0000,0.0000
3,dim_darker,dim_brighter,red_front_left,0.490,4.002,3.512,0.7328,0.9532,0.2204,82.487,104.119,21.632,0.2622,0.0000,0.0000
4,color_cooler,color_warmer,paper,37.509,30.000,-7.509,0.1515,0.5543,0.4029,157.234,170.416,13.183,0.0838,0.0000,0.0000
4,color_cooler,color_warmer,desk_top_center,34.329,31.454,-2.875,0.1806,0.5731,0.3926,152.818,165.288,12.470,0.0816,0.0000,0.0000
4,color_cooler,color_warmer,desk_upper_right,33.439,30.826,-2.613,0.2082,0.5917,0.3835,144.660,157.560,12.899,0.0892,0.0000,0.0000
4,color_cooler,color_warmer,desk_lower_right,32.213,30.751,-1.462,0.1611,0.5742,0.4131,137.229,148.999,11.771,0.0858,0.0000,0.0000
4,color_cooler,color_warmer,blue_top,206.000,180.000,-26.000,0.7824,0.2936,-0.4888,80.555,45.230,-35.325,-0.4385,0.0000,0.0000
4,color_cooler,color_warmer,blue_front_left,205.925,50.900,-155.025,0.5115,0.2853,-0.2263,49.058,34.757,-14.301,-0.2915,0.0000,0.0000
4,color_cooler,color_warmer,red_top,3.366,7.794,4.428,0.8644,0.9329,0.0685,96.444,118.872,22.428,0.2325,0.0000,0.0000
4,color_cooler,color_warmer,red_front_left,3.784,10.040,6.257,0.7851,0.9179,0.1328,70.040,84.843,14.803,0.2113,0.0000,0.0000実行記録
どの画像・どの領域定義・どのライブラリで測ったかを1ファイルに残している。撮影画像はバイト列のコピーなのでファイルの md5 で見て、生成画像は画素配列の sha256 で見る。同じ画素でも保存し直すとファイルの md5 は変わるので、生成した画像の同一性はファイルではなく中身で判定する。
json
{
"script_name": "measure_hsv_log03.py",
"script_path": "/Users/shimazueiichi/r2r2r-scripts/vision/measure_hsv_log03.py",
"executed_at": "2026-07-29T09:28:55.340769+09:00",
"python_version": "3.12.12 | packaged by conda-forge | (main, Oct 22 2025, 23:34:53) [Clang 19.1.7 ]",
"library_versions": {
"numpy": "2.4.4",
"Pillow": "12.2.0",
"opencv": "4.13.0"
},
"input_files": [
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7911.JPG",
"md5": "2b1192f6fbc414c9cb0ca47d7fef924e",
"size_bytes": 1147899,
"icc_profile_name": "Display P3"
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7912.JPG",
"md5": "47a59644472e498591f49cfdba6cbfa4",
"size_bytes": 653339,
"icc_profile_name": "Display P3"
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7913.JPG",
"md5": "cb9d0627d1c3d5c0074b5efe434e546d",
"size_bytes": 1601744,
"icc_profile_name": "Display P3"
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/IMG_7914.JPG",
"md5": "dfa4e0b8ad162546755c1e83ef82d328",
"size_bytes": 1551091,
"icc_profile_name": "Display P3"
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/captured/Log03/measure_regions.json",
"md5": "812fab056eccd61504f3f22cf19863bf",
"size_bytes": 1275
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/generated/Log02/base_image.png",
"pixel_sha256": "05a8472d19a181c2fd98f9a45f756b61d904c3b04c066aec2d701298460d84b7",
"spec_pixel_sha256": "05a8472d19a181c2fd98f9a45f756b61d904c3b04c066aec2d701298460d84b7",
"pixel_sha256_match": true,
"size_bytes": 2525,
"icc_profile_name": ""
},
{
"path": "/Volumes/KIOXIA/r2r2r-data/vision/generated/Log02/base_image_spec.json",
"md5": "4af6f456ccd693e10a7fc6ef80cb6298",
"size_bytes": 6119
}
],
"output_files": [
"exp_hsv_generated.csv",
"exp_hsv_scale.csv",
"region_hsv_captured.csv",
"region_hsv_delta.csv",
"run_meta_hsv.json"
],
"notes": {
"hsv_scales_normalized": "H=deg[0,360), S=[0,1], V=[0,255]",
"low_sat_threshold_255": 25,
"color_conversion": "none (Display P3 のまま)"
}
}生成画像の pixel_sha256 が仕様書の値と一致した。前回作った画像そのもので測っていることが確認できる。
ここに1つ抜けがある。スクリプト自身のハッシュを記録していない。 この測定でスクリプトを3回直していて、退避したCSVが2世代残っている。どのCSVがどの版で出たものか、記録からは辿れない。script_md5 の列を足したが、足したのが最後の実行のあとなので、この記録には入っていない。次の測定から効く。
もう1つ、生成画像と撮影画像で色の入れ物が違っていることも記録に出ている。撮影画像は Display P3、生成画像はプロファイルが空。同じ「赤が255」でも指している色が違う。この回の結論には影響しない。生成画像は変換の検算と丸め誤差の測定にしか使っておらず、実写と数値を直接比べていないため。ただし生成画像で確かめたことが実写でもそのまま成り立つ、とは言えない。
この回で分かったこと
明るさだけを変えたとき、色相はほとんど動かなかった。 色のある4面で0.126度から3.512度。同じとき明度は22から28パーセント動いている。変化のほとんどが明度側へ行く。HSV は明るさの変化を、狙いどおり分離した。
ただし完全ではない。 赤の手前左面の3.512度は、丸め誤差0.33度の11倍で、実在する動きになる。また彩度は明るさから独立しておらず、明るくすると最大0.220上がった。
光の色を変えたとき、動く量は色によって桁が違った。 赤は4度から6度、青は26度、暗い青の面は155度。同じ照明の変化が、物の色によってまるで違う大きさで効く。オレンジの光には青い成分がほとんど無いので、青い物体は反射する光が減り、色として残らない。
彩度は色相より先に壊れる。 青い面は色相が意味を失う前に、彩度が0.512から0.285へ落ちていた。色で物を見分ける仕組みを作るなら、色相の幅を広げるより、彩度に下限を置くほうが早く効く。青に耐えられるところまで色相の幅を広げると、色相環の大半を飲み込むことになって、見分ける道具として機能しなくなる。
測る道具のほうにも目盛りがある。 OpenCV は色相を0〜179の整数で持つので、刻みが2度になる。それより小さい変化はきっちり0として出る。この経路で丸め誤差を測ると、画像ではなくライブラリを測ることになる。Pillow は0〜255で持つので刻みは約1.41度。式で直接計算すれば連続になる。同じ画素を同じ色空間に直しても、通した経路で表せる細かさが違う。
写真の見た目では、条件が揃っているかを判定できない。 最初の4枚は、撮影の途中でレンズが別物に切り替わっていた。写真を見てもおかしなところはない。EXIF にだけ出ていた。見た目が当てにならないことを扱っているのだから、見た目で確認して済ませるわけにはいかない。
次は、ここで測った色相と彩度をどう使えば物を切り出せるかに進む。この回で出た「青は155度動く」「彩度が先に落ちる」は、そのまま閾値の決め方に効いてくる。
