star back image
people4
電飾 電飾
moon
astronaut

【Webアプリ】お散歩ハートみっけアプリ

BLOG AIWEBアプリWEBログ
読了約:86分

「愛(ハートマーク)を見つける手伝いをしてくれるAIアプリが欲しい」です。

四葉のクローバーのアプリの仕組みを使うと、ハートマークを見つけるのに使えますか?」と質問しました。すると出来るというのです。

お散歩中にハートを見つけて、少し幸せな気分になっちゃいませんか。

そんなに愛されたいんですか。

ハートマーク可愛いですよね。

可愛いかどうかは、わかりません。

動作環境:
・スマホのカメラ
・PCのWebカメラ

URLを開いて、Webブラウザ画面でスキャン動作します。
お散歩ハートみっけアプリ
https://astrowave.jp/heart_finder/
完全ローカル(自分のPC内)で挑戦

なぜローカル?
Roboflowだと有料にしないとクラウドに公開されてしまいます。ネットから集めたハート画像で学習が「まる見え」で権利問題が起きるのを避けたいです。

また手元のM1 Macを基地にして、Apple Silicon(GPU)を使ったローカル学習を試そうと思いました。

コーディングには、Cursor(カーソル)を使っています。

Cursor AIコーディングツール
https://cursor.com/ja

【ステップ1】SAHIロジックのhtmlを作成

モバイルでも動作するようにしたいです。
やりたいことを相談しながら、出力してもらうための内容を盛り込んだテキストです。

新しく「heart_finder」というWebアプリを作成します。
お散歩中に、道端の石や落ち葉、アスファルトのひび割れなどから「ハートの形に見えるもの」をAIが自動で探し出す、世界を少し優しくする『お散歩ハートみっけレーダー』のHTMLコード(1ファイル完結)を作ってください。

以前作成した「YOLOv8+分散タイルスキャン」の超軽量Webカメラコードをベースに、以下のすべての要件を満たすコードを記述してください。

【1. 世界観とデザイン(かわいいUI)】
・テーマカラーは、優しくかわいいピンク・赤系(例: メインを #ff6b81、アクセントを #ff4757)にしてください。
・背景はカメラ映像が映りますが、UIのボタンやステータス表示の背景をガラスモーフィズム(半透明ピンク+backdrop-filterのぼかし)にして、今風の洗練された「かわいいデザイン」にしてください。
・検出時の文言はすべて「ハート」に統一します。画面上部のステータスには「ハート: X箇所発見!」、または「ハートを探しています...」と表示してください。
・CLASS_NAMES は ['heart'](Roboflowでの学習ラベル想定)とします。

【2. 検出音の「幸せ仕様」化】
・ハートを見つけたときに鳴る音(playBeep関数)は、四葉のときの無機質な電子音(880Hz)ではなく、ブラウザのAudioContextを駆使して「ピロリ〜ン♪」や「キュインッ❤」といった、見つけた瞬間に嬉しくなるような可愛い和音、または周波数が滑らかに上がるキュートな音に変更してください。

【3. 未来のウェアラブル(Meta Quest 3)想定の打たれ強い設計】
実機が手元になくても、オキュラス(Meta Quest 3 / 3S)等の標準ブラウザ(Meta Quest Browser)で将来そのまま動くように、以下の2つのフォールバック処理を実装してください。
① ハンズフリー即時起動:
画面上の「検出 ON」ボタンを押さなくても、アプリのURLを開いた瞬間から自動的に「detecting = true」の状態でスキャンが始まるようにしてください。
② 強靭なカメラ起動ロジック(startCamera):
まず通常のスマホ向けに背面カメラ(facingMode: { ideal: 'environment' })をリクエストします。もしエラー(見つからない)になった場合は、端末内の全カメラリスト(navigator.mediaDevices.enumerateDevices())を引っこ抜き、ラベル名に「back」や「外側」が含まれるカメラ、あるいは見つかった最初のビデオ入力を自動で選択して、deviceIdを使って強制起動を試みるコードにしてください。これにより、Quest3などの左右のカラーパススルーカメラも自動で掴めるように先回りします。

【4. iPhone SEでもヌルヌル動く「分散タイルスキャン」の維持】
・スマホ判定時は「2×2の4タイル」、PC時は「3×3の9タイル」に画像を切り出し、1回の推論(runInference)につき「1つのタイルだけ」を処理して currentTileIndex をズラしていく、前回完成させた「負荷分散ラウンドロビン方式」のロジックは100%引き継いでください。
・時間的安定化(trackedBoxes)のチラつき防止ロジックもそのまま維持してください。

上記の内容を踏まえ、コピペすればそのままインデックスとして使える、HTML / CSS / JavaScript が一体になった美しい完全なコードを1ファイルで出力してください。

✅ コード完成

作成ファイル
heart_finder/
├── index.html (メインアプリ:YOLOv8 SAHI込み )
├── tf.min.js (TensorFlow.js ライブラリ本体)
└── yolo_model/ (学習済みAIモデル一式用フォルダ)

次→ アノテーション(MakeSense.ai)
その次 → Apple Silicon(M1)のMPSで学習
最後 → モデルをFTPアップ
完成→ ハートマークを探しに行こう

参考サイト
Ultralytics公式 – TF.jsエクスポート
YOLOv8-tfjs ブラウザデモ

【ステップ2】ハート画像の生成

ハート画像は1枚も所有していません。
改めて作る。そのためのテキストです。

① ハート型の石(縦構図・スマホ写真風)
Prompt: Aスマホで撮ったようなリアルな写真(A realistic smartphone photo taken while walking. A small heart-shaped stone sitting on a gray asphalt road. High angle shot looking down at the ground. Natural daylight.)

② ハート型のひび割れ(アスファルト)
Prompt: ドキュメンタリー写真風(A close-up high-quality photo of cracks on a concrete pavement forming a clear heart shape. Ground texture is rough and realistic. Natural outdoor lighting.)

③ ハート型の落ち葉(公園)
Prompt: お散歩中の発見風(A real photo of a single brown autumn leaf that is perfectly shaped like a heart, lying on green grass in a park. Shallow depth of field.)

こんな景色ありますかね。

自分でデータセットを作るときの合格ライン

どう画像を作成すればいいのか助言をもらいました。

AIに画像を作らせるとき、「1つだけ気をつけたい落とし穴」があります。それは、「綺麗すぎる、完璧なハートばかりにしないこと」です。

AIが作った完璧な左右対称の「❤」ばかりを100枚学習させると、AIの頭が固くなってしまい、外を歩いたときに「ちょっと歪んだ本物のハート型の石」を見落とすようになってしまいます(専門用語で「過学習」といいます)。

なので、AIに画像を作らせるときは、

  • 「ちょっと歪んだハート型の葉っぱ」
  • 「欠けているハート型のひび割れ」
  • 「不完全なハートの形の水たまり」

といった、「ちょっと不完全な、惜しいハート」を半分くらい混ぜてあげるのが、お散歩レーダーを爆発的に賢くする最大のコツです!

まずは50枚〜100枚くらいを目指しましょう。とのこと。

【ステップ3】MakeSense.aiでアノテーション

学習前に画像に目印をつけるアノテーション。ローカル環境で進めていくには、優秀な無料ツール 「AnyLabeling」 や、ブラウザ型の 「MakeSense.ai」があるとのこと。

どちらも知りません。どちらがおすすめですか?

完全に内緒で、かつ「お金をかけずにサクッと進めたい」という今のあなたの状況なら、圧倒的に「MakeSense.ai」がおすすめです!

2つのツールの決定的な違い

おすすめ:MakeSense.ai(メイクセンス)
どんなツール?:
ブラウザ(SafariやChrome)でサイトを開いて使うタイプです。

おすすめな理由:
何より「インストールの手間がゼロ」です。サイトを開いて、690枚の画像をドラッグ&ドロップするだけで、今すぐ1秒後にハートを囲む作業が始められます。

悪くないけど保留:AnyLabeling(エニーレーベリング)
どんなツール?:
PCに直接ソフトをダウンロードしてインストールする本格派です。

今回は保留な理由:
動かすためにPythonの環境が必要だったり、アプリを立ち上げるまでの設定がちょっとだけ面倒です。「今すぐコッソリ始めたい」というスピード感を考えると、最初のハードルが少し高く感じてしまうかもしれません。

メイクセンス
 https://www.makesense.ai/

初見で使い方がわかりません。

動画で解説がないか探しました。

Roboflowとよく似てますが、機能がシンプルですね。

スタートしましょう!
画像を読み込ませて、どんどんとハートを枠に囲んでいきます。

heartの綴りが間違ってますよ。

Roboflowにあるような自動選択の機能はないので、頑張って、終わりましたら、エクスポートしましょう。

YOLOフォーマットにチェックを入れて、エクスポートしてください。

これで学習の材料ができました。

ハート画像は捨てないでください。学習に一緒に使います。

MakeSenseは作業途中で保存でき何回でも再開できます

ブラウザ型なので「ページを閉じたら全部消えちゃうのでは?」と不安になりますが、「これまでの作業データを丸ごとPCにダウンロードして、後でそれを読み込ませて再開する」という賢い方法があります。

完全ローカル(秘密)で作業を進めるあなたにとって、最も安全で確実な「途中の保存方法」と「再開の手順」をまとめました。

1. 作業を「保存(中断)」する方法

作業を途中で終わりたいときは、画面左上のメニューから以下の手順で「プロジェクトファイル」をダウンロードします。

  1. ナビゲーションバーの 「Actions」 をクリックします。
  2. 「Export Annotations」 を選択します。
  3. 一番上の 「A .zip package containing files in YOLO format.」 にチェックが入っていることを確認します。
  4. 「Export」 ボタンを押します。

これで、今までに枠をつけた分のデータがすべて入った .zip ファイルがPCにダウンロードされます。これでブラウザを閉じてしまっても大丈夫です!

2. 次回、作業を「再開」する方法

「昨日ここまでやったから、続きから枠をつけたい!」という時は、以下の手順で一瞬で元通りになります。

  1. 再び MakeSense.ai を開いて、右下の 「Get Started」 を押します。
  2. 最初に画像を選んだ画面で、「お手持ちのハートの画像」と「前回保存した.zipファイル(解凍しなくてOK!)」を同時にすべて選択してドロップします。
  3. 画面の指示に従って進めると、「前回どこまで枠をつけたか」が完全に復元された状態で、続きからアノテーションを再開できます!

何日かに分けて、隙間時間にコッソリとハートの教科書を作り進めることができます。

作業再会のコツ

・画像とテキストは必ずセット
MakeSense.aiを再開するときは、必ず「画像」と「前回のzip(またはtxt)」をセットで一緒に読み込ませるのがコツです。

・上書き保存の感覚でOK
続きから作業をして、また中断したいときは、同じように「Export Annotations」をすれば、その時点までの最新のデータが新しくダウンロードされます。古いzipファイルはゴミ箱に捨ててしまって大丈夫です。
ネガティブサンプルの件について

同じ落とし穴にハマる可能性が高いためです。

⚠️ 注意:MakeSense.aiは、枠をつけなかった画像のtxtファイルを
書き出さないことがあります。
YOLOはラベルファイルが「存在しない」画像を無視するため、
ネガティブサンプルが学習に使われません。

対処法:ネガティブサンプルの画像と同じファイル名(拡張子だけ.txt)で、
中身が空のテキストファイルを手動で作り、labelsフォルダに入れてください。

【ステップ4】Python環境と学習用スクリプトを作成

学習用スクリプト(Pythonコード)を作成してもらいます。テキストは以下です。

heart_finderフォルダにて作業中です。

MakeSense.aiからYOLO形式のテキストデータをエクスポートできました。
自分のPC内だけで、誰にも見られずにこのデータと元の画像を使って、YOLOv8のカスタム学習を実行し、最終的にWeb用の TensorFlow.js(model.json と weights.bin)を書き出すための Python環境のセットアップ手順と、学習用スクリプト(Pythonコード)を作成してください。

フォルダ構成をどのように配置すればいいか(imagesフォルダやlabelsフォルダの作り方)、および必要なライブラリ(ultralyticsなど)のインストールコマンドも教えてください。

また、学習の際に「画像を自動でランダムにぐるぐる回転させたり、上下逆さまにしたりして、あらゆる角度のハートに対応させる(データ拡張)」の設定もコード内に盛り込んでください。

このテキストで、学習に必要なtrainingファイルとフォルダが一斉に作成されます。

heart_finder/
├── index.html (メインアプリ:YOLOv8 )
├── tf.min.js (TensorFlow.js ライブラリ本体)
├── yolo_model/ (Web公開用:学習済みモデル一式)
│ ├── model.json (モデル構造定義)
│ └── group1-shard*.bin (モデルの重みデータ)
└── training/ (学習環境:Python関連)
├── venv/ (Python仮想環境:ライブラリ一式)
├── train.py (学習実行&TF.js書き出しスクリプト)
├── split_data.py (データ分割ヘルパー:all → train/val)
├── data.yaml (YOLO学習用設定ファイル)
├── yolov8n.pt (学習のベースとなる公式モデル)
└── dataset/ (学習用データセット)
├── images/ (画像フォルダ:all/train/val)
└── labels/ (ラベルフォルダ:all/train/val)

training /一式はFTPでアップロードしなくても大丈夫です。
yolo_model/の中のファイルは【ステップ5】で作成します。

yolo_model/の中のファイルが「モデル」というもので、AIが導き出した特徴やこだわりを算出したAIアプリたるものの正体、重みデータとなります。

【学習スクリプト】

これがないと動かないです。

ファイル理由
train.py学習の本体。データ拡張・エクスポートまで一括
split_data.pyデータ分割。これがないとtrain/val作れない
data.yamlYOLOが読むクラス定義。1行でも間違うと学習失敗

train.py

"""
Heart Finder - YOLOv8 ローカル学習 & TensorFlow.jsエクスポート
=============================================================
使い方:
  1. dataset/images/train/ に学習用画像を配置
  2. dataset/labels/train/ に対応するYOLOラベル(.txt)を配置
  3. dataset/images/val/ に検証用画像を配置(全体の10-20%)
  4. dataset/labels/val/ に対応するYOLOラベル(.txt)を配置
  5. python train.py を実行
"""

from ultralytics import YOLO
import shutil
from pathlib import Path


def main():
    # === 設定 ===
    MODEL_SIZE = "yolov8n.pt"  # nano: 軽量・高速(Webブラウザ向き)
    EPOCHS = 150               # 学習回数(画像が少なければ150-200に上げてもOK)
    IMG_SIZE = 640             # 入力画像サイズ
    BATCH_SIZE = 8             # メモリが足りなければ4に下げる

    # === データ拡張(augmentation)設定 ===
    # あらゆる角度のハートに対応するための回転・反転設定
    augment_params = dict(
        degrees=180.0,   # ±180度ランダム回転(全方向対応)
        flipud=0.5,      # 50%の確率で上下反転
        fliplr=0.5,      # 50%の確率で左右反転
        scale=0.5,       # 50%の範囲でランダムスケール
        translate=0.1,   # 10%の範囲でランダム平行移動
        mosaic=1.0,      # モザイク拡張(4枚を合成)
        hsv_h=0.015,     # 色相を微調整
        hsv_s=0.5,       # 彩度を変化
        hsv_v=0.4,       # 明度を変化
    )

    print("=" * 50)
    print("  Heart Finder - YOLOv8 学習開始")
    print("=" * 50)
    print(f"  モデル: {MODEL_SIZE}")
    print(f"  エポック数: {EPOCHS}")
    print(f"  画像サイズ: {IMG_SIZE}px")
    print(f"  回転角度: ±{augment_params['degrees']}°")
    print("=" * 50)

    # === 学習実行 ===
    model = YOLO(MODEL_SIZE)

    results = model.train(
        data="data.yaml",
        epochs=EPOCHS,
        imgsz=IMG_SIZE,
        batch=BATCH_SIZE,
        name="heart_finder",
        patience=20,         # 20エポック改善なしで早期終了
        device="mps",        # Apple Silicon GPU使用(CPUなら "cpu")
        workers=4,
        **augment_params,
    )

    print("\n✅ 学習完了!")

    # === best.ptのパスを取得 ===
    import glob
    # ultralyticsはプロジェクトルートにrunsを作ることがあるため広く検索
    search_patterns = [
        "runs/detect/heart_finder*/weights/best.pt",
        "../runs/detect/heart_finder*/weights/best.pt",
        str(Path.home() / "Documents/astrowave/runs/detect/heart_finder*/weights/best.pt"),
    ]
    best_pt = None
    for pattern in search_patterns:
        candidates = sorted(glob.glob(pattern))
        if candidates:
            best_pt = Path(candidates[-1])
            break

    if best_pt is None or not best_pt.exists():
        print("❌ best.pt が見つかりません。以下で手動検索してください:")
        print("   find ~ -name 'best.pt' -path '*heart_finder*'")
        return

    print(f"\n📦 ベストモデル: {best_pt}")

    # === TensorFlow.js形式にエクスポート ===
    print("\n🔄 TensorFlow.js形式にエクスポート中...")
    best_model = YOLO(str(best_pt))
    export_path = best_model.export(format="tfjs")

    print(f"\n✅ エクスポート完了: {export_path}")

    # === yolo_model/ にコピー ===
    output_dir = Path("../yolo_model")
    output_dir.mkdir(exist_ok=True)

    # エクスポートされたファイルをコピー
    export_dir = Path(export_path)
    if export_dir.is_dir():
        for f in export_dir.iterdir():
            dest = output_dir / f.name
            shutil.copy2(f, dest)
            print(f"  → {dest}")

    print("\n" + "=" * 50)
    print("  🎉 すべて完了!")
    print(f"  モデルファイル: heart_finder/yolo_model/")
    print("  → model.json と group1-shard*.bin が生成されました")
    print("=" * 50)


if __name__ == "__main__":
    main()

split_data.py

"""
画像データを train / val に自動で分割するヘルパースクリプト
===========================================================
使い方:
  1. dataset/images/all/ フォルダに全画像を入れる
  2. dataset/labels/all/ フォルダに全ラベル(.txt)を入れる
  3. python split_data.py を実行
  4. 自動で train(80%) / val(20%) に分割されます
"""

import shutil
import random
from pathlib import Path


def main():
    # 元データのパス
    img_all = Path("dataset/images/all")
    lbl_all = Path("dataset/labels/all")

    # 出力先
    img_train = Path("dataset/images/train")
    img_val = Path("dataset/images/val")
    lbl_train = Path("dataset/labels/train")
    lbl_val = Path("dataset/labels/val")

    # 出力フォルダ作成
    for d in [img_train, img_val, lbl_train, lbl_val]:
        d.mkdir(parents=True, exist_ok=True)

    # 画像ファイル一覧を取得
    images = sorted([f for f in img_all.iterdir()
                     if f.suffix.lower() in ('.jpg', '.jpeg', '.png', '.webp', '.bmp')])

    if not images:
        print("❌ dataset/images/all/ に画像が見つかりません。")
        print("   まず画像ファイルを配置してください。")
        return

    # ランダムシャッフル
    random.seed(42)
    random.shuffle(images)

    # 80% : 20% に分割
    split_idx = int(len(images) * 0.8)
    train_imgs = images[:split_idx]
    val_imgs = images[split_idx:]

    print(f"📊 データ分割: 全{len(images)}枚 → train: {len(train_imgs)}枚 / val: {len(val_imgs)}枚")

    # コピー実行
    for img in train_imgs:
        shutil.copy2(img, img_train / img.name)
        lbl = lbl_all / (img.stem + ".txt")
        if lbl.exists():
            shutil.copy2(lbl, lbl_train / lbl.name)

    for img in val_imgs:
        shutil.copy2(img, img_val / img.name)
        lbl = lbl_all / (img.stem + ".txt")
        if lbl.exists():
            shutil.copy2(lbl, lbl_val / lbl.name)

    print("✅ 分割完了!")
    print(f"   train: {img_train}")
    print(f"   val:   {img_val}")


if __name__ == "__main__":
    main()

data.yaml
YAML(ヤムル) という設定ファイル用のフォーマット

# YOLOv8 Heart Finder - Dataset Configuration
# MakeSense.aiからエクスポートしたYOLO形式のデータ用

path: ./dataset
train: images/train
val: images/val

nc: 1
names: ['heart']

学習前にデータをセットする

【ステップ5】のAI学習前に、使った画像ファイルと、作ったtxtファイルをdataset/images/all/ に画像、all/labels/all/ にアノテーション済みテキストを、全部入れ込みます。

【ステップ5】に進み、split_data.py → train.py の順で実行します。

【ステップ5】M1 MacのGPUパワーとPythonによるAIの学習

ターミナルを使います。覚悟してください。

いやです。

黒い画面のターミナルが苦手な人、いますよね。
私です。

cd /Users/saigamo/Documents/astrowave/heart_finder/training
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install ultralytics tensorflowjs tensorflow-macos
python split_data.py
python train.py
pythonが古いとエラーが出たら新しくしましょう

pythonが古いとアレなのでターミナルで順番に実行してください。

ステップ1: 古いvenvを削除

cd /Users/saigamo/Documents/astrowave/heart_finder/training
rm -rf venv

ステップ2: Python 3.11をHomebrewでインストール

brew install python@3.11

(約1〜3分かかります)

ステップ3: 新しいvenvを作成して有効化

/opt/homebrew/opt/python@3.11/bin/python3.11 -m venv venv
source venv/bin/activate

ステップ4: ライブラリのインストール(TensorFlowなしで軽量に)

pip install --upgrade pip
pip install ultralytics

ステップ5: 学習実行

python train.py

成功すれば「モデル」が出力されます。

Finderでフォルダを見てください。出力されているはずです。

ファイルをアップロードして、URLを叩くと。。ハートを探し出そうとWebカメラがスキャンを始めると思います!
https://astrowave.jp/heart_finder/

どうですか?ここまでが1連の流れです。

うまくいくといいですね。

再学習で精度を上げるアクション

ネガティブサンプルを追加します。

  1. MakeSense.aiに「ハートが映っていない画像」を30枚追加
    • 普通の石垣、ボタン、壁、道路など
    • 枠をつけずにエクスポート → 空の.txtファイルができる
  2. 「石のハート」「自然物のグレーなハート」の画像を追加(可能なら10〜20枚)
    • 灰色・茶色のハート型の石、木、葉っぱなど
  3. エクスポートして dataset/images/all/ と dataset/labels/all/ に追加
  4. 再学習:
cd /Users/saigamo/Documents/astrowave/heart_finder/training
source venv/bin/activate
python split_data.py
python train.py

その後、前回と同じく tensorflowjs_converter でエクスポートする。

ネガティブサンプルの件について

同じ落とし穴にハマる可能性が高いためです。
それは枠をつけずにエクスポートの時です。

⚠️ 注意:MakeSense.aiは、枠をつけなかった画像のtxtファイルを
書き出さないことがあります。
YOLOはラベルファイルが「存在しない」画像を無視するため、
ネガティブサンプルが学習に使われません。

対処法:ネガティブサンプルの画像と同じファイル名(拡張子だけ.txt)で、
中身が空のテキストファイルを手動で作り、labelsフォルダに入れてください。

【共有】ソースコード

「ここまでできれば良いライン」を超えられたと思ったので、コードを共有したいです。また、CDNの読み込みを無くしています。

学習が思ったようにいかないですね。
そこが苦労のポイントでしょうか。

YOLO化バージョン
https://astrowave.jp/heart_finder/

heart_finder/
├── index.html (メインアプリ:YOLOv8 )
├── manifest.json (PWA設定:ホーム画面追加・インストール用)
├── sw.js (Service Worker:オフライン動作)
├── tf.min.js (TensorFlow.js ライブラリ本体)
├── icons/ (PWA用アイコン画像)
│ ├── icon.svg (アイコンのソースデータ)
│ ├── icon-192.png (ホーム画面用アイコン)
│ └── icon-512.png (スプラッシュ画面用アイコン)
└── yolo_model/ (学習済みAIモデル一式)
├── model.json (モデルの構造定義ファイル)
├── group1-shard*.bin (モデルの重みデータファイル)
└── metadata.yaml (モデルのメタデータ)

html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#ff6b81">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ハートみっけ">
<link rel="manifest" href="./manifest.json">
<link rel="apple-touch-icon" href="./icons/icon-192.png">
<title>お散歩ハートみっけレーダー</title>
<script src="./tf.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a0a0e; }

#container {
  position: relative;
  width: 100%;
  height: 100%;
}

video {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}

canvas {
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}

#ui {
  position: absolute;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 10;
  display: flex;
  gap: 10px;
  align-items: center;
}

button {
  padding: 10px 18px;
  font-size: 13px;
  font-weight: 600;
  border: none;
  border-radius: 20px;
  cursor: pointer;
  background: rgba(255, 107, 129, 0.25);
  color: #fff;
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 107, 129, 0.4);
  transition: background 0.2s;
}

button:active { background: rgba(255, 71, 87, 0.5); }

#status {
  position: absolute;
  top: 12px; left: 12px;
  z-index: 10;
  color: #fff;
  font-family: system-ui, sans-serif;
  font-size: 13px;
  background: rgba(255, 107, 129, 0.2);
  padding: 8px 14px;
  border-radius: 20px;
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 107, 129, 0.3);
}
</style>
</head>
<body>

<div id="container">
  <video id="video" autoplay playsinline muted></video>
  <canvas id="canvas"></canvas>
  <div id="status">カメラ起動中...</div>
  <div id="ui">
    <button id="toggleBtn">検出 ON</button>
    <button id="captureBtn">📷 保存</button>
    <button id="soundBtn">🔊 ON</button>
  </div>
</div>

<script>
(function() {
  const video = document.getElementById('video');
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');
  const status = document.getElementById('status');
  const toggleBtn = document.getElementById('toggleBtn');

  let detecting = true;
  let animId = null;
  let yoloModel = null;

  const MODEL_PATH = './yolo_model/model.json';
  const MODEL_INPUT_SIZE = 640;
  const CONFIDENCE_THRESHOLD = 0.65;
  const IOU_THRESHOLD = 0.45;
  const CLASS_NAMES = ['heart'];

  const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

  // SAHI設定(スマホ: 2×2=4タイル / PC: 3×3=9タイル)
  const SAHI_COLS = isMobile ? 2 : 3;
  const SAHI_ROWS = isMobile ? 2 : 3;
  const SAHI_OVERLAP = 0.2;

  let lastResults = [];
  let inferring = false;

  // 分散タイルスキャン: 1フレームにつき1タイルだけ処理
  let currentTileIndex = 0;
  const TOTAL_TILES = SAHI_COLS * SAHI_ROWS;
  let tileResults = [];

  // 時間的安定化
  let trackedBoxes = [];
  const HIT_TO_SHOW = 1;
  const MISS_TO_HIDE = 5;
  const IOU_MATCH = 0.3;

  // SAHI用オフスクリーンキャンバス
  const sliceCanvas = document.createElement('canvas');
  const sliceCtx = sliceCanvas.getContext('2d');
  sliceCanvas.width = MODEL_INPUT_SIZE;
  sliceCanvas.height = MODEL_INPUT_SIZE;

  // --- 検出音(かわいい和音: ピロリ〜ン♪) ---
  let soundEnabled = true;
  let lastSoundTime = 0;
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  function playBeep() {
    const now = Date.now();
    if (now - lastSoundTime < 3000) return;
    lastSoundTime = now;

    const t = audioCtx.currentTime;

    // 音1: キュイン(周波数上昇)
    const osc1 = audioCtx.createOscillator();
    const gain1 = audioCtx.createGain();
    osc1.connect(gain1);
    gain1.connect(audioCtx.destination);
    osc1.type = 'sine';
    osc1.frequency.setValueAtTime(600, t);
    osc1.frequency.exponentialRampToValueAtTime(1200, t + 0.12);
    gain1.gain.setValueAtTime(0.25, t);
    gain1.gain.exponentialRampToValueAtTime(0.01, t + 0.2);
    osc1.start(t);
    osc1.stop(t + 0.2);

    // 音2: ピロリン(高音の和音)
    const osc2 = audioCtx.createOscillator();
    const gain2 = audioCtx.createGain();
    osc2.connect(gain2);
    gain2.connect(audioCtx.destination);
    osc2.type = 'triangle';
    osc2.frequency.setValueAtTime(1047, t + 0.1);
    osc2.frequency.setValueAtTime(1319, t + 0.2);
    osc2.frequency.setValueAtTime(1568, t + 0.3);
    gain2.gain.setValueAtTime(0, t);
    gain2.gain.linearRampToValueAtTime(0.2, t + 0.1);
    gain2.gain.exponentialRampToValueAtTime(0.01, t + 0.5);
    osc2.start(t + 0.1);
    osc2.stop(t + 0.5);

    // 音3: キラキラ感(高い倍音)
    const osc3 = audioCtx.createOscillator();
    const gain3 = audioCtx.createGain();
    osc3.connect(gain3);
    gain3.connect(audioCtx.destination);
    osc3.type = 'sine';
    osc3.frequency.setValueAtTime(2093, t + 0.15);
    gain3.gain.setValueAtTime(0.1, t + 0.15);
    gain3.gain.exponentialRampToValueAtTime(0.01, t + 0.4);
    osc3.start(t + 0.15);
    osc3.stop(t + 0.4);
  }

  // --- モデル読み込み ---
  async function loadModel() {
    status.textContent = 'モデル読み込み中...';
    try {
      yoloModel = await tf.loadGraphModel(MODEL_PATH);
      const dummy = tf.zeros([1, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE, 3]);
      await yoloModel.executeAsync(dummy);
      dummy.dispose();
      status.textContent = 'ハートを探しています...';
    } catch (e) {
      console.error('YOLO読込失敗:', e);
      status.textContent = 'モデル読み込み失敗: ' + e.message;
    }
  }

  // --- カメラ起動(Quest3対応フォールバック付き) ---
  async function startCamera() {
    // 1st attempt: 標準の背面カメラ
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: { ideal: 'environment' },
          width: { ideal: 1280 },
          height: { ideal: 720 }
        }
      });
      video.srcObject = stream;
      await video.play();
      status.textContent = 'カメラ起動完了・モデル読込中...';
      resizeCanvas();
      await loadModel();
      detect();
      return;
    } catch (e) {
      console.warn('標準カメラ起動失敗、フォールバック試行:', e.message);
    }

    // 2nd attempt: デバイスリストから背面カメラを探す
    try {
      const devices = await navigator.mediaDevices.enumerateDevices();
      const videoDevices = devices.filter(d => d.kind === 'videoinput');
      let targetId = null;

      for (const dev of videoDevices) {
        const label = (dev.label || '').toLowerCase();
        if (label.includes('back') || label.includes('外側') || label.includes('rear') || label.includes('environment')) {
          targetId = dev.deviceId;
          break;
        }
      }

      if (!targetId && videoDevices.length > 0) {
        targetId = videoDevices[0].deviceId;
      }

      if (targetId) {
        const stream = await navigator.mediaDevices.getUserMedia({
          video: { deviceId: { exact: targetId }, width: { ideal: 1280 }, height: { ideal: 720 } }
        });
        video.srcObject = stream;
        await video.play();
        status.textContent = 'カメラ起動完了・モデル読込中...';
        resizeCanvas();
        await loadModel();
        detect();
        return;
      }
    } catch (e2) {
      console.error('フォールバックカメラ起動失敗:', e2);
    }

    status.textContent = 'カメラにアクセスできません';
  }

  function resizeCanvas() {
    canvas.width = video.videoWidth || 640;
    canvas.height = video.videoHeight || 480;
  }

  // --- 前処理 ---
  function preprocess(source) {
    return tf.tidy(() => {
      const img = tf.browser.fromPixels(source);
      const resized = tf.image.resizeBilinear(img, [MODEL_INPUT_SIZE, MODEL_INPUT_SIZE]);
      const normalized = resized.div(255.0);
      return normalized.expandDims(0);
    });
  }

  // --- 後処理 ---
  function postprocess(output, imgWidth, imgHeight) {
    const results = [];
    const data = output.dataSync();
    const numClasses = CLASS_NAMES.length;
    const numDetections = 8400;

    const boxes = [];
    const scores = [];
    const classIds = [];

    for (let i = 0; i < numDetections; i++) {
      const cx = data[0 * numDetections + i];
      const cy = data[1 * numDetections + i];
      const w = data[2 * numDetections + i];
      const h = data[3 * numDetections + i];

      let maxScore = 0;
      let maxClassId = 0;
      for (let c = 0; c < numClasses; c++) {
        const score = data[(4 + c) * numDetections + i];
        if (score > maxScore) {
          maxScore = score;
          maxClassId = c;
        }
      }

      if (maxScore > CONFIDENCE_THRESHOLD) {
        const x1 = (cx - w / 2) / MODEL_INPUT_SIZE * imgWidth;
        const y1 = (cy - h / 2) / MODEL_INPUT_SIZE * imgHeight;
        const x2 = (cx + w / 2) / MODEL_INPUT_SIZE * imgWidth;
        const y2 = (cy + h / 2) / MODEL_INPUT_SIZE * imgHeight;
        boxes.push([x1, y1, x2, y2]);
        scores.push(maxScore);
        classIds.push(maxClassId);
      }
    }

    const nmsIndices = nms(boxes, scores, IOU_THRESHOLD);
    for (const idx of nmsIndices) {
      results.push({
        box: boxes[idx],
        score: scores[idx],
        classId: classIds[idx],
        className: CLASS_NAMES[classIds[idx]]
      });
    }
    return results;
  }

  // --- NMS ---
  function nms(boxes, scores, iouThreshold) {
    const indices = scores
      .map((s, i) => ({ score: s, index: i }))
      .sort((a, b) => b.score - a.score)
      .map(item => item.index);

    const selected = [];
    const active = new Array(indices.length).fill(true);

    for (let i = 0; i < indices.length; i++) {
      if (!active[i]) continue;
      selected.push(indices[i]);
      for (let j = i + 1; j < indices.length; j++) {
        if (!active[j]) continue;
        if (computeIoU(boxes[indices[i]], boxes[indices[j]]) > iouThreshold) {
          active[j] = false;
        }
      }
    }
    return selected;
  }

  function computeIoU(boxA, boxB) {
    const x1 = Math.max(boxA[0], boxB[0]);
    const y1 = Math.max(boxA[1], boxB[1]);
    const x2 = Math.min(boxA[2], boxB[2]);
    const y2 = Math.min(boxA[3], boxB[3]);
    const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
    const areaA = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]);
    const areaB = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]);
    return intersection / (areaA + areaB - intersection);
  }

  // --- 分散タイルスキャン推論(1フレーム1タイル) ---
  async function runInference() {
    if (!yoloModel || inferring) return;
    inferring = true;

    try {
      const imgW = canvas.width;
      const imgH = canvas.height;
      const tileW = imgW / SAHI_COLS;
      const tileH = imgH / SAHI_ROWS;
      const overlapX = tileW * SAHI_OVERLAP;
      const overlapY = tileH * SAHI_OVERLAP;

      const row = Math.floor(currentTileIndex / SAHI_COLS);
      const col = currentTileIndex % SAHI_COLS;

      const sx = Math.max(0, col * tileW - overlapX);
      const sy = Math.max(0, row * tileH - overlapY);
      const sw = Math.min(tileW + overlapX * 2, imgW - sx);
      const sh = Math.min(tileH + overlapY * 2, imgH - sy);

      sliceCtx.drawImage(video, sx, sy, sw, sh, 0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);

      const inputTensor = preprocess(sliceCanvas);
      const rawOutput = await yoloModel.executeAsync(inputTensor);
      const outputTensor = Array.isArray(rawOutput) ? rawOutput[0] : rawOutput;
      const detections = postprocess(outputTensor, sw, sh);

      tileResults[currentTileIndex] = detections.map(det => ({
        box: [det.box[0] + sx, det.box[1] + sy, det.box[2] + sx, det.box[3] + sy],
        score: det.score,
        classId: det.classId,
        className: det.className
      }));

      inputTensor.dispose();
      if (Array.isArray(rawOutput)) {
        rawOutput.forEach(t => t.dispose());
      } else {
        rawOutput.dispose();
      }

      currentTileIndex = (currentTileIndex + 1) % TOTAL_TILES;

      if (currentTileIndex === 0) {
        const allBoxes = [];
        const allScores = [];
        const allClassIds = [];

        for (let i = 0; i < TOTAL_TILES; i++) {
          if (!tileResults[i]) continue;
          for (const det of tileResults[i]) {
            allBoxes.push(det.box);
            allScores.push(det.score);
            allClassIds.push(det.classId);
          }
        }

        const finalIndices = nms(allBoxes, allScores, IOU_THRESHOLD);
        const results = finalIndices
          .map(idx => ({
            box: allBoxes[idx],
            score: allScores[idx],
            classId: allClassIds[idx],
            className: CLASS_NAMES[allClassIds[idx]]
          }))
          .filter(det => {
            const bw = det.box[2] - det.box[0];
            const bh = det.box[3] - det.box[1];
            const boxArea = bw * bh;
            const screenArea = imgW * imgH;
            if (boxArea > screenArea * 0.15) return false;
            const aspect = Math.max(bw, bh) / (Math.min(bw, bh) || 1);
            if (aspect > 2.0) return false;
            return true;
          });

        lastResults = results;
        updateTracking(results);

        const stableResults = trackedBoxes.filter(t => t.hitCount >= HIT_TO_SHOW);
        if (soundEnabled && stableResults.length > 0) {
          playBeep();
        }
      }
    } catch (e) {
      console.error('推論エラー:', e);
    }

    inferring = false;
  }

  // --- 時間的安定化 ---
  function updateTracking(newDetections) {
    const matched = new Set();

    for (const det of newDetections) {
      let bestIdx = -1;
      let bestIou = IOU_MATCH;

      for (let i = 0; i < trackedBoxes.length; i++) {
        const iou = calcIoU(det.box, trackedBoxes[i].box);
        if (iou > bestIou) {
          bestIou = iou;
          bestIdx = i;
        }
      }

      if (bestIdx >= 0) {
        trackedBoxes[bestIdx].box = det.box;
        trackedBoxes[bestIdx].score = det.score;
        trackedBoxes[bestIdx].hitCount = Math.min(trackedBoxes[bestIdx].hitCount + 1, HIT_TO_SHOW + 2);
        trackedBoxes[bestIdx].missCount = 0;
        matched.add(bestIdx);
      } else {
        trackedBoxes.push({
          box: det.box,
          score: det.score,
          classId: det.classId,
          className: det.className,
          hitCount: 1,
          missCount: 0
        });
      }
    }

    for (let i = trackedBoxes.length - 1; i >= 0; i--) {
      if (!matched.has(i)) {
        trackedBoxes[i].missCount++;
        if (trackedBoxes[i].missCount >= MISS_TO_HIDE) {
          trackedBoxes.splice(i, 1);
        }
      }
    }
  }

  function calcIoU(boxA, boxB) {
    const x1 = Math.max(boxA[0], boxB[0]);
    const y1 = Math.max(boxA[1], boxB[1]);
    const x2 = Math.min(boxA[2], boxB[2]);
    const y2 = Math.min(boxA[3], boxB[3]);
    const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
    const areaA = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]);
    const areaB = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]);
    return inter / (areaA + areaB - inter);
  }

  // --- ハート描画ヘルパー ---
  function drawHeart(cx, cy, size) {
    ctx.beginPath();
    const topY = cy - size * 0.35;
    ctx.moveTo(cx, cy + size * 0.6);
    ctx.bezierCurveTo(cx - size, cy - size * 0.1, cx - size * 0.6, topY - size * 0.4, cx, topY + size * 0.1);
    ctx.bezierCurveTo(cx + size * 0.6, topY - size * 0.4, cx + size, cy - size * 0.1, cx, cy + size * 0.6);
    ctx.closePath();
  }

  // --- 描画ループ ---
  function detect() {
    if (!video.videoWidth) {
      animId = requestAnimationFrame(detect);
      return;
    }

    const w = canvas.width;
    const h = canvas.height;
    ctx.drawImage(video, 0, 0, w, h);

    if (detecting) {
      const stableResults = trackedBoxes.filter(t => t.hitCount >= HIT_TO_SHOW);
      for (const det of stableResults) {
        const [x1, y1, x2, y2] = det.box;
        const bw = x2 - x1;
        const bh = y2 - y1;

        // バウンディングボックス(ピンク系、角丸風に2重線)
        ctx.strokeStyle = 'rgba(255, 107, 129, 0.85)';
        ctx.lineWidth = 3;
        ctx.strokeRect(x1, y1, bw, bh);
        ctx.strokeStyle = 'rgba(255, 71, 87, 0.4)';
        ctx.lineWidth = 1;
        ctx.strokeRect(x1 - 2, y1 - 2, bw + 4, bh + 4);

        // ラベル
        const label = '♥ ' + Math.round(det.score * 100) + '%';
        ctx.font = 'bold 14px system-ui, sans-serif';
        const tw = ctx.measureText(label).width;
        ctx.fillStyle = 'rgba(255, 71, 87, 0.8)';
        ctx.fillRect(x1, y1 - 24, tw + 12, 24);
        ctx.fillStyle = '#fff';
        ctx.fillText(label, x1 + 6, y1 - 7);
      }

      const heartCount = stableResults.length;
      status.textContent = heartCount > 0
        ? 'ハート: ' + heartCount + '箇所発見!'
        : 'ハートを探しています...';

      runInference();
    }

    animId = requestAnimationFrame(detect);
  }

  // --- UIイベント ---
  toggleBtn.addEventListener('click', function() {
    detecting = !detecting;
    toggleBtn.textContent = detecting ? '検出 ON' : '検出 OFF';
    if (!detecting) {
      lastResults = [];
      trackedBoxes = [];
      status.textContent = '検出停止中';
    }
  });

  const captureBtn = document.getElementById('captureBtn');
  captureBtn.addEventListener('click', function() {
    const link = document.createElement('a');
    link.download = 'heart_' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.png';
    link.href = canvas.toDataURL('image/png');
    link.click();
  });

  const soundBtn = document.getElementById('soundBtn');
  soundBtn.addEventListener('click', function() {
    soundEnabled = !soundEnabled;
    soundBtn.textContent = soundEnabled ? '🔊 ON' : '🔇 OFF';
  });

  window.addEventListener('resize', resizeCanvas);

  startCamera();

  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js');
  }
})();
</script>

</body>
</html>

107行目の数値を変えると、
CONFIDENCE_THRESHOLD = 0.75

ハート感度調整ができます。

所有の古いスマホ端末でも何とか動きました。
コードの詳しくは、AIに聞いてください。

【PWA化】URLでアクセスが面倒な人に

アプリをダウンロードしてデスクトップに配置したい。
URLを叩いてWebアプリページを見に行くなんてダサいし、面倒くさくてしょうがない。そう言う人もいると思い、AIに聞きました。

「PWA化」したらいいですと言うのです。

以下2ファイルを追加するだけでいいとのこと。

 manifest.json と sw.js を追加して、デスクトップにインストールできるようにしました。

スマホ(iPhone Safari)
  1. 「ハートみっけアプリ」のURLを開く
  2. 下の共有ボタン(□↑)をタップ
  3. 「ホーム画面に追加」をタップ
  4. → アプリアイコンとして追加される(次回から全画面で起動)
スマホ(Android Chrome)
  1. URLを開く
  2. アドレスバーに「インストール」バナーが自動表示される
  3. タップでインストール
PC(Chrome)
  1. URLを開く
  2. アドレスバー右端に「⊕ インストール」アイコンが出る
  3. クリックでデスクトップアプリとして追加
URLで使う → 今まで通りブラウザで開くだけ。
アプリとして使う → 上記手順でインストール。

どちらも同じコードで動作します。

【補足】素材の大量の「ハート画像」を手に入れる

学習させるのに必要な大量の「ハート画像」が必要です。AIさんに生成してもらうにも限界があります。どうしたら良いでしょう。

Google画像検索の「商用利用可(クリエイティブ・コモンズ)」から一括ダウンロードする

世界中の人が「ハートの形をした石」や「ハートの葉っぱ」の写真を、無料のフリー素材としてネット上にアップしています。

利用させてもらいましょう。

  1. Googleで heart shape stoneheart shaped leaf と検索します。
  2. 検索窓の下の「ツール」>「ライセンス」で「クリエイティブ・コモンズ ライセンス」を選びます。これで無料で使っていい画像だけが絞り込まれます。
  3. Chromeの拡張機能(例:『Image Downloader』など)を使えば、画面に映った大量のハートの写真を1クリックで一括ダウンロードできます。これだけで100枚くらいは一瞬で集まります!

Image downloaderはこのアプリが便利でした。

chromeウェブストア

【AI】イラストを描いてもらった

Flow」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

かわいいです。

私のようなおじさんに、天使は微笑むのでしょうか。

妖精さんですよね。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」