はじめに
『Raspberry Pi』とは、低コストの小型コンピュータです。
フルスペックのLLMを動かすことは到底リソースが足りず、
通常は、クラウドやSaaS上で動作するAIを利用しなければ、AIを利用することはできません。
ですが、『1つの万能なAI(LLM)』を使う考えではなく、『得意なことに特化した小さなAIを組み合わせる』という設計思想に切り替えると、話が変わります。
本記事では、USBカメラ映像から『動きを検出』し、『英語で画像説明文を生成』し、『日本語に翻訳する』という3段階のパイプラインを、 オフライン(Offline) で Raspberry Pi のみで動かした実装をご紹介します。
なぜ『1つのLLM』では動かないのか?
メモリの壁
Raspberry Pi 4(4GB)でLLMを動かす場合、使えるRAMはシステム消費分を引いて実質2〜3GB程度です。
ですが、一般的な多言語/画像対応の大型LLMは、最低でも8〜16GBのVRAMを要求します。
『画像認識』と『日本語対応』の特徴を同時に持つモデルは、このメモリ量では動作困難です。
| モデル | AI種別 | 画像認識 | 日本語対応 | 必要メモリ目安 | Pi4 (4GB)で動作 | 備考 |
|---|---|---|---|---|---|---|
| Llama 3.2 Vision 11B (大型マルチモーダルLLM) | マルチモーダルLLM | ◯ | △ (限定的) | 10GB〜 | ✕ | |
| OpenCV | 画像処理ライブラリ | △ (動き検知など) | ✕ | 数百MB | ◯ | 本稿のStage1で利用 |
| moondream | VLM(視覚言語モデル) | ◯ | ✕ | 2〜4GB程度 | ◯ | 本稿のStage2で利用 |
| Qwen2.5:1.5b-instruct | SLM(テキストLLM) | ✕ | ◯ | 2GB程度 | ◯ | 本稿のStage3で利用 |
前提
ここで利用する機器や仕様は次のとおりです。
- Raspberry Pi : Raspberry Pi 4 4GB
- USBカメラ : logi HD 1080p
- GPU搭載有無 : GPUなし
- microSD : KIOXIA EXCERIA 256GB
システム構成
以下、システム構成となります。
| ステージ | 利用技術 | 入力 | 出力 | 説明 |
|---|---|---|---|---|
| CameraAI_Stage_1 | OpenCV (フレーム差分) | USBカメラ映像 | JPG + JSON | 動き検出後、5秒後のキャプチャを取得 |
| CameraAI_Stage_2 | moondream (Ollama) | JPG | JSON | 画像認識し、英語キャプションを出力 |
| CameraAI_Stage_3 | Qwen2.5:1.5b (Ollama) | JSON | JSON | 英語から日本語へ翻訳 |
セットアップ
ここからは、実際にRaspberry Pi上で動かすための準備手順を書きます。
Ollama のインストール
まず、モデルをローカルで動かすためにOllamaをインストールします。
これを利用することで、LLM/SLMなどのダウンロードや管理、インタラクティブのチャットツールの利用ができるようになります。
curl -fsSL https://ollama.com/install.sh | sh
モデルの取得
今回利用するmoondreamとQwen2.5:1.5b-instructをダウンロードします。
ollama pull moondream
ollama pull qwen2.5:1.5b-instruct
Python環境の準備
Python環境を整えるために、Conda(Miniforge)を導入し、そこに環境を構築します。
Conda(Miniforge)のインストール
##### 事前準備
sudo localedef -f UTF-8 -i ja_JP ja_JP
##### 1. Miniforge (linux-aarch64 版) のインストーラをダウンロード
cd /tmp
curl -LO https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-aarch64.sh
##### 2. インストーラを実行
bash Miniforge3-Linux-aarch64.sh
###### Proceed with initialization? [yes|no] -> yes
Conda環境設定の読み込み
##### ターミナルをいったん閉じて開き直すか、もしくは:
source ~/.bashrc
##### conda が使えるか確認
conda --version
###### conda 26.1.1などが表示されればOK
Python仮想環境の作成(cameraai環境)
##### 仮想環境 cameraai を作成
conda create -n cameraai python=3.11 -y
##### 環境を有効化
conda activate cameraai
##### 必要なパッケージをインストール
pip install opencv-python ollama
プログラムの配備
まず、作業用ディレクトリを作成し、今回のスクリプト群を配置します。
mkdir -p ~/WORKSPACE/cameraai
cd ~/WORKSPACE/cameraai
mkdir handoff logs
このディレクトリに、以下 4 つのファイルを作成します。
- cameraai_stage_1_motion_capture.py
- cameraai_stage_2_scene_caption.py
- cameraai_stage_3_translate_ja_qwen.py
- cameraai_run_all_stages.sh
以降では、それぞれの中身を記載します。
Stage1: OpenCVで動き検出+5秒後キャプチャ
動きがあった瞬間ではなく、5秒後の静止フレームを採用することで、ブレの少ない画像を VLM に渡せるようにしています。
# cameraai_stage_1_motion_capture.py
import cv2
import json
import os
import time
CAMERA_INDEX = int(os.getenv("CAMERAAI_CAMERA_INDEX", 0))
FRAME_WIDTH = int(os.getenv("CAMERAAI_FRAME_WIDTH", 640))
FRAME_HEIGHT = int(os.getenv("CAMERAAI_FRAME_HEIGHT", 480))
FPS = int(os.getenv("CAMERAAI_FPS", 15))
# 撮りすぎ防止のため、しきい値を少し厳しめに
MOTION_RATIO = float(os.getenv("CAMERAAI_MOTION_RATIO", 0.05)) # 0.02 → 0.05
DIFF_THRESH = int(os.getenv("CAMERAAI_DIFF_THRESH", 25))
# 「検出 → カウントダウン → 撮影」の待ち時間
CAPTURE_DELAY_SEC = float(os.getenv("CAMERAAI_CAPTURE_DELAY_SEC", 5.0))
COOLDOWN_SEC = float(os.getenv("CAMERAAI_EVENT_COOLDOWN_SEC", 8.0))
# 起動直後は 5 秒間ウォームアップし、その間は撮影しない
WARMUP_SEC = float(os.getenv("CAMERAAI_WARMUP_SEC", 5.0))
HANDOFF_DIR = "handoff"
STAGE1_JPG = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.jpg")
STAGE1_JSON = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.json")
os.makedirs(HANDOFF_DIR, exist_ok=True)
def handoff_busy() -> bool:
"""handoff/ にファイルが残っている間は、前回イベント処理中とみなす"""
return os.path.exists(STAGE1_JPG) or os.path.exists(STAGE1_JSON)
cap = cv2.VideoCapture(CAMERA_INDEX)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)
cap.set(cv2.CAP_PROP_FPS, FPS)
print(f"[INIT] warming up camera for {WARMUP_SEC} seconds...", flush=True)
warmup_start = time.time()
prev_gray = None
frame_count = 0
# ウォームアップ期間: 「通常状態」の学習とみなす
while time.time() - warmup_start < WARMUP_SEC:
ok, frame = cap.read()
if not ok:
time.sleep(0.1)
continue
frame_count += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
prev_gray = gray
print("[INIT] warmup finished. Start motion detection.", flush=True)
capture_scheduled_at = None
trigger_info = {}
idle_announced = False
while True:
ok, frame = cap.read()
if not ok:
time.sleep(0.1)
continue
frame_count += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
if prev_gray is None:
prev_gray = gray
continue
diff = cv2.absdiff(prev_gray, gray)
_, th = cv2.threshold(diff, DIFF_THRESH, 255, cv2.THRESH_BINARY)
motion_ratio = th.sum() / 255 / th.size
prev_gray = gray
# handoff が埋まっている間は何もしない
if handoff_busy():
if capture_scheduled_at is not None:
print("[INFO] handoff busy; cancel scheduled capture.", flush=True)
capture_scheduled_at = None
idle_announced = False
continue
# 完全 idle 状態のときだけ、新しいイベントを受付
if capture_scheduled_at is None and not idle_announced:
print("[STATE] pipeline idle; waiting for next motion event", flush=True)
idle_announced = True
# イベント検出: motion_ratio をログに出しつつ、しきい値以上で反応
if capture_scheduled_at is None and motion_ratio > MOTION_RATIO:
now = time.time()
capture_scheduled_at = now + CAPTURE_DELAY_SEC
trigger_info = {
"trigger_frame_count": frame_count,
"motion_ratio": round(motion_ratio, 6),
"detected_at": now,
}
print(
f"[EVENT] motion detected frame={frame_count} "
f"motion_ratio={motion_ratio:.6f} "
f"capture_in={CAPTURE_DELAY_SEC:.1f}s",
flush=True,
)
# ここでカウントダウンを開始(撮影前に必ずカウントダウン)
remaining = int(CAPTURE_DELAY_SEC)
while remaining > 0:
# 途中で handoff が埋まったらキャンセル
if handoff_busy():
print("[INFO] handoff became busy during countdown; cancel capture.", flush=True)
capture_scheduled_at = None
break
print(f"[COUNTDOWN] capture in {remaining} sec", flush=True)
time.sleep(1.0)
remaining -= 1
# handoff busy によるキャンセル時は次のフレームへ
if capture_scheduled_at is None:
continue
# 撮影タイミングになったら、現在のフレームを保存
if capture_scheduled_at is not None and time.time() >= capture_scheduled_at:
cv2.imwrite(STAGE1_JPG, frame)
meta = {
"frame_count": frame_count,
"capture_delay_sec": CAPTURE_DELAY_SEC,
**trigger_info,
}
with open(STAGE1_JSON, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False)
print(
f"[PRODUCE] delayed_capture frame={frame_count} "
f"(trigger={trigger_info.get('trigger_frame_count')} "
f"motion_ratio={trigger_info.get('motion_ratio')})",
flush=True,
)
capture_scheduled_at = None
idle_announced = False
# 撮りすぎ防止のクールダウン
time.sleep(COOLDOWN_SEC)
Stage2: moondreamで英語キャプション生成
handoff/ に jpg + json が置かれるのを待ち、moondream を使って英語キャプションを生成します。
# cameraai_stage_2_scene_caption.py
import json
import os
import time
import ollama
VLM_MODEL = os.getenv("CAMERAAI_VLM_MODEL", "moondream")
VLM_PROMPT = os.getenv(
"CAMERAAI_VLM_PROMPT",
"Describe"
)
POLL_SEC = float(os.getenv("CAMERAAI_STAGE2_POLL_SEC", 0.2))
HANDOFF_DIR = "handoff"
STAGE1_JPG = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.jpg")
STAGE1_JSON = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.json")
STAGE2_JSON = os.path.join(HANDOFF_DIR, "cameraai_stage_2_scene_caption.json")
while True:
# 入力がそろうまで待機
if not (os.path.exists(STAGE1_JPG) and os.path.exists(STAGE1_JSON)):
time.sleep(POLL_SEC)
continue
# まだ前回の出力が残っている場合は待機
if os.path.exists(STAGE2_JSON):
time.sleep(POLL_SEC)
continue
with open(STAGE1_JSON, encoding="utf-8") as f:
meta = json.load(f)
print(f"[CAPTION] start image={os.path.basename(STAGE1_JPG)}", flush=True)
t0 = time.time()
with open(STAGE1_JPG, "rb") as img_f:
img_bytes = img_f.read()
res = ollama.chat(
model=VLM_MODEL,
messages=[{
"role": "user",
"content": VLM_PROMPT,
"images": [img_bytes],
}],
)
caption_en = res["message"]["content"].strip()
elapsed = round(time.time() - t0, 2)
out = {
**meta,
"caption_en": caption_en,
"caption_elapsed_sec": elapsed,
"model": VLM_MODEL,
"prompt": VLM_PROMPT,
}
with open(STAGE2_JSON, "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False)
print(f"[CAPTION] done elapsed={elapsed}s", flush=True)
print(f"[CAPTION] EN: {caption_en}", flush=True)
Stage3: Qwen2.5で英語→日本語翻訳
Stage2 が出力した英語キャプションを、Qwen2.5 で日本語 1 文に翻訳し、logs/ に追記していきます。
# cameraai_stage_3_translate_ja_qwen.py
import json
import os
import re
import time
import ollama
TRANSLATE_MODEL = os.getenv("CAMERAAI_TRANSLATE_MODEL", "qwen2.5:1.5b-instruct")
TEMPERATURE = float(os.getenv("CAMERAAI_TRANSLATE_TEMPERATURE", 0))
POLL_SEC = float(os.getenv("CAMERAAI_STAGE3_POLL_SEC", 0.2))
HANDOFF_DIR = "handoff"
STAGE1_JPG = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.jpg")
STAGE1_JSON = os.path.join(HANDOFF_DIR, "cameraai_stage_1_motion_capture.json")
STAGE2_JSON = os.path.join(HANDOFF_DIR, "cameraai_stage_2_scene_caption.json")
LOG_FILE = os.path.join("logs", "cameraai_results_qwen.jsonl")
os.makedirs("logs", exist_ok=True)
PROMPT_TMPL = """\
You are a careful bilingual translation assistant.
Translate the following English image caption into natural Japanese.
Rules:
- Keep it to one Japanese sentence.
- Do not add new information.
- Do not remove objects or relations mentioned in the English caption.
- If the English sentence is long, compress wording but keep all objects.
- Return valid JSON only.
JSON schema:
{{"caption_ja": "natural Japanese translation"}}
English caption:
"{caption_en}"
"""
def extract_json_object(text: str):
m = re.search(r"\{.*\}", text, re.DOTALL)
return json.loads(m.group()) if m else {}
while True:
if not os.path.exists(STAGE2_JSON):
time.sleep(POLL_SEC)
continue
with open(STAGE2_JSON, encoding="utf-8") as f:
src = json.load(f)
caption_en = src["caption_en"]
print(f"[TRANSLATE] start model={TRANSLATE_MODEL}", flush=True)
t0 = time.time()
res = ollama.chat(
model=TRANSLATE_MODEL,
messages=[{
"role": "user",
"content": PROMPT_TMPL.format(caption_en=caption_en),
}],
options={"temperature": TEMPERATURE},
)
raw = res["message"]["content"].strip()
parsed = extract_json_object(raw)
caption_ja = parsed.get("caption_ja", raw)
elapsed = round(time.time() - t0, 2)
result = {
**src,
"caption_ja": caption_ja,
"translate_elapsed_sec": elapsed,
"translate_model": TRANSLATE_MODEL,
"translated_at": time.time(),
}
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(result, ensure_ascii=False) + "\n")
print(f"[TRANSLATE] done elapsed={elapsed}s", flush=True)
print(f"[RESULT] EN: {caption_en}", flush=True)
print(f"[RESULT] JA: {caption_ja}", flush=True)
# handoff を空にして Stage1 に「処理完了」を伝える
for path in [STAGE1_JPG, STAGE1_JSON, STAGE2_JSON]:
if os.path.exists(path):
os.remove(path)
print("[ACK] cleared handoff files", flush=True)
一括起動スクリプト
3つのStageをまとめて立ち上げるシェルスクリプトです。
#!/bin/bash
set -e
mkdir -p handoff logs
python -u cameraai_stage_1_motion_capture.py &
PID1=$!
python -u cameraai_stage_2_scene_caption.py &
PID2=$!
python -u cameraai_stage_3_translate_ja_qwen.py &
PID3=$!
trap "kill $PID1 $PID2 $PID3 2>/dev/null" EXIT INT TERM
wait
実行
実行方法
conda activate cameraai
cd ~/WORKSPACE/cameraai
# 前回の handoff を消してから起動
rm -f handoff/*.jpg handoff/*.json
bash cameraai_run_all_stages.sh
実行結果の確認
カメラの前で動きを見せると、
Stage1 が 5 秒後のフレームを handoff/ に保存
Stage2 が moondream で英語キャプションを生成
Stage3 が Qwen2.5 で日本語に翻訳し、logs/cameraai_results_qwen.jsonl に 1 行 JSON を追記
という流れでパイプラインが回ります。
(cameraai) hiro_to_int@pi4:~/WORKSPACE/cameraai $ bash cameraai_run_all_stages.sh
[INIT] warming up camera for 5.0 seconds...
[INIT] warmup finished. Start motion detection.
[STATE] pipeline idle; waiting for next motion event
[EVENT] motion detected frame=107 motion_ratio=0.177288 capture_in=5.0s
[COUNTDOWN] capture in 5 sec
[COUNTDOWN] capture in 4 sec
[COUNTDOWN] capture in 3 sec
[COUNTDOWN] capture in 2 sec
[COUNTDOWN] capture in 1 sec
[PRODUCE] delayed_capture frame=107 (trigger=107 motion_ratio=0.177288)
[CAPTION] start image=cameraai_stage_1_motion_capture.jpg
[CAPTION] done elapsed=210.31s
[CAPTION] EN: The image features a pink stuffed animal, likely a Pokemon character or toy, lying on its back on top of a bed. The plush toy is positioned in such a way that it appears to be resting comfortably and facing the camera directly. The bed occupies most of the background, showcasing the main subject of the photo - the adorable Pokemon plushie.
[TRANSLATE] start model=qwen2.5:1.5b-instruct
[TRANSLATE] done elapsed=46.39s
[RESULT] EN: The image features a pink stuffed animal, likely a Pokemon character or toy, lying on its back on top of a bed. The plush toy is positioned in such a way that it appears to be resting comfortably and facing the camera directly. The bed occupies most of the background, showcasing the main subject of the photo - the adorable Pokemon plushie.
[RESULT] JA: ピンクのぬいぐるみが、 likely ポケモンキャラクターまたは玩具で、ベッドの上に寝転がっています。このぬいぐるとカメラに向かって直接見つめています。背景には主な被写体であるポケモンぬいぐりが占めるスペースが広く、ベッドは全体的に占めており、その中にいるポケモンぬいぐりがゆっくりと寝転がっています。
[ACK] cleared handoff files
[STATE] pipeline idle; waiting for next motion event
{
"frame_count": 107,
"capture_delay_sec": 5.0,
"trigger_frame_count": 107,
"motion_ratio": 0.177288,
"detected_at": 1776545852.8470438,
"caption_en": "The image features a pink stuffed animal, likely a Pokemon character or toy, lying on its back on top of a bed. The plush toy is positioned in such a way that it appears to be resting comfortably and facing the camera directly. The bed occupies most of the background, showcasing the main subject of the photo - the adorable Pokemon plushie.",
"caption_elapsed_sec": 210.31,
"model": "moondream",
"prompt": "Describe",
"caption_ja": "ピンクのぬいぐるみが、 likely ポケモンキャラクターまたは玩具で、ベッドの上に寝転がっています。このぬいぐるとカメラに向かって直接見つめています。背景には主な被写体であるポケモンぬいぐりが占めるスペースが広く、ベッドは全体的に占めており、その中にいるポケモンぬいぐりがゆっくりと寝転がっています。",
"translate_elapsed_sec": 46.39,
"translate_model": "qwen2.5: 1.5b-instruct",
"translated_at": 1776546114.9013596
}
各ステージのポイント
Stage1: 動き検出+5秒後キャプチャ
Stage1 は「撮りすぎない」「ブレたフレームを渡さない」ことを重視しています。
- 起動直後 5 秒間はウォームアップとして、撮影は一切しない
- フレーム差分から計算した
motion_ratioが 0.05 を超えたときだけイベント扱い - 動きを検出したらすぐには撮らず、ログでカウントダウンしながら 5 秒待つ
- カウントダウン中に handoff が埋まったら、そのイベントはキャンセルして次を待つ
これにより、「起動時のノイズで大量に撮れる」「少し手を動かしただけで連写になる」といった挙動を避けています。
Stage2: moondream で英語キャプション生成
Stage2 は、画像から説明文を作る VLM (Vision Language Model) のステージです。
- 入力は Stage1 が保存した
JPG + JSON - 出力は
caption_enと処理時間caption_elapsed_sec - 処理時間を考慮し、プロンプトは
"Describe"のようなシンプルなものを採用している。
Stage3: Qwen2.5 で英語→日本語翻訳
Stage3 は、英語キャプションを日本語 1〜2 文に翻訳するステージです。
- Qwen2.5 に渡すプロンプトで、「要約ではなく翻訳」「オブジェクトを削らない」ことを明示
- 英語キャプションをそのまま埋め込むのではなく、
English caption: "{caption_en}"のように
ダブルクォートで 1 つの文字列として渡すことで、長文でも途中で切れずに翻訳されるようになりました。 - Qwen の出力は JSON で返させていますが、取り出しているのは
caption_jaだけです。
caption_enも一緒に生成させてしまうと、元の英語キャプションを「要約」したり書き換えてしまうことがあったため、
英語は 必ず Stage2 で生成したものだけを使うこととしています。
実際、この "{caption_en}" に変える前は、カンマ以降の情報が無視されてしまうことがあり、短い訳しか返ってこない場面がありました。
プロンプト側で「文字列の境界」を明確にしてあげることが、地味に効いたポイントです。
今後の発展アイデア
今回は、あくまで「Raspberry Pi 4 (4GB) だけでどこまでできるか」という制約つきの構成でしたが、
ここから先の発展として、次のような方向性が考えられます。
-
Stage2 の高速化
- 入力画像を 1280x960 ではなく 512px 四方程度に縮小してから moondream に渡す
- それでも遅い場合は、Stage2/3 だけを GPU 付き PC にオフロードし、Pi は Stage1 専任にする
-
翻訳品質のチューニング
- 「2 文まで OK」にして、読みやすさを優先する
- 「です・ます調」「だ・である調」のどちらで話すかをプロンプトで固定する
- Stage3の翻訳は『Argos Translate』の利用も検討した方が良いかと思われる
-
応用例
- 監視カメラ代わりに、「人が通ったときだけ日本語で説明ログを残す」
- 工場やガレージで、「特定の場所に物が置かれたときだけログを取る」
- 視覚に課題がある人向けに、「手元のモノをかざすと日本語で説明してくれる」端末として使う
まとめ
本記事では、Raspberry Pi 4 (4GB) という小さなマシン上で、
- Stage1: OpenCV による動き検出と「5秒後」の静止フレーム取得
- Stage2: moondream による画像 → 英語キャプション生成
- Stage3: Qwen2.5:1.5b による英語 → 日本語翻訳
という 3 段構成のパイプラインを構築し、USB カメラの映像を「日本語の説明文」に変換するところまで実装しました。
ここで重要だったのは、
- フルスペックのマルチモーダル LLM はメモリ的に載らない → 1 台に「全部入り」を使わない
- 代わりに、OpenCV / VLM / SLM それぞれの 得意分野に処理を分割 する
といった設計上の工夫でした。
Raspberry Pi のようなリソース制約の厳しい環境でも、
- 古典的な画像処理(OpenCV)
- 軽量 VLM(moondream)
- 小さめの言語モデル(Qwen2.5:1.5b)
をうまく組み合わせることで、「巨大な LLM がなければできないと思っていた処理」も、
十分に現実的な形で動かせることが分かったと思います。
エッジでのAI利用でも組み合わせや用途によっては、十分に活用できる可能性がありますので、それぞれの利用シーンに応じた検討を実施して行って頂けたらと思います。
参考リンク
- Moondream 公式サイト: https://moondream.ai
- Moondream GitHub: https://github.com/vikhyat/moondream
- Qwen2.5-1.5B-Instruct (Hugging Face 公式モデルカード): https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct
- Qwen2.5 (Ollama ライブラリ): https://ollama.com/library/qwen2.5
- Ollama 公式サイト: https://ollama.com
