diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..d17af30
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,16 @@
+* text=auto
+
+*.py text eol=lf
+*.js text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.md text eol=lf
+*.json text eol=lf
+*.toml text eol=lf
+*.yaml text eol=lf
+*.yml text eol=lf
+*.txt text eol=lf
+*.webmanifest text eol=lf
+*.svg text eol=lf
+
+*.onnx binary
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..3c1093b
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,40 @@
+name: Deploy Fieldmark to GitHub Pages
+
+on:
+ push:
+ branches:
+ - main
+ - agent/github-pages-browser-inference
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Configure GitHub Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload static site
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: web
+
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dccdc3d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.venv/
+venv/
+node_modules/
+.env
+.DS_Store
+
+# Editor and agent-local state
+.claude/
+
+# NABirds may exist in either supported local location. Never publish it.
+nabirds/
+data/raw/*
+!data/raw/.gitkeep
+data/external/my_photos/*
+!data/external/my_photos/.gitkeep
+data/processed/*
+!data/processed/.gitkeep
+
+# Large/generated Python model artifacts stay local.
+models/*
+!models/.gitkeep
+reports/*
+!reports/.gitkeep
+!reports/figures/
+reports/figures/*
+!reports/figures/.gitkeep
+
+# The optimized browser artifact is intentionally versioned under web/model.
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..f5c1645
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,20 @@
+[theme]
+base = "dark"
+primaryColor = "#67C592"
+backgroundColor = "#0F1513"
+secondaryBackgroundColor = "#17201B"
+textColor = "#EAF0E9"
+linkColor = "#8CD6AB"
+borderColor = "#3B4A40"
+showWidgetBorder = true
+baseRadius = "0.8rem"
+buttonRadius = "full"
+font = "sans-serif"
+headingFont = "serif"
+codeFont = "monospace"
+
+[browser]
+gatherUsageStats = false
+
+[server]
+maxUploadSize = 20
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b4faa3e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,144 @@
+# Fieldmark Bird Detector
+
+[](https://github.com/du000362/Bird-Detector/actions/workflows/pages.yml)
+
+Fieldmark is a complete fine-grained bird-recognition project built with PyTorch, EfficientNet-B0, NABirds, and an interactive privacy-first website.
+
+**Live website:** [du000362.github.io/Bird-Detector](https://du000362.github.io/Bird-Detector/)
+
+The GitHub Pages version runs the trained ONNX model directly in the visitor's browser. A selected photograph is decoded and classified on-device; it is not uploaded to GitHub or an inference API.
+
+## Model results
+
+Evaluation used the untouched official NABirds test split: 24,633 images across 555 fine-grained classes.
+
+| Metric | Result | Meaning |
+|---|---:|---|
+| Top-1 accuracy | 52.02% | The first guess was the exact class |
+| Top-3 accuracy | 71.05% | The exact class appeared among three guesses |
+| Top-5 accuracy | 78.03% | The exact class appeared among five guesses |
+| Macro-F1 | 49.27% | Every class received equal weight |
+| Calibration error | 1.67% | Confidence closely tracked observed accuracy |
+
+Top-5 accuracy is not a 78% guarantee for a particular photo. It is the share of the full test set where the correct class appeared somewhere in the five-name shortlist.
+
+## Run it
+
+### Interactive GitHub Pages site
+
+Open the [live website](https://du000362.github.io/Bird-Detector/), select **Identify**, wait for the 18 MB browser model to become ready, then drop in a JPEG, PNG, or WebP image.
+
+### Local Fieldmark website
+
+From PowerShell:
+
+```powershell
+Set-Location "F:\Python Code\Bird Detector"
+.\.venv\Scripts\Activate.ps1
+python webapp.py
+```
+
+Open the address printed by the command. The local Starlette interface uses the PyTorch checkpoint and can use CUDA.
+
+### Streamlit interface
+
+```powershell
+Set-Location "F:\Python Code\Bird Detector"
+.\.venv\Scripts\Activate.ps1
+streamlit run app.py
+```
+
+### Command-line prediction
+
+```powershell
+python -m src.predict --image "F:\path\to\bird.jpg"
+```
+
+## Fresh setup
+
+Python 3.11 or newer is recommended.
+
+```powershell
+Set-Location "F:\Python Code\Bird Detector"
+python -m venv .venv
+.\.venv\Scripts\Activate.ps1
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+```
+
+The large training dataset and PyTorch checkpoints are intentionally ignored by Git. The optimized 18 MB browser model is versioned at `web/model/fieldmark.onnx` so the public site can make real predictions.
+
+## Project layout
+
+```text
+Bird Detector/
+├── app.py Streamlit interface
+├── webapp.py Local Starlette interface and prediction API
+├── config/config.yaml Paths and training configuration
+├── src/ Dataset, model, training, evaluation, inference
+├── scripts/export_browser_model.py PyTorch-to-ONNX export and parity validation
+├── web/ Static GitHub Pages application
+│ ├── model/ ONNX model and class metadata
+│ └── static/ CSS, JavaScript, and icon assets
+└── .github/workflows/pages.yml GitHub Pages deployment
+```
+
+## Browser inference architecture
+
+1. GitHub Pages serves static HTML, CSS, JavaScript, model metadata, and `fieldmark.onnx`.
+2. ONNX Runtime Web loads the model once into browser memory.
+3. Canvas applies the same resize, center-crop, RGB conversion, and ImageNet normalization used during Python evaluation.
+4. EfficientNet-B0 generates logits for all 555 classes.
+5. Temperature scaling calibrates the probabilities.
+6. Fieldmark returns the ranked alternatives, entropy, top-two margin, and uncertainty verdict.
+
+No backend is required for the Pages site. The original local Python interfaces remain available for CUDA inference, training, and evaluation.
+
+## Re-export the browser model
+
+Install the export dependencies, then run the reproducible exporter:
+
+```powershell
+python -m pip install -r requirements-export.txt
+python scripts/export_browser_model.py
+```
+
+The exporter checks the ONNX graph and compares deterministic ONNX Runtime logits against the PyTorch checkpoint before writing metadata.
+
+## Train and continue training
+
+Start training:
+
+```powershell
+python -m src.train --config config\config.yaml
+```
+
+Continue from a checkpoint:
+
+```powershell
+python -m src.train --config config\config.yaml --resume models\checkpoints\last.pt
+```
+
+Evaluate the best model:
+
+```powershell
+python -m src.evaluate --config config\config.yaml
+```
+
+## Validate before publishing
+
+```powershell
+python scripts\check_static_site.py
+python scripts\check_theme_contrast.py
+node --check web\static\js\browser-inference.js
+node --check web\static\js\app.js
+pytest -q
+```
+
+Pushes to `main` deploy `web/` through the GitHub Pages workflow.
+
+## Dataset and responsible use
+
+NABirds is a fine-grained North American bird image dataset assembled with the Cornell Lab of Ornithology and collaborators. Download it from the [official NABirds page](https://dl.allaboutbirds.org/nabirds), review the [NABirds / Merlin terms](https://dl.allaboutbirds.org/merlin-computer-vision-terms-of-use), and preserve the supplied attribution and citation materials.
+
+This repository does not redistribute NABirds photographs or metadata. The classifier is closed-set: it can only name classes it learned. Low confidence can mean an unsupported class, poor lighting, an unusual pose, background shift, or an ordinary model error; it is not a scientifically validated unknown-species detector.
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..c76e641
--- /dev/null
+++ b/app.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+
+import pandas as pd
+import streamlit as st
+from PIL import Image, UnidentifiedImageError
+
+from src.config import load_config
+from src.evaluate import save_external_evaluation
+from src.inference import BirdPredictor
+
+
+st.set_page_config(page_title="Fieldmark bird identification", page_icon="🪶", layout="wide")
+config = load_config()
+
+st.title("Fieldmark bird identification")
+st.caption(
+ "Private local inference for 555 NABirds classes. "
+ "Selected photographs are not sent to an external API."
+)
+
+
+@st.cache_resource(show_spinner=False)
+def load_predictor(model_path: str) -> BirdPredictor:
+ return BirdPredictor(
+ model_path,
+ config["app"]["metadata_path"],
+ config["project"]["output_dir"] / "calibration.json",
+ )
+
+
+def uploaded_to_image(uploaded) -> Image.Image:
+ limit = int(config["app"]["max_upload_size_mb"]) * 1024 * 1024
+ if uploaded.size > limit:
+ raise ValueError(
+ f"{uploaded.name} is larger than the "
+ f"{config['app']['max_upload_size_mb']} MB limit."
+ )
+ try:
+ return Image.open(io.BytesIO(uploaded.getvalue())).convert("RGB")
+ except (OSError, UnidentifiedImageError) as exc:
+ raise ValueError(f"{uploaded.name} is not a readable supported image.") from exc
+
+
+def prediction_frame(result: dict) -> pd.DataFrame:
+ return pd.DataFrame(
+ [
+ {"Species": item["class_name"], "Probability": item["probability"]}
+ for item in result["top_k"]
+ ]
+ )
+
+
+with st.sidebar:
+ st.header("Model controls")
+ model_path_text = st.text_input("Model path", str(config["app"]["model_path"]))
+ unknown_threshold = st.slider(
+ "Confidence threshold",
+ min_value=0.05,
+ max_value=0.95,
+ value=float(config["evaluation"]["unknown_threshold"]),
+ step=0.01,
+ help="Results below this probability are flagged as possibly unknown.",
+ )
+ top_k = st.slider(
+ "Ranked alternatives",
+ min_value=1,
+ max_value=10,
+ value=int(config["evaluation"]["top_k"]),
+ )
+ show_technical = st.checkbox("Show technical details")
+
+
+model_path = Path(model_path_text).expanduser()
+if not model_path.exists():
+ st.info("Train the model first with `python -m src.train`.")
+ st.stop()
+
+try:
+ predictor = load_predictor(str(model_path.resolve()))
+except Exception as exc:
+ st.error(f"The local model could not be loaded: {exc}")
+ st.stop()
+
+with st.sidebar:
+ with st.container(border=True):
+ st.caption("Active runtime")
+ st.write(f"**Device:** `{predictor.device}`")
+ st.write(f"**Architecture:** `{predictor.architecture}`")
+ st.write(f"**Known classes:** `{predictor.num_classes}`")
+
+
+single_tab, batch_tab, external_tab = st.tabs(
+ ["Single photograph", "Batch prediction", "External evaluation"]
+)
+
+with single_tab:
+ uploaded = st.file_uploader(
+ "Choose a bird photograph",
+ type=["jpg", "jpeg", "png", "webp"],
+ accept_multiple_files=False,
+ )
+ if uploaded:
+ try:
+ image = uploaded_to_image(uploaded)
+ with st.spinner("Reading field marks…"):
+ result = predictor.predict(
+ image,
+ top_k=top_k,
+ unknown_threshold=unknown_threshold,
+ )
+ except Exception as exc:
+ st.error(str(exc))
+ else:
+ image_column, result_column = st.columns([1, 1], gap="large")
+ with image_column:
+ with st.container(border=True):
+ st.image(image, caption=uploaded.name, width="stretch")
+ with result_column:
+ with st.container(border=True):
+ st.subheader(result["predicted_class"])
+ st.metric("Calibrated probability", f"{result['probability']:.1%}")
+ if result["uncertainty_status"] == "confident_prediction":
+ st.success("Confident prediction")
+ elif result["uncertainty_status"] == "uncertain_prediction":
+ st.warning("Uncertain — review the alternatives")
+ else:
+ st.error("Possibly unknown or out of distribution")
+ st.caption(result["warning"])
+
+ frame = prediction_frame(result)
+ st.subheader("Ranked alternatives")
+ st.dataframe(
+ frame.style.format({"Probability": "{:.1%}"}),
+ hide_index=True,
+ width="stretch",
+ )
+ st.bar_chart(frame.set_index("Species"))
+ if show_technical:
+ with st.expander("Technical details", expanded=True):
+ st.json(
+ {
+ "entropy": result["entropy"],
+ "normalized_entropy": result["normalized_entropy"],
+ "top1_top2_margin": result["margin"],
+ "preprocessing_image_size": predictor.image_size,
+ "model_architecture": predictor.architecture,
+ "device": str(predictor.device),
+ "inference_time_ms": result["inference_time_ms"],
+ "temperature": result["temperature"],
+ }
+ )
+
+with batch_tab:
+ uploads = st.file_uploader(
+ "Choose several photographs",
+ type=["jpg", "jpeg", "png", "webp"],
+ accept_multiple_files=True,
+ key="batch",
+ )
+ if uploads:
+ rows = []
+ progress = st.progress(0, text="Preparing batch…")
+ for index, upload in enumerate(uploads):
+ try:
+ image = uploaded_to_image(upload)
+ result = predictor.predict(
+ image,
+ top_k=top_k,
+ unknown_threshold=unknown_threshold,
+ )
+ rows.append(
+ {
+ "filename": upload.name,
+ "predicted_species": result["predicted_class"],
+ "probability": result["probability"],
+ "uncertainty_status": result["uncertainty_status"],
+ "top_k": json.dumps(result["top_k"], ensure_ascii=False),
+ "error": "",
+ }
+ )
+ except Exception as exc:
+ rows.append(
+ {
+ "filename": upload.name,
+ "predicted_species": "",
+ "probability": None,
+ "uncertainty_status": "",
+ "top_k": "",
+ "error": str(exc),
+ }
+ )
+ progress.progress(
+ (index + 1) / len(uploads),
+ text=f"Identified {index + 1} of {len(uploads)}",
+ )
+ progress.empty()
+ batch_frame = pd.DataFrame(rows)
+ st.dataframe(batch_frame, hide_index=True, width="stretch")
+ st.download_button(
+ "Download predictions as CSV",
+ batch_frame.to_csv(index=False).encode("utf-8"),
+ "fieldmark_batch_predictions.csv",
+ "text/csv",
+ type="primary",
+ )
+
+with external_tab:
+ external_dir = Path(config["evaluation"]["external_test_dir"])
+ st.write(
+ "Evaluate class-named folders of personal photographs separately from "
+ "NABirds training. Folder names must exactly match known classes."
+ )
+ st.code(str(external_dir), language=None)
+ if st.button("Run external evaluation", type="primary"):
+ if not external_dir.exists() or not any(external_dir.rglob("*.*")):
+ st.info("Add personal photographs to labeled subfolders first.")
+ else:
+ with st.spinner("Evaluating local photographs…"):
+ external_frame, metrics = save_external_evaluation(
+ predictor,
+ external_dir,
+ config["project"]["reports_dir"],
+ top_k=top_k,
+ )
+ if "accuracy" in metrics:
+ col1, col2, col3 = st.columns(3)
+ col1.metric("Accuracy", f"{metrics['accuracy']:.1%}", border=True)
+ col2.metric("Macro-F1", f"{metrics['macro_f1']:.3f}", border=True)
+ col3.metric("Uncertain rate", f"{metrics['uncertain_rate']:.1%}", border=True)
+ st.json(metrics)
+ st.dataframe(external_frame, hide_index=True, width="stretch")
+ confusion_path = (
+ config["project"]["reports_dir"]
+ / "figures"
+ / "external_confusion_matrix.png"
+ )
+ if confusion_path.exists():
+ st.image(str(confusion_path), caption="External-photo confusion matrix")
diff --git a/config/config.yaml b/config/config.yaml
new file mode 100644
index 0000000..4c86cbd
--- /dev/null
+++ b/config/config.yaml
@@ -0,0 +1,54 @@
+project:
+ name: bird-detector
+ random_seed: 42
+ output_dir: models
+ reports_dir: reports
+ deterministic: false
+
+data:
+ root_dir: data/raw/nabirds
+ manifest_path: data/processed/subset_manifest.csv
+ image_size: 224
+ crop_to_bounding_box: false
+ skip_invalid: false
+ min_images_per_class: 20
+ max_classes: null
+ max_images_per_class: null
+ use_official_split: true
+ validation_fraction_from_train: 0.15
+ num_workers: 4
+ pin_memory: true
+ invalid_image_threshold: 0
+
+model:
+ architecture: efficientnet_b0
+ pretrained: true
+ dropout: 0.2
+ freeze_backbone_initially: true
+ unfreeze_last_n_blocks: 2
+ label_smoothing: 0.1
+
+training:
+ batch_size: 64
+ head_epochs: 5
+ finetune_epochs: 15
+ learning_rate_head: 0.001
+ learning_rate_finetune: 0.00005
+ weight_decay: 0.0001
+ use_amp: true
+ gradient_clip_norm: 1.0
+ early_stopping_patience: 5
+ scheduler: cosine
+ checkpoint_every_epoch: true
+ class_balancing: weighted_sampler
+
+evaluation:
+ top_k: 5
+ unknown_threshold: 0.45
+ calibration_method: temperature_scaling
+ external_test_dir: data/external/my_photos
+
+app:
+ model_path: models/best_model.pt
+ metadata_path: models/class_metadata.json
+ max_upload_size_mb: 20
diff --git a/data/external/my_photos/.gitkeep b/data/external/my_photos/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/data/external/my_photos/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/data/processed/.gitkeep b/data/processed/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/data/processed/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/data/raw/.gitkeep b/data/raw/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/data/raw/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/models/.gitkeep b/models/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/models/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/reports/.gitkeep b/reports/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/reports/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/reports/figures/.gitkeep b/reports/figures/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/reports/figures/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/requirements-export.txt b/requirements-export.txt
new file mode 100644
index 0000000..8cd07a1
--- /dev/null
+++ b/requirements-export.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+onnx>=1.22,<2
+onnxruntime>=1.27,<2
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..22c9036
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,16 @@
+torch>=2.2
+torchvision>=0.17
+pandas>=2.1
+numpy>=1.26
+Pillow>=10.0
+scikit-learn>=1.3
+matplotlib>=3.8
+seaborn>=0.13
+PyYAML>=6.0
+tqdm>=4.66
+joblib>=1.3
+streamlit>=1.59.2,<2
+starlette>=0.37
+uvicorn>=0.29
+python-multipart>=0.0.9
+pytest>=8.0
diff --git a/scripts/check_static_site.py b/scripts/check_static_site.py
new file mode 100644
index 0000000..c33226e
--- /dev/null
+++ b/scripts/check_static_site.py
@@ -0,0 +1,85 @@
+"""Fail fast when the GitHub Pages bundle is incomplete or non-portable."""
+
+from __future__ import annotations
+
+import json
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlsplit
+
+
+ROOT = Path(__file__).resolve().parents[1]
+WEB = ROOT / "web"
+
+
+class AssetParser(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.references: list[str] = []
+ self.selectors: set[str] = set()
+
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ values = dict(attrs)
+ for key in ("src", "href"):
+ if values.get(key):
+ self.references.append(str(values[key]))
+ self.selectors.update(key for key in values if key.startswith("data-"))
+
+
+def main() -> int:
+ failures: list[str] = []
+ index = WEB / "index.html"
+ parser = AssetParser()
+ parser.feed(index.read_text(encoding="utf-8"))
+
+ required_selectors = {
+ "data-panel",
+ "data-tab",
+ "data-dropzone",
+ "data-file-input",
+ "data-identify-button",
+ "data-result-template",
+ }
+ for selector in sorted(required_selectors - parser.selectors):
+ failures.append(f"Missing required UI hook: {selector}")
+
+ for reference in parser.references:
+ if reference.startswith("/"):
+ failures.append(f"Root-relative reference breaks project Pages: {reference}")
+ continue
+ parsed = urlsplit(reference)
+ if parsed.scheme or reference.startswith("#"):
+ continue
+ local_path = (WEB / parsed.path).resolve()
+ if not local_path.is_relative_to(WEB.resolve()):
+ failures.append(f"Asset escapes web root: {reference}")
+ elif not local_path.exists():
+ failures.append(f"Missing local asset: {reference}")
+
+ metadata_path = WEB / "model" / "metadata.json"
+ model_path = WEB / "model" / "fieldmark.onnx"
+ if not metadata_path.exists() or not model_path.exists():
+ failures.append("Browser model bundle is missing.")
+ else:
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+ class_names = metadata.get("class_names", [])
+ if metadata.get("class_count") != 555 or len(class_names) != 555:
+ failures.append("Browser metadata must contain all 555 class names.")
+ recorded_size = metadata.get("artifact", {}).get("size_bytes")
+ if recorded_size != model_path.stat().st_size:
+ failures.append("Model size does not match exported metadata.")
+ if model_path.stat().st_size >= 100 * 1024 * 1024:
+ failures.append("Model exceeds GitHub's 100 MiB per-file limit.")
+
+ if failures:
+ print("Static-site validation failed:")
+ for failure in failures:
+ print(f" - {failure}")
+ return 1
+ print("Static-site bundle is portable and complete (555 classes, ONNX included).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_theme_contrast.py b/scripts/check_theme_contrast.py
new file mode 100644
index 0000000..aec411e
--- /dev/null
+++ b/scripts/check_theme_contrast.py
@@ -0,0 +1,123 @@
+"""Verify WCAG AA contrast for every theme defined in web/static/css/theme.css.
+
+Parses each ``.theme-*`` block, resolves its hex color tokens, and checks a
+manifest of foreground/background pairings against WCAG 2.1 AA thresholds
+(4.5:1 for normal text, 3:1 for UI components). Run after any palette change
+or when adding a theme:
+
+ python scripts/check_theme_contrast.py
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+THEME_CSS = Path(__file__).resolve().parents[1] / "web" / "static" / "css" / "theme.css"
+
+# (foreground token, background token, minimum ratio)
+PAIRINGS: list[tuple[str, str, float]] = [
+ ("--text-primary", "--surface-page", 4.5),
+ ("--text-primary", "--surface-raised", 4.5),
+ ("--text-primary", "--surface-sunken", 4.5),
+ ("--text-secondary", "--surface-page", 4.5),
+ ("--text-secondary", "--surface-raised", 4.5),
+ ("--text-secondary", "--surface-sunken", 4.5),
+ ("--text-muted", "--surface-page", 4.5),
+ ("--text-muted", "--surface-raised", 4.5),
+ ("--text-accent", "--surface-page", 4.5),
+ ("--text-accent", "--surface-raised", 4.5),
+ ("--text-accent", "--accent-subtle", 4.5),
+ ("--text-accent-secondary", "--surface-page", 4.5),
+ ("--text-accent-secondary", "--surface-raised", 4.5),
+ ("--text-on-accent", "--accent", 4.5),
+ ("--text-on-accent", "--accent-hover", 4.5),
+ ("--success-text", "--success-surface", 4.5),
+ ("--success-text", "--surface-raised", 4.5),
+ ("--warning-text", "--warning-surface", 4.5),
+ ("--warning-text", "--surface-raised", 4.5),
+ ("--danger-text", "--danger-surface", 4.5),
+ ("--danger-text", "--surface-raised", 4.5),
+ ("--border-strong", "--surface-page", 3.0),
+ ("--border-strong", "--surface-raised", 3.0),
+ ("--focus-ring", "--surface-page", 3.0),
+ ("--accent", "--surface-page", 3.0),
+ ("--accent", "--surface-raised", 3.0),
+]
+
+THEME_BLOCK = re.compile(r"\.(theme-[\w-]+)\s*\{(.*?)\}", re.DOTALL)
+DECLARATION = re.compile(r"(--[\w-]+)\s*:\s*([^;]+);")
+HEX_COLOR = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
+
+
+def parse_themes(css: str) -> dict[str, dict[str, str]]:
+ themes: dict[str, dict[str, str]] = {}
+ for match in THEME_BLOCK.finditer(css):
+ name, body = match.group(1), match.group(2)
+ tokens = {prop: value.strip() for prop, value in DECLARATION.findall(body)}
+ themes.setdefault(name, {}).update(tokens)
+ return themes
+
+
+def hex_to_rgb(value: str) -> tuple[float, float, float]:
+ value = value.lstrip("#")
+ if len(value) == 3:
+ value = "".join(ch * 2 for ch in value)
+ return tuple(int(value[i : i + 2], 16) / 255 for i in (0, 2, 4))
+
+
+def relative_luminance(rgb: tuple[float, float, float]) -> float:
+ def channel(c: float) -> float:
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
+
+ r, g, b = (channel(c) for c in rgb)
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+
+def contrast_ratio(fg: str, bg: str) -> float:
+ lighter, darker = sorted(
+ (relative_luminance(hex_to_rgb(fg)), relative_luminance(hex_to_rgb(bg))),
+ reverse=True,
+ )
+ return (lighter + 0.05) / (darker + 0.05)
+
+
+def main() -> int:
+ themes = parse_themes(THEME_CSS.read_text(encoding="utf-8"))
+ if not themes:
+ print(f"No .theme-* blocks found in {THEME_CSS}")
+ return 1
+
+ failures = 0
+ for theme_name, tokens in sorted(themes.items()):
+ print(f"\n{theme_name}")
+ for fg_token, bg_token, minimum in PAIRINGS:
+ fg, bg = tokens.get(fg_token), tokens.get(bg_token)
+ if fg is None or bg is None:
+ missing = fg_token if fg is None else bg_token
+ print(f" MISSING {missing} is not defined")
+ failures += 1
+ continue
+ if not HEX_COLOR.match(fg) or not HEX_COLOR.match(bg):
+ print(f" SKIP {fg_token} on {bg_token}: non-hex value")
+ continue
+ ratio = contrast_ratio(fg, bg)
+ status = "ok " if ratio >= minimum else "FAIL"
+ if ratio < minimum:
+ failures += 1
+ print(
+ f" {status} {ratio:5.2f}:1 (min {minimum}) "
+ f"{fg_token} on {bg_token}"
+ )
+
+ print()
+ if failures:
+ print(f"{failures} pairing(s) below WCAG AA.")
+ return 1
+ print("All pairings meet WCAG AA in every theme.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/create_subset.py b/scripts/create_subset.py
new file mode 100644
index 0000000..646256d
--- /dev/null
+++ b/scripts/create_subset.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from src.config import load_config
+from src.metadata import build_and_save_manifest
+from src.utils import save_json
+
+
+def _select_per_class(
+ frame: pd.DataFrame,
+ maximum: int,
+ rng: np.random.Generator,
+) -> pd.DataFrame:
+ if len(frame) <= maximum:
+ return frame
+ selected = rng.choice(frame.index.to_numpy(), size=maximum, replace=False)
+ return frame.loc[selected]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Create a deterministic manifest-only subset.")
+ parser.add_argument("--classes", type=int, default=100)
+ parser.add_argument("--images-per-class", type=int, default=300)
+ parser.add_argument("--config", default="config/config.yaml")
+ args = parser.parse_args()
+ if args.classes <= 0 or args.images_per_class <= 1:
+ parser.error("--classes must be positive and --images-per-class must be at least 2")
+
+ config = load_config(args.config)
+ if config["data"]["manifest_path"].exists():
+ manifest = pd.read_csv(config["data"]["manifest_path"])
+ manifest["is_training"] = manifest["is_training"].astype(str).str.lower().isin(
+ {"true", "1", "yes"}
+ )
+ else:
+ manifest = build_and_save_manifest(
+ config["data"]["root_dir"], config["data"]["manifest_path"]
+ ).manifest
+
+ eligible = []
+ for class_id, group in manifest.groupby("original_class_id"):
+ if group["is_training"].any() and (~group["is_training"]).any():
+ eligible.append(class_id)
+ if len(eligible) < args.classes:
+ print(
+ f"Requested {args.classes} classes, but only {len(eligible)} have both "
+ "official train and test images; using all eligible classes."
+ )
+ rng = np.random.default_rng(int(config["project"]["random_seed"]))
+ selected_classes = sorted(
+ rng.choice(
+ np.asarray(eligible),
+ size=min(args.classes, len(eligible)),
+ replace=False,
+ ).tolist()
+ )
+ pieces = []
+ for class_id in selected_classes:
+ group = manifest[manifest["original_class_id"] == class_id]
+ # Sample each side separately so an image cap never removes a whole official split.
+ train_group = group[group["is_training"]]
+ test_group = group[~group["is_training"]]
+ total = len(group)
+ train_budget = max(
+ 1,
+ min(
+ len(train_group),
+ round(args.images_per_class * len(train_group) / total),
+ ),
+ )
+ test_budget = max(1, min(len(test_group), args.images_per_class - train_budget))
+ if train_budget + test_budget < min(args.images_per_class, total):
+ train_budget = min(
+ len(train_group),
+ train_budget + min(args.images_per_class, total) - train_budget - test_budget,
+ )
+ pieces.append(_select_per_class(train_group, train_budget, rng))
+ pieces.append(_select_per_class(test_group, test_budget, rng))
+ subset = pd.concat(pieces, ignore_index=True)
+ mapping = {
+ class_id: index for index, class_id in enumerate(sorted(subset["original_class_id"].unique()))
+ }
+ subset["internal_label"] = subset["original_class_id"].map(mapping).astype(int)
+ output = config["data"]["manifest_path"].parent / "subset_manifest.csv"
+ subset.to_csv(output, index=False)
+ summary = {
+ "synthetic_data": False,
+ "source_manifest": str(config["data"]["manifest_path"]),
+ "subset_manifest": str(output),
+ "seed": int(config["project"]["random_seed"]),
+ "requested_classes": args.classes,
+ "selected_class_count": int(subset["internal_label"].nunique()),
+ "maximum_images_per_class": args.images_per_class,
+ "image_count": len(subset),
+ "official_train_count": int(subset["is_training"].sum()),
+ "official_test_count": int((~subset["is_training"]).sum()),
+ "images_copied": False,
+ }
+ save_json(config["project"]["reports_dir"] / "subset_summary.json", summary)
+ print(f"Saved {len(subset):,} manifest rows for {summary['selected_class_count']} classes.")
+ print(f"No images were copied. Subset manifest: {output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/export_browser_model.py b/scripts/export_browser_model.py
new file mode 100644
index 0000000..e11a931
--- /dev/null
+++ b/scripts/export_browser_model.py
@@ -0,0 +1,178 @@
+"""Export the trained Fieldmark classifier to a browser-ready ONNX bundle.
+
+The resulting ``web/model`` directory is self-contained: GitHub Pages serves
+the network and its metadata while ONNX Runtime Web performs inference locally
+inside the visitor's browser.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+from pathlib import Path
+
+import numpy as np
+import onnx
+import onnxruntime as ort
+import torch
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from src.config import load_config
+from src.model import create_model
+
+
+def _rooted(path: str | Path) -> Path:
+ candidate = Path(path).expanduser()
+ return candidate if candidate.is_absolute() else ROOT / candidate
+
+
+def _read_json(path: Path, default: dict | None = None) -> dict:
+ if not path.exists():
+ return {} if default is None else default
+ with path.open("r", encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--config", default="config/config.yaml")
+ parser.add_argument("--checkpoint", default="models/best_model.pt")
+ parser.add_argument("--output", default="web/model/fieldmark.onnx")
+ parser.add_argument("--metadata-output", default="web/model/metadata.json")
+ args = parser.parse_args()
+
+ config = load_config(_rooted(args.config), create_dirs=False)
+ checkpoint_path = _rooted(args.checkpoint)
+ output_path = _rooted(args.output)
+ metadata_path = _rooted(args.metadata_output)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
+ class_names = checkpoint["class_names"]
+ architecture = checkpoint["architecture"]
+ image_size = int(
+ checkpoint.get("preprocessing", {}).get(
+ "image_size", config["data"]["image_size"]
+ )
+ )
+ dropout = float(
+ checkpoint.get("configuration", {}).get("model", {}).get(
+ "dropout", config["model"].get("dropout", 0.2)
+ )
+ )
+
+ model, preprocessing = create_model(
+ architecture,
+ len(class_names),
+ pretrained=False,
+ dropout=dropout,
+ image_size=image_size,
+ )
+ model.load_state_dict(checkpoint["model_state_dict"])
+ model.eval()
+
+ generator = torch.Generator().manual_seed(20260720)
+ sample = torch.randn(1, 3, image_size, image_size, generator=generator)
+ with torch.inference_mode():
+ torch_logits = model(sample).numpy()
+
+ torch.onnx.export(
+ model,
+ sample,
+ str(output_path),
+ input_names=["input"],
+ output_names=["logits"],
+ opset_version=17,
+ do_constant_folding=True,
+ dynamo=False,
+ )
+
+ exported = onnx.load(output_path)
+ onnx.checker.check_model(exported)
+ session = ort.InferenceSession(str(output_path), providers=["CPUExecutionProvider"])
+ ort_logits = session.run(["logits"], {"input": sample.numpy()})[0]
+ max_abs_error = float(np.max(np.abs(torch_logits - ort_logits)))
+ # Convolution kernels differ slightly between PyTorch and ONNX Runtime.
+ # This catches a broken export while allowing expected CPU-kernel drift,
+ # which remains well below a tenth of one probability point.
+ if not np.allclose(torch_logits, ort_logits, rtol=2e-3, atol=1.5e-2):
+ raise RuntimeError(
+ "ONNX parity check failed; maximum absolute logit error was "
+ f"{max_abs_error:.6g}."
+ )
+
+ calibration = _read_json(ROOT / "models" / "calibration.json")
+ class_metadata = _read_json(ROOT / "models" / "class_metadata.json")
+ metrics = _read_json(ROOT / "reports" / "test_metrics.json")
+ digest = hashlib.sha256(output_path.read_bytes()).hexdigest()
+ metadata = {
+ "format_version": 1,
+ "name": "Fieldmark NABirds classifier",
+ "architecture": architecture,
+ "class_count": len(class_names),
+ "class_names": class_names,
+ "internal_label_to_original_class_id": class_metadata.get(
+ "internal_label_to_original_class_id", {}
+ ),
+ "input": {
+ "name": "input",
+ "shape": [1, 3, image_size, image_size],
+ "color_mode": preprocessing["color_mode"],
+ "resize_shorter_side": preprocessing["resize_shorter_side"],
+ "crop_size": image_size,
+ "mean": list(preprocessing["mean"]),
+ "std": list(preprocessing["std"]),
+ },
+ "output": {"name": "logits"},
+ "calibration": {
+ "method": calibration.get("method", "temperature_scaling"),
+ "temperature": float(calibration.get("temperature", 1.0)),
+ },
+ "defaults": {
+ "top_k": int(config["evaluation"]["top_k"]),
+ "unknown_threshold": float(config["evaluation"]["unknown_threshold"]),
+ },
+ "metrics": {
+ key: metrics.get(key)
+ for key in (
+ "top1_accuracy",
+ "top3_accuracy",
+ "top5_accuracy",
+ "macro_f1",
+ "expected_calibration_error",
+ "image_count",
+ )
+ if metrics.get(key) is not None
+ },
+ "training": {
+ "dataset": "NABirds",
+ "training_image_count": 48562,
+ "best_validation_macro_f1": checkpoint.get("best_validation_metric"),
+ "best_epoch": checkpoint.get("epoch"),
+ "stage": checkpoint.get("stage"),
+ },
+ "artifact": {
+ "filename": output_path.name,
+ "sha256": digest,
+ "size_bytes": output_path.stat().st_size,
+ "onnx_opset": 17,
+ "pytorch_onnx_max_abs_logit_error": max_abs_error,
+ },
+ }
+ metadata_path.parent.mkdir(parents=True, exist_ok=True)
+ with metadata_path.open("w", encoding="utf-8") as handle:
+ json.dump(metadata, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+
+ print(f"Exported {output_path} ({output_path.stat().st_size / 1024 / 1024:.1f} MiB)")
+ print(f"Wrote {metadata_path}")
+ print(f"PyTorch/ONNX maximum absolute logit error: {max_abs_error:.6g}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/inspect_dataset.py b/scripts/inspect_dataset.py
new file mode 100644
index 0000000..53a7b86
--- /dev/null
+++ b/scripts/inspect_dataset.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pandas as pd
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from src.config import load_config
+from src.metadata import build_and_save_manifest
+from src.utils import configure_logging, save_json
+from src.visualize import save_class_distribution
+
+
+def main() -> None:
+ configure_logging()
+ config = load_config()
+ try:
+ result = build_and_save_manifest(
+ config["data"]["root_dir"],
+ config["data"]["manifest_path"],
+ inspect_dimensions=True,
+ )
+ except FileNotFoundError as exc:
+ print(f"Dataset inspection could not start: {exc}", file=sys.stderr)
+ raise SystemExit(2) from exc
+
+ print("Discovered metadata:")
+ for name, path in sorted(result.discovered_files.items()):
+ print(f" {name}: {path}")
+ manifest = result.manifest
+ counts = manifest.groupby("class_name").size().sort_values()
+ summary = {
+ **result.diagnostics,
+ "images_per_class": {
+ "minimum": int(counts.min()),
+ "maximum": int(counts.max()),
+ "mean": float(counts.mean()),
+ "median": float(counts.median()),
+ },
+ "five_smallest_classes": {
+ str(key): int(value) for key, value in counts.head(5).items()
+ },
+ "five_largest_classes": {
+ str(key): int(value) for key, value in counts.tail(5).sort_values(ascending=False).items()
+ },
+ }
+ reports = config["project"]["reports_dir"]
+ save_json(reports / "dataset_summary.json", summary)
+ save_class_distribution(counts, reports / "figures" / "class_distribution.png")
+
+ print(f"Total images: {len(manifest):,}")
+ print(f"Official train images: {int(manifest['is_training'].sum()):,}")
+ print(f"Official test images: {int((~manifest['is_training']).sum()):,}")
+ print(f"Classes: {manifest['internal_label'].nunique():,}")
+ print(
+ "Images per class: "
+ f"min={counts.min()}, max={counts.max()}, mean={counts.mean():.2f}, "
+ f"median={counts.median():.2f}"
+ )
+ print("Five smallest classes:")
+ print(counts.head(5).to_string())
+ print("Five largest classes:")
+ print(counts.tail(5).sort_values(ascending=False).to_string())
+ print(f"Missing images: {result.diagnostics['missing_image_count']}")
+ print(f"Unreadable images: {result.diagnostics['unreadable_image_count']}")
+ print(f"Saved manifest: {config['data']['manifest_path']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/split_own_photos.py b/scripts/split_own_photos.py
new file mode 100644
index 0000000..c3de764
--- /dev/null
+++ b/scripts/split_own_photos.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import argparse
+import shutil
+from pathlib import Path
+
+import numpy as np
+
+
+EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Optionally copy personal photos into separate train/validation/test folders."
+ )
+ parser.add_argument("--source", type=Path, default=Path("data/external/my_photos"))
+ parser.add_argument("--output", type=Path, default=Path("data/external/my_photos_split"))
+ parser.add_argument("--validation-fraction", type=float, default=0.15)
+ parser.add_argument("--test-fraction", type=float, default=0.15)
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument(
+ "--copy",
+ action="store_true",
+ help="Required safety switch: create copied split folders.",
+ )
+ args = parser.parse_args()
+ if not args.copy:
+ parser.error("No files changed. Pass --copy only when you explicitly want split copies.")
+ if args.output.resolve() == args.source.resolve():
+ parser.error("--output must differ from --source")
+ if not 0 <= args.validation_fraction < 1 or not 0 <= args.test_fraction < 1:
+ parser.error("Fractions must be between 0 and 1.")
+ if args.validation_fraction + args.test_fraction >= 1:
+ parser.error("Validation and test fractions must sum to less than 1.")
+
+ rng = np.random.default_rng(args.seed)
+ copied = 0
+ for class_dir in sorted(path for path in args.source.iterdir() if path.is_dir()):
+ images = sorted(
+ path
+ for path in class_dir.rglob("*")
+ if path.is_file() and path.suffix.lower() in EXTENSIONS
+ )
+ indices = np.arange(len(images))
+ rng.shuffle(indices)
+ test_count = int(round(len(images) * args.test_fraction))
+ validation_count = int(round(len(images) * args.validation_fraction))
+ assignments = {}
+ for position, index in enumerate(indices):
+ split = (
+ "test"
+ if position < test_count
+ else "validation"
+ if position < test_count + validation_count
+ else "train"
+ )
+ assignments[index] = split
+ for index, image in enumerate(images):
+ destination = args.output / assignments[index] / class_dir.name / image.name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(image, destination)
+ copied += 1
+ print(f"Copied {copied} personal images into {args.output}.")
+ print("These files remain external and are not used by the main NABirds training run.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validate_dataset.py b/scripts/validate_dataset.py
new file mode 100644
index 0000000..c14f34a
--- /dev/null
+++ b/scripts/validate_dataset.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import pandas as pd
+from PIL import Image, UnidentifiedImageError
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from src.config import load_config
+from src.metadata import build_and_save_manifest
+from src.utils import configure_logging
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Validate every referenced NABirds image.")
+ parser.add_argument("--config", default="config/config.yaml")
+ parser.add_argument("--max-invalid", type=int)
+ args = parser.parse_args()
+ configure_logging()
+ config = load_config(args.config)
+ try:
+ if config["data"]["manifest_path"].exists():
+ manifest = pd.read_csv(config["data"]["manifest_path"])
+ else:
+ manifest = build_and_save_manifest(
+ config["data"]["root_dir"], config["data"]["manifest_path"]
+ ).manifest
+ except FileNotFoundError as exc:
+ print(f"Dataset validation could not start: {exc}", file=sys.stderr)
+ raise SystemExit(2) from exc
+
+ failures: list[dict[str, str | int | None]] = []
+ duplicate_mask = manifest["absolute_path"].duplicated(keep=False)
+ for path in manifest.loc[duplicate_mask, "absolute_path"].drop_duplicates():
+ failures.append(
+ {
+ "image_path": path,
+ "failure_type": "duplicate_path",
+ "error": "The same image path is referenced more than once.",
+ "width": None,
+ "height": None,
+ "mode": None,
+ }
+ )
+
+ for path_text in manifest["absolute_path"]:
+ path = Path(path_text)
+ try:
+ with Image.open(path) as image:
+ image.load()
+ width, height = image.size
+ mode = image.mode
+ if width <= 0 or height <= 0:
+ raise ValueError("non-positive dimensions")
+ if mode not in {"RGB", "RGBA", "L"}:
+ # Other Pillow modes are convertible, but should be visible in the audit.
+ image.convert("RGB")
+ except (OSError, ValueError, UnidentifiedImageError) as exc:
+ failures.append(
+ {
+ "image_path": str(path),
+ "failure_type": "invalid_image",
+ "error": str(exc),
+ "width": None,
+ "height": None,
+ "mode": None,
+ }
+ )
+
+ output = config["project"]["reports_dir"] / "invalid_images.csv"
+ pd.DataFrame(
+ failures,
+ columns=["image_path", "failure_type", "error", "width", "height", "mode"],
+ ).to_csv(output, index=False)
+ threshold = (
+ args.max_invalid
+ if args.max_invalid is not None
+ else int(config["data"].get("invalid_image_threshold", 0))
+ )
+ print(f"Validated {len(manifest):,} references; found {len(failures):,} failures.")
+ print(f"Saved validation report: {output}")
+ if len(failures) > threshold:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..2d37952
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,3 @@
+"""NABirds Wildlife Recognition package."""
+
+__version__ = "0.1.0"
diff --git a/src/calibration.py b/src/calibration.py
new file mode 100644
index 0000000..5a9d6c5
--- /dev/null
+++ b/src/calibration.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+
+import numpy as np
+import torch
+from torch import nn
+
+
+class TemperatureScaler(nn.Module):
+ """Single-parameter post-hoc calibration fitted on validation logits only."""
+
+ def __init__(self, initial_temperature: float = 1.0) -> None:
+ super().__init__()
+ self.log_temperature = nn.Parameter(
+ torch.tensor(math.log(max(initial_temperature, 1e-3)), dtype=torch.float32)
+ )
+
+ @property
+ def temperature(self) -> torch.Tensor:
+ return self.log_temperature.exp().clamp(min=1e-3, max=100.0)
+
+ def forward(self, logits: torch.Tensor) -> torch.Tensor:
+ return logits / self.temperature
+
+
+def fit_temperature(
+ logits: torch.Tensor,
+ labels: torch.Tensor,
+ *,
+ max_iter: int = 50,
+) -> float:
+ if logits.ndim != 2 or labels.ndim != 1 or len(logits) != len(labels):
+ raise ValueError("Expected logits [N, C] and labels [N].")
+ device = logits.device
+ scaler = TemperatureScaler().to(device)
+ criterion = nn.CrossEntropyLoss()
+ optimizer = torch.optim.LBFGS([scaler.log_temperature], lr=0.05, max_iter=max_iter)
+
+ def closure():
+ optimizer.zero_grad()
+ loss = criterion(scaler(logits), labels)
+ loss.backward()
+ return loss
+
+ optimizer.step(closure)
+ return float(scaler.temperature.detach().cpu().item())
+
+
+def apply_temperature(logits: torch.Tensor, temperature: float) -> torch.Tensor:
+ return logits / max(float(temperature), 1e-3)
+
+
+def expected_calibration_error(
+ probabilities: np.ndarray,
+ labels: np.ndarray,
+ bins: int = 15,
+) -> float:
+ confidences = probabilities.max(axis=1)
+ predictions = probabilities.argmax(axis=1)
+ accuracies = predictions == labels
+ edges = np.linspace(0.0, 1.0, bins + 1)
+ ece = 0.0
+ for lower, upper in zip(edges[:-1], edges[1:]):
+ mask = (confidences > lower) & (confidences <= upper)
+ if mask.any():
+ ece += float(mask.mean()) * abs(
+ float(accuracies[mask].mean()) - float(confidences[mask].mean())
+ )
+ return ece
+
+
+@dataclass(frozen=True)
+class Uncertainty:
+ status: str
+ maximum_probability: float
+ entropy: float
+ normalized_entropy: float
+ margin: float
+
+
+def uncertainty_from_probabilities(
+ probabilities: np.ndarray,
+ unknown_threshold: float,
+) -> Uncertainty:
+ probabilities = np.asarray(probabilities, dtype=np.float64)
+ probabilities = probabilities / probabilities.sum()
+ ordered = np.sort(probabilities)[::-1]
+ maximum = float(ordered[0])
+ margin = float(ordered[0] - ordered[1]) if len(ordered) > 1 else maximum
+ entropy = float(-(probabilities * np.log(probabilities + 1e-12)).sum())
+ normalized_entropy = entropy / max(math.log(len(probabilities)), 1e-12)
+ if maximum < unknown_threshold:
+ status = "possibly_unknown"
+ elif margin < 0.10 or normalized_entropy > 0.65:
+ status = "uncertain_prediction"
+ else:
+ status = "confident_prediction"
+ return Uncertainty(status, maximum, entropy, normalized_entropy, margin)
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..abc7929
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+import yaml
+
+
+SUPPORTED_ARCHITECTURES = {"efficientnet_b0", "resnet50", "mobilenet_v3_large"}
+PATH_FIELDS = {
+ ("project", "output_dir"),
+ ("project", "reports_dir"),
+ ("data", "root_dir"),
+ ("data", "manifest_path"),
+ ("evaluation", "external_test_dir"),
+ ("app", "model_path"),
+ ("app", "metadata_path"),
+}
+
+
+def repository_root() -> Path:
+ return Path(__file__).resolve().parents[1]
+
+
+@dataclass(frozen=True)
+class ProjectConfig:
+ """Dictionary-like configuration with resolved filesystem paths."""
+
+ values: dict[str, Any]
+ source: Path
+ root: Path
+
+ def __getitem__(self, key: str) -> Any:
+ return self.values[key]
+
+ def get(self, key: str, default: Any = None) -> Any:
+ return self.values.get(key, default)
+
+ def to_dict(self, stringify_paths: bool = False) -> dict[str, Any]:
+ data = deepcopy(self.values)
+ if stringify_paths:
+ for section, field in PATH_FIELDS:
+ if section in data and field in data[section]:
+ data[section][field] = str(data[section][field])
+ return data
+
+
+def _resolve_path(value: str | Path, root: Path) -> Path:
+ path = Path(value).expanduser()
+ return path.resolve() if path.is_absolute() else (root / path).resolve()
+
+
+def _validate(raw: Mapping[str, Any]) -> None:
+ required_sections = {"project", "data", "model", "training", "evaluation", "app"}
+ missing = sorted(required_sections.difference(raw))
+ if missing:
+ raise ValueError(f"Missing configuration sections: {', '.join(missing)}")
+
+ architecture = raw["model"].get("architecture")
+ if architecture not in SUPPORTED_ARCHITECTURES:
+ choices = ", ".join(sorted(SUPPORTED_ARCHITECTURES))
+ raise ValueError(f"Unsupported model architecture {architecture!r}; choose one of: {choices}")
+
+ image_size = int(raw["data"].get("image_size", 0))
+ if image_size <= 0:
+ raise ValueError("data.image_size must be a positive integer")
+
+ validation_fraction = float(raw["data"].get("validation_fraction_from_train", 0))
+ if not 0 < validation_fraction < 1:
+ raise ValueError("data.validation_fraction_from_train must be between 0 and 1")
+
+ if int(raw["training"].get("batch_size", 0)) <= 0:
+ raise ValueError("training.batch_size must be positive")
+
+ threshold = float(raw["evaluation"].get("unknown_threshold", -1))
+ if not 0 <= threshold <= 1:
+ raise ValueError("evaluation.unknown_threshold must be between 0 and 1")
+
+
+def load_config(path: str | Path = "config/config.yaml", create_dirs: bool = True) -> ProjectConfig:
+ root = repository_root()
+ source = _resolve_path(path, root)
+ if not source.exists():
+ raise FileNotFoundError(f"Configuration file not found: {source}")
+
+ with source.open("r", encoding="utf-8") as handle:
+ raw = yaml.safe_load(handle) or {}
+ _validate(raw)
+
+ values = deepcopy(raw)
+ for section, field in PATH_FIELDS:
+ values[section][field] = _resolve_path(values[section][field], root)
+
+ config = ProjectConfig(values=values, source=source, root=root)
+ if create_dirs:
+ directories = [
+ config["project"]["output_dir"],
+ config["project"]["reports_dir"],
+ config["project"]["reports_dir"] / "figures",
+ config["data"]["manifest_path"].parent,
+ config["evaluation"]["external_test_dir"],
+ ]
+ for directory in directories:
+ directory.mkdir(parents=True, exist_ok=True)
+ return config
diff --git a/src/dataset.py b/src/dataset.py
new file mode 100644
index 0000000..8303d6f
--- /dev/null
+++ b/src/dataset.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+import torch
+from PIL import Image, UnidentifiedImageError
+from torch.utils.data import Dataset
+
+
+class NABirdsDataset(Dataset):
+ """Dataset backed by a normalized NABirds manifest."""
+
+ def __init__(
+ self,
+ manifest: pd.DataFrame,
+ transform=None,
+ *,
+ crop_to_bounding_box: bool = False,
+ mode: str = "train",
+ ) -> None:
+ if manifest.empty:
+ raise ValueError("The dataset manifest is empty.")
+ self.manifest = manifest.reset_index(drop=True).copy()
+ self.transform = transform
+ self.crop_to_bounding_box = crop_to_bounding_box
+ self.mode = mode
+
+ def __len__(self) -> int:
+ return len(self.manifest)
+
+ @staticmethod
+ def _crop_bbox(image: Image.Image, row: pd.Series) -> Image.Image:
+ values = [row.get(name) for name in ("bbox_x", "bbox_y", "bbox_width", "bbox_height")]
+ if any(pd.isna(value) for value in values):
+ return image
+ x, y, width, height = (float(value) for value in values)
+ left = max(0, min(image.width, int(round(x))))
+ top = max(0, min(image.height, int(round(y))))
+ right = max(left + 1, min(image.width, int(round(x + width))))
+ bottom = max(top + 1, min(image.height, int(round(y + height))))
+ if left >= image.width or top >= image.height:
+ raise ValueError(f"Bounding box starts outside image dimensions: {values}")
+ return image.crop((left, top, right, bottom))
+
+ def __getitem__(self, index: int) -> dict[str, Any]:
+ row = self.manifest.iloc[index]
+ path = Path(row["absolute_path"])
+ try:
+ with Image.open(path) as opened:
+ image = opened.convert("RGB")
+ except (OSError, ValueError, UnidentifiedImageError) as exc:
+ raise RuntimeError(f"Unable to read image {path}: {exc}") from exc
+ if self.crop_to_bounding_box:
+ image = self._crop_bbox(image, row)
+ if self.transform is not None:
+ image = self.transform(image)
+ label = int(row.get("internal_label", -1))
+ return {
+ "image": image,
+ "label": torch.tensor(label, dtype=torch.long),
+ "image_id": str(row.get("image_id", path.stem)),
+ "path": str(path),
+ }
diff --git a/src/evaluate.py b/src/evaluate.py
new file mode 100644
index 0000000..11e0c44
--- /dev/null
+++ b/src/evaluate.py
@@ -0,0 +1,365 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+from collections import Counter
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import torch
+from sklearn.metrics import (
+ accuracy_score,
+ balanced_accuracy_score,
+ classification_report,
+ f1_score,
+ precision_recall_fscore_support,
+ top_k_accuracy_score,
+)
+from torch.utils.data import DataLoader
+from tqdm import tqdm
+
+from .calibration import expected_calibration_error, fit_temperature
+from .config import load_config
+from .dataset import NABirdsDataset
+from .inference import BirdPredictor
+from .model import create_model
+from .transforms import build_eval_transform
+from .utils import configure_logging, save_json, select_device
+from .visualize import (
+ save_calibration_curve,
+ save_confusion_views,
+ save_per_class_f1,
+)
+
+
+LOGGER = logging.getLogger(__name__)
+EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
+
+
+def _collect_logits(
+ model: torch.nn.Module,
+ loader: DataLoader,
+ device: torch.device,
+) -> tuple[np.ndarray, np.ndarray, list[str], list[str]]:
+ logits: list[np.ndarray] = []
+ labels: list[np.ndarray] = []
+ image_ids: list[str] = []
+ paths: list[str] = []
+ model.eval()
+ with torch.inference_mode():
+ for batch in tqdm(loader, desc="Evaluating"):
+ output = model(batch["image"].to(device))
+ logits.append(output.cpu().numpy())
+ labels.append(batch["label"].numpy())
+ image_ids.extend(batch["image_id"])
+ paths.extend(batch["path"])
+ return np.concatenate(logits), np.concatenate(labels), image_ids, paths
+
+
+def _top_confusions(
+ y_true: np.ndarray,
+ y_pred: np.ndarray,
+ class_names: list[str],
+ limit: int = 25,
+) -> list[dict[str, Any]]:
+ pairs = Counter(
+ (int(true), int(predicted))
+ for true, predicted in zip(y_true, y_pred)
+ if true != predicted
+ )
+ return [
+ {
+ "true_class": class_names[true],
+ "predicted_class": class_names[predicted],
+ "count": count,
+ }
+ for (true, predicted), count in pairs.most_common(limit)
+ ]
+
+
+def evaluate_checkpoint(config_path: str, checkpoint_path: Path | None, device_name: str) -> None:
+ config = load_config(config_path)
+ checkpoint_path = checkpoint_path or config["app"]["model_path"]
+ if not checkpoint_path.exists():
+ raise FileNotFoundError(
+ f"Trained model not found: {checkpoint_path}. Train the model first with python -m src.train."
+ )
+ test_manifest_path = config["data"]["manifest_path"].parent / "test_manifest.csv"
+ validation_manifest_path = config["data"]["manifest_path"].parent / "validation_manifest.csv"
+ if not test_manifest_path.exists():
+ raise FileNotFoundError(
+ f"Test manifest not found: {test_manifest_path}. Run training or a dry run to create splits."
+ )
+ test = pd.read_csv(test_manifest_path)
+ validation = pd.read_csv(validation_manifest_path) if validation_manifest_path.exists() else None
+ device = select_device(device_name)
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
+ class_names = [str(name) for name in checkpoint["class_names"]]
+ model, _ = create_model(
+ checkpoint["architecture"],
+ len(class_names),
+ pretrained=False,
+ dropout=float(checkpoint["configuration"]["model"].get("dropout", 0.2)),
+ image_size=int(checkpoint["preprocessing"].get("image_size", 224)),
+ )
+ model.load_state_dict(checkpoint["model_state_dict"])
+ model.to(device)
+ transform = build_eval_transform(int(checkpoint["preprocessing"].get("image_size", 224)))
+ loader_options = {
+ "batch_size": int(config["training"]["batch_size"]),
+ "num_workers": int(config["data"].get("num_workers", 0)),
+ "pin_memory": device.type == "cuda",
+ }
+
+ temperature = 1.0
+ if (
+ config["evaluation"].get("calibration_method") == "temperature_scaling"
+ and validation is not None
+ and not validation.empty
+ ):
+ validation_loader = DataLoader(
+ NABirdsDataset(validation, transform, mode="validation"),
+ shuffle=False,
+ **loader_options,
+ )
+ validation_logits, validation_labels, _, _ = _collect_logits(
+ model, validation_loader, device
+ )
+ temperature = fit_temperature(
+ torch.from_numpy(validation_logits).to(device),
+ torch.from_numpy(validation_labels).long().to(device),
+ )
+ save_json(
+ config["project"]["output_dir"] / "calibration.json",
+ {
+ "method": "temperature_scaling",
+ "temperature": temperature,
+ "fitted_on": str(validation_manifest_path),
+ },
+ )
+
+ test_loader = DataLoader(
+ NABirdsDataset(test, transform, mode="test"),
+ shuffle=False,
+ **loader_options,
+ )
+ logits, y_true, image_ids, paths = _collect_logits(model, test_loader, device)
+ logits = logits / max(temperature, 1e-3)
+ probabilities = torch.softmax(torch.from_numpy(logits), dim=1).numpy()
+ y_pred = probabilities.argmax(axis=1)
+ labels = np.arange(len(class_names))
+ top3 = min(3, len(class_names))
+ top5 = min(5, len(class_names))
+ macro = precision_recall_fscore_support(
+ y_true, y_pred, average="macro", zero_division=0
+ )
+ weighted = precision_recall_fscore_support(
+ y_true, y_pred, average="weighted", zero_division=0
+ )
+ metrics = {
+ "top1_accuracy": accuracy_score(y_true, y_pred),
+ "top3_accuracy": top_k_accuracy_score(y_true, probabilities, k=top3, labels=labels),
+ "top5_accuracy": top_k_accuracy_score(y_true, probabilities, k=top5, labels=labels),
+ "macro_precision": macro[0],
+ "macro_recall": macro[1],
+ "macro_f1": macro[2],
+ "weighted_precision": weighted[0],
+ "weighted_recall": weighted[1],
+ "weighted_f1": weighted[2],
+ "balanced_accuracy": balanced_accuracy_score(y_true, y_pred),
+ "expected_calibration_error": expected_calibration_error(probabilities, y_true),
+ "temperature": temperature,
+ "image_count": len(y_true),
+ "class_count": len(class_names),
+ "top_confusion_pairs": _top_confusions(y_true, y_pred, class_names),
+ }
+ reports_dir = config["project"]["reports_dir"]
+ figures_dir = reports_dir / "figures"
+ save_json(reports_dir / "test_metrics.json", metrics)
+
+ raw_report = classification_report(
+ y_true,
+ y_pred,
+ labels=labels,
+ target_names=class_names,
+ output_dict=True,
+ zero_division=0,
+ )
+ report_rows = []
+ for label, name in enumerate(class_names):
+ row = raw_report[name]
+ report_rows.append({"internal_label": label, "class_name": name, **row})
+ report_frame = pd.DataFrame(report_rows)
+ report_frame.to_csv(reports_dir / "test_classification_report.csv", index=False)
+
+ top_indices = np.argsort(probabilities, axis=1)[:, ::-1][:, :top5]
+ prediction_rows = []
+ for index, (true, predicted) in enumerate(zip(y_true, y_pred)):
+ prediction_rows.append(
+ {
+ "image_id": image_ids[index],
+ "image_path": paths[index],
+ "true_label": int(true),
+ "true_class_name": class_names[true],
+ "predicted_label": int(predicted),
+ "predicted_class_name": class_names[predicted],
+ "top1_probability": float(probabilities[index, predicted]),
+ "top5_class_names": json.dumps(
+ [class_names[item] for item in top_indices[index]], ensure_ascii=False
+ ),
+ "top5_probabilities": json.dumps(
+ [float(probabilities[index, item]) for item in top_indices[index]]
+ ),
+ }
+ )
+ pd.DataFrame(prediction_rows).to_csv(reports_dir / "test_predictions.csv", index=False)
+ save_confusion_views(
+ y_true, y_pred, class_names, figures_dir / "confusion_matrix.png"
+ )
+ save_per_class_f1(report_frame, figures_dir / "per_class_f1.png")
+ save_calibration_curve(y_true, probabilities, figures_dir / "calibration_curve.png")
+
+ # A compact visual makes top-k gains readable without a hundreds-class chart.
+ import matplotlib.pyplot as plt
+
+ fig, ax = plt.subplots(figsize=(6, 4))
+ ax.bar(
+ ["Top-1", f"Top-{top3}", f"Top-{top5}"],
+ [metrics["top1_accuracy"], metrics["top3_accuracy"], metrics["top5_accuracy"]],
+ color=["#244c3d", "#4f8068", "#8db39d"],
+ )
+ ax.set(ylim=(0, 1), ylabel="Accuracy", title="Test top-k accuracy")
+ fig.tight_layout()
+ fig.savefig(figures_dir / "top5_accuracy.png", dpi=160)
+ plt.close(fig)
+ print(json.dumps(metrics, indent=2, default=str))
+
+
+def evaluate_external_photos(
+ predictor: BirdPredictor,
+ root: str | Path,
+ *,
+ top_k: int = 5,
+) -> tuple[pd.DataFrame, dict[str, Any]]:
+ root = Path(root)
+ records: list[dict[str, Any]] = []
+ supported = {name.casefold(): index for index, name in enumerate(predictor.class_names)}
+ failures: list[dict[str, str]] = []
+ for path in sorted(root.rglob("*")):
+ if not path.is_file() or path.suffix.lower() not in EXTENSIONS:
+ continue
+ folder_label = path.parent.name
+ try:
+ prediction = predictor.predict(path, top_k=top_k)
+ except Exception as exc:
+ failures.append({"image_path": str(path), "error": str(exc)})
+ continue
+ true_label = supported.get(folder_label.casefold())
+ records.append(
+ {
+ "image_path": str(path),
+ "folder_label": folder_label,
+ "supported_class": true_label is not None,
+ "true_label": true_label,
+ "predicted_label": prediction["predicted_label"],
+ "predicted_class": prediction["predicted_class"],
+ "probability": prediction["probability"],
+ "uncertainty_status": prediction["uncertainty_status"],
+ "top_k_labels": [item["internal_label"] for item in prediction["top_k"]],
+ }
+ )
+ frame = pd.DataFrame(records)
+ supported_frame = frame[frame["supported_class"]].copy() if not frame.empty else frame
+ metrics: dict[str, Any] = {
+ "total_images": len(frame),
+ "supported_images": len(supported_frame),
+ "unsupported_images": int((~frame["supported_class"]).sum()) if not frame.empty else 0,
+ "failed_images": len(failures),
+ "failures": failures,
+ }
+ if not supported_frame.empty:
+ true = supported_frame["true_label"].astype(int).to_numpy()
+ predicted = supported_frame["predicted_label"].astype(int).to_numpy()
+ metrics.update(
+ {
+ "accuracy": accuracy_score(true, predicted),
+ "macro_f1": f1_score(true, predicted, average="macro", zero_division=0),
+ "top5_accuracy": float(
+ np.mean(
+ [
+ label in top_labels
+ for label, top_labels in zip(
+ supported_frame["true_label"],
+ supported_frame["top_k_labels"],
+ )
+ ]
+ )
+ ),
+ "uncertain_rate": float(
+ (supported_frame["uncertainty_status"] != "confident_prediction").mean()
+ ),
+ }
+ )
+ evaluated_labels = sorted(set(true.tolist()))
+ precision, recall, per_class_f1, support = precision_recall_fscore_support(
+ true,
+ predicted,
+ labels=evaluated_labels,
+ zero_division=0,
+ )
+ metrics["per_class_results"] = [
+ {
+ "class_name": predictor.class_names[label],
+ "precision": float(precision[index]),
+ "recall": float(recall[index]),
+ "f1": float(per_class_f1[index]),
+ "support": int(support[index]),
+ }
+ for index, label in enumerate(evaluated_labels)
+ ]
+ return frame, metrics
+
+
+def save_external_evaluation(
+ predictor: BirdPredictor,
+ root: str | Path,
+ reports_dir: str | Path,
+ *,
+ top_k: int = 5,
+) -> tuple[pd.DataFrame, dict[str, Any]]:
+ frame, metrics = evaluate_external_photos(predictor, root, top_k=top_k)
+ reports_dir = Path(reports_dir)
+ figures_dir = reports_dir / "figures"
+ reports_dir.mkdir(parents=True, exist_ok=True)
+ serializable = frame.copy()
+ if "top_k_labels" in serializable:
+ serializable["top_k_labels"] = serializable["top_k_labels"].map(json.dumps)
+ serializable.to_csv(reports_dir / "external_predictions.csv", index=False)
+ save_json(reports_dir / "external_metrics.json", metrics)
+ supported = frame[frame["supported_class"]].copy() if not frame.empty else frame
+ if not supported.empty:
+ save_confusion_views(
+ supported["true_label"].astype(int).to_numpy(),
+ supported["predicted_label"].astype(int).to_numpy(),
+ predictor.class_names,
+ figures_dir / "external_confusion_matrix.png",
+ max_classes=40,
+ )
+ return frame, metrics
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Evaluate the untouched official NABirds test split.")
+ parser.add_argument("--config", default="config/config.yaml")
+ parser.add_argument("--checkpoint", type=Path)
+ parser.add_argument("--device", default="auto")
+ args = parser.parse_args()
+ configure_logging()
+ evaluate_checkpoint(args.config, args.checkpoint, args.device)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/inference.py b/src/inference.py
new file mode 100644
index 0000000..2bd39f2
--- /dev/null
+++ b/src/inference.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+from PIL import Image, UnidentifiedImageError
+
+from .calibration import apply_temperature, uncertainty_from_probabilities
+from .model import create_model
+from .transforms import build_eval_transform
+from .utils import select_device
+
+
+class BirdPredictor:
+ """Reusable, local-only NABirds image predictor."""
+
+ def __init__(
+ self,
+ model_path: str | Path,
+ metadata_path: str | Path | None = None,
+ calibration_path: str | Path | None = None,
+ *,
+ device: str | None = None,
+ unknown_threshold: float | None = None,
+ ) -> None:
+ self.model_path = Path(model_path)
+ if not self.model_path.exists():
+ raise FileNotFoundError(
+ f"Trained model not found: {self.model_path}. "
+ "Train the model first with python -m src.train."
+ )
+ self.device = select_device(device)
+ checkpoint = torch.load(self.model_path, map_location=self.device, weights_only=False)
+ self.architecture = checkpoint.get("architecture", "efficientnet_b0")
+ self.num_classes = int(
+ checkpoint.get("num_classes", len(checkpoint.get("class_names", [])))
+ )
+ if self.num_classes < 2:
+ raise ValueError("Checkpoint does not contain a valid class count.")
+ preprocessing = checkpoint.get("preprocessing", {})
+ self.image_size = int(preprocessing.get("image_size", 224))
+
+ self.model, _ = create_model(
+ self.architecture,
+ self.num_classes,
+ pretrained=False,
+ dropout=float(
+ checkpoint.get("configuration", {}).get("model", {}).get("dropout", 0.2)
+ ),
+ image_size=self.image_size,
+ )
+ self.model.load_state_dict(checkpoint["model_state_dict"])
+ self.model.to(self.device).eval()
+ self.transform = build_eval_transform(self.image_size)
+
+ names = checkpoint.get("class_names")
+ if metadata_path is not None and Path(metadata_path).exists():
+ with Path(metadata_path).open("r", encoding="utf-8") as handle:
+ metadata = json.load(handle)
+ names = metadata.get("class_names", names)
+ if not names or len(names) != self.num_classes:
+ raise ValueError("Class metadata is missing or does not match the checkpoint.")
+ self.class_names = [str(name) for name in names]
+
+ configuration = checkpoint.get("configuration", {})
+ checkpoint_threshold = (
+ configuration.get("evaluation", {}).get("unknown_threshold", 0.45)
+ )
+ self.unknown_threshold = float(
+ checkpoint_threshold if unknown_threshold is None else unknown_threshold
+ )
+ self.temperature = 1.0
+ if calibration_path is None:
+ calibration_path = self.model_path.parent / "calibration.json"
+ calibration_path = Path(calibration_path)
+ if calibration_path.exists():
+ with calibration_path.open("r", encoding="utf-8") as handle:
+ calibration = json.load(handle)
+ self.temperature = float(calibration.get("temperature", 1.0))
+
+ @staticmethod
+ def _load_image(image: str | Path | Image.Image) -> Image.Image:
+ if isinstance(image, Image.Image):
+ return image.convert("RGB")
+ path = Path(image)
+ try:
+ with Image.open(path) as opened:
+ return opened.convert("RGB")
+ except (OSError, ValueError, UnidentifiedImageError) as exc:
+ raise ValueError(f"Unable to read image {path}: {exc}") from exc
+
+ def predict(
+ self,
+ image: str | Path | Image.Image,
+ *,
+ top_k: int = 5,
+ unknown_threshold: float | None = None,
+ ) -> dict[str, Any]:
+ started = time.perf_counter()
+ pil_image = self._load_image(image)
+ tensor = self.transform(pil_image).unsqueeze(0).to(self.device)
+ with torch.inference_mode():
+ logits = self.model(tensor)
+ logits = apply_temperature(logits, self.temperature)
+ probabilities = torch.softmax(logits, dim=1)[0].cpu().numpy()
+
+ threshold = self.unknown_threshold if unknown_threshold is None else unknown_threshold
+ uncertainty = uncertainty_from_probabilities(probabilities, threshold)
+ top_k = max(1, min(int(top_k), self.num_classes))
+ indices = np.argsort(probabilities)[::-1][:top_k]
+ results = [
+ {
+ "internal_label": int(index),
+ "class_name": self.class_names[index],
+ "probability": float(probabilities[index]),
+ }
+ for index in indices
+ ]
+ if uncertainty.status == "possibly_unknown":
+ warning = (
+ "Low confidence: this may be an unsupported species or an unusual image. "
+ "This is not a scientifically validated unknown-species detector."
+ )
+ elif uncertainty.status == "uncertain_prediction":
+ warning = "The model is uncertain; review the alternatives and do not treat this as certain."
+ else:
+ warning = "Prediction is model-generated and may be incorrect."
+ return {
+ "predicted_class": results[0]["class_name"],
+ "predicted_label": results[0]["internal_label"],
+ "probability": results[0]["probability"],
+ "uncertainty_status": uncertainty.status,
+ "top_k": results,
+ "entropy": uncertainty.entropy,
+ "normalized_entropy": uncertainty.normalized_entropy,
+ "margin": uncertainty.margin,
+ "unknown_threshold": float(threshold),
+ "temperature": self.temperature,
+ "warning": warning,
+ "inference_time_ms": (time.perf_counter() - started) * 1000,
+ }
diff --git a/src/metadata.py b/src/metadata.py
new file mode 100644
index 0000000..a7c1995
--- /dev/null
+++ b/src/metadata.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+import pandas as pd
+from PIL import Image, UnidentifiedImageError
+
+
+LOGGER = logging.getLogger(__name__)
+REQUIRED_FILES = (
+ "images.txt",
+ "image_class_labels.txt",
+ "train_test_split.txt",
+ "classes.txt",
+)
+OPTIONAL_FILES = ("bounding_boxes.txt",)
+IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
+
+
+@dataclass
+class MetadataResult:
+ manifest: pd.DataFrame
+ discovered_files: dict[str, Path]
+ diagnostics: dict[str, Any]
+
+
+def _choose_match(root: Path, filename: str) -> Path | None:
+ matches = sorted(
+ (path for path in root.rglob(filename) if path.is_file()),
+ key=lambda path: (len(path.parts), str(path).lower()),
+ )
+ if not matches:
+ return None
+ if len(matches) > 1:
+ LOGGER.warning("Multiple %s files found; using %s", filename, matches[0])
+ return matches[0]
+
+
+def find_metadata_files(root: str | Path) -> dict[str, Path]:
+ root = Path(root).expanduser().resolve()
+ if not root.exists():
+ expected = ", ".join(REQUIRED_FILES)
+ raise FileNotFoundError(
+ f"NABirds dataset directory not found: {root}. Extract NABirds there "
+ f"so it contains (possibly in nested folders) {expected}."
+ )
+ discovered: dict[str, Path] = {}
+ for filename in REQUIRED_FILES + OPTIONAL_FILES:
+ match = _choose_match(root, filename)
+ if match is not None:
+ discovered[filename] = match
+ missing = [name for name in REQUIRED_FILES if name not in discovered]
+ if missing:
+ raise FileNotFoundError(
+ f"NABirds metadata is incomplete under {root}. Missing: {', '.join(missing)}"
+ )
+ return discovered
+
+
+def _parse_id_and_text(path: Path, id_column: str, value_column: str) -> pd.DataFrame:
+ records: list[tuple[str, str]] = []
+ with path.open("r", encoding="utf-8-sig") as handle:
+ for line_number, raw_line in enumerate(handle, start=1):
+ line = raw_line.strip()
+ if not line:
+ continue
+ parts = line.split(maxsplit=1)
+ if len(parts) != 2:
+ raise ValueError(f"Malformed line {line_number} in {path}: {raw_line!r}")
+ records.append((parts[0], parts[1]))
+ return pd.DataFrame(records, columns=[id_column, value_column])
+
+
+def _parse_bbox(path: Path) -> pd.DataFrame:
+ records: list[tuple[str, float, float, float, float]] = []
+ with path.open("r", encoding="utf-8-sig") as handle:
+ for line_number, raw_line in enumerate(handle, start=1):
+ parts = raw_line.strip().split()
+ if not parts:
+ continue
+ if len(parts) != 5:
+ raise ValueError(f"Malformed bounding box on line {line_number} in {path}")
+ records.append((parts[0], *(float(value) for value in parts[1:])))
+ return pd.DataFrame(
+ records,
+ columns=["image_id", "bbox_x", "bbox_y", "bbox_width", "bbox_height"],
+ )
+
+
+def _locate_image_base(images_metadata_path: Path, relative_paths: Iterable[str]) -> Path:
+ sample_paths = [Path(value) for value in list(relative_paths)[:20]]
+ candidates: list[Path] = []
+ current = images_metadata_path.parent
+ for ancestor in (current, *current.parents):
+ candidates.extend([ancestor, ancestor / "images"])
+ if len(candidates) >= 12:
+ break
+ for candidate in candidates:
+ if sample_paths and sum((candidate / path).is_file() for path in sample_paths) >= max(
+ 1, len(sample_paths) // 2
+ ):
+ return candidate.resolve()
+ return images_metadata_path.parent.resolve()
+
+
+def _inspect_image(path: Path) -> tuple[int | None, int | None, str | None]:
+ try:
+ with Image.open(path) as image:
+ image.verify()
+ with Image.open(path) as image:
+ width, height = image.size
+ if width <= 0 or height <= 0:
+ return width, height, "image has non-positive dimensions"
+ return width, height, None
+ except (OSError, ValueError, UnidentifiedImageError) as exc:
+ return None, None, str(exc)
+
+
+def load_nabirds_metadata(
+ root: str | Path,
+ *,
+ verify_images: bool = True,
+ inspect_dimensions: bool = False,
+) -> MetadataResult:
+ discovered = find_metadata_files(root)
+ images = _parse_id_and_text(discovered["images.txt"], "image_id", "relative_path")
+ labels = _parse_id_and_text(
+ discovered["image_class_labels.txt"], "image_id", "original_class_id"
+ )
+ split = _parse_id_and_text(discovered["train_test_split.txt"], "image_id", "is_training")
+ classes = _parse_id_and_text(discovered["classes.txt"], "original_class_id", "class_name")
+
+ for frame, name, id_column in (
+ (images, "images.txt", "image_id"),
+ (labels, "image_class_labels.txt", "image_id"),
+ (split, "train_test_split.txt", "image_id"),
+ (classes, "classes.txt", "original_class_id"),
+ ):
+ duplicates = frame[id_column].duplicated(keep=False)
+ if duplicates.any():
+ values = frame.loc[duplicates, id_column].astype(str).tolist()[:10]
+ raise ValueError(f"Duplicate IDs in {name}: {values}")
+
+ image_ids = set(images["image_id"])
+ label_ids = set(labels["image_id"])
+ split_ids = set(split["image_id"])
+ if image_ids != label_ids or image_ids != split_ids:
+ raise ValueError(
+ "Image IDs do not match across metadata files: "
+ f"images={len(image_ids)}, labels={len(label_ids)}, split={len(split_ids)}"
+ )
+
+ manifest = images.merge(labels, on="image_id", validate="one_to_one")
+ manifest = manifest.merge(split, on="image_id", validate="one_to_one")
+ manifest = manifest.merge(classes, on="original_class_id", validate="many_to_one")
+ if len(manifest) != len(images):
+ missing_classes = sorted(set(labels["original_class_id"]) - set(classes["original_class_id"]))
+ raise ValueError(f"Class metadata is missing class IDs: {missing_classes[:20]}")
+
+ class_ids = sorted(manifest["original_class_id"].unique(), key=lambda value: int(value))
+ internal_mapping = {class_id: index for index, class_id in enumerate(class_ids)}
+ manifest["internal_label"] = manifest["original_class_id"].map(internal_mapping).astype(int)
+ manifest["original_class_id"] = manifest["original_class_id"].astype(int)
+ manifest["is_training"] = manifest["is_training"].astype(int).astype(bool)
+ manifest["split"] = manifest["is_training"].map({True: "official_train", False: "test"})
+
+ image_base = _locate_image_base(discovered["images.txt"], manifest["relative_path"])
+ manifest["absolute_path"] = manifest["relative_path"].map(
+ lambda value: str((image_base / Path(value)).resolve())
+ )
+
+ if "bounding_boxes.txt" in discovered:
+ boxes = _parse_bbox(discovered["bounding_boxes.txt"])
+ manifest = manifest.merge(boxes, on="image_id", how="left", validate="one_to_one")
+ else:
+ for column in ("bbox_x", "bbox_y", "bbox_width", "bbox_height"):
+ manifest[column] = pd.NA
+
+ exists = manifest["absolute_path"].map(lambda value: Path(value).is_file())
+ missing_paths = manifest.loc[~exists, "absolute_path"].tolist()
+ duplicate_paths = (
+ manifest.loc[manifest["absolute_path"].duplicated(keep=False), "absolute_path"]
+ .drop_duplicates()
+ .tolist()
+ )
+
+ invalid_records: list[dict[str, str]] = []
+ widths: list[int | None] = [None] * len(manifest)
+ heights: list[int | None] = [None] * len(manifest)
+ if inspect_dimensions:
+ for position, path_text in enumerate(manifest["absolute_path"]):
+ path = Path(path_text)
+ if not path.is_file():
+ continue
+ width, height, error = _inspect_image(path)
+ widths[position] = width
+ heights[position] = height
+ if error:
+ invalid_records.append({"image_path": str(path), "error": error})
+ manifest["image_width"] = pd.array(widths, dtype="Int64")
+ manifest["image_height"] = pd.array(heights, dtype="Int64")
+
+ diagnostics = {
+ "dataset_root": str(Path(root).resolve()),
+ "image_base": str(image_base),
+ "image_count": int(len(manifest)),
+ "class_count": int(manifest["internal_label"].nunique()),
+ "official_train_count": int(manifest["is_training"].sum()),
+ "official_test_count": int((~manifest["is_training"]).sum()),
+ "missing_image_count": len(missing_paths),
+ "missing_images": missing_paths,
+ "duplicate_path_count": len(duplicate_paths),
+ "duplicate_paths": duplicate_paths,
+ "unreadable_image_count": len(invalid_records),
+ "unreadable_images": invalid_records,
+ "class_counts": {
+ str(key): int(value)
+ for key, value in manifest["class_name"].value_counts().sort_index().items()
+ },
+ }
+ if verify_images and missing_paths:
+ LOGGER.warning("%d referenced images are missing.", len(missing_paths))
+
+ columns = [
+ "image_id",
+ "relative_path",
+ "absolute_path",
+ "original_class_id",
+ "internal_label",
+ "class_name",
+ "is_training",
+ "split",
+ "image_width",
+ "image_height",
+ "bbox_x",
+ "bbox_y",
+ "bbox_width",
+ "bbox_height",
+ ]
+ return MetadataResult(manifest=manifest[columns], discovered_files=discovered, diagnostics=diagnostics)
+
+
+def build_and_save_manifest(
+ root: str | Path,
+ output_path: str | Path,
+ *,
+ inspect_dimensions: bool = False,
+) -> MetadataResult:
+ result = load_nabirds_metadata(
+ root,
+ verify_images=True,
+ inspect_dimensions=inspect_dimensions,
+ )
+ output_path = Path(output_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ result.manifest.to_csv(output_path, index=False)
+ return result
diff --git a/src/model.py b/src/model.py
new file mode 100644
index 0000000..b94bea5
--- /dev/null
+++ b/src/model.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any
+
+import torch
+from torch import nn
+from torchvision import models
+
+from .transforms import preprocessing_metadata
+
+
+SUPPORTED_ARCHITECTURES = {"efficientnet_b0", "resnet50", "mobilenet_v3_large"}
+
+
+def _weights_or_none(architecture: str, pretrained: bool):
+ if not pretrained:
+ return None
+ return {
+ "efficientnet_b0": models.EfficientNet_B0_Weights.DEFAULT,
+ "resnet50": models.ResNet50_Weights.DEFAULT,
+ "mobilenet_v3_large": models.MobileNet_V3_Large_Weights.DEFAULT,
+ }[architecture]
+
+
+def create_model(
+ architecture: str,
+ num_classes: int,
+ *,
+ pretrained: bool = True,
+ dropout: float = 0.2,
+ image_size: int = 224,
+) -> tuple[nn.Module, dict[str, Any]]:
+ if architecture not in SUPPORTED_ARCHITECTURES:
+ raise ValueError(f"Unsupported architecture: {architecture}")
+ if num_classes <= 1:
+ raise ValueError("num_classes must be at least 2")
+
+ weights = _weights_or_none(architecture, pretrained)
+ if architecture == "efficientnet_b0":
+ model = models.efficientnet_b0(weights=weights)
+ in_features = model.classifier[1].in_features
+ model.classifier = nn.Sequential(nn.Dropout(dropout), nn.Linear(in_features, num_classes))
+ elif architecture == "resnet50":
+ model = models.resnet50(weights=weights)
+ in_features = model.fc.in_features
+ model.fc = nn.Sequential(nn.Dropout(dropout), nn.Linear(in_features, num_classes))
+ else:
+ model = models.mobilenet_v3_large(weights=weights)
+ in_features = model.classifier[3].in_features
+ model.classifier[2] = nn.Dropout(dropout)
+ model.classifier[3] = nn.Linear(in_features, num_classes)
+
+ metadata = {
+ "architecture": architecture,
+ "pretrained": pretrained,
+ **preprocessing_metadata(image_size),
+ }
+ return model, metadata
+
+
+def classifier_parameters(model: nn.Module, architecture: str):
+ if architecture == "resnet50":
+ return model.fc.parameters()
+ return model.classifier.parameters()
+
+
+def freeze_backbone(model: nn.Module, architecture: str) -> None:
+ for parameter in model.parameters():
+ parameter.requires_grad = False
+ for parameter in classifier_parameters(model, architecture):
+ parameter.requires_grad = True
+
+
+def unfreeze_last_blocks(model: nn.Module, architecture: str, last_n_blocks: int) -> None:
+ freeze_backbone(model, architecture)
+ if last_n_blocks <= 0:
+ return
+ if architecture in {"efficientnet_b0", "mobilenet_v3_large"}:
+ blocks = list(model.features.children())
+ else:
+ blocks = [model.layer1, model.layer2, model.layer3, model.layer4]
+ for block in blocks[-last_n_blocks:]:
+ for parameter in block.parameters():
+ parameter.requires_grad = True
+
+
+def parameter_counts(model: nn.Module) -> dict[str, int]:
+ total = sum(parameter.numel() for parameter in model.parameters())
+ trainable = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
+ return {"trainable": trainable, "total": total}
diff --git a/src/predict.py b/src/predict.py
new file mode 100644
index 0000000..b44e5af
--- /dev/null
+++ b/src/predict.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import pandas as pd
+
+from .config import load_config
+from .inference import BirdPredictor
+from .utils import configure_logging
+
+
+EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
+
+
+def _record(path: Path, prediction: dict) -> dict:
+ return {
+ "image_path": str(path),
+ "predicted_class": prediction["predicted_class"],
+ "probability": prediction["probability"],
+ "uncertainty_status": prediction["uncertainty_status"],
+ "top_k_class_names": json.dumps(
+ [item["class_name"] for item in prediction["top_k"]], ensure_ascii=False
+ ),
+ "top_k_probabilities": json.dumps(
+ [item["probability"] for item in prediction["top_k"]]
+ ),
+ "entropy": prediction["entropy"],
+ "margin": prediction["margin"],
+ "warning": prediction["warning"],
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Predict NABirds species from local images.")
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument("--image", type=Path)
+ group.add_argument("--directory", type=Path)
+ parser.add_argument("--output", type=Path, default=Path("reports/my_predictions.csv"))
+ parser.add_argument("--failures-output", type=Path, default=Path("reports/prediction_failures.csv"))
+ parser.add_argument("--config", default="config/config.yaml")
+ parser.add_argument("--model", type=Path)
+ parser.add_argument("--device", default="auto")
+ parser.add_argument("--top-k", type=int, default=5)
+ args = parser.parse_args()
+ configure_logging()
+
+ config = load_config(args.config)
+ predictor = BirdPredictor(
+ args.model or config["app"]["model_path"],
+ config["app"]["metadata_path"],
+ device=args.device,
+ )
+ if args.image:
+ prediction = predictor.predict(args.image, top_k=args.top_k)
+ print(f"image: {args.image}")
+ print(f"predicted species: {prediction['predicted_class']}")
+ print(f"confidence: {prediction['probability']:.2%}")
+ print(f"uncertainty: {prediction['uncertainty_status']}")
+ print("top predictions:")
+ for item in prediction["top_k"]:
+ print(f" {item['class_name']}: {item['probability']:.2%}")
+ print(f"warning: {prediction['warning']}")
+ return
+
+ images = sorted(
+ path for path in args.directory.rglob("*") if path.is_file() and path.suffix.lower() in EXTENSIONS
+ )
+ records: list[dict] = []
+ failures: list[dict] = []
+ for path in images:
+ try:
+ records.append(_record(path, predictor.predict(path, top_k=args.top_k)))
+ except Exception as exc: # one bad personal image should not stop a batch
+ failures.append({"image_path": str(path), "error": str(exc)})
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ pd.DataFrame(records).to_csv(args.output, index=False)
+ if failures:
+ args.failures_output.parent.mkdir(parents=True, exist_ok=True)
+ pd.DataFrame(failures).to_csv(args.failures_output, index=False)
+ print(f"Processed {len(records)} images; {len(failures)} failed. Saved {args.output}.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/train.py b/src/train.py
new file mode 100644
index 0000000..ecf58d9
--- /dev/null
+++ b/src/train.py
@@ -0,0 +1,547 @@
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import math
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import torch
+from sklearn.metrics import accuracy_score, balanced_accuracy_score, f1_score
+from torch import nn
+from torch.optim import AdamW
+from torch.optim.lr_scheduler import CosineAnnealingLR
+from torch.utils.data import DataLoader, WeightedRandomSampler
+from tqdm import tqdm
+
+from .config import ProjectConfig, load_config
+from .dataset import NABirdsDataset
+from .metadata import build_and_save_manifest
+from .model import (
+ create_model,
+ freeze_backbone,
+ parameter_counts,
+ unfreeze_last_blocks,
+)
+from .transforms import build_eval_transform, build_train_transform
+from .utils import (
+ EpochTimer,
+ configure_logging,
+ package_versions,
+ save_json,
+ seed_everything,
+ select_device,
+)
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+def _load_manifest(config: ProjectConfig) -> pd.DataFrame:
+ manifest_path = config["data"]["manifest_path"]
+ if not manifest_path.exists():
+ result = build_and_save_manifest(config["data"]["root_dir"], manifest_path)
+ manifest = result.manifest
+ else:
+ manifest = pd.read_csv(manifest_path)
+ required = {
+ "image_id",
+ "absolute_path",
+ "internal_label",
+ "class_name",
+ "is_training",
+ }
+ missing = sorted(required.difference(manifest.columns))
+ if missing:
+ raise ValueError(f"Manifest is missing columns: {', '.join(missing)}")
+ manifest["is_training"] = manifest["is_training"].astype(str).str.lower().isin(
+ {"true", "1", "yes"}
+ )
+ return manifest
+
+
+def _limit_manifest(
+ manifest: pd.DataFrame,
+ *,
+ max_classes: int | None,
+ max_images_per_class: int | None,
+ seed: int,
+) -> pd.DataFrame:
+ output = manifest.copy()
+ if max_classes is not None:
+ class_ids = sorted(output["original_class_id"].unique())
+ rng = np.random.default_rng(seed)
+ selected = set(rng.choice(class_ids, size=min(max_classes, len(class_ids)), replace=False))
+ output = output[output["original_class_id"].isin(selected)]
+ if max_images_per_class is not None:
+ sampled_groups = [
+ frame.sample(
+ n=min(len(frame), max_images_per_class),
+ random_state=seed,
+ )
+ for _, frame in output.groupby("original_class_id", sort=True)
+ ]
+ output = pd.concat(sampled_groups, ignore_index=True)
+
+ class_ids = sorted(output["original_class_id"].unique())
+ mapping = {class_id: index for index, class_id in enumerate(class_ids)}
+ output["internal_label"] = output["original_class_id"].map(mapping).astype(int)
+ return output.reset_index(drop=True)
+
+
+def create_splits(
+ manifest: pd.DataFrame,
+ validation_fraction: float,
+ seed: int,
+) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+ official_train = manifest[manifest["is_training"]].copy()
+ test = manifest[~manifest["is_training"]].copy()
+ train_indices: list[int] = []
+ validation_indices: list[int] = []
+ rng = np.random.default_rng(seed)
+
+ for _, group in official_train.groupby("internal_label"):
+ indices = group.index.to_numpy().copy()
+ rng.shuffle(indices)
+ if len(indices) < 2:
+ LOGGER.warning(
+ "Class %s has only one official training image; it will have no validation sample.",
+ group["class_name"].iloc[0],
+ )
+ train_indices.extend(indices.tolist())
+ continue
+ validation_count = max(1, int(round(len(indices) * validation_fraction)))
+ validation_count = min(validation_count, len(indices) - 1)
+ validation_indices.extend(indices[:validation_count].tolist())
+ train_indices.extend(indices[validation_count:].tolist())
+
+ train = manifest.loc[train_indices].copy()
+ validation = manifest.loc[validation_indices].copy()
+ train["split"] = "train"
+ validation["split"] = "validation"
+ test["split"] = "test"
+
+ combined_ids = pd.concat([train["image_id"], validation["image_id"], test["image_id"]])
+ if len(combined_ids) != len(manifest) or combined_ids.duplicated().any():
+ raise RuntimeError("Split integrity check failed: every image must belong to exactly one split.")
+ if validation.empty:
+ raise ValueError("No validation images could be created from the official training split.")
+ return train.reset_index(drop=True), validation.reset_index(drop=True), test.reset_index(drop=True)
+
+
+def _save_splits(config: ProjectConfig, splits: tuple[pd.DataFrame, ...]) -> None:
+ names = ("train", "validation", "test")
+ processed = config["data"]["manifest_path"].parent
+ distributions: dict[str, dict[str, int]] = {}
+ for name, frame in zip(names, splits):
+ frame.to_csv(processed / f"{name}_manifest.csv", index=False)
+ distributions[name] = {
+ str(key): int(value)
+ for key, value in frame["class_name"].value_counts().sort_index().items()
+ }
+ save_json(config["project"]["reports_dir"] / "split_class_distributions.json", distributions)
+
+
+def _weighted_sampler(frame: pd.DataFrame) -> WeightedRandomSampler:
+ counts = frame["internal_label"].value_counts().to_dict()
+ weights = frame["internal_label"].map(lambda label: 1.0 / counts[label]).to_numpy()
+ return WeightedRandomSampler(torch.as_tensor(weights, dtype=torch.double), len(weights), True)
+
+
+def build_loaders(
+ config: ProjectConfig,
+ train: pd.DataFrame,
+ validation: pd.DataFrame,
+ test: pd.DataFrame,
+) -> tuple[DataLoader, DataLoader, DataLoader]:
+ data = config["data"]
+ training = config["training"]
+ train_dataset = NABirdsDataset(
+ train,
+ build_train_transform(data["image_size"]),
+ crop_to_bounding_box=data.get("crop_to_bounding_box", False),
+ )
+ eval_transform = build_eval_transform(data["image_size"])
+ validation_dataset = NABirdsDataset(
+ validation,
+ eval_transform,
+ crop_to_bounding_box=data.get("crop_to_bounding_box", False),
+ mode="validation",
+ )
+ test_dataset = NABirdsDataset(
+ test,
+ eval_transform,
+ crop_to_bounding_box=data.get("crop_to_bounding_box", False),
+ mode="test",
+ )
+ common = {
+ "batch_size": int(training["batch_size"]),
+ "num_workers": int(data.get("num_workers", 0)),
+ "pin_memory": bool(data.get("pin_memory", True) and torch.cuda.is_available()),
+ }
+ sampler = None
+ shuffle = True
+ if training.get("class_balancing") == "weighted_sampler":
+ sampler = _weighted_sampler(train)
+ shuffle = False
+ LOGGER.info("Class balancing active: weighted sampler using training labels only.")
+ return (
+ DataLoader(train_dataset, shuffle=shuffle, sampler=sampler, **common),
+ DataLoader(validation_dataset, shuffle=False, **common),
+ DataLoader(test_dataset, shuffle=False, **common),
+ )
+
+
+def _topk_correct(logits: torch.Tensor, labels: torch.Tensor, k: int) -> int:
+ k = min(k, logits.shape[1])
+ return int(logits.topk(k, dim=1).indices.eq(labels[:, None]).any(dim=1).sum().item())
+
+
+def run_epoch(
+ model: nn.Module,
+ loader: DataLoader,
+ criterion: nn.Module,
+ device: torch.device,
+ *,
+ optimizer: torch.optim.Optimizer | None = None,
+ scaler: torch.amp.GradScaler | None = None,
+ amp_enabled: bool = False,
+ gradient_clip_norm: float | None = None,
+) -> dict[str, float]:
+ training = optimizer is not None
+ model.train(training)
+ total_loss = 0.0
+ total = 0
+ top1 = 0
+ top5 = 0
+ all_labels: list[int] = []
+ all_predictions: list[int] = []
+
+ context = torch.enable_grad if training else torch.no_grad
+ with context():
+ for batch in tqdm(loader, leave=False):
+ images = batch["image"].to(device, non_blocking=True)
+ labels = batch["label"].to(device, non_blocking=True)
+ if training:
+ optimizer.zero_grad(set_to_none=True)
+ with torch.autocast(
+ device_type=device.type,
+ dtype=torch.float16,
+ enabled=amp_enabled,
+ ):
+ logits = model(images)
+ loss = criterion(logits, labels)
+ if training:
+ assert scaler is not None
+ scaler.scale(loss).backward()
+ if gradient_clip_norm:
+ scaler.unscale_(optimizer)
+ nn.utils.clip_grad_norm_(model.parameters(), gradient_clip_norm)
+ scaler.step(optimizer)
+ scaler.update()
+
+ batch_size = len(labels)
+ total_loss += float(loss.item()) * batch_size
+ total += batch_size
+ predictions = logits.argmax(dim=1)
+ top1 += int((predictions == labels).sum().item())
+ top5 += _topk_correct(logits, labels, 5)
+ all_labels.extend(labels.detach().cpu().tolist())
+ all_predictions.extend(predictions.detach().cpu().tolist())
+
+ return {
+ "loss": total_loss / max(total, 1),
+ "top1_accuracy": top1 / max(total, 1),
+ "top5_accuracy": top5 / max(total, 1),
+ "macro_f1": f1_score(all_labels, all_predictions, average="macro", zero_division=0),
+ "weighted_f1": f1_score(all_labels, all_predictions, average="weighted", zero_division=0),
+ "balanced_accuracy": balanced_accuracy_score(all_labels, all_predictions),
+ }
+
+
+def _checkpoint_payload(
+ model: nn.Module,
+ optimizer: torch.optim.Optimizer,
+ scheduler: Any,
+ *,
+ epoch: int,
+ stage: str,
+ best_metric: float,
+ class_names: list[str],
+ class_mapping: dict[int, str],
+ config: ProjectConfig,
+) -> dict[str, Any]:
+ return {
+ "model_state_dict": model.state_dict(),
+ "optimizer_state_dict": optimizer.state_dict(),
+ "scheduler_state_dict": scheduler.state_dict() if scheduler else None,
+ "epoch": epoch,
+ "stage": stage,
+ "best_validation_metric": best_metric,
+ "class_names": class_names,
+ "internal_label_mapping": class_mapping,
+ "num_classes": len(class_names),
+ "architecture": config["model"]["architecture"],
+ "preprocessing": {
+ "image_size": config["data"]["image_size"],
+ "crop_to_bounding_box": config["data"].get("crop_to_bounding_box", False),
+ },
+ "configuration": config.to_dict(stringify_paths=True),
+ "random_seed": config["project"]["random_seed"],
+ "dataset_manifest_path": str(config["data"]["manifest_path"]),
+ "package_versions": package_versions(),
+ }
+
+
+def train_stage(
+ model: nn.Module,
+ train_loader: DataLoader,
+ validation_loader: DataLoader,
+ criterion: nn.Module,
+ device: torch.device,
+ config: ProjectConfig,
+ *,
+ stage: str,
+ epochs: int,
+ learning_rate: float,
+ class_names: list[str],
+ class_mapping: dict[int, str],
+ best_metric: float,
+) -> float:
+ if epochs <= 0:
+ return best_metric
+ optimizer = AdamW(
+ (parameter for parameter in model.parameters() if parameter.requires_grad),
+ lr=learning_rate,
+ weight_decay=float(config["training"]["weight_decay"]),
+ )
+ scheduler = CosineAnnealingLR(optimizer, T_max=max(epochs, 1))
+ amp_enabled = bool(config["training"].get("use_amp", True) and device.type == "cuda")
+ scaler = torch.amp.GradScaler(device.type, enabled=amp_enabled)
+ LOGGER.info("AMP active: %s", amp_enabled)
+ patience = int(config["training"]["early_stopping_patience"])
+ stale_epochs = 0
+ timer = EpochTimer(epochs)
+ checkpoints = config["project"]["output_dir"] / "checkpoints"
+ checkpoints.mkdir(parents=True, exist_ok=True)
+
+ for epoch in range(1, epochs + 1):
+ train_metrics = run_epoch(
+ model,
+ train_loader,
+ criterion,
+ device,
+ optimizer=optimizer,
+ scaler=scaler,
+ amp_enabled=amp_enabled,
+ gradient_clip_norm=float(config["training"].get("gradient_clip_norm", 0)) or None,
+ )
+ validation_metrics = run_epoch(model, validation_loader, criterion, device)
+ scheduler.step()
+ elapsed, remaining = timer.mark_epoch()
+ metric = validation_metrics["macro_f1"]
+ payload = _checkpoint_payload(
+ model,
+ optimizer,
+ scheduler,
+ epoch=epoch,
+ stage=stage,
+ best_metric=max(best_metric, metric),
+ class_names=class_names,
+ class_mapping=class_mapping,
+ config=config,
+ )
+ payload["metrics"] = {"train": train_metrics, "validation": validation_metrics}
+ if config["training"].get("checkpoint_every_epoch", True):
+ torch.save(payload, checkpoints / f"{stage}_epoch_{epoch:03d}.pt")
+ global_epoch = epoch + (
+ int(config["training"]["head_epochs"]) if stage == "finetune" else 0
+ )
+ torch.save(payload, checkpoints / f"epoch_{global_epoch:03d}.pt")
+ torch.save(payload, checkpoints / "last.pt")
+ if metric > best_metric:
+ best_metric = metric
+ payload["best_validation_metric"] = best_metric
+ torch.save(payload, config["project"]["output_dir"] / "best_model.pt")
+ stale_epochs = 0
+ else:
+ stale_epochs += 1
+ LOGGER.info(
+ "%s epoch %d/%d | train loss %.4f | val top1 %.4f | val top5 %.4f | "
+ "val macro-F1 %.4f | lr %.2e | elapsed %.1fs | remaining %.1fs",
+ stage,
+ epoch,
+ epochs,
+ train_metrics["loss"],
+ validation_metrics["top1_accuracy"],
+ validation_metrics["top5_accuracy"],
+ validation_metrics["macro_f1"],
+ scheduler.get_last_lr()[0],
+ elapsed,
+ remaining,
+ )
+ if stale_epochs >= patience:
+ LOGGER.info("Early stopping after %d stale epochs.", stale_epochs)
+ break
+ return best_metric
+
+
+def run_training(args: argparse.Namespace) -> None:
+ config = load_config(args.config)
+ values = config.values
+ if args.batch_size:
+ values["training"]["batch_size"] = args.batch_size
+ if args.epochs is not None:
+ values["training"]["finetune_epochs"] = args.epochs
+ seed = int(config["project"]["random_seed"])
+ seed_everything(seed, bool(config["project"].get("deterministic", False)))
+ device = select_device(args.device)
+
+ manifest = _load_manifest(config)
+ max_classes = args.max_classes if args.max_classes is not None else config["data"].get("max_classes")
+ max_images = (
+ args.max_images_per_class
+ if args.max_images_per_class is not None
+ else config["data"].get("max_images_per_class")
+ )
+ manifest = _limit_manifest(
+ manifest,
+ max_classes=max_classes,
+ max_images_per_class=max_images,
+ seed=seed,
+ )
+ train, validation, test = create_splits(
+ manifest,
+ float(config["data"]["validation_fraction_from_train"]),
+ seed,
+ )
+ _save_splits(config, (train, validation, test))
+ train_loader, validation_loader, _ = build_loaders(config, train, validation, test)
+
+ label_rows = (
+ manifest[["internal_label", "class_name", "original_class_id"]]
+ .drop_duplicates("internal_label")
+ .sort_values("internal_label")
+ )
+ class_names = label_rows["class_name"].tolist()
+ class_mapping = {
+ int(row.internal_label): str(row.class_name) for row in label_rows.itertuples()
+ }
+ save_json(
+ config["project"]["output_dir"] / "class_metadata.json",
+ {
+ "class_names": class_names,
+ "internal_label_to_name": class_mapping,
+ "internal_label_to_original_class_id": {
+ int(row.internal_label): int(row.original_class_id)
+ for row in label_rows.itertuples()
+ },
+ },
+ )
+
+ model, preprocessing = create_model(
+ config["model"]["architecture"],
+ len(class_names),
+ pretrained=bool(config["model"]["pretrained"]),
+ dropout=float(config["model"]["dropout"]),
+ image_size=int(config["data"]["image_size"]),
+ )
+ model.to(device)
+ counts = parameter_counts(model)
+ LOGGER.info("Parameters: %d trainable / %d total", counts["trainable"], counts["total"])
+ LOGGER.info("Preprocessing: %s", json.dumps(preprocessing))
+
+ if args.resume:
+ checkpoint = torch.load(args.resume, map_location=device, weights_only=False)
+ model.load_state_dict(checkpoint["model_state_dict"])
+ LOGGER.info("Loaded model weights from %s", args.resume)
+
+ criterion = nn.CrossEntropyLoss(label_smoothing=float(config["model"]["label_smoothing"]))
+ if args.dry_run:
+ freeze_backbone(model, config["model"]["architecture"])
+ batch = next(iter(train_loader))
+ images = batch["image"].to(device)
+ labels = batch["label"].to(device)
+ optimizer = AdamW(
+ (parameter for parameter in model.parameters() if parameter.requires_grad),
+ lr=float(config["training"]["learning_rate_head"]),
+ )
+ optimizer.zero_grad()
+ logits = model(images)
+ loss = criterion(logits, labels)
+ loss.backward()
+ optimizer.step()
+ validation_batch = next(iter(validation_loader))
+ with torch.no_grad():
+ validation_logits = model(validation_batch["image"].to(device))
+ print(f"train images: {tuple(images.shape)}")
+ print(f"train labels: {tuple(labels.shape)}")
+ print(f"model output: {tuple(logits.shape)}")
+ print(f"validation output: {tuple(validation_logits.shape)}")
+ print("Dry run completed; no final model was saved.")
+ return
+
+ best_metric = -math.inf
+ if config["model"].get("freeze_backbone_initially", True):
+ freeze_backbone(model, config["model"]["architecture"])
+ best_metric = train_stage(
+ model,
+ train_loader,
+ validation_loader,
+ criterion,
+ device,
+ config,
+ stage="head",
+ epochs=int(config["training"]["head_epochs"]),
+ learning_rate=float(config["training"]["learning_rate_head"]),
+ class_names=class_names,
+ class_mapping=class_mapping,
+ best_metric=best_metric,
+ )
+
+ unfreeze_last_blocks(
+ model,
+ config["model"]["architecture"],
+ int(config["model"]["unfreeze_last_n_blocks"]),
+ )
+ counts = parameter_counts(model)
+ LOGGER.info("Fine-tuning parameters: %d trainable / %d total", counts["trainable"], counts["total"])
+ train_stage(
+ model,
+ train_loader,
+ validation_loader,
+ criterion,
+ device,
+ config,
+ stage="finetune",
+ epochs=int(config["training"]["finetune_epochs"]),
+ learning_rate=float(config["training"]["learning_rate_finetune"]),
+ class_names=class_names,
+ class_mapping=class_mapping,
+ best_metric=best_metric,
+ )
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Train a local NABirds classifier.")
+ parser.add_argument("--config", default="config/config.yaml")
+ parser.add_argument("--max-classes", type=int)
+ parser.add_argument("--max-images-per-class", type=int)
+ parser.add_argument("--batch-size", type=int)
+ parser.add_argument("--epochs", type=int)
+ parser.add_argument("--device", default="auto")
+ parser.add_argument("--resume", type=Path)
+ parser.add_argument("--dry-run", action="store_true")
+ return parser
+
+
+def main() -> None:
+ configure_logging()
+ run_training(build_parser().parse_args())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/transforms.py b/src/transforms.py
new file mode 100644
index 0000000..20147ab
--- /dev/null
+++ b/src/transforms.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from torchvision import transforms
+
+
+IMAGENET_MEAN = (0.485, 0.456, 0.406)
+IMAGENET_STD = (0.229, 0.224, 0.225)
+
+
+def build_train_transform(image_size: int = 224):
+ resize_size = max(256, int(round(image_size * 256 / 224)))
+ return transforms.Compose(
+ [
+ transforms.Resize(resize_size, antialias=True),
+ transforms.RandomResizedCrop(image_size, scale=(0.75, 1.0), ratio=(0.8, 1.25)),
+ transforms.RandomHorizontalFlip(),
+ transforms.RandomRotation(10),
+ transforms.ColorJitter(
+ brightness=0.15,
+ contrast=0.15,
+ saturation=0.12,
+ hue=0.02,
+ ),
+ transforms.RandomPerspective(distortion_scale=0.1, p=0.1),
+ transforms.ToTensor(),
+ transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
+ ]
+ )
+
+
+def build_eval_transform(image_size: int = 224):
+ resize_size = max(256, int(round(image_size * 256 / 224)))
+ return transforms.Compose(
+ [
+ transforms.Resize(resize_size, antialias=True),
+ transforms.CenterCrop(image_size),
+ transforms.ToTensor(),
+ transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
+ ]
+ )
+
+
+def preprocessing_metadata(image_size: int) -> dict[str, object]:
+ return {
+ "image_size": image_size,
+ "resize_shorter_side": max(256, int(round(image_size * 256 / 224))),
+ "mean": IMAGENET_MEAN,
+ "std": IMAGENET_STD,
+ "color_mode": "RGB",
+ }
diff --git a/src/utils.py b/src/utils.py
new file mode 100644
index 0000000..0eee893
--- /dev/null
+++ b/src/utils.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import platform
+import random
+import time
+from importlib import metadata as importlib_metadata
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+def configure_logging(level: int = logging.INFO) -> None:
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
+ )
+
+
+def seed_everything(seed: int, deterministic: bool = False) -> None:
+ random.seed(seed)
+ os.environ["PYTHONHASHSEED"] = str(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed(seed)
+ torch.cuda.manual_seed_all(seed)
+ torch.backends.cudnn.deterministic = deterministic
+ torch.backends.cudnn.benchmark = not deterministic
+ if deterministic:
+ torch.use_deterministic_algorithms(True, warn_only=True)
+ LOGGER.warning("Deterministic training is enabled and can be slower.")
+
+
+def select_device(requested: str | None = None) -> torch.device:
+ requested = (requested or "auto").lower()
+ if requested == "auto":
+ requested = "cuda" if torch.cuda.is_available() else "cpu"
+ if requested.startswith("cuda") and not torch.cuda.is_available():
+ LOGGER.warning("CUDA was requested but is unavailable; falling back to CPU.")
+ requested = "cpu"
+ device = torch.device(requested)
+ LOGGER.info("Selected device: %s", device)
+ if device.type == "cuda":
+ props = torch.cuda.get_device_properties(device)
+ LOGGER.info(
+ "CUDA device: %s (%.2f GB)",
+ props.name,
+ props.total_memory / (1024**3),
+ )
+ return device
+
+
+def device_summary(device: torch.device | None = None) -> dict[str, Any]:
+ device = device or select_device()
+ summary: dict[str, Any] = {
+ "device": str(device),
+ "cuda_available": torch.cuda.is_available(),
+ "platform": platform.platform(),
+ }
+ if torch.cuda.is_available():
+ summary["cuda_device_name"] = torch.cuda.get_device_name(0)
+ summary["cuda_device_memory_gb"] = round(
+ torch.cuda.get_device_properties(0).total_memory / (1024**3), 2
+ )
+ return summary
+
+
+def package_versions() -> dict[str, str]:
+ packages = [
+ "torch",
+ "torchvision",
+ "pandas",
+ "numpy",
+ "Pillow",
+ "scikit-learn",
+ ]
+ versions = {"python": platform.python_version()}
+ for package in packages:
+ try:
+ versions[package] = importlib_metadata.version(package)
+ except importlib_metadata.PackageNotFoundError:
+ versions[package] = "not-installed"
+ return versions
+
+
+def save_json(path: str | Path, payload: Any) -> None:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, ensure_ascii=False, default=str)
+ temporary.replace(path)
+
+
+class EpochTimer:
+ def __init__(self, total_epochs: int) -> None:
+ self.total_epochs = total_epochs
+ self.started = time.perf_counter()
+ self.completed = 0
+
+ def mark_epoch(self) -> tuple[float, float]:
+ self.completed += 1
+ elapsed = time.perf_counter() - self.started
+ remaining = (elapsed / self.completed) * max(self.total_epochs - self.completed, 0)
+ return elapsed, remaining
diff --git a/src/visualize.py b/src/visualize.py
new file mode 100644
index 0000000..2673ac8
--- /dev/null
+++ b/src/visualize.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Sequence
+
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+import seaborn as sns
+from sklearn.calibration import calibration_curve
+from sklearn.metrics import confusion_matrix
+
+
+def save_class_distribution(counts: pd.Series, path: str | Path) -> None:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig, ax = plt.subplots(figsize=(10, 5))
+ ax.hist(counts.to_numpy(), bins=min(50, max(5, len(counts) // 5)))
+ ax.set(title="NABirds images per class", xlabel="Images", ylabel="Classes")
+ fig.tight_layout()
+ fig.savefig(path, dpi=160)
+ plt.close(fig)
+
+
+def save_confusion_views(
+ y_true: Sequence[int],
+ y_pred: Sequence[int],
+ class_names: Sequence[str],
+ path: str | Path,
+ *,
+ max_classes: int = 40,
+) -> None:
+ labels = np.arange(len(class_names))
+ matrix = confusion_matrix(y_true, y_pred, labels=labels, normalize="true")
+ supports = np.bincount(np.asarray(y_true), minlength=len(class_names))
+ selected = np.argsort(supports)[::-1][: min(max_classes, len(class_names))]
+ reduced = matrix[np.ix_(selected, selected)]
+ names = [class_names[index] for index in selected]
+
+ width = max(10, min(20, len(names) * 0.45))
+ fig, ax = plt.subplots(figsize=(width, width))
+ sns.heatmap(
+ reduced,
+ cmap="mako",
+ vmin=0,
+ vmax=1,
+ xticklabels=names,
+ yticklabels=names,
+ ax=ax,
+ )
+ ax.set(
+ title=f"Normalized confusion matrix ({len(names)} most-supported classes)",
+ xlabel="Predicted",
+ ylabel="True",
+ )
+ fig.tight_layout()
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(path, dpi=160)
+ plt.close(fig)
+
+
+def save_per_class_f1(report: pd.DataFrame, path: str | Path, limit: int = 60) -> None:
+ view = report.nsmallest(min(limit, len(report)), "f1-score").sort_values("f1-score")
+ fig, ax = plt.subplots(figsize=(10, max(5, len(view) * 0.22)))
+ ax.barh(view["class_name"], view["f1-score"], color="#3d7a57")
+ ax.set(xlim=(0, 1), title="Lowest per-class F1 scores", xlabel="F1")
+ fig.tight_layout()
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(path, dpi=160)
+ plt.close(fig)
+
+
+def save_calibration_curve(
+ y_true: Sequence[int],
+ probabilities: np.ndarray,
+ path: str | Path,
+) -> None:
+ confidence = probabilities.max(axis=1)
+ correct = (probabilities.argmax(axis=1) == np.asarray(y_true)).astype(int)
+ observed, predicted = calibration_curve(correct, confidence, n_bins=12, strategy="uniform")
+ fig, ax = plt.subplots(figsize=(6, 6))
+ ax.plot([0, 1], [0, 1], "--", color="gray", label="Perfect calibration")
+ ax.plot(predicted, observed, marker="o", label="Model")
+ ax.set(
+ xlim=(0, 1),
+ ylim=(0, 1),
+ xlabel="Mean confidence",
+ ylabel="Observed accuracy",
+ title="Reliability curve",
+ )
+ ax.legend()
+ fig.tight_layout()
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig.savefig(path, dpi=160)
+ plt.close(fig)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..4b35d91
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture()
+def synthetic_nabirds(tmp_path: Path) -> Path:
+ """A tiny generated fixture; it is synthetic test data, not NABirds."""
+ Image = pytest.importorskip("PIL.Image")
+ root = tmp_path / "synthetic NABirds"
+ image_dir = root / "nested metadata" / "images"
+ image_dir.mkdir(parents=True)
+ records = [
+ ("img1", "images/robin one.jpg", 10, 1, "RGB"),
+ ("img2", "images/robin two.jpg", 10, 0, "L"),
+ ("img3", "images/finch one.png", 20, 1, "RGBA"),
+ ("img4", "images/finch two.jpg", 20, 0, "RGB"),
+ ]
+ colors = [(180, 45, 35), 120, (70, 150, 80, 255), (160, 130, 50)]
+ for record, color in zip(records, colors):
+ _, relative, _, _, mode = record
+ image = Image.new(mode, (40, 30), color=color)
+ image.save(root / "nested metadata" / relative)
+
+ metadata = root / "nested metadata"
+ (metadata / "images.txt").write_text(
+ "\n".join(f"{image_id} {relative}" for image_id, relative, *_ in records),
+ encoding="utf-8",
+ )
+ (metadata / "image_class_labels.txt").write_text(
+ "\n".join(f"{image_id} {class_id}" for image_id, _, class_id, *_ in records),
+ encoding="utf-8",
+ )
+ (metadata / "train_test_split.txt").write_text(
+ "\n".join(f"{image_id} {is_train}" for image_id, _, _, is_train, _ in records),
+ encoding="utf-8",
+ )
+ (metadata / "classes.txt").write_text(
+ "10 Synthetic American Robin\n20 Synthetic House Finch\n",
+ encoding="utf-8",
+ )
+ (metadata / "bounding_boxes.txt").write_text(
+ "\n".join(f"{image_id} -5 -4 100 80" for image_id, *_ in records),
+ encoding="utf-8",
+ )
+ return root
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
new file mode 100644
index 0000000..d640d76
--- /dev/null
+++ b/tests/test_dataset.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+torch = pytest.importorskip("torch")
+pytest.importorskip("torchvision")
+
+from src.dataset import NABirdsDataset
+from src.metadata import load_nabirds_metadata
+from src.transforms import build_eval_transform
+
+
+def test_dataset_returns_finite_rgb_tensor(synthetic_nabirds: Path) -> None:
+ manifest = load_nabirds_metadata(synthetic_nabirds).manifest
+ dataset = NABirdsDataset(manifest, build_eval_transform(224))
+ sample = dataset[1] # grayscale source exercises RGB conversion
+ assert sample["image"].shape == (3, 224, 224)
+ assert sample["label"].dtype == torch.long
+ assert torch.isfinite(sample["image"]).all()
+ assert isinstance(sample["path"], str)
+
+
+def test_bbox_crop_is_clipped_to_image(synthetic_nabirds: Path) -> None:
+ manifest = load_nabirds_metadata(synthetic_nabirds).manifest
+ dataset = NABirdsDataset(
+ manifest,
+ build_eval_transform(224),
+ crop_to_bounding_box=True,
+ )
+ assert dataset[0]["image"].shape == (3, 224, 224)
diff --git a/tests/test_inference.py b/tests/test_inference.py
new file mode 100644
index 0000000..e008681
--- /dev/null
+++ b/tests/test_inference.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+torch = pytest.importorskip("torch")
+pytest.importorskip("torchvision")
+Image = pytest.importorskip("PIL.Image")
+
+from src.inference import BirdPredictor
+from src.model import create_model
+
+
+@pytest.fixture()
+def synthetic_checkpoint(tmp_path: Path) -> tuple[Path, Path]:
+ """Synthetic random weights used only to test software behavior."""
+ model, _ = create_model("efficientnet_b0", 3, pretrained=False, image_size=64)
+ checkpoint = {
+ "model_state_dict": model.state_dict(),
+ "architecture": "efficientnet_b0",
+ "num_classes": 3,
+ "class_names": ["Synthetic Robin", "Synthetic Finch", "Synthetic Sparrow"],
+ "preprocessing": {"image_size": 64},
+ "configuration": {
+ "model": {"dropout": 0.2},
+ "evaluation": {"unknown_threshold": 0.45},
+ },
+ }
+ checkpoint_path = tmp_path / "synthetic_model.pt"
+ torch.save(checkpoint, checkpoint_path)
+ metadata_path = tmp_path / "class_metadata.json"
+ metadata_path.write_text(
+ json.dumps({"class_names": checkpoint["class_names"]}),
+ encoding="utf-8",
+ )
+ return checkpoint_path, metadata_path
+
+
+def test_predictor_returns_top_k_and_uncertainty(
+ synthetic_checkpoint: tuple[Path, Path],
+) -> None:
+ checkpoint_path, metadata_path = synthetic_checkpoint
+ predictor = BirdPredictor(checkpoint_path, metadata_path, device="cpu")
+ image = Image.new("RGB", (80, 70), color=(110, 80, 40))
+ result = predictor.predict(image, top_k=3)
+ assert len(result["top_k"]) == 3
+ assert sum(item["probability"] for item in result["top_k"]) == pytest.approx(1.0, abs=1e-5)
+ assert result["uncertainty_status"] in {
+ "confident_prediction",
+ "uncertain_prediction",
+ "possibly_unknown",
+ }
+
+
+def test_invalid_image_error_is_understandable(
+ synthetic_checkpoint: tuple[Path, Path],
+ tmp_path: Path,
+) -> None:
+ checkpoint_path, metadata_path = synthetic_checkpoint
+ predictor = BirdPredictor(checkpoint_path, metadata_path, device="cpu")
+ invalid = tmp_path / "not-an-image.jpg"
+ invalid.write_text("synthetic invalid image fixture", encoding="utf-8")
+ with pytest.raises(ValueError, match="Unable to read image"):
+ predictor.predict(invalid)
diff --git a/tests/test_metadata.py b/tests/test_metadata.py
new file mode 100644
index 0000000..af8edaf
--- /dev/null
+++ b/tests/test_metadata.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+pd = pytest.importorskip("pandas")
+
+from src.metadata import find_metadata_files, load_nabirds_metadata
+
+
+def test_metadata_files_are_located_recursively(synthetic_nabirds: Path) -> None:
+ files = find_metadata_files(synthetic_nabirds)
+ assert set(files) >= {
+ "images.txt",
+ "image_class_labels.txt",
+ "train_test_split.txt",
+ "classes.txt",
+ }
+
+
+def test_metadata_joins_and_labels_are_zero_based(synthetic_nabirds: Path) -> None:
+ result = load_nabirds_metadata(synthetic_nabirds)
+ assert len(result.manifest) == 4
+ assert sorted(result.manifest["internal_label"].unique().tolist()) == [0, 1]
+ assert result.diagnostics["class_count"] == 2
+ assert result.manifest["class_name"].str.startswith("Synthetic").all()
+ assert result.diagnostics["missing_image_count"] == 0
+
+
+def test_missing_paths_are_reported(synthetic_nabirds: Path) -> None:
+ missing = synthetic_nabirds / "nested metadata" / "images" / "robin one.jpg"
+ missing.unlink()
+ result = load_nabirds_metadata(synthetic_nabirds)
+ assert result.diagnostics["missing_image_count"] == 1
+ assert str(missing.resolve()) in result.diagnostics["missing_images"]
+
+
+def test_duplicate_paths_are_detected(synthetic_nabirds: Path) -> None:
+ metadata = synthetic_nabirds / "nested metadata" / "images.txt"
+ text = metadata.read_text(encoding="utf-8").replace(
+ "img2 images/robin two.jpg", "img2 images/robin one.jpg"
+ )
+ metadata.write_text(text, encoding="utf-8")
+ result = load_nabirds_metadata(synthetic_nabirds)
+ assert result.diagnostics["duplicate_path_count"] == 1
diff --git a/tests/test_model.py b/tests/test_model.py
new file mode 100644
index 0000000..3fa01f0
--- /dev/null
+++ b/tests/test_model.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import pytest
+
+torch = pytest.importorskip("torch")
+pytest.importorskip("torchvision")
+
+from src.model import (
+ create_model,
+ freeze_backbone,
+ parameter_counts,
+ unfreeze_last_blocks,
+)
+
+
+def test_model_factory_replaces_classifier() -> None:
+ model, metadata = create_model(
+ "efficientnet_b0",
+ 3,
+ pretrained=False,
+ image_size=64,
+ )
+ output = model(torch.randn(2, 3, 64, 64))
+ assert output.shape == (2, 3)
+ assert metadata["architecture"] == "efficientnet_b0"
+
+
+def test_freeze_and_partial_unfreeze_change_trainable_count() -> None:
+ model, _ = create_model("mobilenet_v3_large", 4, pretrained=False)
+ freeze_backbone(model, "mobilenet_v3_large")
+ frozen = parameter_counts(model)
+ assert frozen["trainable"] < frozen["total"]
+ assert all(parameter.requires_grad for parameter in model.classifier.parameters())
+ unfreeze_last_blocks(model, "mobilenet_v3_large", 2)
+ fine_tuned = parameter_counts(model)
+ assert fine_tuned["trainable"] > frozen["trainable"]
diff --git a/web/.nojekyll b/web/.nojekyll
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/web/.nojekyll
@@ -0,0 +1 @@
+
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..be3b05b
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,224 @@
+
+
+
+
+
+
+
+
+ Fieldmark · Private bird identification
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+
+
+
+
+
+
+
Live neural network · no photo uploads
+
Field intelligence, running on your device
+
Every field mark , considered.
+
A fine-grained vision model trained on 48,562 NABirds photographs to recognize 555 North American bird classes — and show you when it is not sure.
+
+
+
+
+
+
+
+
0 bird classes
+
0 training photographs
+
0 top-5 test accuracy right class appears among five guesses
+
0 calibration error
+
+
+
+
+
+ From photograph to shortlist
+ A serious model in a static page.
+ GitHub Pages serves the interface and an 18 MB ONNX model. Your browser performs every calculation locally.
+
+
+
01 Decode Read the image in local browser memory.
+
02 Prepare Resize, center-crop, and normalize to 224 × 224.
+
03 Recognize Run EfficientNet-B0 across all 555 learned classes.
+
04 Calibrate Return ranked probabilities and an uncertainty verdict.
+
+
+
+
Your photo stays yours. It is never posted to GitHub, an API, or an analytics service. Closing the tab clears the in-memory prediction session.
+
+
+
+
+
+
Read the numbers honestly
+
On the untouched 24,633-image test split, the exact class was the first guess 52.0% of the time, inside the top three 71.1% of the time, and inside the top five 78.0% of the time. Top-5 is not a 78% guarantee for one photo; it means the correct answer appeared somewhere in the five-name shortlist across the full test set.
+
+
+
+
+
+
+
+ Field guide
+ Give the model a fair look.
+ Fine-grained identification depends on details: bill shape, eye rings, wing bars, posture, and plumage.
+
+
+ 01 Frame one bird Use a focused photo where one bird fills a useful part of the image. Side and three-quarter views expose more field marks.
+ 02 Upload privately Drop JPEG, PNG, or WebP files on the Identify tab. Batch them if you like; each stays in your browser.
+ 03 Read the badge Green is the strongest verdict. Amber means compare alternatives. Red means unsupported species, an unusual image, or low confidence.
+ 04 Use the shortlist Look-alikes are hard. Treat the ranked alternatives as a birder’s shortlist, not five independent guesses.
+
+
+
+
+
Photograph do’s and don’ts
+
+
One bird, reasonably large in frame Natural light with plumage in focus Side or three-quarter profile Original image with minimal overlays
+
Flocks with several possible subjects Silhouettes or severe backlighting Distant birds occupying very few pixels UI screenshots, watermarks, or heavy filters
+
+
+
+
+
+
+
Alternatives Choose three, five, or ten ranked candidates. Five balances useful context and readability.
+
Confidence threshold Below this probability, Fieldmark flags a result as possibly unknown. It is a caution threshold, not an open-set detector.
+
Technical details Expose entropy, top-two margin, calibration temperature, runtime, and inference time for a deeper read.
+
Closed-set model The network must choose among the 555 classes it learned. Geography, season, age, and sex are not separate inputs.
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop bird photographs here
+
or browse your files · JPEG, PNG, WebP · up to 20 MB
+
+
+
+
+
+ Settings
+ Alternatives Top 3 Top 5 Top 10
+ Confidence threshold 0.45
+ Show technical details
+
+
Identify
+
+
+
+
+
+
+
No sightings yet Upload a photograph and its calibrated shortlist will appear here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Technical details
+
+
+
+
+
+
+
+
+
diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest
new file mode 100644
index 0000000..f6998b4
--- /dev/null
+++ b/web/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Fieldmark bird identification",
+ "short_name": "Fieldmark",
+ "description": "Private, in-browser recognition for 555 North American bird classes.",
+ "start_url": "./#identify",
+ "display": "standalone",
+ "background_color": "#f6f3ea",
+ "theme_color": "#2c6a47",
+ "icons": [
+ {
+ "src": "./static/images/fieldmark.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/web/model/fieldmark.onnx b/web/model/fieldmark.onnx
new file mode 100644
index 0000000..6c87754
Binary files /dev/null and b/web/model/fieldmark.onnx differ
diff --git a/web/model/metadata.json b/web/model/metadata.json
new file mode 100644
index 0000000..ff6b61e
--- /dev/null
+++ b/web/model/metadata.json
@@ -0,0 +1,1175 @@
+{
+ "format_version": 1,
+ "name": "Fieldmark NABirds classifier",
+ "architecture": "efficientnet_b0",
+ "class_count": 555,
+ "class_names": [
+ "Common Eider (Adult male)",
+ "Long-tailed Duck (Winter male)",
+ "Ruddy Duck (Breeding male)",
+ "Swainson's Hawk (Dark morph )",
+ "Red-tailed Hawk (Light morph adult)",
+ "Snow Goose (White morph)",
+ "Wood Duck (Breeding male)",
+ "Gadwall (Breeding male)",
+ "American Wigeon (Breeding male)",
+ "Mallard (Breeding male)",
+ "Blue-winged Teal (Male)",
+ "Cinnamon Teal (Male)",
+ "Northern Shoveler (Breeding male)",
+ "Northern Pintail (Breeding male)",
+ "Green-winged Teal (Male)",
+ "Canvasback (Breeding male)",
+ "Redhead (Breeding male)",
+ "Ring-necked Duck (Breeding male)",
+ "Greater Scaup (Breeding male)",
+ "Lesser Scaup (Breeding male)",
+ "Harlequin Duck (Male)",
+ "Surf Scoter (Male)",
+ "White-winged Scoter (Male)",
+ "Black Scoter (Male)",
+ "Bufflehead (Breeding male)",
+ "Common Goldeneye (Breeding male)",
+ "Barrow's Goldeneye (Breeding male)",
+ "Hooded Merganser (Breeding male)",
+ "Common Merganser (Breeding male)",
+ "Red-breasted Merganser (Breeding male)",
+ "California Quail (Male)",
+ "Gambel's Quail (Male)",
+ "Ring-necked Pheasant (Male)",
+ "Red-throated Loon (Breeding)",
+ "Pacific Loon (Breeding)",
+ "Common Loon (Breeding)",
+ "Horned Grebe (Breeding)",
+ "Red-necked Grebe (Breeding)",
+ "Eared Grebe (Breeding)",
+ "Northern Gannet (Adult, Subadult)",
+ "Double-crested Cormorant (Immature)",
+ "Great Cormorant (Adult)",
+ "Little Blue Heron (Adult)",
+ "Reddish Egret (Dark morph)",
+ "Black-crowned Night-Heron (Adult)",
+ "Yellow-crowned Night-Heron (Adult)",
+ "White Ibis (Adult)",
+ "Bald Eagle (Adult, subadult)",
+ "Northern Harrier (Adult male)",
+ "Sharp-shinned Hawk (Adult )",
+ "Cooper's Hawk (Adult)",
+ "Red-shouldered Hawk (Adult )",
+ "Broad-winged Hawk (Adult)",
+ "Rough-legged Hawk (Dark morph)",
+ "Golden Eagle (Adult)",
+ "American Kestrel (Adult male)",
+ "Peregrine Falcon (Adult)",
+ "Purple Gallinule (Adult)",
+ "Common Gallinule (Adult)",
+ "Black-bellied Plover (Breeding)",
+ "Spotted Sandpiper (Breeding)",
+ "Sanderling (Breeding)",
+ "Dunlin (Breeding)",
+ "Wilson's Phalarope (Breeding)",
+ "Broad-billed Hummingbird (Adult Male)",
+ "Ruby-throated Hummingbird (Adult Male)",
+ "Black-chinned Hummingbird (Adult Male)",
+ "Anna's Hummingbird (Adult Male)",
+ "Costa's Hummingbird (Adult Male)",
+ "Calliope Hummingbird (Adult Male)",
+ "Broad-tailed Hummingbird (Adult Male)",
+ "Rufous Hummingbird (Adult Male)",
+ "Allen's Hummingbird (Adult Male)",
+ "Red-headed Woodpecker (Adult)",
+ "Northern Flicker (Yellow-shafted)",
+ "Black Guillemot (Breeding)",
+ "Pigeon Guillemot (Breeding)",
+ "Black-legged Kittiwake (Adult)",
+ "Laughing Gull (Breeding)",
+ "Heermann's Gull (Adult)",
+ "Ring-billed Gull (Adult)",
+ "Western Gull (Adult)",
+ "California Gull (Adult)",
+ "Herring Gull (Adult)",
+ "Glaucous-winged Gull (Adult)",
+ "Great Black-backed Gull (Adult)",
+ "Bonaparte's Gull",
+ "Mew Gull",
+ "Caspian Tern",
+ "Black Tern",
+ "Common Tern",
+ "Forster's Tern",
+ "Royal Tern",
+ "Black Skimmer",
+ "Greater White-fronted Goose",
+ "Brant",
+ "Cackling Goose",
+ "Canada Goose",
+ "Mute Swan",
+ "Trumpeter Swan",
+ "Tundra Swan",
+ "American Black Duck",
+ "Mottled Duck",
+ "Common Eider (Female/juvenile)",
+ "Long-tailed Duck (Summer male)",
+ "Ruddy Duck (Winter male)",
+ "Scaled Quail",
+ "Northern Bobwhite",
+ "Ruffed Grouse",
+ "Wild Turkey",
+ "Pied-billed Grebe",
+ "Western Grebe",
+ "Clark's Grebe",
+ "Wood Stork",
+ "Brandt's Cormorant",
+ "Neotropic Cormorant",
+ "Pelagic Cormorant",
+ "Anhinga",
+ "American White Pelican",
+ "Brown Pelican",
+ "Great Blue Heron",
+ "Great Egret",
+ "Snowy Egret",
+ "Tricolored Heron",
+ "Cattle Egret",
+ "Green Heron",
+ "Glossy Ibis",
+ "White-faced Ibis",
+ "Roseate Spoonbill",
+ "Black Vulture",
+ "Turkey Vulture",
+ "Osprey",
+ "Swallow-tailed Kite",
+ "White-tailed Kite",
+ "Mississippi Kite",
+ "Harris's Hawk",
+ "Swainson's Hawk (Light morph )",
+ "Red-tailed Hawk (Dark morph)",
+ "Crested Caracara",
+ "Merlin",
+ "Prairie Falcon",
+ "American Coot",
+ "Sandhill Crane",
+ "Semipalmated Plover",
+ "Killdeer",
+ "American Oystercatcher",
+ "Black Oystercatcher",
+ "Black-necked Stilt",
+ "American Avocet",
+ "Solitary Sandpiper",
+ "Greater Yellowlegs",
+ "Willet",
+ "Lesser Yellowlegs",
+ "Whimbrel",
+ "Long-billed Curlew",
+ "Marbled Godwit",
+ "Ruddy Turnstone",
+ "Black Turnstone",
+ "Surfbird",
+ "Semipalmated Sandpiper",
+ "Western Sandpiper",
+ "Least Sandpiper",
+ "Short-billed Dowitcher",
+ "Wilson's Snipe",
+ "American Woodcock",
+ "Rock Pigeon",
+ "Band-tailed Pigeon",
+ "Eurasian Collared-Dove",
+ "White-winged Dove",
+ "Mourning Dove",
+ "Inca Dove",
+ "Common Ground-Dove",
+ "Monk Parakeet",
+ "Yellow-billed Cuckoo",
+ "Black-billed Cuckoo",
+ "Greater Roadrunner",
+ "Barn Owl",
+ "Western Screech-Owl",
+ "Eastern Screech-Owl",
+ "Great Horned Owl",
+ "Snowy Owl",
+ "Northern Pygmy-Owl",
+ "Burrowing Owl",
+ "Barred Owl",
+ "Northern Saw-whet Owl",
+ "Common Nighthawk",
+ "Chimney Swift",
+ "Vaux's Swift",
+ "White-throated Swift",
+ "Belted Kingfisher",
+ "Acorn Woodpecker",
+ "Gila Woodpecker",
+ "Golden-fronted Woodpecker",
+ "Red-bellied Woodpecker",
+ "Yellow-bellied Sapsucker",
+ "Red-naped Sapsucker",
+ "Red-breasted Sapsucker",
+ "Ladder-backed Woodpecker",
+ "Nuttall's Woodpecker",
+ "Downy Woodpecker",
+ "Hairy Woodpecker",
+ "Pileated Woodpecker",
+ "Black Guillemot (Nonbreeding, juvenile)",
+ "Pigeon Guillemot (Nonbreeding, juvenile)",
+ "Black-legged Kittiwake (Immature)",
+ "Laughing Gull (Nonbreeding/Immature)",
+ "Heermann's Gull (Immature)",
+ "Ring-billed Gull (Immature)",
+ "Western Gull (Immature)",
+ "California Gull (Immature)",
+ "Herring Gull (Immature)",
+ "Glaucous-winged Gull (Immature)",
+ "Great Black-backed Gull (Immature)",
+ "Black-bellied Whistling-Duck",
+ "Snow Goose (Blue morph)",
+ "Ross's Goose",
+ "Wood Duck (Female/Eclipse male)",
+ "Gadwall (Female/Eclipse male)",
+ "American Wigeon (Female/Eclipse male)",
+ "Mallard (Female/Eclipse male)",
+ "Blue-winged Teal (Female/juvenile)",
+ "Cinnamon Teal (Female/juvenile)",
+ "Northern Shoveler (Female/Eclipse male)",
+ "Northern Pintail (Female/Eclipse male)",
+ "Green-winged Teal (Female/juvenile)",
+ "Canvasback (Female/Eclipse male)",
+ "Redhead (Female/Eclipse male)",
+ "Ring-necked Duck (Female/Eclipse male)",
+ "Greater Scaup (Female/Eclipse male)",
+ "Lesser Scaup (Female/Eclipse male)",
+ "Harlequin Duck (Female/juvenile)",
+ "Surf Scoter (Female/immature)",
+ "White-winged Scoter (Female/juvenile)",
+ "Black Scoter (Female/juvenile)",
+ "Bufflehead (Female/immature male)",
+ "Common Goldeneye (Female/Eclipse male)",
+ "Barrow's Goldeneye (Female/Eclipse male)",
+ "Hooded Merganser (Female/immature male)",
+ "Common Merganser (Female/immature male)",
+ "Red-breasted Merganser (Female/immature male)",
+ "California Quail (Female/juvenile)",
+ "Gambel's Quail (Female/juvenile)",
+ "Ring-necked Pheasant (Female/juvenile)",
+ "Red-throated Loon (Nonbreeding/juvenile)",
+ "Pacific Loon (Nonbreeding/juvenile)",
+ "Common Loon (Nonbreeding/juvenile)",
+ "Horned Grebe (Nonbreeding/juvenile)",
+ "Red-necked Grebe (Nonbreeding/juvenile)",
+ "Eared Grebe (Nonbreeding/juvenile)",
+ "Northern Gannet (Immature/Juvenile)",
+ "Double-crested Cormorant (Adult)",
+ "Great Cormorant (Immature)",
+ "Little Blue Heron (Immature)",
+ "Reddish Egret (White morph)",
+ "Black-crowned Night-Heron (Immature)",
+ "Yellow-crowned Night-Heron (Immature)",
+ "White Ibis (Immature)",
+ "Bald Eagle (Immature, juvenile)",
+ "Northern Harrier (Female, immature)",
+ "Sharp-shinned Hawk (Immature)",
+ "Cooper's Hawk (Immature)",
+ "Red-shouldered Hawk (Immature)",
+ "Broad-winged Hawk (Immature)",
+ "Rough-legged Hawk (Light morph)",
+ "Golden Eagle (Immature)",
+ "American Kestrel (Female, immature)",
+ "Peregrine Falcon (Immature)",
+ "Purple Gallinule (Immature)",
+ "Common Gallinule (Immature)",
+ "Black-bellied Plover (Nonbreeding/juvenile)",
+ "Spotted Sandpiper (Nonbreeding/juvenile)",
+ "Sanderling (Nonbreeding/juvenile)",
+ "Dunlin (Nonbreeding/juvenile)",
+ "Wilson's Phalarope (Nonbreeding, juvenile)",
+ "Broad-billed Hummingbird (Female, immature)",
+ "Ruby-throated Hummingbird (Female, immature)",
+ "Black-chinned Hummingbird (Female, immature)",
+ "Anna's Hummingbird (Female, immature)",
+ "Costa's Hummingbird (Female, immature)",
+ "Calliope Hummingbird (Female, immature)",
+ "Broad-tailed Hummingbird (Female, immature)",
+ "Rufous Hummingbird (Female, immature)",
+ "Allen's Hummingbird (Female, immature)",
+ "Red-headed Woodpecker (Immature)",
+ "Northern Flicker (Red-shafted)",
+ "Common Eider (Immature/Eclipse male)",
+ "Long-tailed Duck (Female/juvenile)",
+ "Ruddy Duck (Female/juvenile)",
+ "Swainson's Hawk (Immature)",
+ "Red-tailed Hawk (Light morph immature)",
+ "Dark-eyed Junco (Slate-colored)",
+ "Yellow-rumped Warbler (Breeding Myrtle)",
+ "European Starling (Breeding Adult)",
+ "Fox Sparrow (Red)",
+ "Summer Tanager (Adult Male)",
+ "Orchard Oriole (Adult Male)",
+ "Purple Martin (Adult male)",
+ "American Robin (Adult)",
+ "Phainopepla (Female/juvenile)",
+ "Snow Bunting (Breeding adult)",
+ "Common Yellowthroat (Adult Male)",
+ "American Redstart (Adult Male)",
+ "Magnolia Warbler (Breeding male)",
+ "Bay-breasted Warbler (Breeding male)",
+ "Chestnut-sided Warbler (Breeding male)",
+ "Blackpoll Warbler (Breeding male)",
+ "Black-throated Blue Warbler (Adult Male)",
+ "Lark Bunting (Breeding male)",
+ "White-throated Sparrow (White-striped)",
+ "Harris's Sparrow (Adult)",
+ "White-crowned Sparrow (Adult)",
+ "Golden-crowned Sparrow (Adult)",
+ "Dark-eyed Junco (Oregon)",
+ "Chipping Sparrow (Breeding)",
+ "Scarlet Tanager (Breeding Male)",
+ "Western Tanager (Breeding Male)",
+ "Northern Cardinal (Adult Male)",
+ "Rose-breasted Grosbeak (Adult Male)",
+ "Black-headed Grosbeak (Adult Male)",
+ "Blue Grosbeak (Adult Male)",
+ "Lazuli Bunting (Adult Male)",
+ "Indigo Bunting (Adult Male)",
+ "Painted Bunting (Adult Male)",
+ "Bobolink (Breeding male)",
+ "Red-winged Blackbird (Male)",
+ "Yellow-headed Blackbird (Adult Male)",
+ "Brewer's Blackbird (Female/Juvenile)",
+ "Brown-headed Cowbird (Male)",
+ "Hooded Oriole (Adult male)",
+ "Bullock's Oriole (Adult male)",
+ "Baltimore Oriole (Adult male)",
+ "Pine Grosbeak (Adult Male)",
+ "Purple Finch (Adult Male)",
+ "Cassin's Finch (Adult Male)",
+ "House Finch (Adult Male)",
+ "Red Crossbill (Adult Male)",
+ "White-winged Crossbill (Adult Male)",
+ "Lesser Goldfinch (Adult Male)",
+ "American Goldfinch (Breeding Male)",
+ "Evening Grosbeak (Adult Male)",
+ "House Sparrow (Male)",
+ "Vermilion Flycatcher (Adult male)",
+ "Yellow-rumped Warbler (Winter/juvenile Myrtle)",
+ "Northwestern Crow",
+ "Fish Crow",
+ "Chihuahuan Raven",
+ "Common Raven",
+ "Horned Lark",
+ "Tree Swallow",
+ "Violet-green Swallow",
+ "Northern Rough-winged Swallow",
+ "Bank Swallow",
+ "Cliff Swallow",
+ "Cave Swallow",
+ "Barn Swallow",
+ "Carolina Chickadee",
+ "Black-capped Chickadee",
+ "Mountain Chickadee",
+ "Chestnut-backed Chickadee",
+ "Boreal Chickadee",
+ "Bridled Titmouse",
+ "Oak Titmouse",
+ "Juniper Titmouse",
+ "Tufted Titmouse",
+ "Black-crested Titmouse",
+ "Verdin",
+ "Bushtit",
+ "Red-breasted Nuthatch",
+ "White-breasted Nuthatch",
+ "Pygmy Nuthatch",
+ "Brown-headed Nuthatch",
+ "Brown Creeper",
+ "Cactus Wren",
+ "Canyon Wren",
+ "Carolina Wren",
+ "Bewick's Wren",
+ "House Wren",
+ "Pacific Wren",
+ "Winter Wren",
+ "Marsh Wren",
+ "Blue-gray Gnatcatcher",
+ "Black-tailed Gnatcatcher",
+ "American Dipper",
+ "Golden-crowned Kinglet",
+ "Ruby-crowned Kinglet",
+ "Wrentit",
+ "Eastern Bluebird",
+ "Western Bluebird",
+ "Mountain Bluebird",
+ "Townsend's Solitaire",
+ "Veery",
+ "Swainson's Thrush",
+ "Hermit Thrush",
+ "Wood Thrush",
+ "Varied Thrush",
+ "Gray Catbird",
+ "Northern Mockingbird",
+ "Brown Thrasher",
+ "Curve-billed Thrasher",
+ "California Thrasher",
+ "European Starling (Nonbreeding Adult)",
+ "American Pipit",
+ "Bohemian Waxwing",
+ "Cedar Waxwing",
+ "Ovenbird",
+ "Louisiana Waterthrush",
+ "Northern Waterthrush",
+ "Blue-winged Warbler",
+ "Black-and-white Warbler",
+ "Prothonotary Warbler",
+ "Tennessee Warbler",
+ "Orange-crowned Warbler",
+ "Nashville Warbler",
+ "MacGillivray's Warbler",
+ "Mourning Warbler",
+ "Hooded Warbler",
+ "Cape May Warbler",
+ "Northern Parula",
+ "Blackburnian Warbler",
+ "Yellow Warbler",
+ "Palm Warbler",
+ "Pine Warbler",
+ "Yellow-throated Warbler",
+ "Prairie Warbler",
+ "Black-throated Gray Warbler",
+ "Townsend's Warbler",
+ "Hermit Warbler",
+ "Black-throated Green Warbler",
+ "Canada Warbler",
+ "Wilson's Warbler",
+ "Yellow-breasted Chat",
+ "Green-tailed Towhee",
+ "Spotted Towhee",
+ "Eastern Towhee",
+ "Rufous-crowned Sparrow",
+ "Canyon Towhee",
+ "California Towhee",
+ "Abert's Towhee",
+ "American Tree Sparrow",
+ "Clay-colored Sparrow",
+ "Brewer's Sparrow",
+ "Field Sparrow",
+ "Vesper Sparrow",
+ "Lark Sparrow",
+ "Savannah Sparrow",
+ "Fox Sparrow (Sooty)",
+ "Song Sparrow",
+ "Lincoln's Sparrow",
+ "Swamp Sparrow",
+ "Dark-eyed Junco (Pink-sided)",
+ "Summer Tanager (Female)",
+ "Pyrrhuloxia",
+ "Dickcissel",
+ "Eastern Meadowlark",
+ "Western Meadowlark",
+ "Rusty Blackbird",
+ "Common Grackle",
+ "Boat-tailed Grackle",
+ "Great-tailed Grackle",
+ "Bronzed Cowbird",
+ "Orchard Oriole (Immature Male)",
+ "Gray-crowned Rosy-Finch",
+ "Black Rosy-Finch",
+ "Brown-capped Rosy-Finch",
+ "Common Redpoll",
+ "Hoary Redpoll",
+ "Pine Siskin",
+ "Western Wood-Pewee",
+ "Eastern Wood-Pewee",
+ "Least Flycatcher",
+ "Pacific-slope Flycatcher",
+ "Cordilleran Flycatcher",
+ "Black Phoebe",
+ "Eastern Phoebe",
+ "Say's Phoebe",
+ "Ash-throated Flycatcher",
+ "Great Crested Flycatcher",
+ "Cassin's Kingbird",
+ "Western Kingbird",
+ "Eastern Kingbird",
+ "Scissor-tailed Flycatcher",
+ "Loggerhead Shrike",
+ "Northern Shrike",
+ "White-eyed Vireo",
+ "Bell's Vireo",
+ "Yellow-throated Vireo",
+ "Plumbeous Vireo",
+ "Cassin's Vireo",
+ "Blue-headed Vireo",
+ "Hutton's Vireo",
+ "Warbling Vireo",
+ "Red-eyed Vireo",
+ "Gray Jay",
+ "Steller's Jay",
+ "Blue Jay",
+ "Florida Scrub-Jay",
+ "Western Scrub-Jay",
+ "Mexican Jay",
+ "Clark's Nutcracker",
+ "Black-billed Magpie",
+ "Yellow-billed Magpie",
+ "American Crow",
+ "Yellow-rumped Warbler (Breeding Audubon's)",
+ "Purple Martin (Female/juvenile)",
+ "American Robin (Juvenile)",
+ "Phainopepla (Male)",
+ "Snow Bunting (Nonbreeding)",
+ "Common Yellowthroat (Female/immature male)",
+ "American Redstart (Female/juvenile)",
+ "Magnolia Warbler (Female/immature male)",
+ "Bay-breasted Warbler (Female, Nonbreeding male, Immature)",
+ "Chestnut-sided Warbler (Female/immature male)",
+ "Blackpoll Warbler (Female/juvenile)",
+ "Black-throated Blue Warbler (Female/Immature male)",
+ "Lark Bunting (Female/Nonbreeding male)",
+ "White-throated Sparrow (Tan-striped/immature)",
+ "Harris's Sparrow (Immature)",
+ "White-crowned Sparrow (Immature)",
+ "Golden-crowned Sparrow (Immature)",
+ "Dark-eyed Junco (White-winged)",
+ "Chipping Sparrow (Immature/nonbreeding adult)",
+ "Scarlet Tanager (Female/Nonbreeding Male)",
+ "Western Tanager (Female/Nonbreeding Male)",
+ "Northern Cardinal (Female/Juvenile)",
+ "Rose-breasted Grosbeak (Female/immature male)",
+ "Black-headed Grosbeak (Female/immature male)",
+ "Blue Grosbeak (Female/juvenile)",
+ "Lazuli Bunting (Female/juvenile)",
+ "Indigo Bunting (Female/juvenile)",
+ "Painted Bunting (Female/juvenile)",
+ "Bobolink (Female/juvenile/nonbreeding male)",
+ "Red-winged Blackbird (Female/juvenile)",
+ "Yellow-headed Blackbird (Female/Immature Male)",
+ "Brewer's Blackbird (Male)",
+ "Brown-headed Cowbird (Female/Juvenile)",
+ "Hooded Oriole (Female/Immature male)",
+ "Bullock's Oriole (Female/Immature male)",
+ "Baltimore Oriole (Female/Immature male)",
+ "Pine Grosbeak (Female/juvenile)",
+ "Purple Finch (Female/immature)",
+ "Cassin's Finch (Female/immature)",
+ "House Finch (Female/immature)",
+ "Red Crossbill (Female/juvenile)",
+ "White-winged Crossbill (Female/juvenile)",
+ "Lesser Goldfinch (Female/juvenile)",
+ "American Goldfinch (Female/Nonbreeding Male)",
+ "Evening Grosbeak (Female/Juvenile)",
+ "House Sparrow (Female/Juvenile)",
+ "Vermilion Flycatcher (Female, immature)",
+ "European Starling (Juvenile)",
+ "Fox Sparrow (Thick-billed/Slate-colored)",
+ "Summer Tanager (Immature Male)",
+ "Orchard Oriole (Female/Juvenile)",
+ "Yellow-rumped Warbler (Winter/juvenile Audubon's)",
+ "Dark-eyed Junco (Red-backed/Gray-headed)"
+ ],
+ "internal_label_to_original_class_id": {
+ "0": 295,
+ "1": 296,
+ "2": 297,
+ "3": 298,
+ "4": 299,
+ "5": 313,
+ "6": 314,
+ "7": 315,
+ "8": 316,
+ "9": 317,
+ "10": 318,
+ "11": 319,
+ "12": 320,
+ "13": 321,
+ "14": 322,
+ "15": 323,
+ "16": 324,
+ "17": 325,
+ "18": 326,
+ "19": 327,
+ "20": 328,
+ "21": 329,
+ "22": 330,
+ "23": 331,
+ "24": 332,
+ "25": 333,
+ "26": 334,
+ "27": 335,
+ "28": 336,
+ "29": 337,
+ "30": 338,
+ "31": 339,
+ "32": 340,
+ "33": 341,
+ "34": 342,
+ "35": 343,
+ "36": 344,
+ "37": 345,
+ "38": 346,
+ "39": 347,
+ "40": 348,
+ "41": 349,
+ "42": 350,
+ "43": 351,
+ "44": 352,
+ "45": 353,
+ "46": 354,
+ "47": 355,
+ "48": 356,
+ "49": 357,
+ "50": 358,
+ "51": 359,
+ "52": 360,
+ "53": 361,
+ "54": 362,
+ "55": 363,
+ "56": 364,
+ "57": 365,
+ "58": 366,
+ "59": 367,
+ "60": 368,
+ "61": 369,
+ "62": 370,
+ "63": 371,
+ "64": 372,
+ "65": 373,
+ "66": 374,
+ "67": 375,
+ "68": 376,
+ "69": 377,
+ "70": 378,
+ "71": 379,
+ "72": 380,
+ "73": 381,
+ "74": 382,
+ "75": 392,
+ "76": 393,
+ "77": 394,
+ "78": 395,
+ "79": 396,
+ "80": 397,
+ "81": 398,
+ "82": 399,
+ "83": 400,
+ "84": 401,
+ "85": 402,
+ "86": 446,
+ "87": 447,
+ "88": 448,
+ "89": 449,
+ "90": 450,
+ "91": 451,
+ "92": 452,
+ "93": 453,
+ "94": 454,
+ "95": 455,
+ "96": 456,
+ "97": 457,
+ "98": 458,
+ "99": 459,
+ "100": 460,
+ "101": 461,
+ "102": 462,
+ "103": 463,
+ "104": 464,
+ "105": 465,
+ "106": 466,
+ "107": 467,
+ "108": 468,
+ "109": 469,
+ "110": 470,
+ "111": 471,
+ "112": 472,
+ "113": 473,
+ "114": 474,
+ "115": 475,
+ "116": 476,
+ "117": 477,
+ "118": 478,
+ "119": 479,
+ "120": 480,
+ "121": 481,
+ "122": 482,
+ "123": 483,
+ "124": 484,
+ "125": 485,
+ "126": 486,
+ "127": 487,
+ "128": 488,
+ "129": 489,
+ "130": 490,
+ "131": 491,
+ "132": 492,
+ "133": 493,
+ "134": 494,
+ "135": 495,
+ "136": 496,
+ "137": 497,
+ "138": 498,
+ "139": 499,
+ "140": 500,
+ "141": 501,
+ "142": 502,
+ "143": 503,
+ "144": 504,
+ "145": 505,
+ "146": 506,
+ "147": 507,
+ "148": 508,
+ "149": 509,
+ "150": 510,
+ "151": 511,
+ "152": 512,
+ "153": 513,
+ "154": 514,
+ "155": 515,
+ "156": 516,
+ "157": 517,
+ "158": 518,
+ "159": 519,
+ "160": 520,
+ "161": 521,
+ "162": 522,
+ "163": 523,
+ "164": 524,
+ "165": 525,
+ "166": 526,
+ "167": 527,
+ "168": 528,
+ "169": 529,
+ "170": 530,
+ "171": 531,
+ "172": 532,
+ "173": 533,
+ "174": 534,
+ "175": 535,
+ "176": 536,
+ "177": 537,
+ "178": 538,
+ "179": 539,
+ "180": 540,
+ "181": 541,
+ "182": 542,
+ "183": 543,
+ "184": 544,
+ "185": 545,
+ "186": 546,
+ "187": 547,
+ "188": 548,
+ "189": 549,
+ "190": 550,
+ "191": 551,
+ "192": 552,
+ "193": 553,
+ "194": 554,
+ "195": 555,
+ "196": 556,
+ "197": 557,
+ "198": 558,
+ "199": 559,
+ "200": 560,
+ "201": 561,
+ "202": 599,
+ "203": 600,
+ "204": 601,
+ "205": 602,
+ "206": 603,
+ "207": 604,
+ "208": 605,
+ "209": 606,
+ "210": 607,
+ "211": 608,
+ "212": 609,
+ "213": 610,
+ "214": 611,
+ "215": 612,
+ "216": 613,
+ "217": 614,
+ "218": 615,
+ "219": 616,
+ "220": 617,
+ "221": 618,
+ "222": 619,
+ "223": 620,
+ "224": 621,
+ "225": 622,
+ "226": 623,
+ "227": 624,
+ "228": 625,
+ "229": 626,
+ "230": 627,
+ "231": 628,
+ "232": 629,
+ "233": 630,
+ "234": 631,
+ "235": 632,
+ "236": 633,
+ "237": 634,
+ "238": 635,
+ "239": 636,
+ "240": 637,
+ "241": 638,
+ "242": 639,
+ "243": 640,
+ "244": 641,
+ "245": 642,
+ "246": 643,
+ "247": 644,
+ "248": 645,
+ "249": 646,
+ "250": 647,
+ "251": 648,
+ "252": 649,
+ "253": 650,
+ "254": 651,
+ "255": 652,
+ "256": 653,
+ "257": 654,
+ "258": 655,
+ "259": 656,
+ "260": 657,
+ "261": 658,
+ "262": 659,
+ "263": 660,
+ "264": 661,
+ "265": 662,
+ "266": 663,
+ "267": 664,
+ "268": 665,
+ "269": 666,
+ "270": 667,
+ "271": 668,
+ "272": 669,
+ "273": 670,
+ "274": 671,
+ "275": 672,
+ "276": 673,
+ "277": 674,
+ "278": 675,
+ "279": 676,
+ "280": 677,
+ "281": 678,
+ "282": 679,
+ "283": 680,
+ "284": 681,
+ "285": 696,
+ "286": 697,
+ "287": 698,
+ "288": 699,
+ "289": 700,
+ "290": 746,
+ "291": 747,
+ "292": 748,
+ "293": 749,
+ "294": 750,
+ "295": 751,
+ "296": 752,
+ "297": 753,
+ "298": 754,
+ "299": 755,
+ "300": 756,
+ "301": 757,
+ "302": 758,
+ "303": 759,
+ "304": 760,
+ "305": 761,
+ "306": 762,
+ "307": 763,
+ "308": 764,
+ "309": 765,
+ "310": 766,
+ "311": 767,
+ "312": 768,
+ "313": 769,
+ "314": 770,
+ "315": 771,
+ "316": 772,
+ "317": 773,
+ "318": 774,
+ "319": 775,
+ "320": 776,
+ "321": 777,
+ "322": 778,
+ "323": 779,
+ "324": 780,
+ "325": 781,
+ "326": 782,
+ "327": 783,
+ "328": 784,
+ "329": 785,
+ "330": 786,
+ "331": 787,
+ "332": 788,
+ "333": 789,
+ "334": 790,
+ "335": 791,
+ "336": 792,
+ "337": 793,
+ "338": 794,
+ "339": 795,
+ "340": 796,
+ "341": 797,
+ "342": 798,
+ "343": 799,
+ "344": 800,
+ "345": 801,
+ "346": 802,
+ "347": 803,
+ "348": 804,
+ "349": 805,
+ "350": 806,
+ "351": 807,
+ "352": 808,
+ "353": 809,
+ "354": 810,
+ "355": 811,
+ "356": 812,
+ "357": 813,
+ "358": 814,
+ "359": 815,
+ "360": 816,
+ "361": 817,
+ "362": 818,
+ "363": 819,
+ "364": 820,
+ "365": 821,
+ "366": 822,
+ "367": 823,
+ "368": 824,
+ "369": 825,
+ "370": 826,
+ "371": 827,
+ "372": 828,
+ "373": 829,
+ "374": 830,
+ "375": 831,
+ "376": 832,
+ "377": 833,
+ "378": 834,
+ "379": 835,
+ "380": 836,
+ "381": 837,
+ "382": 838,
+ "383": 839,
+ "384": 840,
+ "385": 841,
+ "386": 842,
+ "387": 843,
+ "388": 844,
+ "389": 845,
+ "390": 846,
+ "391": 847,
+ "392": 848,
+ "393": 849,
+ "394": 850,
+ "395": 851,
+ "396": 852,
+ "397": 853,
+ "398": 854,
+ "399": 855,
+ "400": 856,
+ "401": 857,
+ "402": 858,
+ "403": 859,
+ "404": 860,
+ "405": 861,
+ "406": 862,
+ "407": 863,
+ "408": 864,
+ "409": 865,
+ "410": 866,
+ "411": 867,
+ "412": 868,
+ "413": 869,
+ "414": 870,
+ "415": 871,
+ "416": 872,
+ "417": 873,
+ "418": 874,
+ "419": 875,
+ "420": 876,
+ "421": 877,
+ "422": 878,
+ "423": 879,
+ "424": 880,
+ "425": 881,
+ "426": 882,
+ "427": 883,
+ "428": 884,
+ "429": 885,
+ "430": 886,
+ "431": 887,
+ "432": 888,
+ "433": 889,
+ "434": 890,
+ "435": 891,
+ "436": 892,
+ "437": 893,
+ "438": 894,
+ "439": 895,
+ "440": 896,
+ "441": 897,
+ "442": 898,
+ "443": 899,
+ "444": 900,
+ "445": 901,
+ "446": 902,
+ "447": 903,
+ "448": 904,
+ "449": 905,
+ "450": 906,
+ "451": 907,
+ "452": 908,
+ "453": 909,
+ "454": 910,
+ "455": 911,
+ "456": 912,
+ "457": 913,
+ "458": 914,
+ "459": 915,
+ "460": 916,
+ "461": 917,
+ "462": 918,
+ "463": 919,
+ "464": 920,
+ "465": 921,
+ "466": 922,
+ "467": 923,
+ "468": 924,
+ "469": 925,
+ "470": 926,
+ "471": 927,
+ "472": 928,
+ "473": 929,
+ "474": 930,
+ "475": 931,
+ "476": 932,
+ "477": 933,
+ "478": 934,
+ "479": 935,
+ "480": 936,
+ "481": 937,
+ "482": 938,
+ "483": 939,
+ "484": 940,
+ "485": 941,
+ "486": 942,
+ "487": 943,
+ "488": 944,
+ "489": 945,
+ "490": 946,
+ "491": 947,
+ "492": 948,
+ "493": 949,
+ "494": 950,
+ "495": 951,
+ "496": 952,
+ "497": 953,
+ "498": 954,
+ "499": 955,
+ "500": 956,
+ "501": 957,
+ "502": 958,
+ "503": 959,
+ "504": 960,
+ "505": 961,
+ "506": 962,
+ "507": 963,
+ "508": 964,
+ "509": 965,
+ "510": 966,
+ "511": 967,
+ "512": 968,
+ "513": 969,
+ "514": 970,
+ "515": 971,
+ "516": 972,
+ "517": 973,
+ "518": 974,
+ "519": 975,
+ "520": 976,
+ "521": 977,
+ "522": 978,
+ "523": 979,
+ "524": 980,
+ "525": 981,
+ "526": 982,
+ "527": 983,
+ "528": 984,
+ "529": 985,
+ "530": 986,
+ "531": 987,
+ "532": 988,
+ "533": 989,
+ "534": 990,
+ "535": 991,
+ "536": 992,
+ "537": 993,
+ "538": 994,
+ "539": 995,
+ "540": 996,
+ "541": 997,
+ "542": 998,
+ "543": 999,
+ "544": 1000,
+ "545": 1001,
+ "546": 1002,
+ "547": 1003,
+ "548": 1004,
+ "549": 1005,
+ "550": 1006,
+ "551": 1007,
+ "552": 1008,
+ "553": 1009,
+ "554": 1010
+ },
+ "input": {
+ "name": "input",
+ "shape": [
+ 1,
+ 3,
+ 224,
+ 224
+ ],
+ "color_mode": "RGB",
+ "resize_shorter_side": 256,
+ "crop_size": 224,
+ "mean": [
+ 0.485,
+ 0.456,
+ 0.406
+ ],
+ "std": [
+ 0.229,
+ 0.224,
+ 0.225
+ ]
+ },
+ "output": {
+ "name": "logits"
+ },
+ "calibration": {
+ "method": "temperature_scaling",
+ "temperature": 0.7876213192939758
+ },
+ "defaults": {
+ "top_k": 5,
+ "unknown_threshold": 0.45
+ },
+ "metrics": {
+ "top1_accuracy": 0.5202370803393821,
+ "top3_accuracy": 0.7105102910729509,
+ "top5_accuracy": 0.7802947265862867,
+ "macro_f1": 0.4927397416485183,
+ "expected_calibration_error": 0.016740799091596953,
+ "image_count": 24633
+ },
+ "training": {
+ "dataset": "NABirds",
+ "training_image_count": 48562,
+ "best_validation_macro_f1": 0.5601403486594405,
+ "best_epoch": 14,
+ "stage": "finetune"
+ },
+ "artifact": {
+ "filename": "fieldmark.onnx",
+ "sha256": "21706302c5b358bbcf47a0d07ed59e1d7d064cf174fcf455d791c1b41197d163",
+ "size_bytes": 18864014,
+ "onnx_opset": 17,
+ "pytorch_onnx_max_abs_logit_error": 0.009567022323608398
+ }
+}
diff --git a/web/static/css/enhancements.css b/web/static/css/enhancements.css
new file mode 100644
index 0000000..c03e717
--- /dev/null
+++ b/web/static/css/enhancements.css
@@ -0,0 +1,195 @@
+/* GitHub Pages additions layered over the original Fieldmark design system. */
+
+.runtime-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: fit-content;
+ padding: 0.45rem 0.85rem;
+ margin-bottom: var(--space-4);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-pill);
+ color: var(--text-secondary);
+ background: var(--surface-overlay);
+ box-shadow: var(--shadow-soft);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.runtime-chip__pulse {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 0 0 var(--glow-accent);
+ animation: runtime-pulse 2.4s infinite;
+}
+
+@keyframes runtime-pulse {
+ 60% { box-shadow: 0 0 0 0.55rem transparent; }
+ 100% { box-shadow: 0 0 0 0 transparent; }
+}
+
+.hero__copy { z-index: 2; }
+
+.hero__collage::before {
+ content: "";
+ position: absolute;
+ width: 18rem;
+ height: 18rem;
+ inset: 8% auto auto 8%;
+ border-radius: 48% 52% 55% 45%;
+ background: var(--accent-subtle);
+ filter: blur(1px);
+ animation: morph 12s ease-in-out infinite alternate;
+}
+
+@keyframes morph {
+ to { border-radius: 58% 42% 44% 56%; rotate: 8deg; scale: 1.06; }
+}
+
+.polaroid--illustrated {
+ display: grid;
+ place-items: center;
+ min-height: 15rem;
+ background:
+ linear-gradient(155deg, var(--surface-raised) 0 56%, var(--accent-subtle) 56% 100%);
+}
+
+.polaroid--illustrated svg {
+ width: 82%;
+ height: 82%;
+ color: var(--accent);
+ filter: drop-shadow(0 8px 12px var(--glow-accent));
+}
+
+.polaroid--illustrated:nth-child(2) {
+ background:
+ radial-gradient(circle at 25% 20%, var(--glow-warm), transparent 34%),
+ linear-gradient(145deg, var(--surface-raised), var(--surface-sunken));
+}
+
+.polaroid--illustrated:nth-child(3) {
+ background:
+ linear-gradient(165deg, var(--accent-subtle), var(--surface-raised) 66%);
+}
+
+.metric-note {
+ display: block;
+ margin-top: 0.2rem;
+ color: var(--text-muted);
+ font-size: var(--text-xs);
+}
+
+.privacy-strip {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--space-4);
+ align-items: center;
+ margin: var(--space-6) 0 0;
+ padding: var(--space-4);
+ border: 1px solid var(--success-border);
+ border-radius: var(--radius-md);
+ color: var(--success-text);
+ background: var(--success-surface);
+}
+
+.privacy-strip svg { width: 2rem; height: 2rem; }
+.privacy-strip strong { display: block; }
+.privacy-strip span { color: var(--text-secondary); font-size: var(--text-sm); }
+
+.architecture {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-3);
+ align-items: stretch;
+}
+
+.architecture__node {
+ position: relative;
+ padding: var(--space-5) var(--space-4);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+ box-shadow: var(--shadow-soft);
+}
+
+.architecture__node:not(:last-child)::after {
+ content: "→";
+ position: absolute;
+ right: calc(var(--space-3) * -1.7);
+ top: 50%;
+ z-index: 2;
+ translate: 0 -50%;
+ color: var(--text-accent-secondary);
+ font-size: 1.25rem;
+}
+
+.architecture__node span {
+ display: block;
+ margin-bottom: var(--space-2);
+ color: var(--text-accent-secondary);
+ font: 600 var(--text-xs) var(--font-mono);
+}
+
+.architecture__node strong { display: block; margin-bottom: var(--space-1); }
+.architecture__node p { color: var(--text-muted); font-size: var(--text-sm); }
+
+.identify__controls::before {
+ content: "On-device · photos never uploaded";
+ align-self: flex-start;
+ padding: 0.35rem 0.75rem;
+ border-radius: var(--radius-pill);
+ background: var(--success-surface);
+ color: var(--success-text);
+ font-size: var(--text-xs);
+ font-weight: 600;
+}
+
+.dropzone {
+ position: relative;
+ overflow: hidden;
+}
+
+.dropzone::after {
+ content: "";
+ position: absolute;
+ inset: auto -20% -50% 10%;
+ height: 8rem;
+ background: radial-gradient(ellipse, var(--glow-accent), transparent 65%);
+ pointer-events: none;
+}
+
+.result { animation: result-arrive 500ms var(--ease-out) both; }
+@keyframes result-arrive {
+ from { opacity: 0; transform: translateY(1rem) scale(0.985); }
+}
+
+.site-footer__links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ margin-top: var(--space-3);
+}
+
+.site-footer__links a { font-weight: 600; }
+
+.tips--do li::before { content: "✓"; }
+.tips--dont li::before { content: "×"; }
+
+@media (max-width: 60rem) {
+ .architecture { grid-template-columns: repeat(2, 1fr); }
+ .architecture__node::after { display: none; }
+}
+
+@media (max-width: 38rem) {
+ .architecture { grid-template-columns: 1fr; }
+ .privacy-strip { grid-template-columns: 1fr; }
+ .hero__actions .button { width: 100%; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .runtime-chip__pulse, .hero__collage::before, .result { animation: none; }
+}
diff --git a/web/static/css/main.css b/web/static/css/main.css
new file mode 100644
index 0000000..a10008c
--- /dev/null
+++ b/web/static/css/main.css
@@ -0,0 +1,814 @@
+/* Fieldmark · layout and components.
+ Colors come exclusively from the semantic tokens in theme.css. */
+
+/* ---- Base ------------------------------------------------------------- */
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { scroll-behavior: smooth; }
+
+body {
+ margin: 0;
+ font-family: var(--font-body);
+ font-size: var(--text-md);
+ line-height: 1.65;
+ color: var(--text-primary);
+ background: var(--surface-page);
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
+}
+
+h1, h2, h3, h4 {
+ font-family: var(--font-display);
+ font-weight: 600;
+ line-height: 1.15;
+ margin: 0 0 var(--space-3);
+ letter-spacing: -0.01em;
+}
+
+h2 { font-size: var(--text-xl); }
+h3 { font-size: var(--text-lg); }
+
+p { margin: 0 0 var(--space-4); }
+p:last-child { margin-bottom: 0; }
+
+img { max-width: 100%; display: block; }
+
+a { color: var(--text-accent); text-decoration-thickness: 1px; text-underline-offset: 3px; }
+
+code {
+ font-family: var(--font-mono);
+ font-size: 0.875em;
+ background: var(--surface-sunken);
+ border-radius: var(--radius-sm);
+ padding: 0.15em 0.45em;
+}
+
+::selection { background: var(--accent); color: var(--text-on-accent); }
+
+:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+}
+
+.skip-link {
+ position: absolute;
+ left: var(--space-4);
+ top: -4rem;
+ z-index: 100;
+ background: var(--accent);
+ color: var(--text-on-accent);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-sm);
+ transition: top var(--duration-fast) ease;
+}
+.skip-link:focus { top: var(--space-4); }
+
+.eyebrow {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-accent);
+ margin-bottom: var(--space-3);
+}
+
+.accent-dot { color: var(--text-accent-secondary); }
+
+/* ---- Header ----------------------------------------------------------- */
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ background: var(--surface-overlay);
+ -webkit-backdrop-filter: blur(14px);
+ backdrop-filter: blur(14px);
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.site-header__inner {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ height: var(--header-height);
+ padding: 0 var(--space-5);
+ display: flex;
+ align-items: center;
+ gap: var(--space-5);
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ text-decoration: none;
+ color: var(--text-primary);
+ margin-right: auto;
+}
+
+.brand__mark { width: 2rem; height: 2rem; color: var(--accent); flex: none; }
+
+.brand__text { display: flex; flex-direction: column; line-height: 1.1; }
+.brand__text strong { font-family: var(--font-display); font-size: 1.25rem; letter-spacing: 0.01em; }
+.brand__text small { font-size: var(--text-xs); color: var(--text-muted); }
+
+.tabs { display: flex; gap: var(--space-1); }
+
+.tabs__link {
+ display: inline-block;
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-pill);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ text-decoration: none;
+ color: var(--text-secondary);
+ border: 1px solid transparent;
+ transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease;
+}
+
+.tabs__link:hover { color: var(--text-primary); background: var(--surface-sunken); }
+
+.tabs__link[aria-current="page"] {
+ color: var(--text-accent);
+ background: var(--accent-subtle);
+}
+
+.tabs__link--cta {
+ color: var(--text-on-accent);
+ background: var(--accent);
+}
+.tabs__link--cta:hover { color: var(--text-on-accent); background: var(--accent-hover); }
+.tabs__link--cta[aria-current="page"] { color: var(--text-on-accent); background: var(--accent-hover); }
+
+.theme-toggle {
+ display: grid;
+ place-items: center;
+ width: 2.6rem;
+ height: 2.6rem;
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--border-subtle);
+ background: var(--surface-raised);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: transform var(--duration-fast) ease, color var(--duration-fast) ease;
+}
+.theme-toggle:hover { color: var(--text-accent); transform: rotate(15deg); }
+.theme-toggle__icon { width: 1.25rem; height: 1.25rem; }
+.theme-light .theme-toggle__icon--sun { display: none; }
+.theme-dark .theme-toggle__icon--moon { display: none; }
+
+/* ---- Panels & sections ------------------------------------------------ */
+
+.tab-panel[hidden] { display: none; }
+
+.section {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: var(--space-8) var(--space-5);
+}
+
+.section--first { padding-top: var(--space-7); }
+
+.section__head { max-width: 44rem; margin-bottom: var(--space-7); }
+.section__lead { color: var(--text-secondary); font-size: var(--text-lg); }
+
+/* ---- Hero ------------------------------------------------------------- */
+
+.hero {
+ position: relative;
+ overflow: hidden;
+ padding: var(--space-8) var(--space-5) var(--space-7);
+}
+
+.hero__glow {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(42rem 26rem at 12% -8%, var(--glow-accent), transparent 65%),
+ radial-gradient(36rem 24rem at 95% 20%, var(--glow-warm), transparent 60%);
+}
+
+.hero__feather {
+ position: absolute;
+ color: var(--accent);
+ opacity: 0.14;
+ pointer-events: none;
+ animation: drift 14s ease-in-out infinite alternate;
+}
+.hero__feather--a { width: 7rem; top: 9%; right: 6%; transform: rotate(24deg); }
+.hero__feather--b { width: 4.5rem; bottom: 18%; left: 3%; transform: rotate(-32deg); animation-delay: -6s; }
+
+@keyframes drift {
+ from { translate: 0 0; }
+ to { translate: 0 -1.4rem; }
+}
+
+.hero__inner {
+ position: relative;
+ max-width: var(--content-width);
+ margin: 0 auto;
+ display: grid;
+ grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
+ gap: var(--space-7);
+ align-items: center;
+}
+
+.hero__title {
+ font-size: var(--text-hero);
+ font-weight: 600;
+ margin: 0 0 var(--space-4);
+}
+.hero__title em { font-style: italic; color: var(--text-accent); }
+
+.hero__lead {
+ font-size: var(--text-lg);
+ color: var(--text-secondary);
+ max-width: 34rem;
+ margin-bottom: var(--space-6);
+}
+
+.hero__actions { display: flex; gap: var(--space-3); flex-wrap: wrap; }
+
+.hero__collage {
+ position: relative;
+ min-height: 24rem;
+}
+
+.polaroid {
+ position: absolute;
+ margin: 0;
+ width: 58%;
+ aspect-ratio: 4 / 5;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: var(--space-2);
+ box-shadow: var(--shadow-lifted);
+ overflow: hidden;
+ transition: transform var(--duration-slow) var(--ease-out), opacity var(--duration-slow) ease;
+}
+.polaroid img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: calc(var(--radius-md) - 6px);
+}
+.polaroid--a { top: 0; left: 0; rotate: -5deg; z-index: 3; }
+.polaroid--b { top: 14%; right: 0; rotate: 4deg; z-index: 2; }
+.polaroid--c { bottom: 0; left: 14%; rotate: -1deg; z-index: 1; }
+.polaroid:empty { display: none; }
+
+.hero__stats {
+ position: relative;
+ max-width: var(--content-width);
+ margin: var(--space-8) auto 0;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-4);
+ padding: var(--space-5) 0 0;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.stat { display: flex; flex-direction: column; gap: var(--space-1); }
+.stat__value {
+ font-family: var(--font-display);
+ font-size: clamp(1.9rem, 1.4rem + 2vw, 3rem);
+ color: var(--text-accent);
+ font-variant-numeric: tabular-nums;
+}
+.stat__label { font-size: var(--text-sm); color: var(--text-muted); }
+
+/* ---- Buttons ---------------------------------------------------------- */
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: 0.8rem 1.6rem;
+ border-radius: var(--radius-pill);
+ font: 600 var(--text-sm) var(--font-body);
+ text-decoration: none;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background-color var(--duration-fast) ease, transform var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease;
+}
+.button:active { transform: translateY(1px); }
+
+.button--primary {
+ background: var(--accent);
+ color: var(--text-on-accent);
+ box-shadow: var(--shadow-soft);
+}
+.button--primary:hover { background: var(--accent-hover); box-shadow: var(--shadow-lifted); }
+.button--primary:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+
+.button--ghost {
+ background: transparent;
+ color: var(--text-accent);
+ border-color: var(--border-strong);
+}
+.button--ghost:hover { background: var(--accent-subtle); }
+
+.button--small { padding: 0.45rem 1rem; }
+.button--full { width: 100%; }
+
+/* ---- Story ------------------------------------------------------------ */
+
+.story {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+}
+
+.story__step {
+ display: grid;
+ grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+ gap: var(--space-7);
+ align-items: center;
+}
+.story__step--flip .story__visual { order: 2; }
+.story__step--flip .story__text { order: 1; }
+
+.story__visual {
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-soft);
+ padding: var(--space-5);
+}
+.story__art { width: 100%; height: auto; }
+
+.story__visual--badges {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-3);
+}
+
+.story__index {
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ color: var(--text-accent-secondary);
+ letter-spacing: 0.12em;
+}
+
+.story__text h3 { margin-top: var(--space-2); }
+.story__text p { color: var(--text-secondary); }
+
+/* ---- Badges ----------------------------------------------------------- */
+
+.badge {
+ display: inline-block;
+ padding: 0.35rem 0.9rem;
+ border-radius: var(--radius-pill);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ border: 1px solid;
+ white-space: nowrap;
+}
+.badge--success { color: var(--success-text); background: var(--success-surface); border-color: var(--success-border); }
+.badge--warning { color: var(--warning-text); background: var(--warning-surface); border-color: var(--warning-border); }
+.badge--danger { color: var(--danger-text); background: var(--danger-surface); border-color: var(--danger-border); }
+
+/* ---- Gallery ---------------------------------------------------------- */
+
+.gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+ gap: var(--space-4);
+}
+
+.gallery__card {
+ position: relative;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ aspect-ratio: 4 / 5;
+ background: var(--surface-sunken);
+ border: 1px solid var(--border-subtle);
+ box-shadow: var(--shadow-soft);
+ transition: transform var(--duration-fast) var(--ease-out), box-shadow var(--duration-fast) ease;
+}
+.gallery__card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lifted); }
+.gallery__card img { width: 100%; height: 100%; object-fit: cover; }
+
+.gallery__name {
+ position: absolute;
+ inset: auto 0 0 0;
+ margin: 0;
+ padding: var(--space-5) var(--space-3) var(--space-3);
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: #f6f3ea; /* fixed: always sits on the dark photo scrim below */
+ background: linear-gradient(to top, rgba(10, 14, 12, 0.85), transparent);
+}
+
+.gallery__credit {
+ margin-top: var(--space-5);
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+ max-width: 44rem;
+}
+
+/* ---- Notices & cards --------------------------------------------------- */
+
+.notice {
+ background: var(--accent-subtle);
+ border: 1px solid var(--border-subtle);
+ border-left: 4px solid var(--accent);
+ border-radius: var(--radius-md);
+ padding: var(--space-5) var(--space-6);
+}
+.notice h3 { color: var(--text-accent); }
+.notice p { color: var(--text-secondary); }
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
+ gap: var(--space-4);
+}
+
+.info-card {
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: var(--space-5);
+ box-shadow: var(--shadow-soft);
+}
+.info-card h3 { font-size: var(--text-md); color: var(--text-accent); }
+.info-card p { color: var(--text-secondary); font-size: var(--text-sm); }
+
+/* ---- Field guide ------------------------------------------------------- */
+
+.guide-steps {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
+ gap: var(--space-4);
+ counter-reset: step;
+}
+
+.guide-step {
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6) var(--space-5);
+ box-shadow: var(--shadow-soft);
+}
+.guide-step__index {
+ font-family: var(--font-mono);
+ color: var(--text-accent-secondary);
+ letter-spacing: 0.12em;
+}
+.guide-step h3 { margin-top: var(--space-3); font-size: var(--text-lg); }
+.guide-step p { color: var(--text-secondary); font-size: var(--text-sm); }
+
+.dos-donts {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
+ gap: var(--space-4);
+}
+
+.tips {
+ list-style: none;
+ margin: 0;
+ padding: var(--space-5);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-subtle);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+.tips li { padding-left: 2rem; position: relative; color: var(--text-secondary); }
+.tips li::before {
+ position: absolute;
+ left: 0;
+ top: 0;
+ font-weight: 700;
+}
+.tips--do { background: var(--success-surface); border-color: var(--success-border); }
+.tips--do li::before { content: "✓"; color: var(--success-text); }
+.tips--dont { background: var(--danger-surface); border-color: var(--danger-border); }
+.tips--dont li::before { content: "✕"; color: var(--danger-text); }
+
+.commands { display: flex; flex-direction: column; gap: var(--space-3); }
+
+.command {
+ background: var(--surface-sunken);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-5);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ overflow-x: auto;
+}
+.command__label { display: block; color: var(--text-muted); font-size: var(--text-xs); margin-bottom: 2px; }
+.command code { background: none; padding: 0; color: var(--text-primary); }
+
+/* ---- Identify --------------------------------------------------------- */
+
+.model-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-pill);
+ padding: var(--space-2) var(--space-4);
+}
+.model-status__dot {
+ width: 0.6rem;
+ height: 0.6rem;
+ border-radius: 50%;
+ background: var(--border-strong);
+ flex: none;
+}
+.model-status--ready .model-status__dot { background: var(--accent); }
+.model-status--ready { color: var(--text-secondary); }
+.model-status--error .model-status__dot { background: var(--danger-text); }
+.model-status--error { color: var(--danger-text); border-color: var(--danger-border); }
+
+.identify {
+ display: grid;
+ grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+ gap: var(--space-6);
+ align-items: start;
+}
+
+.identify__controls {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ position: sticky;
+ top: calc(var(--header-height) + var(--space-4));
+}
+
+.dropzone {
+ background: var(--surface-sunken);
+ border: 2px dashed var(--border-strong);
+ border-radius: var(--radius-lg);
+ padding: var(--space-7) var(--space-5);
+ text-align: center;
+ cursor: pointer;
+ transition: border-color var(--duration-fast) ease, background-color var(--duration-fast) ease;
+}
+.dropzone:hover, .dropzone:focus-visible, .dropzone--active {
+ border-color: var(--accent);
+ background: var(--accent-subtle);
+}
+.dropzone__icon { width: 3rem; height: 3rem; color: var(--accent); margin: 0 auto var(--space-3); }
+.dropzone__title { font-family: var(--font-display); font-size: var(--text-lg); margin-bottom: var(--space-1); }
+.dropzone__hint { font-size: var(--text-sm); color: var(--text-muted); margin: 0; }
+.dropzone__browse { color: var(--text-accent); text-decoration: underline; text-underline-offset: 3px; }
+
+.queue {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.queue__item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--text-sm);
+}
+.queue__thumb {
+ width: 2.6rem;
+ height: 2.6rem;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+ flex: none;
+}
+.queue__name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-secondary);
+}
+.queue__remove {
+ border: none;
+ background: none;
+ color: var(--text-muted);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-sm);
+}
+.queue__remove:hover { color: var(--danger-text); background: var(--danger-surface); }
+
+.settings {
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+ padding: var(--space-4) var(--space-5) var(--space-5);
+ margin: 0;
+}
+.settings legend {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ padding: 0 var(--space-2);
+}
+.settings__row { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); }
+.settings__row:last-child { margin-bottom: 0; }
+.settings__row label { font-size: var(--text-sm); font-weight: 600; color: var(--text-secondary); }
+.settings__row output { font-family: var(--font-mono); color: var(--text-accent); margin-left: var(--space-2); }
+
+.settings__row--check { flex-direction: row; align-items: center; }
+.settings__row--check label { font-weight: 400; }
+
+.settings select {
+ font: inherit;
+ color: var(--text-primary);
+ background: var(--surface-page);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ padding: var(--space-2) var(--space-3);
+}
+
+.settings input[type="range"] { accent-color: var(--accent); }
+.settings input[type="checkbox"] { accent-color: var(--accent); width: 1.05rem; height: 1.05rem; }
+
+.identify__error {
+ color: var(--danger-text);
+ background: var(--danger-surface);
+ border: 1px solid var(--danger-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--text-sm);
+}
+
+/* ---- Results ---------------------------------------------------------- */
+
+.empty-state {
+ border: 1px dashed var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8) var(--space-5);
+ text-align: center;
+ color: var(--text-muted);
+}
+.empty-state__art { width: 9rem; margin: 0 auto var(--space-4); }
+.empty-state h3 { color: var(--text-secondary); }
+.empty-state p { max-width: 26rem; margin: 0 auto; font-size: var(--text-sm); }
+
+.results__toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ margin-bottom: var(--space-4);
+}
+.results__toolbar h3 { margin: 0; font-size: var(--text-md); color: var(--text-muted); font-family: var(--font-body); }
+
+.results__list { display: flex; flex-direction: column; gap: var(--space-5); }
+
+.result {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
+ background: var(--surface-raised);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ box-shadow: var(--shadow-soft);
+}
+
+.result__media { background: var(--surface-sunken); }
+.result__media img { width: 100%; height: 100%; object-fit: cover; min-height: 14rem; }
+
+.result__body { padding: var(--space-5); }
+
+.result__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+ margin-bottom: var(--space-3);
+}
+.result__filename { font-size: var(--text-xs); color: var(--text-muted); margin-bottom: var(--space-1); }
+.result__species { margin: 0; font-size: var(--text-xl); }
+
+.result__warning { font-size: var(--text-sm); color: var(--text-muted); }
+
+.rank {
+ list-style: none;
+ margin: var(--space-4) 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.rank__item { display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); }
+.rank__name { font-size: var(--text-sm); color: var(--text-secondary); }
+.rank__value { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); align-self: center; }
+.rank__meter {
+ grid-column: 1 / -1;
+ height: 0.45rem;
+ border-radius: var(--radius-pill);
+ background: var(--surface-sunken);
+ overflow: hidden;
+}
+.rank__fill {
+ display: block;
+ height: 100%;
+ width: 0;
+ border-radius: inherit;
+ background: var(--accent);
+ transition: width var(--duration-slow) var(--ease-out);
+}
+.rank__item:first-child .rank__name { color: var(--text-primary); font-weight: 600; }
+
+.result__tech { margin-top: var(--space-4); font-size: var(--text-sm); }
+.result__tech summary { cursor: pointer; color: var(--text-accent); font-weight: 600; }
+.tech-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--space-1) var(--space-4);
+ margin: var(--space-3) 0 0;
+}
+.tech-grid dt { color: var(--text-muted); }
+.tech-grid dd { margin: 0; font-family: var(--font-mono); font-size: var(--text-xs); align-self: center; }
+
+/* ---- Footer ----------------------------------------------------------- */
+
+.site-footer {
+ border-top: 1px solid var(--border-subtle);
+ background: var(--surface-sunken);
+ margin-top: var(--space-8);
+}
+.site-footer__inner {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: var(--space-6) var(--space-5);
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+}
+
+/* ---- Scroll reveal ----------------------------------------------------- */
+
+.reveal {
+ opacity: 0;
+ translate: 0 1.6rem;
+ transition: opacity var(--duration-slow) ease, translate var(--duration-slow) var(--ease-out);
+}
+.reveal.is-visible { opacity: 1; translate: 0 0; }
+
+/* ---- Responsive -------------------------------------------------------- */
+
+@media (max-width: 60rem) {
+ .hero__inner { grid-template-columns: 1fr; }
+ .hero__collage { min-height: 19rem; max-width: 24rem; }
+ .hero__stats { grid-template-columns: repeat(2, 1fr); row-gap: var(--space-5); }
+ .story__step { grid-template-columns: 1fr; gap: var(--space-4); }
+ .story__step--flip .story__visual { order: 0; }
+ .story__visual { max-width: 26rem; }
+ .identify { grid-template-columns: 1fr; }
+ .identify__controls { position: static; }
+ .result { grid-template-columns: 1fr; }
+ .result__media img { max-height: 16rem; }
+}
+
+@media (max-width: 44rem) {
+ .site-header__inner { flex-wrap: wrap; height: auto; padding: var(--space-3) var(--space-4); gap: var(--space-3); }
+ .brand { margin-right: 0; }
+ .tabs { order: 3; width: 100%; justify-content: center; }
+ .theme-toggle { margin-left: auto; }
+ .section { padding: var(--space-7) var(--space-4); }
+}
+
+/* ---- Reduced motion ----------------------------------------------------- */
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+ .reveal { opacity: 1; translate: 0 0; }
+}
diff --git a/web/static/css/theme.css b/web/static/css/theme.css
new file mode 100644
index 0000000..132bf71
--- /dev/null
+++ b/web/static/css/theme.css
@@ -0,0 +1,183 @@
+/* =========================================================================
+ Fieldmark theming system
+ =========================================================================
+ Architecture
+ ------------
+ Layer 1 · :root Theme-agnostic primitives: type, spacing,
+ radii, motion. Never colors.
+ Layer 2 · .theme-* Semantic color tokens. Every theme class
+ defines the SAME token contract, so themes
+ are interchangeable and new ones can be
+ added without touching component CSS.
+
+ The active theme is a single class on (e.g. class="theme-light"),
+ shipped in the markup itself — no JavaScript is needed for the initial
+ render. JavaScript only *toggles* the class afterwards.
+
+ Adding a theme
+ --------------
+ 1. Copy a .theme-* block below and restyle every token in it.
+ 2. Register the class name in the toggle (main.js THEMES list).
+ 3. Run: python scripts/check_theme_contrast.py
+ The checker parses this file and fails if any foreground/background
+ pairing drops below WCAG AA (4.5:1 text, 3:1 UI components).
+ ========================================================================= */
+
+/* ---- Layer 1 · primitives -------------------------------------------- */
+
+:root {
+ /* Typography (local system stacks; nothing is fetched) */
+ --font-display: "Palatino Linotype", Palatino, "Iowan Old Style",
+ "Book Antiqua", Georgia, serif;
+ --font-body: "Segoe UI", system-ui, -apple-system, "Helvetica Neue",
+ Arial, sans-serif;
+ --font-mono: "Cascadia Code", Consolas, "SF Mono", "Fira Code", monospace;
+
+ --text-xs: 0.8125rem;
+ --text-sm: 0.9375rem;
+ --text-md: 1.0625rem;
+ --text-lg: 1.375rem;
+ --text-xl: clamp(1.75rem, 1.3rem + 2vw, 2.5rem);
+ --text-hero: clamp(2.75rem, 1.8rem + 4.5vw, 5rem);
+
+ /* Spacing scale */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.5rem;
+ --space-6: 2rem;
+ --space-7: 3rem;
+ --space-8: 4.5rem;
+ --space-9: 7rem;
+
+ /* Shape */
+ --radius-sm: 8px;
+ --radius-md: 14px;
+ --radius-lg: 22px;
+ --radius-pill: 999px;
+
+ /* Motion */
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
+ --duration-fast: 160ms;
+ --duration-slow: 700ms;
+
+ /* Layout */
+ --content-width: 72rem;
+ --header-height: 4.25rem;
+}
+
+/* ---- Layer 2 · semantic tokens ---------------------------------------
+ Token contract (every theme must define all of these):
+
+ Surfaces --surface-page page background
+ --surface-raised cards, panels
+ --surface-sunken wells, code, dropzones
+ --surface-overlay translucent header/scrims
+ --accent-subtle tinted fills behind accent content
+
+ Text --text-primary headings, body
+ --text-secondary supporting copy (AA on all surfaces)
+ --text-muted captions, metadata (AA on page+raised)
+ --text-accent links, brand text (AA on page+raised)
+ --text-accent-secondary amber highlight text (AA on page+raised)
+ --text-on-accent text atop --accent fills
+
+ Interactive --accent primary buttons, meters
+ --accent-hover hover/active state
+ --focus-ring focus outlines (3:1 on page)
+
+ Feedback --{success,warning,danger}-text (AA on own surface + raised)
+ --{success,warning,danger}-surface
+ --{success,warning,danger}-border
+
+ Lines --border-subtle hairlines, dividers
+ --border-strong form controls, outlines (3:1 on page+raised)
+
+ Depth --shadow-soft / --shadow-lifted
+ Decoration --glow-accent / --glow-warm (hero gradients, non-text only)
+ --------------------------------------------------------------------- */
+
+/* ---- Theme: light · "Meadow" ----------------------------------------- */
+
+.theme-light {
+ color-scheme: light;
+
+ --surface-page: #f6f3ea;
+ --surface-raised: #ffffff;
+ --surface-sunken: #ece7d8;
+ --surface-overlay: rgba(246, 243, 234, 0.82);
+ --accent-subtle: #dceadf;
+
+ --text-primary: #1e2620;
+ --text-secondary: #43503f;
+ --text-muted: #5a6653;
+ --text-accent: #235f41;
+ --text-accent-secondary: #7e5300;
+ --text-on-accent: #ffffff;
+
+ --accent: #2c6a47;
+ --accent-hover: #225339;
+ --focus-ring: #2c6a47;
+
+ --success-text: #1d6b3b;
+ --success-surface: #def0e1;
+ --success-border: #9ccba8;
+ --warning-text: #7c4f00;
+ --warning-surface: #f6e9cd;
+ --warning-border: #ddb86a;
+ --danger-text: #a82a1f;
+ --danger-surface: #f9e2dd;
+ --danger-border: #e39c90;
+
+ --border-subtle: #ddd7c6;
+ --border-strong: #6e7860;
+
+ --shadow-soft: 0 2px 10px rgba(30, 38, 32, 0.07);
+ --shadow-lifted: 0 14px 40px rgba(30, 38, 32, 0.14);
+
+ --glow-accent: rgba(44, 106, 71, 0.16);
+ --glow-warm: rgba(196, 142, 42, 0.14);
+}
+
+/* ---- Theme: dark · "Dusk" -------------------------------------------- */
+
+.theme-dark {
+ color-scheme: dark;
+
+ --surface-page: #0f1513;
+ --surface-raised: #17201b;
+ --surface-sunken: #0a0e0c;
+ --surface-overlay: rgba(15, 21, 19, 0.8);
+ --accent-subtle: #1d3226;
+
+ --text-primary: #eaf0e9;
+ --text-secondary: #becab9;
+ --text-muted: #94a28f;
+ --text-accent: #8cd6ab;
+ --text-accent-secondary: #e3b566;
+ --text-on-accent: #08130c;
+
+ --accent: #67c592;
+ --accent-hover: #82d8a8;
+ --focus-ring: #8cd6ab;
+
+ --success-text: #a4dfb6;
+ --success-surface: #16301f;
+ --success-border: #2f6b45;
+ --warning-text: #e9c27b;
+ --warning-surface: #322809;
+ --warning-border: #8a6a22;
+ --danger-text: #f2a99e;
+ --danger-surface: #3a1710;
+ --danger-border: #8c3a2e;
+
+ --border-subtle: #273129;
+ --border-strong: #6a7a70;
+
+ --shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.35);
+ --shadow-lifted: 0 16px 44px rgba(0, 0, 0, 0.5);
+
+ --glow-accent: rgba(103, 197, 146, 0.12);
+ --glow-warm: rgba(227, 181, 102, 0.09);
+}
diff --git a/web/static/images/fieldmark.svg b/web/static/images/fieldmark.svg
new file mode 100644
index 0000000..9356fb9
--- /dev/null
+++ b/web/static/images/fieldmark.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..342e1f2
--- /dev/null
+++ b/web/static/js/app.js
@@ -0,0 +1,421 @@
+/* Fieldmark application shell: themes, navigation, uploads, and results. */
+(function () {
+ "use strict";
+
+ var THEMES = ["theme-light", "theme-dark"];
+ var reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
+ var browserMode = /\.github\.io$/i.test(location.hostname) || location.protocol === "file:";
+
+ /* Theme -------------------------------------------------------------- */
+
+ var toggle = document.querySelector("[data-theme-toggle]");
+ if (toggle) {
+ toggle.addEventListener("click", function () {
+ var root = document.documentElement;
+ var current = THEMES.indexOf(root.className) === -1 ? THEMES[0] : root.className;
+ var next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
+ root.className = next;
+ toggle.setAttribute("aria-pressed", String(next === "theme-dark"));
+ try { localStorage.setItem("fieldmark-theme", next); } catch (error) { /* private mode */ }
+ });
+ }
+
+ /* Hash-routed tabs --------------------------------------------------- */
+
+ var panels = document.querySelectorAll("[data-panel]");
+ var tabLinks = document.querySelectorAll("[data-tab]");
+
+ function activateTab(name, scrollTop) {
+ var known = Array.prototype.some.call(panels, function (panel) {
+ return panel.dataset.panel === name;
+ });
+ if (!known) return;
+ panels.forEach(function (panel) {
+ panel.hidden = panel.dataset.panel !== name;
+ });
+ tabLinks.forEach(function (link) {
+ if (link.dataset.tab === name) link.setAttribute("aria-current", "page");
+ else link.removeAttribute("aria-current");
+ });
+ if (scrollTop) window.scrollTo({ top: 0, behavior: "auto" });
+ refreshReveals();
+ }
+
+ function tabFromHash() {
+ var name = location.hash.replace("#", "");
+ return document.querySelector('[data-panel="' + name + '"]') ? name : "how-it-works";
+ }
+
+ window.addEventListener("hashchange", function () { activateTab(tabFromHash(), true); });
+ activateTab(tabFromHash(), false);
+
+ /* Motion ------------------------------------------------------------- */
+
+ var revealObserver = null;
+ if ("IntersectionObserver" in window && !reduceMotion) {
+ revealObserver = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ entry.target.classList.add("is-visible");
+ revealObserver.unobserve(entry.target);
+ if (entry.target.classList.contains("hero__stats")) runCounters(entry.target);
+ });
+ }, { threshold: 0.14, rootMargin: "0px 0px -5% 0px" });
+ }
+
+ function refreshReveals() {
+ document.querySelectorAll(".reveal:not(.is-visible)").forEach(function (element) {
+ if (element.closest("[hidden]")) return;
+ if (revealObserver) revealObserver.observe(element);
+ else {
+ element.classList.add("is-visible");
+ if (element.classList.contains("hero__stats")) runCounters(element);
+ }
+ });
+ }
+
+ function formatCount(value, element) {
+ var decimals = parseInt(element.dataset.decimals || "0", 10);
+ var output = element.dataset.format === "grouped"
+ ? Math.round(value).toLocaleString("en-US")
+ : value.toFixed(decimals);
+ return output + (element.dataset.suffix || "");
+ }
+
+ function runCounters(scope) {
+ scope.querySelectorAll("[data-count]").forEach(function (element) {
+ var target = parseFloat(element.dataset.count);
+ if (element.dataset.counted) return;
+ element.dataset.counted = "true";
+ if (reduceMotion) {
+ element.textContent = formatCount(target, element);
+ return;
+ }
+ var start = null;
+ function frame(now) {
+ if (start === null) start = now;
+ var progress = Math.min((now - start) / 1400, 1);
+ var eased = 1 - Math.pow(1 - progress, 3);
+ element.textContent = formatCount(target * eased, element);
+ if (progress < 1) requestAnimationFrame(frame);
+ }
+ requestAnimationFrame(frame);
+ });
+ }
+
+ refreshReveals();
+
+ /* Optional local-only gallery --------------------------------------- */
+
+ if (!browserMode) {
+ fetch("./api/showcase").then(function (response) {
+ return response.ok ? response.json() : { birds: [] };
+ }).then(function (data) {
+ var birds = data.birds || [];
+ document.querySelectorAll("[data-collage-slot]").forEach(function (slot, index) {
+ if (!birds[index]) return;
+ slot.innerHTML = "";
+ var image = document.createElement("img");
+ image.src = birds[index].url;
+ image.alt = "";
+ slot.appendChild(image);
+ });
+ }).catch(function () { /* Decorative artwork remains in place. */ });
+ }
+
+ /* Model readiness ---------------------------------------------------- */
+
+ var statusBox = document.querySelector("[data-model-status]");
+ var statusText = document.querySelector("[data-model-status-text]");
+ var identifyButton = document.querySelector("[data-identify-button]");
+ var modelReady = false;
+
+ function setStatus(kind, message) {
+ if (!statusBox || !statusText) return;
+ statusBox.classList.remove("model-status--ready", "model-status--error");
+ if (kind) statusBox.classList.add("model-status--" + kind);
+ statusText.textContent = message;
+ }
+
+ function updateIdentifyButton() {
+ if (identifyButton) identifyButton.disabled = !(modelReady && queue.length > 0);
+ }
+
+ function pollStatus() {
+ if (browserMode) {
+ setStatus(null, "Downloading the private browser model (18 MB)…");
+ window.FieldmarkBrowser.init().then(function (details) {
+ modelReady = true;
+ setStatus("ready", details.architecture + " · " + details.classCount +
+ " classes · private on-device inference ready");
+ updateIdentifyButton();
+ }).catch(function (error) {
+ setStatus("error", "The browser model could not load: " + error.message);
+ });
+ return;
+ }
+
+ fetch("./api/status").then(function (response) { return response.json(); })
+ .then(function (data) {
+ if (!data.model_available) {
+ setStatus("error", "No trained model found — run: python -m src.train");
+ return;
+ }
+ if (data.model_error) {
+ setStatus("error", data.model_error);
+ return;
+ }
+ modelReady = true;
+ updateIdentifyButton();
+ setStatus("ready", data.architecture + " · " + data.num_classes +
+ " classes · running on " + data.device);
+ }).catch(function () {
+ setStatus("error", "Cannot reach the local model server.");
+ });
+ }
+
+ /* Upload queue ------------------------------------------------------- */
+
+ var dropzone = document.querySelector("[data-dropzone]");
+ var fileInput = document.querySelector("[data-file-input]");
+ var queueList = document.querySelector("[data-queue]");
+ var queue = [];
+ var maximumBytes = 20 * 1024 * 1024;
+
+ function renderQueue() {
+ if (!queueList) return;
+ queueList.innerHTML = "";
+ queueList.hidden = queue.length === 0;
+ queue.forEach(function (entry, index) {
+ var item = document.createElement("li");
+ item.className = "queue__item";
+ var thumbnail = document.createElement("img");
+ thumbnail.className = "queue__thumb";
+ thumbnail.alt = "";
+ thumbnail.src = entry.previewUrl;
+ var name = document.createElement("span");
+ name.className = "queue__name";
+ name.textContent = entry.file.name;
+ var remove = document.createElement("button");
+ remove.className = "queue__remove";
+ remove.type = "button";
+ remove.setAttribute("aria-label", "Remove " + entry.file.name);
+ remove.textContent = "×";
+ remove.addEventListener("click", function () {
+ URL.revokeObjectURL(entry.previewUrl);
+ queue.splice(index, 1);
+ renderQueue();
+ });
+ item.appendChild(thumbnail);
+ item.appendChild(name);
+ item.appendChild(remove);
+ queueList.appendChild(item);
+ });
+ updateIdentifyButton();
+ }
+
+ function showError(message) {
+ var box = document.querySelector("[data-identify-error]");
+ if (!box) return;
+ box.textContent = message;
+ box.hidden = !message;
+ }
+
+ function addFiles(files) {
+ var rejected = [];
+ Array.prototype.forEach.call(files, function (file) {
+ if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
+ rejected.push(file.name + " is not a supported image");
+ } else if (file.size > maximumBytes) {
+ rejected.push(file.name + " is larger than 20 MB");
+ } else {
+ queue.push({ file: file, previewUrl: URL.createObjectURL(file) });
+ }
+ });
+ showError(rejected.join(". "));
+ renderQueue();
+ }
+
+ if (dropzone && fileInput) {
+ dropzone.addEventListener("click", function () { fileInput.click(); });
+ dropzone.addEventListener("keydown", function (event) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ fileInput.click();
+ }
+ });
+ fileInput.addEventListener("change", function () {
+ addFiles(fileInput.files);
+ fileInput.value = "";
+ });
+ ["dragenter", "dragover"].forEach(function (type) {
+ dropzone.addEventListener(type, function (event) {
+ event.preventDefault();
+ dropzone.classList.add("dropzone--active");
+ });
+ });
+ ["dragleave", "drop"].forEach(function (type) {
+ dropzone.addEventListener(type, function (event) {
+ event.preventDefault();
+ dropzone.classList.remove("dropzone--active");
+ });
+ });
+ dropzone.addEventListener("drop", function (event) {
+ if (event.dataTransfer) addFiles(event.dataTransfer.files);
+ });
+ }
+
+ /* Prediction results ------------------------------------------------- */
+
+ var resultsBox = document.querySelector("[data-results]");
+ var resultsList = document.querySelector("[data-results-list]");
+ var resultsCount = document.querySelector("[data-results-count]");
+ var emptyState = document.querySelector("[data-empty-state]");
+ var csvButton = document.querySelector("[data-download-csv]");
+ var template = document.querySelector("[data-result-template]");
+ var completed = [];
+ var BADGES = {
+ confident_prediction: { className: "badge--success", label: "Confident" },
+ uncertain_prediction: { className: "badge--warning", label: "Uncertain" },
+ possibly_unknown: { className: "badge--danger", label: "Possibly unknown" }
+ };
+
+ function renderResult(result, previewUrl) {
+ var node = template.content.firstElementChild.cloneNode(true);
+ node.querySelector("[data-result-image]").src = previewUrl;
+ node.querySelector("[data-result-image]").alt = "Uploaded bird photograph";
+ node.querySelector("[data-result-filename]").textContent = result.filename;
+ node.querySelector("[data-result-species]").textContent = result.predicted_class;
+
+ var badge = node.querySelector("[data-result-badge]");
+ var badgeSpec = BADGES[result.uncertainty_status] || BADGES.uncertain_prediction;
+ badge.classList.add(badgeSpec.className);
+ badge.textContent = badgeSpec.label + " · " + (result.probability * 100).toFixed(1) + "%";
+ node.querySelector("[data-result-warning]").textContent = result.warning;
+
+ var ranks = node.querySelector("[data-result-ranks]");
+ result.top_k.forEach(function (candidate) {
+ var item = document.createElement("li");
+ item.className = "rank__item";
+ item.innerHTML = ' ' +
+ ' ';
+ item.querySelector(".rank__name").textContent = candidate.class_name;
+ item.querySelector(".rank__value").textContent = (candidate.probability * 100).toFixed(1) + "%";
+ ranks.appendChild(item);
+ requestAnimationFrame(function () {
+ item.querySelector(".rank__fill").style.width =
+ Math.max(candidate.probability * 100, 1.5) + "%";
+ });
+ });
+
+ if (document.querySelector("[data-setting-technical]").checked) {
+ var technical = node.querySelector("[data-result-tech]");
+ technical.hidden = false;
+ var grid = node.querySelector("[data-result-tech-grid]");
+ [
+ ["Entropy", result.entropy.toFixed(3)],
+ ["Normalized entropy", result.normalized_entropy.toFixed(3)],
+ ["Top-1 / top-2 margin", result.margin.toFixed(3)],
+ ["Temperature", result.temperature.toFixed(3)],
+ ["Unknown threshold", result.unknown_threshold.toFixed(2)],
+ ["Inference time", result.inference_time_ms.toFixed(0) + " ms"],
+ ["Architecture", result.model.architecture],
+ ["Runtime", result.model.device]
+ ].forEach(function (pair) {
+ var term = document.createElement("dt");
+ var definition = document.createElement("dd");
+ term.textContent = pair[0];
+ definition.textContent = pair[1];
+ grid.appendChild(term);
+ grid.appendChild(definition);
+ });
+ }
+ resultsList.prepend(node);
+ }
+
+ function acceptResult(result, entry) {
+ completed.push(result);
+ emptyState.hidden = true;
+ resultsBox.hidden = false;
+ resultsCount.textContent = completed.length === 1
+ ? "1 identification" : completed.length + " identifications";
+ csvButton.hidden = completed.length < 2;
+ renderResult(result, entry.previewUrl);
+ }
+
+ function localPrediction(entry, topK, threshold) {
+ var form = new FormData();
+ form.append("file", entry.file);
+ form.append("top_k", topK);
+ form.append("threshold", threshold);
+ return fetch("./api/predict", { method: "POST", body: form }).then(function (response) {
+ return response.json().then(function (data) {
+ if (!response.ok) throw new Error(data.error || "Prediction failed.");
+ return data;
+ });
+ });
+ }
+
+ function identifyAll() {
+ if (!queue.length || !modelReady) return;
+ showError("");
+ var batch = queue.slice();
+ queue = [];
+ renderQueue();
+ var topK = document.querySelector("[data-setting-topk]").value;
+ var threshold = document.querySelector("[data-setting-threshold]").value;
+ identifyButton.disabled = true;
+
+ var chain = Promise.resolve();
+ batch.forEach(function (entry, index) {
+ chain = chain.then(function () {
+ identifyButton.textContent = "Identifying " + (index + 1) + " of " + batch.length + "…";
+ var prediction = browserMode
+ ? window.FieldmarkBrowser.predict(entry.file, { topK: topK, threshold: threshold })
+ : localPrediction(entry, topK, threshold);
+ return prediction.then(function (result) {
+ acceptResult(result, entry);
+ }).catch(function (error) {
+ showError(entry.file.name + ": " + error.message);
+ });
+ });
+ });
+ chain.then(function () {
+ identifyButton.textContent = "Identify";
+ updateIdentifyButton();
+ });
+ }
+
+ if (identifyButton) identifyButton.addEventListener("click", identifyAll);
+
+ if (csvButton) {
+ csvButton.addEventListener("click", function () {
+ var header = "filename,predicted_species,probability,uncertainty_status,alternatives\n";
+ var rows = completed.map(function (result) {
+ var alternatives = result.top_k.map(function (candidate) {
+ return candidate.class_name + " (" + (candidate.probability * 100).toFixed(1) + "%)";
+ }).join("; ");
+ return [result.filename, result.predicted_class, result.probability.toFixed(4),
+ result.uncertainty_status, alternatives].map(function (field) {
+ return '"' + String(field).replace(/"/g, '""') + '"';
+ }).join(",");
+ });
+ var url = URL.createObjectURL(new Blob([header + rows.join("\n")], { type: "text/csv" }));
+ var link = document.createElement("a");
+ link.href = url;
+ link.download = "fieldmark_predictions.csv";
+ link.click();
+ setTimeout(function () { URL.revokeObjectURL(url); }, 0);
+ });
+ }
+
+ var thresholdInput = document.querySelector("[data-setting-threshold]");
+ var thresholdValue = document.querySelector("[data-threshold-value]");
+ if (thresholdInput && thresholdValue) {
+ thresholdInput.addEventListener("input", function () {
+ thresholdValue.textContent = parseFloat(thresholdInput.value).toFixed(2);
+ });
+ }
+
+ if (statusBox) pollStatus();
+})();
diff --git a/web/static/js/browser-inference.js b/web/static/js/browser-inference.js
new file mode 100644
index 0000000..72436a5
--- /dev/null
+++ b/web/static/js/browser-inference.js
@@ -0,0 +1,241 @@
+/* Browser-only Fieldmark inference.
+ *
+ * The trained ONNX model, preprocessing, calibration, and uncertainty logic
+ * all execute on-device. Uploaded images are decoded through the browser and
+ * are never sent to a server.
+ */
+(function () {
+ "use strict";
+
+ var scriptUrl = document.currentScript && document.currentScript.src
+ ? document.currentScript.src
+ : new URL("./static/js/browser-inference.js", document.baseURI).href;
+ var modelBase = new URL("../../model/", scriptUrl);
+ var session = null;
+ var metadata = null;
+ var initialization = null;
+
+ function softmax(logits, temperature) {
+ var scaled = new Float64Array(logits.length);
+ var maximum = -Infinity;
+ var divisor = Math.max(Number(temperature) || 1, 0.001);
+ var i;
+ for (i = 0; i < logits.length; i += 1) {
+ scaled[i] = logits[i] / divisor;
+ maximum = Math.max(maximum, scaled[i]);
+ }
+ var total = 0;
+ for (i = 0; i < scaled.length; i += 1) {
+ scaled[i] = Math.exp(scaled[i] - maximum);
+ total += scaled[i];
+ }
+ for (i = 0; i < scaled.length; i += 1) scaled[i] /= total;
+ return scaled;
+ }
+
+ function uncertainty(probabilities, threshold) {
+ var ordered = Array.from(probabilities).sort(function (a, b) { return b - a; });
+ var maximum = ordered[0];
+ var margin = maximum - (ordered[1] || 0);
+ var entropy = 0;
+ probabilities.forEach(function (probability) {
+ entropy -= probability * Math.log(probability + 1e-12);
+ });
+ var normalizedEntropy = entropy / Math.max(Math.log(probabilities.length), 1e-12);
+ var status = "confident_prediction";
+ if (maximum < threshold) {
+ status = "possibly_unknown";
+ } else if (margin < 0.10 || normalizedEntropy > 0.65) {
+ status = "uncertain_prediction";
+ }
+ return {
+ status: status,
+ maximum: maximum,
+ entropy: entropy,
+ normalizedEntropy: normalizedEntropy,
+ margin: margin
+ };
+ }
+
+ function warningFor(status) {
+ if (status === "possibly_unknown") {
+ return "Low confidence: this may be an unsupported species or an unusual image. This is not a scientifically validated unknown-species detector.";
+ }
+ if (status === "uncertain_prediction") {
+ return "The model is uncertain; review the alternatives and do not treat this as certain.";
+ }
+ return "Prediction is model-generated and may be incorrect.";
+ }
+
+ function loadBitmap(file) {
+ if ("createImageBitmap" in window) {
+ return createImageBitmap(file, { imageOrientation: "from-image" })
+ .catch(function () { return createImageBitmap(file); });
+ }
+ return new Promise(function (resolve, reject) {
+ var image = new Image();
+ var url = URL.createObjectURL(file);
+ image.onload = function () {
+ URL.revokeObjectURL(url);
+ resolve(image);
+ };
+ image.onerror = function () {
+ URL.revokeObjectURL(url);
+ reject(new Error("The image could not be decoded by this browser."));
+ };
+ image.src = url;
+ });
+ }
+
+ function imageDimensions(image) {
+ return {
+ width: image.width || image.naturalWidth,
+ height: image.height || image.naturalHeight
+ };
+ }
+
+ function preprocess(image) {
+ var dimensions = imageDimensions(image);
+ var cropSize = metadata.input.crop_size;
+ var resizeShorter = metadata.input.resize_shorter_side;
+ var scale = resizeShorter / Math.min(dimensions.width, dimensions.height);
+ var sourceWidth = cropSize / scale;
+ var sourceHeight = cropSize / scale;
+ var sourceX = (dimensions.width - sourceWidth) / 2;
+ var sourceY = (dimensions.height - sourceHeight) / 2;
+
+ var canvas = document.createElement("canvas");
+ canvas.width = cropSize;
+ canvas.height = cropSize;
+ var context = canvas.getContext("2d", { willReadFrequently: true });
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = "high";
+ context.drawImage(
+ image,
+ sourceX,
+ sourceY,
+ sourceWidth,
+ sourceHeight,
+ 0,
+ 0,
+ cropSize,
+ cropSize
+ );
+
+ var pixels = context.getImageData(0, 0, cropSize, cropSize).data;
+ var plane = cropSize * cropSize;
+ var tensorData = new Float32Array(plane * 3);
+ var mean = metadata.input.mean;
+ var standardDeviation = metadata.input.std;
+ for (var index = 0; index < plane; index += 1) {
+ var pixel = index * 4;
+ tensorData[index] = (pixels[pixel] / 255 - mean[0]) / standardDeviation[0];
+ tensorData[plane + index] = (pixels[pixel + 1] / 255 - mean[1]) / standardDeviation[1];
+ tensorData[plane * 2 + index] = (pixels[pixel + 2] / 255 - mean[2]) / standardDeviation[2];
+ }
+ return tensorData;
+ }
+
+ function init() {
+ if (initialization) return initialization;
+ initialization = Promise.resolve().then(function () {
+ if (!window.ort) throw new Error("ONNX Runtime Web did not load.");
+ ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/";
+ // GitHub Pages cannot set the isolation headers required by WASM threads.
+ ort.env.wasm.numThreads = 1;
+ ort.env.logLevel = "warning";
+ return fetch(new URL("metadata.json", modelBase), { cache: "force-cache" });
+ }).then(function (response) {
+ if (!response.ok) throw new Error("Model metadata could not be downloaded.");
+ return response.json();
+ }).then(function (loadedMetadata) {
+ metadata = loadedMetadata;
+ if (!metadata.class_names || metadata.class_names.length !== metadata.class_count) {
+ throw new Error("Model class metadata is incomplete.");
+ }
+ return ort.InferenceSession.create(new URL("fieldmark.onnx", modelBase).href, {
+ executionProviders: ["wasm"],
+ graphOptimizationLevel: "all"
+ });
+ }).then(function (loadedSession) {
+ session = loadedSession;
+ return {
+ architecture: metadata.architecture,
+ classCount: metadata.class_count,
+ device: "this browser (WebAssembly)",
+ artifactSize: metadata.artifact.size_bytes
+ };
+ });
+ return initialization;
+ }
+
+ function predict(file, options) {
+ options = options || {};
+ var started = performance.now();
+ var decodedImage;
+ return init().then(function () {
+ return loadBitmap(file);
+ }).then(function (image) {
+ decodedImage = image;
+ var input = preprocess(image);
+ if (typeof image.close === "function") image.close();
+ var tensor = new ort.Tensor("float32", input, metadata.input.shape);
+ var feeds = {};
+ feeds[metadata.input.name] = tensor;
+ return session.run(feeds).then(function (outputs) {
+ if (typeof tensor.dispose === "function") tensor.dispose();
+ return outputs[metadata.output.name] || outputs[session.outputNames[0]];
+ });
+ }).then(function (output) {
+ var temperature = metadata.calibration.temperature;
+ var probabilities = softmax(output.data, temperature);
+ if (typeof output.dispose === "function") output.dispose();
+ var threshold = Math.min(Math.max(
+ Number(options.threshold || metadata.defaults.unknown_threshold), 0.05
+ ), 0.95);
+ var topK = Math.min(Math.max(
+ parseInt(options.topK || metadata.defaults.top_k, 10), 1
+ ), metadata.class_count);
+ var verdict = uncertainty(probabilities, threshold);
+ var ranked = Array.from(probabilities, function (probability, index) {
+ return { internal_label: index, probability: probability };
+ }).sort(function (a, b) {
+ return b.probability - a.probability;
+ }).slice(0, topK).map(function (candidate) {
+ candidate.class_name = metadata.class_names[candidate.internal_label];
+ return candidate;
+ });
+
+ return {
+ filename: file.name,
+ predicted_class: ranked[0].class_name,
+ predicted_label: ranked[0].internal_label,
+ probability: ranked[0].probability,
+ uncertainty_status: verdict.status,
+ top_k: ranked,
+ entropy: verdict.entropy,
+ normalized_entropy: verdict.normalizedEntropy,
+ margin: verdict.margin,
+ unknown_threshold: threshold,
+ temperature: temperature,
+ warning: warningFor(verdict.status),
+ inference_time_ms: performance.now() - started,
+ model: {
+ architecture: metadata.architecture,
+ device: "browser / WebAssembly",
+ image_size: metadata.input.crop_size,
+ num_classes: metadata.class_count
+ }
+ };
+ }).catch(function (error) {
+ if (decodedImage && typeof decodedImage.close === "function") decodedImage.close();
+ throw error;
+ });
+ }
+
+ window.FieldmarkBrowser = {
+ init: init,
+ predict: predict,
+ getMetadata: function () { return metadata; }
+ };
+})();
diff --git a/webapp.py b/webapp.py
new file mode 100644
index 0000000..de19394
--- /dev/null
+++ b/webapp.py
@@ -0,0 +1,305 @@
+"""Local web interface for NABirds bird recognition.
+
+Serves the static site in ``web/`` and a small JSON API around
+:class:`src.inference.BirdPredictor`. Everything runs on this machine;
+no image or result leaves the local process.
+
+Run with:
+
+ python webapp.py
+"""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import threading
+from pathlib import Path
+
+import uvicorn
+from PIL import Image, UnidentifiedImageError
+from starlette.applications import Starlette
+from starlette.requests import Request
+from starlette.responses import FileResponse, JSONResponse
+from starlette.routing import Mount, Route
+from starlette.staticfiles import StaticFiles
+
+from src.config import load_config
+
+ROOT = Path(__file__).resolve().parent
+WEB_DIR = ROOT / "web"
+
+config = load_config()
+
+# Species shown in the "How it works" gallery. Matched case-insensitively as
+# substrings against the trained class names; missing species are skipped.
+SHOWCASE_SPECIES = [
+ "Wood Duck (Breeding male)",
+ "Atlantic Puffin",
+ "Northern Cardinal (Adult male)",
+ "Cedar Waxwing",
+ "Blue Jay",
+ "Painted Bunting (Adult male)",
+ "American Goldfinch (Breeding Male)",
+ "Snowy Owl",
+ "Baltimore Oriole (Adult male)",
+ "Ruby-throated Hummingbird (Adult Male)",
+ "Belted Kingfisher",
+ "Mountain Bluebird",
+]
+
+_predictor = None
+_predictor_error: str | None = None
+_predictor_lock = threading.Lock()
+
+_showcase_cache: list[dict] | None = None
+_image_index: dict[str, Path] | None = None
+_showcase_lock = threading.Lock()
+
+
+def _load_predictor():
+ """Load the predictor once, from any thread, remembering failures."""
+ global _predictor, _predictor_error
+ with _predictor_lock:
+ if _predictor is not None or _predictor_error is not None:
+ return _predictor
+ model_path = Path(config["app"]["model_path"]).expanduser()
+ if not model_path.exists():
+ _predictor_error = (
+ f"No trained model at {model_path}. Train one first with "
+ "python -m src.train."
+ )
+ return None
+ try:
+ from src.inference import BirdPredictor
+
+ _predictor = BirdPredictor(
+ str(model_path.resolve()),
+ config["app"]["metadata_path"],
+ config["project"]["output_dir"] / "calibration.json",
+ )
+ except Exception as exc: # surfaced to the UI, not silenced
+ _predictor_error = f"The local model could not be loaded: {exc}"
+ return _predictor
+
+
+def _dataset_file(name: str) -> Path | None:
+ root = Path(config["data"]["root_dir"])
+ direct = root / name
+ if direct.exists():
+ return direct
+ return next(root.rglob(name), None)
+
+
+def _build_showcase() -> tuple[list[dict], dict[str, Path]]:
+ """Pick one local dataset image for each showcase species."""
+ metadata_path = Path(config["app"]["metadata_path"])
+ images_txt = _dataset_file("images.txt")
+ labels_txt = _dataset_file("image_class_labels.txt")
+ if not metadata_path.exists() or images_txt is None or labels_txt is None:
+ return [], {}
+
+ with metadata_path.open("r", encoding="utf-8") as handle:
+ metadata = json.load(handle)
+ class_names = metadata["class_names"]
+ label_to_original = metadata["internal_label_to_original_class_id"]
+
+ wanted: dict[str, str] = {} # original class id -> display species name
+ for species in SHOWCASE_SPECIES:
+ needle = species.lower()
+ for internal, name in enumerate(class_names):
+ if needle in name.lower():
+ original = str(label_to_original[str(internal)])
+ wanted.setdefault(original, name)
+ break
+
+ class_to_image: dict[str, str] = {}
+ with labels_txt.open("r", encoding="utf-8") as handle:
+ for line in handle:
+ image_id, class_id = line.split()
+ if class_id in wanted and class_id not in class_to_image:
+ class_to_image[class_id] = image_id
+
+ image_base = images_txt.parent / "images"
+ paths: dict[str, str] = {}
+ with images_txt.open("r", encoding="utf-8") as handle:
+ for line in handle:
+ image_id, rel_path = line.split(maxsplit=1)
+ paths[image_id] = rel_path.strip()
+
+ entries: list[dict] = []
+ index: dict[str, Path] = {}
+ for species in SHOWCASE_SPECIES:
+ needle = species.lower()
+ for class_id, name in wanted.items():
+ if needle in name.lower() and class_id in class_to_image:
+ image_id = class_to_image[class_id]
+ image_path = image_base / paths.get(image_id, "")
+ if image_path.exists():
+ index[image_id] = image_path
+ entries.append({"species": name, "url": f"/api/bird-image/{image_id}"})
+ break
+ return entries, index
+
+
+def _showcase() -> tuple[list[dict], dict[str, Path]]:
+ global _showcase_cache, _image_index
+ with _showcase_lock:
+ if _showcase_cache is None:
+ _showcase_cache, _image_index = _build_showcase()
+ return _showcase_cache, _image_index
+
+
+async def index(request: Request) -> FileResponse:
+ return FileResponse(WEB_DIR / "index.html")
+
+
+async def status(request: Request) -> JSONResponse:
+ payload: dict = {
+ "model_available": Path(config["app"]["model_path"]).expanduser().exists(),
+ "model_loaded": _predictor is not None,
+ "model_error": _predictor_error,
+ "default_threshold": float(config["evaluation"]["unknown_threshold"]),
+ "default_top_k": int(config["evaluation"]["top_k"]),
+ "max_upload_mb": int(config["app"]["max_upload_size_mb"]),
+ }
+ if _predictor is not None:
+ payload.update(
+ architecture=_predictor.architecture,
+ num_classes=_predictor.num_classes,
+ device=str(_predictor.device),
+ temperature=_predictor.temperature,
+ )
+ metrics_path = Path(config["project"]["reports_dir"]) / "test_metrics.json"
+ if metrics_path.exists():
+ with metrics_path.open("r", encoding="utf-8") as handle:
+ metrics = json.load(handle)
+ payload["metrics"] = {
+ key: metrics.get(key)
+ for key in (
+ "top1_accuracy",
+ "top3_accuracy",
+ "top5_accuracy",
+ "expected_calibration_error",
+ "class_count",
+ "image_count",
+ )
+ }
+ return JSONResponse(payload)
+
+
+async def predict(request: Request) -> JSONResponse:
+ predictor = _load_predictor()
+ if predictor is None:
+ return JSONResponse({"error": _predictor_error}, status_code=503)
+
+ form = await request.form()
+ upload = form.get("file")
+ if upload is None or isinstance(upload, str):
+ return JSONResponse({"error": "No image file was uploaded."}, status_code=400)
+
+ data = await upload.read()
+ limit = int(config["app"]["max_upload_size_mb"]) * 1024 * 1024
+ if len(data) > limit:
+ return JSONResponse(
+ {
+ "error": f"{upload.filename} is larger than the "
+ f"{config['app']['max_upload_size_mb']} MB limit."
+ },
+ status_code=413,
+ )
+ try:
+ image = Image.open(io.BytesIO(data)).convert("RGB")
+ except (OSError, UnidentifiedImageError):
+ return JSONResponse(
+ {"error": f"{upload.filename} is not a readable supported image."},
+ status_code=400,
+ )
+
+ try:
+ top_k = int(form.get("top_k", config["evaluation"]["top_k"]))
+ threshold = float(form.get("threshold", config["evaluation"]["unknown_threshold"]))
+ except (TypeError, ValueError):
+ return JSONResponse({"error": "Invalid top_k or threshold value."}, status_code=400)
+
+ try:
+ result = predictor.predict(
+ image,
+ top_k=max(1, min(top_k, 10)),
+ unknown_threshold=min(max(threshold, 0.05), 0.95),
+ )
+ except Exception as exc:
+ return JSONResponse({"error": str(exc)}, status_code=500)
+
+ result["filename"] = upload.filename
+ result["model"] = {
+ "architecture": predictor.architecture,
+ "device": str(predictor.device),
+ "image_size": predictor.image_size,
+ "num_classes": predictor.num_classes,
+ }
+ return JSONResponse(result)
+
+
+async def showcase(request: Request) -> JSONResponse:
+ entries, _ = _showcase()
+ return JSONResponse({"birds": entries})
+
+
+async def bird_image(request: Request):
+ _, index_map = _showcase()
+ image_path = index_map.get(request.path_params["image_id"])
+ if image_path is None:
+ return JSONResponse({"error": "Unknown image."}, status_code=404)
+ return FileResponse(image_path)
+
+
+@contextlib.asynccontextmanager
+async def lifespan(app: Starlette):
+ # Warm the model in the background so the first prediction is fast.
+ threading.Thread(target=_load_predictor, daemon=True).start()
+ yield
+
+
+app = Starlette(
+ routes=[
+ Route("/", index),
+ Route("/api/status", status),
+ Route("/api/predict", predict, methods=["POST"]),
+ Route("/api/showcase", showcase),
+ Route("/api/bird-image/{image_id}", bird_image),
+ Mount("/static", StaticFiles(directory=WEB_DIR / "static"), name="static"),
+ ],
+ lifespan=lifespan,
+)
+
+
+def _lan_ip() -> str:
+ """Best-effort local network IP, so the startup banner shows a usable URL."""
+ import socket
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ # No packets are sent; this just picks the outbound interface.
+ sock.connect(("8.8.8.8", 80))
+ return sock.getsockname()[0]
+ except OSError:
+ return "127.0.0.1"
+ finally:
+ sock.close()
+
+
+if __name__ == "__main__":
+ # Bind to every interface so other devices on the same network can connect.
+ # This serves the upload/predict API without authentication or encryption,
+ # so only run it on a trusted network (e.g. home Wi-Fi).
+ # Port 80 lets other devices drop the ":port" suffix (http://).
+ # Override with FIELDMARK_PORT if 80 is taken.
+ port = int(os.environ.get("FIELDMARK_PORT", "80"))
+ suffix = "" if port == 80 else f":{port}"
+ print("Fieldmark is running. Open it from any device on this network:")
+ print(f" this computer: http://127.0.0.1{suffix}")
+ print(f" other devices: http://{_lan_ip()}{suffix}")
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")