1
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?

愛ハムスターに対する「かわいい」を一生涯カウントしてみた

Last updated at Posted at 2025-12-21

ハムスターの「かわいい」を一生涯カウントしてみた

〜JOJOの「パン何枚」になぞって、“かわいい”を数える実験〜

「お前は今までに食ったパンの枚数を覚えているのか?」
— JOJOより

「自分は今までに、愛ハムに“かわいい”って何回言ったんだろう?」

めっちゃ言うからなー🐹

それが“見えたら”面白いかも知らんな

ということで、「かわいいを数えて可視化するガジェット」を作ってみました!

251221180640201.JPG
全景


背景:ハム旅(ハムタビ)とつなげたい

私は「ハム旅(ハムタビ)」という、ハムスターの行動や日常ログを集めて“旅”として可視化するアプリを作っています。これと連動できたらおもしろそうだなって思っています。
ハム旅コンセプト(Note記事)


今回実現したこと(要件)

  • ケージ前に人が来たら検知(=1生涯で何時間ハムスターのお世話をしたか?の起点としても活用)
  • その間の音声から「かわいい」を自動検出してカウント
  • 累計を保存し続ける(かわいいといった回数/お世話した時間)
  • 1かわいい = 1LED として物理メーター化
  • MAX到達でレインボー演出(祝福)

ガジェットの挙動

1.ケージ前に座る
2.ハムスターと戯れる
3.うっかり発した言葉に「かわいい」が含まれていたらカウント
4.LEDが1個増える
5.LEDが全部埋まったら 虹🌈
6.ケージ前にいた時間が **「お世話時間」**として累計される


構成

ハード

  • Raspberry Pi 4 Model B
  • USBマイク(無駄に、HyperX QuadCast)
  • PIR人感センサー(HC-SR501)
  • WS2812B LEDテープ

ソフト

  • Python3
  • Vosk(ローカル音声認識)
  • gpiozero(PIR制御用)
  • spidev(SPIでNeoPixel駆動)


「かわいい」の認識は、Vosk を利用!

「かわいい」をどうやって自動判定するか、がこのガジェットの肝です。

今回は Vosk というオフライン音声認識ライブラリを使い、
マイクで録音した音声をその場で文字起こしし、
そこから「かわいい」を拾う、という構成にしました。

Vosk を採用した理由

理由はシンプルで

  • 日本語対応
  • Raspberry Pi で動く
  • オフラインで完結する
  • Python から扱いやすい
    「精度最優先」ではなく、常時動かしても破綻しないことを重視しました。

※ 精度を上げすぎるとモデルが重くなり、常時動作でつらくなったり、
「かわいい」以外の誤検出対策(辞書・フィルタ)が増えて運用が面倒になるので、今回はこのくらいがちょうど良いと判断しました。

ざっくりした使い方

  1. arecord で数秒だけ音声を録音
  2. WAV ファイルを Vosk に渡す
  3. 認識結果(テキスト)を受け取る
  4. テキストから「かわいい」を数える
    Vosk は音声ストリームをそのまま扱えるので、長時間の音声にも対応できますが、
    今回は 短い音声を小刻みに処理する方式にしています。

文字に起こしたものから検索

Vosk で文字起こしした結果は、最終的に ただの文字列として扱えるので、
そこから「かわいい系ワード」だけを拾いました。

import re

def count_kawaii(text: str) -> int:
    pattern = r"(かわいい|可愛い|カワイイ|かわいー|かわええ)"
    return len(re.findall(pattern, text))

表記ゆれ(ひらがな/カタカナ/漢字)を吸収
精度を上げすぎない(多少の誤認識は許容)
「完璧に聞き取る」より「それっぽく拾えればOKだぜ!」という割り切りです。

運用上のポイント

実際に動かしてみて、重要だった点をまとめます。
常時録音しないことにした
→ 人感センサーで「ケージ前に人がいるとき」だけ処理

長文を一気に認識しない
→ 数秒単位で区切って処理

認識結果は信じすぎない
→ 正規表現で雑に拾う

音声認識は「精度」より「雰囲気」が大事だと感じました。

「かわいい」を物理メーターにする(LED)

次に、可愛いを可視化します。
WS2812BというLEDテープを使った物理メーターとして表現しました。
「かわいい」が1回増えるとLEDが1個増えるだけです
今回は 1:1 にしましたが、あとからレートを変えられるように定数にしたりしました。
テープのハウジングは3Dプリンタでつくりました。
251221180911821.JPG
※ WS2812B(NeoPixel)は定番ライブラリが素直に動きませんでした。今回は最終的に SPI 経由で駆動する形に落ち着きました。このあたりはハマりどころが多いので、別記事にするかもしれません。

「お世話時間」の累計

RIPセンサーでケージの前に人が来たことを検知しているので、
「一生涯お世話した時間」も一緒に記録します。

考え方

  • ケージ前に人が来たら「滞在開始」
  • いなくなったら「滞在終了」
  • あまりに短い滞在は無視する
  • 有効だった滞在時間を累計する
    「お世話=ケージの前にちゃんといた時間」とした感じです。
    滞在が終わったら、まとめて加算する
    ※ PIRは人の微妙な動きで 0/1 が揺れるので、秒単位で正確な滞在時間は取りにくいです。
    今回は「だいたい一緒にいた時間が分かればOK」という割り切りで、短すぎる滞在を除外する形にしました。
    251221180918996.JPG

滞在が終了したタイミングで、その時間をまとめて累計します。

if duration >= MIN_PRESENCE_SEC:
    total_presence_sec += duration

1秒ごとに足すのではなく、「1回の滞在=1まとまり」として扱っています。
電源を切っても消えないように、「かわいい」の回数と一緒に JSON で保存しています。

{
  "total_kawaii": 123,
  "total_presence_sec": 4567.8
}

起動時にこれを読み込み、前回の続きから再開する仕掛けです。
ハムスターの一生涯は短い(2年くらい)ですが、正確に測る必要はなく、だいたいわかればいいかなーって感じです。
Console.png

おわりに

「かわいい」と「一緒にいた時間」が残ると、ハムスターとの生活がちょっとだけ“記録”になります。

このログ、いずれ ハム旅(ハムタビ) 側にも連動させて、行動ログと一緒に眺められるようにしたいです。
続きも作ったらまた書きます🐹


付録:完成版ソースコード(折りたたみ)

※ コピペして動かしたい方向けに、完成版 hamkawa_auto2.py を載せます。

hamkawa_auto.py(クリックで展開)

#!/usr/bin/env python3
"""
hamkawa_auto.py
- PIR(人感)で「ケージ前に人がいる時だけ」録音・認識する
- Vosk(オフライン音声認識)でテキスト化し、「かわいい」をカウント
- 1かわいい=1LEDとしてWS2812B(NeoPixel)をSPI駆動で積み上げ表示
- ついでに「お世話時間(滞在時間)」も累計してJSONに保存

調整したくなったらまずここ:
- GPIOピン番号: DigitalInputDevice(17)
- 録音デバイス: CARD_DEVICE
- 「録音の間隔」: RECORD_INTERVAL
- 「滞在判定のゆるさ」: MIN_PRESENCE_SEC / PRESENCE_TIMEOUT
- LED本数 & レート: LED_COUNT / KAWAII_PER_LED
"""

import time
import wave
import json
import re
import subprocess
import os

import spidev
from vosk import Model, KaldiRecognizer
from gpiozero import DigitalInputDevice

# ======================
# 基本設定
# ======================

# Vosk日本語モデルのパス(事前にダウンロード&展開しておく)
MODEL_PATH = "models/vosk-model-small-ja-0.22"

# arecord に渡す録音デバイス指定
# `arecord -l` でカード番号を確認して、環境に合わせて変更する
CARD_DEVICE = "plughw:3,0"

# 一時録音ファイル(短いチャンクを繰り返し録る)
WAV_PATH = "hamkawa_chunk.wav"

# 累計保存(電源OFFでも消えないようにね)
STATS_PATH = "hamkawa_stats.json"

# 録音チャンクの長さ(秒)
# 長いほど認識が安定しやすいが、レスポンスは遅くなる。。
RECORD_SECONDS = 3

# 録音を繰り返す間隔(秒)
# ここを短くすると反応は良いがCPU/電力/保存負荷が増える。。
RECORD_INTERVAL = 5.0

# 「お世話時間」にカウントする最小滞在時間(秒)
# 通りすがりなどを弾く目的
MIN_PRESENCE_SEC = 5.0

# PIRが0になっても、少しだけ猶予を持って「滞在継続」とみなす(秒)
# PIRは微妙に揺れるので、これがないと滞在がブツ切れになりがちなの
PRESENCE_TIMEOUT = 10.0

# ======================
# LED(SPI NeoPixel)設定
# ======================

# LEDテープのLED数(好きな数に切れるタイプのテープでした)
LED_COUNT = 10

# 「何かわいいで1LED増えるか」
# 例: 1 -> 1回で1LED / 5 -> 5回で1LED
KAWAII_PER_LED = 1

# SPI設定(Raspberry PiのSPI0を使う想定)
SPI_BUS = 0
SPI_DEV = 0

# SPIクロック(WS2812Bの擬似波形を作るための速度)
# 速すぎ/遅すぎで不安定になることがあるので、動作実績のある値を使うのが吉
SPI_HZ = 2400000

# MAX演出(LEDが満タンになった瞬間にだけレインボー)
RAINBOW_SECONDS = 3.0
RAINBOW_FPS = 25

# ======================
# LED 制御(SPI)
# ======================

def _encode_byte(b: int):
    """
    WS2812B は「一定のタイミングの波形」を期待するが、SPIで疑似的に作る。
    1bitを3bitに展開し、0/1を波形っぽく表現する方式。

    - 1 -> 110
    - 0 -> 100

    これをビット列にしてSPIで流すことで、NeoPixelを駆動する。
    """
    out = []
    for bit in range(7, -1, -1):
        out.append(0b110 if (b >> bit) & 1 else 0b100)

    # 3bit単位のデータを8bitに詰め直す
    packed = 0
    nbits = 0
    res = []
    for v in out:
        packed = (packed << 3) | v
        nbits += 3
        while nbits >= 8:
            shift = nbits - 8
            res.append((packed >> shift) & 0xFF)
            nbits -= 8
            packed &= (1 << nbits) - 1 if nbits else 0
    if nbits > 0:
        res.append((packed << (8 - nbits)) & 0xFF)
    return res


def _hsv_to_rgb(h, s, v):
    """レインボー演出用:HSV→RGB変換(簡易)"""
    i = int(h * 6.0)
    f = (h * 6.0) - i
    p = v * (1.0 - s)
    q = v * (1.0 - f * s)
    t = v * (1.0 - (1.0 - f) * s)
    i = i % 6
    if i == 0:
        r, g, b = v, t, p
    elif i == 1:
        r, g, b = q, v, p
    elif i == 2:
        r, g, b = p, v, t
    elif i == 3:
        r, g, b = p, q, v
    elif i == 4:
        r, g, b = t, p, v
    else:
        r, g, b = v, p, q
    return int(r * 255), int(g * 255), int(b * 255)


class KawaiiLED:
    """
    LED表示専用クラス
    - update_by_kawaii(): 累計かわいい数から点灯数を決めて表示
    - rainbow_fx(): MAX到達演出
    """

    def __init__(self, led_count):
        self.led_count = led_count
        self.spi = spidev.SpiDev()
        self.spi.open(SPI_BUS, SPI_DEV)
        self.spi.max_speed_hz = SPI_HZ
        self.spi.mode = 0
        self._max_fx_last = False  # 前回MAXだったか(MAX到達した瞬間だけ演出するため)

    def show(self, pixels_grb):
        """
        WS2812BはGRB順なので (g, r, b) で受け取る。
        SPIに流すために疑似波形に変換して送る。
        """
        buf = []
        for (g, r, b) in pixels_grb:
            buf.extend(_encode_byte(g))
            buf.extend(_encode_byte(r))
            buf.extend(_encode_byte(b))
        self.spi.xfer2(buf)
        time.sleep(0.001)  # ほんの少し待って安定させる

    def clear(self):
        """消灯(終了時や例外時に呼ぶと安全)"""
        self.show([(0, 0, 0)] * self.led_count)

    def update_by_kawaii(self, total_kawaii):
        """
        累計かわいい数 → 点灯数に変換して表示
        """
        # 1LEDあたりのレートを反映して点灯数を決める
        leds_on = min(self.led_count, total_kawaii // KAWAII_PER_LED)

        # MAX到達「した瞬間」だけ演出(ずっと虹だと通常表示に戻れないので)
        is_max = (leds_on >= self.led_count)
        if is_max and not self._max_fx_last:
            self.rainbow_fx(RAINBOW_SECONDS)
        self._max_fx_last = is_max

        # 通常表示(緑の積み上げ)
        pixels = []
        for i in range(self.led_count):
            if i < leds_on:
                pixels.append((80, 0, 0))  # 緑(GRB)
            else:
                pixels.append((0, 0, 0))
        self.show(pixels)

    def rainbow_fx(self, seconds=3.0):
        """全LEDを虹で流す演出(祝福)"""
        start = time.time()
        frame = 0
        while time.time() - start < seconds:
            pixels = []
            for i in range(self.led_count):
                h = ((i / self.led_count) + (frame / (self.led_count * 3.0))) % 1.0
                r, g, b = _hsv_to_rgb(h, 1.0, 0.4)  # v=0.4で眩しすぎ防止
                pixels.append((g, r, b))  # GRB
            self.show(pixels)
            frame += 1
            time.sleep(1.0 / RAINBOW_FPS)

    def close(self):
        """後始末:消灯してSPIクローズ"""
        self.clear()
        self.spi.close()


# ======================
# 音声関連
# ======================

def count_kawaii(text: str) -> int:
    """
    認識結果テキストから「かわいい系ワード」を数える。
    音声認識は表記ゆれが起きやすいので、正規表現でざっくり拾う。
    """
    pattern = r"(かわいい|可愛い|カワイイ|かわいー|かわええ)"
    return len(re.findall(pattern, text))


def record_chunk():
    """
    arecordで短時間録音(モノラル16kHz / 16bit)
    Vosk(KaldiRecognizer)は16kHzが扱いやすいのでこの設定にしている。

    check=True にしているので録音に失敗すると例外で落ちる(デバッグしやすい)
    """
    subprocess.run(
        [
            "arecord",
            "-D", CARD_DEVICE,
            "-d", str(RECORD_SECONDS),
            "-f", "S16_LE",
            "-r", "16000",
            "-c", "1",
            WAV_PATH,
        ],
        check=True,
    )


def transcribe_wav(path, model):
    """
    WAVファイルをVoskで文字起こし。
    - AcceptWaveform() が True になるタイミングで部分結果が取れる
    - 最後に FinalResult() も必ず回収する
    """
    wf = wave.open(path, "rb")
    rec = KaldiRecognizer(model, wf.getframerate())
    texts = []

    while True:
        data = wf.readframes(4000)
        if len(data) == 0:
            break
        if rec.AcceptWaveform(data):
            res = json.loads(rec.Result())
            if res.get("text"):
                texts.append(res["text"])

    res = json.loads(rec.FinalResult())
    if res.get("text"):
        texts.append(res["text"])

    return " ".join(texts)


# ======================
# 累計保存
# ======================

def load_stats():
    """
    累計値をJSONからロード。
    ファイルがなければ初期値(0,0.0)
    """
    if not os.path.exists(STATS_PATH):
        return 0, 0.0
    with open(STATS_PATH, "r", encoding="utf-8") as f:
        d = json.load(f)
    return d.get("total_kawaii", 0), d.get("total_presence_sec", 0.0)


def save_stats(kawaii, presence):
    """
    累計値をJSONへ保存(indent=2で人が読める)
    """
    with open(STATS_PATH, "w", encoding="utf-8") as f:
        json.dump(
            {"total_kawaii": kawaii, "total_presence_sec": presence},
            f,
            ensure_ascii=False,
            indent=2,
        )


# ======================
# メイン
# ======================

def main():
    print("🧠 モデル読み込み中...")
    model = Model(MODEL_PATH)  # 初回はここが重い(モデルロード)

    # 前回までの累計をロード
    total_kawaii, total_presence_sec = load_stats()
    print(f"📂 累計: かわいい {total_kawaii} / お世話 {total_presence_sec:.1f}s")

    # LED初期表示(起動直後に現在値が見える)
    led = KawaiiLED(LED_COUNT)
    led.update_by_kawaii(total_kawaii)

    # PIR入力(GPIO17)
    # 配線を変えたらここを変える
    pir = DigitalInputDevice(17)

    # 滞在状態管理
    presence_active = False
    presence_start = None
    last_motion_time = None
    last_record_time = 0

    print("🐹 hamkawa 自動モード開始!Ctrl+Cで終了")

    try:
        while True:
            now = time.time()
            v = pir.value  # 0/1

            # 1) 滞在開始(0→1に入ったタイミング)
            if not presence_active and v == 1:
                presence_active = True
                presence_start = now
                last_motion_time = now

                # すぐ録音したいので「前回録音時刻」を過去にしておく
                last_record_time = now - RECORD_INTERVAL
                print("\n👀 ケージ前に来た!")

            # 2) 滞在中(1の間)…定期的に録音→認識→カウント
            if presence_active and v == 1:
                last_motion_time = now

                # 録音間隔が来たら録音する
                if now - last_record_time >= RECORD_INTERVAL:
                    last_record_time = now

                    # 録音→文字起こし→カウント
                    record_chunk()
                    text = transcribe_wav(WAV_PATH, model)
                    k = count_kawaii(text)

                    # かわいいが見つかったら加算&LED更新
                    if k > 0:
                        total_kawaii += k
                        led.update_by_kawaii(total_kawaii)
                        print(f"💕 +{k} → 累計 {total_kawaii}")

            # 3) 滞在終了判定(最後に動きを検知してから一定時間経ったら終了)
            if presence_active and last_motion_time and now - last_motion_time > PRESENCE_TIMEOUT:
                duration = now - presence_start
                presence_active = False

                # 短すぎる滞在は除外
                if duration >= MIN_PRESENCE_SEC:
                    total_presence_sec += duration
                    print(f"🏡 お世話 {duration:.1f}s / 累計 {total_presence_sec:.1f}s")

            # CPU負荷&PIRの揺れ抑制のために少し休む
            time.sleep(0.5)

    except KeyboardInterrupt:
        # Ctrl+C で終了。ここで必ず保存&消灯して終わる(後始末大事)
        print("\n🛑 終了")
        save_stats(total_kawaii, total_presence_sec)
        led.close()


if __name__ == "__main__":
    main()

1
0
0

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
1
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?