mg643 commited on
Commit
2cc62ca
Β·
1 Parent(s): e54e8ef

backend changes

Browse files
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  """
2
  app.py
3
 
@@ -11,22 +12,20 @@ Endpoints:
11
  Usage:
12
  uvicorn app:app --host 0.0.0.0 --port 8000 --reload
13
 
14
- The server loads the best model weights and label encoder from models/
15
- on startup. Set HF_REPO_ID env var to pull weights from HuggingFace Hub
16
- instead of local disk.
17
  """
18
 
19
- import io
20
  import json
21
  import os
22
  import tempfile
23
  from pathlib import Path
24
  from typing import Optional
25
 
 
26
  import joblib
27
- import librosa
28
  import numpy as np
29
- import torch
30
  import uvicorn
31
  from fastapi import FastAPI, File, HTTPException, UploadFile
32
  from fastapi.middleware.cors import CORSMiddleware
@@ -48,6 +47,9 @@ from scripts.model import EfficientNetModel
48
  MODELS_DIR = Path("models")
49
  CONFIG_PATH = MODELS_DIR / "model_config.json"
50
 
 
 
 
51
  # ── App ────────────────────────────────────────────────────────────────────────
52
  app = FastAPI(
53
  title="Warbler β€” Bird Audio Classifier",
@@ -57,43 +59,38 @@ app = FastAPI(
57
 
58
  app.add_middleware(
59
  CORSMiddleware,
60
- allow_origins=["*"], # tighten for production
61
  allow_methods=["*"],
62
  allow_headers=["*"],
63
  )
64
 
65
- # ── Global model state (loaded once at startup) ────────────────────────────────
66
- _model: Optional[EfficientNetModel] = None
67
- _le = None
68
- _config: Optional[dict] = None
69
 
70
 
71
  def _load_from_hub(repo_id: str) -> None:
72
  """
73
- Download model artifacts from a HuggingFace Hub model repository.
74
 
75
  Args:
76
- repo_id: HuggingFace repo string, e.g. 'username/warbler-bird-classifier'.
77
  """
78
  from huggingface_hub import hf_hub_download
79
- files = ["efficientnet_best.pt", "label_encoder.pkl", "model_config.json"]
80
  MODELS_DIR.mkdir(parents=True, exist_ok=True)
81
- for filename in files:
82
  dest = MODELS_DIR / filename
83
  if not dest.exists():
84
- print(f"Downloading {filename}...")
85
  path = hf_hub_download(repo_id=repo_id, filename=filename)
86
  dest.write_bytes(Path(path).read_bytes())
87
 
88
 
89
  @app.on_event("startup")
90
  def load_model() -> None:
91
- """
92
- Load model weights, label encoder, and config at server startup.
93
-
94
- Pulls from HuggingFace Hub if HF_REPO_ID env var is set;
95
- otherwise loads from local models/ directory.
96
- """
97
  global _model, _le, _config
98
 
99
  hf_repo = "mg643/chirp_model"
@@ -101,9 +98,7 @@ def load_model() -> None:
101
  _load_from_hub(hf_repo)
102
 
103
  if not CONFIG_PATH.exists():
104
- raise RuntimeError(
105
- "model_config.json not found. Run setup.py or set HF_REPO_ID."
106
- )
107
 
108
  with open(CONFIG_PATH) as f:
109
  _config = json.load(f)
@@ -111,11 +106,11 @@ def load_model() -> None:
111
  _le = joblib.load(MODELS_DIR / "label_encoder.pkl")
112
  _model = EfficientNetModel.load(num_classes=_config["num_classes"], models_dir=MODELS_DIR)
113
 
114
- print(f"Model loaded: {_config['best_model']} | {_config['num_classes']} classes")
 
 
115
 
116
 
117
- # ── Helper ─────────────────────────────────────────────────────────────────────
118
-
119
  def _preprocess_audio(audio_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
120
  """
121
  Decode uploaded audio bytes and extract MFCC + mel spectrogram features.
@@ -124,7 +119,7 @@ def _preprocess_audio(audio_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
124
  audio_bytes: Raw bytes of any librosa-supported audio format.
125
 
126
  Returns:
127
- Tuple of (mfcc_vector, mel_spectrogram) as numpy arrays.
128
  """
129
  with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
130
  tmp.write(audio_bytes)
@@ -135,20 +130,47 @@ def _preprocess_audio(audio_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
135
  finally:
136
  Path(tmp_path).unlink(missing_ok=True)
137
 
138
- mfcc = extract_mfcc(audio)
139
- mel = compute_mel_spectrogram(audio)
140
- return mfcc, mel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
 
143
  # ── Routes ─────────────────────────────────────────────────────────────────────
144
 
145
  @app.get("/health")
146
  def health() -> dict:
147
- """Liveness check β€” returns model name and class count."""
148
  return {
149
- "status": "ok",
150
- "model": _config["best_model"] if _config else "not loaded",
151
  "classes": _config["num_classes"] if _config else 0,
 
152
  }
153
 
154
 
@@ -170,9 +192,7 @@ async def predict(file: UploadFile = File(...), top_k: int = 3) -> dict:
170
  top_k: Number of top predictions to return (default 3).
171
 
172
  Returns:
173
- JSON with top-K predictions, each containing:
174
- - species_code : BirdCLEF species label (e.g. 'norcar')
175
- - confidence : Softmax probability (0–1)
176
  """
177
  if _model is None:
178
  raise HTTPException(status_code=503, detail="Model not loaded.")
@@ -186,10 +206,8 @@ async def predict(file: UploadFile = File(...), top_k: int = 3) -> dict:
186
  except Exception as exc:
187
  raise HTTPException(status_code=422, detail=f"Audio processing failed: {exc}")
188
 
189
- # Run inference β€” mel shape must be (1, N_MELS, T)
190
- probs = _model.predict_proba(mel[np.newaxis]) # (1, num_classes)
191
- probs_flat = probs[0]
192
-
193
  top_k = min(top_k, len(_le.classes_))
194
  top_idx = np.argsort(probs_flat)[::-1][:top_k]
195
 
@@ -201,15 +219,26 @@ async def predict(file: UploadFile = File(...), top_k: int = 3) -> dict:
201
  for i in top_idx
202
  ]
203
 
 
 
 
 
 
 
 
 
 
 
 
204
  return {
205
- "predictions": predictions,
206
- "model": _config["best_model"],
207
- "top_species": predictions[0]["species_code"],
208
- "confidence": predictions[0]["confidence"],
209
  }
210
 
211
 
212
  # ── Entry point ────────────────────────────────────────────────────────────────
213
 
214
  if __name__ == "__main__":
215
- uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
 
1
+
2
  """
3
  app.py
4
 
 
12
  Usage:
13
  uvicorn app:app --host 0.0.0.0 --port 8000 --reload
14
 
15
+ Environment variables:
16
+ HF_REPO_ID β€” HuggingFace model repo to pull weights from
17
+ PI_URL β€” Raspberry Pi server URL e.g. http://192.168.1.42:5000
18
  """
19
 
 
20
  import json
21
  import os
22
  import tempfile
23
  from pathlib import Path
24
  from typing import Optional
25
 
26
+ import httpx
27
  import joblib
 
28
  import numpy as np
 
29
  import uvicorn
30
  from fastapi import FastAPI, File, HTTPException, UploadFile
31
  from fastapi.middleware.cors import CORSMiddleware
 
47
  MODELS_DIR = Path("models")
48
  CONFIG_PATH = MODELS_DIR / "model_config.json"
49
 
50
+ # ── Pi config β€” set PI_URL env var to enable Pi notifications ─────────────────
51
+ PI_URL = os.getenv("PI_URL") # e.g. "http://192.168.1.42:5000"
52
+
53
  # ── App ────────────────────────────────────────────────────────────────────────
54
  app = FastAPI(
55
  title="Warbler β€” Bird Audio Classifier",
 
59
 
60
  app.add_middleware(
61
  CORSMiddleware,
62
+ allow_origins=["*"],
63
  allow_methods=["*"],
64
  allow_headers=["*"],
65
  )
66
 
67
+ # ── Global model state ─────────────────────────────────────────────────────────
68
+ _model: Optional[EfficientNetModel] = None
69
+ _le = None
70
+ _config: Optional[dict] = None
71
 
72
 
73
  def _load_from_hub(repo_id: str) -> None:
74
  """
75
+ Download model artifacts from HuggingFace Hub.
76
 
77
  Args:
78
+ repo_id: e.g. 'mg643/chirp_model'
79
  """
80
  from huggingface_hub import hf_hub_download
81
+
82
  MODELS_DIR.mkdir(parents=True, exist_ok=True)
83
+ for filename in ["efficientnet_best.pt", "label_encoder.pkl", "model_config.json"]:
84
  dest = MODELS_DIR / filename
85
  if not dest.exists():
86
+ print(f"Downloading {filename} from {repo_id}…")
87
  path = hf_hub_download(repo_id=repo_id, filename=filename)
88
  dest.write_bytes(Path(path).read_bytes())
89
 
90
 
91
  @app.on_event("startup")
92
  def load_model() -> None:
93
+ """Load model weights, label encoder, and config at server startup."""
 
 
 
 
 
94
  global _model, _le, _config
95
 
96
  hf_repo = "mg643/chirp_model"
 
98
  _load_from_hub(hf_repo)
99
 
100
  if not CONFIG_PATH.exists():
101
+ raise RuntimeError("model_config.json not found. Run setup.py or set HF_REPO_ID.")
 
 
102
 
103
  with open(CONFIG_PATH) as f:
104
  _config = json.load(f)
 
106
  _le = joblib.load(MODELS_DIR / "label_encoder.pkl")
107
  _model = EfficientNetModel.load(num_classes=_config["num_classes"], models_dir=MODELS_DIR)
108
 
109
+ pi_status = f"Pi notifications β†’ {PI_URL}" if PI_URL else "Pi notifications β†’ disabled (set PI_URL)"
110
+ print(f"Model loaded: {_config['best_model']} | {_config['num_classes']} classes")
111
+ print(pi_status)
112
 
113
 
 
 
114
  def _preprocess_audio(audio_bytes: bytes) -> tuple[np.ndarray, np.ndarray]:
115
  """
116
  Decode uploaded audio bytes and extract MFCC + mel spectrogram features.
 
119
  audio_bytes: Raw bytes of any librosa-supported audio format.
120
 
121
  Returns:
122
+ Tuple of (mfcc_vector, mel_spectrogram).
123
  """
124
  with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
125
  tmp.write(audio_bytes)
 
130
  finally:
131
  Path(tmp_path).unlink(missing_ok=True)
132
 
133
+ return extract_mfcc(audio), compute_mel_spectrogram(audio)
134
+
135
+
136
+ async def _notify_pi(species_code: str, common_name: str, confidence: float) -> None:
137
+ """
138
+ Fire-and-forget POST to the Raspberry Pi server.
139
+ Fails silently so Pi issues never break the main prediction response.
140
+
141
+ Args:
142
+ species_code: BirdCLEF 6-letter code e.g. 'norcar'
143
+ common_name: Human-readable name e.g. 'Northern Cardinal'
144
+ confidence: Model confidence 0–1
145
+ """
146
+ if not PI_URL:
147
+ return
148
+
149
+ payload = {
150
+ "species_code": species_code,
151
+ "common_name": common_name,
152
+ "confidence": confidence,
153
+ }
154
+
155
+ try:
156
+ async with httpx.AsyncClient(timeout=3.0) as client:
157
+ res = await client.post(f"{PI_URL}/bird", json=payload)
158
+ print(f"Pi notified: {res.status_code}")
159
+ except Exception as exc:
160
+ # Log but never raise β€” Pi being offline shouldn't break predictions
161
+ print(f"Pi notification failed (continuing): {exc}")
162
 
163
 
164
  # ── Routes ─────────────────────────────────────────────────────────────────────
165
 
166
  @app.get("/health")
167
  def health() -> dict:
168
+ """Liveness check."""
169
  return {
170
+ "status": "ok",
171
+ "model": _config["best_model"] if _config else "not loaded",
172
  "classes": _config["num_classes"] if _config else 0,
173
+ "pi": PI_URL ?? "not configured",
174
  }
175
 
176
 
 
192
  top_k: Number of top predictions to return (default 3).
193
 
194
  Returns:
195
+ JSON with top-K predictions plus top species name and confidence.
 
 
196
  """
197
  if _model is None:
198
  raise HTTPException(status_code=503, detail="Model not loaded.")
 
206
  except Exception as exc:
207
  raise HTTPException(status_code=422, detail=f"Audio processing failed: {exc}")
208
 
209
+ # Run inference
210
+ probs_flat = _model.predict_proba(mel[np.newaxis])[0]
 
 
211
  top_k = min(top_k, len(_le.classes_))
212
  top_idx = np.argsort(probs_flat)[::-1][:top_k]
213
 
 
219
  for i in top_idx
220
  ]
221
 
222
+ top_species = predictions[0]["species_code"]
223
+ top_conf = predictions[0]["confidence"]
224
+
225
+ # Look up common name from config classes list (species_code IS the label here)
226
+ # Notify Pi asynchronously β€” does not block response
227
+ await _notify_pi(
228
+ species_code=top_species,
229
+ common_name=top_species, # replace with a lookup dict if you have common names
230
+ confidence=top_conf,
231
+ )
232
+
233
  return {
234
+ "predictions": predictions,
235
+ "model": _config["best_model"],
236
+ "top_species": top_species,
237
+ "confidence": top_conf,
238
  }
239
 
240
 
241
  # ── Entry point ────────────────────────────────────────────────────────────────
242
 
243
  if __name__ == "__main__":
244
+ uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
frontend/src/App.jsx CHANGED
@@ -1,49 +1,69 @@
1
  import React, { useState, useCallback } from 'react'
2
  import { motion, AnimatePresence } from 'framer-motion'
3
- import Header from './components/Header'
4
  import AudioUploader from './components/AudioUploader'
5
- import Spectrogram from './components/Spectrogram'
6
- import BirdResult from './components/BirdResult'
7
- import RangeMap from './components/RangeMap'
8
- import { mockPredict, MOCK_BIRDS, SAMPLE_AUDIOS } from './data/mockData'
9
  import { useSpectrogram } from './hooks/useSpectrogram'
10
  import styles from './App.module.css'
11
 
 
 
12
  export default function App() {
13
- const [audioSelection, setAudioSelection] = useState(null) // { file, sampleId }
14
  const [isLoading, setIsLoading] = useState(false)
15
  const [result, setResult] = useState(null)
 
16
 
17
  const { spectrogramUrl, generateSpectrogram, isGenerating } = useSpectrogram()
18
 
19
- const handleAudioSelected = useCallback((selection) => {
20
  setAudioSelection(selection)
21
  setResult(null)
22
- // Generate spectrogram if a real file was uploaded
 
23
  if (selection.file) {
24
  generateSpectrogram(selection.file)
 
 
 
 
 
 
 
 
25
  }
26
  }, [generateSpectrogram])
27
 
28
  const handleIdentify = useCallback(async () => {
29
  if (!audioSelection) return
30
  setIsLoading(true)
 
31
 
32
  try {
33
- // Determine which mock bird to return
34
- const sampleId = audioSelection.sampleId
35
- ?? SAMPLE_AUDIOS[Math.floor(Math.random() * SAMPLE_AUDIOS.length)].id
36
-
37
- // ── Replace mockPredict() with real API call when backend is ready ──
38
- // const res = await fetch('http://localhost:8000/predict', {
39
- // method: 'POST',
40
- // body: formData,
41
- // })
42
- // const data = await res.json()
43
- const data = await mockPredict(sampleId)
44
- setResult(data)
 
 
 
 
 
 
45
  } catch (err) {
46
- console.error('Prediction failed:', err)
 
47
  } finally {
48
  setIsLoading(false)
49
  }
@@ -53,7 +73,6 @@ export default function App() {
53
 
54
  return (
55
  <div className={styles.page}>
56
- {/* Decorative background blobs */}
57
  <div className={styles.blob1} />
58
  <div className={styles.blob2} />
59
 
@@ -61,7 +80,7 @@ export default function App() {
61
  <Header />
62
 
63
  <main className={styles.main}>
64
- {/* Left panel β€” always visible */}
65
  <motion.div
66
  className={styles.leftPanel}
67
  initial={{ opacity: 0, x: -20 }}
@@ -71,8 +90,8 @@ export default function App() {
71
  <div className={styles.panelCard}>
72
  <p className={styles.panelTitle}>Listen & Identify</p>
73
  <p className={styles.panelSub}>
74
- Upload a bird call recording or try one of our curated samples.
75
- Chirp will identify the species in seconds.
76
  </p>
77
 
78
  <div className={styles.divider} />
@@ -82,7 +101,6 @@ export default function App() {
82
  isLoading={isLoading}
83
  />
84
 
85
- {/* Wire up identify button here */}
86
  {hasAudio && !isLoading && !result && (
87
  <motion.button
88
  className={styles.identifyBtn}
@@ -102,9 +120,13 @@ export default function App() {
102
  <p>Analysing audio…</p>
103
  </div>
104
  )}
 
 
 
 
105
  </div>
106
 
107
- {/* Spectrogram below uploader */}
108
  <AnimatePresence>
109
  {(spectrogramUrl || isGenerating) && (
110
  <motion.div
@@ -119,7 +141,7 @@ export default function App() {
119
  </AnimatePresence>
120
  </motion.div>
121
 
122
- {/* Right panel β€” results */}
123
  <AnimatePresence mode="wait">
124
  {!result ? (
125
  <motion.div
@@ -132,17 +154,13 @@ export default function App() {
132
  <div className={styles.placeholderInner}>
133
  <div className={styles.birdIllustration}>
134
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
135
- {/* Simple elegant bird silhouette */}
136
  <ellipse cx="52" cy="42" rx="22" ry="14" fill="var(--mauve-200)" />
137
  <circle cx="74" cy="34" r="10" fill="var(--mauve-200)" />
138
  <ellipse cx="30" cy="46" rx="18" ry="8" fill="var(--mauve-100)" transform="rotate(-15 30 46)" />
139
  <ellipse cx="68" cy="56" rx="14" ry="5" fill="var(--mauve-100)" transform="rotate(10 68 56)" />
140
- {/* Eye */}
141
  <circle cx="78" cy="31" r="2.5" fill="var(--ink)" />
142
  <circle cx="79" cy="30" r="0.8" fill="white" />
143
- {/* Beak */}
144
  <path d="M83 34 L92 36 L83 37 Z" fill="var(--gold)" />
145
- {/* Branch */}
146
  <path d="M10 65 Q60 60 110 68" stroke="var(--beige-300)" strokeWidth="2.5" strokeLinecap="round" fill="none" />
147
  </svg>
148
  </div>
@@ -166,7 +184,6 @@ export default function App() {
166
  commonName={result.commonName}
167
  color={result.color}
168
  />
169
-
170
  <button
171
  className={styles.resetBtn}
172
  onClick={() => { setResult(null); setAudioSelection(null) }}
 
1
  import React, { useState, useCallback } from 'react'
2
  import { motion, AnimatePresence } from 'framer-motion'
3
+ import Header from './components/Header'
4
  import AudioUploader from './components/AudioUploader'
5
+ import Spectrogram from './components/Spectrogram'
6
+ import BirdResult from './components/BirdResult'
7
+ import RangeMap from './components/RangeMap'
8
+ import { enrichResult } from './data/mockData'
9
  import { useSpectrogram } from './hooks/useSpectrogram'
10
  import styles from './App.module.css'
11
 
12
+ const API_URL = 'https://mg643-chirp.hf.space'
13
+
14
  export default function App() {
15
+ const [audioSelection, setAudioSelection] = useState(null)
16
  const [isLoading, setIsLoading] = useState(false)
17
  const [result, setResult] = useState(null)
18
+ const [error, setError] = useState(null)
19
 
20
  const { spectrogramUrl, generateSpectrogram, isGenerating } = useSpectrogram()
21
 
22
+ const handleAudioSelected = useCallback(async (selection) => {
23
  setAudioSelection(selection)
24
  setResult(null)
25
+ setError(null)
26
+
27
  if (selection.file) {
28
  generateSpectrogram(selection.file)
29
+ } else if (selection.sampleId) {
30
+ try {
31
+ const res = await fetch(`/samples/${selection.sampleId}.ogg`)
32
+ const blob = await res.blob()
33
+ generateSpectrogram(new File([blob], `${selection.sampleId}.ogg`, { type: 'audio/ogg' }))
34
+ } catch (err) {
35
+ console.warn('Spectrogram generation failed for sample:', err)
36
+ }
37
  }
38
  }, [generateSpectrogram])
39
 
40
  const handleIdentify = useCallback(async () => {
41
  if (!audioSelection) return
42
  setIsLoading(true)
43
+ setError(null)
44
 
45
  try {
46
+ let audioFile = audioSelection.file
47
+ if (!audioFile && audioSelection.sampleId) {
48
+ const res = await fetch(`/samples/${audioSelection.sampleId}.ogg`)
49
+ const blob = await res.blob()
50
+ audioFile = new File([blob], `${audioSelection.sampleId}.ogg`, { type: 'audio/ogg' })
51
+ }
52
+
53
+ const formData = new FormData()
54
+ formData.append('file', audioFile)
55
+
56
+ const res = await fetch(`${API_URL}/predict?top_k=3`, {
57
+ method: 'POST',
58
+ body: formData,
59
+ })
60
+ if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`)
61
+
62
+ const data = await res.json()
63
+ setResult(enrichResult(data))
64
  } catch (err) {
65
+ console.error('Identify failed:', err)
66
+ setError('Could not reach the identification service. Please try again.')
67
  } finally {
68
  setIsLoading(false)
69
  }
 
73
 
74
  return (
75
  <div className={styles.page}>
 
76
  <div className={styles.blob1} />
77
  <div className={styles.blob2} />
78
 
 
80
  <Header />
81
 
82
  <main className={styles.main}>
83
+ {/* ── Left panel ── */}
84
  <motion.div
85
  className={styles.leftPanel}
86
  initial={{ opacity: 0, x: -20 }}
 
90
  <div className={styles.panelCard}>
91
  <p className={styles.panelTitle}>Listen & Identify</p>
92
  <p className={styles.panelSub}>
93
+ Upload a bird call recording or try our sample.
94
+ Chirp identifies the species in seconds.
95
  </p>
96
 
97
  <div className={styles.divider} />
 
101
  isLoading={isLoading}
102
  />
103
 
 
104
  {hasAudio && !isLoading && !result && (
105
  <motion.button
106
  className={styles.identifyBtn}
 
120
  <p>Analysing audio…</p>
121
  </div>
122
  )}
123
+
124
+ {error && (
125
+ <p className={styles.errorMsg}>{error}</p>
126
+ )}
127
  </div>
128
 
129
+ {/* Spectrogram */}
130
  <AnimatePresence>
131
  {(spectrogramUrl || isGenerating) && (
132
  <motion.div
 
141
  </AnimatePresence>
142
  </motion.div>
143
 
144
+ {/* ── Right panel ── */}
145
  <AnimatePresence mode="wait">
146
  {!result ? (
147
  <motion.div
 
154
  <div className={styles.placeholderInner}>
155
  <div className={styles.birdIllustration}>
156
  <svg viewBox="0 0 120 80" fill="none" xmlns="http://www.w3.org/2000/svg">
 
157
  <ellipse cx="52" cy="42" rx="22" ry="14" fill="var(--mauve-200)" />
158
  <circle cx="74" cy="34" r="10" fill="var(--mauve-200)" />
159
  <ellipse cx="30" cy="46" rx="18" ry="8" fill="var(--mauve-100)" transform="rotate(-15 30 46)" />
160
  <ellipse cx="68" cy="56" rx="14" ry="5" fill="var(--mauve-100)" transform="rotate(10 68 56)" />
 
161
  <circle cx="78" cy="31" r="2.5" fill="var(--ink)" />
162
  <circle cx="79" cy="30" r="0.8" fill="white" />
 
163
  <path d="M83 34 L92 36 L83 37 Z" fill="var(--gold)" />
 
164
  <path d="M10 65 Q60 60 110 68" stroke="var(--beige-300)" strokeWidth="2.5" strokeLinecap="round" fill="none" />
165
  </svg>
166
  </div>
 
184
  commonName={result.commonName}
185
  color={result.color}
186
  />
 
187
  <button
188
  className={styles.resetBtn}
189
  onClick={() => { setResult(null); setAudioSelection(null) }}
frontend/src/components/Header.jsx CHANGED
@@ -11,11 +11,11 @@ export default function Header() {
11
  transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
12
  >
13
  <div className={styles.logo}>
14
- <span className={styles.logoIcon}>π„ž</span>
15
  <span className={styles.logoText}>chirp!</span>
16
  </div>
17
  <p className={styles.tagline}>your birdwatching companion</p>
18
  <div className={styles.divider} />
19
  </motion.header>
20
  )
21
- }
 
11
  transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
12
  >
13
  <div className={styles.logo}>
14
+ <img src="/logo.png" alt="Chirp logo" className={styles.logoImg} />
15
  <span className={styles.logoText}>chirp!</span>
16
  </div>
17
  <p className={styles.tagline}>your birdwatching companion</p>
18
  <div className={styles.divider} />
19
  </motion.header>
20
  )
21
+ }
frontend/src/components/Header.module.css CHANGED
@@ -40,3 +40,9 @@
40
  background: var(--mauve-200);
41
  margin: 1.5rem auto 0;
42
  }
 
 
 
 
 
 
 
40
  background: var(--mauve-200);
41
  margin: 1.5rem auto 0;
42
  }
43
+
44
+ .logoImg {
45
+ height: 100px;
46
+ width: auto;
47
+ object-fit: contain;
48
+ }
frontend/src/components/RangeMap.jsx CHANGED
@@ -1,32 +1,31 @@
1
- import React, { useEffect } from 'react'
 
 
 
 
 
2
  import { motion } from 'framer-motion'
3
  import styles from './RangeMap.module.css'
4
 
5
- // Lazy-load Leaflet to avoid SSR issues
6
- let L, MapContainer, TileLayer, Circle, Popup
7
-
8
- async function loadLeaflet() {
9
- if (typeof window === 'undefined') return false
10
- L = await import('leaflet')
11
- const rl = await import('react-leaflet')
12
- MapContainer = rl.MapContainer
13
- TileLayer = rl.TileLayer
14
- Circle = rl.Circle
15
- Popup = rl.Popup
16
- return true
17
- }
18
-
19
  export default function RangeMap({ range, commonName, color }) {
20
- const [ready, setReady] = React.useState(false)
 
 
 
 
21
 
22
  useEffect(() => {
23
- loadLeaflet().then(setReady)
 
 
 
 
 
 
24
  }, [])
25
 
26
  if (!range) return null
27
 
28
- const { center, description } = range
29
-
30
  return (
31
  <motion.div
32
  className={styles.wrapper}
@@ -38,7 +37,7 @@ export default function RangeMap({ range, commonName, color }) {
38
  <div className={styles.mapContainer}>
39
  {ready && MapContainer ? (
40
  <MapContainer
41
- center={center}
42
  zoom={3}
43
  scrollWheelZoom={false}
44
  style={{ height: '100%', width: '100%' }}
@@ -49,23 +48,23 @@ export default function RangeMap({ range, commonName, color }) {
49
  url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
50
  />
51
  <Circle
52
- center={center}
53
  radius={800000}
54
  pathOptions={{
55
- color: color || '#9a749a',
56
- fillColor: color || '#9a749a',
57
  fillOpacity: 0.18,
58
- weight: 1.5,
59
  }}
60
  >
61
- <Popup>{commonName} β€” breeding range</Popup>
62
  </Circle>
63
  </MapContainer>
64
  ) : (
65
  <div className={styles.mapPlaceholder}>Loading map…</div>
66
  )}
67
  </div>
68
- <p className={styles.rangeDesc}>{description}</p>
69
  </motion.div>
70
  )
71
  }
 
1
+ /**
2
+ * src/components/RangeMap.jsx
3
+ * Renders a Leaflet map with a range circle centered on the species' known habitat.
4
+ */
5
+
6
+ import React, { useEffect, useState } from 'react'
7
  import { motion } from 'framer-motion'
8
  import styles from './RangeMap.module.css'
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  export default function RangeMap({ range, commonName, color }) {
11
+ const [ready, setReady] = useState(false)
12
+ const [MapContainer, setMapContainer] = useState(null)
13
+ const [TileLayer, setTileLayer] = useState(null)
14
+ const [Circle, setCircle] = useState(null)
15
+ const [Popup, setPopup] = useState(null)
16
 
17
  useEffect(() => {
18
+ import('react-leaflet').then(rl => {
19
+ setMapContainer(() => rl.MapContainer)
20
+ setTileLayer(() => rl.TileLayer)
21
+ setCircle(() => rl.Circle)
22
+ setPopup(() => rl.Popup)
23
+ setReady(true)
24
+ })
25
  }, [])
26
 
27
  if (!range) return null
28
 
 
 
29
  return (
30
  <motion.div
31
  className={styles.wrapper}
 
37
  <div className={styles.mapContainer}>
38
  {ready && MapContainer ? (
39
  <MapContainer
40
+ center={range.center}
41
  zoom={3}
42
  scrollWheelZoom={false}
43
  style={{ height: '100%', width: '100%' }}
 
48
  url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
49
  />
50
  <Circle
51
+ center={range.center}
52
  radius={800000}
53
  pathOptions={{
54
+ color: color ?? '#9a749a',
55
+ fillColor: color ?? '#9a749a',
56
  fillOpacity: 0.18,
57
+ weight: 1.5,
58
  }}
59
  >
60
+ <Popup>{commonName} β€” approximate range</Popup>
61
  </Circle>
62
  </MapContainer>
63
  ) : (
64
  <div className={styles.mapPlaceholder}>Loading map…</div>
65
  )}
66
  </div>
67
+ <p className={styles.rangeDesc}>{range.description}</p>
68
  </motion.div>
69
  )
70
  }
frontend/src/data/mockData.js CHANGED
@@ -1,113 +1,145 @@
1
- // Mock bird data for dummy backend responses
2
- // Replace with real API calls when backend is connected
 
 
 
 
 
3
 
4
- export const MOCK_BIRDS = {
5
- 'westan': {
6
- commonName: 'Western Tanager',
7
- scientificName: 'Piranga ludoviciana',
8
- confidence: 0.87,
9
- family: 'Cardinalidae',
10
- habitat: 'Coniferous and mixed forests',
11
- diet: 'Insects, berries, and fruit',
12
- funFacts: [
13
- 'Males sport a brilliant red head with yellow and black body β€” one of North America\'s most colorful birds.',
14
- 'Despite their tropical appearance, Western Tanagers breed across western North America and migrate to Central America.',
15
- 'Their song is a burry, robin-like phrase often described as "pit-er-ick".',
16
- ],
17
- range: {
18
- center: [45.5, -116.0],
19
- description: 'Western North America, from Alaska south to Mexico during breeding season.',
20
- },
21
- topPredictions: [
22
- { name: 'Western Tanager', confidence: 0.87 },
23
- { name: 'American Robin', confidence: 0.08 },
24
- { name: 'Varied Thrush', confidence: 0.05 },
25
- ],
26
- wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/thirty/Piranga_ludoviciana_-_Western_Tanager.jpg/480px-Piranga_ludoviciana_-_Western_Tanager.jpg',
27
- color: '#c9a84c',
28
  },
29
- 'norcar': {
30
- commonName: 'Northern Cardinal',
 
 
 
 
31
  scientificName: 'Cardinalis cardinalis',
32
- confidence: 0.92,
33
- family: 'Cardinalidae',
34
- habitat: 'Woodlands, gardens, shrublands',
35
- diet: 'Seeds, fruit, and insects',
36
  funFacts: [
37
- 'The male\'s brilliant red plumage comes from carotenoid pigments in its diet.',
38
  'Unlike most songbirds, female Northern Cardinals also sing β€” a rare trait among North American birds.',
39
  'They are non-migratory and often visit backyard feeders year-round.',
40
  ],
41
  range: {
42
- center: [37.0, -85.0],
43
  description: 'Eastern and central North America, from southern Canada to Mexico.',
44
  },
45
- topPredictions: [
46
- { name: 'Northern Cardinal', confidence: 0.92 },
47
- { name: 'House Finch', confidence: 0.05 },
48
- { name: 'Purple Finch', confidence: 0.03 },
49
- ],
50
  wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Cardinalis_cardinalis_-_20070909.jpg/480px-Cardinalis_cardinalis_-_20070909.jpg',
51
  color: '#c4714a',
52
  },
53
- 'baleag': {
54
- commonName: 'Bald Eagle',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  scientificName: 'Haliaeetus leucocephalus',
56
- confidence: 0.78,
57
- family: 'Accipitridae',
58
- habitat: 'Coasts, rivers, large lakes',
59
- diet: 'Fish, waterfowl, small mammals',
60
  funFacts: [
61
- 'Bald Eagles can spot a fish from nearly two miles away thanks to eyesight four times sharper than humans.',
62
- 'Their iconic white head and tail don\'t appear until they reach maturity at age 4–5.',
63
- 'A Bald Eagle\'s nest, called an eyrie, can weigh over a ton after years of additions.',
64
  ],
65
  range: {
66
- center: [55.0, -105.0],
67
  description: 'Across North America, especially near large bodies of open water.',
68
  },
69
- topPredictions: [
70
- { name: 'Bald Eagle', confidence: 0.78 },
71
- { name: 'Osprey', confidence: 0.14 },
72
- { name: 'Golden Eagle', confidence: 0.08 },
73
- ],
74
  wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Bald_Eagle_Portrait.jpg/480px-Bald_Eagle_Portrait.jpg',
75
  color: '#7a9e7e',
76
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
- export const SAMPLE_AUDIOS = [
80
- {
81
- id: 'westan',
82
- label: 'Western Tanager',
83
- description: 'Coniferous forest, Pacific Northwest',
84
- file: '/samples/western_tanager.ogg',
85
- emoji: '🟑',
86
- },
87
- {
88
- id: 'norcar',
89
- label: 'Northern Cardinal',
90
- description: 'Backyard garden, Eastern US',
91
- file: '/samples/northern_cardinal.ogg',
92
- emoji: 'πŸ”΄',
93
- },
94
- {
95
- id: 'baleag',
96
- label: 'Bald Eagle',
97
- description: 'Lakeside at dawn, Pacific Northwest',
98
- file: '/samples/bald_eagle.ogg',
99
- emoji: '🟀',
100
  },
101
- ]
 
 
102
 
103
  /**
104
- * Simulate a backend call with a fixed delay.
105
- * Replace this with a real fetch() to your FastAPI backend.
106
  *
107
- * @param {string} birdId - Key into MOCK_BIRDS
108
- * @returns {Promise<object>} - Mock prediction result
 
109
  */
110
- export async function mockPredict(birdId = 'norcar') {
111
- await new Promise(r => setTimeout(r, 2200))
112
- return MOCK_BIRDS[birdId] ?? MOCK_BIRDS['norcar']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  }
 
1
+ /**
2
+ * src/data/mockData.js
3
+ *
4
+ * - SAMPLE_AUDIOS: single test.ogg sample in public/samples/
5
+ * - BIRD_FACTS: static enrichment data keyed by BirdCLEF species code
6
+ * - enrichResult: merges API response with local facts for the result panel
7
+ */
8
 
9
+ // ── Sample audio files (place .ogg files in public/samples/) ─────────────────
10
+ export const SAMPLE_AUDIOS = [
11
+ {
12
+ id: 'test',
13
+ label: 'Sample Recording',
14
+ description: 'Test bird call β€” North America',
15
+ file: '/samples/test.ogg',
16
+ emoji: '🎡',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  },
18
+ ]
19
+
20
+ // ── Static enrichment data keyed by BirdCLEF species code ────────────────────
21
+ export const BIRD_FACTS = {
22
+ norcar: {
23
+ commonName: 'Northern Cardinal',
24
  scientificName: 'Cardinalis cardinalis',
25
+ family: 'Cardinalidae',
26
+ habitat: 'Woodlands, gardens, shrublands',
27
+ diet: 'Seeds, fruit, and insects',
 
28
  funFacts: [
29
+ "The male's brilliant red plumage comes from carotenoid pigments in its diet.",
30
  'Unlike most songbirds, female Northern Cardinals also sing β€” a rare trait among North American birds.',
31
  'They are non-migratory and often visit backyard feeders year-round.',
32
  ],
33
  range: {
34
+ center: [37.0, -85.0],
35
  description: 'Eastern and central North America, from southern Canada to Mexico.',
36
  },
 
 
 
 
 
37
  wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Cardinalis_cardinalis_-_20070909.jpg/480px-Cardinalis_cardinalis_-_20070909.jpg',
38
  color: '#c4714a',
39
  },
40
+ westan: {
41
+ commonName: 'Western Tanager',
42
+ scientificName: 'Piranga ludoviciana',
43
+ family: 'Cardinalidae',
44
+ habitat: 'Coniferous and mixed forests',
45
+ diet: 'Insects, berries, and fruit',
46
+ funFacts: [
47
+ "Males sport a brilliant red head with yellow and black body β€” one of North America's most colorful birds.",
48
+ 'Despite their tropical appearance, Western Tanagers breed across western North America.',
49
+ 'Their song is a burry, robin-like phrase often described as "pit-er-ick".',
50
+ ],
51
+ range: {
52
+ center: [45.5, -116.0],
53
+ description: 'Western North America, from Alaska south to Mexico during breeding season.',
54
+ },
55
+ wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/f7/Piranga_ludoviciana_-Western_Tanager.jpg/480px-Piranga_ludoviciana_-Western_Tanager.jpg',
56
+ color: '#c9a84c',
57
+ },
58
+ baleag: {
59
+ commonName: 'Bald Eagle',
60
  scientificName: 'Haliaeetus leucocephalus',
61
+ family: 'Accipitridae',
62
+ habitat: 'Coasts, rivers, large lakes',
63
+ diet: 'Fish, waterfowl, small mammals',
 
64
  funFacts: [
65
+ 'Bald Eagles can spot a fish from nearly two miles away.',
66
+ "Their iconic white head and tail don't appear until they reach maturity at age 4–5.",
67
+ "A Bald Eagle's nest can weigh over a ton after years of additions.",
68
  ],
69
  range: {
70
+ center: [55.0, -105.0],
71
  description: 'Across North America, especially near large bodies of open water.',
72
  },
 
 
 
 
 
73
  wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Bald_Eagle_Portrait.jpg/480px-Bald_Eagle_Portrait.jpg',
74
  color: '#7a9e7e',
75
  },
76
+ amecro: {
77
+ commonName: 'American Crow',
78
+ scientificName: 'Corvus brachyrhynchos',
79
+ family: 'Corvidae',
80
+ habitat: 'Open woodland, fields, urban areas',
81
+ diet: 'Omnivore β€” insects, seeds, carrion, small animals',
82
+ funFacts: [
83
+ 'American Crows are among the most intelligent birds β€” they use tools and recognize human faces.',
84
+ 'They form large communal roosts in winter, sometimes numbering in the millions.',
85
+ 'Crows remember and hold grudges against specific humans who have threatened them.',
86
+ ],
87
+ range: {
88
+ center: [42.0, -95.0],
89
+ description: 'Throughout North America except the far north and desert southwest.',
90
+ },
91
+ wikiImage: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/74/American-Crow.jpg/480px-American-Crow.jpg',
92
+ color: '#4a3a4a',
93
+ },
94
  }
95
 
96
+ // ── Default fallback for unknown species codes ────────────────────────────────
97
+ const DEFAULT_FACTS = {
98
+ commonName: null,
99
+ scientificName: '',
100
+ family: 'Unknown',
101
+ habitat: 'North America',
102
+ diet: 'Varies by species',
103
+ funFacts: [
104
+ 'This species was identified from its unique acoustic signature.',
105
+ 'Bird calls are as individual as fingerprints β€” no two species sound alike.',
106
+ ],
107
+ range: {
108
+ center: [45.0, -100.0],
109
+ description: 'North America',
 
 
 
 
 
 
 
110
  },
111
+ wikiImage: null,
112
+ color: '#9a749a',
113
+ }
114
 
115
  /**
116
+ * Merge the raw API prediction response with local enrichment facts.
 
117
  *
118
+ * @param {object} apiResponse - Response from POST /predict
119
+ * Shape: { predictions: [{species_code, confidence}], top_species, confidence, model }
120
+ * @returns {object} Full result object for BirdResult + RangeMap components
121
  */
122
+ export function enrichResult(apiResponse) {
123
+ const { predictions, top_species, confidence } = apiResponse
124
+ const facts = BIRD_FACTS[top_species] ?? DEFAULT_FACTS
125
+ const commonName = facts.commonName ?? top_species
126
+
127
+ const topPredictions = predictions.map(p => ({
128
+ name: BIRD_FACTS[p.species_code]?.commonName ?? p.species_code,
129
+ confidence: p.confidence,
130
+ }))
131
+
132
+ return {
133
+ commonName,
134
+ scientificName: facts.scientificName ?? '',
135
+ confidence,
136
+ family: facts.family,
137
+ habitat: facts.habitat,
138
+ diet: facts.diet,
139
+ funFacts: facts.funFacts,
140
+ range: facts.range,
141
+ wikiImage: facts.wikiImage,
142
+ color: facts.color,
143
+ topPredictions,
144
+ }
145
  }
requirements.txt CHANGED
@@ -22,3 +22,4 @@ huggingface_hub==0.21.3
22
  # Utilities
23
  tqdm==4.66.2
24
  kaggle==1.6.6
 
 
22
  # Utilities
23
  tqdm==4.66.2
24
  kaggle==1.6.6
25
+ httpx==0.27.0