Spaces:
Runtime error
Runtime error
| """ | |
| FeatherFind Perch Server (PRIMARY sound-ID engine) | |
| --------------------------------------------------- | |
| Wraps Google's Perch model, loaded DIRECTLY from TensorFlow Hub, | |
| to identify bird species from audio recordings. | |
| WHY THIS FILE IS STRUCTURED THE WAY IT IS: | |
| This server is deliberately split into two layers: | |
| 1. MODEL_LOADER / MODEL -- the ONLY place that knows which specific | |
| bioacoustics model is in use and how it's loaded. Everything | |
| below this point talks to a generic prediction interface. | |
| 2. Flask routes -- talk only to the abstraction above, never | |
| directly to the TF-Hub model object. | |
| This means swapping to a different model later should only ever | |
| require changing the MODEL_LOADER section below -- nothing in the | |
| Flask routes, response formatting, or rate-limiting logic should | |
| need to change. See backend-birdnet/server.py for the secondary | |
| engine, which intentionally mirrors this same structure. | |
| WHY DIRECT TF-HUB LOADING (not the bioacoustics-model-zoo package): | |
| We originally used the `bioacoustics-model-zoo` package's `bmz.Perch()` | |
| wrapper. It works, but it has `opensoundscape` as a hard (non-optional) | |
| dependency, which in turn pulls in a large, unrelated set of packages | |
| (Jupyter notebook server components, etc.) that we never use. On | |
| Render's free tier, this made installs slow and pushed the deploy past | |
| Render's port-detection timeout -- a real problem hit and diagnosed | |
| during this project's deployment (see ARCHITECTURE_HANDOFF.md section | |
| 3.2 for the full story). Loading directly from TensorFlow Hub avoids | |
| this entirely: only `tensorflow`, `tensorflow_hub`, and `librosa` (for | |
| audio loading/resampling) are needed. This is the same underlying | |
| Perch model, same license, same accuracy -- just a lighter path to it. | |
| MODEL CHOICE NOTE (read before changing): | |
| This is Perch v1 (`bird-vocalization-classifier/1` on TF-Hub) -- the | |
| ORIGINAL Perch model, not Perch2. Perch2 currently requires a GPU per | |
| Google's own model card. Render's free tier has no GPU. Do not swap to | |
| Perch2 here without first re-confirming a CPU-compatible build is | |
| officially available and well-tested. | |
| LICENSE NOTE: Perch is Apache 2.0 -- fully permissive, safe for | |
| commercial/app-store distribution, no restrictions. This is the main | |
| reason Perch is the PRIMARY engine, with BirdNET (CC BY-NC-SA, | |
| non-commercial) kept as a secondary/comparison engine instead. | |
| SETUP (for a parent/guardian): | |
| 1. pip install -r requirements.txt | |
| 2. python server.py | |
| 3. The first run downloads the Perch model (~80MB) and its label | |
| list -- this only happens once. | |
| 4. Deploy this to a free Python host (Render.com free tier works). | |
| """ | |
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| import tempfile | |
| import os | |
| import math | |
| from datetime import date | |
| app = Flask(__name__) | |
| CORS(app) | |
| # ================================================================= | |
| # MODEL_LOADER -- the ONLY section that should change if/when we | |
| # swap to a different bioacoustics model. Everything below this | |
| # block in the rest of the file is model-agnostic. | |
| # ================================================================= | |
| print("Loading Perch model from TensorFlow Hub, this may take a moment on first run...") | |
| import numpy as np | |
| import tensorflow_hub as hub | |
| import csv | |
| import urllib.request | |
| MODEL = hub.load("https://tfhub.dev/google/bird-vocalization-classifier/1") | |
| MODEL_NAME = "Perch (Google, v1, direct TF-Hub)" | |
| # The model's label list (eBird species codes, one per output column) | |
| # is cached locally by TensorFlow Hub when the model is loaded via | |
| # hub.load() above. We resolve that same local cache path and read | |
| # label.csv directly from it -- no separate network request needed, | |
| # and no dependency on a Kaggle download URL. (An earlier version of | |
| # this code tried fetching from Kaggle directly; that URL pointed to | |
| # a tar.gz archive, not a raw CSV, and failed unpredictably across | |
| # environments -- caught and fixed during real deployment testing, | |
| # see ARCHITECTURE_HANDOFF.md section 3.2.) | |
| LABELS = [] | |
| try: | |
| model_path = hub.resolve("https://tfhub.dev/google/bird-vocalization-classifier/1") | |
| labels_path = os.path.join(model_path, "assets", "label.csv") | |
| with open(labels_path, newline="", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| LABELS = [row["ebird2021"] for row in reader if row.get("ebird2021")] | |
| print(f"Loaded {len(LABELS)} species labels.") | |
| except Exception as e: | |
| print(f"WARNING: could not load labels file ({e}). Falling back to raw index numbers as labels.") | |
| LABELS = [] | |
| print(f"{MODEL_NAME} loaded successfully.") | |
| # ================================================================= | |
| # eBird taxonomy lookup: maps bare species codes (e.g. "asikoe2") to | |
| # real common/scientific names (e.g. "Asian Koel"). Perch's own | |
| # label.csv only has bare codes -- this is a SEPARATE, independently | |
| # sourced official Cornell Lab dataset. | |
| # | |
| # Source verified directly (fetched and inspected the real file | |
| # before relying on it): the public eBird Taxonomy v2025 CSV, linked | |
| # from Cornell's own taxonomy page (science.ebird.org). Columns used: | |
| # SPECIES_CODE, PRIMARY_COM_NAME, SCI_NAME. No login or API key | |
| # needed -- this is a plain public file. | |
| # | |
| # IMPORTANT: do not trust a hand-typed code->name dictionary from any | |
| # source (including AI chat tools) without verifying against this | |
| # real file first. During development, ChatGPT/Gemini both produced | |
| # a few confident-sounding but WRONG species names for these exact | |
| # codes (e.g. claimed "busflu1" = "Bush-hen"; verified real answer is | |
| # "Buff-spotted Flufftail"). This is why we use the authoritative | |
| # file directly instead of hardcoding any name. | |
| # | |
| # PERFORMANCE NOTE: this file is bundled directly in the repo | |
| # (ebird_taxonomy.csv, alongside server.py) rather than downloaded at | |
| # every container startup. Downloading it fresh on every restart | |
| # (rebuild, sleep/wake cycle) was adding real, noticeable startup | |
| # delay -- bundling it makes startup instant for this step. If the | |
| # bundled file is ever missing (e.g. someone forgets to upload it | |
| # after cloning this repo), this code automatically falls back to | |
| # downloading it once, so the server still works either way. | |
| EBIRD_TAXONOMY_BUNDLED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ebird_taxonomy.csv") | |
| EBIRD_TAXONOMY_URL = "https://www.birds.cornell.edu/clementschecklist/wp-content/uploads/2026/04/eBird_taxonomy_v2025-4.csv" | |
| EBIRD_TAXONOMY_FALLBACK_DOWNLOAD_PATH = "/tmp/ebird_taxonomy.csv" | |
| EBIRD_CODE_TO_NAME = {} | |
| try: | |
| if os.path.exists(EBIRD_TAXONOMY_BUNDLED_PATH): | |
| taxonomy_path = EBIRD_TAXONOMY_BUNDLED_PATH | |
| print("Using bundled eBird taxonomy file (no download needed).") | |
| else: | |
| taxonomy_path = EBIRD_TAXONOMY_FALLBACK_DOWNLOAD_PATH | |
| if not os.path.exists(taxonomy_path): | |
| print("Bundled eBird taxonomy file not found -- downloading instead (one-time fallback)...") | |
| urllib.request.urlretrieve(EBIRD_TAXONOMY_URL, taxonomy_path) | |
| with open(taxonomy_path, newline="", encoding="utf-8-sig") as f: | |
| reader = csv.DictReader(f) | |
| for row in reader: | |
| code = row.get("SPECIES_CODE") | |
| common = row.get("PRIMARY_COM_NAME") | |
| sci = row.get("SCI_NAME") | |
| if code and common: | |
| EBIRD_CODE_TO_NAME[code] = {"common": common, "scientific": sci or ""} | |
| print(f"Loaded {len(EBIRD_CODE_TO_NAME)} eBird code-to-name mappings.") | |
| except Exception as e: | |
| print(f"WARNING: could not load eBird taxonomy ({e}). Will show raw species codes instead of names.") | |
| EBIRD_CODE_TO_NAME = {} | |
| def run_model_prediction(audio_file_path): | |
| """ | |
| Model-agnostic prediction wrapper. | |
| Takes a path to an audio file, returns a list of | |
| {"commonName": str, "scientificName": str, "confidence": float 0-100} | |
| dicts, sorted by confidence descending, top 3 only. | |
| """ | |
| import librosa | |
| # Load audio, resampled to 32kHz mono, as the model requires. | |
| waveform, _ = librosa.load(audio_file_path, sr=32000, mono=True) | |
| # The model expects 5-second (160,000 sample) windows. Pad short | |
| # clips with silence; for longer clips, just use the first 5 | |
| # seconds (good enough for a hobby app; not doing multi-window | |
| # analysis here, unlike the bioacoustics-model-zoo wrapper did). | |
| target_len = 5 * 32000 | |
| if len(waveform) < target_len: | |
| waveform = np.pad(waveform, (0, target_len - len(waveform))) | |
| else: | |
| waveform = waveform[:target_len] | |
| waveform = waveform.astype(np.float32)[np.newaxis, :] | |
| logits, _embeddings = MODEL.infer_tf(waveform) | |
| logits = logits.numpy()[0] # shape: (num_classes,) | |
| # Apply sigmoid: Perch's raw outputs are uncalibrated logits, not | |
| # probabilities. Without this, confidence values can be far | |
| # outside a sane 0-100% range (this was caught and fixed during | |
| # testing -- see ARCHITECTURE_HANDOFF.md section 3.2). | |
| probabilities = 1.0 / (1.0 + np.exp(-logits)) | |
| top_indices = np.argsort(probabilities)[::-1][:3] | |
| results = [] | |
| for idx in top_indices: | |
| label = LABELS[idx] if idx < len(LABELS) else f"species_{idx}" | |
| if "_" in label: | |
| # Some label formats (other engines, or future Perch label | |
| # files) may already combine "Scientific_Common" -- handle | |
| # that format if it shows up. | |
| scientific, common = label.split("_", 1) | |
| elif label in EBIRD_CODE_TO_NAME: | |
| # The normal case for Perch: label is a bare eBird code | |
| # (e.g. "asikoe2"). Look up its real name in the eBird | |
| # taxonomy we downloaded at startup. | |
| common = EBIRD_CODE_TO_NAME[label]["common"] | |
| scientific = EBIRD_CODE_TO_NAME[label]["scientific"] | |
| else: | |
| # Taxonomy lookup failed to load, or this specific code | |
| # isn't in it (e.g. a hybrid/spuh entry) -- fall back to | |
| # showing the raw code clearly labeled, rather than | |
| # guessing at a name or showing a bare, confusing string. | |
| scientific, common = "", f"eBird code: {label}" | |
| results.append({ | |
| "commonName": common, | |
| "scientificName": scientific, | |
| "confidence": round(float(probabilities[idx]) * 100, 1), | |
| }) | |
| return results | |
| # ================================================================= | |
| # Everything below this line is model-agnostic and intentionally | |
| # mirrors backend-birdnet/server.py's structure. | |
| # ================================================================= | |
| MAX_REQUESTS_PER_DAY = 100 | |
| request_count = {"date": None, "count": 0} | |
| def check_and_increment_rate_limit(): | |
| today = str(date.today()) | |
| if request_count["date"] != today: | |
| request_count["date"] = today | |
| request_count["count"] = 0 | |
| if request_count["count"] >= MAX_REQUESTS_PER_DAY: | |
| return False | |
| request_count["count"] += 1 | |
| return True | |
| def identify_sound(): | |
| if not check_and_increment_rate_limit(): | |
| return jsonify({ | |
| "error": "Daily identification limit reached. Please try again tomorrow.", | |
| "matches": [] | |
| }), 429 | |
| if "audio" not in request.files: | |
| return jsonify({"error": "No audio file provided.", "matches": []}), 400 | |
| audio_file = request.files["audio"] | |
| suffix = os.path.splitext(audio_file.filename or "audio.wav")[1] or ".wav" | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| audio_file.save(tmp.name) | |
| tmp_path = tmp.name | |
| try: | |
| matches = run_model_prediction(tmp_path) | |
| if not matches: | |
| return jsonify({"matches": [], "error": "No bird sound clearly detected in this recording."}) | |
| return jsonify({"matches": matches}) | |
| except Exception as e: | |
| return jsonify({"error": f"Could not analyze audio: {str(e)}", "matches": []}), 500 | |
| finally: | |
| if os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| def health(): | |
| return jsonify({"status": "ok", "model": MODEL_NAME}) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 8080)) | |
| app.run(host="0.0.0.0", port=port) |