star back image
people4
電飾 電飾
moon
astronaut

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

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

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

どうしたのですか。

20年以上前の話です。
大阪の人気TV番組「探偵ナイトスクープ」で、四葉のクローバーの声が聞こえる女の子の回がありました。1分もしないで四葉のクローバーを見つけます。何個もです!

取材中の探偵も、TVの前の私も驚愕です。

やらせとか仕込みでは。

「四葉のクローバー探し」はしたことありますか?
私は数回ありますが、いずれも失敗。

実力の違いを見せつけられた私は、四葉のクローバーの声を聞く女の子を「遠い異国の主人公級お姫様のおとぎ話」として処理し、心の奥底にしまい込みました。

それから数十年。

会社のイベントで、公園でBBQをしていた時。
上司のご家族が、芝生のクローバー群生地から見つけてくるじゃありませんか。

あのナイトスクープの記憶が、走馬灯のように甦りました。

採取した大体の場所を教えてもらい、目を皿にして探しました。
でも、見つけられなかった。。

根気が足りない?

しかし今はAIの時代です。

【願望】AIに手伝ってもらえないだろか

相談したところ「出来ます」と言うのです。
先人がいるはずです。検索するとありました。
FOUR-LEAF: AIで四葉のクローバーを見つけよう

やっぱりありましたか。。AIと自作しようと思ったのですが。

評価がなんか微妙なラインでしょうか。
もしかしたら技術的に難しいのかも。

後発ですが、同じことをしたら怒られますでしょうか。AIに聞きました。
結論からお伝えします。

全く怒られる必要はありませんし、堂々と作って大丈夫です! 
法律的にも、エンジニアのカルチャー的にも、何一つ問題はありません。

「アイデア」に著作権はない
法律上、「四葉のクローバーをAIで探す」というアイデアそのものを独占することは誰にもできません。もしそれがダメなら、世の中に数え切れないほどある「家計簿アプリ」や「ToDoリストアプリ」は全部アウトになってしまいますよね。

相手のアプリのソースコードを盗んでコピーしたら怒られますが、あなたがCursorとエージェント(Geminiなど)を使ってゼロから自分で構築するコードは、完全にあなたのオリジナルです。

続けて言うのです。
「ブラウザで動くWebアプリ」ここに大きな強みがあります。

1.インストール不要の手軽さ
「URLをタップするだけで、その場でカメラが起動して探せる」という手軽さは、ダウンロードの手間がない分、圧倒的にユーザーに喜ばれます。

2.あなただけのカスタマイズ
先ほど妄想されたように、「見つかった時の演出」や「色の判定ロジック」を自分好みにいくらでもチューニングできます。

最終的に、iPhoneでもAndroidでもスマホの背面カメラを起動して、リアルタイムで画面を動かすことができます。

応援してくれてるみたいですね!

【考察】課金を進めない方向で作れないか

手元にあるものでなんとか形にしたいです。

妄想が「幸せになろうよ」と語りかけてきます。

要件は以下です。

  • 最終的にスマホで動作 getUserMedia(カメラアクセス機能)
  • ブラウザを使うWebGPU / WebGL(超高速グラフィック処理)
  • スマホ所有とネットが繋がっていること
  • ページを開いた後は、インターネット不要で

( ˘•ω•˘ ).。oスマホのカメラをクローバーに向けて、魔法少女のように草むらをサッとスキャンして、遅延なくリアルタイムで色の計算や画像認識が走る。。

山の中でも電波がなくても使えるように。

妄想がほとばしってますね。

以下の無料の環境構築を使ってスマホでテストするのが一番賢いです。
とAIは言います。

  • Visual Studio Code / Cursorの拡張機能「Live Server」を使う PC上でローカルサーバーを立ち上げ、同じWi-Fiに繋がっているスマホからPCのIPアドレス(http://192.168.X.X:5500 みたいなURL)を叩くだけで、一瞬でスマホのカメラが起動します。
  • GitHub Pages(無料)にアップする コードをGitHubにポンと置くだけで、世界中どこからでもスマホでアクセスできるURLが無料で手に入ります。

わたしはこのアストロウェーブ(ロリポップ)のレンタルサーバーにディレクトリを作って、FTPでアップします。

【ステップ1】緑色に反応させたい

PCのカメラに緑色のペンや葉っぱをかざした瞬間、画面上で赤い丸が追従する「あなただけのひみつ道具」の第一歩。

クローバーは緑色なので。
Cursorに以下の文を投げます。

私はWebコーダーです。スマホやPCのカメラを使って、草むらから「四葉のクローバー」を自動で見つけるアプリのプロトタイプを作りたいです。

課金を抑えたいので、まずはAIライブラリを使わず、純粋なフロントエンド(HTML/CSS/JS)だけで動く最小限のコードを作ってください。

【要件】

1.ブラウザ上でPCのカメラ(またはスマホの背面カメラ)をリアルタイムで画面いっぱいに起動する。

2.カメラ映像の中で「クローバーのような緑色(特定のRGB範囲)」の部分をリアルタイムで検知し、その場所にHTML5のCanvasを使って「赤い丸」を表示する。

1つのファイル(index.html)にまとめた、シンプルでクリーンなコードを出力してください。

まずは、クローバーの色に反応させようというステップです。

緑色を認識したいバージョン
https://astrowave.jp/clover/index_color.html

見つけた緑色にマークしましたでしょうか。

椅子の背もたれが緑色なんですね。

【ステップ2】AIの認識の目を組み込みたい

AIの認識ロジックはどうやるのでしょうか。さっぱりわかりません。。

そうすると、画像認識ライブラリ(TensorFlow.jscoco-ssd)を使うのが良いと教えてくれます。

ステップ1がサーバー上で無事に動作しました!カメラが起動して緑色を検知できています。

次は【ステップ2】として、外部の有料サーバーにデータを送る形ではなく、ユーザーの端末(ブラウザ上)だけで完結して無料で動く、軽量な画像認識ライブラリ(TensorFlow.js と cocossd など)を組み込みたいです。

課金(トークン)を節約したいので、ステップ1のコード全体を再出力するのではなく、変更・追加が必要な「CDNの読み込み部分」と「JavaScriptのAI認識ロジックの部分」だけをピンポイントで出力してください。

【やりたいこと】
緑色を検知している現在のロジックをベースにしつつ、AIがクローバー(または植物・物体)の「形」を認識して、その物体の中心座標に赤い丸(またはターゲットマーク)を表示するコードの追加をお願いします。

それにクローバーが手元にないです。
もちろん、四葉のクローバーは所持していません。

クローバーの画像をモニターに写して、Webカメラを向けてテストするしかないです。

coco-ssdのバージョン
https://astrowave.jp/clover/index_coco_ssd.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">
<title>四葉のクローバー検出</title>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd@2.2.3/dist/coco-ssd.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);
}
</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>
  </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 cocoModel = null;

  // HSV色範囲の閾値(クローバーの緑を検出)
  const HUE_MIN = 60;
  const HUE_MAX = 160;
  const SAT_MIN = 40;
  const VAL_MIN = 30;
  const VAL_MAX = 220;

  // クラスタリングの最小ピクセル数(ノイズ除去)
  const MIN_CLUSTER_SIZE = 80;

  // 間引きステップ(パフォーマンス用)
  const STEP = 4;

  // AI検出の間引き(重いので数フレームに1回)
  let frameCount = 0;
  const AI_INTERVAL = 15;
  let lastDetections = [];

  function rgbToHsv(r, g, b) {
    r /= 255; g /= 255; b /= 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const d = max - min;
    let h = 0;
    const s = max === 0 ? 0 : (d / max) * 100;
    const v = max * 100;

    if (d !== 0) {
      if (max === r) h = 60 * (((g - b) / d) % 6);
      else if (max === g) h = 60 * ((b - r) / d + 2);
      else h = 60 * ((r - g) / d + 4);
    }
    if (h < 0) h += 360;
    return [h, s, v];
  }

  function isCloverGreen(r, g, b) {
    const [h, s, v] = rgbToHsv(r, g, b);
    return h >= HUE_MIN && h <= HUE_MAX &&
           s >= SAT_MIN &&
           v >= VAL_MIN && v <= VAL_MAX;
  }

  async function loadAIModel() {
    status.textContent = 'AIモデル読み込み中...';
    try {
      cocoModel = await cocoSsd.load({ base: 'lite_mobilenet_v2' });
      status.textContent = 'AIモデル準備完了';
    } catch (e) {
      status.textContent = 'AIモデル読み込み失敗: ' + 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 = 'カメラ起動完了・AIモデル読込中...';
      resizeCanvas();
      await loadAIModel();
      detect();
    } catch (e) {
      status.textContent = 'カメラにアクセスできません: ' + e.message;
    }
  }

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

  function drawTarget(cx, cy, radius, label) {
    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();

    if (label) {
      ctx.font = '12px system-ui, sans-serif';
      ctx.fillStyle = 'rgba(255, 50, 50, 0.95)';
      ctx.fillText(label, cx + radius + 8, cy + 4);
    }
  }

  function drawBoundingBox(x, y, w, h, label, score) {
    ctx.strokeStyle = 'rgba(0, 255, 150, 0.8)';
    ctx.lineWidth = 2;
    ctx.strokeRect(x, y, w, h);

    const text = label + ' ' + Math.round(score * 100) + '%';
    ctx.font = 'bold 11px system-ui, sans-serif';
    const tw = ctx.measureText(text).width;
    ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
    ctx.fillRect(x, y - 18, tw + 8, 18);
    ctx.fillStyle = 'rgba(0, 255, 150, 1)';
    ctx.fillText(text, x + 4, y - 5);
  }

  async 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) {
      animId = requestAnimationFrame(detect);
      return;
    }

    // --- 色検出(毎フレーム) ---
    const imageData = ctx.getImageData(0, 0, w, h);
    const data = imageData.data;

    let sumX = 0;
    let sumY = 0;
    let count = 0;

    for (let y = 0; y < h; y += STEP) {
      for (let x = 0; x < w; x += STEP) {
        const i = (y * w + x) * 4;
        if (isCloverGreen(data[i], data[i+1], data[i+2])) {
          sumX += x;
          sumY += y;
          count++;
        }
      }
    }

    // --- AI物体検出(数フレームに1回) ---
    frameCount++;
    if (cocoModel && frameCount % AI_INTERVAL === 0) {
      try {
        lastDetections = await cocoModel.detect(video, 10, 0.3);
      } catch (e) { /* skip */ }
    }

    // --- 描画 ---
    ctx.drawImage(video, 0, 0, w, h);

    // 緑色重心マーカー
    if (count >= MIN_CLUSTER_SIZE) {
      const cx = sumX / count;
      const cy = sumY / count;
      const radius = Math.min(30, Math.sqrt(count * STEP) * 0.8);
      drawTarget(cx, cy, radius, 'green:' + count + 'px');
    }

    // AI検出結果の描画
    let plantCount = 0;
    for (const det of lastDetections) {
      const [bx, by, bw, bh] = det.bbox;
      const cx = bx + bw / 2;
      const cy = by + bh / 2;

      drawBoundingBox(bx, by, bw, bh, det.class, det.score);
      drawTarget(cx, cy, 18, det.class);

      if (det.class === 'potted plant' || det.class === 'broccoli') {
        plantCount++;
      }
    }

    // ステータス更新
    const aiInfo = lastDetections.length > 0
      ? ' | AI: ' + lastDetections.map(d => d.class).join(', ')
      : '';
    status.textContent = '緑:' + count + 'px' + aiInfo;

    animId = requestAnimationFrame(detect);
  }

  toggleBtn.addEventListener('click', function() {
    detecting = !detecting;
    toggleBtn.textContent = detecting ? '検出 ON' : '検出 OFF';
    if (!detecting) {
      status.textContent = '検出停止中';
    }
  });

  window.addEventListener('resize', resizeCanvas);

  startCamera();
})();
</script>

</body>
</html>

AIに四葉のクローバーの画像を育成してもらい、カメラテストすると。。

tvとテキストが出力されています。

カメラを人に向けたらperson、キーボードに向けたらkeyboardとテキスト出力がありますね。

クローバーと出ませんが、AIがカメラの内容を認識しているっぽいです。
単純にすごくないですか?

「なぜtvなの?クローバーは?」とAIに聞きました

現在組み込んだ coco-ssd というAIモデルは、「日常にある一般的なもの(人、車、犬、コップ、テレビ、キーボードなど)を90種類」だけを認識するように作られたモデルです。

クローバーに反応しない理由: このモデルの「90種類」の中に、そもそも「クローバー(または四葉のクローバー)」というカテゴリが存在しないからです。AIにとっては「知らないもの」なので、完全に無視されてしまっています。

「tv」の正体: クローバーの画像を映している「モニター(テレビの画面)」そのものをAIが認識してしまっているため、画面の中央から「tv」というテキストが動かない状態になっています。

coco-ssdにクローバーは存在しないのですね。

なるほど。ですよね。
僕も最初からそう思ってました。

なるほどとか最初からとか、馬鹿ワードですよ!
気をつけて。

粋がっちゃいました。すいません。
ここまで来たのも私の実力ではありません。m(_ _)m ゴメンナサイ

AIに作成してもらった四葉のクローバーの画像

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

確認用の四葉のクローバーが2個ある想定です。

もちろん。どこに四葉のクローバーがあるか、わかるようにしています。

さて、これからどうやって四葉のクローバーをAIに認識してもらうのでしょう。
さっぱりわかりません。

【ステップ3】Teachable Machineをつかう

AIが言うのです。googleのTeachable Machineを使いましょうよと。

音声やポーズもトレーニングできるようですが、今回は画像で学習します。

Teachable Machineって知ってましたか?

常識じゃないんですか?
知らんけど。

先ほど(ステップ2)は、personやkeyboardの認識成功おめでとう!

次の【ステップ3】として、いよいよこのアプリを「四葉のクローバー」専用にしたい。

ここに、私がAIで作成した四葉のクローバーの画像を貼り付けたよ。
この画像が、私が探したい四葉のクローバーの「理想的な形(特徴)」だよ。

課金を抑えるため、サーバーを使わない(完全無料の)Teachable Machineなどの仕組みを使い、私が自分でこの画像をAIに学習させ、その学習モデル(無料のURLやファイル)を読み込ませるようにしたい。

今のコードのJavaScriptのモデル読み込み部分(loadModel)を、独自のTeachable Machineモデルを読み込むように書き換えるための最小限の修正コードを教えて。

🚀 学習とURLの発行

認識の精度が上がるまで繰り返す。

  1. 画面中央の 「Train Model(モデルをトレーニング)」 ボタンを押します。
  2. 完了すると、右側に「Preview」画面が出ます。
  3. Preview画面の上にある 「Export Model(モデルをエクスポート)」 という青いボタンを押します。
  4. ポップアップが出るので、「Upload my model(モデルをアップロード)」 という共有リンク作成ボタンを押します。
  5. 数秒待つと、https://teachablemachine.withgoogle.com/models/XXXXX/ というあなた専用の無料URLが発行されます!
// ★ Teachable Machine のモデルURL(学習後にここを書き換える)
const TM_MODEL_URL = 'https://teachablemachine.withgoogle.com/models/ここに発行されたIDを入れる/';

ゲットしたURLが404になってしまう。どうして?

よくわからないのでダウンロードに変更です。

Teachable Machineでモデルをダウンロード

トレーニングのコツ

  • Teachable Machineの「ウェブカメラ」機能で、部屋の中をカメラで撮影しながら直接サンプルを追加すると楽です。
  • 学習後にエクスポートして model/ フォルダのファイル3つ(model.jsonmetadata.jsonweights.bin)を上書きアップロードするだけ

【ステップ4】学習手順の内容

AI(Teachable Machine)に「これが四葉だよ」と教え、そのモデルを組み込むコードを書かせれば、あなたの家庭菜園で活躍する「あなただけの四葉の目」がついに完成します!

AIも順調に進んでいるのがわかるのか、会話が前のめり気味です。

画像さえあれば、GoogleのTeachable Machine(ティーチャブルマシーン)の作業は、専門知識ゼロでも3分で終わるクリックパズルのようなものです。

CDNの読み込みも、AIでの画像認識も、お金は1円もかかりません(完全無料)です!

「無料」すばらしい響きですね。

Teachable Machineでの3ステップ学習手順

【クラス1:四葉のターゲット(見つけたいもの)】

  1. 「Class 1」の名前を four-leaf に書き換えます。
  2. 「Upload」 ボタンを押し、あなたが用意してくれた画像 clover_1.jpeg(赤丸がない綺麗な方) をドラッグ&ドロップして入れます。
  3. (※もし可能なら、その画像を少し拡大したものや、回転させたものなど、何枚かバリエーションを入れると賢くなりますが、まずは1枚だけでもOKです!)

【クラス2:その他(無視したいもの)】

  1. 「Class 2」の名前を other に書き換えます。
  2. ここには、「四葉ではないもの(テレビ画面、キーボード、自分の顔など)」を教えます。
  3. 「Webcam」 ボタンを押し、PCのウェブカメラに向かって、自分の顔やキーボード、部屋の景色を映しながら「Hold to Record(長押しして録画)」を数秒押して、100枚くらいパパパパッと撮影してしまいます。
  4. (※これで、AIは「テレビ画面やキーボードは四葉じゃないんだな」と学びます)

、「tv」や「person」の代わりに「four-leaf(四葉!)」を狙い撃ちで探す特化型カメラに変貌します。

矛盾と現状の問題点

カメラの画面中央にクローバーを持って行って拡大する。
すると四葉クローバーを認識するまで出来ました。

基本動作バージョン
https://astrowave.jp/clover/index_kihon.html

しかしそれだと「四葉のクローバーをAIよりも先に見つけていること」になります。

四葉のクローバー判定機ですね。

宝探しレーダーとしては使えません。失格です!

認識&判定をしてもらいたいがために、ドアップのクローバー画像ばかりをTeachable Machineに投げ込んでいました。

AIは言います。

「Teachable Machineで再トレーニングをするしかない」と。

【共有】ソースコード

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

完成バージョン
https://astrowave.jp/clover

ファイル内容
astrowave.jp/
└── clover/
├── index.html (メインプログラム: HTML/CSS/JS)
├── sw.js (オフライン動作・キャッシュ管理)
├── manifest.json (PWA設定: インストールするため)
├── tf.min.js (ライブラリ:js 本体)
├── teachablemachine-image.min.js (ライブラリ: 画像認識用)
└── model/ (AI学習モデルフォルダ)
├── model.json (モデルの構造定義)
├── metadata.json (クラス名などのメタデータ)
└── weights.bin (学習済みの重みデータ)

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>四葉のクローバー検出</title>
<script src="./tf.min.js"></script>
<script src="./teachablemachine-image.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);
}
</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', { willReadFrequently: true });
  const status = document.getElementById('status');
  const toggleBtn = document.getElementById('toggleBtn');

  let detecting = true;
  let animId = null;
  let tmModel = null;

  // ★ Teachable Machine のモデルURL(学習後にここを書き換える)
  const TM_MODEL_URL = './model/';

  // HSV色範囲の閾値(クローバーの緑を検出)
  const HUE_MIN = 60;
  const HUE_MAX = 160;
  const SAT_MIN = 40;
  const VAL_MIN = 30;
  const VAL_MAX = 220;

  // クラスタリングの最小ピクセル数(ノイズ除去)
  const MIN_CLUSTER_SIZE = 80;

  // 間引きステップ(パフォーマンス用)
  const STEP = 4;

  // デバイス自動判定(スマホ → 軽量モード)
  const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

  // AI検出の間引き(重いので数フレームに1回)
  let frameCount = 0;
  const AI_INTERVAL = isMobile ? 8 : 12;

  // グリッドスキャン設定(スマホ: 3×2=6タイル / PC: 8×6=48タイル)
  const GRID_COLS = isMobile ? 3 : 8;
  const GRID_ROWS = isMobile ? 2 : 6;

  // 安定化: 各タイルのスコアを追跡(点滅防止)
  let tileScores = {}; // key: "row,col" → { score, data }
  const SCORE_UP = 2;       // 検出時にスコア加算
  const SCORE_DOWN = 1;     // 未検出時にスコア減算
  const SHOW_THRESHOLD = isMobile ? 2 : 3; // スマホは即反応、PCは安定重視
  const MAX_SCORE = 8;      // スコア上限
  let confirmedResults = []; // 安定した検出結果(描画に使用)

  // オフスクリーンキャンバス(タイル切り出し用)
  const tileCanvas = document.createElement('canvas');
  const tileCtx = tileCanvas.getContext('2d');
  tileCanvas.width = 224;
  tileCanvas.height = 224;

  function rgbToHsv(r, g, b) {
    r /= 255; g /= 255; b /= 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const d = max - min;
    let h = 0;
    const s = max === 0 ? 0 : (d / max) * 100;
    const v = max * 100;

    if (d !== 0) {
      if (max === r) h = 60 * (((g - b) / d) % 6);
      else if (max === g) h = 60 * ((b - r) / d + 2);
      else h = 60 * ((r - g) / d + 4);
    }
    if (h < 0) h += 360;
    return [h, s, v];
  }

  function isCloverGreen(r, g, b) {
    const [h, s, v] = rgbToHsv(r, g, b);
    return h >= HUE_MIN && h <= HUE_MAX &&
           s >= SAT_MIN &&
           v >= VAL_MIN && v <= VAL_MAX;
  }

  async function loadAIModel() {
    status.textContent = 'AIモデル読み込み中...';
    try {
      const baseUrl = TM_MODEL_URL.endsWith('/') ? TM_MODEL_URL : TM_MODEL_URL + '/';
      const modelURL = baseUrl + 'model.json';
      const metadataURL = baseUrl + 'metadata.json';
      tmModel = await tmImage.load(modelURL, metadataURL);
      status.textContent = 'AIモデル準備完了 (クラス: ' + tmModel.getClassLabels().join(', ') + ')';
    } catch (e) {
      console.error("AI読込失敗詳細:", e);
      status.textContent = 'AIモデル読み込み失敗: ' + 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 = 'カメラ起動完了・AIモデル読込中...';
      resizeCanvas();
      await loadAIModel();
      detect();
    } catch (e) {
      status.textContent = 'カメラにアクセスできません: ' + e.message;
    }
  }

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

  function drawTarget(cx, cy, radius, label) {
    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();

    if (label) {
      ctx.font = '12px system-ui, sans-serif';
      ctx.fillStyle = 'rgba(255, 50, 50, 0.95)';
      ctx.fillText(label, cx + radius + 8, cy + 4);
    }
  }

  function drawBoundingBox(x, y, w, h, label, score) {
    ctx.strokeStyle = 'rgba(0, 255, 150, 0.8)';
    ctx.lineWidth = 2;
    ctx.strokeRect(x, y, w, h);

    const text = label + ' ' + Math.round(score * 100) + '%';
    ctx.font = 'bold 11px system-ui, sans-serif';
    const tw = ctx.measureText(text).width;
    ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
    ctx.fillRect(x, y - 18, tw + 8, 18);
    ctx.fillStyle = 'rgba(0, 255, 150, 1)';
    ctx.fillText(text, x + 4, y - 5);
  }

  async 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 imageData = ctx.getImageData(0, 0, w, h);
      const data = imageData.data;
      let sumX = 0, sumY = 0, count = 0;

      for (let y = 0; y < h; y += STEP) {
        for (let x = 0; x < w; x += STEP) {
          const i = (y * w + x) * 4;
          if (isCloverGreen(data[i], data[i+1], data[i+2])) {
            sumX += x;
            sumY += y;
            count++;
          }
        }
      }

      if (count >= MIN_CLUSTER_SIZE) {
        const cx = sumX / count;
        const cy = sumY / count;
        const radius = Math.min(20, Math.sqrt(count * STEP) * 0.5);
        ctx.beginPath();
        ctx.arc(cx, cy, radius, 0, Math.PI * 2);
        ctx.strokeStyle = 'rgba(100, 255, 100, 0.4)';
        ctx.lineWidth = 1.5;
        ctx.stroke();
      }

      // AI検出マーカー(検出数が多すぎる場合は上位のみ+ガイド表示)
      const found = confirmedResults.length;
      const TOO_MANY = 5;
      const TOP_N = 3;

      if (found > TOO_MANY) {
        // 遠すぎて全体判別不能 → 上位3箇所だけマーカー表示 + ガイド
        const sorted = [...confirmedResults].sort((a, b) => b.prob - a.prob);
        for (let i = 0; i < Math.min(TOP_N, sorted.length); i++) {
          const result = sorted[i];
          drawTarget(result.cx, result.cy, 35, '四葉! ' + Math.round(result.prob * 100) + '%');
          ctx.strokeStyle = 'rgba(255, 215, 0, 0.9)';
          ctx.lineWidth = 3;
          ctx.strokeRect(result.cx - result.w / 2, result.cy - result.h / 2, result.w, result.h);
        }
        // ガイドメッセージ
        ctx.font = 'bold 16px system-ui, sans-serif';
        ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
        ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)';
        ctx.lineWidth = 3;
        const msg = '↓ もっと近づくと精度UP ↓';
        const msgW = ctx.measureText(msg).width;
        ctx.strokeText(msg, (w - msgW) / 2, h - 50);
        ctx.fillText(msg, (w - msgW) / 2, h - 50);
      } else if (found > 0) {
        // 適切な検出数 → 全マーカー表示
        for (const result of confirmedResults) {
          drawTarget(result.cx, result.cy, 35, '四葉! ' + Math.round(result.prob * 100) + '%');
          ctx.strokeStyle = 'rgba(255, 215, 0, 0.9)';
          ctx.lineWidth = 3;
          ctx.strokeRect(result.cx - result.w / 2, result.cy - result.h / 2, result.w, result.h);
        }
      }

      // ステータス更新
      const aiInfo = found > TOO_MANY
        ? ' | 四葉候補: ' + TOP_N + '箇所(近づいて確認)'
        : found > 0
          ? ' | 四葉: ' + found + '箇所検出!'
          : ' | スキャン中...';
      status.textContent = '緑:' + count + 'px' + aiInfo;
    }

    animId = requestAnimationFrame(detect);

    // --- AI処理は描画後に非同期で実行(画面には影響しない) ---
    if (detecting && tmModel) {
      frameCount++;
      if (frameCount % AI_INTERVAL === 0) {
        await runAIScan(w, h);
      }
    }
  }

  async function runAIScan(w, h) {
    try {
      const tileW = w / GRID_COLS;
      const tileH = h / GRID_ROWS;
      const currentHits = {};

      for (let row = 0; row < GRID_ROWS; row++) {
        for (let col = 0; col < GRID_COLS; col++) {
          const sx = col * tileW;
          const sy = row * tileH;

          tileCtx.drawImage(video, sx, sy, tileW, tileH, 0, 0, 224, 224);

          // 緑色フィルタ: タイル内の緑色割合をチェック
          const tileImageData = tileCtx.getImageData(0, 0, 224, 224);
          const tileData = tileImageData.data;
          let greenCount = 0;
          const tileStep = 4;
          const totalSampled = Math.floor(224 / tileStep) * Math.floor(224 / tileStep);
          for (let ty = 0; ty < 224; ty += tileStep) {
            for (let tx = 0; tx < 224; tx += tileStep) {
              const ti = (ty * 224 + tx) * 4;
              if (isCloverGreen(tileData[ti], tileData[ti+1], tileData[ti+2])) {
                greenCount++;
              }
            }
          }
          const greenRatio = greenCount / totalSampled;
          if (greenRatio < 0.3) continue;

          const preds = await tmModel.predict(tileCanvas);

          let fourProb = 0;
          for (const p of preds) {
            if (p.className === 'four-leaf' || p.className === 'four_leaf' || p.className === '4leaf' || p.className === '四葉' || p.className === 'yotsuba') {
              fourProb = p.probability;
            }
          }

          if (fourProb > 0.99) {
            const key = row + ',' + col;
            currentHits[key] = { cx: sx + tileW / 2, cy: sy + tileH / 2, w: tileW, h: tileH, prob: fourProb };
          }
        }
      }

      // スコアシステムで安定化
      for (const key in currentHits) {
        if (!tileScores[key]) {
          tileScores[key] = { score: 0, data: currentHits[key] };
        }
        tileScores[key].score = Math.min(tileScores[key].score + SCORE_UP, MAX_SCORE);
        tileScores[key].data = currentHits[key];
      }
      for (const key in tileScores) {
        if (!currentHits[key]) {
          tileScores[key].score -= SCORE_DOWN;
          if (tileScores[key].score <= 0) {
            delete tileScores[key];
          }
        }
      }

      // スコアが閾値以上のタイルのみ表示
      const newConfirmed = [];
      for (const key in tileScores) {
        if (tileScores[key].score >= SHOW_THRESHOLD) {
          newConfirmed.push(tileScores[key].data);
        }
      }
      confirmedResults = newConfirmed;

      // 検出音を鳴らす(5箇所以下の有効検出がある場合のみ)
      if (soundEnabled && newConfirmed.length > 0 && newConfirmed.length <= 5) {
        playDetectionBeep();
      }
    } catch (e) { /* skip */ }
  }

  // --- 検出音(Web Audio API) ---
  let soundEnabled = true;
  let lastSoundTime = 0;
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  function playDetectionBeep() {
    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);
  }

  // --- スクリーンショット保存 ---
  const captureBtn = document.getElementById('captureBtn');
  captureBtn.addEventListener('click', function() {
    const link = document.createElement('a');
    link.download = 'clover_' + 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';
  });

  toggleBtn.addEventListener('click', function() {
    detecting = !detecting;
    toggleBtn.textContent = detecting ? '検出 ON' : '検出 OFF';
    if (!detecting) {
      status.textContent = '検出停止中';
    }
  });

  window.addEventListener('resize', resizeCanvas);

  startCamera();
})();
</script>

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

</body>
</html>

スマホ(iphone SE)で試すと、かなり動作が遅いですね!

モバイルの時は、スキャンパネルの枚数を減らしました。。

感度がよくないと感じる時

・思ったように四葉に反応してくれない。
・関係ないところが反応する。

もう、Teachable Machineで再トレーニングをするしかないようです。

学習における画像選定の「方法論がフワッとしていて」、手探り状態で突き進んでいたなと言う感想です。

そこが難しいのですかね。

対策1: コード側フィルタリング強化(即効性あり)

グリッドスキャン部分を修正

  • 各タイル内の「緑色ピクセル数」を先に計算
  • 緑色が一定割合以上あるタイルのみAI判定にかける

これだけでカーテンや壁への誤反応がかなり消えました。

対策2: 学習データの改善(根本改善)

Teachable Machineで再トレーニングする際のガイドライン

「四葉」クラス(50枚以上推奨)

  • 四葉のクローバー1株のアップ画像(様々な角度・照明)
  • 背景に三つ葉が写り込んでいてもOK(メインが四葉であれば)
  • AI生成画像 + 実際のクローバー写真を混ぜる
  • 明るい写真、暗い写真、ぼやけた写真を含める

「三つ葉/背景」クラス(50枚以上推奨)

  • 三つ葉のクローバーのアップ画像
  • クローバーの群生(四葉が含まれていないもの)
  • 草むら、芝生、緑の葉っぱ(クローバー以外)
  • 室内の風景(カーテン、壁、机など) ← これが重要

「四葉ではないもの」を多様に学習させることで、誤検出が大幅に減る。
実際に使用する時を想定のカメラとの距離も大切。

結局エポックを100の、80枚近く登録していました。

対策3: 検出閾値の引き上げ

最初の閾値 0.6(60%)を 0.9(90%)に引き上げることで、確信度が高い検出のみ表示する。

ゆるい設定にすると、ほとんどの箇所で四葉と認識します。

↑こう表示されると、途方にくれますよね。

改善を続けようと思った

動けばいいみたいなザックリ感が悪い印象。
使っていてやっぱり微妙な認識力。
実際にスマホで動かしたら、画面を細かく分けてAIに何度も計算させるせいで、スマホがめちゃくちゃ重くなってしまいました。

使えないアプリ作った?

わんことお散歩しながらクローバーを探して撮影したりして少し疲れましたが、ブラッシュアップしたい気持ちが湧いてくる不思議な感覚です。。

(´・ω・`)ヨウセイサン..

妖精さんが寄ってくるようなアプリにしたいです。

湧き上がる不思議な感覚はひとえに、1つもゲットしたことがない四葉への渇望ではないでしょうか。

ヤフオクで買えますよ。

不満をAIにぶつけました。
以下のように攻めようと言うのです。

項目内容
YOLOモデルに変更グリッド不要で直接「四葉の位置」を検出できる。精度が桁違いだが学習データ収集が大変
コミュニティ機能発見した場所を共有する地図

YOLOモデルってなんですかね。。

現在(Teachable Machine)の推論回数/スキャンは、48回(タイル数)です。YOLO nano(軽量版)だと、1回でできる。合計負荷は同等程度との推論。

YOLO(You Only Look Once)方式:
名前の通り「一回見るだけでいい(You Only Look Once)」という天才的なアルゴリズムです。
画面全体を1回パッと見るだけで、画面の中にある「すべての物体の位置(バウンディングボックス)」と「それが何であるか(四葉か三つ葉か)」を、同時に一瞬で割り出します。

  • 性能面: 古いスマホでも今と同等かそれ以上に動く可能性あり
  • 導入難易度: Teachable Machineに比べて格段に難しい
  • 現実的なタイミング: 実際に四葉を何度も見つけて、写真を蓄積してからがベスト

今のアプリで四葉を見つけた時にスクリーンショットを貯めていくのが、将来YOLO化する時のデータ収集になります。ライフワークとしてちょうど良いステップです。

家から出ないといけませんね。

今の学習データはそのまま使える?

使えません。 形式が根本的に違います。

Teachable Machine(今)YOLO
学習データ画像 → ラベル(分類)画像 → 四葉の位置を矩形で囲む
作業画像をフォルダに入れるだけ1枚1枚、四葉の場所を手動で囲む
ツールブラウザ(簡単)Python + アノテーションツール

学習データは使えない。まじですか。。
最初から教えて欲しかったかもです。(>ω<。)

【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】イラストを描いてもらった

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

かわいいです。

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

妖精さんですよね。

星間旅路のメロディ

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

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