ラズパイ4 + Bedrockでホワイトボードを自動要約してみた
前に受付システムをラズパイで作った記事を書いたんですが、あれからラズパイで何かやる癖がついてしまいました。「社内の困りごと、ラズパイで解決できないか?」が口癖になりつつある。
で、次に目をつけたのが会議室のホワイトボード。
うちのオフィスでは会議の終わりにホワイトボードをスマホで撮って、Slackに投げる運用になっています。一応。ただ、実際は撮り忘れが頻発するし、撮ったとしても「これ何の議論だっけ?」が3日後にはわからなくなる。写真だけ残っても、文脈がないと意味がないんですよね。
じゃあ、ラズパイにカメラをつけて定期的に撮影して、AIに要約させたらどうか。受付システムではOpenAIを使いましたが、今回はAWS Bedrockを使ってみることにしました。会社のAWSアカウントがあるし、IAM管理のほうが APIキーの管理より楽なので。
構成
ざっくり言うと、こんな感じです。
ラズパイのカメラで定期撮影
→ 画像をS3にアップロード
→ Bedrock(Claude)のVision機能で内容を解析
→ 要約テキストをSlackに投稿
ハードウェアはこれだけ。
- Raspberry Pi 4(4GB): 前回の受付で使ったのとは別の1台。余ってた
- Raspberry Pi カメラモジュール V2: 800万画素。ホワイトボード撮るには十分
- カメラ用マウント: 100均の吸盤スマホホルダーを改造した。見た目はアレだけど固定できればOK
カメラモジュールが約4,000円なので、ラズパイ本体と合わせて1万円ちょっと。受付システムより安上がりです。
カメラのセットアップ
Raspberry Pi OS(Bookworm)では libcamera がデフォルトです。古い raspistill は使えないので注意。
# カメラが認識されているか確認
libcamera-hello --list-cameras
# テスト撮影
libcamera-still -o test.jpg --width 3280 --height 2464
ハマりポイント: 最初にハマったのが、カメラモジュールのフラットケーブルの向き。差し込む方向を間違えると何も映りません。検索しても「ケーブルの青い面をLANポート側に向ける」って書いてあるんですが、Pi 4のボードリビジョンによって微妙に違うっぽくて、結局3回抜き差しして正解を見つけました。
撮影の自動化
cronで5分おきに撮影するのが最初のアイデアでしたが、無人の会議室を延々と撮影しても意味がない。人感センサーとか付けようかとも思ったんですが、まずはシンプルに「会議の終了時刻に合わせて撮影する」方式にしました。Googleカレンダーの予定を見て、会議終了の5分前に撮影をトリガーします。
import subprocess
from datetime import datetime
from pathlib import Path
CAPTURE_DIR = Path("/home/pi/whiteboard/captures")
CAPTURE_DIR.mkdir(parents=True, exist_ok=True)
def capture_whiteboard():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = CAPTURE_DIR / f"wb_{timestamp}.jpg"
result = subprocess.run(
[
"libcamera-still",
"-o", str(filename),
"--width", "3280",
"--height", "2464",
"--shutter", "20000",
"--gain", "1.0",
"--awb", "fluorescent",
],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"撮影失敗: {result.stderr}")
return None
return filename
--awb fluorescent は蛍光灯下のホワイトバランス補正です。オフィスの照明で撮ると青っぽくなりがちなので、これを入れておくと文字が読みやすくなります。地味だけど大事。
Googleカレンダー連携
会議終了の5分前にカメラを起動するために、Googleカレンダーの予定を取得します。
pip install google-api-python-client google-auth-oauthlib
import os
from datetime import datetime, timedelta, timezone
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
CALENDAR_ID = os.getenv("MEETING_ROOM_CALENDAR_ID")
def get_upcoming_meeting_end():
creds = Credentials.from_authorized_user_file(
"/home/pi/whiteboard/credentials.json", SCOPES
)
service = build("calendar", "v3", credentials=creds)
now = datetime.now(timezone.utc)
events = service.events().list(
calendarId=CALENDAR_ID,
timeMin=now.isoformat(),
timeMax=(now + timedelta(hours=2)).isoformat(),
maxResults=5,
singleEvents=True,
orderBy="startTime",
).execute()
for event in events.get("items", []):
end_str = event["end"].get("dateTime")
if end_str:
end_time = datetime.fromisoformat(end_str)
trigger_time = end_time - timedelta(minutes=5)
if trigger_time > now:
return trigger_time, event.get("summary", "無題の会議")
return None, None
会議室ごとにGoogleカレンダーのリソースがあるので、そのカレンダーIDを指定します。これはGoogle Workspaceの管理者に聞かないとわからないかもしれない。僕は管理者だったので自分で確認できましたが。
S3へのアップロード
撮影した画像をS3に置きます。Bedrockに直接画像を投げることもできるんですが、後から参照したいときのためにS3に残しておきます。
pip install boto3
import boto3
from pathlib import Path
s3 = boto3.client("s3")
BUCKET_NAME = "whiteboard-captures-next"
def upload_to_s3(local_path: Path) -> str:
key = f"captures/{local_path.name}"
s3.upload_file(str(local_path), BUCKET_NAME, key)
return key
IAMの認証は ~/.aws/credentials に置くか、ラズパイにIAMロールを付与する方法がありますが、今回は credentials ファイルを使いました。IoT Coreを経由してロールを取得する方式のほうが本来はベターだけど、そこまでやると記事が終わらない。
Bedrockで画像解析
ここが本丸です。Bedrock の Claude にホワイトボードの画像を渡して、要約を生成してもらいます。
import boto3
import base64
import json
from pathlib import Path
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
def summarize_whiteboard(image_path: Path, meeting_title: str) -> str:
with open(image_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
response = bedrock.invoke_model(
modelId="anthropic.claude-sonnet-4-6-20250514",
contentType="application/json",
accept="application/json",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": image_data,
},
},
{
"type": "text",
"text": (
f"これは「{meeting_title}」という会議で使われた"
"ホワイトボードの写真です。\n"
"以下の形式で要約してください:\n"
"1. 議題(1行)\n"
"2. 主要な論点(箇条書き3-5個)\n"
"3. 決定事項(あれば)\n"
"4. TODO(あれば、担当者名付きで)\n\n"
"読み取れない文字は[不明]としてください。"
),
},
],
}
],
}),
)
result = json.loads(response["body"].read())
return result["content"][0]["text"]
最初は claude-3-sonnet を使っていたんですが、手書き文字の認識精度がイマイチだったので claude-sonnet-4-6 に変えました。手書きの崩した字を読み取る力がだいぶ違います。特に走り書きの日本語。
プロンプトで苦労した話
最初のプロンプトは「ホワイトボードの内容を要約してください」だけにしていたんですが、これだと出力のフォーマットがバラバラになる。あるときは箇条書き、あるときは文章、あるときはホワイトボードに書いてある文字をそのまま書き起こすだけ。
結局、出力フォーマットを明示的に指定して安定しました。あと meeting_title を渡しておくと、文脈を持った要約をしてくれるのが地味に便利です。カレンダーの会議名が「Q2プロダクト方針」とかだと、そのコンテキストを踏まえた解釈をしてくれる。
Slack投稿
要約テキストと元画像をSlackに投げます。
import requests
import os
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
SLACK_CHANNEL = os.getenv("SLACK_CHANNEL_ID")
def post_to_slack(summary: str, image_path: Path, meeting_title: str):
# まずテキストを投稿
header = f"*📋 {meeting_title}* のホワイトボード要約"
requests.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"},
json={
"channel": SLACK_CHANNEL,
"text": f"{header}\n\n{summary}",
},
)
# 画像もアップロード
with open(image_path, "rb") as f:
requests.post(
"https://slack.com/api/files.upload",
headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"},
data={
"channels": SLACK_CHANNEL,
"title": f"{meeting_title} - ホワイトボード",
},
files={"file": f},
)
受付システムのときはWebhookだけで済んでいたんですが、画像アップロードが入るとBot Tokenが必要になります。Slack Appの作成とスコープ設定(chat:write, files:write)が追加で要る。ここは少し面倒でした。
全体をつなげる
import time
import schedule
from datetime import datetime, timezone
from capture import capture_whiteboard
from calendar_check import get_upcoming_meeting_end
from s3_upload import upload_to_s3
from summarize import summarize_whiteboard
from slack_notify import post_to_slack
scheduled_meetings = set()
def check_and_schedule():
trigger_time, meeting_title = get_upcoming_meeting_end()
if trigger_time is None:
return
meeting_key = f"{trigger_time.isoformat()}_{meeting_title}"
if meeting_key in scheduled_meetings:
return
scheduled_meetings.add(meeting_key)
delay = (trigger_time - datetime.now(timezone.utc)).total_seconds()
if delay > 0:
print(f"{meeting_title}: {int(delay)}秒後に撮影予定")
schedule.every(int(delay)).seconds.do(
run_pipeline, meeting_title
).tag("one-shot")
def run_pipeline(meeting_title: str):
print(f"撮影開始: {meeting_title}")
image_path = capture_whiteboard()
if image_path is None:
print("撮影失敗、スキップ")
return schedule.CancelJob
upload_to_s3(image_path)
summary = summarize_whiteboard(image_path, meeting_title)
print(f"要約完了:\n{summary[:100]}...")
post_to_slack(summary, image_path, meeting_title)
print("Slack投稿完了")
return schedule.CancelJob
# 10分ごとにカレンダーをチェック
schedule.every(10).minutes.do(check_and_schedule)
print("ホワイトボード要約システム起動")
check_and_schedule()
while True:
schedule.run_pending()
time.sleep(1)
ハマったところ
画像がボケる
最初、ホワイトボードの文字がボケて読み取り精度が低かった。原因はオートフォーカスではなく、シャッタースピードが長すぎたこと。ラズパイカメラ V2 は固定フォーカスなので、距離が適切ならピントは問題ないんですが、暗い会議室だとシャッタースピードが自動で長くなって、手ブレ(というかラズパイの微振動)でボケる。
--shutter 20000(20ミリ秒)で固定して、代わりにゲインを上げるか照明をちゃんと点けてもらうことで解決しました。
Bedrockのリージョン問題
Claude のモデルは全リージョンで使えるわけじゃないです。us-east-1 か us-west-2 を使うのが無難。最初 ap-northeast-1 で試して ModelNotFound が返ってきて「え?」ってなりました。
東京リージョンでもClaude使えるようになってほしいですね。レイテンシ的には問題ないんですが、気持ちの問題として。
画像サイズの上限
Bedrock経由でClaudeに投げられる画像サイズには上限があります。3280×2464のフル解像度だとbase64エンコード後に数MBになって、リクエストサイズの上限に引っかかることがある。
from PIL import Image
def resize_if_needed(image_path: Path, max_pixels=1568*1568) -> Path:
img = Image.open(image_path)
w, h = img.size
if w * h > max_pixels:
ratio = (max_pixels / (w * h)) ** 0.5
new_size = (int(w * ratio), int(h * ratio))
img = img.resize(new_size, Image.LANCZOS)
resized_path = image_path.with_suffix(".resized.jpg")
img.save(resized_path, "JPEG", quality=85)
return resized_path
return image_path
1568×1568ピクセルくらいに収めておくとトークン消費も抑えられます。ホワイトボードの文字を読むには十分な解像度。
空のホワイトボードを要約しようとする
会議が終わった後にホワイトボードが消されていることがある。あるいは、そもそもホワイトボードを使わなかった会議。これを判定しないと、Claudeが「ホワイトボードは空白です」という要約を毎回Slackに投げることになる。
def is_whiteboard_empty(image_path: Path) -> bool:
response = bedrock.invoke_model(
modelId="anthropic.claude-sonnet-4-6-20250514",
contentType="application/json",
accept="application/json",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 10,
"messages": [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": base64.b64encode(
open(image_path, "rb").read()
).decode(),
},
},
{
"type": "text",
"text": "このホワイトボードに文字や図が書かれていますか?YESかNOだけで答えてください。",
},
],
}
],
}),
)
result = json.loads(response["body"].read())
answer = result["content"][0]["text"].strip().upper()
return "NO" in answer
API呼び出しが1回増えるのは気になるけど、max_tokens: 10 なのでコストはほぼゼロです。空のホワイトボードに対してフルの要約プロンプトを投げるよりは安い。
コスト感
1ヶ月運用してみた結果です。1日平均3〜4件の会議で使用。
- Bedrock(Claude Sonnet): 月800円くらい。画像解析は通常のテキストよりトークン消費が多いけど、1リクエストあたり数十円程度
- S3: 月10円以下。画像ファイルを保存しているだけなので
- 合計: 月1,000円未満
受付システムの月300円より高いけど、「会議の記録が自動で残る」価値を考えると十分ペイしていると思います。
精度の話
正直に言うと、手書き文字の認識精度は完璧ではないです。
- 丁寧に書かれた文字: 95%くらい正確に読める
- 走り書き: 70〜80%。人名や固有名詞が特に弱い
- 図やフロー: 構造は理解するけど、矢印の関係を完全に正しく解釈するとは限らない
ただ、要約としては十分使えるレベルです。完璧な議事録を求めるものではなく、「あの会議で何を話したっけ?」を思い出すきっかけがあれば目的達成なので。
所感
受付システムのときは「ChatGPTって受付に要るか?」と書きましたが、今回のホワイトボード要約は割と実用的だと感じています。写真を撮り忘れる問題と、撮っても後から見返さない問題を同時に解決できる。
BedrockのVision機能、思ったより手書き文字の認識が優秀でした。特に日本語の認識はClaudeが強い印象です。GPT-4oでも同じことはできると思いますが、AWS内で完結するのは運用面で楽。
次にやりたいこと
次はOCRの結果をNotionに自動で飛ばして、会議ノートと紐づけたい。あとカメラの設置位置をもっと工夫すれば斜めから撮っても歪み補正できるはず。OpenCVの射影変換で…とか考え始めると沼になりそうなので、ここらへんで止めておきます。
ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。
- 選考ではありません
- 履歴書不要
- 技術の話が中心
- 所要時間30分程度
- オンラインOK