📷

監視カメラMCPサーバー@ラズパイを自作して遊ぶ!

に公開2

はじめに

MCPサーバーを自作してみたいということで、ついでに身の回りで困りごとなものを解決したいと思い実施しました。今回の困りごととは、家の横あるおばあちゃんの家にいかなくても状況を知らせてくれるMCPサーバーです。何かしらのアプリなどで自然言語で聞くだけで、なんとなくどういう状況か教えてくれるものを目指します。

やりたいイメージ

簡単ですが、下記のイメージです。今回はクラウド上ではなく、完全オンプレなのでローカルホストで実験的に検証していきます。

実装デバイス

  • ロジクールのWebカメラ

    ※下のNVIDIA Jetson Orinは別の検証で使います
  • ラズベリーパイ3

    ここにMCPサーバーを立てます。クライアント側のPCは別途用意し、今回は検証しました。

作成した監視カメラMCPサーバーと関連コード一覧

ラズベリーパイで実装しているため、Ubuntu上での実行を行っています。

mcp
from fastmcp import FastMCP
from photo_capture import CameraCapture
from image_explain import ImageDescriber
from setting import openai_api_key
from typing import Optional
import os
from setting import DEFAULT_PROMPT
import glob

mcp = FastMCP("camera-explainer")

# 共通設定
IMAGE_DIR   = r"C:\image"
CAMERA_INDEX = 1

# ヘルパー関数(ツール同士で使い回す)
def _capture_image() -> str:
    """実際にカメラを叩いて画像を保存し、ファイルパスを返す"""
    cam = CameraCapture(camera_index=CAMERA_INDEX, output_dir=IMAGE_DIR)
    path = cam.capture()
    cam.release()
    if not path or not os.path.exists(path):
        raise RuntimeError("画像の撮影に失敗しました。")
    return path

def _image_explain(prompt: Optional[str] = None) -> str:
    """IMAGE_DIR 内で最新の画像を Vision に送り Markdown を返す"""
    if not prompt or not prompt.strip():
        prompt = DEFAULT_PROMPT
    describer = ImageDescriber(IMAGE_DIR, openai_api_key)
    result = describer.describe(prompt=prompt)
    if result is None:
        raise RuntimeError("説明文の生成に失敗しました。")

    # 削除処理
    for fp in glob.glob(os.path.join(IMAGE_DIR, "*")):  
        try:
            os.remove(fp)
        except Exception as e:
            # ログだけ出して続行(失敗しても説明文は返す)
            print(f"[WARN] ファイル削除失敗: {fp} ({e})")

    return result

# 画像撮影ツール
@mcp.tool()
def capture_image() -> str:
    """外付けカメラで写真を撮影し、保存ファイルの絶対パスを返す"""
    return _capture_image()

# 画像解釈ツール
@mcp.tool()
def describe_latest_markdown(prompt: Optional[str] = None) -> str:
    """最新画像をMLLMに送り、Markdownで説明文を返す"""
    return _image_explain(prompt)

# 撮影し解釈結果を返すツール
@mcp.tool()
def image_capture_and_explain(prompt: Optional[str] = None) -> str:
    """
    新しく写真を撮影し、その写真をMLLMで説明して返す
    """
    _capture_image() 
    result = _image_explain(prompt)                   
    return result

# エントリーポイント
if __name__ == "__main__":
    # HTTP で公開
    mcp.run(
        transport="http",
        host="ご自身のパソコンのIPアドレス",
        port=8000,
    )

カメラのキャプチャーをとり、MLLMで回答を返す仕組みです。
本来のMCPサーバーはツールとしての役割に徹するべきなので、カメラの画像をBase64等で送るべきですが、あくまで今回はすぐに試したかったのでツール側で解釈結果まで整理し、レスポンスを返します。

photo_capture
import cv2
import datetime
import os

# カメラ撮影
class CameraCapture:
    def __init__(self, camera_index=0, output_dir='./'):
        self.camera_index = camera_index
        self.output_dir = output_dir
        self.cap = cv2.VideoCapture(camera_index)
        if not self.cap.isOpened():
            raise RuntimeError("カメラがオープンできませんでした")
        
    def capture(self):
        os.makedirs(self.output_dir, exist_ok=True)
        ret, frame = self.cap.read()
        if not ret:
            raise RuntimeError("カメラが見つかりません")

        # 撮影日時をフォーマット
        dt = datetime.datetime.now()
        timestamp_str = dt.strftime("%Y-%m-%d %H:%M:%S")

        # 画像の左下に描画
        cv2.putText(
            frame, timestamp_str,
            org=(10, frame.shape[0] - 10),       # 座標 (x, y)
            fontFace=cv2.FONT_HERSHEY_SIMPLEX,
            fontScale=0.8,
            color=(255, 255, 255),               # 白文字
            thickness=2,
            lineType=cv2.LINE_AA
        )

        # 保存
        file_name = dt.strftime("photo_%Y%m%d_%H%M%S.jpg")
        file_path = os.path.join(self.output_dir, file_name)
        if cv2.imwrite(file_path, frame):
            print(f"写真を保存しました: {file_path}")
            return file_path
        else:
            raise RuntimeError("保存に失敗しました")


    def release(self):
        self.cap.release()

if __name__ == "__main__":
    cam = CameraCapture(
        camera_index=1, 
        output_dir=r"C:\image"
    )
    cam.capture()
    cam.release()

上記は、ラズパイに接続されたカメラでキャプチャをとるコードです。

image_explain
import os
import openai
import base64

class ImageDescriber:
    def __init__(self, image_dir, api_key, image_ext=".jpg"):
        self.image_dir = image_dir
        self.api_key = api_key
        self.image_ext = image_ext
        openai.api_key = api_key

    def get_latest_image(self):
        files = [f for f in os.listdir(self.image_dir) if f.endswith(self.image_ext)]
        if not files:
            return None
        files = [os.path.join(self.image_dir, f) for f in files]
        latest_file = max(files, key=os.path.getmtime)
        return latest_file

    def describe(self, prompt="この画像を説明してください。"):
        image_path = self.get_latest_image()
        if not image_path:
            print("画像ファイルが見つかりませんでした。")
            return None

        with open(image_path, "rb") as img_file:
            b64_image = base64.b64encode(img_file.read()).decode('utf-8')
        response = openai.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_image}"}}
                    ]
                }
            ],
            max_tokens=1000
        )
        return response.choices[0].message.content

if __name__ == "__main__":
    from setting import openai_api_key

    image_dir = r"C:\image"
    describer = ImageDescriber(image_dir, openai_api_key)
    user_prompt = """ 
    この画像に写っている内容や状況を、日本語でMarkdown形式で詳しく説明してください。見出しや箇条書き、テーブルなども使ってください。
"""  
    result = describer.describe(prompt=user_prompt)
    if result:
        print("画像の説明:")
        print(result)

上記は画像で説明してもらうコードです。

人生初のMCPサーバーを起動!


今回はFastMCPで組んでいます。起動に成功すると上記のような表示が出ます。
サーバーの実行ログが出るところなどもFsatAPIの仕様に非常に近いので、使いやすいです。

応答結果

実際にMCPサーバーにアクセスし、応答結果を確認します。
夏なので。。上裸の私をキャプチャしてます苦笑が、実際にリモートでMCPサーバーにアクセスし、検証を実施しています。おぉなんかいい感じです。特段すごい技術というわけではないですが、応用範囲が広いので非常に興味深い結果です。

## 概要
写真は室内で撮影されており、男性がカメラに向かって静かに座っている様子が写されています。画面の左上にはエアコンが設置されており、背景にはカーテンが見えます。

## 推定される日付・時間帯
- **2025年8月8日頃(推定)**
  - 写真に表示されたタイムスタンプに基づきます。

## 詳細説明

### 場所・状況
- 室内での撮影と思われ、一般的な生活空間を背景にしています。
- エアコンの設置から、日本や他のアジアの国々で見られる典型的な住宅環境と推測されます。

### 人物
| 推定名前・識別 | 年齢 | 表情・動作 | 補足情報 |
| --- | --- | --- | --- |
| 男性 | 20代後半から30代前半 | 静かに座っている | メガネ着用、上半身裸 |

### 主要な物体・背景
- エアコンユニット
- 花柄のカーテン
- 窓からの自然光

### 色彩・雰囲気
- 全体的に落ち着いたトーン。
- 室内はやや暗いが、自然光がカーテンを通して柔らかく入り込んでいる。

## 補足情報
- エアコンの利用が一般的な夏の時期であることから、日本の夏に関連する環境と考えられます。
- 特に大きな文化的・歴史的背景は見当たりませんが、一般的な現代の生活空間の一部をとらえていると言えます。

まとめ

初のMCPサーバーを作成し、FastAPIのように簡単に作成できるところが非常に良いなと思いました。また、MCPサーバーは計算処理や検索機能だけでなく、IoTデバイスとしてつなげることで更に用途の幅が広がると思いました。今後は様々なIoTデバイスとの連携もしていきたいと思います。

Discussion

nnnwannnwa

構築に慣れてしまえばやれる幅も広くてとても良いですね!

SNGSNG

nnnwaさん、こんばんは!
なんでもいいのでとりあえず使えそうなツールやデバイスはMCPサーバーにしておくのが今後のエージェント時代に必要な気がしてます🔥