Skip to content

Commit d050b30

Browse files
obielinfemi23
andcommitted
fix(auth): gate cv_dev bypass; feat(inference): ONNX serving; data: channel-aware loader
Co-authored-by: Olufemi Taiwo <117665354+femi23@users.noreply.github.com>
1 parent 8044b77 commit d050b30

4 files changed

Lines changed: 110 additions & 12 deletions

File tree

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ optuna>=3.1.0
4949
# Explainability & Governance
5050
shap>=0.42.0
5151

52+
# Optimised inference (serve exported ONNX models)
53+
onnxruntime>=1.16.0
54+
5255
# Testing and Development
5356
pytest>=7.0.0
5457
pytest-cov>=3.0.0

src/climatevision/api/auth.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import hashlib
1111
import hmac
1212
import logging
13+
import os
1314
import secrets
1415
from datetime import datetime
1516
from typing import Optional
@@ -77,13 +78,17 @@ def validate_key(self, api_key: str) -> Optional[dict]:
7778
if not api_key or not api_key.startswith("cv_"):
7879
return None
7980

80-
# Development bypass — allow cv_dev for local testing
81+
# Development bypass — only when explicitly enabled. Off by default so
82+
# production is secure even if CLIMATEVISION_ALLOW_DEV_KEY is unset.
8183
if api_key == "cv_dev":
82-
return {
83-
"id": 0,
84-
"name": "Development",
85-
"demo": True,
86-
}
84+
if os.environ.get("CLIMATEVISION_ALLOW_DEV_KEY", "0") == "1":
85+
return {
86+
"id": 0,
87+
"name": "Development",
88+
"demo": True,
89+
}
90+
logger.warning("cv_dev key rejected: dev bypass disabled in this environment.")
91+
return None
8792

8893
# Check cache first
8994
key_hash = self.hash_key(api_key)

src/climatevision/data/dataset.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,13 @@ def __init__(
8282
transform: Optional[Callable] = None,
8383
normalizer: Optional[Callable] = None,
8484
image_size: int = 256,
85+
n_channels: int = 4,
8586
):
8687
self.root = Path(root)
8788
self.transform = transform
8889
self.normalizer = normalizer
8990
self.image_size = image_size
91+
self.n_channels = n_channels
9092

9193
image_dir = self.root / "images"
9294
mask_dir = self.root / "masks"
@@ -119,13 +121,13 @@ def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]:
119121
image = _load_tif(img_path) # (C, H, W) float32
120122
mask = _load_mask(mask_path) # (H, W) uint8
121123

122-
# Ensure 4 bands (pad with zeros if fewer)
124+
# Ensure exactly n_channels bands (pad with zeros if fewer, truncate if more)
123125
c, h, w = image.shape
124-
if c < 4:
125-
pad = np.zeros((4 - c, h, w), dtype=np.float32)
126+
if c < self.n_channels:
127+
pad = np.zeros((self.n_channels - c, h, w), dtype=np.float32)
126128
image = np.concatenate([image, pad], axis=0)
127-
elif c > 4:
128-
image = image[:4]
129+
elif c > self.n_channels:
130+
image = image[:self.n_channels]
129131

130132
# Ensure spatial size — pad if smaller, random crop via transform
131133
if h < self.image_size or w < self.image_size:
@@ -217,6 +219,7 @@ def create_dataloaders(
217219
normalizer: Optional[Callable] = None,
218220
pin_memory: bool = True,
219221
use_weighted_sampler: bool = True,
222+
n_channels: int = 4,
220223
) -> dict[str, DataLoader]:
221224
"""
222225
Build train / val / test DataLoaders from a data directory.
@@ -252,6 +255,7 @@ def create_dataloaders(
252255
transform=transform,
253256
normalizer=normalizer,
254257
image_size=image_size,
258+
n_channels=n_channels,
255259
)
256260

257261
sampler = None

src/climatevision/inference/pipeline.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,60 @@ def _find_best_checkpoint(analysis_type: str) -> Optional[Path]:
6767
return candidates[0] if candidates else None
6868

6969

70-
def _load_model(analysis_type: str = "deforestation") -> tuple[UNet, torch.device]:
70+
def _find_onnx(analysis_type: str) -> Optional[Path]:
71+
"""
72+
Search for a trained ONNX model for an analysis type.
73+
Priority: config.yaml ``onnx_weights`` > newest models/<analysis_type>_*/model.onnx
74+
> models/unet_<analysis_type>.onnx
75+
"""
76+
model_cfg = get_model_config(analysis_type)
77+
config_path = model_cfg.get("onnx_weights")
78+
if config_path:
79+
p = _PROJECT_ROOT / config_path
80+
if p.exists():
81+
return p
82+
83+
typed = sorted(
84+
_MODELS_DIR.glob(f"{analysis_type}_*/model.onnx"),
85+
key=lambda p: p.stat().st_mtime,
86+
reverse=True,
87+
)
88+
if typed:
89+
return typed[0]
90+
91+
direct = _MODELS_DIR / f"unet_{analysis_type}.onnx"
92+
return direct if direct.exists() else None
93+
94+
95+
class _ONNXSegmenter:
96+
"""
97+
Drop-in replacement for :class:`UNet` in :func:`run_inference`.
98+
99+
Mimics the minimal torch.nn.Module surface that run_inference relies on
100+
(``n_channels``, ``n_classes``, ``.eval()``, ``.to()``, and being callable
101+
on a ``(N, C, H, W)`` tensor returning a logits tensor) so the rest of the
102+
pipeline needs no changes whether the backing model is PyTorch or ONNX.
103+
"""
104+
105+
def __init__(self, session: Any, n_channels: int, n_classes: int, input_name: str):
106+
self._session = session
107+
self.n_channels = n_channels
108+
self.n_classes = n_classes
109+
self._input_name = input_name
110+
111+
def eval(self) -> "_ONNXSegmenter":
112+
return self
113+
114+
def to(self, _device: Any) -> "_ONNXSegmenter": # device handled by the ORT provider
115+
return self
116+
117+
def __call__(self, tensor: "torch.Tensor") -> "torch.Tensor":
118+
arr = tensor.detach().cpu().numpy().astype(np.float32)
119+
logits = self._session.run(None, {self._input_name: arr})[0]
120+
return torch.from_numpy(np.ascontiguousarray(logits))
121+
122+
123+
def _load_model(analysis_type: str = "deforestation") -> tuple[Any, torch.device]:
71124
"""Load (or return cached) U-Net model configured for the analysis type."""
72125
if analysis_type in _model_cache:
73126
return _model_cache[analysis_type]
@@ -104,6 +157,17 @@ def _load_model(analysis_type: str = "deforestation") -> tuple[UNet, torch.devic
104157
checkpoint.get("val_iou", 0.0),
105158
)
106159
else:
160+
onnx_path = _find_onnx(analysis_type)
161+
onnx_model = _try_load_onnx(onnx_path, n_channels, n_classes) if onnx_path else None
162+
if onnx_model is not None:
163+
logger.info(
164+
"Loaded %s model from ONNX %s (%d channels, %d classes).",
165+
analysis_type, onnx_path, n_channels, n_classes,
166+
)
167+
onnx_model.eval()
168+
_model_cache[analysis_type] = (onnx_model, device)
169+
return onnx_model, device
170+
107171
logger.warning(
108172
"No trained model found for %s under %s — using untrained weights (demo).",
109173
analysis_type,
@@ -117,6 +181,28 @@ def _load_model(analysis_type: str = "deforestation") -> tuple[UNet, torch.devic
117181
return model, device
118182

119183

184+
def _try_load_onnx(
185+
onnx_path: Path, n_channels: int, n_classes: int
186+
) -> Optional["_ONNXSegmenter"]:
187+
"""Build an ONNX inference session, or return None if onnxruntime is unavailable."""
188+
try:
189+
import onnxruntime as ort
190+
except ImportError:
191+
logger.warning(
192+
"Found ONNX model at %s but onnxruntime is not installed; "
193+
"add 'onnxruntime' to requirements to serve it. Falling back.",
194+
onnx_path,
195+
)
196+
return None
197+
198+
providers = ["CPUExecutionProvider"]
199+
if "CUDAExecutionProvider" in ort.get_available_providers():
200+
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
201+
session = ort.InferenceSession(str(onnx_path), providers=providers)
202+
input_name = session.get_inputs()[0].name
203+
return _ONNXSegmenter(session, n_channels, n_classes, input_name)
204+
205+
120206
# ---------------------------------------------------------------------------
121207
# Sentinel-2 normalisation statistics (matches preprocessing.py)
122208
# Band order: [Red, Green, Blue, NIR]

0 commit comments

Comments
 (0)