Download src/identify.py from SaiPavankumar22/Bird-finder: direct link, hf CLI and curl.
- Browser
- Download file 20.7 kB
-
https://huggingface.co/SaiPavankumar22/Bird-finder/resolve/main/src/identify.py
- Command line
-
hf download hf://SaiPavankumar22/Bird-finder/src/identify.py
-
curl -L -o identify.py https://huggingface.co/SaiPavankumar22/Bird-finder/resolve/main/src/identify.py
20.7 kB
| """ | |
| Offline bird-call identification for a recording you made. | |
| Usage: | |
| python identify.py path/to/recording.mp3 # classify + log to field notebook | |
| python identify.py path/to/recording.mp3 --no-log # classify only, don't log | |
| python identify.py path/to/recording.mp3 --json # machine-readable JSON output | |
| What it does (all offline, no network): | |
| 1. Reads the audio file (any format librosa/ffmpeg can read: mp3, wav, m4a, flac, ogg). | |
| 2. Converts it to the same mel-spectrogram representation the model was trained on, | |
| using the saved preprocessing config so inference is reproducible. | |
| 3. Slides 3-second windows across the recording and runs the exported ONNX model | |
| (or the PyTorch model if ONNX isn't available) on each window. | |
| 4. Aggregates the window predictions into a single answer: the top species, how many | |
| windows support it, the average confidence, a shortlist of alternatives, and a | |
| confidence flag. | |
| 5. Prints a one-screen summary (the screen is meant to be the shortest part). | |
| 6. Optionally writes an entry to the local field log (no network, stays on your device). | |
| Confidence is reported honestly. The model was trained on 5 species from one area, so: | |
| - A clear recording of a known species in similar conditions -> high confidence. | |
| - A recording with little bird sound, or a species the model doesn't know, or very | |
| different recording conditions -> low confidence. The tool says so rather than | |
| pretending to know. | |
| If the model or preprocessing config isn't present yet, the script tells you how to | |
| train/export it first. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import librosa | |
| PROJECT_ROOT = (Path(__file__).resolve().parent / "..").resolve() | |
| MODELS_DIR = PROJECT_ROOT / "models" | |
| DATA_DIR = PROJECT_ROOT / "data" / "dataset" | |
| FIELD_LOG = PROJECT_ROOT / "tools" / "field_log.py" # (currently unused; logging uses sys.path + `from tools import field_log`) | |
| CONFIG_PATH = MODELS_DIR / "config.json" | |
| LABEL_PATH = MODELS_DIR / "label_map.json" | |
| MANIFEST_PATH = MODELS_DIR / "manifest.json" | |
| ONNX_PATH = MODELS_DIR / "model.onnx" | |
| TORCH_PATH = MODELS_DIR / "model.pt" | |
| def set_models_dir(d: Path) -> Path: | |
| """Point the CLI at a different model directory (e.g. models_v1_5species/). | |
| Keeps "which model am I asking?" answerable from the command line instead of | |
| being hard-coded: | |
| models/ v2 -- current 95-class BirdCallNet (mel -> CNN) | |
| models_v1_5species/ v1 -- archived 5-species starter | |
| models_v3/ v3 -- frozen Perch 2.0 backbone + trained heads | |
| A directory holding `bundle.json` is a v3 bundle, which switches the whole | |
| inference path (raw 32 kHz audio -> Perch embedding -> linear head) rather | |
| than just the weights. | |
| """ | |
| global MODELS_DIR, CONFIG_PATH, LABEL_PATH, MANIFEST_PATH, ONNX_PATH, TORCH_PATH | |
| d = Path(d).expanduser() | |
| if not d.is_absolute(): | |
| d = (PROJECT_ROOT / d).resolve() | |
| if not d.exists(): | |
| raise SystemExit(f"Model directory not found: {d}") | |
| MODELS_DIR = d | |
| CONFIG_PATH = d / "config.json" | |
| LABEL_PATH = d / "label_map.json" | |
| MANIFEST_PATH = d / "manifest.json" | |
| ONNX_PATH = d / "model.onnx" | |
| TORCH_PATH = d / "model.pt" | |
| if (d / "bundle.json").exists() and not LABEL_PATH.exists(): | |
| raise SystemExit(f"v3 bundle in {d} is missing label_map.json -- incomplete copy") | |
| return d | |
| def is_v3(d: Path | None = None) -> bool: | |
| """True if the selected model directory is a v3 (Perch) bundle.""" | |
| d = Path(d) if d else MODELS_DIR | |
| return (Path(d) / "bundle.json").exists() | |
| WINDOW_SEC = 3.0 | |
| INFER_HOP_SEC = 0.5 # slide a new window every 0.5s during inference (dense but manageable) | |
| # v3 only: how many 5 s Perch windows get pooled into one answer. 12 is the cap | |
| # used while training the heads, so it is the default here too (--max-windows overrides). | |
| v3_max_windows = 12 | |
| def _load_text(p: Path, label: str) -> dict | list[str] | list[dict]: | |
| if not p.exists(): | |
| raise FileNotFoundError(f"{label} not found at {p}. Train/export the model first.") | |
| return json.loads(p.read_text(encoding="utf-8")) | |
| def _ensure_model() -> tuple[str, Any, dict, dict[str, int], dict]: | |
| """Return (backend, session_or_model, config, label_map, manifest). | |
| backend is one of "onnx" | "torch" | "perch-v3"; the third is a different | |
| model family entirely (frozen Perch backbone + heads), so callers must branch | |
| on it rather than assuming mel-spectrogram windows. | |
| """ | |
| if is_v3(): | |
| from perch_v3 import PerchV3 | |
| bundle = json.loads((MODELS_DIR / "bundle.json").read_text(encoding="utf-8")) | |
| label_map = _load_text(LABEL_PATH, "label_map.json") | |
| cfg = bundle.get("input", {}) | |
| config = { | |
| "sr": int(cfg.get("sample_rate_hz", 32000)), | |
| "window_sec": float(cfg.get("window_sec", 5.0)), | |
| "hop_sec": float(cfg.get("hop_sec", 2.5)), | |
| "feature": cfg.get("feature", "Perch embedding, 1536-d"), | |
| "arch": bundle.get("species_head"), | |
| "backbone": bundle.get("backbone"), | |
| } | |
| manifest = { | |
| "input_shape": [None, 1536], | |
| "n_classes": bundle.get("n_classes", len(label_map)), | |
| "label_map": label_map, | |
| "config": config, | |
| "bundle": bundle, | |
| } | |
| return ("perch-v3", PerchV3(MODELS_DIR), config, label_map, manifest) | |
| config = _load_text(CONFIG_PATH, "config.json") | |
| label_map = _load_text(LABEL_PATH, "label_map.json") | |
| manifest = _load_text(MANIFEST_PATH, "manifest.json") if MANIFEST_PATH.exists() else {} | |
| use_onnx = ONNX_PATH.exists() | |
| if use_onnx: | |
| import onnxruntime as ort | |
| sess = ort.InferenceSession(str(ONNX_PATH), providers=["CPUExecutionProvider"]) | |
| return ("onnx", sess, config, label_map, manifest) | |
| if TORCH_PATH.exists(): | |
| import torch | |
| from model import BirdCallNet, arch_kwargs, _expected_frames | |
| state = torch.load(TORCH_PATH, map_location="cpu", weights_only=True) | |
| model = BirdCallNet( | |
| n_mels=config["n_mels"], | |
| n_frames=_expected_frames(config), | |
| n_classes=len(label_map), | |
| **arch_kwargs(config.get("arch") or manifest.get("arch")), | |
| ) | |
| model.load_state_dict(state) | |
| model.eval() | |
| return ("torch", model, config, label_map, manifest) | |
| raise SystemExit( | |
| "No trained model found in models/. Run: python train.py && python export.py" | |
| ) | |
| def _audio_to_mono_y(path: Path, sr: int) -> np.ndarray[float]: | |
| import librosa | |
| y, _ = librosa.load(path, sr=sr, mono=True) | |
| return y.astype(np.float32) | |
| def _mel_from_y(y: np.ndarray, config: dict) -> np.ndarray[float]: | |
| """Convert a mono audio buffer to a normalized mel spectrogram matching training. | |
| Returns an array of shape (n_mels, n_frames) with float32 values.""" | |
| mel = librosa.feature.melspectrogram( | |
| y=y, | |
| sr=config["sr"], | |
| n_fft=config["n_fft"], | |
| hop_length=config["hop_length"], | |
| win_length=config["win_length"], | |
| n_mels=config["n_mels"], | |
| fmin=config["fmin"], | |
| fmax=config["fmax"], | |
| power=2.0, | |
| ) | |
| log_mel = librosa.power_to_db(mel, ref=np.max) | |
| x = log_mel.astype(np.float32) | |
| x = (x - x.mean()) / (x.std() + 1e-6) | |
| return x | |
| def _predict_one(x: np.ndarray, backend: str, sess_or_model: Any) -> np.ndarray[float]: | |
| """Return logits (n_classes,) for a single mel window (n_mels x n_frames).""" | |
| if backend == "onnx": | |
| inp_name = sess_or_model.get_inputs()[0].name | |
| out = sess_or_model.run(None, {inp_name: x[None, ...].astype(np.float32)})[0] | |
| return out[0] | |
| # torch | |
| import torch | |
| with torch.no_grad(): | |
| t = torch.from_numpy(x[None, ...].astype(np.float32)) | |
| return sess_or_model(t).numpy()[0] | |
| def _predict_probs(logits: np.ndarray) -> np.ndarray[float]: | |
| e = np.exp(logits - logits.max()) | |
| return e / e.sum() | |
| def identify(path: Path, no_log: bool = False, json_out: bool = False) -> dict[str, Any] | list[str] | list[dict]: | |
| backend, sess_or_model, config, label_map, manifest = _ensure_model() | |
| if backend == "perch-v3": | |
| return _identify_v3(path, sess_or_model, config, label_map, manifest, | |
| no_log=no_log, json_out=json_out) | |
| n_classes = len(label_map) | |
| idx_map = {v: k for k, v in label_map.items()} | |
| sr = config["sr"] | |
| win_samples = int(round(WINDOW_SEC * sr)) | |
| infer_hop_samples = int(round(INFER_HOP_SEC * sr)) | |
| n_frames_expected = int(round(WINDOW_SEC * sr / config["hop_length"])) + 1 | |
| y = _audio_to_mono_y(path, sr) | |
| total_sec = len(y) / sr | |
| # Slide windows across the recording. | |
| window_results: list[dict] = [] | |
| pos = 0 | |
| while pos + win_samples <= len(y): | |
| seg = y[pos:pos + win_samples] | |
| mel = _mel_from_y(seg, config) | |
| # Safety: if mel frame count differs from training (edge case), skip. | |
| if mel.shape[1] != n_frames_expected: | |
| pos += infer_hop_samples | |
| continue | |
| logits = _predict_one(mel, backend, sess_or_model) | |
| probs = _predict_probs(logits) | |
| pred = int(logits.argmax()) | |
| window_results.append({ | |
| "start_sec": round(pos / sr, 2), | |
| "pred_idx": pred, | |
| "pred_species": idx_map[pred], | |
| "confidence": float(probs[pred]), | |
| "probs": probs.tolist(), | |
| }) | |
| pos += infer_hop_samples | |
| if not window_results: | |
| raise SystemExit(f"Recording too short to extract any {WINDOW_SEC}s windows (duration {total_sec:.1f}s).") | |
| # Aggregate by class. | |
| counts = [0] * n_classes | |
| conf_sum = [0.0] * n_classes | |
| for wr in window_results: | |
| c = wr["pred_idx"] | |
| counts[c] += 1 | |
| conf_sum[c] += wr["confidence"] | |
| total_windows = len(window_results) | |
| support = [counts[c] / total_windows for c in range(n_classes)] | |
| mean_conf = [conf_sum[c] / counts[c] if counts[c] else 0.0 for c in range(n_classes)] | |
| # Rank classes by (support, mean_conf). | |
| ranked = sorted(range(n_classes), key=lambda c: (support[c], mean_conf[c]), reverse=True) | |
| top = ranked[0] | |
| shortlist = [ | |
| { | |
| "species": idx_map[c], | |
| "support": round(support[c], 3), | |
| "mean_confidence": round(mean_conf[c], 3), | |
| "window_count": counts[c], | |
| } | |
| for c in ranked[:3] | |
| ] | |
| # Confidence flag: honest heuristic. "confident" only if the top class has both | |
| # substantial support and decent per-window confidence. Otherwise "low confidence". | |
| top_support = support[top] | |
| top_conf = mean_conf[top] | |
| max_prob_across = float(max(wr["confidence"] for wr in window_results)) | |
| if top_support >= 0.5 and top_conf >= 0.5 and total_windows >= 3: | |
| flag = "confident" | |
| elif top_support >= 0.5 and top_conf >= 0.35: | |
| flag = "moderately confident" | |
| else: | |
| flag = "low confidence" | |
| result = { | |
| "audio_path": str(path), | |
| "duration_sec": round(total_sec, 1), | |
| "windows_used": total_windows, | |
| "top_species": idx_map[top], | |
| "top_species_index": top, | |
| "top_support": round(top_support, 3), | |
| "top_confidence": round(top_conf, 3), | |
| "max_window_confidence": round(max_prob_across, 3), | |
| "shortlist": shortlist, | |
| "confidence_flag": flag, | |
| "backend": backend, | |
| "model_params": manifest.get("input_shape"), | |
| "config": config, | |
| "label_map": label_map, | |
| "note": _model_note(label_map), | |
| } | |
| # Print a one-screen summary (skipped under --json, which owns stdout). | |
| if not json_out: | |
| _print_summary(result) | |
| if not no_log: | |
| _log_result(result, path) | |
| return result | |
| def _identify_v3(path: Path, v3: Any, config: dict, label_map: dict[str, int], | |
| manifest: dict, no_log: bool = False, | |
| json_out: bool = False) -> dict[str, Any]: | |
| """Classify with the v3 bundle: Perch backbone -> pooled embedding -> heads. | |
| Same output shape as the v2 path so logging, --json and the field notebook | |
| keep working; the semantics of `top_support` differ (a pooled probability, | |
| not a fraction of windows) and the summary says so rather than implying a | |
| window vote that never happened. | |
| """ | |
| sr = config["sr"] | |
| y = _audio_to_mono_y(path, sr) | |
| total_sec = len(y) / sr | |
| out = v3.predict(y, max_windows=v3_max_windows) | |
| idx_map = {v: k for k, v in label_map.items()} | |
| top = out["top_index"] | |
| result = { | |
| "audio_path": str(path), | |
| "duration_sec": round(total_sec, 1), | |
| "windows_used": out["n_windows"], | |
| "padded_short_clip": out["padded"], | |
| "top_species": idx_map.get(top, out["top_species"]), | |
| "top_species_index": top, | |
| "top_support": round(out["top_probability"], 3), | |
| "top_confidence": round(out["top_probability"], 3), | |
| "max_window_confidence": round(out["max_window_confidence"], 3), | |
| "window_agreement": out["window_agreement"], | |
| "bird_probability": round(out["bird_probability"], 3), | |
| "shortlist": out["shortlist"], | |
| "confidence_flag": out["confidence_flag"], | |
| "backend": "perch-v3", | |
| "model_params": manifest.get("input_shape"), | |
| "config": config, | |
| "label_map": label_map, | |
| "note": _model_note(label_map), | |
| } | |
| if not json_out: # --json means stdout is JSON and nothing else | |
| _print_summary(result) | |
| if not no_log: | |
| _log_result(result, path) | |
| return result | |
| def _model_note(label_map: dict) -> str: | |
| """Honest, dynamic description of what the loaded model actually covers.""" | |
| special = {"unknown bird", "no bird"} | |
| names = list(label_map) | |
| species = [n for n in names if n not in special] | |
| bits = [f"{len(species)} species"] | |
| for s in sorted(special & set(names)): | |
| bits.append(s) | |
| if is_v3(): | |
| return ("v3: frozen Perch 2.0 backbone (Apache-2.0) + a linear head trained on " | |
| "" + " + ".join(bits) + | |
| " from iNaturalist (CC0/CC-BY), Xeno-canto British birdsong, NPS Rocky " | |
| "Mountain and DCASE 2018 Task B. Recordist-disjoint CV top-1 and the " | |
| "frozen-set score live in reports/. Honest classifier: not a general " | |
| "bird ID.") | |
| return ("Trained on " + " + ".join(bits) + | |
| " (species from the NPS Rocky Mountain sound library and the Xeno-canto " | |
| "British birdsong set; bird/no-bird from DCASE 2018 Task B). " | |
| "Honest classifier: not a general bird ID. See models/report.json for " | |
| "held-out metrics.") | |
| def _print_summary(r: dict[str, Any] | list[str] | list[dict]) -> None: | |
| sp = r["top_species"] | |
| flag = r["confidence_flag"] | |
| v3 = r.get("backend") == "perch-v3" | |
| print("=" * 70) | |
| print(f"Recording : {r['audio_path']}") | |
| print(f"Duration : {r['duration_sec']}s ({r['windows_used']} windows analyzed)") | |
| if v3 and r.get("padded_short_clip"): | |
| print(" : shorter than one 5s window -- zero-padded, so treat this as a sample") | |
| print("-" * 70) | |
| print(f"Best guess: {sp}") | |
| if v3: | |
| print(f" pooled probability : {r['top_support']:.1%}") | |
| print(f" bird / no-bird probability : {r['bird_probability']:.2f}") | |
| print(f" window agreement on that species : {r['window_agreement']:.1%}") | |
| print(f" confidence flag : {flag}") | |
| print(" Rule (set before seeing these results): confident if pooled probability >= 50% and") | |
| print(" window agreement >= 50% with >= 3 windows; moderately confident if probability >= 35%;") | |
| print(" else low confidence. The flag reflects confidence, not correctness.") | |
| print(" Heads were trained on pooled clip vectors, so per-window scores are a diagnostic.") | |
| else: | |
| print(f" support (fraction of windows): {r['top_support']:.1%}") | |
| print(f" mean confidence per window : {r['top_confidence']:.2f}") | |
| print(f" max single-window confidence : {r['max_window_confidence']:.2f}") | |
| print(f" confidence flag : {flag}") | |
| print(" Rule (set before seeing these results): confident if top support >= 50%% and " | |
| "mean per-window confidence >= 0.50 with >= 3 windows analyzed; moderately " | |
| "confident if top support >= 50%% and mean >= 0.35; else low confidence. " | |
| "The flag reflects agreement/confidence, not correctness.") | |
| print("-" * 70) | |
| print("Shortlist (top 3):") | |
| for i, s in enumerate(r["shortlist"], 1): | |
| print(f" {i}. {s['species']:<20} support {s['support']:.1%} " | |
| f"mean conf {s['mean_confidence']:.2f} ({s['window_count']} windows)") | |
| print("-" * 70) | |
| if flag.startswith("low"): | |
| print("NOTE: low confidence. In this one test the model declined to commit on a") | |
| print(" species it doesn't know -- which is what an honest tool should do. But one") | |
| print(" clip is not proof of the mechanism: a different clip could easily cross the") | |
| print(" threshold. See the non-target evaluation in README.md for a broader check.") | |
| print("=" * 70) | |
| print("All offline. Nothing sent anywhere. Result logged to the local field log.") | |
| n_classes = len(r.get("label_map", {})) | |
| print(f"Model covers {n_classes} classes. It is NOT a general bird-identification service.") | |
| print("=" * 70) | |
| def _log_result(r: dict[str, Any], path: Path) -> None: | |
| sys.path.insert(0, str(PROJECT_ROOT)) | |
| from tools import field_log as fl | |
| try: | |
| params = json.loads((PROJECT_ROOT / "models" / "report.json").read_text()).get("model_params") | |
| except Exception: # noqa: BLE001 | |
| params = None | |
| entry = { | |
| "created": fl._now_iso(), | |
| "device_tag": fl._device_id(), | |
| "audio_path": str(path), | |
| "audio_exists": int(path.exists()), | |
| "top_species": r["top_species"], | |
| "top_support": r["top_support"], | |
| "top_confidence": r["top_confidence"], | |
| "shortlist": r["shortlist"], | |
| "confidence_flag": r["confidence_flag"], | |
| "model_name": ("Perch 2.0 (frozen) + linear head (v3)" if r.get("backend") == "perch-v3" | |
| else "BirdCallNet (open CNN, trained by us)"), | |
| "model_params": params, | |
| "note": r.get("note", "Offline bird-call identifier."), | |
| } | |
| row_id = fl.append(entry) | |
| print(f"\nLogged to field notebook (row id: {row_id}).", file=sys.stderr) | |
| def _json_result(r: dict[str, Any] | list[str] | list[dict]) -> None: | |
| public = {k: v for k, v in r.items() if k != "config"} | |
| print(json.dumps(public, indent=2)) | |
| def main() -> int | list[str] | list[dict]: | |
| p = argparse.ArgumentParser( | |
| description="Offline bird-call identification for a recording (no network)." | |
| ) | |
| p.add_argument("audio", type=Path, help="path to a recording (mp3, wav, m4a, flac, ogg)") | |
| p.add_argument("--no-log", action="store_true", help="classify only; do not write the field log") | |
| p.add_argument("--json", action="store_true", help="also print machine-readable JSON to stdout") | |
| p.add_argument("--models-dir", default="models", metavar="DIR", | |
| help="which model to use, relative to the repo root or absolute " | |
| "(default: models; v1 = models_v1_5species, v3 = models_v3)") | |
| p.add_argument("--max-windows", type=int, default=0, metavar="N", | |
| help="v3 only: cap the 5s windows pooled into one answer " | |
| "(default 12, the value the heads were trained with; 0 = that default)") | |
| args = p.parse_args() | |
| used_dir = set_models_dir(args.models_dir) | |
| if args.max_windows > 0: | |
| globals()["v3_max_windows"] = args.max_windows | |
| if args.json: | |
| # surface which model answered, so two runs can be compared unambiguously | |
| print(json.dumps({"models_dir": str(used_dir)}, indent=2), file=sys.stderr) | |
| if not args.audio.exists(): | |
| raise SystemExit(f"Audio file not found: {args.audio}") | |
| result = identify(args.audio, no_log=args.no_log, json_out=args.json) | |
| if args.json: | |
| result["models_dir"] = str(MODELS_DIR) | |
| _json_result(result) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |