はじめに
前回は、サーボでパンチルト機構を自作しようとして3D CADまで設計したものの、最終的に100均のスマホホルダーに方針転換し、カメラとディスプレイが物理的に動く状態まで組み上げました。今回はハードウェアが一通り動くようになった状態から見つかった、ソフトウェア側の問題を一気に潰していきます。
コードはGitHubで公開しています → Haradasn/baby-watcher
カメラの画角が勝手にズームされていた
組み上げたロボットを動かしてみたところ、「前はもっと広角だった気がする」という違和感がありました。調べてみると、Picamera2が1280x720の出力サイズから自動的に選ぶセンサーモードが、実はセンサー中央の約66%だけをクロップするモードだったことが判明しました。
cam.sensor_modes
# {'size': (1536, 864), ..., 'crop_limits': (784, 456, 3072, 1728)} ← センサー全域(4608x2592)の中央部分だけ
# {'size': (2304, 1296), ..., 'crop_limits': (16, 24, 4608, 2592)} ← センサー全域
rawの解像度を明示的にフルFOVのモード(2304x1296)に固定し、そこからISPに1280x720へ縮小させることで、意図した広角のまま撮影できるようになりました。
cam.configure(cam.create_preview_configuration(
main={"size": (1280, 720), "format": "RGB888"},
raw={"size": (2304, 1296)}, # フルFOVのモードを明示的に指定
))
Picamera2は解像度だけ指定すると「それっぽい」センサーモードを自動選択してくれますが、それが必ずしもフルFOVとは限らない、という学びでした。
カメラの向き(CAMERA_ROTATE)が迷走する
ハードウェアの組み直しのたびに、カメラの物理的な向きが変わってしまい、画像の上下左右がズレる問題にも何度も遭遇しました。ソフト側ではCAMERA_ROTATEという環境変数(0/90/180/270)でcv2.rotateによる補正をかけられるようにしてあるのですが、
- リボンケーブルの取り回しの都合でカメラを反転
- サーボのホーンを付け直した
といった、前回で触れたパンチルト機構側の泥臭い調整のたびにこの値がズレ、「真っ黒な画像になった」(単に手で覆っていただけ)、「上下逆」「横向き」など、都度実際に撮影して目で見ながら正しい値を探る作業を繰り返しました。
「首振り」を本当の追跡にする
もともとの実装は、動きを検知したら一度だけ首をそちらに振り、1秒待って正面に戻る、という単発の反応でした。これだと「動きを追いかけている」感じがしません。
そこで、動きが見え続けている間は首を振り続け、見失って一定時間(3秒)経ったら正面に戻って自律巡回を再開する、というtracking状態を持たせるように書き換えました。
if ratio >= MOTION_THRESHOLD and in_active_hours():
last_motion_time = now
if not tracking:
tracking = True
if now - last_track_move >= TRACK_UPDATE_INTERVAL:
neck.react_toward(dx, dy)
last_track_move = now
elif tracking and now - last_motion_time >= TRACK_LOSE_AFTER:
tracking = False
neck.center()
ここで一つハマったのが、首の目標角度を常に「固定の正面(90度)」を基準に計算していたことです。
# 旧: 固定の正面が基準
pan = PAN_CENTER - dx * strength
これだと、追いついて被写体が画面中央に来る(dxが0に近づく)たびに、計算結果がまた正面に戻ろうとしてしまい、「追いつく→戻る→追いつく→戻る」を繰り返すだけで、滑らかに追従する動きになりませんでした。「今向いている角度」を基準にする相対移動に直すことで解決しました。
# 新: 今向いている角度が基準
pan = _cur_pan - dx * pan_strength
下ばかり向いてしまう問題と顔検出の追加
継続追跡を入れたことで、今度は「カメラが低い位置(棚の上)にあるせいか、近くの人の手や胴体の動きに引っ張られて下ばかり向く」という新しい問題に気づきました。動きの重心をそのまま追いかけていると、人の顔より下の部分の動きに吸い寄せられてしまうのが原因でした。
「普通は顔を見て追いかけるはず」という指摘はもっともだったので、顔検出を追加することにしました。ところが、ここでOpenCVのバージョン問題にぶつかりました。
cv2.CascadeClassifier(...)
# AttributeError: module 'cv2' has no attribute 'CascadeClassifier'
今回使っているOpenCV 5.0では、おなじみのHaar検出器(CascadeClassifier)が廃止されていました。代わりに、OpenCV公式のモデル置き場(opencv_zoo)で配布されているYuNet(ONNX形式の軽量な顔検出モデル)を使うcv2.FaceDetectorYNに置き換えました。外部のモデルファイルを実行することになるので、ダウンロードして使う前に一度確認を挟んでいます。
もう一つハマったのが、デフォルトのスコア閾値(0.9程度)が実際のWebカメラ品質の画像には厳しすぎて、写っているはずの顔が全く検出されなかったことです。score_threshold=0.5まで緩めることで、安定して検出できるようになりました。
顔が見つかればそちらを優先し、見つからなければ従来通り動きの重心にフォールバックするようにしています。
チルトの向きが逆だった
顔検出を入れたら、むしろ前より下を向くようになってしまいました。「サーボの付け方が左右逆を想定してるのでは?」という指摘を受けて、実際にチルトの角度を振って目視確認したところ、まさにその通りで、チルトの角度と物理的な向きの対応が、パンとは逆になっていました。ホーンの付け直しやカメラの反転を繰り返すうちに、いつの間にか逆転していたようです。
# 修正前: dyが+(下で検知)なら tilt を下げる想定だったが、実機では逆
tilt = _cur_tilt - dy * tilt_strength
# 修正後
tilt = _cur_tilt + dy * tilt_strength
ソフトの計算式は合っているつもりでも、ハードウェアの組み方次第で符号が反転する、という典型例でした。
サーボの動きを滑らかにする
ここまでで追跡自体は機能するようになったものの、「目標角度に一気にジャンプして止まる」実装だったため、カクカクした動きでした。目標角度まで細かいステップに分割して少しずつ動かすようmove_toを書き換え、物理的な動きを滑らかにしました。
steps = max(1, int(max(abs(pan - start_pan), abs(tilt - start_tilt)) / STEP_DEG))
for i in range(1, steps + 1):
kit.servo[PAN_CH].angle = start_pan + (pan - start_pan) * i / steps
kit.servo[TILT_CH].angle = start_tilt + (tilt - start_tilt) * i / steps
time.sleep(STEP_DELAY)
ところが、これだけでは「正面に戻る動きだけが一番滑らかで、追跡中はまだカクつく」という状態が残りました。原因はCPU負荷でした。追跡中は0.1秒ごとに毎回顔検出(YuNet)を実行していたため、CPU使用率が170%を超えており、サーボを少しずつ動かすためのステップ間隔そのものがOSのスケジューリングでブレてしまっていたのです。重い顔検出は0.3秒ごとに間引いて結果をキャッシュし、サーボの更新自体は0.1秒ごとに細かく行う形に分離して解決しました。あわせて、検出結果(dx, dy)に指数移動平均のなましをかけ、フレームごとの細かいブレがそのまま震えとして伝わらないようにしています。
しゃべり方が「AIの実況」っぽい
初期のプロンプトは「画像の中の具体的な手がかりを最低1つ拾ってください」という指示があり、これが裏目に出て、「天井のライトが明るいね」「白い服を着てるパパが立っているよ」のような、人間なら普通言わない実況調のセリフを量産していました。実際にスマホに届いていた通知を見返すと、一目瞭然でした。
「黒い服だね」「白いニットの大人の人が、すぐ近くにいるよ」「白いお洋服がかわいいよ」と、ほぼ毎回誰かの服の色を実況しています。
「服の色や照明がついているかなんて、普通の大人は気にしない。気にするのは誰が来たか・何をしてくれているか」という方向でプロンプトを書き直したところ、今度は逆にお手本として与えた短い例文(5パターン)をなぞるだけになり、バリエーションが失われてしまいました。最終的に、「誰が来たか」「何をしているか」「場の空気」「さっきまでと変わったこと」といった、人間が自然に気づく観点のヒントを与えることで、自然な口調とバリエーションを両立できました。
| Before | After |
|---|---|
| 天井のライトが明るいね | パパだ、来てくれたね |
| 白い服を着てるパパが立っているよ | あやしてもらってるね、よかったね |
表情表示を可愛くする
GC9A01に表示する顔も、最初は白一色の目と口だけのシンプルなものでした。虹彩とハイライトを入れた目、ほっぺの赤み、表情ごとに変わる曲線の口元、そして背景色まで表情に合わせて変えることで、マスコットらしい見た目になりました。Claudeが返すemotionラベル5種類それぞれに、目の形・口の形・背景色を割り当てています。
| emotion | 目 | 口 | 背景色 |
|---|---|---|---|
| neutral | 虹彩+ハイライト(標準サイズ) | 小さい笑顔カーブ | クリーム |
| happy | ^の形の笑い目 | 大きい笑顔カーブ | 温かいピーチ |
| surprised | 虹彩+ハイライト(大きめ) | 丸い「O」 | 淡いイエロー |
| sleepy | 下向きの一本線 | 真横の線 | 淡いラベンダー |
| curious | 虹彩+ハイライト(少し大きめ) | 小さい笑顔カーブ | 淡いスカイブルー |
発話中はmouth_openを0.15秒おきにトグルして、簡易的な口パクリップシンクをさせています。
最終的なアーキテクチャ
①で最初に書いたアーキテクチャ図は「動き検知→Claude API→表情/音声/首振り/通知」という単純な一本道でしたが、実際には首を振る(追跡)処理とAIに判断を仰ぐ処理を、まったく違う頻度で別々に回す形に落ち着きました。
首振り(D)は反応速度とCPU負荷が重要なので0.1秒間隔のローカル処理だけで完結させ、コストと精度が重要なClaude APIへの問い合わせ(E)は20秒に1回だけ、という風に、同じ「動き検知」の結果を起点にしつつ両者を疎結合にしたのがポイントです。顔検出(C)を追跡専用に使い、Claude API側の画像判定とは別物にしているのも同じ理由です。
完成形
ここまでの修正を全部合わせると、実際にはこんな感じで動きます。
まとめ
今回は、カメラの画角クロップ問題、カメラ向きの再調整、首振りの継続追跡化、顔検出の導入(とOpenCV5でのAPI変更対応)、チルト符号の反転発見、サーボの滑らか化とCPU負荷対策、しゃべり方の自然さ調整、表情表示の見た目改善と、かなり盛りだくさんの一日になりました。
一つ一つは地味な修正の積み重ねですが、実機を使ったロボット工作は「コードが合っていても、ハードの組み方次第で前提がひっくり返る」ことの連続だと改めて実感しました。これでひとまず、見た目も中身も「赤ちゃんに語りかけるロボット」としての最低限の形になったと思います。
全5回を振り返ると、①で「ぬいぐるみに埋め込みたい」という最初のアイデアをSIDSリスクの観点で自分から諦め、据え置き型に倒した判断は、見た目の面白さより安全側を優先する、という以降すべての判断の土台になりました。Claude Codeとの壁打ちが一番効いたのは、④のUSB-C PD充電器や本記事のチルト符号反転のように、「配線もコードも合っているはずなのに動かない」という切り分けの場面です。一人で悩んでいたら見落としたままだった可能性のある仮説を、会話しながら一つずつ消していけたのは大きかったです。逆に、3D CADでのパンチルト自作のように「ゼロから最適な形を作る」工程は、AIと何度やり直しても詰めが甘く、最終的には100均の既製品に頼るという結論になりました。ラズパイもロボット工作もほぼ初めての状態からでも、Claudeと相談しながら一歩ずつ進めれば、ここまでは形になる。それが、育児の隙間時間でこの連載を書き続けてみて一番の実感です。
参考
- 本プロジェクトは個人のPoCであり、実際の乳児の安全監視を代替するものではありません
- 使用モデル: Claude Code / Sonnet系(ソフトウェア実装・デバッグ)
- YuNet顔検出モデル: opencv_zoo(OpenCV公式)


