Wi-Fi APIを持たない世代の一眼レフを、USB接続のまま MCP サーバーにしました。
- 道具(digiCamControl)のCLIを直接叩いているのは145行。書き直す場所が数か所に集まる
- 「朝6時まで100秒間隔で、白飛びしないように撮って」が通る
- 一晩(203コマ・5時間37分)回して、16.3段ぶん露出を自動で追従した(破綻はしなかった、が正確)
この記事はカメラの記事ではありません。主題は「手元にCLIしかない道具を、AIから叩けるようにする型」のほうです。カメラは、たまたま私が持っていた題材です。
全体はこうなっています。
先行実装はある。無かったのは1点だけ
やっている「夜から朝にかけて露出を追従させるタイムラプス」は、新しい発想ではありません。Magic Lantern の Auto Exposure Ramping が、ほぼ同じアルゴリズム(直前のコマの明るさを見てシャッターとISOを動かす)を2011年から実装しています。qDslrDashboard にもホーリーグレイル用の自動露出があります。しかも私が使った Canon EOS 6D は、Magic Lantern のビルドが現存する機種です(要ファーム 1.1.6)。素直にそちらを入れたほうが早い場面は普通にあります。
MCP側も探しました。カメラを扱うMCPサーバーは、すでにあります。
| 実装 | できること |
|---|---|
| darktable-mcp | libgphoto2 でカメラを検出し、写真を取り込む(撮影・設定変更は無し) |
| mcp-webcam / videocapture-mcp | Webカメラから静止画を取得 |
そのうえで、GitHub・PyPI・npm・Qiita・Zenn を MCP × gphoto2 / EDSDK / digiCamControl / CCAPI / tethering で見た範囲では、見つからなかったものが1つだけありました。
一眼レフのシャッターと露出を、USB接続のままMCPから制御する実装。
取り込みではなく制御、Webカメラではなく一眼レフ、Wi-Fiではなく USB。この3つが重なるところだけが空いていました。
できるようになったこと
公開しているMCPツールは9本です。
| ツール | 内容 |
|---|---|
camera_status |
接続状態・現在のISO/絞り/シャッター速度・実行中ジョブ |
camera_capture |
1枚撮影してファイルパスを返す |
camera_set |
ISO・絞り・シャッター速度・露出補正・WBの変更 |
camera_focus |
ライブビュー中のフォーカスモーター駆動 |
timelapse_start / stop / status / list
|
タイムラプスの開始・停止・進捗・過去セッション |
video_create |
連番写真から mp4 を生成(ffmpeg) |
こう言うと、通ります。
朝6時まで100秒間隔で、白飛びしないように撮って
AI側は、こう組み立ててきました(実際に走った呼び出しです)。
timelapse_start(name="asagao_0808", interval_sec=100, end_at="06:00",
autofocus=False, auto_exposure=True,
ae_target=95, ae_max_shutter="8", ae_max_iso="12800")
ae_target の既定値は115で、この夜は95で回りました。誰がこの数字を出したのかは、ログに残っていません。 私が下見のときに「ISO6400・F5.6・8秒で平均輝度94〜99」と実測しているので、たぶん私発です。docstringにも「夜のシルエット重視なら90前後」と自分で書いてあります。
はっきり言えるのはこちらです。このあと私が「7:00まで伸ばせる?」と聞いたら、同じ呼び出しを end_at="07:00" で組み直してきました。「白飛びしないように」や「7時まで」といった言葉と、ae_target や end_at という引数のあいだを、対話で埋められる。 先行実装との差があるとすれば、たぶんここだけです。それが価値かどうかは、正直まだ分かりません。
ジョブはすぐ返ってきます。あとは朝まで回ります。人は寝ています。
途中で timelapse_status を叩くと、こう返ります(実際の出力から、保存先など一部のフィールドを省いたもの。04:09時点)。
{
"name": "asagao_0808",
"state": "running",
"shots": 121,
"errors": 0,
"interval_sec": 100,
"auto_exposure": true,
"current_exposure": { "tv": "8", "iso": "6400" },
"last_brightness": { "mean": 103.0, "clip_hi": 0.4 }
}
一晩ぶんの実測はこうなりました。
| 項目 | 実測 |
|---|---|
| コマ数 | 203 |
| 時間 | 00:49 〜 06:26(5時間37分。最終コマは 06:25:57) |
| 開始 | 8秒 / ISO6400(目標の平均輝度 95) |
| 終了 | 1/320秒 / ISO200 |
| 下げた量 | 16.3段 |
| 露出を動かした回数 | 55 |
| 平均輝度 | 中央値 95.5 / 最小 44.3 / 最大 149.4 |
| 白飛び画素の割合 | 最大 1.4%(03:12・03:16)※ |
※ 白飛び率は200pxに縮小した画像で測っています(輝度250以上の画素の割合)。原寸で測り直しても差は±0.04ポイント程度なので、相対比較用の指標としてはそのまま使えます。
一晩を通すと、平均輝度は中央値95.5・73%のコマが目標±10 に収まりました。
ただし、この73%はほとんど「制御が何もしていない時間」の成績です。 時間帯で割ると、こうなります。
| 区間 | コマ数 | 目標±10 に収まった割合 |
|---|---|---|
| 夜(〜04:12:47) | 122 | 97% |
| 降下中(04:12:47〜05:54:18・16.3段を下げた区間) | 62 | 32% |
| 降下後(05:54:18〜) | 19 | 53% |
区切りは、最初に露出を下げたコマ(04:12:47)と、下限の 1/320秒・ISO200 に到達したコマ(05:54:18)です。
なお「夜」も完全な据え置きではなく、03:11〜03:16 に ISO 6400↔12800 を4回行き来しています(後述のハンチング)。
白飛び最大の1.4%は、この揺れのときのものです。
本当に効かせたかった降下中は、32%しか入っていません。 とくに立ち上がりのいちばん急な時間帯(04:22〜04:44)では、一時 149.4 まで振れています。フィードバックは常に1コマ遅れなので、外光が100秒単位で変わる区間には原理的に追いつけません。
それでも、固定露出ならこの区間は確実に真っ白です。「ちゃんと追従した」ではなく「破綻はしなかった」、が正確なところです。
動作環境と、始め方
| 項目 | 値 |
|---|---|
| OS | Windows のみ(digiCamControl が Windows 専用のため) |
| Python | 3.10 以上 |
| MCP | fastmcp 3.4.4 |
| カメラ | Canon EOS 6D(2012年発売)。digiCamControl 対応機なら概ね動くはず(未検証) |
| 接続 | USB(Wi-Fi・CCAPI は使わない) |
| クライアント | Claude Code で確認 |
ここまでで動きます。
winget install dukus.digiCamControl
git clone https://github.com/nokorishiro/camera-mcp
cd camera-mcp
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e .
claude mcp add camera --scope user -- C:\path\to\camera-mcp\.venv\Scripts\python.exe -m camera_mcp
最後の行のパスは、clone した場所の絶対パスに置き換えてください。claude_desktop_config.json に直接書く場合の例は README にあります。
サーバー本体の起点はこうです。instructions は、ツール個別のdocstringとは別に、サーバー全体の説明としてAIに渡る文字列です。
from fastmcp import FastMCP
mcp = FastMCP(
"camera",
instructions="Canon EOS 6D(USB接続)を制御するサーバー。撮影・設定変更・タイムラプス・...",
)
型:既存CLIをMCPにする3層
構造はこれだけです。
[MCPツール定義] server.py @mcp.tool を付けるだけ。ここは薄い
↓
[翻訳層] dcc.py CLIを叩いて応答をパースする。145行
↓ ★道具を替えるとき書き直すのは、ここだけ
[既存の道具] digiCamControl 他人が作ったフリーソフト(Canon機の場合、内部は EDSDK)
digiCamControl のCLIを直接叩いているのは、この翻訳層だけです。 自動露出やジョブ管理(残り約650行)はその上に載っています。
……と言い切りたいのですが、正直に書くと漏れがあります。server.py の camera_focus は翻訳層を素通りして dcc.do("LiveView_Focus_M2") のような digiCamControl 固有のコマンド名を直書きしていますし、exposure.py には EOS 6D の1/3段テーブルと Canon 表記(30" 0"3)のパーサが入っています。ここは設計の甘さです。 なので「650行がそのまま動く」とは言えません。ただ、書き直す場所が翻訳層とこの数か所に集まっているのは確かで、探して回らずに済みます。境界を薄くする値打ちはそこにあります。
翻訳層の中身は、拍子抜けするほど単純です。コマンドを投げて、標準出力から応答部分を抜くだけ。REMOTE_CMD は digiCamControl 付属の C:\Program Files (x86)\digiCamControl\CameraControlRemoteCmd.exe、/c はコマンド送信オプションです。
class DccError(RuntimeError):
pass
# 応答は `:;response:<値>;` の形で返ってくる
_RESPONSE_RE = re.compile(r":;response:(.*?);", re.S)
def run(*args: str, timeout: int = 60):
proc = subprocess.run(
[str(REMOTE_CMD), "/c", *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout,
)
m = _RESPONSE_RE.search(proc.stdout or "")
if not m:
raise DccError(f"no response from digiCamControl: {proc.stdout!r}")
raw = m.group(1).strip()
try:
return json.loads(raw)
except json.JSONDecodeError:
# パスに \U 等が含まれるとJSONとして不正になるので生文字列で返す
return raw.strip('"')
正規表現を非貪欲(.*?)にしてあるのは、出力に ; が複数あるとき、貪欲だと最後の ; まで飲み込んでしまうからです。ここは実際に踏みました。
もうひとつ、CLIの裏でGUIが常駐している道具は珍しくありません。digiCamControl も、本体の CameraControl.exe が起動していないとCLIが応答しません。なので各ツールの頭で dcc.ensure_app() を呼び、落ちていたら起動して接続を待ちます。この「常駐の面倒を見る係」は、翻訳層の役目にしておくと上が綺麗になります。
MCPツール側は、その関数を呼ぶだけです。
@mcp.tool
def camera_capture(autofocus: bool = True) -> dict:
"""1枚撮影して保存ファイルのフルパスを返す。autofocus=Falseで置きピン撮影。"""
dcc.ensure_app()
path = dcc.capture(autofocus=autofocus)
return {"file": str(path), "size_bytes": path.stat().st_size}
docstring がそのままAIへの説明になります。ここを丁寧に書くかどうかで、AIの使い方の精度がわりと変わります。
つまり、CLIを持っている道具なら、だいたい同じことができます。測定器でも、古いハードでも、コマンドラインが付いているアプリでも。
詰まったところ① 長時間ジョブは、ツールの中で待てない
MCPのツール呼び出しは、結果を返すまでが1往復です。進捗通知(ctx.report_progress)を受け取ったらタイムアウトを延長してもよい、と仕様は書いていますが(Lifecycle / Timeouts)、延ばすかどうかはクライアント実装次第で、しかも「最大タイムアウトは常に強制すべき」とも書かれています。つまり、一晩待つツールは前提にできません。
素直に書くと、こうしたくなります。
@mcp.tool
def timelapse_start(...):
for i in range(count):
capture()
time.sleep(interval) # ← 一晩ここで待つ
return {"done": True}
これは死にます。 一晩の撮影は5〜7時間です。クライアントはとっくにタイムアウトしています。
なので、開始ツールは即座に返し、撮影ループはバックグラウンドスレッドに逃がします。
class TimelapseJob(threading.Thread):
def __init__(self, name, interval_sec, ...):
super().__init__(daemon=True)
self.name_ = name # Thread.name と衝突するので name_
...
_current: TimelapseJob | None = None
_lock = threading.Lock()
def start(name, interval_sec, ...) -> dict:
global _current # ← これが無いと下の if _current で UnboundLocalError
with _lock:
if _current and _current.is_alive():
raise RuntimeError(
f"ジョブ '{_current.name_}' が実行中です。先に timelapse_stop してください。")
_current = TimelapseJob(name, interval_sec, ...)
_current.start()
return _current.status() # ← 走り出した状態を返してすぐ抜ける
進捗は別のツール(timelapse_status)で取りに行かせます。これで「開始して」「いま何枚撮れた?」という会話が成立します。長時間かかる道具をMCP化すると、たいていここにぶつかると思います。
ついでに、定期実行のリズムは sleep(interval) では守れません。撮影自体に時間がかかるので、素朴に書くと少しずつ後ろへずれます。
next_shot += self.interval
delay = next_shot - time.monotonic()
if delay < 0: # 撮影がインターバルより長引いた場合はリズムを取り直す
next_shot = time.monotonic()
delay = 0
if self.stop_event.wait(delay): # 待ちながら、停止指示も拾う
break
「次の時刻」を積算して、そこから逆算して待つ。 数行ですが、一晩で効いてきます。
正直に書いておくと、この作りには制約があります。撮影ループはMCPサーバーのプロセス内で動いているので、クライアント(Claude Code等)を終了するとサーバーごと落ちて、ジョブも止まります。常駐デーモン化は未対応で、一晩回すあいだ、そのウィンドウを閉じられません。
詰まったところ② 1枚の失敗で、ジョブを殺さない
夜通し走らせるものは、どこで失敗を握りつぶすかを先に決めておく必要があります。決めていないと、朝には何も残っていません。
try:
path = dcc.capture(autofocus=self.autofocus)
self.shots += 1
self.last_file = str(path)
self._log("shot", n=self.shots, file=path.name)
if self.ae:
self._auto_expose(path)
except Exception as e: # 1枚の失敗でジョブは殺さない
self.errors += 1
self.last_error = str(e)
self._log("error", message=str(e))
if self.errors >= 10 and self.shots == 0:
self.state = "failed" # ただし一度も撮れずに10連続なら諦める
return
握りつぶす一方で、「一度も成功しないまま失敗し続ける」は別扱いにしています。カメラが繋がっていない状態で7時間空振りし続けても、誰も得をしません。露出調整の失敗も、撮影本体は止めないようにしています。撮れているのに調整で落ちるのが、いちばんもったいない。
詰まったところ③ 黙って既定値に落とすと、一晩壊れる
これがいちばん効きました。
自動露出は、カメラが持っているシャッター速度とISOの候補リストの上を、インデックスで動きます。開始時に「いまの設定はリストの何番目か」を調べるのですが、候補リストに無い値から始まることがあります(上限を超えたISOで撮っていた、など)。
初版は、こういうときに黙って index=0 に落としていました。
if current in values:
return values.index(current)
return default # ← これが事故のもと
index=0 は最低感度です。実際は ISO 25600 で撮っているのに「ISO 100 で撮っている」と思い込むので、初手で数段ぶん過補正して、一晩ぶんが壊れます。
直したあとはこうです。数値が近い候補に丸めたうえで、必ず警告を残します。
def _index(self, values, current, default, kind="tv") -> int:
if current in values:
return values.index(current)
to_num = tv_seconds if kind == "tv" else iso_value
cur = to_num(current)
if cur is not None and values:
diffs = [(abs((to_num(v) or 0.0) - cur), i) for i, v in enumerate(values)]
i = min(diffs)[1]
self._warn(f"現在の{kind} {current!r} が候補リストにありません。"
f"最も近い {values[i]!r} として扱います")
return i
...
self.warnings はジョブ開始時のログに必ず出るので、呼び出し側から検知できます。
一般化すると、AIに渡す道具でいちばんきついのは「静かに失敗する」です。人が見ていないあいだに動くものだから、黙って間違った前提で進まれると、朝まで誰も気づけません。落ちるより、間違ったまま完走するほうが悪い。
任せる前に、決めておくこと
AIに物理的な機械を触らせるので、止める口は用意してあります。
- 撮影は枚数(
count)と終了時刻(end_at)の両方で止められる。どちらか早いほうで終わる - ジョブは同時に1本だけ。走っている最中に
timelapse_startを呼ぶとエラーになる - ただし
timelapse_stopは待機中にしか即座に効きません。撮影コマンドの実行中はキャンセルできないので、その1枚が終わるまで(最大40秒ほど)止まりません - 自動露出が触れる範囲も
ae_max_shutter/ae_max_isoで頭打ちにできる(露光がインターバルを超えると破綻するので、上限は実質必須)
ただし正直に書くと、count も end_at も既定では未設定=無期限です。この夜は end_at="07:00" が渡されていましたが、実際にはその手前の06:26に timelapse_stop で止めていて、上限が効いて終わったわけではありませんでした。上限を渡してもらえる前提になっているのは、設計の穴です。既定で上限を持たせるべきでした。シャッター回数(EOS 6D は一般に約10万回とされます)とバッテリー残量も見ていません。
測り方にも癖があります。
- 測光は全画面の平均で、ROI指定がありません。構図の中で何かが変化すると、明るさの変化と誤認します(花が開く、雲が流れる)
- 制御は、誤差の大きさを3段階に量子化して候補リストのインデックスを動かすだけです。積分項もヒステリシスもないので、境界付近では明滅します(実際、03:11〜03:16に4回振れました)
- 保存先はクラウド同期フォルダの外にしてください。一晩で1,000枚・数GBになります
- digiCamControl と Canon の EOS Utility は、同時にカメラを掴めません
まとめ
- 道具側との境界は薄くできる(今回145行)。残りは「やりたいこと」の実装で、道具を替えても持っていける
- 長時間ジョブは、開始ツールで即返してバックグラウンドに逃がし、進捗は別ツールで取りに行かせる
- AIに渡す道具でいちばん危ないのは「静かに既定値へ落ちる」こと。丸めるなら必ず警告を残す
やってみて思ったのは、MCP化のハードルは実装より「題材が思いつくかどうか」にある、ということでした。手元の道具を見回してみてください。--help が出るものなら、だいたい繋がります。
参考
- Model Context Protocol / Lifecycle / Timeouts
- FastMCP
- digiCamControl
- Magic Lantern / Bulb/Focus Ramping
- リポジトリ:https://github.com/nokorishiro/camera-mcp
- なぜ作ったのか・一晩まかせてみて何を思ったのかは note に書きました
7年前にこのアカウントで書いたのは「現実世界の数字をPythonに読ませる」話(OCRで温度計を読む)でした。やっていることは、あまり変わっていません。

