11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CursorでGoogle Apps Scriptの迷子インコ判定アプリを開発してみた

11
Last updated at Posted at 2025-12-08

バードライフアドバイザーの山本剛史です。
先日、同じ部署のメンバーから 「出社のタイミングで迷子のインコを見かけた」 とのSlackに投稿があり、気になってどんなインコだったか尋ねてみたところ、日本で野生化が進むワカケホンセイインコでした。
近年、東京・神奈川を中心にワカケホンセイインコの個体数が増え、迷子インコとの識別が難しくなっています。
そこで、Cursorを使って、Google Apps Scriptによる迷子インコ判定アプリをさくっと作成してみました。

バードライフアドバイザーとは

バードライフアドバイザーとは認定NPO法人TSUBASAが提供している資格です。鳥の中でもフィンチ、インコ、オウムの飼い鳥の飼育方法に特化しており、現在3級から1級の3つの認定講座が提供されています。

私もバードライフアドバイザーの3級、2級の認定講座を受け、資格を取得しました*。弊社内で鳥を飼いたいという相談が来た時に備えて、鳥を飼う責任や必要な飼育道具、ライフスタイルにあった鳥種のアドバイスができるよう準備しています。

なお、今のところ相談は0件です。
*バードライフアドバイザー2級は有効期限があり、更新条件を満たさなかったため、失効中

東京ではインコの野生化が進む

そんな中、冒頭ご紹介したような迷子インコの識別における課題が顕在化しました。皆様もご存じの通り、元々日本にはインコは生息していません。しかし、ワカケホンセイインコという種類が1960年ごろに日本に持ち込まれ、東京を中心に個体数を増やしています。

image.png

ワカケホンセイインコの野生化問題については研究者のインタビュー記事をご覧ください。

桜の花びらを落とすところや、フン害などがテレビで報じられることも多いです。

インコ野生化で迷子鳥の識別が困難に

ワカケホンセイインコが日本で野生化した結果、インコを飼っていない人が、外で見かけたインコが迷子鳥なのか識別が難しい状態です。

ただ、迷子となってしまった飼い鳥は外で生き延びることはできません。捕食者に狙われる前に保護してあげることで命を繋げることができます。

ワカケホンセイインコだろうと思っていたけど、実は飼育されていたインコだったパターンは避けなければなりません。

迷子インコ判定アプリをCursorで開発

そこでワカケホンセイインコかもとSNS等での拡散に躊躇される方にむけて、迷子インコ判定アプリをCursorを使って開発してみました。

CursorのAgentモードで下記のプロンプトを入力し、迷子鳥判定アプリをGoogle Apps Scriptで開発するよう依頼します。

スクリーンショット 2025-11-27 17.46.32.png

Google Apps Scriptで迷子鳥かどうか判定するwebアプリを作りたいです。アップロードされたインコの写真を分析し、日本で野生化しているワカケホンセイインコか、迷子のペットかを判定するアプリです。
画像をアップロードすると、Gemini APIに画像を送ってワカケホンセイインコか判定してもらい、以下のようなフィードバックをするようにしてください。
* ワカケホンセイインコではない場合、迷子の可能性が高いので、SNS等で目撃場所や写真を投稿するよう案内してください。
* ワカケホンセイインコ1羽だけの場合は、飼われているワカケホンセイインコの可能性もあるので、同様に案内してください。
* ワカケホンセイインコが複数羽の場合は、迷子の可能性はほとんどないため、野生のワカケホンセイインコで迷子鳥ではないとアナウンスする。

今回は画像認識の精度が高いGeminiのAPIを利用する形としました。なおCursorでGeminiのAPIを使うように指示を出すと、高確率で古いバージョンが使用されます。そこで、こちらからgemini 2.5 proの指定とモデル名の変数を定義し、APIのエンドポイントURLを設定するように追加で指示しました。

これでGoogle Apps Script(GAS)のスクリプトファイルとindex.htmlが生成されました。

Code.gs
/**
 * 迷子鳥判定アプリ - メインスクリプト
 * ワカケホンセイインコかどうかを判定するWebアプリ
 */

// スクリプトプロパティからGemini APIキーを取得
const GEMINI_API_KEY = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');

// 使用するGeminiモデルを定義(新しいモデルが出た時にここを変更するだけでOK)
const GEMINI_MODEL = 'gemini-2.5-pro';

/**
 * Webアプリのエントリーポイント
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('迷子鳥判定アプリ')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * 画像を分析してワカケホンセイインコかどうかを判定
 * @param {string} base64Image - Base64エンコードされた画像データ
 * @return {Object} 判定結果
 */
function analyzeImage(base64Image) {
  try {
    // APIキーの確認
    if (!GEMINI_API_KEY) {
      throw new Error('Gemini APIキーが設定されていません。スクリプトプロパティにGEMINI_API_KEYを設定してください。');
    }
    
    // Base64データからデータURL部分を削除(data:image/png;base64,など)
    const base64Data = base64Image.split(',')[1] || base64Image;
    
    // Gemini APIのエンドポイント
    const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
    
    // プロンプトの作成
    const prompt = `この画像を分析して、以下の情報を提供してください:

1. 画像に写っている鳥の種類を特定してください。
2. ワカケホンセイインコ(Rose-ringed Parakeet)が写っているかどうかを判定してください。
3. ワカケホンセイインコが写っている場合、何羽いるかを数えてください。
4. 画像に鳥が写っていない場合、または鳥が判別できない場合はその旨を報告してください。

以下のJSON形式で回答してください(JSON以外の文字は含めないでください):
{
  "hasBird": true または false,
  "birdSpecies": "鳥の種類(日本語)",
  "isWakakeHonseiInko": true または false,
  "count": 数値(ワカケホンセイインコの数、いない場合は0),
  "confidence": "high" または "medium" または "low",
  "description": "判定の説明"
}`;

    // APIリクエストのペイロード
    const payload = {
      contents: [{
        parts: [
          {
            text: prompt
          },
          {
            inline_data: {
              mime_type: "image/jpeg",
              data: base64Data
            }
          }
        ]
      }],
      generationConfig: {
        temperature: 0.4,
        maxOutputTokens: 1024,
      }
    };
    
    // APIリクエストの実行
    const options = {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };
    
    const response = UrlFetchApp.fetch(apiUrl, options);
    const responseCode = response.getResponseCode();
    
    if (responseCode !== 200) {
      throw new Error(`Gemini API エラー (${responseCode}): ${response.getContentText()}`);
    }
    
    const result = JSON.parse(response.getContentText());
    
    // レスポンスからテキストを抽出
    if (!result.candidates || result.candidates.length === 0) {
      throw new Error('Gemini APIからの応答が空です');
    }
    
    let responseText = result.candidates[0].content.parts[0].text;
    
    // JSONブロックの抽出(```jsonで囲まれている場合)
    const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/) || 
                      responseText.match(/```\s*([\s\S]*?)\s*```/);
    if (jsonMatch) {
      responseText = jsonMatch[1];
    }
    
    // JSONとしてパース
    const analysis = JSON.parse(responseText.trim());
    
    // 判定結果の生成
    const judgement = generateJudgement(analysis);
    
    return {
      success: true,
      analysis: analysis,
      judgement: judgement
    };
    
  } catch (error) {
    Logger.log('エラー: ' + error.toString());
    return {
      success: false,
      error: error.toString()
    };
  }
}

/**
 * 分析結果から判定メッセージを生成
 * @param {Object} analysis - Gemini APIからの分析結果
 * @return {Object} 判定結果
 */
function generateJudgement(analysis) {
  const result = {
    type: '',
    title: '',
    message: '',
    action: '',
    color: ''
  };
  
  // 鳥が写っていない場合
  if (!analysis.hasBird) {
    result.type = 'no_bird';
    result.title = '⚠️ 鳥が検出されませんでした';
    result.message = '画像に鳥が写っていないか、判別できませんでした。インコが写っている画像を再度アップロードしてください。';
    result.action = '';
    result.color = '#757575';
    return result;
  }
  
  // ワカケホンセイインコではない場合
  if (!analysis.isWakakeHonseiInko) {
    result.type = 'lost_bird';
    result.title = '🔍 迷子の可能性が高いです!';
    result.message = `この鳥は「${analysis.birdSpecies}」と思われます。ワカケホンセイインコではないため、迷子のペットである可能性が高いです。`;
    result.action = '📢 SNS(Twitter/X、Facebook等)に目撃場所と写真を投稿して、飼い主さんを探しましょう!\n\n投稿例:\n「【迷子鳥情報】○○市△△で保護しました。心当たりのある方はご連絡ください。」';
    result.color = '#f44336';
    return result;
  }
  
  // ワカケホンセイインコが1羽のみの場合
  if (analysis.count === 1) {
    result.type = 'possible_lost';
    result.title = '🤔 迷子の可能性があります';
    result.message = 'この鳥は「ワカケホンセイインコ」です。1羽だけなので、飼われていたワカケホンセイインコが迷子になった可能性もあります。';
    result.action = '📢 念のため、SNS(Twitter/X、Facebook等)に目撃場所と写真を投稿することをおすすめします。\n\n投稿例:\n「【迷子鳥かも?】○○市△△でワカケホンセイインコ1羽を目撃しました。心当たりのある方はご連絡ください。」';
    result.color = '#ff9800';
    return result;
  }
  
  // ワカケホンセイインコが複数羽の場合
  if (analysis.count >= 2) {
    result.type = 'wild_bird';
    result.title = '✅ 野生のワカケホンセイインコです';
    result.message = `この写真には${analysis.count}羽のワカケホンセイインコが写っています。複数羽で行動しているため、野生化したワカケホンセイインコの群れと思われます。迷子の可能性はほとんどありません。`;
    result.action = 'ℹ️ ワカケホンセイインコは日本で野生化している外来種です。主に東京都、神奈川県、千葉県などで群れで生活しています。迷子鳥ではありませんので、特に対応は不要です。';
    result.color = '#4caf50';
    return result;
  }
  
  // その他の場合(エラー)
  result.type = 'error';
  result.title = '⚠️ 判定できませんでした';
  result.message = '画像の分析結果が不明確です。再度お試しください。';
  result.action = '';
  result.color = '#757575';
  return result;
}

/**
 * テスト用関数:スクリプトプロパティの設定状態を確認
 */
function checkApiKey() {
  const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (key) {
    Logger.log('APIキーは設定されています(最初の10文字): ' + key.substring(0, 10) + '...');
    return true;
  } else {
    Logger.log('APIキーが設定されていません');
    return false;
  }
}

/**
 * スクリプトプロパティにAPIキーを設定する関数
 * 使用方法: setApiKey('your-api-key-here')
 */
function setApiKey(apiKey) {
  PropertiesService.getScriptProperties().setProperty('GEMINI_API_KEY', apiKey);
  Logger.log('APIキーを設定しました');
}


Index.html
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>迷子鳥判定アプリ</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 20px;
    }

    .container {
      background: white;
      border-radius: 20px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
      max-width: 600px;
      width: 100%;
      padding: 40px;
      animation: slideIn 0.5s ease-out;
    }

    @keyframes slideIn {
      from {
        opacity: 0;
        transform: translateY(-30px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }

    h1 {
      color: #333;
      text-align: center;
      margin-bottom: 10px;
      font-size: 28px;
    }

    .subtitle {
      text-align: center;
      color: #666;
      margin-bottom: 30px;
      font-size: 14px;
    }

    .upload-area {
      border: 3px dashed #667eea;
      border-radius: 15px;
      padding: 40px;
      text-align: center;
      cursor: pointer;
      transition: all 0.3s ease;
      background: #f8f9ff;
    }

    .upload-area:hover {
      border-color: #764ba2;
      background: #f0f2ff;
      transform: translateY(-2px);
    }

    .upload-area.dragover {
      border-color: #764ba2;
      background: #e8eaff;
      transform: scale(1.02);
    }

    .upload-icon {
      font-size: 48px;
      margin-bottom: 15px;
    }

    .upload-text {
      color: #667eea;
      font-size: 16px;
      font-weight: 600;
      margin-bottom: 10px;
    }

    .upload-hint {
      color: #999;
      font-size: 12px;
    }

    #fileInput {
      display: none;
    }

    #preview {
      margin-top: 20px;
      text-align: center;
      display: none;
    }

    #previewImage {
      max-width: 100%;
      max-height: 300px;
      border-radius: 10px;
      box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
      margin-bottom: 15px;
    }

    .button {
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border: none;
      padding: 12px 30px;
      border-radius: 25px;
      font-size: 16px;
      font-weight: 600;
      cursor: pointer;
      transition: all 0.3s ease;
      margin: 5px;
    }

    .button:hover {
      transform: translateY(-2px);
      box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
    }

    .button:disabled {
      background: #ccc;
      cursor: not-allowed;
      transform: none;
    }

    .button-secondary {
      background: #f44336;
    }

    .button-secondary:hover {
      box-shadow: 0 5px 15px rgba(244, 67, 54, 0.4);
    }

    #loading {
      display: none;
      text-align: center;
      margin-top: 20px;
    }

    .spinner {
      border: 4px solid #f3f3f3;
      border-top: 4px solid #667eea;
      border-radius: 50%;
      width: 40px;
      height: 40px;
      animation: spin 1s linear infinite;
      margin: 0 auto 15px;
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }

    .loading-text {
      color: #667eea;
      font-weight: 600;
    }

    #result {
      display: none;
      margin-top: 30px;
      padding: 25px;
      border-radius: 15px;
      animation: fadeIn 0.5s ease-out;
    }

    @keyframes fadeIn {
      from {
        opacity: 0;
        transform: translateY(10px);
      }
      to {
        opacity: 1;
        transform: translateY(0);
      }
    }

    .result-title {
      font-size: 22px;
      font-weight: bold;
      margin-bottom: 15px;
      color: white;
    }

    .result-message {
      font-size: 16px;
      margin-bottom: 15px;
      line-height: 1.6;
      color: white;
    }

    .result-action {
      font-size: 14px;
      padding: 15px;
      background: rgba(255, 255, 255, 0.2);
      border-radius: 10px;
      white-space: pre-line;
      line-height: 1.8;
      color: white;
    }

    .error {
      background: #f44336;
      color: white;
      padding: 15px;
      border-radius: 10px;
      margin-top: 15px;
      display: none;
    }

    @media (max-width: 600px) {
      .container {
        padding: 20px;
      }

      h1 {
        font-size: 24px;
      }

      .upload-area {
        padding: 30px 20px;
      }
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>🦜 迷子鳥判定アプリ</h1>
    <p class="subtitle">インコの写真をアップロードして、迷子鳥かどうかを判定します</p>

    <div class="upload-area" id="uploadArea">
      <div class="upload-icon">📸</div>
      <div class="upload-text">クリックまたはドラッグ&ドロップ</div>
      <div class="upload-hint">インコの写真を選択してください(JPEG, PNG対応)</div>
    </div>

    <input type="file" id="fileInput" accept="image/*">

    <div id="preview">
      <img id="previewImage" alt="プレビュー">
      <div>
        <button class="button" id="analyzeBtn">判定する</button>
        <button class="button button-secondary" id="cancelBtn">キャンセル</button>
      </div>
    </div>

    <div id="loading">
      <div class="spinner"></div>
      <div class="loading-text">分析中です。しばらくお待ちください...</div>
    </div>

    <div id="result"></div>

    <div class="error" id="error"></div>
  </div>

  <script>
    const uploadArea = document.getElementById('uploadArea');
    const fileInput = document.getElementById('fileInput');
    const preview = document.getElementById('preview');
    const previewImage = document.getElementById('previewImage');
    const analyzeBtn = document.getElementById('analyzeBtn');
    const cancelBtn = document.getElementById('cancelBtn');
    const loading = document.getElementById('loading');
    const result = document.getElementById('result');
    const errorDiv = document.getElementById('error');

    let currentImageData = null;

    // アップロードエリアのクリックイベント
    uploadArea.addEventListener('click', () => {
      fileInput.click();
    });

    // ドラッグ&ドロップのイベント
    uploadArea.addEventListener('dragover', (e) => {
      e.preventDefault();
      uploadArea.classList.add('dragover');
    });

    uploadArea.addEventListener('dragleave', () => {
      uploadArea.classList.remove('dragover');
    });

    uploadArea.addEventListener('drop', (e) => {
      e.preventDefault();
      uploadArea.classList.remove('dragover');
      const files = e.dataTransfer.files;
      if (files.length > 0) {
        handleFile(files[0]);
      }
    });

    // ファイル選択イベント
    fileInput.addEventListener('change', (e) => {
      if (e.target.files.length > 0) {
        handleFile(e.target.files[0]);
      }
    });

    // ファイル処理
    function handleFile(file) {
      if (!file.type.startsWith('image/')) {
        showError('画像ファイルを選択してください');
        return;
      }

      const reader = new FileReader();
      reader.onload = (e) => {
        currentImageData = e.target.result;
        previewImage.src = currentImageData;
        uploadArea.style.display = 'none';
        preview.style.display = 'block';
        result.style.display = 'none';
        errorDiv.style.display = 'none';
      };
      reader.readAsDataURL(file);
    }

    // 判定ボタンのクリックイベント
    analyzeBtn.addEventListener('click', () => {
      if (!currentImageData) {
        showError('画像が選択されていません');
        return;
      }

      // UI更新
      analyzeBtn.disabled = true;
      cancelBtn.disabled = true;
      loading.style.display = 'block';
      result.style.display = 'none';
      errorDiv.style.display = 'none';

      // Google Apps Scriptの関数を呼び出し
      google.script.run
        .withSuccessHandler(onAnalysisSuccess)
        .withFailureHandler(onAnalysisFailure)
        .analyzeImage(currentImageData);
    });

    // キャンセルボタンのクリックイベント
    cancelBtn.addEventListener('click', () => {
      resetUI();
    });

    // 分析成功時の処理
    function onAnalysisSuccess(response) {
      analyzeBtn.disabled = false;
      cancelBtn.disabled = false;
      loading.style.display = 'none';

      if (response.success) {
        displayResult(response.judgement);
      } else {
        showError('エラーが発生しました: ' + response.error);
      }
    }

    // 分析失敗時の処理
    function onAnalysisFailure(error) {
      analyzeBtn.disabled = false;
      cancelBtn.disabled = false;
      loading.style.display = 'none';
      showError('通信エラーが発生しました: ' + error.message);
    }

    // 結果表示
    function displayResult(judgement) {
      result.style.display = 'block';
      result.style.background = judgement.color;
      result.innerHTML = `
        <div class="result-title">${judgement.title}</div>
        <div class="result-message">${judgement.message}</div>
        ${judgement.action ? `<div class="result-action">${judgement.action}</div>` : ''}
      `;
      
      // 結果までスクロール
      result.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }

    // エラー表示
    function showError(message) {
      errorDiv.textContent = message;
      errorDiv.style.display = 'block';
      setTimeout(() => {
        errorDiv.style.display = 'none';
      }, 5000);
    }

    // UI リセット
    function resetUI() {
      currentImageData = null;
      fileInput.value = '';
      uploadArea.style.display = 'block';
      preview.style.display = 'none';
      result.style.display = 'none';
      errorDiv.style.display = 'none';
      analyzeBtn.disabled = false;
      cancelBtn.disabled = false;
    }
  </script>
</body>
</html>


Cursorが生成したファイルをGASのスクリプトエディタに配置します。

スクリーンショット 2025-11-27 15.54.20.png

GeminiをAPIリクエストするために、プロジェクトの設定からAPIキーをスクリプトプロパティにセットしました。

GASでウェブアプリケーションとして利用できるようにデプロイを行います。今回はテスト的な開発で一般公開しない想定のため、実行できるのは自分のみとしました。
※誰でも使えるようにするにはアクセスできるユーザーを「全員」とします。

デプロイするとURLが発行されるので、アクセスすると迷子判定アプリを開くことができました。

スクリーンショット 2025-11-27 15.54.44.png

画像をアップロードできるようになっているので、インコの中でももっともポピュラーなセキセイインコの写真をアップロードしてみます。

スクリーンショット 2025-11-27 15.42.17.png

無事、迷子インコを判定できています。
ワカケホンセイインコ2羽が写っている写真でも試してみると、しっかりワカケホンセイインコと識別して、迷子鳥の可能性は非常に低いと応答してくれました。

スクリーンショット 2025-12-04 14.54.40.png

最後にワカケホンセイインコ1羽のみ写った写真で試すと、迷子鳥の可能性は低いものの、SNS等で投稿するよう呼びかけてくれました。

スクリーンショット 2025-11-27 15.41.45.png

そのほかにもオカメインコやコザクラインコといった飼育数の多いインコでも判定ができました。

スクリーンショット 2025-11-27 15.44.01.png

野鳥も迷子鳥と判定するミス

Cursorで最初に開発したGoogle Apps Scriptでは10種類ほどのインコで試してみましたが、かなりの高精度でした。しかし、判定条件を「ワカケホンセイインコかどうか」に絞っていたゆえに「普通の野鳥でも迷子鳥と判定される」という問題がありました。

スクリーンショット 2025-11-27 15.41.23.png

そこで、ワカケホンセイインコかどうかに加えて、野鳥かどうかという観点でも追加しました。
これで先ほどのジョウビタキやスズメなど日本の在来種の場合は野鳥と判断してもらえるようになりました。

スクリーンショット 2025-11-27 16.15.26.png

「雪の妖精」とも呼ばれる可愛さのシマエナガもきちんと野鳥と判定してくれました。

スクリーンショット 2025-11-27 15.53.26.png

最終的なCode.gsの中身は以下の通りです(Index.htmlは変更なし)。

Code.gs
/**
 * 迷子鳥判定アプリ - メインスクリプト
 * ワカケホンセイインコかどうかを判定するWebアプリ
 */

// スクリプトプロパティからGemini APIキーを取得
const GEMINI_API_KEY = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');

// 使用するGeminiモデルを定義(新しいモデルが出た時にここを変更するだけでOK)
const GEMINI_MODEL = 'gemini-2.5-pro';

/**
 * Webアプリのエントリーポイント
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('迷子鳥判定アプリ')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * 画像を分析してワカケホンセイインコかどうかを判定
 * @param {string} base64Image - Base64エンコードされた画像データ
 * @return {Object} 判定結果
 */
function analyzeImage(base64Image) {
  try {
    // APIキーの確認
    if (!GEMINI_API_KEY) {
      throw new Error('Gemini APIキーが設定されていません。スクリプトプロパティにGEMINI_API_KEYを設定してください。');
    }
    
    // Base64データからデータURL部分を削除(data:image/png;base64,など)
    const base64Data = base64Image.split(',')[1] || base64Image;
    
    // Gemini APIのエンドポイント
    const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
    
    // プロンプトの作成
    const prompt = `この画像を分析して、以下の情報を提供してください:

1. 画像に写っている鳥の種類を特定してください。
2. ワカケホンセイインコ(Rose-ringed Parakeet)が写っているかどうかを判定してください。
3. ワカケホンセイインコが写っている場合、何羽いるかを数えてください。
4. 画像に鳥が写っていない場合、または鳥が判別できない場合はその旨を報告してください。

以下のJSON形式で回答してください(JSON以外の文字は含めないでください):
{
  "hasBird": true または false,
  "birdSpecies": "鳥の種類(日本語)",
  "isWakakeHonseiInko": true または false,
  "count": 数値(ワカケホンセイインコの数、いない場合は0),
  "confidence": "high" または "medium" または "low",
  "description": "判定の説明"
}`;

    // APIリクエストのペイロード
    const payload = {
      contents: [{
        parts: [
          {
            text: prompt
          },
          {
            inline_data: {
              mime_type: "image/jpeg",
              data: base64Data
            }
          }
        ]
      }],
      generationConfig: {
        temperature: 0.4,
        maxOutputTokens: 1024,
      }
    };
    
    // APIリクエストの実行
    const options = {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(payload),
      muteHttpExceptions: true
    };
    
    const response = UrlFetchApp.fetch(apiUrl, options);
    const responseCode = response.getResponseCode();
    
    if (responseCode !== 200) {
      throw new Error(`Gemini API エラー (${responseCode}): ${response.getContentText()}`);
    }
    
    const result = JSON.parse(response.getContentText());
    
    // レスポンスからテキストを抽出
    if (!result.candidates || result.candidates.length === 0) {
      throw new Error('Gemini APIからの応答が空です');
    }
    
    let responseText = result.candidates[0].content.parts[0].text;
    
    // JSONブロックの抽出(```jsonで囲まれている場合)
    const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/) || 
                      responseText.match(/```\s*([\s\S]*?)\s*```/);
    if (jsonMatch) {
      responseText = jsonMatch[1];
    }
    
    // JSONとしてパース
    const analysis = JSON.parse(responseText.trim());
    
    // 判定結果の生成
    const judgement = generateJudgement(analysis);
    
    return {
      success: true,
      analysis: analysis,
      judgement: judgement
    };
    
  } catch (error) {
    Logger.log('エラー: ' + error.toString());
    return {
      success: false,
      error: error.toString()
    };
  }
}

/**
 * 分析結果から判定メッセージを生成
 * @param {Object} analysis - Gemini APIからの分析結果
 * @return {Object} 判定結果
 */
function generateJudgement(analysis) {
  const result = {
    type: '',
    title: '',
    message: '',
    action: '',
    color: ''
  };
  
  // 鳥が写っていない場合
  if (!analysis.hasBird) {
    result.type = 'no_bird';
    result.title = '⚠️ 鳥が検出されませんでした';
    result.message = '画像に鳥が写っていないか、判別できませんでした。インコが写っている画像を再度アップロードしてください。';
    result.action = '';
    result.color = '#757575';
    return result;
  }
  
  // ワカケホンセイインコではない場合
  if (!analysis.isWakakeHonseiInko) {
    result.type = 'lost_bird';
    result.title = '🔍 迷子の可能性が高いです!';
    result.message = `この鳥は「${analysis.birdSpecies}」と思われます。ワカケホンセイインコではないため、迷子のペットである可能性が高いです。`;
    result.action = '📢 SNS(Twitter/X、Facebook等)に目撃場所と写真を投稿して、飼い主さんを探しましょう!\n\n投稿例:\n「【迷子鳥情報】○○市△△で保護しました。心当たりのある方はご連絡ください。」';
    result.color = '#f44336';
    return result;
  }
  
  // ワカケホンセイインコが1羽のみの場合
  if (analysis.count === 1) {
    result.type = 'possible_lost';
    result.title = '🤔 迷子の可能性があります';
    result.message = 'この鳥は「ワカケホンセイインコ」です。1羽だけなので、飼われていたワカケホンセイインコが迷子になった可能性もあります。';
    result.action = '📢 念のため、SNS(Twitter/X、Facebook等)に目撃場所と写真を投稿することをおすすめします。\n\n投稿例:\n「【迷子鳥かも?】○○市△△でワカケホンセイインコ1羽を目撃しました。心当たりのある方はご連絡ください。」';
    result.color = '#ff9800';
    return result;
  }
  
  // ワカケホンセイインコが複数羽の場合
  if (analysis.count >= 2) {
    result.type = 'wild_bird';
    result.title = '✅ 野生のワカケホンセイインコです';
    result.message = `この写真には${analysis.count}羽のワカケホンセイインコが写っています。複数羽で行動しているため、野生化したワカケホンセイインコの群れと思われます。迷子の可能性はほとんどありません。`;
    result.action = 'ℹ️ ワカケホンセイインコは日本で野生化している外来種です。主に東京都、神奈川県、千葉県などで群れで生活しています。迷子鳥ではありませんので、特に対応は不要です。';
    result.color = '#4caf50';
    return result;
  }
  
  // その他の場合(エラー)
  result.type = 'error';
  result.title = '⚠️ 判定できませんでした';
  result.message = '画像の分析結果が不明確です。再度お試しください。';
  result.action = '';
  result.color = '#757575';
  return result;
}

/**
 * テスト用関数:スクリプトプロパティの設定状態を確認
 */
function checkApiKey() {
  const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
  if (key) {
    Logger.log('APIキーは設定されています(最初の10文字): ' + key.substring(0, 10) + '...');
    return true;
  } else {
    Logger.log('APIキーが設定されていません');
    return false;
  }
}

/**
 * スクリプトプロパティにAPIキーを設定する関数
 * 使用方法: setApiKey('your-api-key-here')
 */
function setApiKey(apiKey) {
  PropertiesService.getScriptProperties().setProperty('GEMINI_API_KEY', apiKey);
  Logger.log('APIキーを設定しました');
}


AIコーディングで判定アプリが簡単に

生成AIによってこうした判定アプリの開発が非常に容易になりました。以前は画像判定の部分も自分で画像を用意してAIサービスで学習させる必要がありましたが、生成AIの画像認識の精度が向上し、鳥種の識別ができるようになりました。

さらに生成AIを取り入れたCursorなどのAIコーディングツールによって、アプリ開発のハードルも劇的に下がりました。CSSなどのデザインもリッチなものではありませんが、自分で実装しなくても生成AIがコードとして出力してくれるので、対応不要です。

今回の迷子鳥判定アプリはわずか1時間ほどで作れました。

以前だと少なくとも学習からコーディング、デザインまで実装に1ヶ月近くかかったと思います。本当に生成AIの普及によってちょっとしたアプリ開発がすぐできるので便利な世の中になったと感じています。

わからなければ、迷子鳥として情報提供を

ただ残念ながら今回開発してみた識別ツールも万能ではありません。
テストで試した結果では、ワカケホンセイインコとの識別ができていますが、画像によっては正確に判定できないケースもあります。
もし迷子鳥であった場合は、見つけたタイミングが命を救う最後のチャンスかもしれません。飼われていたインコは人に慣れているため、もし保護できそうなら保護をお願いします。保護が難しくても迷子掲示板やSNSで情報提供してもらえると幸いです。

探している飼い主さんが目にして捜索の手がかりになるかもしれません。
「あれもしかして迷子鳥だったかな?」 と思ったら、SNSなどで写真を撮影し、目撃日時と目撃場所をつけて投稿してあげてください。

11
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?