0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

14年前の一眼レフをMCPサーバーにした — 既存CLIをラップしてAIから叩く

0
Last updated at Posted at 2026-08-13

Wi-Fi APIを持たない世代の一眼レフを、USB接続のまま MCP サーバーにしました。

  • 道具(digiCamControl)のCLIを直接叩いているのは145行。書き直す場所が数か所に集まる
  • 「朝6時まで100秒間隔で、白飛びしないように撮って」が通る
  • 一晩(203コマ・5時間37分)回して、16.3段ぶん露出を自動で追従した(破綻はしなかった、が正確)

この記事はカメラの記事ではありません。主題は「手元にCLIしかない道具を、AIから叩けるようにする型」のほうです。カメラは、たまたま私が持っていた題材です。

全体はこうなっています。

「撮って」の一言がシャッターに届くまで。撮る→測る→次の露出を決める、が camera-mcp の中で100秒ごとに回る

先行実装はある。無かったのは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_targetend_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に渡る文字列です。

src/camera_mcp/server.py
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.pycamera_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 はコマンド送信オプションです。

src/camera_mcp/dcc.py
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ツール側は、その関数を呼ぶだけです。

src/camera_mcp/server.py
@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時間です。クライアントはとっくにタイムアウトしています。

なので、開始ツールは即座に返し、撮影ループはバックグラウンドスレッドに逃がします。

src/camera_mcp/timelapse.py
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) では守れません。撮影自体に時間がかかるので、素朴に書くと少しずつ後ろへずれます。

src/camera_mcp/timelapse.py
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枚の失敗で、ジョブを殺さない

夜通し走らせるものは、どこで失敗を握りつぶすかを先に決めておく必要があります。決めていないと、朝には何も残っていません。

src/camera_mcp/timelapse.py
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 に落としていました。

src/camera_mcp/exposure.py
if current in values:
    return values.index(current)
return default      # ← これが事故のもと

index=0 は最低感度です。実際は ISO 25600 で撮っているのに「ISO 100 で撮っている」と思い込むので、初手で数段ぶん過補正して、一晩ぶんが壊れます。

直したあとはこうです。数値が近い候補に丸めたうえで、必ず警告を残します。

src/camera_mcp/exposure.py
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 で頭打ちにできる(露光がインターバルを超えると破綻するので、上限は実質必須)

ただし正直に書くと、countend_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 が出るものなら、だいたい繋がります。

参考


7年前にこのアカウントで書いたのは「現実世界の数字をPythonに読ませる」話(OCRで温度計を読む)でした。やっていることは、あまり変わっていません。

0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?