Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- backend/__init__.py +0 -0
- backend/config.py +65 -0
- backend/data/__init__.py +17 -0
- backend/data/eda.py +121 -0
- backend/data/label_mapping.py +29 -0
- backend/data/patch_dataset.py +118 -0
- backend/data/split.py +92 -0
- backend/data/transforms.py +58 -0
- backend/db/schema.sql +173 -0
- backend/db/seed_demo.sql +85 -0
- backend/inference/__init__.py +1 -0
- backend/inference/classifier.py +126 -0
- backend/inference/enhancer.py +14 -0
- backend/inference/frame_extractor.py +57 -0
- backend/inference/pipeline.py +146 -0
- backend/inference/segmenter.py +91 -0
- backend/jobs.py +59 -0
- backend/main.py +148 -0
- backend/models/train_dinov2.py +317 -0
- backend/observability.py +80 -0
- backend/persistence.py +157 -0
- backend/requirements.txt +48 -0
- backend/schemas.py +65 -0
- backend/tests/__init__.py +0 -0
- backend/tests/test_api.py +82 -0
- backend/tests/test_observability.py +65 -0
backend/__init__.py
ADDED
|
File without changes
|
backend/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runtime configuration from environment. Phase 5.
|
| 2 |
+
|
| 3 |
+
All secrets come from env / .env (never hardcoded — see CLAUDE.md). Everything degrades
|
| 4 |
+
gracefully when a secret is absent so the app runs locally without Supabase / R2 / weights:
|
| 5 |
+
- no Supabase creds -> logging is a no-op
|
| 6 |
+
- no R2 creds -> uploads return a local placeholder url
|
| 7 |
+
- weights not on Hub -> inference runs in STUB mode (contract-valid synthetic output)
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _flag(name: str) -> bool:
|
| 16 |
+
return os.getenv(name, "").lower() in ("1", "true", "yes")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class Settings:
|
| 21 |
+
# --- model weights (HF Hub) ---
|
| 22 |
+
hf_repo: str = os.getenv("HF_MODEL_REPO", "HrishiKabra/reefscan-dinov2-coral")
|
| 23 |
+
hf_stage: str = os.getenv("HF_MODEL_STAGE", "linear_probe")
|
| 24 |
+
hf_token: str | None = os.getenv("HF_TOKEN") or None
|
| 25 |
+
|
| 26 |
+
# --- Supabase (logging) ---
|
| 27 |
+
# Use the SERVICE_ROLE key here (server-side only) — tables have RLS enabled, so the
|
| 28 |
+
# service_role key (which bypasses RLS) is required to read/write. SUPABASE_KEY is the
|
| 29 |
+
# canonical name; SUPABASE_ANON_KEY is accepted for backward-compat.
|
| 30 |
+
supabase_url: str | None = os.getenv("SUPABASE_URL") or None
|
| 31 |
+
supabase_key: str | None = os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY") or None
|
| 32 |
+
|
| 33 |
+
# --- object storage ---
|
| 34 |
+
# Default = Supabase Storage (reuses the Supabase service_role client; no extra account).
|
| 35 |
+
# Optionally override with Cloudflare R2 by setting all four R2_* vars.
|
| 36 |
+
storage_bucket: str = os.getenv("STORAGE_BUCKET", "reefscan-uploads")
|
| 37 |
+
r2_endpoint: str | None = os.getenv("R2_ENDPOINT") or None
|
| 38 |
+
r2_key_id: str | None = os.getenv("R2_ACCESS_KEY_ID") or None
|
| 39 |
+
r2_secret: str | None = os.getenv("R2_SECRET_ACCESS_KEY") or None
|
| 40 |
+
r2_bucket: str | None = os.getenv("R2_BUCKET") or None
|
| 41 |
+
|
| 42 |
+
# --- locked AMG config (Phase 1.5; do NOT inline elsewhere) ---
|
| 43 |
+
amg_points_per_side: int = 16
|
| 44 |
+
amg_longest_edge: int = 512
|
| 45 |
+
|
| 46 |
+
# --- conformal ---
|
| 47 |
+
coverage_alpha: float = 0.10 # 90% target; qhat loaded from HF conformal.json
|
| 48 |
+
|
| 49 |
+
# --- modeling ---
|
| 50 |
+
classes: tuple[str, ...] = ("healthy", "bleached")
|
| 51 |
+
input_size: int = 224
|
| 52 |
+
|
| 53 |
+
# force synthetic inference (also auto-enabled if weights fail to load)
|
| 54 |
+
stub_mode: bool = _flag("REEFSCAN_STUB")
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def supabase_enabled(self) -> bool:
|
| 58 |
+
return bool(self.supabase_url and self.supabase_key)
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def r2_enabled(self) -> bool:
|
| 62 |
+
return bool(self.r2_endpoint and self.r2_key_id and self.r2_secret and self.r2_bucket)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
settings = Settings()
|
backend/data/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ReefScan data pipeline (Phase 2): imagefolder patch Dataset, transforms, EDA.
|
| 2 |
+
|
| 3 |
+
CoralPatchDataset (ImageFolder) is the loader for the current 2-class NOAA dataset.
|
| 4 |
+
backend.data.split is retained for FUTURE point-annotated sources (not used here).
|
| 5 |
+
"""
|
| 6 |
+
from .label_mapping import CLASSES, CLASS_TO_IDX, IDX_TO_CLASS, LABEL_MAPPING, map_label
|
| 7 |
+
from .patch_dataset import CoralPatchDataset, scan_imagefolder
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"CoralPatchDataset",
|
| 11 |
+
"scan_imagefolder",
|
| 12 |
+
"CLASSES",
|
| 13 |
+
"CLASS_TO_IDX",
|
| 14 |
+
"IDX_TO_CLASS",
|
| 15 |
+
"LABEL_MAPPING",
|
| 16 |
+
"map_label",
|
| 17 |
+
]
|
backend/data/eda.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""EDA over the CoralNet annotations. Phase 2.
|
| 2 |
+
|
| 3 |
+
Its main job is to produce exactly what's needed to author backend/data/label_mapping.py
|
| 4 |
+
and choose a loss strategy. When run against real data it prints:
|
| 5 |
+
|
| 6 |
+
1. Raw label frequency table — every CoralNet label found, sorted by count, printed
|
| 7 |
+
VERBATIM (no normalization/stripping) so the strings can be pasted into LABEL_MAPPING.
|
| 8 |
+
2. Per-image label distribution — points/image and distinct-labels/image stats, plus a
|
| 9 |
+
full image x label count matrix written to data/eda_per_image.csv.
|
| 10 |
+
3. Class imbalance ratio — on the 4-class mapped distribution if LABEL_MAPPING is filled,
|
| 11 |
+
otherwise on raw labels as a proxy.
|
| 12 |
+
4. A recommendation — standard CE vs. class-weighting/oversampling vs. focal loss.
|
| 13 |
+
|
| 14 |
+
Run: python -m backend.data.eda [--csv data/annotations.csv] [--per-image-out data/eda_per_image.csv]
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import pandas as pd
|
| 22 |
+
|
| 23 |
+
from .label_mapping import CLASSES, LABEL_MAPPING, map_label
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _imbalance_report(counts: dict[str, int], basis: str) -> str:
|
| 27 |
+
vals = [c for c in counts.values() if c > 0]
|
| 28 |
+
if not vals:
|
| 29 |
+
return f"imbalance ({basis}): no labeled samples."
|
| 30 |
+
hi, lo = max(vals), min(vals)
|
| 31 |
+
ratio = hi / lo
|
| 32 |
+
lines = [f"imbalance ratio ({basis}) = {ratio:.1f}:1 (largest {hi} / smallest {lo})"]
|
| 33 |
+
if ratio < 3:
|
| 34 |
+
lines.append(" -> roughly balanced. Standard cross-entropy; no resampling needed.")
|
| 35 |
+
elif ratio < 10:
|
| 36 |
+
lines.append(" -> MODERATE imbalance. Use class-weighted cross-entropy "
|
| 37 |
+
"(weight = 1/freq) OR oversample minority classes.")
|
| 38 |
+
else:
|
| 39 |
+
lines.append(" -> SEVERE imbalance. Recommend focal loss (gamma~2) and/or "
|
| 40 |
+
"oversampling minority classes; consider undersampling the dominant class.")
|
| 41 |
+
if lo < 50:
|
| 42 |
+
lines.append(f" !! smallest class has only {lo} samples — likely too few for a "
|
| 43 |
+
"reliable linear probe. Consider merging classes or collecting more.")
|
| 44 |
+
return "\n".join(lines)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_eda(csv_path: str | Path, per_image_out: str | Path = "data/eda_per_image.csv") -> None:
|
| 48 |
+
# dtype=str on label keeps the raw CoralNet strings EXACTLY as written (no coercion).
|
| 49 |
+
df = pd.read_csv(csv_path, dtype={"label": str})
|
| 50 |
+
n = len(df)
|
| 51 |
+
n_images = df["image_name"].nunique()
|
| 52 |
+
|
| 53 |
+
print(f"\n=== ReefScan EDA :: {csv_path} ===")
|
| 54 |
+
print(f"annotations: {n} images: {n_images} "
|
| 55 |
+
f"avg points/image: {n / max(n_images, 1):.1f}")
|
| 56 |
+
|
| 57 |
+
# --- 1. Raw label frequency table (verbatim strings, sorted by count) -------------
|
| 58 |
+
print("\n--- 1. RAW CoralNet label frequency (verbatim, sorted) ---")
|
| 59 |
+
raw_counts = df["label"].value_counts() # already sorted desc
|
| 60 |
+
width = max((len(str(l)) for l in raw_counts.index), default=8)
|
| 61 |
+
print(f" {'label'.ljust(width)} {'count':>7} {'pct':>6} -> mapped")
|
| 62 |
+
for label, c in raw_counts.items():
|
| 63 |
+
mapped = map_label(label)
|
| 64 |
+
if mapped is not None:
|
| 65 |
+
tag = mapped
|
| 66 |
+
elif label in LABEL_MAPPING:
|
| 67 |
+
tag = "DROP"
|
| 68 |
+
else:
|
| 69 |
+
tag = "UNMAPPED"
|
| 70 |
+
print(f" {str(label).ljust(width)} {c:>7} {100 * c / n:>5.1f}% -> {tag}")
|
| 71 |
+
|
| 72 |
+
unmapped = sorted(l for l in raw_counts.index if l not in LABEL_MAPPING)
|
| 73 |
+
if unmapped:
|
| 74 |
+
print(f"\n !! {len(unmapped)} raw label(s) UNMAPPED — add each to LABEL_MAPPING:")
|
| 75 |
+
print(f" {unmapped}")
|
| 76 |
+
|
| 77 |
+
# --- 2. Per-image label distribution ----------------------------------------------
|
| 78 |
+
print("\n--- 2. Per-image label distribution ---")
|
| 79 |
+
points_per_image = df.groupby("image_name").size()
|
| 80 |
+
labels_per_image = df.groupby("image_name")["label"].nunique()
|
| 81 |
+
print(f" points/image : min {points_per_image.min()} median "
|
| 82 |
+
f"{points_per_image.median():.0f} mean {points_per_image.mean():.1f} "
|
| 83 |
+
f"max {points_per_image.max()}")
|
| 84 |
+
print(f" distinct labels/image: min {labels_per_image.min()} median "
|
| 85 |
+
f"{labels_per_image.median():.0f} max {labels_per_image.max()}")
|
| 86 |
+
matrix = pd.crosstab(df["image_name"], df["label"]) # image x label counts
|
| 87 |
+
Path(per_image_out).parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
+
matrix.to_csv(per_image_out)
|
| 89 |
+
print(f" full image x label count matrix ({matrix.shape[0]} images x "
|
| 90 |
+
f"{matrix.shape[1]} labels) written to: {per_image_out}")
|
| 91 |
+
print(" head:")
|
| 92 |
+
print(matrix.head(10).to_string().replace("\n", "\n "))
|
| 93 |
+
|
| 94 |
+
# --- 3 & 4. Imbalance ratio + recommendation --------------------------------------
|
| 95 |
+
print("\n--- 3/4. Class imbalance + loss recommendation ---")
|
| 96 |
+
if LABEL_MAPPING:
|
| 97 |
+
mapped_counts = {cls: 0 for cls in CLASSES}
|
| 98 |
+
for label, c in raw_counts.items():
|
| 99 |
+
m = map_label(label)
|
| 100 |
+
if m is not None:
|
| 101 |
+
mapped_counts[m] += int(c)
|
| 102 |
+
total_mapped = sum(mapped_counts.values()) or 1
|
| 103 |
+
nclass = len(CLASSES)
|
| 104 |
+
print(f" {nclass}-class distribution:")
|
| 105 |
+
for cls in CLASSES:
|
| 106 |
+
c = mapped_counts[cls]
|
| 107 |
+
print(f" {cls:<16} {c:>7} ({100 * c / total_mapped:.1f}%)")
|
| 108 |
+
print(_imbalance_report(mapped_counts, basis=f"{nclass}-class"))
|
| 109 |
+
else:
|
| 110 |
+
print(" LABEL_MAPPING is EMPTY — reporting imbalance on RAW labels as a proxy.")
|
| 111 |
+
print(" (Recompute after filling the mapping; the 4-class ratio is what matters.)")
|
| 112 |
+
print(_imbalance_report(dict(raw_counts), basis="raw labels, PROXY"))
|
| 113 |
+
print()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
ap = argparse.ArgumentParser()
|
| 118 |
+
ap.add_argument("--csv", default="data/annotations.csv")
|
| 119 |
+
ap.add_argument("--per-image-out", default="data/eda_per_image.csv")
|
| 120 |
+
args = ap.parse_args()
|
| 121 |
+
run_eda(args.csv, args.per_image_out)
|
backend/data/label_mapping.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Raw dataset label -> ReefScan class mapping.
|
| 2 |
+
|
| 3 |
+
INITIAL MODEL IS 2-CLASS: healthy | bleached
|
| 4 |
+
(Locked from EDA of NMFS-OSI/NOAA-PIFSC-ESD-CORAL-Bleaching-Dataset, which contains only
|
| 5 |
+
two health states: CORAL = healthy, CORAL_BL = bleached. No taxonomy, no dead/algae.)
|
| 6 |
+
|
| 7 |
+
The DB `coral_label` enum still reserves `dead` and `algae_covered` for a future
|
| 8 |
+
extension (e.g. ReefNet supplementation) — those are NOT modeled yet, but keeping the
|
| 9 |
+
enum values means no DB migration is needed when they arrive. The MODEL head, UI, and
|
| 10 |
+
conformal sets are all 2-class for now.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
# The valid target classes for the CURRENT model. 2-class initial model.
|
| 15 |
+
# (The DB enum reserves "dead" and "algae_covered" for future extension — see schema.sql.)
|
| 16 |
+
CLASSES: tuple[str, ...] = ("healthy", "bleached")
|
| 17 |
+
CLASS_TO_IDX: dict[str, int] = {c: i for i, c in enumerate(CLASSES)}
|
| 18 |
+
IDX_TO_CLASS: dict[int, str] = {i: c for i, c in enumerate(CLASSES)}
|
| 19 |
+
|
| 20 |
+
# raw dataset label -> one of CLASSES, or None to explicitly drop.
|
| 21 |
+
LABEL_MAPPING: dict[str, str | None] = {
|
| 22 |
+
"CORAL": "healthy",
|
| 23 |
+
"CORAL_BL": "bleached",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def map_label(raw_label: str) -> str | None:
|
| 28 |
+
"""Return the ReefScan class for a raw dataset label, or None if unmapped/dropped."""
|
| 29 |
+
return LABEL_MAPPING.get(raw_label)
|
backend/data/patch_dataset.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Coral colony-patch classification dataset (ImageFolder format). Phase 2.
|
| 2 |
+
|
| 3 |
+
Built for NMFS-OSI/NOAA-PIFSC-ESD-CORAL-Bleaching-Dataset, which ships as an imagefolder:
|
| 4 |
+
|
| 5 |
+
<root>/<split>/<RAW_LABEL>/<image>.PNG e.g. train/CORAL/FFS-B013_2019_15_1024.PNG
|
| 6 |
+
|
| 7 |
+
These are pre-cropped, image-level colony patches (one health label per image) — NOT point
|
| 8 |
+
annotations. So training uses the WHOLE patch (resize -> 224, ImageNet-normalize); there is
|
| 9 |
+
no point/centroid crop here. Raw folder labels (CORAL, CORAL_BL) are collapsed to the
|
| 10 |
+
ReefScan classes via label_mapping.LABEL_MAPPING.
|
| 11 |
+
|
| 12 |
+
IMPORTANT: use the dataset's NATIVE train/val/test splits (the directory names). Do NOT
|
| 13 |
+
re-split with backend.data.split — the NOAA splits are site/year-controlled to prevent
|
| 14 |
+
leakage. backend.data.split is retained only for FUTURE point-annotated sources.
|
| 15 |
+
|
| 16 |
+
The inference-time bridge (Phase 5): SAM2 mask -> bbox crop -> resize 224 -> classify. The
|
| 17 |
+
training distribution (colony-level crops) closely matches those mask-bbox crops, so this
|
| 18 |
+
imagefolder design is a cleaner train/inference match than the original centroid-patch plan.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
from collections import Counter
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
from PIL import Image
|
| 27 |
+
from torch.utils.data import Dataset
|
| 28 |
+
|
| 29 |
+
from .label_mapping import CLASS_TO_IDX, LABEL_MAPPING, map_label
|
| 30 |
+
from .transforms import build_transform
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
IMG_EXTENSIONS = (".png", ".jpg", ".jpeg", ".tif", ".tiff")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def scan_imagefolder(root: str | Path, split: str) -> list[tuple[Path, str]]:
|
| 38 |
+
"""Return [(image_path, raw_label), ...] for <root>/<split>/<RAW_LABEL>/*.
|
| 39 |
+
|
| 40 |
+
raw_label is the immediate parent directory name (e.g. "CORAL", "CORAL_BL").
|
| 41 |
+
"""
|
| 42 |
+
split_dir = Path(root) / split
|
| 43 |
+
if not split_dir.is_dir():
|
| 44 |
+
raise FileNotFoundError(f"split dir not found: {split_dir}")
|
| 45 |
+
samples: list[tuple[Path, str]] = []
|
| 46 |
+
for label_dir in sorted(p for p in split_dir.iterdir() if p.is_dir()):
|
| 47 |
+
raw_label = label_dir.name
|
| 48 |
+
for img in label_dir.rglob("*"):
|
| 49 |
+
if img.suffix.lower() in IMG_EXTENSIONS:
|
| 50 |
+
samples.append((img, raw_label))
|
| 51 |
+
return samples
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class CoralPatchDataset(Dataset):
|
| 55 |
+
"""ImageFolder-style health-state classification dataset."""
|
| 56 |
+
|
| 57 |
+
def __init__(
|
| 58 |
+
self,
|
| 59 |
+
samples: list[tuple[Path, str]],
|
| 60 |
+
train: bool = False,
|
| 61 |
+
allow_empty_mapping: bool = False,
|
| 62 |
+
) -> None:
|
| 63 |
+
"""
|
| 64 |
+
Args:
|
| 65 |
+
samples: list of (image_path, raw_label) — build with `from_imagefolder`.
|
| 66 |
+
train: apply train-time augmentations if True.
|
| 67 |
+
allow_empty_mapping: TEST-MODE ESCAPE HATCH. Must be True to construct while
|
| 68 |
+
LABEL_MAPPING is empty. Real training/inference leaves this False so an
|
| 69 |
+
unfilled mapping fails loudly instead of passing raw label strings as
|
| 70 |
+
targets into Phase 3.
|
| 71 |
+
"""
|
| 72 |
+
# Guard: never let an unfilled label_mapping.py reach training as a no-op.
|
| 73 |
+
if not LABEL_MAPPING and not allow_empty_mapping:
|
| 74 |
+
raise RuntimeError(
|
| 75 |
+
"LABEL_MAPPING is empty — fill backend/data/label_mapping.py from the EDA "
|
| 76 |
+
"raw-label table before building a real CoralPatchDataset. "
|
| 77 |
+
"Pass allow_empty_mapping=True ONLY in tests/smoke checks."
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
self.transform = build_transform(train=train)
|
| 81 |
+
|
| 82 |
+
kept: list[tuple[Path, int]] = []
|
| 83 |
+
dropped: Counter = Counter()
|
| 84 |
+
for path, raw in samples:
|
| 85 |
+
mapped = map_label(raw)
|
| 86 |
+
if mapped is None:
|
| 87 |
+
dropped[raw] += 1
|
| 88 |
+
continue
|
| 89 |
+
kept.append((path, CLASS_TO_IDX[mapped]))
|
| 90 |
+
if dropped:
|
| 91 |
+
logger.warning("Dropping %d annotations with unmapped/None labels: %s",
|
| 92 |
+
sum(dropped.values()), dict(dropped))
|
| 93 |
+
self.samples = kept
|
| 94 |
+
if not kept:
|
| 95 |
+
logger.warning(
|
| 96 |
+
"CoralPatchDataset is EMPTY — LABEL_MAPPING covers none of the raw labels."
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
@classmethod
|
| 100 |
+
def from_imagefolder(
|
| 101 |
+
cls, root: str | Path, split: str, train: bool | None = None,
|
| 102 |
+
allow_empty_mapping: bool = False,
|
| 103 |
+
) -> "CoralPatchDataset":
|
| 104 |
+
"""Build from <root>/<split>/<RAW_LABEL>/*. Augments iff split == 'train' (override
|
| 105 |
+
with `train`)."""
|
| 106 |
+
samples = scan_imagefolder(root, split)
|
| 107 |
+
do_train = (split == "train") if train is None else train
|
| 108 |
+
return cls(samples, train=do_train, allow_empty_mapping=allow_empty_mapping)
|
| 109 |
+
|
| 110 |
+
def __len__(self) -> int:
|
| 111 |
+
return len(self.samples)
|
| 112 |
+
|
| 113 |
+
def __getitem__(self, idx: int):
|
| 114 |
+
path, label_idx = self.samples[idx]
|
| 115 |
+
with Image.open(path) as im:
|
| 116 |
+
im = im.convert("RGB")
|
| 117 |
+
tensor = self.transform(im)
|
| 118 |
+
return tensor, label_idx
|
backend/data/split.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train/val/test split, stratified BY IMAGE on the image's majority class. Phase 2.
|
| 2 |
+
|
| 3 |
+
RESERVED FOR FUTURE POINT-ANNOTATED SOURCES — NOT used for the current NOAA dataset.
|
| 4 |
+
The NOAA-PIFSC bleaching dataset ships with native, site/year-controlled train/val/test
|
| 5 |
+
splits, so we use those directly (see patch_dataset.CoralPatchDataset.from_imagefolder)
|
| 6 |
+
and must NOT re-split it here (that would risk the very site leakage NOAA already
|
| 7 |
+
controlled for). This module is kept for a future CoralNet-style point-annotated source.
|
| 8 |
+
|
| 9 |
+
Splitting by image (not by point) prevents leakage — many points share one image, so a
|
| 10 |
+
point-level split would put correlated patches from the same image in both train and val.
|
| 11 |
+
Each image is assigned its majority mapped label and stratified on that.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import pandas as pd
|
| 18 |
+
from sklearn.model_selection import train_test_split
|
| 19 |
+
|
| 20 |
+
from .label_mapping import map_label
|
| 21 |
+
|
| 22 |
+
DEFAULT_RATIOS = (0.70, 0.15, 0.15) # train, val, test
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _split_once(frame: pd.DataFrame, test_size: float, random_state: int,
|
| 26 |
+
stratify_col: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
| 27 |
+
"""train_test_split, stratified when possible, falling back to random.
|
| 28 |
+
|
| 29 |
+
Stratification needs >=2 samples per class AND each resulting partition to hold at
|
| 30 |
+
least one sample per class; on tiny/dev data that can't hold, so we fall back rather
|
| 31 |
+
than crash. On real data (thousands of images) the stratified path is taken.
|
| 32 |
+
"""
|
| 33 |
+
strat = frame[stratify_col]
|
| 34 |
+
if strat.value_counts().min() >= 2:
|
| 35 |
+
try:
|
| 36 |
+
return train_test_split(frame, test_size=test_size,
|
| 37 |
+
random_state=random_state, stratify=strat)
|
| 38 |
+
except ValueError:
|
| 39 |
+
pass # partition too small to carry every class -> fall back
|
| 40 |
+
return train_test_split(frame, test_size=test_size, random_state=random_state)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _image_majority_labels(df: pd.DataFrame) -> pd.DataFrame:
|
| 44 |
+
"""One row per image with its majority mapped label (unmapped points ignored)."""
|
| 45 |
+
d = df.copy()
|
| 46 |
+
d["mapped"] = d["label"].map(map_label)
|
| 47 |
+
d = d[d["mapped"].notna()]
|
| 48 |
+
if len(d) == 0:
|
| 49 |
+
return pd.DataFrame(columns=["image_name", "majority"])
|
| 50 |
+
majority = (
|
| 51 |
+
d.groupby("image_name")["mapped"]
|
| 52 |
+
.agg(lambda s: s.value_counts().idxmax())
|
| 53 |
+
.reset_index()
|
| 54 |
+
.rename(columns={"mapped": "majority"})
|
| 55 |
+
)
|
| 56 |
+
return majority
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def stratified_split_by_image(
|
| 60 |
+
annotations_csv: str | Path,
|
| 61 |
+
ratios: tuple[float, float, float] = DEFAULT_RATIOS,
|
| 62 |
+
random_state: int = 42,
|
| 63 |
+
) -> dict[str, pd.DataFrame]:
|
| 64 |
+
"""Return {'train','val','test'} -> annotation DataFrames (point rows).
|
| 65 |
+
|
| 66 |
+
Stratification falls back to a non-stratified split for any class too small to
|
| 67 |
+
appear in every split (common with tiny/dev data).
|
| 68 |
+
"""
|
| 69 |
+
assert abs(sum(ratios) - 1.0) < 1e-6, "ratios must sum to 1"
|
| 70 |
+
df = pd.read_csv(annotations_csv)
|
| 71 |
+
img_labels = _image_majority_labels(df)
|
| 72 |
+
if len(img_labels) < 3:
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"Need >=3 labeled images to split; got {len(img_labels)}. "
|
| 75 |
+
"This is expected with the synthetic stub data — provide real data."
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
train_r, val_r, test_r = ratios
|
| 79 |
+
train_imgs, hold_imgs = _split_once(
|
| 80 |
+
img_labels, test_size=(val_r + test_r),
|
| 81 |
+
random_state=random_state, stratify_col="majority",
|
| 82 |
+
)
|
| 83 |
+
rel_test = test_r / (val_r + test_r)
|
| 84 |
+
val_imgs, test_imgs = _split_once(
|
| 85 |
+
hold_imgs, test_size=rel_test,
|
| 86 |
+
random_state=random_state, stratify_col="majority",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def rows_for(imgs: pd.DataFrame) -> pd.DataFrame:
|
| 90 |
+
return df[df["image_name"].isin(imgs["image_name"])].reset_index(drop=True)
|
| 91 |
+
|
| 92 |
+
return {"train": rows_for(train_imgs), "val": rows_for(val_imgs), "test": rows_for(test_imgs)}
|
backend/data/transforms.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Preprocessing transforms for DINOv2-B. Phase 2.
|
| 2 |
+
|
| 3 |
+
Patch geometry (locked in CLAUDE.md, must be IDENTICAL in train and inference):
|
| 4 |
+
- Training (current NOAA 2-class data): the dataset images ARE colony patches, so we use
|
| 5 |
+
the WHOLE image — resize to 224x224, ImageNet-normalize. No point/centroid crop.
|
| 6 |
+
- Inference: crop the SAM2 mask's bounding box, then resize 224 + normalize (same tail).
|
| 7 |
+
DINOv2 expects inputs sized to multiples of 14; 224 = 16 * 14.
|
| 8 |
+
|
| 9 |
+
`crop_patch_around_point` below is retained for FUTURE point-annotated sources (paired with
|
| 10 |
+
backend.data.split); it is NOT used by the current imagefolder pipeline.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from PIL import Image
|
| 15 |
+
from torchvision import transforms
|
| 16 |
+
|
| 17 |
+
# DINOv2 uses ImageNet normalization.
|
| 18 |
+
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
| 19 |
+
IMAGENET_STD = (0.229, 0.224, 0.225)
|
| 20 |
+
INPUT_SIZE = 224
|
| 21 |
+
|
| 22 |
+
# Default square window (in source pixels) cropped around a point before resizing.
|
| 23 |
+
# CoralNet-style patch classification; tune in Phase 3 if needed.
|
| 24 |
+
DEFAULT_CROP_SIZE = 224
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_transform(train: bool) -> transforms.Compose:
|
| 28 |
+
"""Return the tensor transform applied to an already-cropped PIL patch."""
|
| 29 |
+
if train:
|
| 30 |
+
return transforms.Compose([
|
| 31 |
+
transforms.Resize((INPUT_SIZE, INPUT_SIZE)),
|
| 32 |
+
transforms.RandomHorizontalFlip(),
|
| 33 |
+
transforms.ColorJitter(brightness=0.2, contrast=0.2),
|
| 34 |
+
transforms.ToTensor(),
|
| 35 |
+
transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
|
| 36 |
+
])
|
| 37 |
+
return transforms.Compose([
|
| 38 |
+
transforms.Resize((INPUT_SIZE, INPUT_SIZE)),
|
| 39 |
+
transforms.ToTensor(),
|
| 40 |
+
transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
|
| 41 |
+
])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def crop_patch_around_point(img: Image.Image, row: int, col: int,
|
| 45 |
+
crop_size: int = DEFAULT_CROP_SIZE) -> Image.Image:
|
| 46 |
+
"""Crop a square `crop_size` window centered on (row, col), clamped to image bounds.
|
| 47 |
+
|
| 48 |
+
(row, col) follow CoralNet convention: row = y (vertical), col = x (horizontal).
|
| 49 |
+
"""
|
| 50 |
+
half = crop_size // 2
|
| 51 |
+
width, height = img.size
|
| 52 |
+
left = max(0, min(col - half, width - crop_size))
|
| 53 |
+
top = max(0, min(row - half, height - crop_size))
|
| 54 |
+
left = max(0, left)
|
| 55 |
+
top = max(0, top)
|
| 56 |
+
right = min(width, left + crop_size)
|
| 57 |
+
bottom = min(height, top + crop_size)
|
| 58 |
+
return img.crop((left, top, right, bottom))
|
backend/db/schema.sql
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ReefScan — Supabase (Postgres) schema
|
| 2 |
+
-- Phase 1 deliverable.
|
| 3 |
+
--
|
| 4 |
+
-- Tables:
|
| 5 |
+
-- jobs — async inference jobs (POST /infer enqueues, GET /infer/{job_id} polls)
|
| 6 |
+
-- reef_locations — named reef sites; uploads attach to one
|
| 7 |
+
-- inference_logs — one row per classified SEGMENT (grouped by request_id);
|
| 8 |
+
-- powers observability + drift views
|
| 9 |
+
-- review_queue — uncertain predictions (conformal set size > 1) awaiting human label
|
| 10 |
+
-- human_labels — confirmed labels from /admin/review; feeds manual retraining
|
| 11 |
+
-- health_snapshots — per-upload aggregate per reef; powers the temporal tracker
|
| 12 |
+
-- (stores BOTH per-class counts and per-class pixel areas)
|
| 13 |
+
--
|
| 14 |
+
-- Conventions: uuid PKs, timestamptz, jsonb for variable-shape fields.
|
| 15 |
+
-- Apply via Supabase SQL editor or `supabase db push`.
|
| 16 |
+
|
| 17 |
+
-- ---------------------------------------------------------------------------
|
| 18 |
+
-- Enums
|
| 19 |
+
-- ---------------------------------------------------------------------------
|
| 20 |
+
-- coral_label keeps all 4 values, but the INITIAL model is 2-class (healthy/bleached);
|
| 21 |
+
-- 'dead' and 'algae_covered' are RESERVED for a future extension (ReefNet supplementation)
|
| 22 |
+
-- so no enum migration is needed when they arrive. See CLAUDE.md / label_mapping.py.
|
| 23 |
+
do $$ begin
|
| 24 |
+
create type coral_label as enum ('healthy', 'bleached', 'dead', 'algae_covered');
|
| 25 |
+
exception when duplicate_object then null; end $$;
|
| 26 |
+
|
| 27 |
+
do $$ begin
|
| 28 |
+
create type review_status as enum ('pending', 'confirmed', 'rejected');
|
| 29 |
+
exception when duplicate_object then null; end $$;
|
| 30 |
+
|
| 31 |
+
do $$ begin
|
| 32 |
+
create type job_status as enum ('queued', 'processing', 'complete', 'failed');
|
| 33 |
+
exception when duplicate_object then null; end $$;
|
| 34 |
+
|
| 35 |
+
-- ---------------------------------------------------------------------------
|
| 36 |
+
-- reef_locations (defined first — referenced by jobs/inference_logs/etc.)
|
| 37 |
+
-- ---------------------------------------------------------------------------
|
| 38 |
+
create table if not exists reef_locations (
|
| 39 |
+
id uuid primary key default gen_random_uuid(),
|
| 40 |
+
name text not null,
|
| 41 |
+
latitude double precision,
|
| 42 |
+
longitude double precision,
|
| 43 |
+
description text,
|
| 44 |
+
created_at timestamptz not null default now()
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
-- ---------------------------------------------------------------------------
|
| 48 |
+
-- jobs (async inference; POST /infer enqueues, GET /infer/{job_id} polls)
|
| 49 |
+
-- Same pipeline for image and video (a video is just N frames).
|
| 50 |
+
-- ---------------------------------------------------------------------------
|
| 51 |
+
create table if not exists jobs (
|
| 52 |
+
job_id uuid primary key default gen_random_uuid(),
|
| 53 |
+
status job_status not null default 'queued',
|
| 54 |
+
reef_location_id uuid references reef_locations(id) on delete set null,
|
| 55 |
+
source_kind text, -- 'image' | 'video'
|
| 56 |
+
source_url text, -- R2 url of the uploaded file
|
| 57 |
+
created_at timestamptz not null default now(),
|
| 58 |
+
completed_at timestamptz,
|
| 59 |
+
result_json jsonb, -- structured pipeline output when complete
|
| 60 |
+
error_message text -- populated when status = 'failed'
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
create index if not exists idx_jobs_status on jobs (status);
|
| 64 |
+
create index if not exists idx_jobs_created on jobs (created_at);
|
| 65 |
+
|
| 66 |
+
-- ---------------------------------------------------------------------------
|
| 67 |
+
-- inference_logs (one row per classified segment)
|
| 68 |
+
-- ---------------------------------------------------------------------------
|
| 69 |
+
create table if not exists inference_logs (
|
| 70 |
+
id uuid primary key default gen_random_uuid(),
|
| 71 |
+
request_id uuid not null, -- groups all segments of one /infer call
|
| 72 |
+
image_id text not null, -- R2 object key / source image id
|
| 73 |
+
segment_id int not null, -- index of the SAM2 mask within the image
|
| 74 |
+
reef_location_id uuid references reef_locations(id) on delete set null,
|
| 75 |
+
|
| 76 |
+
ts timestamptz not null default now(),
|
| 77 |
+
latency_ms integer not null, -- full-image pipeline latency (same per request)
|
| 78 |
+
|
| 79 |
+
-- raw softmax per class (kept as explicit columns for easy SQL aggregation)
|
| 80 |
+
conf_healthy real not null,
|
| 81 |
+
conf_bleached real not null,
|
| 82 |
+
conf_dead real not null,
|
| 83 |
+
conf_algae_covered real not null,
|
| 84 |
+
|
| 85 |
+
-- conformal output
|
| 86 |
+
prediction_set jsonb not null, -- e.g. ["healthy","algae_covered"]
|
| 87 |
+
prediction_set_size int not null, -- 1 = confident, >1 = uncertain
|
| 88 |
+
predicted_label coral_label, -- argmax / point label for convenience
|
| 89 |
+
|
| 90 |
+
model_version text not null,
|
| 91 |
+
|
| 92 |
+
created_at timestamptz not null default now()
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
create index if not exists idx_inflogs_ts on inference_logs (ts);
|
| 96 |
+
create index if not exists idx_inflogs_request on inference_logs (request_id);
|
| 97 |
+
create index if not exists idx_inflogs_location on inference_logs (reef_location_id);
|
| 98 |
+
create index if not exists idx_inflogs_setsize on inference_logs (prediction_set_size);
|
| 99 |
+
create index if not exists idx_inflogs_version on inference_logs (model_version);
|
| 100 |
+
|
| 101 |
+
-- ---------------------------------------------------------------------------
|
| 102 |
+
-- review_queue (uncertain predictions -> human review)
|
| 103 |
+
-- ---------------------------------------------------------------------------
|
| 104 |
+
create table if not exists review_queue (
|
| 105 |
+
id uuid primary key default gen_random_uuid(),
|
| 106 |
+
request_id uuid not null,
|
| 107 |
+
image_id text not null,
|
| 108 |
+
segment_id int not null,
|
| 109 |
+
reef_location_id uuid references reef_locations(id) on delete set null,
|
| 110 |
+
|
| 111 |
+
patch_url text, -- R2 url of the centroid patch crop
|
| 112 |
+
image_url text, -- R2 url of the full source image
|
| 113 |
+
|
| 114 |
+
prediction_set jsonb not null, -- candidate labels shown to the human
|
| 115 |
+
conf_healthy real,
|
| 116 |
+
conf_bleached real,
|
| 117 |
+
conf_dead real,
|
| 118 |
+
conf_algae_covered real,
|
| 119 |
+
|
| 120 |
+
model_version text not null,
|
| 121 |
+
status review_status not null default 'pending',
|
| 122 |
+
|
| 123 |
+
created_at timestamptz not null default now()
|
| 124 |
+
);
|
| 125 |
+
|
| 126 |
+
create index if not exists idx_review_status on review_queue (status);
|
| 127 |
+
create index if not exists idx_review_created on review_queue (created_at);
|
| 128 |
+
|
| 129 |
+
-- ---------------------------------------------------------------------------
|
| 130 |
+
-- human_labels (confirmed labels; retrain trigger at 100 new rows)
|
| 131 |
+
-- ---------------------------------------------------------------------------
|
| 132 |
+
create table if not exists human_labels (
|
| 133 |
+
id uuid primary key default gen_random_uuid(),
|
| 134 |
+
review_queue_id uuid references review_queue(id) on delete set null,
|
| 135 |
+
image_id text not null,
|
| 136 |
+
segment_id int not null,
|
| 137 |
+
confirmed_label coral_label not null,
|
| 138 |
+
labeled_by text, -- reviewer id/email
|
| 139 |
+
used_in_training boolean not null default false, -- flips true once consumed by a retrain
|
| 140 |
+
created_at timestamptz not null default now()
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
create index if not exists idx_humanlabels_unused on human_labels (used_in_training);
|
| 144 |
+
create index if not exists idx_humanlabels_created on human_labels (created_at);
|
| 145 |
+
|
| 146 |
+
-- ---------------------------------------------------------------------------
|
| 147 |
+
-- health_snapshots (per-upload aggregate; temporal tracker source)
|
| 148 |
+
-- Stores BOTH counts and pixel areas per class so the UI can show count-% or area-%.
|
| 149 |
+
-- ---------------------------------------------------------------------------
|
| 150 |
+
create table if not exists health_snapshots (
|
| 151 |
+
id uuid primary key default gen_random_uuid(),
|
| 152 |
+
reef_location_id uuid references reef_locations(id) on delete cascade,
|
| 153 |
+
request_id uuid not null,
|
| 154 |
+
source_image_id text not null,
|
| 155 |
+
snapshot_time timestamptz not null default now(),
|
| 156 |
+
|
| 157 |
+
total_segments int not null,
|
| 158 |
+
healthy_count int not null default 0,
|
| 159 |
+
bleached_count int not null default 0,
|
| 160 |
+
dead_count int not null default 0,
|
| 161 |
+
algae_covered_count int not null default 0,
|
| 162 |
+
|
| 163 |
+
total_area_px bigint not null default 0,
|
| 164 |
+
healthy_area_px bigint not null default 0,
|
| 165 |
+
bleached_area_px bigint not null default 0,
|
| 166 |
+
dead_area_px bigint not null default 0,
|
| 167 |
+
algae_covered_area_px bigint not null default 0,
|
| 168 |
+
|
| 169 |
+
created_at timestamptz not null default now()
|
| 170 |
+
);
|
| 171 |
+
|
| 172 |
+
create index if not exists idx_snapshots_location_time
|
| 173 |
+
on health_snapshots (reef_location_id, snapshot_time);
|
backend/db/seed_demo.sql
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ReefScan demo seed — populates a fresh Supabase so the tracker + dashboard render with
|
| 2 |
+
-- data before any real uploads. Run AFTER schema.sql, in the Supabase SQL editor.
|
| 3 |
+
-- Safe to re-run: it clears the demo rows first (by the fixed reef UUIDs / 'demo%' ids).
|
| 4 |
+
|
| 5 |
+
delete from inference_logs where image_id like 'demo%';
|
| 6 |
+
delete from review_queue where image_id like 'demo%';
|
| 7 |
+
delete from health_snapshots where source_image_id like 'demo%';
|
| 8 |
+
delete from reef_locations where id in (
|
| 9 |
+
'11111111-1111-1111-1111-111111111111',
|
| 10 |
+
'22222222-2222-2222-2222-222222222222',
|
| 11 |
+
'33333333-3333-3333-3333-333333333333');
|
| 12 |
+
|
| 13 |
+
-- ---- reef_locations ----
|
| 14 |
+
insert into reef_locations (id, name, latitude, longitude, description) values
|
| 15 |
+
('11111111-1111-1111-1111-111111111111', 'Kāneʻohe Bay — Patch Reef 12', 21.45, -157.79, 'demo'),
|
| 16 |
+
('22222222-2222-2222-2222-222222222222', 'Molokini Crater', 20.63, -156.49, 'demo'),
|
| 17 |
+
('33333333-3333-3333-3333-333333333333', 'Hanauma Bay', 21.27, -157.69, 'demo');
|
| 18 |
+
|
| 19 |
+
-- ---- health_snapshots: 9 monthly surveys per reef, healthy % declining toward now ----
|
| 20 |
+
insert into health_snapshots (reef_location_id, request_id, source_image_id, snapshot_time,
|
| 21 |
+
total_segments, healthy_count, bleached_count, dead_count, algae_covered_count,
|
| 22 |
+
total_area_px, healthy_area_px, bleached_area_px, dead_area_px, algae_covered_area_px)
|
| 23 |
+
select r.id, gen_random_uuid(), 'demo-survey',
|
| 24 |
+
date_trunc('month', now()) - (m || ' months')::interval,
|
| 25 |
+
20, g.gh, 20 - g.gh, 0, 0,
|
| 26 |
+
200000, (200000 * g.gh / 20.0)::bigint, (200000 * (20 - g.gh) / 20.0)::bigint, 0, 0
|
| 27 |
+
from (values
|
| 28 |
+
('11111111-1111-1111-1111-111111111111'::uuid, 17),
|
| 29 |
+
('22222222-2222-2222-2222-222222222222'::uuid, 18),
|
| 30 |
+
('33333333-3333-3333-3333-333333333333'::uuid, 15)
|
| 31 |
+
) as r(id, base)
|
| 32 |
+
cross join generate_series(0, 8) as m
|
| 33 |
+
cross join lateral (
|
| 34 |
+
select greatest(6, least(20, r.base - (8 - m) + (random() * 2 - 1)::int)) as gh
|
| 35 |
+
) as g;
|
| 36 |
+
|
| 37 |
+
-- ---- inference_logs: 14 days x 3 reefs x 6 segments ----
|
| 38 |
+
-- Health declines + uncertainty (set size) rises toward now (drift story). Confidences are
|
| 39 |
+
-- bimodal (mostly near 0/1) like a real model, so ~10-20% land in the conformal band
|
| 40 |
+
-- [1-qhat, qhat] = [0.372, 0.628] and become uncertain. NB the per-row random()s live in a
|
| 41 |
+
-- MATERIALIZED CTE — a plain lateral subquery gets hoisted and evaluated ONCE for the whole
|
| 42 |
+
-- insert (every row identical). Don't "simplify" it back to a lateral.
|
| 43 |
+
insert into inference_logs (request_id, image_id, segment_id, reef_location_id, ts, latency_ms,
|
| 44 |
+
conf_healthy, conf_bleached, conf_dead, conf_algae_covered, prediction_set, prediction_set_size,
|
| 45 |
+
predicted_label, model_version)
|
| 46 |
+
with base as materialized (
|
| 47 |
+
select r.id as reef, d, s, random() as u, random() as ra,
|
| 48 |
+
(0.62 - (13 - d) * 0.012) as p_h, -- healthy prob declines toward now
|
| 49 |
+
(0.08 + (13 - d) * 0.009) as p_u -- uncertain prob rises toward now (drift)
|
| 50 |
+
from (values
|
| 51 |
+
('11111111-1111-1111-1111-111111111111'::uuid),
|
| 52 |
+
('22222222-2222-2222-2222-222222222222'::uuid),
|
| 53 |
+
('33333333-3333-3333-3333-333333333333'::uuid)
|
| 54 |
+
) r(id)
|
| 55 |
+
cross join generate_series(0, 13) d
|
| 56 |
+
cross join generate_series(1, 6) s
|
| 57 |
+
),
|
| 58 |
+
draw as (
|
| 59 |
+
select *,
|
| 60 |
+
case
|
| 61 |
+
when u < p_u then 0.45 + ra * 0.10 -- uncertain band
|
| 62 |
+
when u < p_u + p_h then 0.70 + ra * 0.28 -- confident healthy
|
| 63 |
+
else 0.02 + ra * 0.28 -- confident bleached
|
| 64 |
+
end as rh
|
| 65 |
+
from base
|
| 66 |
+
)
|
| 67 |
+
select gen_random_uuid(), 'demo-' || d || '-' || s, s, reef,
|
| 68 |
+
now() - (d || ' days')::interval - (s || ' minutes')::interval,
|
| 69 |
+
16000 + (13 - d) * 250 + (random() * 4000)::int,
|
| 70 |
+
round(rh::numeric, 3), round((1 - rh)::numeric, 3), 0, 0,
|
| 71 |
+
case when rh between 0.372 and 0.628 then '["healthy","bleached"]'::jsonb
|
| 72 |
+
when rh >= 0.5 then '["healthy"]'::jsonb else '["bleached"]'::jsonb end,
|
| 73 |
+
case when rh between 0.372 and 0.628 then 2 else 1 end,
|
| 74 |
+
(case when rh >= 0.5 then 'healthy' else 'bleached' end)::coral_label,
|
| 75 |
+
'reefscan-dinov2-coral-v1-linearprobe'
|
| 76 |
+
from draw;
|
| 77 |
+
|
| 78 |
+
-- ---- review_queue: a few pending uncertain segments ----
|
| 79 |
+
insert into review_queue (request_id, image_id, segment_id, reef_location_id, image_url,
|
| 80 |
+
prediction_set, conf_healthy, conf_bleached, model_version, status)
|
| 81 |
+
select gen_random_uuid(), 'demo-review-' || g, g, '11111111-1111-1111-1111-111111111111'::uuid,
|
| 82 |
+
'local://demo.jpg', '["healthy","bleached"]'::jsonb,
|
| 83 |
+
round((0.45 + random() * 0.1)::numeric, 3), round((0.45 + random() * 0.1)::numeric, 3),
|
| 84 |
+
'reefscan-dinov2-coral-v1-linearprobe', 'pending'
|
| 85 |
+
from generate_series(1, 5) as g;
|
backend/inference/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""ReefScan inference pipeline (Phase 5): enhance -> frames -> segment -> classify -> log."""
|
backend/inference/classifier.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DINOv2-B classifier + MAPIE-style split conformal. Phase 4/5.
|
| 2 |
+
|
| 3 |
+
Loads the trained head + backbone and the conformal calibration (qhat, LAC) from the HF
|
| 4 |
+
weights repo. Each SAM2 mask is classified on its bbox crop (resize 224 + ImageNet norm —
|
| 5 |
+
identical to training). Conformal LAC builds the prediction SET: include class k iff
|
| 6 |
+
1 - p_k <= qhat; never emit an empty set. Set size > 1 => uncertain => review_queue.
|
| 7 |
+
|
| 8 |
+
When weights are unavailable (stub mode), deterministic softmax is produced so the pipeline
|
| 9 |
+
and the uncertainty/review path are fully exercisable.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from ..config import settings
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
CLASSES = list(settings.classes) # ["healthy", "bleached"]
|
| 22 |
+
_STUB_VERSION = "reefscan-stub-v0"
|
| 23 |
+
_STUB_QHAT = 0.6 # threshold 0.4 -> segments within ~40-60 become uncertain (demo-visible)
|
| 24 |
+
|
| 25 |
+
_model = None
|
| 26 |
+
_tf = None
|
| 27 |
+
_qhat: float = _STUB_QHAT
|
| 28 |
+
_model_version: str = _STUB_VERSION
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Model (mirrors notebooks/01_train_dinov2 DINOv2Classifier)
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
def _build_model(num_classes: int):
|
| 35 |
+
import torch.nn as nn
|
| 36 |
+
from transformers import AutoModel
|
| 37 |
+
|
| 38 |
+
class DINOv2Classifier(nn.Module):
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
super().__init__()
|
| 41 |
+
self.backbone = AutoModel.from_pretrained("facebook/dinov2-base")
|
| 42 |
+
self.head = nn.Linear(self.backbone.config.hidden_size, num_classes)
|
| 43 |
+
|
| 44 |
+
def forward(self, x):
|
| 45 |
+
o = self.backbone(pixel_values=x)
|
| 46 |
+
cls = getattr(o, "pooler_output", None)
|
| 47 |
+
if cls is None:
|
| 48 |
+
cls = o.last_hidden_state[:, 0]
|
| 49 |
+
return self.head(cls)
|
| 50 |
+
|
| 51 |
+
return DINOv2Classifier()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load() -> None:
|
| 55 |
+
"""Load DINOv2 head/backbone + conformal.json from the HF weights repo. Raises if absent."""
|
| 56 |
+
global _model, _tf, _qhat, _model_version
|
| 57 |
+
import json
|
| 58 |
+
|
| 59 |
+
import torch
|
| 60 |
+
from huggingface_hub import hf_hub_download
|
| 61 |
+
from safetensors.torch import load_file
|
| 62 |
+
from torchvision import transforms
|
| 63 |
+
|
| 64 |
+
stage = settings.hf_stage
|
| 65 |
+
w = hf_hub_download(settings.hf_repo, f"{stage}/model.safetensors", token=settings.hf_token)
|
| 66 |
+
model = _build_model(len(CLASSES))
|
| 67 |
+
model.load_state_dict(load_file(w))
|
| 68 |
+
model.eval()
|
| 69 |
+
_model = model
|
| 70 |
+
|
| 71 |
+
_tf = transforms.Compose([
|
| 72 |
+
transforms.Resize((settings.input_size, settings.input_size)),
|
| 73 |
+
transforms.ToTensor(),
|
| 74 |
+
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
|
| 75 |
+
])
|
| 76 |
+
|
| 77 |
+
cj = hf_hub_download(settings.hf_repo, f"{stage}/conformal.json", token=settings.hf_token)
|
| 78 |
+
meta = json.load(open(cj))
|
| 79 |
+
_qhat = float(meta["qhat"])
|
| 80 |
+
_model_version = meta.get("model_version", f"reefscan-dinov2-coral-{stage}")
|
| 81 |
+
logger.info("DINOv2 classifier loaded (version=%s, qhat=%.4f)", _model_version, _qhat)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_loaded() -> bool:
|
| 85 |
+
return _model is not None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def qhat() -> float:
|
| 89 |
+
return _qhat
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def model_version() -> str:
|
| 93 |
+
return _model_version
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def classify(crop: Image.Image) -> dict[str, float]:
|
| 97 |
+
"""Return softmax {healthy, bleached} for a bbox crop."""
|
| 98 |
+
if _model is None:
|
| 99 |
+
return _stub_probs(crop)
|
| 100 |
+
import torch
|
| 101 |
+
|
| 102 |
+
x = _tf(crop.convert("RGB")).unsqueeze(0)
|
| 103 |
+
with torch.inference_mode():
|
| 104 |
+
p = torch.softmax(_model(x), dim=1)[0].tolist()
|
| 105 |
+
return {CLASSES[i]: float(p[i]) for i in range(len(CLASSES))}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def conformal_set(probs: dict[str, float], q: float) -> tuple[list[str], int]:
|
| 109 |
+
"""LAC split-conformal set: include class k iff (1 - p_k) <= qhat. Never empty."""
|
| 110 |
+
keep = [c for c in CLASSES if (1.0 - probs[c]) <= q]
|
| 111 |
+
if not keep:
|
| 112 |
+
keep = [max(probs, key=probs.get)]
|
| 113 |
+
# order by descending confidence for stable display
|
| 114 |
+
keep.sort(key=lambda c: probs[c], reverse=True)
|
| 115 |
+
return keep, len(keep)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _stub_probs(crop: Image.Image) -> dict[str, float]:
|
| 119 |
+
"""Deterministic softmax from the crop's mean color — yields a mix incl. near-even
|
| 120 |
+
(uncertain) segments so the review path is exercised."""
|
| 121 |
+
import numpy as np
|
| 122 |
+
|
| 123 |
+
arr = np.asarray(crop.convert("RGB").resize((16, 16)), dtype="float32") / 255.0
|
| 124 |
+
# 'bluer / darker' -> leans bleached-ish; just a deterministic spread, not real signal
|
| 125 |
+
h = float(np.clip(0.5 + (arr[..., 1].mean() - arr[..., 2].mean()) * 1.4, 0.05, 0.95))
|
| 126 |
+
return {"healthy": h, "bleached": 1.0 - h}
|
backend/inference/enhancer.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WaterNet underwater enhancement. Phase 5.
|
| 2 |
+
|
| 3 |
+
Currently an identity passthrough (real WaterNet weights are vendored later — the
|
| 4 |
+
WaterNet-over-CLAHE rationale stays a README talking point). Kept as a seam so the real
|
| 5 |
+
model drops in without touching the pipeline.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def enhance(img: Image.Image) -> Image.Image:
|
| 13 |
+
"""Return an enhanced copy of the image. Identity for now."""
|
| 14 |
+
return img
|
backend/inference/frame_extractor.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frame extraction. Phase 5.
|
| 2 |
+
|
| 3 |
+
Image input passes through as a single frame. Video input is split on scene changes via
|
| 4 |
+
PySceneDetect (NOT fixed-fps). Both feed the same downstream pipeline — the image/video
|
| 5 |
+
distinction collapses (CLAUDE.md). Video decoding degrades gracefully if scenedetect/opencv
|
| 6 |
+
are unavailable.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import io
|
| 11 |
+
import logging
|
| 12 |
+
import tempfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
MAX_VIDEO_FRAMES = 12 # cap representative frames per clip (free-CPU budget)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def extract(data: bytes, kind: str) -> list[Image.Image]:
|
| 23 |
+
"""Return a list of frames (PIL RGB) for an image or video upload."""
|
| 24 |
+
if kind == "video":
|
| 25 |
+
return _video_frames(data)
|
| 26 |
+
return [Image.open(io.BytesIO(data)).convert("RGB")]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _video_frames(data: bytes) -> list[Image.Image]:
|
| 30 |
+
try:
|
| 31 |
+
import cv2 # type: ignore
|
| 32 |
+
from scenedetect import ContentDetector, SceneManager, open_video # type: ignore
|
| 33 |
+
except Exception as e: # noqa: BLE001
|
| 34 |
+
raise RuntimeError(f"video support needs scenedetect+opencv: {e}")
|
| 35 |
+
|
| 36 |
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
|
| 37 |
+
f.write(data)
|
| 38 |
+
path = f.name
|
| 39 |
+
try:
|
| 40 |
+
video = open_video(path)
|
| 41 |
+
sm = SceneManager()
|
| 42 |
+
sm.add_detector(ContentDetector())
|
| 43 |
+
sm.detect_scenes(video)
|
| 44 |
+
scenes = sm.get_scene_list()
|
| 45 |
+
cap = cv2.VideoCapture(path)
|
| 46 |
+
frames: list[Image.Image] = []
|
| 47 |
+
# one representative frame per scene (scene start), capped
|
| 48 |
+
targets = [s[0].get_frames() for s in scenes] or [0]
|
| 49 |
+
for fno in targets[:MAX_VIDEO_FRAMES]:
|
| 50 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, fno)
|
| 51 |
+
ok, frame = cap.read()
|
| 52 |
+
if ok:
|
| 53 |
+
frames.append(Image.fromarray(frame[:, :, ::-1])) # BGR -> RGB
|
| 54 |
+
cap.release()
|
| 55 |
+
return frames or [Image.new("RGB", (512, 512))]
|
| 56 |
+
finally:
|
| 57 |
+
Path(path).unlink(missing_ok=True)
|
backend/inference/pipeline.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Full inference pipeline orchestration. Phase 5.
|
| 2 |
+
|
| 3 |
+
enhance -> frames (video) / passthrough (image) -> SAM2 AMG segment -> per-segment bbox
|
| 4 |
+
crop -> DINOv2 + conformal classify -> assemble the frozen contract -> log to Supabase +
|
| 5 |
+
store source to R2 -> return result dict.
|
| 6 |
+
|
| 7 |
+
Returns the InferenceResponse fields (minus job_id/status, which the JobStore adds).
|
| 8 |
+
Runs synchronously in a threadpool (called via run_in_executor) — CPU-bound.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
from ..config import settings
|
| 16 |
+
from ..persistence import new_request_id, r2, supabase
|
| 17 |
+
from . import classifier, enhancer, frame_extractor, segmenter
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
_last_latency_ms: int | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_models() -> None:
|
| 25 |
+
"""Load SAM2 + DINOv2 once at startup (resident, no lazy-load — Phase 1.5). On any
|
| 26 |
+
failure (e.g. weights not yet on the Hub) we log and fall back to STUB mode so the
|
| 27 |
+
service still answers with contract-valid output."""
|
| 28 |
+
if settings.stub_mode:
|
| 29 |
+
logger.warning("REEFSCAN_STUB=1 -> running inference in STUB mode")
|
| 30 |
+
return
|
| 31 |
+
try:
|
| 32 |
+
segmenter.load()
|
| 33 |
+
classifier.load()
|
| 34 |
+
except Exception as e: # noqa: BLE001
|
| 35 |
+
logger.warning("model load failed (%s) -> STUB mode (contract-valid synthetic output)", e)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def models_loaded() -> bool:
|
| 39 |
+
return segmenter.is_loaded() and classifier.is_loaded()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def model_version() -> str:
|
| 43 |
+
return classifier.model_version()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def last_latency_ms() -> int | None:
|
| 47 |
+
return _last_latency_ms
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def run(job_id: str, data: bytes, kind: str, reef_location_id: str | None) -> dict:
|
| 51 |
+
global _last_latency_ms
|
| 52 |
+
t0 = time.perf_counter()
|
| 53 |
+
request_id = new_request_id()
|
| 54 |
+
|
| 55 |
+
content_type = "video/mp4" if kind == "video" else "image/jpeg"
|
| 56 |
+
image_url = r2.upload(f"{request_id}/source", data, content_type)
|
| 57 |
+
|
| 58 |
+
frames = frame_extractor.extract(data, kind)
|
| 59 |
+
img = enhancer.enhance(frames[0]) # scaffold: classify on first frame (N-frame agg = future)
|
| 60 |
+
W, H = img.size
|
| 61 |
+
|
| 62 |
+
raw = segmenter.segment(img)
|
| 63 |
+
q = classifier.qhat()
|
| 64 |
+
total_area = sum(s["mask_area_px"] for s in raw) or 1
|
| 65 |
+
|
| 66 |
+
segments: list[dict] = []
|
| 67 |
+
for i, s in enumerate(raw, start=1):
|
| 68 |
+
crop = img.crop(tuple(s["bbox"]))
|
| 69 |
+
probs = classifier.classify(crop)
|
| 70 |
+
pset, size = classifier.conformal_set(probs, q)
|
| 71 |
+
predicted = max(probs, key=probs.get)
|
| 72 |
+
segments.append({
|
| 73 |
+
"segment_id": i,
|
| 74 |
+
"mask_area_px": s["mask_area_px"],
|
| 75 |
+
"bbox": s["bbox"],
|
| 76 |
+
"predicted_class": predicted,
|
| 77 |
+
"prediction_set": pset,
|
| 78 |
+
"prediction_set_size": size,
|
| 79 |
+
"confidence_scores": {k: round(v, 4) for k, v in probs.items()},
|
| 80 |
+
"coverage_pct": round(s["mask_area_px"] / total_area * 100, 1),
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
summary = _summarize(segments, total_area)
|
| 84 |
+
latency = int((time.perf_counter() - t0) * 1000)
|
| 85 |
+
_last_latency_ms = latency
|
| 86 |
+
version = classifier.model_version()
|
| 87 |
+
|
| 88 |
+
_log_all(request_id, image_url, reef_location_id, segments, summary,
|
| 89 |
+
total_area, latency, version)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"processing_time_ms": latency,
|
| 93 |
+
"image_url": image_url,
|
| 94 |
+
"model_version": version,
|
| 95 |
+
"image_width": W,
|
| 96 |
+
"image_height": H,
|
| 97 |
+
"segments": segments,
|
| 98 |
+
"summary": summary,
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _summarize(segments: list[dict], total_area: int) -> dict:
|
| 103 |
+
healthy_area = sum(s["mask_area_px"] for s in segments if s["predicted_class"] == "healthy")
|
| 104 |
+
healthy_pct = round(healthy_area / total_area * 100, 1) if total_area else 0.0
|
| 105 |
+
return {
|
| 106 |
+
"total_segments": len(segments),
|
| 107 |
+
"area_weighted": {"healthy_pct": healthy_pct, "bleached_pct": round(100 - healthy_pct, 1)},
|
| 108 |
+
"uncertain_segments": sum(1 for s in segments if s["prediction_set_size"] > 1),
|
| 109 |
+
"dominant_status": "healthy" if healthy_pct >= 50 else "bleached",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _log_all(request_id, image_url, reef_id, segments, summary, total_area, latency, version):
|
| 114 |
+
"""Best-effort Supabase logging (no-op without creds). Mirrors backend/db/schema.sql."""
|
| 115 |
+
inf_rows, review_rows = [], []
|
| 116 |
+
counts = {"healthy": 0, "bleached": 0}
|
| 117 |
+
areas = {"healthy": 0, "bleached": 0}
|
| 118 |
+
for s in segments:
|
| 119 |
+
cs = s["confidence_scores"]
|
| 120 |
+
inf_rows.append({
|
| 121 |
+
"request_id": request_id, "image_id": image_url, "segment_id": s["segment_id"],
|
| 122 |
+
"reef_location_id": reef_id, "latency_ms": latency,
|
| 123 |
+
"conf_healthy": cs.get("healthy", 0.0), "conf_bleached": cs.get("bleached", 0.0),
|
| 124 |
+
"conf_dead": 0.0, "conf_algae_covered": 0.0,
|
| 125 |
+
"prediction_set": s["prediction_set"], "prediction_set_size": s["prediction_set_size"],
|
| 126 |
+
"predicted_label": s["predicted_class"], "model_version": version,
|
| 127 |
+
})
|
| 128 |
+
counts[s["predicted_class"]] += 1
|
| 129 |
+
areas[s["predicted_class"]] += s["mask_area_px"]
|
| 130 |
+
if s["prediction_set_size"] > 1:
|
| 131 |
+
review_rows.append({
|
| 132 |
+
"request_id": request_id, "image_id": image_url, "segment_id": s["segment_id"],
|
| 133 |
+
"reef_location_id": reef_id, "image_url": image_url,
|
| 134 |
+
"prediction_set": s["prediction_set"],
|
| 135 |
+
"conf_healthy": cs.get("healthy", 0.0), "conf_bleached": cs.get("bleached", 0.0),
|
| 136 |
+
"model_version": version, "status": "pending",
|
| 137 |
+
})
|
| 138 |
+
supabase.log_segments(inf_rows)
|
| 139 |
+
supabase.log_review(review_rows)
|
| 140 |
+
supabase.log_snapshot({
|
| 141 |
+
"reef_location_id": reef_id, "request_id": request_id, "source_image_id": image_url,
|
| 142 |
+
"total_segments": summary["total_segments"],
|
| 143 |
+
"healthy_count": counts["healthy"], "bleached_count": counts["bleached"],
|
| 144 |
+
"total_area_px": total_area,
|
| 145 |
+
"healthy_area_px": areas["healthy"], "bleached_area_px": areas["bleached"],
|
| 146 |
+
})
|
backend/inference/segmenter.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SAM2 segmentation. Phase 5.
|
| 2 |
+
|
| 3 |
+
SAM2-Hiera-Small via the Automatic Mask Generator (grid point prompts) — NOT manual
|
| 4 |
+
clicks, NOT a trained detection head (that is documented future work). Returns masks +
|
| 5 |
+
centroids; centroids feed classifier.py's patch crop.
|
| 6 |
+
|
| 7 |
+
AMG config is LOCKED by the Phase 1.5 sweep (read from config.settings, never inlined):
|
| 8 |
+
points_per_side = 16 ; AMG input downscaled so longest edge = 512 px.
|
| 9 |
+
|
| 10 |
+
`segment()` returns a list of {bbox:[x0,y0,x1,y1], mask_area_px:int} in ORIGINAL image px.
|
| 11 |
+
When SAM2 isn't loaded (stub mode / weights absent), a deterministic grid of boxes is
|
| 12 |
+
returned so the whole pipeline + contract are exercisable without the heavy model.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
from PIL import Image
|
| 19 |
+
|
| 20 |
+
from ..config import settings
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Locked AMG config (Phase 1.5). Keep in sync with CLAUDE.md.
|
| 25 |
+
AMG_POINTS_PER_SIDE = settings.amg_points_per_side # 16
|
| 26 |
+
AMG_INPUT_LONGEST_EDGE = settings.amg_longest_edge # 512
|
| 27 |
+
|
| 28 |
+
_amg = None # loaded SAM2AutomaticMaskGenerator
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load() -> None:
|
| 32 |
+
"""Load SAM2-Hiera-Small + AMG once at startup. Raises if deps/weights unavailable."""
|
| 33 |
+
global _amg
|
| 34 |
+
from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator # type: ignore
|
| 35 |
+
from sam2.build_sam import build_sam2_hf # type: ignore
|
| 36 |
+
|
| 37 |
+
model = build_sam2_hf("facebook/sam2-hiera-small", device="cpu")
|
| 38 |
+
_amg = SAM2AutomaticMaskGenerator(model, points_per_side=AMG_POINTS_PER_SIDE)
|
| 39 |
+
logger.info("SAM2 AMG loaded (pps=%d)", AMG_POINTS_PER_SIDE)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def is_loaded() -> bool:
|
| 43 |
+
return _amg is not None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _scale_to_longest(img: Image.Image, longest: int) -> tuple[Image.Image, float]:
|
| 47 |
+
w, h = img.size
|
| 48 |
+
scale = longest / max(w, h)
|
| 49 |
+
if scale >= 1.0:
|
| 50 |
+
return img, 1.0
|
| 51 |
+
return img.resize((max(1, round(w * scale)), max(1, round(h * scale)))), scale
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def segment(img: Image.Image) -> list[dict]:
|
| 55 |
+
if _amg is None:
|
| 56 |
+
return _stub_segments(img)
|
| 57 |
+
|
| 58 |
+
import numpy as np
|
| 59 |
+
|
| 60 |
+
small, scale = _scale_to_longest(img, AMG_INPUT_LONGEST_EDGE)
|
| 61 |
+
masks = _amg.generate(np.array(small))
|
| 62 |
+
out: list[dict] = []
|
| 63 |
+
for m in masks:
|
| 64 |
+
x, y, w, h = m["bbox"] # XYWH in downscaled coords
|
| 65 |
+
out.append({
|
| 66 |
+
"bbox": [round(x / scale), round(y / scale),
|
| 67 |
+
round((x + w) / scale), round((y + h) / scale)],
|
| 68 |
+
"mask_area_px": int(m["area"] / (scale * scale)),
|
| 69 |
+
})
|
| 70 |
+
return out
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _stub_segments(img: Image.Image) -> list[dict]:
|
| 74 |
+
"""Deterministic 3x2 grid of boxes — contract-valid output without SAM2."""
|
| 75 |
+
W, H = img.size
|
| 76 |
+
cols, rows = 3, 2
|
| 77 |
+
pad_x, pad_y = W * 0.04, H * 0.05
|
| 78 |
+
cw, ch = (W - pad_x * (cols + 1)) / cols, (H - pad_y * (rows + 1)) / rows
|
| 79 |
+
out: list[dict] = []
|
| 80 |
+
sid = 0
|
| 81 |
+
for r in range(rows):
|
| 82 |
+
for c in range(cols):
|
| 83 |
+
sid += 1
|
| 84 |
+
x0 = pad_x * (c + 1) + cw * c
|
| 85 |
+
y0 = pad_y * (r + 1) + ch * r
|
| 86 |
+
# vary box size a little so coverage % differs per segment
|
| 87 |
+
shrink = 0.82 + 0.12 * ((sid * 7) % 3) / 2
|
| 88 |
+
bw, bh = cw * shrink, ch * shrink
|
| 89 |
+
bbox = [round(x0), round(y0), round(x0 + bw), round(y0 + bh)]
|
| 90 |
+
out.append({"bbox": bbox, "mask_area_px": round(bw * bh * 0.7)})
|
| 91 |
+
return out
|
backend/jobs.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async job store + background worker. Phase 5.
|
| 2 |
+
|
| 3 |
+
POST /infer creates a job (status 'queued') and schedules run_job; GET /infer/{job_id}
|
| 4 |
+
reads back status + results. In-memory store is the source of truth for polling; the
|
| 5 |
+
Supabase `jobs` table is mirrored best-effort (single-process worker on HF Spaces).
|
| 6 |
+
The CPU-bound pipeline runs in a threadpool so the event loop stays responsive.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
from .inference import pipeline
|
| 15 |
+
from .persistence import new_request_id, supabase
|
| 16 |
+
from .schemas import InferenceResponse
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class JobStore:
|
| 22 |
+
def __init__(self) -> None:
|
| 23 |
+
self._jobs: dict[str, dict[str, Any]] = {}
|
| 24 |
+
|
| 25 |
+
def create(self, kind: str, reef_location_id: Optional[str], source_url: str = "") -> str:
|
| 26 |
+
job_id = new_request_id()
|
| 27 |
+
self._jobs[job_id] = {"status": "queued", "result": None, "error": None,
|
| 28 |
+
"kind": kind, "reef": reef_location_id}
|
| 29 |
+
supabase.upsert_job({"job_id": job_id, "status": "queued", "source_kind": kind,
|
| 30 |
+
"source_url": source_url, "reef_location_id": reef_location_id})
|
| 31 |
+
return job_id
|
| 32 |
+
|
| 33 |
+
def update(self, job_id: str, **kw: Any) -> None:
|
| 34 |
+
if job_id in self._jobs:
|
| 35 |
+
self._jobs[job_id].update(kw)
|
| 36 |
+
row = {"job_id": job_id, "status": self._jobs[job_id]["status"]}
|
| 37 |
+
if kw.get("error"):
|
| 38 |
+
row["error_message"] = kw["error"]
|
| 39 |
+
supabase.upsert_job(row)
|
| 40 |
+
|
| 41 |
+
def response(self, job_id: str) -> Optional[InferenceResponse]:
|
| 42 |
+
j = self._jobs.get(job_id)
|
| 43 |
+
if j is None:
|
| 44 |
+
return None
|
| 45 |
+
result = j.get("result") or {}
|
| 46 |
+
return InferenceResponse(job_id=job_id, status=j["status"],
|
| 47 |
+
error_message=j.get("error"), **result)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def run_job(store: JobStore, job_id: str, data: bytes, kind: str,
|
| 51 |
+
reef_location_id: Optional[str]) -> None:
|
| 52 |
+
store.update(job_id, status="processing")
|
| 53 |
+
loop = asyncio.get_event_loop()
|
| 54 |
+
try:
|
| 55 |
+
result = await loop.run_in_executor(None, pipeline.run, job_id, data, kind, reef_location_id)
|
| 56 |
+
store.update(job_id, status="complete", result=result)
|
| 57 |
+
except Exception as e: # noqa: BLE001
|
| 58 |
+
logger.exception("job %s failed", job_id)
|
| 59 |
+
store.update(job_id, status="failed", error=str(e))
|
backend/main.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ReefScan FastAPI app — async-job inference. Phase 5.
|
| 2 |
+
|
| 3 |
+
POST /infer multipart file (image|video) or form `url` -> {job_id} (enqueue)
|
| 4 |
+
GET /infer/{job_id} -> InferenceResponse (status + results when complete) (poll)
|
| 5 |
+
GET /health -> model load status + last inference latency
|
| 6 |
+
|
| 7 |
+
Images and video share the SAME async path (CLAUDE.md). Models are loaded once at startup
|
| 8 |
+
and kept resident (Phase 1.5: RAM cleared, no lazy-load). The CPU-bound pipeline runs in a
|
| 9 |
+
threadpool so polling stays responsive. Phase 6 frontend swaps lib/api.ts onto these routes.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import asyncio
|
| 14 |
+
import io
|
| 15 |
+
import logging
|
| 16 |
+
from contextlib import asynccontextmanager
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
| 20 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 21 |
+
|
| 22 |
+
from . import observability
|
| 23 |
+
from .config import settings
|
| 24 |
+
from .inference import pipeline
|
| 25 |
+
from .jobs import JobStore, run_job
|
| 26 |
+
from .persistence import supabase
|
| 27 |
+
from .schemas import HealthResponse, InferenceResponse, SubmitResponse
|
| 28 |
+
|
| 29 |
+
logging.basicConfig(level=logging.INFO)
|
| 30 |
+
store = JobStore()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@asynccontextmanager
|
| 34 |
+
async def lifespan(app: FastAPI):
|
| 35 |
+
pipeline.load_models() # resident SAM2 + DINOv2 (or stub fallback)
|
| 36 |
+
yield
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
app = FastAPI(title="ReefScan", version="0.1.0", lifespan=lifespan)
|
| 40 |
+
app.add_middleware(
|
| 41 |
+
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.get("/health", response_model=HealthResponse)
|
| 46 |
+
async def health() -> HealthResponse:
|
| 47 |
+
loaded = pipeline.models_loaded()
|
| 48 |
+
return HealthResponse(
|
| 49 |
+
status="ok",
|
| 50 |
+
models_loaded=loaded,
|
| 51 |
+
stub_mode=not loaded,
|
| 52 |
+
model_version=pipeline.model_version(),
|
| 53 |
+
last_inference_latency_ms=pipeline.last_latency_ms(),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def _read_input(file: Optional[UploadFile], url: Optional[str]) -> tuple[bytes, str]:
|
| 58 |
+
"""Return (bytes, kind) for an uploaded file or a url. kind in {'image','video'}."""
|
| 59 |
+
if file is not None:
|
| 60 |
+
data = await file.read()
|
| 61 |
+
kind = "video" if (file.content_type or "").startswith("video") else "image"
|
| 62 |
+
return data, kind
|
| 63 |
+
if url:
|
| 64 |
+
try:
|
| 65 |
+
import httpx
|
| 66 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 67 |
+
r = await client.get(url)
|
| 68 |
+
r.raise_for_status()
|
| 69 |
+
ct = r.headers.get("content-type", "")
|
| 70 |
+
return r.content, ("video" if ct.startswith("video") else "image")
|
| 71 |
+
except Exception as e: # noqa: BLE001
|
| 72 |
+
raise HTTPException(status_code=400, detail=f"could not fetch url: {e}")
|
| 73 |
+
raise HTTPException(status_code=400, detail="provide a file upload or a `url`")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.post("/infer", response_model=SubmitResponse, status_code=202)
|
| 77 |
+
async def infer(
|
| 78 |
+
file: Optional[UploadFile] = File(default=None),
|
| 79 |
+
url: Optional[str] = Form(default=None),
|
| 80 |
+
reef_location_id: Optional[str] = Form(default=None),
|
| 81 |
+
) -> SubmitResponse:
|
| 82 |
+
data, kind = await _read_input(file, url)
|
| 83 |
+
if not data:
|
| 84 |
+
raise HTTPException(status_code=400, detail="empty upload")
|
| 85 |
+
job_id = store.create(kind, reef_location_id, source_url=url or "")
|
| 86 |
+
asyncio.create_task(run_job(store, job_id, data, kind, reef_location_id))
|
| 87 |
+
return SubmitResponse(job_id=job_id)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.get("/infer/{job_id}", response_model=InferenceResponse)
|
| 91 |
+
async def get_job(job_id: str) -> InferenceResponse:
|
| 92 |
+
resp = store.response(job_id)
|
| 93 |
+
if resp is None:
|
| 94 |
+
raise HTTPException(status_code=404, detail="unknown job_id")
|
| 95 |
+
return resp
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# --- read endpoints (Phase 6 review/tracker + Phase 7 dashboard) ---
|
| 99 |
+
# All read from Supabase; return [] when Supabase creds are absent (the frontend keeps
|
| 100 |
+
# its own mock for that case, selected by NEXT_PUBLIC_REEFSCAN_API being unset).
|
| 101 |
+
|
| 102 |
+
@app.get("/review-queue")
|
| 103 |
+
async def review_queue(limit: int = 50) -> list[dict]:
|
| 104 |
+
return [{
|
| 105 |
+
"id": r.get("id"), "image_id": r.get("image_id"), "segment_id": r.get("segment_id"),
|
| 106 |
+
"patch_url": r.get("patch_url") or "", "image_url": r.get("image_url") or "",
|
| 107 |
+
"prediction_set": r.get("prediction_set") or [],
|
| 108 |
+
"confidence_scores": {"healthy": r.get("conf_healthy") or 0.0,
|
| 109 |
+
"bleached": r.get("conf_bleached") or 0.0},
|
| 110 |
+
"model_version": r.get("model_version") or "",
|
| 111 |
+
"reef_location": r.get("reef_location_id") or "",
|
| 112 |
+
"created_at": r.get("created_at") or "", "status": r.get("status") or "pending",
|
| 113 |
+
} for r in supabase.review_queue(limit)]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.post("/review-queue/{review_id}/confirm")
|
| 117 |
+
async def confirm(review_id: str, label: str = Body(..., embed=True)) -> dict:
|
| 118 |
+
if label not in settings.classes:
|
| 119 |
+
raise HTTPException(status_code=400, detail=f"label must be one of {settings.classes}")
|
| 120 |
+
ok = supabase.confirm_label(review_id, label)
|
| 121 |
+
return {"ok": ok, "review_id": review_id, "label": label}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.get("/reef-locations")
|
| 125 |
+
async def reef_locations() -> list[dict]:
|
| 126 |
+
return [{"id": r.get("id"), "name": r.get("name"),
|
| 127 |
+
"lat": r.get("latitude"), "lng": r.get("longitude")}
|
| 128 |
+
for r in supabase.reef_locations()]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@app.get("/reef-locations/{reef_id}/snapshots")
|
| 132 |
+
async def snapshots(reef_id: str) -> list[dict]:
|
| 133 |
+
out = []
|
| 134 |
+
for r in supabase.snapshots(reef_id):
|
| 135 |
+
total_area = r.get("total_area_px") or 0
|
| 136 |
+
healthy_pct = round((r.get("healthy_area_px") or 0) / total_area * 100, 1) if total_area else 0.0
|
| 137 |
+
out.append({
|
| 138 |
+
"date": str(r.get("snapshot_time", ""))[:10],
|
| 139 |
+
"healthy_pct": healthy_pct, "bleached_pct": round(100 - healthy_pct, 1),
|
| 140 |
+
"total_segments": r.get("total_segments") or 0,
|
| 141 |
+
"avg_set_size": None, # drift lives in /observability, not health_snapshots
|
| 142 |
+
})
|
| 143 |
+
return out
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.get("/observability")
|
| 147 |
+
async def get_observability() -> dict:
|
| 148 |
+
return observability.build(supabase.recent_logs(), settings.classes)
|
backend/models/train_dinov2.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DINOv2-B health classifier — canonical training library. Phase 3.
|
| 2 |
+
|
| 3 |
+
This is the maintained, importable implementation. The Kaggle notebook
|
| 4 |
+
(notebooks/01_train_dinov2_kaggle.ipynb) mirrors this logic inline so it can run
|
| 5 |
+
self-contained on Kaggle; keep the two in sync.
|
| 6 |
+
|
| 7 |
+
Two stages (CLAUDE.md):
|
| 8 |
+
- "linear_probe": freeze the DINOv2 backbone, train only the linear head (~30 min).
|
| 9 |
+
- "finetune": unfreeze the last 2 transformer blocks + final norm + head (~4 hr).
|
| 10 |
+
|
| 11 |
+
Hard Kaggle constraints honored:
|
| 12 |
+
- checkpoint EVERY epoch to <work>/checkpoints/ (persisted as Kaggle output)
|
| 13 |
+
- resume from the latest checkpoint if one exists (input dataset or working dir)
|
| 14 |
+
- W&B logging throughout
|
| 15 |
+
- 2-class head (healthy/bleached) driven by backend.data.CLASSES
|
| 16 |
+
|
| 17 |
+
Data: ImageFolder via backend.data.CoralPatchDataset using the NOAA NATIVE train/val/test
|
| 18 |
+
splits (do NOT re-split — see CLAUDE.md).
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
from dataclasses import asdict, dataclass, field
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
from sklearn.metrics import classification_report, f1_score
|
| 31 |
+
from torch.utils.data import DataLoader
|
| 32 |
+
from transformers import AutoModel
|
| 33 |
+
|
| 34 |
+
from backend.data import CLASSES, CoralPatchDataset
|
| 35 |
+
|
| 36 |
+
BACKBONE_NAME = "facebook/dinov2-base"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Config
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
@dataclass
|
| 43 |
+
class TrainConfig:
|
| 44 |
+
stage: str = "linear_probe" # "linear_probe" | "finetune"
|
| 45 |
+
data_root: str = "/kaggle/input/noaa-coral-bleaching" # imagefolder root (train/val/test)
|
| 46 |
+
work_dir: str = "/kaggle/working" # checkpoints written under <work_dir>/checkpoints
|
| 47 |
+
resume_dir: str | None = None # extra dir (e.g. prior run's output) to resume from
|
| 48 |
+
backbone_name: str = BACKBONE_NAME
|
| 49 |
+
epochs: int = 10 # linear_probe ~10; finetune ~8 (fits 9h session)
|
| 50 |
+
batch_size: int = 64
|
| 51 |
+
num_workers: int = 4
|
| 52 |
+
lr_head: float = 1e-3 # linear_probe head lr; finetune head lr below
|
| 53 |
+
lr_head_finetune: float = 1e-4
|
| 54 |
+
lr_backbone: float = 1e-5 # finetune: last-2-block lr
|
| 55 |
+
weight_decay: float = 0.05
|
| 56 |
+
unfreeze_last_n: int = 2 # finetune: number of trailing transformer blocks
|
| 57 |
+
label_smoothing: float = 0.0
|
| 58 |
+
seed: int = 42
|
| 59 |
+
# Hugging Face Hub
|
| 60 |
+
hf_repo: str | None = None # e.g. "user/reefscan-dinov2b" (provided before run)
|
| 61 |
+
push_to_hub: bool = False
|
| 62 |
+
# W&B
|
| 63 |
+
wandb_project: str = "reefscan"
|
| 64 |
+
wandb_run_name: str | None = None
|
| 65 |
+
classes: list[str] = field(default_factory=lambda: list(CLASSES))
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def ckpt_dir(self) -> Path:
|
| 69 |
+
return Path(self.work_dir) / "checkpoints" / self.stage
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ---------------------------------------------------------------------------
|
| 73 |
+
# Model
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
class DINOv2Classifier(nn.Module):
|
| 76 |
+
"""DINOv2 backbone -> CLS pooled embedding -> linear head."""
|
| 77 |
+
|
| 78 |
+
def __init__(self, num_classes: int, backbone_name: str = BACKBONE_NAME):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.backbone = AutoModel.from_pretrained(backbone_name)
|
| 81 |
+
hidden = self.backbone.config.hidden_size
|
| 82 |
+
self.head = nn.Linear(hidden, num_classes)
|
| 83 |
+
|
| 84 |
+
def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 85 |
+
out = self.backbone(pixel_values=pixel_values)
|
| 86 |
+
# DINOv2 returns pooler_output (CLS token); fall back to CLS of last_hidden_state.
|
| 87 |
+
cls = getattr(out, "pooler_output", None)
|
| 88 |
+
if cls is None:
|
| 89 |
+
cls = out.last_hidden_state[:, 0]
|
| 90 |
+
return self.head(cls)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def set_trainable(model: DINOv2Classifier, cfg: TrainConfig) -> list[dict]:
|
| 94 |
+
"""Freeze/unfreeze per stage; return optimizer param groups (with per-group lr)."""
|
| 95 |
+
# Head is always trainable.
|
| 96 |
+
for p in model.backbone.parameters():
|
| 97 |
+
p.requires_grad = False
|
| 98 |
+
for p in model.head.parameters():
|
| 99 |
+
p.requires_grad = True
|
| 100 |
+
|
| 101 |
+
if cfg.stage == "linear_probe":
|
| 102 |
+
return [{"params": model.head.parameters(), "lr": cfg.lr_head}]
|
| 103 |
+
|
| 104 |
+
if cfg.stage == "finetune":
|
| 105 |
+
# Unfreeze the last N transformer blocks + the final layernorm.
|
| 106 |
+
blocks = model.backbone.encoder.layer
|
| 107 |
+
for blk in blocks[-cfg.unfreeze_last_n:]:
|
| 108 |
+
for p in blk.parameters():
|
| 109 |
+
p.requires_grad = True
|
| 110 |
+
if hasattr(model.backbone, "layernorm"):
|
| 111 |
+
for p in model.backbone.layernorm.parameters():
|
| 112 |
+
p.requires_grad = True
|
| 113 |
+
backbone_trainable = [p for p in model.backbone.parameters() if p.requires_grad]
|
| 114 |
+
return [
|
| 115 |
+
{"params": model.head.parameters(), "lr": cfg.lr_head_finetune},
|
| 116 |
+
{"params": backbone_trainable, "lr": cfg.lr_backbone},
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
raise ValueError(f"unknown stage: {cfg.stage!r}")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Data
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
def get_dataloaders(cfg: TrainConfig) -> dict[str, DataLoader]:
|
| 126 |
+
loaders: dict[str, DataLoader] = {}
|
| 127 |
+
for split in ("train", "val", "test"):
|
| 128 |
+
try:
|
| 129 |
+
ds = CoralPatchDataset.from_imagefolder(cfg.data_root, split)
|
| 130 |
+
except FileNotFoundError:
|
| 131 |
+
continue # test split optional
|
| 132 |
+
loaders[split] = DataLoader(
|
| 133 |
+
ds, batch_size=cfg.batch_size, shuffle=(split == "train"),
|
| 134 |
+
num_workers=cfg.num_workers, pin_memory=True, drop_last=(split == "train"),
|
| 135 |
+
)
|
| 136 |
+
if "train" not in loaders or "val" not in loaders:
|
| 137 |
+
raise RuntimeError(f"need train+val imagefolders under {cfg.data_root}")
|
| 138 |
+
return loaders
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
# Checkpointing
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
def save_checkpoint(cfg: TrainConfig, model, optimizer, scheduler, scaler,
|
| 145 |
+
epoch: int, best_f1: float) -> Path:
|
| 146 |
+
cfg.ckpt_dir.mkdir(parents=True, exist_ok=True)
|
| 147 |
+
path = cfg.ckpt_dir / f"epoch_{epoch:03d}.pt"
|
| 148 |
+
torch.save({
|
| 149 |
+
"epoch": epoch,
|
| 150 |
+
"best_f1": best_f1,
|
| 151 |
+
"model": model.state_dict(),
|
| 152 |
+
"optimizer": optimizer.state_dict(),
|
| 153 |
+
"scheduler": scheduler.state_dict() if scheduler else None,
|
| 154 |
+
"scaler": scaler.state_dict() if scaler else None,
|
| 155 |
+
"config": asdict(cfg),
|
| 156 |
+
"classes": cfg.classes,
|
| 157 |
+
}, path)
|
| 158 |
+
# Also write/refresh a 'last.pt' pointer for easy resume.
|
| 159 |
+
torch.save(torch.load(path, map_location="cpu"), cfg.ckpt_dir / "last.pt")
|
| 160 |
+
return path
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _find_latest_checkpoint(cfg: TrainConfig) -> Path | None:
|
| 164 |
+
candidates: list[Path] = []
|
| 165 |
+
for base in [cfg.ckpt_dir, *( [Path(cfg.resume_dir)] if cfg.resume_dir else [] )]:
|
| 166 |
+
if base and base.exists():
|
| 167 |
+
last = base / "last.pt"
|
| 168 |
+
if last.exists():
|
| 169 |
+
candidates.append(last)
|
| 170 |
+
candidates += sorted(base.glob("epoch_*.pt"))
|
| 171 |
+
return candidates[-1] if candidates else None
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def maybe_resume(cfg: TrainConfig, model, optimizer, scheduler, scaler) -> tuple[int, float]:
|
| 175 |
+
ckpt = _find_latest_checkpoint(cfg)
|
| 176 |
+
if ckpt is None:
|
| 177 |
+
return 0, -1.0
|
| 178 |
+
state = torch.load(ckpt, map_location="cpu")
|
| 179 |
+
model.load_state_dict(state["model"])
|
| 180 |
+
optimizer.load_state_dict(state["optimizer"])
|
| 181 |
+
if scheduler and state.get("scheduler"):
|
| 182 |
+
scheduler.load_state_dict(state["scheduler"])
|
| 183 |
+
if scaler and state.get("scaler"):
|
| 184 |
+
scaler.load_state_dict(state["scaler"])
|
| 185 |
+
start_epoch = int(state["epoch"]) + 1
|
| 186 |
+
best_f1 = float(state.get("best_f1", -1.0))
|
| 187 |
+
print(f"[resume] from {ckpt} -> start_epoch={start_epoch}, best_f1={best_f1:.4f}")
|
| 188 |
+
return start_epoch, best_f1
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# Train / eval
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
@torch.inference_mode()
|
| 195 |
+
def evaluate(model, loader, device, criterion) -> dict:
|
| 196 |
+
model.eval()
|
| 197 |
+
losses, ys, ps = [], [], []
|
| 198 |
+
for x, y in loader:
|
| 199 |
+
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
|
| 200 |
+
logits = model(x)
|
| 201 |
+
losses.append(criterion(logits, y).item())
|
| 202 |
+
ys.append(y.cpu().numpy())
|
| 203 |
+
ps.append(logits.argmax(1).cpu().numpy())
|
| 204 |
+
y_true, y_pred = np.concatenate(ys), np.concatenate(ps)
|
| 205 |
+
acc = float((y_true == y_pred).mean())
|
| 206 |
+
macro_f1 = float(f1_score(y_true, y_pred, average="macro"))
|
| 207 |
+
report = classification_report(y_true, y_pred, target_names=CLASSES,
|
| 208 |
+
output_dict=True, zero_division=0)
|
| 209 |
+
return {"loss": float(np.mean(losses)), "acc": acc, "macro_f1": macro_f1, "report": report}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def run_training(cfg: TrainConfig, wandb=None) -> dict:
|
| 213 |
+
torch.manual_seed(cfg.seed)
|
| 214 |
+
np.random.seed(cfg.seed)
|
| 215 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 216 |
+
|
| 217 |
+
loaders = get_dataloaders(cfg)
|
| 218 |
+
model = DINOv2Classifier(len(cfg.classes), cfg.backbone_name).to(device)
|
| 219 |
+
param_groups = set_trainable(model, cfg)
|
| 220 |
+
optimizer = torch.optim.AdamW(param_groups, weight_decay=cfg.weight_decay)
|
| 221 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cfg.epochs)
|
| 222 |
+
scaler = torch.cuda.amp.GradScaler(enabled=(device == "cuda"))
|
| 223 |
+
criterion = nn.CrossEntropyLoss(label_smoothing=cfg.label_smoothing)
|
| 224 |
+
|
| 225 |
+
start_epoch, best_f1 = maybe_resume(cfg, model, optimizer, scheduler, scaler)
|
| 226 |
+
|
| 227 |
+
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 228 |
+
print(f"[train] stage={cfg.stage} device={device} trainable_params={n_trainable:,} "
|
| 229 |
+
f"epochs {start_epoch}->{cfg.epochs}")
|
| 230 |
+
|
| 231 |
+
for epoch in range(start_epoch, cfg.epochs):
|
| 232 |
+
model.train()
|
| 233 |
+
running = 0.0
|
| 234 |
+
for x, y in loaders["train"]:
|
| 235 |
+
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
|
| 236 |
+
optimizer.zero_grad(set_to_none=True)
|
| 237 |
+
with torch.cuda.amp.autocast(enabled=(device == "cuda")):
|
| 238 |
+
loss = criterion(model(x), y)
|
| 239 |
+
scaler.scale(loss).backward()
|
| 240 |
+
scaler.step(optimizer)
|
| 241 |
+
scaler.update()
|
| 242 |
+
running += loss.item()
|
| 243 |
+
scheduler.step()
|
| 244 |
+
train_loss = running / max(len(loaders["train"]), 1)
|
| 245 |
+
val = evaluate(model, loaders["val"], device, criterion)
|
| 246 |
+
best_f1 = max(best_f1, val["macro_f1"])
|
| 247 |
+
print(f"[epoch {epoch:03d}] train_loss={train_loss:.4f} "
|
| 248 |
+
f"val_loss={val['loss']:.4f} val_acc={val['acc']:.4f} "
|
| 249 |
+
f"val_macroF1={val['macro_f1']:.4f} (best {best_f1:.4f})")
|
| 250 |
+
|
| 251 |
+
if wandb is not None:
|
| 252 |
+
wandb.log({"epoch": epoch, "train_loss": train_loss, "val_loss": val["loss"],
|
| 253 |
+
"val_acc": val["acc"], "val_macro_f1": val["macro_f1"],
|
| 254 |
+
"lr": optimizer.param_groups[0]["lr"]})
|
| 255 |
+
|
| 256 |
+
# CHECKPOINT EVERY EPOCH (Kaggle constraint).
|
| 257 |
+
save_checkpoint(cfg, model, optimizer, scheduler, scaler, epoch, best_f1)
|
| 258 |
+
|
| 259 |
+
# Final eval on test if present.
|
| 260 |
+
results = {"best_val_macro_f1": best_f1}
|
| 261 |
+
if "test" in loaders:
|
| 262 |
+
results["test"] = evaluate(model, loaders["test"], device, criterion)
|
| 263 |
+
print(f"[test] acc={results['test']['acc']:.4f} "
|
| 264 |
+
f"macroF1={results['test']['macro_f1']:.4f}")
|
| 265 |
+
|
| 266 |
+
_write_config(cfg, results)
|
| 267 |
+
if cfg.push_to_hub and cfg.hf_repo:
|
| 268 |
+
push_to_hub(cfg, model, results)
|
| 269 |
+
return results
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _write_config(cfg: TrainConfig, results: dict) -> None:
|
| 273 |
+
cfg.ckpt_dir.mkdir(parents=True, exist_ok=True)
|
| 274 |
+
with open(cfg.ckpt_dir / "config.json", "w") as f:
|
| 275 |
+
json.dump({"config": asdict(cfg), "classes": cfg.classes, "results": _slim(results)},
|
| 276 |
+
f, indent=2)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _slim(results: dict) -> dict:
|
| 280 |
+
out = {k: v for k, v in results.items() if k != "test"}
|
| 281 |
+
if "test" in results:
|
| 282 |
+
out["test"] = {k: results["test"][k] for k in ("loss", "acc", "macro_f1")}
|
| 283 |
+
return out
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def push_to_hub(cfg: TrainConfig, model, results: dict) -> None:
|
| 287 |
+
from huggingface_hub import HfApi
|
| 288 |
+
api = HfApi(token=os.environ.get("HF_TOKEN"))
|
| 289 |
+
api.create_repo(cfg.hf_repo, exist_ok=True)
|
| 290 |
+
weights = cfg.ckpt_dir / "model.safetensors"
|
| 291 |
+
try:
|
| 292 |
+
from safetensors.torch import save_file
|
| 293 |
+
save_file(model.state_dict(), str(weights))
|
| 294 |
+
except Exception:
|
| 295 |
+
weights = cfg.ckpt_dir / "model.pt"
|
| 296 |
+
torch.save(model.state_dict(), weights)
|
| 297 |
+
api.upload_file(path_or_fileobj=str(weights), path_in_repo=weights.name, repo_id=cfg.hf_repo)
|
| 298 |
+
api.upload_file(path_or_fileobj=str(cfg.ckpt_dir / "config.json"),
|
| 299 |
+
path_in_repo=f"{cfg.stage}_config.json", repo_id=cfg.hf_repo)
|
| 300 |
+
print(f"[hub] pushed weights + config to {cfg.hf_repo}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
if __name__ == "__main__":
|
| 304 |
+
import argparse
|
| 305 |
+
ap = argparse.ArgumentParser()
|
| 306 |
+
ap.add_argument("--stage", default="linear_probe", choices=["linear_probe", "finetune"])
|
| 307 |
+
ap.add_argument("--data-root", default="/kaggle/input/noaa-coral-bleaching")
|
| 308 |
+
ap.add_argument("--work-dir", default="/kaggle/working")
|
| 309 |
+
ap.add_argument("--epochs", type=int, default=10)
|
| 310 |
+
ap.add_argument("--batch-size", type=int, default=64)
|
| 311 |
+
ap.add_argument("--hf-repo", default=None)
|
| 312 |
+
ap.add_argument("--push-to-hub", action="store_true")
|
| 313 |
+
a = ap.parse_args()
|
| 314 |
+
run_training(TrainConfig(
|
| 315 |
+
stage=a.stage, data_root=a.data_root, work_dir=a.work_dir, epochs=a.epochs,
|
| 316 |
+
batch_size=a.batch_size, hf_repo=a.hf_repo, push_to_hub=a.push_to_hub,
|
| 317 |
+
))
|
backend/observability.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Observability aggregations over inference_logs. Phase 7.
|
| 2 |
+
|
| 3 |
+
Pure functions (testable) that turn raw inference_logs rows into the three dashboard views
|
| 4 |
+
computed entirely from Supabase data — no external observability tool:
|
| 5 |
+
- rolling mean prediction_set_size per day -> drift proxy (rising = distribution shift)
|
| 6 |
+
- latency p50 / p95 per day
|
| 7 |
+
- class distribution: current 7-day window vs the prior 7-day baseline
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
from datetime import date, timedelta
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _day(ts) -> str:
|
| 16 |
+
return str(ts)[:10]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _percentile(values: list[float], p: float) -> float:
|
| 20 |
+
if not values:
|
| 21 |
+
return 0.0
|
| 22 |
+
v = sorted(values)
|
| 23 |
+
k = (len(v) - 1) * p / 100.0
|
| 24 |
+
f = int(k)
|
| 25 |
+
if f + 1 < len(v):
|
| 26 |
+
return round(v[f] + (v[f + 1] - v[f]) * (k - f), 1)
|
| 27 |
+
return round(v[f], 1)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def rolling_set_size(logs: list[dict]) -> list[dict]:
|
| 31 |
+
by: dict[str, list[float]] = defaultdict(list)
|
| 32 |
+
for r in logs:
|
| 33 |
+
by[_day(r["ts"])].append(float(r.get("prediction_set_size", 1)))
|
| 34 |
+
return [{"date": d, "avg_set_size": round(sum(v) / len(v), 3), "n": len(v)}
|
| 35 |
+
for d, v in sorted(by.items())]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def latency_percentiles(logs: list[dict]) -> list[dict]:
|
| 39 |
+
by: dict[str, list[float]] = defaultdict(list)
|
| 40 |
+
for r in logs:
|
| 41 |
+
by[_day(r["ts"])].append(float(r.get("latency_ms", 0)))
|
| 42 |
+
return [{"date": d, "p50": _percentile(v, 50), "p95": _percentile(v, 95), "n": len(v)}
|
| 43 |
+
for d, v in sorted(by.items())]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def class_distribution(logs: list[dict], classes: tuple[str, ...]) -> dict:
|
| 47 |
+
days = sorted({_day(r["ts"]) for r in logs})
|
| 48 |
+
if not days:
|
| 49 |
+
return {"current": {}, "baseline": {}, "current_window": None, "baseline_window": None}
|
| 50 |
+
anchor = date.fromisoformat(days[-1])
|
| 51 |
+
cur_lo = anchor - timedelta(days=6)
|
| 52 |
+
base_hi = cur_lo - timedelta(days=1)
|
| 53 |
+
base_lo = base_hi - timedelta(days=6)
|
| 54 |
+
|
| 55 |
+
def frac(lo: date, hi: date) -> dict:
|
| 56 |
+
counts = {c: 0 for c in classes}
|
| 57 |
+
for r in logs:
|
| 58 |
+
d = date.fromisoformat(_day(r["ts"]))
|
| 59 |
+
if lo <= d <= hi:
|
| 60 |
+
lab = r.get("predicted_label")
|
| 61 |
+
if lab in counts:
|
| 62 |
+
counts[lab] += 1
|
| 63 |
+
total = sum(counts.values()) or 1
|
| 64 |
+
return {c: round(counts[c] / total * 100, 1) for c in classes}
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"current": frac(cur_lo, anchor),
|
| 68 |
+
"baseline": frac(base_lo, base_hi),
|
| 69 |
+
"current_window": [cur_lo.isoformat(), anchor.isoformat()],
|
| 70 |
+
"baseline_window": [base_lo.isoformat(), base_hi.isoformat()],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build(logs: list[dict], classes: tuple[str, ...]) -> dict:
|
| 75 |
+
return {
|
| 76 |
+
"drift": rolling_set_size(logs),
|
| 77 |
+
"latency": latency_percentiles(logs),
|
| 78 |
+
"class_distribution": class_distribution(logs, classes),
|
| 79 |
+
"total_logs": len(logs),
|
| 80 |
+
}
|
backend/persistence.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistence wrappers: Cloudflare R2 (objects) + Supabase (logging). Phase 5.
|
| 2 |
+
|
| 3 |
+
Both degrade to safe no-ops when their credentials are absent, so the pipeline runs
|
| 4 |
+
end-to-end locally. Tables match backend/db/schema.sql.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Any, Optional
|
| 11 |
+
|
| 12 |
+
from .config import settings
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Object storage — Supabase Storage by default (no extra account), R2 if configured.
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
class ObjectStore:
|
| 21 |
+
def __init__(self, supabase_client=None) -> None:
|
| 22 |
+
self._r2 = None
|
| 23 |
+
self._sb = supabase_client # reuse the Supabase service_role client
|
| 24 |
+
self._bucket = settings.storage_bucket
|
| 25 |
+
if settings.r2_enabled:
|
| 26 |
+
try:
|
| 27 |
+
import boto3 # type: ignore
|
| 28 |
+
self._r2 = boto3.client(
|
| 29 |
+
"s3", endpoint_url=settings.r2_endpoint,
|
| 30 |
+
aws_access_key_id=settings.r2_key_id,
|
| 31 |
+
aws_secret_access_key=settings.r2_secret,
|
| 32 |
+
)
|
| 33 |
+
logger.info("object store: Cloudflare R2 (bucket=%s)", settings.r2_bucket)
|
| 34 |
+
except Exception as e: # noqa: BLE001
|
| 35 |
+
logger.warning("R2 init failed: %s", e)
|
| 36 |
+
elif self._sb is not None:
|
| 37 |
+
logger.info("object store: Supabase Storage (bucket=%s)", self._bucket)
|
| 38 |
+
|
| 39 |
+
def upload(self, key: str, data: bytes, content_type: str) -> str:
|
| 40 |
+
"""Store bytes, return a public url. Falls back to a local placeholder if neither
|
| 41 |
+
backend is configured (the frontend then shows its gradient placeholder)."""
|
| 42 |
+
# 1) Cloudflare R2
|
| 43 |
+
if self._r2 is not None:
|
| 44 |
+
self._r2.put_object(Bucket=settings.r2_bucket, Key=key, Body=data,
|
| 45 |
+
ContentType=content_type)
|
| 46 |
+
ep = (settings.r2_endpoint or "").rstrip("/")
|
| 47 |
+
return f"{ep}/{settings.r2_bucket}/{key}"
|
| 48 |
+
# 2) Supabase Storage (via the service_role client; bucket must be public)
|
| 49 |
+
if self._sb is not None:
|
| 50 |
+
try:
|
| 51 |
+
self._sb.storage.from_(self._bucket).upload(
|
| 52 |
+
key, data, {"content-type": content_type, "upsert": "true"})
|
| 53 |
+
url = self._sb.storage.from_(self._bucket).get_public_url(key)
|
| 54 |
+
return url if isinstance(url, str) else key
|
| 55 |
+
except Exception as e: # noqa: BLE001
|
| 56 |
+
logger.warning("supabase storage upload failed: %s", e)
|
| 57 |
+
# 3) local placeholder
|
| 58 |
+
return f"local://uploads/{key}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
# Supabase logging
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
class SupabaseLogger:
|
| 65 |
+
def __init__(self) -> None:
|
| 66 |
+
self._client = None
|
| 67 |
+
if settings.supabase_enabled:
|
| 68 |
+
try:
|
| 69 |
+
from supabase import create_client # type: ignore
|
| 70 |
+
self._client = create_client(settings.supabase_url, settings.supabase_key)
|
| 71 |
+
logger.info("Supabase logging enabled")
|
| 72 |
+
except Exception as e: # noqa: BLE001
|
| 73 |
+
logger.warning("Supabase init failed, logging disabled: %s", e)
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def enabled(self) -> bool:
|
| 77 |
+
return self._client is not None
|
| 78 |
+
|
| 79 |
+
def _insert(self, table: str, rows: list[dict[str, Any]]) -> None:
|
| 80 |
+
if self._client is None or not rows:
|
| 81 |
+
return
|
| 82 |
+
try:
|
| 83 |
+
self._client.table(table).insert(rows).execute()
|
| 84 |
+
except Exception as e: # noqa: BLE001
|
| 85 |
+
logger.warning("supabase insert into %s failed: %s", table, e)
|
| 86 |
+
|
| 87 |
+
# ---- jobs ----
|
| 88 |
+
def upsert_job(self, row: dict[str, Any]) -> None:
|
| 89 |
+
if self._client is None:
|
| 90 |
+
return
|
| 91 |
+
try:
|
| 92 |
+
self._client.table("jobs").upsert(row).execute()
|
| 93 |
+
except Exception as e: # noqa: BLE001
|
| 94 |
+
logger.warning("supabase upsert job failed: %s", e)
|
| 95 |
+
|
| 96 |
+
# ---- inference_logs (one row per classified segment) ----
|
| 97 |
+
def log_segments(self, rows: list[dict[str, Any]]) -> None:
|
| 98 |
+
self._insert("inference_logs", rows)
|
| 99 |
+
|
| 100 |
+
# ---- review_queue (uncertain predictions) ----
|
| 101 |
+
def log_review(self, rows: list[dict[str, Any]]) -> None:
|
| 102 |
+
self._insert("review_queue", rows)
|
| 103 |
+
|
| 104 |
+
# ---- health_snapshots (per-upload aggregate) ----
|
| 105 |
+
def log_snapshot(self, row: dict[str, Any]) -> None:
|
| 106 |
+
self._insert("health_snapshots", [row])
|
| 107 |
+
|
| 108 |
+
# ---- reads (Phase 6 review/tracker + Phase 7 dashboard) ----
|
| 109 |
+
def _select(self, table: str, build) -> list[dict[str, Any]]:
|
| 110 |
+
if self._client is None:
|
| 111 |
+
return []
|
| 112 |
+
try:
|
| 113 |
+
return build(self._client.table(table).select("*")).execute().data or []
|
| 114 |
+
except Exception as e: # noqa: BLE001
|
| 115 |
+
logger.warning("supabase select from %s failed: %s", table, e)
|
| 116 |
+
return []
|
| 117 |
+
|
| 118 |
+
def review_queue(self, limit: int = 50) -> list[dict[str, Any]]:
|
| 119 |
+
return self._select("review_queue", lambda q: q.eq("status", "pending")
|
| 120 |
+
.order("created_at", desc=True).limit(limit))
|
| 121 |
+
|
| 122 |
+
def reef_locations(self) -> list[dict[str, Any]]:
|
| 123 |
+
return self._select("reef_locations", lambda q: q.order("name"))
|
| 124 |
+
|
| 125 |
+
def snapshots(self, reef_id: str) -> list[dict[str, Any]]:
|
| 126 |
+
return self._select("health_snapshots", lambda q: q.eq("reef_location_id", reef_id)
|
| 127 |
+
.order("snapshot_time"))
|
| 128 |
+
|
| 129 |
+
def recent_logs(self, limit: int = 5000) -> list[dict[str, Any]]:
|
| 130 |
+
return self._select("inference_logs", lambda q: q.order("ts", desc=True).limit(limit))
|
| 131 |
+
|
| 132 |
+
def confirm_label(self, review_id: str, label: str, labeled_by: str = "admin") -> bool:
|
| 133 |
+
if self._client is None:
|
| 134 |
+
return False
|
| 135 |
+
try:
|
| 136 |
+
row = self._client.table("review_queue").select("*").eq("id", review_id).execute().data
|
| 137 |
+
if not row:
|
| 138 |
+
return False
|
| 139 |
+
r = row[0]
|
| 140 |
+
self._client.table("human_labels").insert({
|
| 141 |
+
"review_queue_id": review_id, "image_id": r.get("image_id"),
|
| 142 |
+
"segment_id": r.get("segment_id"), "confirmed_label": label,
|
| 143 |
+
"labeled_by": labeled_by,
|
| 144 |
+
}).execute()
|
| 145 |
+
self._client.table("review_queue").update({"status": "confirmed"}).eq("id", review_id).execute()
|
| 146 |
+
return True
|
| 147 |
+
except Exception as e: # noqa: BLE001
|
| 148 |
+
logger.warning("confirm_label failed: %s", e)
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def new_request_id() -> str:
|
| 153 |
+
return str(uuid.uuid4())
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
supabase = SupabaseLogger()
|
| 157 |
+
r2 = ObjectStore(supabase_client=supabase._client) # exported as `r2` for pipeline import
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ReefScan backend — Python 3.11+
|
| 2 |
+
# Install (CPU inference target, e.g. HF Spaces free CPU):
|
| 3 |
+
# pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
|
| 4 |
+
#
|
| 5 |
+
# NOTE: torch/sam2/WaterNet notes below. Versions are pinned with lower bounds and will
|
| 6 |
+
# be hard-pinned once the Phase 1.5 feasibility spike confirms what actually loads/runs
|
| 7 |
+
# inside the HF Spaces free CPU memory budget.
|
| 8 |
+
|
| 9 |
+
# --- Web / API ---
|
| 10 |
+
fastapi>=0.111
|
| 11 |
+
uvicorn[standard]>=0.30
|
| 12 |
+
python-multipart>=0.0.9 # file uploads
|
| 13 |
+
httpx>=0.27 # POST /infer url-fetch path + TestClient
|
| 14 |
+
pydantic>=2.7
|
| 15 |
+
python-dotenv>=1.0
|
| 16 |
+
|
| 17 |
+
# --- ML core (CPU wheels via --extra-index-url above) ---
|
| 18 |
+
torch>=2.4
|
| 19 |
+
torchvision>=0.19
|
| 20 |
+
numpy>=1.26
|
| 21 |
+
pillow>=10.3
|
| 22 |
+
opencv-python-headless>=4.10 # headless: no GUI libs on Spaces
|
| 23 |
+
|
| 24 |
+
# --- Models ---
|
| 25 |
+
transformers>=4.44 # DINOv2 (facebook/dinov2-base)
|
| 26 |
+
# SAM2 (Hiera-Small) — installed from source, no PyPI release:
|
| 27 |
+
# pip install "git+https://github.com/facebookresearch/segment-anything-2.git"
|
| 28 |
+
# WaterNet — pretrained inference only; no PyPI package. Vendor weights + minimal
|
| 29 |
+
# forward pass under backend/inference/ (source documented in enhancer.py, Phase 5).
|
| 30 |
+
|
| 31 |
+
# --- Uncertainty / conformal ---
|
| 32 |
+
mapie>=1.0 # SplitConformalClassifier, LAC/APS scoring
|
| 33 |
+
scikit-learn>=1.5
|
| 34 |
+
|
| 35 |
+
# --- Video frame extraction ---
|
| 36 |
+
scenedetect[opencv]>=0.6.4 # PySceneDetect, scene-change based
|
| 37 |
+
|
| 38 |
+
# --- Data / training ---
|
| 39 |
+
pandas>=2.2
|
| 40 |
+
wandb>=0.17 # experiment tracking (free academic tier)
|
| 41 |
+
huggingface_hub>=0.24 # pull/push model weights
|
| 42 |
+
|
| 43 |
+
# --- Storage / DB ---
|
| 44 |
+
supabase>=2.6 # Supabase Postgres client
|
| 45 |
+
boto3>=1.34 # Cloudflare R2 (S3-compatible)
|
| 46 |
+
|
| 47 |
+
# --- Tests ---
|
| 48 |
+
pytest>=8.2
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for the FROZEN inference response contract. Phase 5.
|
| 2 |
+
|
| 3 |
+
These mirror frontend/lib/types.ts exactly — the field names/shape are the integration
|
| 4 |
+
seam (Phase 6 mocks it, Phase 5 fills it). Do not rename fields without changing both.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Literal, Optional
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
CoralClass = Literal["healthy", "bleached"]
|
| 13 |
+
JobStatus = Literal["queued", "processing", "complete", "failed"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Segment(BaseModel):
|
| 17 |
+
segment_id: int
|
| 18 |
+
mask_area_px: int
|
| 19 |
+
bbox: list[int] # [x0, y0, x1, y1] in source-image px
|
| 20 |
+
predicted_class: CoralClass
|
| 21 |
+
prediction_set: list[CoralClass]
|
| 22 |
+
prediction_set_size: int
|
| 23 |
+
confidence_scores: dict[str, float] # {"healthy": .., "bleached": ..}
|
| 24 |
+
coverage_pct: float
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class AreaWeighted(BaseModel):
|
| 28 |
+
healthy_pct: float
|
| 29 |
+
bleached_pct: float
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class Summary(BaseModel):
|
| 33 |
+
total_segments: int
|
| 34 |
+
area_weighted: AreaWeighted
|
| 35 |
+
uncertain_segments: int
|
| 36 |
+
dominant_status: CoralClass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class InferenceResponse(BaseModel):
|
| 40 |
+
"""Returned by GET /infer/{job_id}. When status != 'complete', segments/summary are
|
| 41 |
+
empty/None and the client keeps polling."""
|
| 42 |
+
job_id: str
|
| 43 |
+
status: JobStatus
|
| 44 |
+
processing_time_ms: int = 0
|
| 45 |
+
image_url: str = ""
|
| 46 |
+
model_version: str = ""
|
| 47 |
+
image_width: Optional[int] = None
|
| 48 |
+
image_height: Optional[int] = None
|
| 49 |
+
segments: list[Segment] = Field(default_factory=list)
|
| 50 |
+
summary: Optional[Summary] = None
|
| 51 |
+
error_message: Optional[str] = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class SubmitResponse(BaseModel):
|
| 55 |
+
"""Returned by POST /infer — the async enqueue ack."""
|
| 56 |
+
job_id: str
|
| 57 |
+
status: JobStatus = "queued"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class HealthResponse(BaseModel):
|
| 61 |
+
status: str
|
| 62 |
+
models_loaded: bool
|
| 63 |
+
stub_mode: bool
|
| 64 |
+
model_version: str
|
| 65 |
+
last_inference_latency_ms: Optional[int] = None
|
backend/tests/__init__.py
ADDED
|
File without changes
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 5 async-pipeline contract tests (stub mode — no weights/Supabase/R2 needed).
|
| 2 |
+
|
| 3 |
+
Drives the real async path: POST /infer enqueues, GET /infer/{job_id} polls to completion,
|
| 4 |
+
and the response is validated against the frozen contract (frontend/lib/types.ts).
|
| 5 |
+
"""
|
| 6 |
+
import io
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
os.environ["REEFSCAN_STUB"] = "1" # force synthetic inference before app import
|
| 11 |
+
|
| 12 |
+
from PIL import Image # noqa: E402
|
| 13 |
+
from fastapi.testclient import TestClient # noqa: E402
|
| 14 |
+
|
| 15 |
+
from backend.main import app # noqa: E402
|
| 16 |
+
|
| 17 |
+
SEGMENT_KEYS = {
|
| 18 |
+
"segment_id", "mask_area_px", "bbox", "predicted_class", "prediction_set",
|
| 19 |
+
"prediction_set_size", "confidence_scores", "coverage_pct",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _png(w=600, h=380):
|
| 24 |
+
b = io.BytesIO()
|
| 25 |
+
Image.new("RGB", (w, h), (20, 80, 90)).save(b, format="PNG")
|
| 26 |
+
return b.getvalue()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _run_to_completion(client, job_id, tries=60):
|
| 30 |
+
for _ in range(tries):
|
| 31 |
+
resp = client.get(f"/infer/{job_id}").json()
|
| 32 |
+
if resp["status"] == "complete":
|
| 33 |
+
return resp
|
| 34 |
+
if resp["status"] == "failed":
|
| 35 |
+
raise AssertionError(f"job failed: {resp.get('error_message')}")
|
| 36 |
+
time.sleep(0.1)
|
| 37 |
+
raise AssertionError("job did not complete in time")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_health_stub_mode():
|
| 41 |
+
with TestClient(app) as client:
|
| 42 |
+
h = client.get("/health").json()
|
| 43 |
+
assert h["status"] == "ok"
|
| 44 |
+
assert h["stub_mode"] is True # no weights -> stub
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_async_infer_contract():
|
| 48 |
+
with TestClient(app) as client:
|
| 49 |
+
r = client.post("/infer", files={"file": ("reef.png", _png(), "image/png")})
|
| 50 |
+
assert r.status_code == 202
|
| 51 |
+
job_id = r.json()["job_id"]
|
| 52 |
+
assert client.get(f"/infer/{job_id}").json()["status"] in ("queued", "processing", "complete")
|
| 53 |
+
|
| 54 |
+
data = _run_to_completion(client, job_id)
|
| 55 |
+
|
| 56 |
+
assert data["image_width"] == 600 and data["image_height"] == 380
|
| 57 |
+
assert data["model_version"]
|
| 58 |
+
assert len(data["segments"]) >= 1
|
| 59 |
+
for seg in data["segments"]:
|
| 60 |
+
assert set(seg) == SEGMENT_KEYS
|
| 61 |
+
assert set(seg["confidence_scores"]) == {"healthy", "bleached"}
|
| 62 |
+
assert seg["predicted_class"] in ("healthy", "bleached")
|
| 63 |
+
assert seg["prediction_set_size"] == len(seg["prediction_set"]) >= 1
|
| 64 |
+
assert abs(sum(seg["confidence_scores"].values()) - 1.0) < 1e-3
|
| 65 |
+
|
| 66 |
+
s = data["summary"]
|
| 67 |
+
assert s["total_segments"] == len(data["segments"])
|
| 68 |
+
assert abs(s["area_weighted"]["healthy_pct"] + s["area_weighted"]["bleached_pct"] - 100) < 0.2
|
| 69 |
+
assert s["dominant_status"] in ("healthy", "bleached")
|
| 70 |
+
# uncertain accounting must match the per-segment sets
|
| 71 |
+
unc = sum(1 for x in data["segments"] if x["prediction_set_size"] > 1)
|
| 72 |
+
assert s["uncertain_segments"] == unc
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_unknown_job_404():
|
| 76 |
+
with TestClient(app) as client:
|
| 77 |
+
assert client.get("/infer/does-not-exist").status_code == 404
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_infer_requires_input():
|
| 81 |
+
with TestClient(app) as client:
|
| 82 |
+
assert client.post("/infer").status_code == 400
|
backend/tests/test_observability.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 7 observability aggregation tests + read-endpoint fallbacks (no Supabase)."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
os.environ["REEFSCAN_STUB"] = "1"
|
| 5 |
+
|
| 6 |
+
from fastapi.testclient import TestClient # noqa: E402
|
| 7 |
+
|
| 8 |
+
from backend import observability # noqa: E402
|
| 9 |
+
from backend.main import app # noqa: E402
|
| 10 |
+
|
| 11 |
+
CLASSES = ("healthy", "bleached")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _logs():
|
| 15 |
+
# two days, varied set sizes / latencies / labels
|
| 16 |
+
rows = []
|
| 17 |
+
for i in range(10):
|
| 18 |
+
rows.append({"ts": "2026-06-20T10:00:00Z", "prediction_set_size": 1 if i % 2 else 2,
|
| 19 |
+
"latency_ms": 100 + i * 10, "predicted_label": "healthy" if i < 7 else "bleached"})
|
| 20 |
+
for i in range(10):
|
| 21 |
+
rows.append({"ts": "2026-06-27T10:00:00Z", "prediction_set_size": 2 if i % 2 else 1,
|
| 22 |
+
"latency_ms": 200 + i * 10, "predicted_label": "bleached" if i < 6 else "healthy"})
|
| 23 |
+
return rows
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_rolling_set_size():
|
| 27 |
+
out = observability.rolling_set_size(_logs())
|
| 28 |
+
assert [r["date"] for r in out] == ["2026-06-20", "2026-06-27"]
|
| 29 |
+
assert all(1.0 <= r["avg_set_size"] <= 2.0 for r in out)
|
| 30 |
+
assert out[0]["n"] == 10
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_latency_percentiles():
|
| 34 |
+
out = observability.latency_percentiles(_logs())
|
| 35 |
+
assert out[0]["p50"] <= out[0]["p95"]
|
| 36 |
+
assert out[1]["p95"] >= out[1]["p50"] >= 200
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_class_distribution_windows():
|
| 40 |
+
cd = observability.class_distribution(_logs(), CLASSES)
|
| 41 |
+
assert abs(cd["current"]["healthy"] + cd["current"]["bleached"] - 100) < 0.2
|
| 42 |
+
assert cd["current_window"] and cd["baseline_window"]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_build_shape():
|
| 46 |
+
o = observability.build(_logs(), CLASSES)
|
| 47 |
+
assert set(o) == {"drift", "latency", "class_distribution", "total_logs"}
|
| 48 |
+
assert o["total_logs"] == 20
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_read_endpoints_empty_without_supabase():
|
| 52 |
+
with TestClient(app) as client:
|
| 53 |
+
assert client.get("/review-queue").json() == []
|
| 54 |
+
assert client.get("/reef-locations").json() == []
|
| 55 |
+
assert client.get("/reef-locations/x/snapshots").json() == []
|
| 56 |
+
obs = client.get("/observability").json()
|
| 57 |
+
assert obs["total_logs"] == 0 and obs["drift"] == []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_confirm_validates_label():
|
| 61 |
+
with TestClient(app) as client:
|
| 62 |
+
assert client.post("/review-queue/abc/confirm", json={"label": "nope"}).status_code == 400
|
| 63 |
+
# valid label but no supabase -> ok False, still 200
|
| 64 |
+
r = client.post("/review-queue/abc/confirm", json={"label": "healthy"})
|
| 65 |
+
assert r.status_code == 200 and r.json()["ok"] is False
|