star back image
people4
電飾 電飾
moon
astronaut

【Webアプリ】四葉のクローバーを見つける(YOLO方式)

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

「四葉のクローバーを見つける手伝いをしてくれるAIアプリが欲しい」です。

Teachable Machineのときは「ドアップ(画面のほとんどが四葉)」じゃないと認識がダメでしたが、YOLO(You Only Look Once)方式がとても良いと言うのです。

私のiPhoneSE 第3世代だと動作がカクカクです。

あなたのスマホ、古すぎですよ。

それは「小さい四葉のクローバーを見つけるのに使えますか?」と質問しました。

草むらから1メートルも離れるのではなく、30センチくらいまでカメラを近づけて、スマホをゆっくりスライドさせながら歩く方法です。
これなら、YOLOの「動画でヌルヌル動く」という強みを活かしながら、四葉も画面の中でそこそこ大きく映るので、歩きながら「ピコンッ!」と自動で見つけてくれます。

「タイルスキャン」と「YOLO」を合体させたような技術

AIの世界にはYOLOを使って、豆粒みたいに小さいものを見つけるための神技術「SAHI(サヒ)」という仕組みがあります。

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

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

そんないい方法、早く教えて欲しかったです。
スマホでも思ったより快適に動作しました。

検証端末:iPhone 15 proにて成果報告。

スマホが熱くなったという報告あり。パワーを使うのかも。

ひとまず、成功と言っていいのでは。

私はちなみに2回、探索を試みましたが見つけられていません。

結局は時の運でしょうか。

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

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

私はWebコーダーです。YOLO用のHTMLコードに、小さな四葉のクローバーも見落とさないための技術**「SAHI(Slicing Aided Hyper Inference)」のロジックをフロントエンドだけで(完全無料で)組み込みたい**です。

課金を抑えるため、コード全体を再出力するのではなく、変更が必要な「前処理・推論・後処理(NMS)」のロジック部分だけをピンポイントで出力してください。

【やりたいこと】

runInference() の中で、カメラ映像全体をそのままAIに渡すのではなく、映像を内部で**縦横3×3(合計9マスのタイル)にスライス(切り出し)**します。

それぞれのスライスした画像を、既存の preprocess を使って 640x640 にリサイズして順番に yoloModel.executeAsync() にかけます。

各タイルで検出されたバウンディングボックスの座標(x1, y1, x2, y2)を、**カメラ映像全体の正しい座標へと逆計算(マージ)**します。

最後に、重なった検出枠を綺麗に1つにまとめるため、既存の nms(Non-Maximum Suppression)関数をそのまま通して、重複のない最終結果(lastResults)を作成するように書き換えてください。

スマホの負荷を考慮して、あまりに処理が重くならないよう、シンプルでクリーンなJavaScriptの実装をお願いします。

✅ コード完成(SAHI込み)

次→ アノテーション(Roboflow)
その次 → Google Colabで学習
最後 → モデルをFTPアップ
完成→ クローバーを探しに外に行こう

参考サイト
Ultralytics公式 - TF.jsエクスポート
YOLOv8-tfjs ブラウザデモ
Roboflow ブラウザアノテーション

【ステップ2】学習データ作成(アノテーション)

今、アノテーションで一押しなのが、Roboflow(最も高機能・無料プランあり)ということです。初見なのでよくわかりません。

URL: https://roboflow.com/

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

AIの補助が楽そうですね。

特徴内容
アカウント必要(Google連携で即登録)
無料枠1,000枚まで
AI補助あり(自動で似た物を囲んでくれる)
YOLO形式エクスポートワンクリック
データ増殖自動(反転・回転・明度変更で枚数を増やしてくれる)

Premium Trial — 14日間の無料お試し。クレカ不要。全機能使える。
Public Plan — 完全無料。ただし作ったデータが公開される。

使い方

  1. https://app.roboflow.com/ にログイン
  2. 「Create New Project」→ Object Detection を選択
  3. クラス名「four-leaf」を作成
  4. 画像をアップロード
  5. 画像上で四葉をマウスで矩形で囲む
  6. 「Generate」→ YOLO形式でエクスポート

Teachable Machine(前回)との違い

YOLO(物体検出)の場合、四葉の画像だけでOKです。

四葉のクローバーの画像だけでOK ?
三つ葉などの「違う画像」は必要ないそうです。

特徴Teachable MachineYOLO
タスク分類(画像全体が何か)検出(どこに何があるか)
学習方法「四葉」と「それ以外」の2クラス必須「四葉」の位置だけ教える
背景の扱い明示的に学習が必要囲まれていない部分=自動的に「背景」と学習

なぜ三つ葉が不要か

YOLOは「矩形で囲んだ場所 = 四葉」「囲んでいない場所 = 四葉じゃない」と自動で学びます。

写真の中に三つ葉が写っていても、そこを囲まなければ「これは四葉じゃない」と勝手に覚えてくれます。

あの苦労はいったい何だったんでしょうね。

ベストな画像

  • 草むらの中に四葉がある写真(周りに三つ葉が写っていてOK)
  • 四葉だけを矩形で囲む
  • 三つ葉は囲まない → それだけで「三つ葉≠四葉」を学習

つまり「四葉が写っている写真」を集めて、四葉の部分だけ矩形で囲めば完了です。

さぁこれから、沢山の画像の登録が始まります。

正直大変ですよね。

ユニバースで公開されているものが使える

Roboflowにはユニバースという先人たちのデータがたくさん公開されています。
クローバーはないだろうかと検索すると。

クローバーを見つけました
https://universe.roboflow.com/adam-fonagy/hunting-for-four-leaf-clovers
※”Hunting for four-leaf clovers” by Adam Fonagy License: CC BY 4.0

AIが言うのです。

このデータセットは使えます。むしろ最高のスタートポイントです!

四葉を求め、同じことを考えている人でしょうか。
ありがとう。使わせてもらいます。

おすすめの進め方

その1: このデータセットをダウンロード(今すぐ

  1. Roboflowにログイン済みなので、このプロジェクトページで「Download Dataset」をクリック
  2. フォーマットは 「YOLOv8」 を選択
  3. ZIPでダウンロード

その2: そのまま学習してみる(まず試す

  • 486枚で一旦YOLOモデルを学習させてみる
  • Google Colabで無料GPU(T4)を使えば、数十分で学習完了

その3: 自分の画像を追加して精度アップ(後から

  • 実際にスマホで撮影した「引きの草むら画像」を追加
  • 特に「遠目からの四つ葉」を重点的に追加

まずはそのままColabに送って学習を試す

ダウンロードのボタンを押します。

YOLOv5でもOKですが、v8の方が新しく、TensorFlow.js変換との相性が良いそうです。

フォーマットを選べます。
YOLOv8が2個あります。

ダウンロードオプションは必要でしょうか(・ω・;)

本当に何もわからないのすね。

  1. フォーマット: 「YOLOv8」 を選択
  2. ダウンロードオプション: 「Show download code」 のまま
  3. 「Continue」をクリック

→ コードスニペットが表示されます。それをそのままGoogle Colabに貼り付ければ、Colab上に直接データセットがダウンロードされます(PCを経由しない)。

表示されたコードをコピーして、次のColab学習ノートブックを作ります。

【ステップ3】Google Colabで学習

1: Google Colabを開く

https://colab.research.google.com にアクセスし、「新しいノートブック」を作成してください。

2: GPUを有効にする

メニュー → 「ランタイム」→「ランタイムのタイプを変更」 → ハードウェアアクセラレータを 「T4 GPU」 に変更 → 保存

3: 以下のコードを1セルずつ貼り付けて実行

セル1: インストール

!pip install roboflow ultralytics

セル2: データセットダウンロード

from roboflow import Roboflow
rf = Roboflow(api_key="まるまるまるまるapiキー")
project = rf.workspace("adam-fonagy").project("hunting-for-four-leaf-clovers")
version = project.version(20)
dataset = version.download("yolov8")

セル3: YOLOv8nモデルの学習(約15〜30分)

from ultralytics import YOLO

model = YOLO("yolov8n.pt")

results = model.train(
    data=dataset.location + "/data.yaml",
    epochs=100,
    imgsz=640,
    batch=16,
    name="clover_4leaf"
)

セル4: TensorFlow.js形式にエクスポート

from ultralytics import YOLO

best_model = YOLO("runs/detect/clover_4leaf/weights/best.pt")
best_model.export(format="tfjs")

セル5: モデルをZIPにしてダウンロード

import shutil

tfjs_path = "runs/detect/clover_4leaf/weights/best_web_model"
shutil.make_archive("clover_yolo_model", "zip", tfjs_path)

from google.colab import files
files.download("clover_yolo_model.zip")
所要時間の目安

約20〜40分

ステップ時間
セル1〜22〜3分
セル3(学習)15〜30分
セル4(エクスポート)2〜3分
セル5(ダウンロード)1分
完了したら

ダウンロードされた clover_yolo_model.zip を解凍して、中のファイルを clover_yolo/yolo_model/ フォルダに入れれば、すでに作成済みの index.html がそのモデルを読み込んで動きます!

学習時間が長いのは、素材の枚数が多いからですね。
zipダウンロードも終わりcolabの作業が終わりましたら、そのまま放置でOK。90分で自動で消えるそうです。

余談 エラーが出た

セッションが切れる時がある。

セル4: TensorFlow.js形式にエクスポートの時にありました。
怖いですね。

エラーの原因は、パッケージ更新後に「ランタイムの再起動」が必要なためです。
毎回ある?

その都度AIにコードを見せて確認するのがいいと思います。

⚠️ 再起動してもファイルは消えません。と言うので一安心。

失敗したセルから、やり直す形です。

再起動後、新しいコード枠で、
セル4: TensorFlow.js形式にエクスポートから、また実施しましたが、最後までいけました。

【共有】WEBアプリとソースコード

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

カメラの画面とボタンが3つのシンプルな設計。

Webアプリ:YOLO化完成バージョン
https://astrowave.jp/clover_yolo/

clover_yolo/
├── index.html (メインアプリ:YOLOv8 + SAHI推論ロジック)
├── manifest.json (PWA設定:ホーム画面追加・インストール用)
├── sw.js (オフライン動作・キャッシュ用)
├── tf.min.js (TensorFlow.js本体)
└── yolo_model/ (Google Colabからのモデル一式)
├── model.json (モデルの構造定義ファイル)
└── group1-shard*.bin (モデルの重みデータファイル)

※スマホ(PC)のブラウザ上で動くWebアプリです。
アプリのインストールは不要で、ブラウザから試せます。

※カメラの映像をサーバーに送信したり、保存したりすることはありませんので、安心して使ってください。

URLを開くと、カメラを使う許可が出ると思います。
カメラを使うアプリなので許可OKしてください。

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="#2e7d32">
<link rel="manifest" href="./manifest.json">
<title>四葉のクローバー検出 (YOLO版)</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: #000; }

#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: 12px;
  align-items: center;
}

button {
  padding: 10px 20px;
  font-size: 14px;
  font-weight: 600;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  background: rgba(255,255,255,0.9);
  color: #333;
  backdrop-filter: blur(4px);
}

button:active { opacity: 0.7; }

#status {
  position: absolute;
  top: 12px; left: 12px;
  z-index: 10;
  color: #fff;
  font-family: system-ui, sans-serif;
  font-size: 13px;
  background: rgba(0,0,0,0.5);
  padding: 6px 10px;
  border-radius: 4px;
  backdrop-filter: blur(4px);
}

#credit {
  position: absolute;
  top: 12px; right: 12px;
  z-index: 10;
  color: #fff;
  font-family: system-ui, sans-serif;
  font-size: 10px;
  text-align: right;
  background: rgba(0,0,0,0.5);
  padding: 4px 8px;
  border-radius: 4px;
  backdrop-filter: blur(4px);
  transition: opacity 1s;
}
#credit a { color: #fff; text-decoration: underline; }
#credit.hide { opacity: 0; pointer-events: none; }
</style>
</head>
<body>

<div id="container">
  <video id="video" autoplay playsinline muted></video>
  <canvas id="canvas"></canvas>
  <div id="status">カメラ起動中...</div>
  <div id="credit">Dataset: <a href="https://universe.roboflow.com/adam-fonagy/hunting-for-four-leaf-clovers" target="_blank">"Hunting for four-leaf clovers"</a> by Adam Fonagy (CC BY 4.0)</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 = ['clover-3', 'clover-4'];

  // デバイス判定
  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 = []; // { box, score, classId, className, hitCount, missCount }
  const HIT_TO_SHOW = 1;   // 1回で即表示(前と同じ速さ)
  const MISS_TO_HIDE = 5;  // 5回未検出で消す(チラつき防止)
  const IOU_MATCH = 0.3;   // 同一ボックスと判定するIoU閾値

  // 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 osc = audioCtx.createOscillator();
    const gain = audioCtx.createGain();
    osc.connect(gain);
    gain.connect(audioCtx.destination);
    osc.frequency.value = 880;
    osc.type = 'sine';
    gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
    osc.start();
    osc.stop(audioCtx.currentTime + 0.3);
  }

  // --- モデル読み込み ---
  async function loadModel() {
    status.textContent = 'YOLOモデル読み込み中...';
    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 = 'YOLOモデル準備完了';
    } catch (e) {
      console.error('YOLO読込失敗:', e);
      status.textContent = 'モデル読み込み失敗: ' + e.message;
    }
  }

  // --- カメラ起動 ---
  async function startCamera() {
    try {
      const constraints = {
        video: {
          facingMode: { ideal: 'environment' },
          width: { ideal: 1280 },
          height: { ideal: 720 }
        }
      };
      const stream = await navigator.mediaDevices.getUserMedia(constraints);
      video.srcObject = stream;
      await video.play();
      status.textContent = 'カメラ起動完了・モデル読込中...';
      resizeCanvas();
      await loadModel();
      detect();
    } catch (e) {
      status.textContent = 'カメラにアクセスできません: ' + e.message;
    }
  }

  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);
    });
  }

  // --- 後処理: NMS + フィルタリング ---
  function postprocess(output, imgWidth, imgHeight) {
    const results = [];
    const data = output.dataSync();

    // YOLOv8出力: [1, 4+numClasses, 8400] → transpose → [8400, 4+numClasses]
    const numClasses = CLASS_NAMES.length;
    const numDetections = 8400;
    const stride = 4 + numClasses;

    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);
      }
    }

    // NMS (Non-Maximum Suppression)
    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;
        const iou = computeIoU(boxes[indices[i]], boxes[indices[j]]);
        if (iou > 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);
  }

  // --- SAHI推論: 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;

      // 現在のタイルのrow/colを算出
      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);

      // タイルを切り出して640×640にリサイズ
      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;

      // 全タイルを1周したらNMS&トラッキング更新
      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]]
        }));

        lastResults = results;
        // 画面の25%以上を占める巨大ボックスはクローバーではないので除外
        const imgArea = canvas.width * canvas.height;
        const filtered = results.filter(r => {
          const [x1, y1, x2, y2] = r.box;
          const boxArea = (x2 - x1) * (y2 - y1);
          return boxArea < imgArea * 0.25;
        });
        updateTracking(filtered.filter(r => r.classId === 1));

        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 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;

        // バウンディングボックス
        ctx.strokeStyle = 'rgba(255, 215, 0, 0.9)';
        ctx.lineWidth = 3;
        ctx.strokeRect(x1, y1, bw, bh);

        // ターゲットマーク
        const cx = x1 + bw / 2;
        const cy = y1 + bh / 2;
        const radius = Math.min(bw, bh) * 0.3;

        ctx.beginPath();
        ctx.arc(cx, cy, radius, 0, Math.PI * 2);
        ctx.strokeStyle = 'rgba(255, 50, 50, 0.9)';
        ctx.lineWidth = 3;
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(cx - radius - 6, cy);
        ctx.lineTo(cx + radius + 6, cy);
        ctx.moveTo(cx, cy - radius - 6);
        ctx.lineTo(cx, cy + radius + 6);
        ctx.strokeStyle = 'rgba(255, 50, 50, 0.6)';
        ctx.lineWidth = 1.5;
        ctx.stroke();

        ctx.beginPath();
        ctx.arc(cx, cy, 4, 0, Math.PI * 2);
        ctx.fillStyle = 'rgba(255, 50, 50, 0.9)';
        ctx.fill();

        // ラベル
        const label = det.className + ' ' + Math.round(det.score * 100) + '%';
        ctx.font = 'bold 14px system-ui, sans-serif';
        const tw = ctx.measureText(label).width;
        ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
        ctx.fillRect(x1, y1 - 22, tw + 10, 22);
        ctx.fillStyle = 'rgba(255, 215, 0, 1)';
        ctx.fillText(label, x1 + 5, y1 - 6);
      }

      // ステータス(表示中の四つ葉の数を表示)
      const fourLeafCount = stableResults.length;
      status.textContent = fourLeafCount > 0
        ? '四葉: ' + fourLeafCount + '箇所検出!'
        : 'スキャン中...';

      // 毎フレーム1タイルずつ軽量スキャン
      runInference();
    }

    animId = requestAnimationFrame(detect);
  }

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

  const captureBtn = document.getElementById('captureBtn');
  captureBtn.addEventListener('click', function() {
    // 枠なしのカメラ映像のみを保存
    const saveCanvas = document.createElement('canvas');
    saveCanvas.width = canvas.width;
    saveCanvas.height = canvas.height;
    const saveCtx = saveCanvas.getContext('2d');
    saveCtx.drawImage(video, 0, 0, saveCanvas.width, saveCanvas.height);
    const link = document.createElement('a');
    link.download = 'clover_yolo_' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.png';
    link.href = saveCanvas.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();

  // クレジット表示を10秒後にフェードアウト
  setTimeout(() => {
    const credit = document.getElementById('credit');
    if (credit) credit.classList.add('hide');
  }, 5000);

  // Service Worker登録(オフライン対応)
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js');
  }
})();
</script>

</body>
</html>

所有の古いスマホ端末でもギリ許せる範囲の動作で、一安心。

【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で使う → 今まで通りブラウザで開くだけ。
アプリとして使う → 上記手順でインストール。

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

【補足】SAHI(サヒ)の動作フロー

カメラ映像(1280×720)

3×3 = 9タイルに分割(20%オーバーラップ付き)

各タイルを640×640にリサイズ → YOLO推論

検出座標をタイル位置分だけオフセット(元画像座標に変換)

9タイル分の検出結果をまとめてNMS(重複排除)

最終結果を描画

設定値
定数説明
SAHI_COLS3横分割数
SAHI_ROWS3縦分割数
SAHI_OVERLAP0.2タイル間の重なり率(20%)
なぜオーバーラップが必要か

タイルの境界線上にあるクローバーが「切られて」検出されないのを防ぎます。20%の重なりにより、境界付近のオブジェクトも必ずどちらかのタイルで完全に写ります。重複検出はNMSで自動的に除去されます。

スマホでの負荷
  • 9タイル × YOLO推論 = 現在のTeachable Machine版(6タイル)より少し重い
  • ただしYOLO nanoの1回の推論はTeachable Machine(MobileNet)と同等程度
  • 必要に応じて SAHI_COLS/ROWS を 2×2 = 4タイル に減らせば軽くできます。

読んで理解できますか。わたしも同じです。
スマホの時、2×2にしました。

以下も実行です。

4マスのタイルを「一瞬で同時に処理」するから重いのです。 これを、「今回のフレームは左上のマスだけ!次のフレームは右上のマスだけ!」 という風に、毎フレーム1マスずつ交代でスキャン(分散処理)させてあげます。

分散タイルスキャン方式

添付したYOLOコードですが、300msの間引きを入れても、runInference の中で4つのタイルを一度にループ処理しているため、推論が走る瞬間に画面が一瞬引っかかります(カクつきます)。

スマホでも100%ヌルヌル動かすために、**「1回の推論(runInference)につき、1つのタイルだけを処理する(分散タイルスキャン方式)」**に書き換えてください。

【修正のポイント】

グローバル変数に let currentTileIndex = 0; を追加し、runInference が呼ばれるたびに 0 → 1 → 2 → 3 と、1マスずつ交代でスキャンするようにしてください。

runInference 内のネストされた for ループを撤去し、currentTileIndex から現在の row と col を割り出して、1つのタイルだけを drawImage して predict にかけるようにします。

各マスの過去の検出結果が消えないように、lastResults ではなく、タイルごとに結果を保持するか、既存のトラッキング機能(updateTracking)へ直接1マス分の結果を流し込めるように調整してください。

これにより負荷が1/4に激減するため、INFERENCE_COOLDOWN(300msの休憩)は撤去するか、ごくわずか(16msなど)にして、毎フレーム1マスずつ超軽量にスキャンが回るようにしてください。

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

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

かわいいです。

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

妖精さんですよね。

星間旅路のメロディ

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

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