前回、14年前の一眼レフ(Canon EOS 6D)をMCPサーバーにした話を書きました。既存のCLIをラップして、AIから自然言語で叩けるようにする、という型の話です。
そのとき見せたのは、道具との境界にあたる部分(145行)でした。その上に乗っている「やりたいこと」の中身には触れていません。今回はそちらです。露出を決めている部分、196行。
ただ、解説記事にはしません。書いたあとで数えてみたら、思っていたのと違ったからです。
このコードを一晩回して、203コマぶんのログが残りました。自分が書いた分岐が何回通ったかを数えたら、一度も通っていないものが2つありました。
何を測っているか
やっていることは単純です。1コマ撮る → その写真を測る → 明るすぎ/暗すぎなら次のコマの設定を変える。これを朝まで繰り返します。カメラから測光値を受け取るのではなく、PCに保存された写真そのものを測定対象にします。
測る部分は10行です。
def brightness(path: Path) -> dict:
"""画像の明るさ指標。mean: 平均輝度(0-255), clip_hi: 白飛び画素率(%)。"""
with Image.open(path) as im:
im.thumbnail((200, 200))
gray = im.convert("L")
stat = ImageStat.Stat(gray)
hist = gray.histogram()
total = sum(hist)
clip_hi = 100.0 * sum(hist[250:]) / total if total else 0.0
return {"mean": round(stat.mean[0], 1), "clip_hi": round(clip_hi, 1)}
数字を2つ返しているのは、平均輝度だけでは白飛びが見えないからです。全体が暗くても、光源のまわりだけ飛ぶことがあります。飛んだ画素は撮ったあとで戻せません。
thumbnail((200, 200)) で縮小してから測っているのは、5472×3648をそのまま舐めるのは重いだろう、と思ったからです。
速くはなります。ただ、この構成では速くする必要がありませんでした。
撮影した203コマから等間隔に21枚を抜き、thumbnail() の有無だけを変えて計測しました(Windows 11・ローカルSSD・1枚1回・21枚の中央値・ウォームアップ除外なし)。
| 1枚あたりの処理時間(中央値) | |
|---|---|
thumbnail() あり |
59.5 ms |
thumbnail() なし |
203.9 ms |
3.4倍の差はあります。しかしインターバルは100秒で、しかも撮影1枚に数秒から数十秒かかります。その中の203.9msは誤差の側です。
測定値のほうも、同じ21枚でほとんど変わりませんでした(差はいずれも「縮小あり − 縮小なし」)。
- 平均輝度の差:中央値 +0.06
- 白飛び率の差:中央値 +0.018ポイント(最大 +0.038 / 最小 −0.022)
ついでに、前回の記事の注記も間違っていました。当初「白飛び率は縮小画像で測っているので実際よりやや小さく出る」と書いていたのですが、21枚中13枚はむしろ大きく出ていて、方向は一定しません。 差が小さすぎて、どちらにも転びます(前回の記事のほうは、この計測を受けて訂正済みです)。
計測に使ったのはこれです。ファイル名順に並べた203枚から10枚おき(0番、10番、20番…)に21枚を取り、1枚ずつ両方を呼んで時間を測っただけです。
from pathlib import Path
from PIL import Image, ImageStat
def meas(p, thumb):
with Image.open(p) as im:
if thumb:
im.thumbnail((200, 200))
g = im.convert("L")
h = g.histogram()
return ImageStat.Stat(g).mean[0], 100.0 * sum(h[250:]) / sum(h)
files = sorted(Path(".").glob("*.jpg"))[::10]
(実際の brightness() にある0除算のガードと round() は省いています)
この21枚では測定値の差は小さかったので、縮小はそのまま残しています。ただし速度を理由にするなら、この構成では根拠がありませんでした。 白飛び率が8%付近まで来る条件は今回一度も通っていないので、しきい値の近くで縮小が効くかどうかは分かりません。
どう決めているか
測った値を受けて次の設定を決めるのが update() です。今回はこの関数の戻り値(steps)を数える話なので、ロジックは省略せずに載せます。
def update(self, measured: dict) -> dict | None:
"""明るさ測定値を受け取り、変更があれば {'tv':..,'iso':..,'steps':n} を返す。"""
mean = measured["mean"]
err = mean - self.target
# 白飛びが広いときは平均が目標内でも1段下げる
if abs(err) <= self.deadband and measured.get("clip_hi", 0) < 8.0:
return None
steps = 1
if abs(err) > 45:
steps = 2
if abs(err) > 90:
steps = 3
darker = err > 0 or measured.get("clip_hi", 0) >= 8.0
moved = 0
for _ in range(steps):
moved += self._step(darker)
if not moved:
return None
return {"tv": self.tv[self.tv_i], "iso": self.iso[self.iso_i], "steps": moved * (-1 if darker else 1)}
- 目標との差が ±10以内なら何もしない(不感帯)。毎コマ触ると明滅します
- 差が大きいほど大きく動かす。45超で2ステップ、90超で3ステップ
- 平均が目標内でも、白飛びが8%以上なら下げる
- 返す
stepsは「動かそうとした数」ではなく実際に動けた数です。候補リストの端では動けないので、movedが 0 なら何も返しません
実際にダイヤルを回すのが _step() です。ここは自分でも気に入っているところで、明るくしたい時と暗くしたい時で、動かす順番が逆になっています。
def _step(self, darker: bool) -> int:
if darker:
if self.iso_i > 0: # まずISOを下げる
self.iso_i -= 1
return 1
if self.tv_i < len(self.tv) - 1: # 次にシャッターを短く
self.tv_i += 1
return 1
else:
if self.tv_i > 0: # まずシャッターを長く
self.tv_i -= 1
return 1
if self.iso_i < len(self.iso) - 1: # 次にISOを上げる
self.iso_i += 1
return 1
return 0
どちらも「画質の良い側から先に使い切る」という一つの方針から出ています。暗い夜に明るくしたいときは、ISOを上げると画が荒れるので、先にシャッターを長くする。夜明けに暗くしたいときは、荒れているISOから先に下げて画質を回復させる。同じ「1段」でも、どちらのダイヤルを回すかで残る写真が違います。
一晩ぶんのログで、自分の制御を採点する
ここからが本題です。
ジョブは1コマごとに、測定値と変更内容を log.jsonl に1行ずつ書いています。8月8日の夜、00:49から06:26まで、203コマぶんが残りました(エラー0)。目標の平均輝度は95(コンストラクタの既定値は115ですが、この夜は95が渡されています)、インターバル100秒、開始はシャッター8秒・ISO6400です。
このログで、さっき載せた分岐がそれぞれ何回通ったかを数えました。
| 書いた分岐 | 一晩での作動回数 |
|---|---|
| 何もしない(不感帯の中) | 148回 |
| ±1ステップ | 48回(-1が41 / +1が7) |
| ±2ステップ(差が45超) | 7回(-2が6 / +2が1) |
| ±3ステップ(差が90超) | 0回 |
| 白飛び優先(8%以上で強制的に下げる) | 0回 |
3ステップ補正は一度も通りませんでした。 平均輝度が目標から90以上ずれる場面が、一晩を通して来なかったからです。
白飛び優先も一度も通りませんでした。 白飛び率の最大は 1.4%(03:12:47と03:16:07の2コマ)で、しきい値の8%には遠く及びません。
しかも、面白いのはここです。いちばん明るかったコマ(04:31:07・平均輝度149.4)でも、白飛びは0.3%でした。
平均輝度は目標の95を大きく超えて149.4まで上がっているのに、白飛び画素はほぼ増えていません。この夜のログでは、2つの数字は単純に連動しませんでした。
平均輝度の帯ごとに白飛び率の平均を出しても、単調には増えていません。
| 平均輝度 | コマ数 | 白飛び率の平均 |
|---|---|---|
| 80〜99.9 | 124 | 0.29% |
| 100〜119.9 | 47 | 0.17% |
| 120〜139.9 | 22 | 0.28% |
| 140〜159.9 | 6 | 0.30% |
(このほかに80未満が4コマ。いちばん明るい帯は6コマしかないので、傾向として強く読める数字ではありません)
言えるのは「平均輝度だけから白飛びの有無は判断できない」までです。1晩・1地点・1構図なので、これ以上の一般化はできません。太陽が画角に入れば話は変わるはずです。それでも、2つを別々に測って別々に記録する理由としては十分でした。白飛びを心配して足した分岐のほうは、心配していた場面そのものが来なかったので、出番がありませんでした。
実際に働いたのは、ほぼ1ステップの補正だけです。2ステップが出た7回のうち6回は04:22〜04:44の22分間に集中していました。夜明けの立ち上がりがいちばん急な区間です。
04:22:47 mean 147.0 -> -2
04:31:07 mean 149.4 -> -2
04:32:47 mean 140.1 -> -2
04:36:06 mean 145.9 -> -2
04:39:22 mean 143.4 -> -2
04:44:20 mean 140.1 -> -2
06:19:18 mean 44.3 -> +2 ← 唯一の逆方向
最後の1回だけ符号が逆です。直前の06:17:38に-1補正が入っていて、そのあと平均輝度が44.3まで落ちて+2で戻しています。時系列としては行きすぎの揺り戻しに見えますが、同じ設定でも輝度は大きく振れているので、原因を直前の補正だけに帰することはできません。 言えるのは、ログの上ではこう並んでいた、というところまでです。
当夜のコードと、いまのコードのずれについて
撮影は8月8日で、そのあとコードを触っています。なので、上の表がいまのコードの話なのか当夜の話なのかを、先に確かめました。
当夜の目標値95と、現在の判定条件(不感帯10/45超/90超/白飛び8%以上)を使い、ログの mean と clip_hi から符号付きステップ数を再計算したところ、203コマすべてでログの steps と一致しました。
ただし確認できたのはこの判定部分だけです。当夜のソースのバージョン、カメラから取得した候補リストとその順序、brightness() の実装が同じだったかは、どれもログに残していないので分かりません。
数えられなかったもの
そして、ここが今回いちばん効きました。数えようとして、数えられなかったものがあります。
def normalize_iso_list(values: list[str]) -> list[str]:
"""有効なISO値だけを、数値の昇順(暗い→明るい)に並べて返す。
_step() は「index+1 = 明るい方向」を前提にしているので、並び順が保証されないと
夜通し逆方向に補正し続ける。カメラ側の返り順に依存しないよう必ずここを通す。
"""
pairs = [(iso_value(v), str(v).strip()) for v in values]
pairs = [(n, s) for n, s in pairs if n is not None]
pairs.sort(key=lambda p: p[0])
out: list[str] = []
seen: set[float] = set()
for n, s in pairs: # 表記ゆれで同じ値が重複することがあるので落とす
if n not in seen:
seen.add(n)
out.append(s)
return out
_step() は「インデックスを1つ増やせば明るくなる」を前提に書かれています。この前提はカメラが返す候補リストが昇順に並んでいる限り成り立ちますが、並びが逆なら、暗いから明るくしようとしてさらに暗くする、を延々と繰り返します。しかも例外は出ません。プログラムは正常に完走して、朝には使えない写真が残ります。
だから並べ替えを噛ませてあります。ただ、これが実際に仕事をしたのかどうかは分かりません。
カメラが返してきた候補リストの中身と順序を、ログに残していませんでした。 当夜も現在と同じ並べ替えが入っていたと仮定するなら、元から昇順で返ってきていた場合はこの関数は一晩なにもしておらず、逆順だった場合は補正の向きを正していたことになります。ただし当夜のソースも候補リストも残していないので、そのどちらだったのかも、そもそも当夜この関数があったのかも確認できません。
初期化まわりでは、もう1つ数え損ねています。前回の記事で「現在値が候補リストに無いときに黙って最低感度へ落ちる」バグを直した話を書きました。丸めたときに警告を残すようにしたのですが、その警告を log.jsonl に出すようにしたのは、この夜より後です。そのためこの夜に、現在値が候補リスト外で警告条件に当たっていたかどうかも判定できません。
数えられた分岐と、数えられなかった分岐の違いは、設計の良し悪しではありません。1行ログを吐いていたかどうかだけです。
まとめ
書いたときの自分は、どの分岐が効くか分かっていませんでした。3ステップも白飛び優先も、「こういうこともあるだろう」で足したものです。実際には、どちらも一度も通らなかった。200pxへの縮小に至っては、速くしたかった理由のほうが成り立っていませんでした。
かといって、消せばいいという話でもありません。8%以上の白飛びは、来なかっただけで、来ないとは限らない。
役に立ったのは、分岐そのものより手前にあったものでした。
一晩ぶんの分岐の作動回数を後から数えられたのは、1コマごとに1行ずつログを吐いていたからです。 測定値と変更内容を log.jsonl に残す _log() は4行です。書いたときは進捗表示のつもりでした。それが、あとから自分の設計を採点する道具になっていた。そして候補リストと初期化時の警告条件については、必要な情報を残しておらず、いまも評価できません。
人が見ていない間に動くものは、動いている最中は誰も見ていません。分岐を増やすことより、後から数えられる形にしておくことのほうが効きました。
次に一晩回すときは、候補リストとそのバージョンを最初の1行に足すつもりです。
なお、この制御にはまだ穴があります(ステップをEVでなくインデックスで数えている/測光が全画面平均でROI指定がない/フィードバックが常に1コマ遅れ)。そのあたりは前回の記事に書きました。
