目次
「愛(ハートマーク)を見つける手伝いをしてくれる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. 作業を「保存(中断)」する方法
作業を途中で終わりたいときは、画面左上のメニューから以下の手順で「プロジェクトファイル」をダウンロードします。
- ナビゲーションバーの 「Actions」 をクリックします。
- 「Export Annotations」 を選択します。
- 一番上の 「A .zip package containing files in YOLO format.」 にチェックが入っていることを確認します。
- 「Export」 ボタンを押します。
これで、今までに枠をつけた分のデータがすべて入った .zip ファイルがPCにダウンロードされます。これでブラウザを閉じてしまっても大丈夫です!
2. 次回、作業を「再開」する方法
「昨日ここまでやったから、続きから枠をつけたい!」という時は、以下の手順で一瞬で元通りになります。
- 再び MakeSense.ai を開いて、右下の 「Get Started」 を押します。
- 最初に画像を選んだ画面で、「お手持ちのハートの画像」と「前回保存した.zipファイル(解凍しなくてOK!)」を同時にすべて選択してドロップします。
- 画面の指示に従って進めると、「前回どこまで枠をつけたか」が完全に復元された状態で、続きからアノテーションを再開できます!
何日かに分けて、隙間時間にコッソリとハートの教科書を作り進めることができます。
作業再会のコツ
・画像とテキストは必ずセット
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.yaml | YOLOが読むクラス定義。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.pypythonが古いとエラーが出たら新しくしましょう
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連の流れです。
うまくいくといいですね。
再学習で精度を上げるアクション
ネガティブサンプルを追加します。
- MakeSense.aiに「ハートが映っていない画像」を30枚追加
- 普通の石垣、ボタン、壁、道路など
- 枠をつけずにエクスポート → 空の.txtファイルができる
- 「石のハート」「自然物のグレーなハート」の画像を追加(可能なら10〜20枚)
- 灰色・茶色のハート型の石、木、葉っぱなど
- エクスポートして
dataset/images/all/とdataset/labels/all/に追加 - 再学習:
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)
- 「ハートみっけアプリ」のURLを開く
- 下の共有ボタン(□↑)をタップ
- 「ホーム画面に追加」をタップ
- → アプリアイコンとして追加される(次回から全画面で起動)
スマホ(Android Chrome)
- URLを開く
- アドレスバーに「インストール」バナーが自動表示される
- タップでインストール
PC(Chrome)
- URLを開く
- アドレスバー右端に「⊕ インストール」アイコンが出る
- クリックでデスクトップアプリとして追加
URLで使う → 今まで通りブラウザで開くだけ。
アプリとして使う → 上記手順でインストール。
どちらも同じコードで動作します。
【補足】素材の大量の「ハート画像」を手に入れる
学習させるのに必要な大量の「ハート画像」が必要です。AIさんに生成してもらうにも限界があります。どうしたら良いでしょう。
Google画像検索の「商用利用可(クリエイティブ・コモンズ)」から一括ダウンロードする
世界中の人が「ハートの形をした石」や「ハートの葉っぱ」の写真を、無料のフリー素材としてネット上にアップしています。
利用させてもらいましょう。
- Googleで
heart shape stoneやheart shaped leafと検索します。 - 検索窓の下の「ツール」>「ライセンス」で「クリエイティブ・コモンズ ライセンス」を選びます。これで無料で使っていい画像だけが絞り込まれます。
- Chromeの拡張機能(例:『Image Downloader』など)を使えば、画面に映った大量のハートの写真を1クリックで一括ダウンロードできます。これだけで100枚くらいは一瞬で集まります!


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


【AI】イラストを描いてもらった
「Flow」で作成した画像です。誰でもgoogleアカウントでログインして使えます。
かわいいです。

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

妖精さんですよね。
星間旅路のメロディ
「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」
「この電波はどこの星からきたのだろうか。」



