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 + +[![Deploy Fieldmark to GitHub Pages](https://github.com/du000362/Bird-Detector/actions/workflows/pages.yml/badge.svg)](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 + + + + + + + + + + + + +
+
+
+ + + + +
+
+

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.

+ +
+ + +
+ +
+
0bird classes
+
0training photographs
+
0top-5 test accuracyright class appears among five guesses
+
0calibration 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.

+
+
+
01Decode

Read the image in local browser memory.

+
02Prepare

Resize, center-crop, and normalize to 224 × 224.

+
03Recognize

Run EfficientNet-B0 across all 555 learned classes.

+
04Calibrate

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.

+
+
+
+ + + + +
+ + + + + + + + + + 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")