HrishiKabra commited on
Commit
f8dd35f
·
verified ·
1 Parent(s): cc19137

Upload folder using huggingface_hub

Browse files
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