Image Segmentation
fastai
PyTorch
geospatial
remote-sensing
semantic-segmentation
sentinel-1
alphaearth
flood-mapping
surface-water
Instructions to use rohitm9/surfaceWaterGlobal_flood with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- fastai
How to use rohitm9/surfaceWaterGlobal_flood with fastai:
from huggingface_hub import from_pretrained_fastai learn = from_pretrained_fastai("rohitm9/surfaceWaterGlobal_flood") - Notebooks
- Google Colab
- Kaggle
Download src/train.py from rohitm9/surfaceWaterGlobal_flood: direct link, hf CLI and curl.
- Browser
- Download file 152 kB
-
https://huggingface.co/rohitm9/surfaceWaterGlobal_flood/resolve/main/src/train.py
- Command line
-
hf download hf://rohitm9/surfaceWaterGlobal_flood/src/train.py
-
curl -L -o train.py https://huggingface.co/rohitm9/surfaceWaterGlobal_flood/resolve/main/src/train.py
152 kB
| #!/usr/bin/env python | |
| """Train S1 segmentation models with optional raw-AEF fusion. | |
| This script is the NERSC-ready training entrypoint for the crop-based wetland | |
| segmentation workflow. It keeps the current loss, metrics, and schedule shape | |
| while supporting two comparable input modes: | |
| - S1 only: a 3-band Sentinel-1 baseline for direct DW-target comparisons. | |
| - S1 + raw AEF: a 67-band tensor where a learned 1x1 convolution projects the | |
| 64 raw AlphaEarth bands before fusion with S1 inside the model. | |
| - Training reads fixed 512x512 random windows directly from the source rasters. | |
| - Epoch validation uses deterministic 512x512 windows, while final full-scene | |
| metrics stream overlapping windows and blend them without loading all 67 | |
| input bands for a scene at once. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import copy as copy_module | |
| import csv | |
| import gc | |
| import importlib | |
| import json | |
| import math | |
| import random | |
| import shutil | |
| import time | |
| from collections import Counter | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Optional, Sequence, Tuple | |
| import numpy as np | |
| import pandas as pd | |
| import rasterio as rio | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.utils.data import Dataset | |
| from torchvision.transforms import InterpolationMode | |
| from torchvision.transforms import functional as TVF | |
| from fastai.imports import * | |
| from fastai.torch_imports import * | |
| from fastai.vision.all import * | |
| DEFAULT_INPUT_DIR = Path("/pscratch/sd/r/rohit9/S1ML/training_mosaics/s1_multiband") | |
| DEFAULT_AEF_DIR = Path("/pscratch/sd/r/rohit9/S1ML/training_embeddings/alphaearth_v1_annual_int8") | |
| DEFAULT_LABEL_DIR = Path("/pscratch/sd/r/rohit9/S1ML/training_mosaics/dw_binary") | |
| DEFAULT_ARTIFACT_DIR = Path( | |
| "/pscratch/sd/r/rohit9/S1ML/training_runs/s1aef_bottleneck_resnet34_crop512" | |
| ) | |
| DEFAULT_HF_CACHE_DIR = Path("/pscratch/sd/r/rohit9/UFO/model_cache/huggingface") | |
| SEGFORMER_MODEL_MAP = { | |
| "segformer_b0": "nvidia/segformer-b0-finetuned-ade-512-512", | |
| "segformer_b1": "nvidia/segformer-b1-finetuned-ade-512-512", | |
| "segformer_b2": "nvidia/segformer-b2-finetuned-ade-512-512", | |
| "segformer_b3": "nvidia/segformer-b3-finetuned-ade-512-512", | |
| "segformer_b4": "nvidia/segformer-b4-finetuned-ade-512-512", | |
| "segformer_b5": "nvidia/segformer-b5-finetuned-cityscapes-1024-1024", | |
| } | |
| class TripletRecord: | |
| name: str | |
| s1_path: Path | |
| aef_path: Optional[Path] | |
| label_path: Path | |
| sample_id: str = "" | |
| group_id: str = "" | |
| source_id: str = "" | |
| prior_path: Optional[Path] = None | |
| IGNORE_INDEX = 255 | |
| def valid_target_mask(targ: torch.Tensor) -> torch.Tensor: | |
| """True where Dynamic World observed the pixel (label is 0 or 1).""" | |
| return targ != IGNORE_INDEX | |
| def sanitized_target(targ: torch.Tensor, valid: torch.Tensor) -> torch.Tensor: | |
| """Replace ignored labels with 0 so one-hot/indexing stays in range.""" | |
| return torch.where(valid, targ, torch.zeros_like(targ)) | |
| def masked_mean(per_pixel: torch.Tensor, valid: torch.Tensor) -> torch.Tensor: | |
| weight = valid.float() | |
| return (per_pixel * weight).sum() / weight.sum().clamp(min=1.0) | |
| def masked_cross_entropy( | |
| pred: torch.Tensor, | |
| targ: torch.Tensor, | |
| valid: torch.Tensor, | |
| gamma: Optional[float] = None, | |
| ) -> torch.Tensor: | |
| """Cross-entropy (optionally focal) averaged over observed pixels only. | |
| With no ignored pixels this equals the fastai CrossEntropyLossFlat / | |
| FocalLossFlat it replaces, so runs on older labels are unaffected. | |
| """ | |
| target = sanitized_target(targ.long(), valid) | |
| per_pixel = F.cross_entropy(pred.float(), target, reduction="none") | |
| if gamma is not None: | |
| p_t = torch.exp(-per_pixel) | |
| per_pixel = torch.pow(1.0 - p_t, gamma) * per_pixel | |
| return masked_mean(per_pixel, valid) | |
| def masked_dice_loss( | |
| pred: torch.Tensor, | |
| targ: torch.Tensor, | |
| valid: torch.Tensor, | |
| axis: int = 1, | |
| smooth: float = 1.0, | |
| reduction: str = "sum", | |
| ) -> torch.Tensor: | |
| """Dice over observed pixels only. | |
| fastai's DiceLoss one-hots the target, so a raw 255 raises IndexError, and it | |
| has no ignore support. Zeroing logits does not work either: softmax(0, 0) is | |
| 0.5, so ignored pixels would still contribute. Zero the probabilities and the | |
| one-hot target instead - ignored pixels then add nothing to either term. The | |
| reduction matches fastai's default ("sum" over batch x class), so with no | |
| ignored pixels this is numerically identical to the loss it replaces. | |
| """ | |
| probability = F.softmax(pred.float(), dim=axis) | |
| n_classes = probability.shape[axis] | |
| target = sanitized_target(targ.long(), valid) | |
| one_hot = F.one_hot(target, n_classes).permute(0, 3, 1, 2).float() | |
| weight = valid.unsqueeze(1).float() | |
| probability = probability * weight | |
| one_hot = one_hot * weight | |
| sum_dims = list(range(2, probability.ndim)) | |
| intersection = torch.sum(probability * one_hot, dim=sum_dims) | |
| union = torch.sum(probability + one_hot, dim=sum_dims) | |
| dice_score = (2.0 * intersection + smooth) / (union + smooth) | |
| loss = 1.0 - dice_score | |
| return loss.sum() if reduction == "sum" else loss.mean() | |
| def binarize_label_array(data) -> "np.ndarray": | |
| """0 = dry, 1 = water, IGNORE_INDEX = not observed. | |
| Non-finite values are treated as unobserved rather than dry, so a gap never | |
| becomes a training negative. | |
| """ | |
| values = np.asarray(data) | |
| mask = np.full(values.shape, IGNORE_INDEX, dtype=np.int64) | |
| observed = values != IGNORE_INDEX | |
| if np.issubdtype(values.dtype, np.floating): | |
| observed &= np.isfinite(values) | |
| mask[observed] = (values[observed] > 0.5).astype(np.int64) | |
| return mask | |
| class CombinedLoss(nn.Module): | |
| """Focal + Dice, carried over from the working baseline.""" | |
| def __init__(self, axis=1, smooth=1.0, alpha=1.0, gamma=2.0): | |
| super().__init__() | |
| self.axis = axis | |
| self.alpha = alpha | |
| self.smooth = smooth | |
| self.gamma = gamma | |
| def forward(self, pred, targ): | |
| pred = pred.float() | |
| targ = targ.long() | |
| valid = valid_target_mask(targ) | |
| focal = masked_cross_entropy(pred, targ, valid, gamma=self.gamma) | |
| dice = masked_dice_loss(pred, targ, valid, axis=self.axis, smooth=self.smooth) | |
| return focal + (self.alpha * dice) | |
| def decodes(self, x): | |
| return x.argmax(dim=self.axis) | |
| def activation(self, x): | |
| return F.softmax(x, dim=self.axis) | |
| def _lovasz_grad(gt_sorted: torch.Tensor) -> torch.Tensor: | |
| p = len(gt_sorted) | |
| gts = gt_sorted.sum() | |
| intersection = gts - gt_sorted.float().cumsum(0) | |
| union = gts + (1 - gt_sorted).float().cumsum(0) | |
| jaccard = 1.0 - intersection / union | |
| if p > 1: | |
| jaccard[1:p] = jaccard[1:p] - jaccard[0:-1] | |
| return jaccard | |
| def _lovasz_softmax_flat( | |
| probas: torch.Tensor, labels: torch.Tensor, classes: str = "present" | |
| ) -> torch.Tensor: | |
| if probas.numel() == 0: | |
| return probas * 0.0 | |
| num_classes = probas.size(1) | |
| losses = [] | |
| class_to_sum = list(range(num_classes)) if classes in ("all", "present") else classes | |
| for class_idx in class_to_sum: | |
| foreground = (labels == class_idx).float() | |
| if classes == "present" and foreground.sum() == 0: | |
| continue | |
| errors = (foreground - probas[:, class_idx]).abs() | |
| errors_sorted, permutation = torch.sort(errors, 0, descending=True) | |
| foreground_sorted = foreground[permutation.data] | |
| grad = _lovasz_grad(foreground_sorted) | |
| losses.append((errors_sorted * grad).sum()) | |
| if not losses: | |
| return torch.tensor(0.0, device=probas.device, requires_grad=True) | |
| return torch.stack(losses).mean() | |
| class LovaszSoftmaxLoss(nn.Module): | |
| """Lovasz-Softmax loss for IoU-oriented segmentation tuning.""" | |
| def __init__(self, axis: int = 1): | |
| super().__init__() | |
| self.axis = axis | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| probas = F.softmax(pred.float(), dim=self.axis) | |
| _, num_classes, _, _ = probas.shape | |
| probas_flat = probas.permute(0, 2, 3, 1).reshape(-1, num_classes) | |
| targ_flat = targ.long().reshape(-1) | |
| keep = targ_flat != IGNORE_INDEX | |
| probas_flat = probas_flat[keep] | |
| targ_flat = targ_flat[keep] | |
| return _lovasz_softmax_flat(probas_flat, targ_flat, classes="present") | |
| def decodes(self, x: torch.Tensor) -> torch.Tensor: | |
| return x.argmax(dim=self.axis) | |
| def activation(self, x: torch.Tensor) -> torch.Tensor: | |
| return F.softmax(x, dim=self.axis) | |
| class CrossEntropyDiceLoss(nn.Module): | |
| """Cross-entropy + Dice baseline for a less recall-heavy alternative.""" | |
| def __init__(self, axis: int = 1, smooth: float = 1.0, alpha: float = 1.0): | |
| super().__init__() | |
| self.axis = axis | |
| self.alpha = alpha | |
| self.smooth = smooth | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| pred = pred.float() | |
| targ = targ.long() | |
| valid = valid_target_mask(targ) | |
| cross_entropy = masked_cross_entropy(pred, targ, valid) | |
| dice = masked_dice_loss(pred, targ, valid, axis=self.axis, smooth=self.smooth) | |
| return cross_entropy + (self.alpha * dice) | |
| def decodes(self, x: torch.Tensor) -> torch.Tensor: | |
| return x.argmax(dim=self.axis) | |
| def activation(self, x: torch.Tensor) -> torch.Tensor: | |
| return F.softmax(x, dim=self.axis) | |
| class TwoClassLossBase(nn.Module): | |
| """Shared FastAI-compatible helpers for experimental water losses.""" | |
| def __init__(self, axis: int = 1): | |
| super().__init__() | |
| self.axis = axis | |
| def decodes(self, x: torch.Tensor) -> torch.Tensor: | |
| return x.argmax(dim=self.axis) | |
| def activation(self, x: torch.Tensor) -> torch.Tensor: | |
| return F.softmax(x, dim=self.axis) | |
| def water_probability_and_target( | |
| self, pred: torch.Tensor, targ: torch.Tensor | |
| ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: | |
| """Water probability, water target and the observed-pixel mask. | |
| Probability and target are zeroed where the label is ignored, so any | |
| sum over them skips unobserved pixels. | |
| """ | |
| valid = valid_target_mask(targ.long()) | |
| weight = valid.float() | |
| probability = F.softmax(pred.float(), dim=self.axis)[:, 1] * weight | |
| target = (targ.long() == 1).float() * weight | |
| return probability, target, valid | |
| class CrossEntropySoftIoULoss(TwoClassLossBase): | |
| """Cross-entropy plus a direct soft Jaccard/IoU objective.""" | |
| def __init__(self, axis: int = 1, smooth: float = 1.0, aux_weight: float = 1.0): | |
| super().__init__(axis=axis) | |
| self.smooth = smooth | |
| self.aux_weight = aux_weight | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| probability, target, valid = self.water_probability_and_target(pred, targ) | |
| dims = tuple(range(1, probability.ndim)) | |
| intersection = (probability * target).sum(dim=dims) | |
| union = (probability + target - probability * target).sum(dim=dims) | |
| soft_iou_loss = 1.0 - ((intersection + self.smooth) / (union + self.smooth)).mean() | |
| return masked_cross_entropy(pred, targ, valid) + self.aux_weight * soft_iou_loss | |
| class FocalTverskyCELoss(TwoClassLossBase): | |
| """CE plus focal Tversky, exposing FP/FN asymmetry for water mapping.""" | |
| def __init__( | |
| self, | |
| axis: int = 1, | |
| alpha: float = 0.3, | |
| beta: float = 0.7, | |
| gamma: float = 1.33, | |
| smooth: float = 1.0, | |
| aux_weight: float = 1.0, | |
| ): | |
| super().__init__(axis=axis) | |
| self.alpha = alpha | |
| self.beta = beta | |
| self.gamma = gamma | |
| self.smooth = smooth | |
| self.aux_weight = aux_weight | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| probability, target, valid = self.water_probability_and_target(pred, targ) | |
| dims = tuple(range(1, probability.ndim)) | |
| true_positive = (probability * target).sum(dim=dims) | |
| false_positive = (probability * (1.0 - target)).sum(dim=dims) | |
| false_negative = ((1.0 - probability) * target).sum(dim=dims) | |
| tversky = (true_positive + self.smooth) / ( | |
| true_positive | |
| + self.alpha * false_positive | |
| + self.beta * false_negative | |
| + self.smooth | |
| ) | |
| focal_tversky = torch.pow(1.0 - tversky, self.gamma).mean() | |
| return masked_cross_entropy(pred, targ, valid) + self.aux_weight * focal_tversky | |
| class BoundaryAwareCEDiceLoss(TwoClassLossBase): | |
| """CE+Dice with an auxiliary soft edge-Dice term for shorelines.""" | |
| def __init__(self, axis: int = 1, smooth: float = 1.0, aux_weight: float = 0.2): | |
| super().__init__(axis=axis) | |
| self.smooth = smooth | |
| self.aux_weight = aux_weight | |
| self.dice_loss = DiceLoss(axis, smooth) | |
| self.register_buffer( | |
| "sobel_x", | |
| torch.tensor( | |
| [[-1.0, 0.0, 1.0], [-2.0, 0.0, 2.0], [-1.0, 0.0, 1.0]] | |
| ).view(1, 1, 3, 3), | |
| ) | |
| self.register_buffer( | |
| "sobel_y", | |
| torch.tensor( | |
| [[-1.0, -2.0, -1.0], [0.0, 0.0, 0.0], [1.0, 2.0, 1.0]] | |
| ).view(1, 1, 3, 3), | |
| ) | |
| def edge_strength(self, image: torch.Tensor) -> torch.Tensor: | |
| image = image.unsqueeze(1) | |
| grad_x = F.conv2d(image, self.sobel_x.to(image), padding=1) | |
| grad_y = F.conv2d(image, self.sobel_y.to(image), padding=1) | |
| return torch.sqrt(grad_x.square() + grad_y.square() + 1e-6).clamp(0.0, 1.0) | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| probability, target, valid = self.water_probability_and_target(pred, targ) | |
| edge_weight = valid.float().unsqueeze(1) | |
| pred_edge = self.edge_strength(probability) * edge_weight | |
| target_edge = self.edge_strength(target) * edge_weight | |
| dims = tuple(range(1, pred_edge.ndim)) | |
| intersection = (pred_edge * target_edge).sum(dim=dims) | |
| denominator = pred_edge.sum(dim=dims) + target_edge.sum(dim=dims) | |
| boundary_loss = 1.0 - ((2.0 * intersection + self.smooth) / (denominator + self.smooth)).mean() | |
| base = masked_cross_entropy(pred, targ, valid) + masked_dice_loss( | |
| pred, targ, valid, axis=self.axis, smooth=self.smooth | |
| ) | |
| return base + self.aux_weight * boundary_loss | |
| def _soft_erode(image: torch.Tensor) -> torch.Tensor: | |
| vertical = -F.max_pool2d(-image, kernel_size=(3, 1), stride=1, padding=(1, 0)) | |
| horizontal = -F.max_pool2d(-image, kernel_size=(1, 3), stride=1, padding=(0, 1)) | |
| return torch.minimum(vertical, horizontal) | |
| def _soft_dilate(image: torch.Tensor) -> torch.Tensor: | |
| return F.max_pool2d(image, kernel_size=3, stride=1, padding=1) | |
| def _soft_skeletonize(image: torch.Tensor, iterations: int) -> torch.Tensor: | |
| opened = _soft_dilate(_soft_erode(image)) | |
| skeleton = F.relu(image - opened) | |
| for _ in range(iterations): | |
| image = _soft_erode(image) | |
| opened = _soft_dilate(_soft_erode(image)) | |
| delta = F.relu(image - opened) | |
| skeleton = skeleton + F.relu(delta - skeleton * delta) | |
| return skeleton | |
| class TopologyAwareCEDiceLoss(TwoClassLossBase): | |
| """CE+Dice+soft-clDice for connected thin water networks.""" | |
| def __init__( | |
| self, | |
| axis: int = 1, | |
| smooth: float = 1.0, | |
| aux_weight: float = 0.2, | |
| skeleton_iterations: int = 10, | |
| ): | |
| super().__init__(axis=axis) | |
| self.smooth = smooth | |
| self.aux_weight = aux_weight | |
| self.skeleton_iterations = skeleton_iterations | |
| self.dice_loss = DiceLoss(axis, smooth) | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| probability, target, valid = self.water_probability_and_target(pred, targ) | |
| probability = probability.unsqueeze(1) | |
| target = target.unsqueeze(1) | |
| pred_skeleton = _soft_skeletonize(probability, self.skeleton_iterations) | |
| target_skeleton = _soft_skeletonize(target, self.skeleton_iterations) | |
| dims = tuple(range(1, probability.ndim)) | |
| topology_precision = ( | |
| (pred_skeleton * target).sum(dim=dims) + self.smooth | |
| ) / (pred_skeleton.sum(dim=dims) + self.smooth) | |
| topology_sensitivity = ( | |
| (target_skeleton * probability).sum(dim=dims) + self.smooth | |
| ) / (target_skeleton.sum(dim=dims) + self.smooth) | |
| cldice = ( | |
| 2.0 * topology_precision * topology_sensitivity | |
| / (topology_precision + topology_sensitivity + 1e-6) | |
| ).mean() | |
| base = masked_cross_entropy(pred, targ, valid) + masked_dice_loss( | |
| pred, targ, valid, axis=self.axis, smooth=self.smooth | |
| ) | |
| return base + self.aux_weight * (1.0 - cldice) | |
| class SymmetricCrossEntropyDiceLoss(TwoClassLossBase): | |
| """Noise-robust symmetric cross-entropy plus Dice for weak DW labels.""" | |
| def __init__(self, axis: int = 1, smooth: float = 1.0, aux_weight: float = 0.1): | |
| super().__init__(axis=axis) | |
| self.aux_weight = aux_weight | |
| self.smooth = smooth | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| logits = pred.float() | |
| labels = targ.long() | |
| valid = valid_target_mask(labels) | |
| safe_labels = sanitized_target(labels, valid) | |
| probabilities = F.softmax(logits, dim=self.axis).clamp(1e-7, 1.0) | |
| one_hot = F.one_hot(safe_labels, num_classes=logits.shape[1]).permute(0, 3, 1, 2).float() | |
| reverse_ce_map = -(probabilities * torch.log(one_hot.clamp(1e-4, 1.0))).sum(dim=1) | |
| reverse_ce = masked_mean(reverse_ce_map, valid) | |
| return ( | |
| masked_cross_entropy(logits, labels, valid) | |
| + self.aux_weight * reverse_ce | |
| + masked_dice_loss(logits, labels, valid, axis=self.axis, smooth=self.smooth) | |
| ) | |
| class MaskedDice(Metric): | |
| """fastai Dice restricted to observed pixels. | |
| The stock fastai Dice/JaccardCoeff multiply and add raw target values, so a | |
| label of 255 corrupts both the intersection and the union. | |
| """ | |
| def __init__(self, axis: int = 1): | |
| self.axis = axis | |
| self.reset() | |
| def reset(self): | |
| self.inter = 0.0 | |
| self.union = 0.0 | |
| def accumulate(self, learn): | |
| pred = learn.pred.argmax(dim=self.axis) | |
| targ = learn.y | |
| valid = targ != IGNORE_INDEX | |
| water_pred = (pred == 1) & valid | |
| water_targ = (targ == 1) & valid | |
| self.inter += (water_pred & water_targ).float().sum().item() | |
| self.union += water_pred.float().sum().item() + water_targ.float().sum().item() | |
| def value(self): | |
| return 2.0 * self.inter / self.union if self.union > 0 else None | |
| def name(self): | |
| return "dice" | |
| class MaskedJaccardCoeff(MaskedDice): | |
| """fastai JaccardCoeff restricted to observed pixels.""" | |
| def value(self): | |
| denominator = self.union - self.inter | |
| return self.inter / denominator if denominator > 0 else None | |
| def name(self): | |
| return "jaccard_coeff" | |
| class WaterIoU(Metric): | |
| """Per-class IoU for the water class (index 1).""" | |
| def __init__(self): | |
| self.inter = 0 | |
| self.union = 0 | |
| def reset(self): | |
| self.inter = 0 | |
| self.union = 0 | |
| def accumulate(self, learn): | |
| pred = learn.pred.argmax(dim=1) | |
| targ = learn.y | |
| valid = targ != IGNORE_INDEX | |
| water_pred = (pred == 1) & valid | |
| water_targ = (targ == 1) & valid | |
| self.inter += (water_pred & water_targ).float().sum().item() | |
| self.union += (water_pred | water_targ).float().sum().item() | |
| def value(self): | |
| return self.inter / self.union if self.union > 0 else 0.0 | |
| def name(self): | |
| return "water_iou" | |
| class SourceBalancedWaterIoU(Metric): | |
| """Mean water IoU across validation sources, giving each source equal weight.""" | |
| def __init__(self, source_ids: Sequence[str]): | |
| self.source_ids = list(source_ids) | |
| self.reset() | |
| def reset(self): | |
| self.cursor = 0 | |
| self.intersections: Dict[str, float] = {} | |
| self.unions: Dict[str, float] = {} | |
| def accumulate(self, learn): | |
| pred = learn.pred.argmax(dim=1) | |
| targ = learn.y | |
| batch_size = int(pred.shape[0]) | |
| if self.cursor + batch_size > len(self.source_ids): | |
| raise RuntimeError( | |
| "Validation source tracking exceeded the configured source sequence." | |
| ) | |
| for batch_idx in range(batch_size): | |
| source_id = self.source_ids[self.cursor + batch_idx] or "unspecified" | |
| valid = targ[batch_idx] != IGNORE_INDEX | |
| water_pred = (pred[batch_idx] == 1) & valid | |
| water_targ = (targ[batch_idx] == 1) & valid | |
| self.intersections[source_id] = self.intersections.get(source_id, 0.0) + ( | |
| water_pred & water_targ | |
| ).float().sum().item() | |
| self.unions[source_id] = self.unions.get(source_id, 0.0) + ( | |
| water_pred | water_targ | |
| ).float().sum().item() | |
| self.cursor += batch_size | |
| def value(self): | |
| source_ious = [ | |
| self.intersections.get(source_id, 0.0) / union | |
| for source_id, union in self.unions.items() | |
| if union > 0 | |
| ] | |
| return float(np.mean(source_ious)) if source_ious else 0.0 | |
| def name(self): | |
| return "source_balanced_water_iou" | |
| class TargetSourceWaterIoU(Metric): | |
| """Pooled water IoU for one validation source used as the checkpoint objective.""" | |
| def __init__(self, source_ids: Sequence[str], target_source_id: str): | |
| self.source_ids = list(source_ids) | |
| self.target_source_id = target_source_id | |
| self.reset() | |
| def reset(self): | |
| self.cursor = 0 | |
| self.intersection = 0.0 | |
| self.union = 0.0 | |
| def accumulate(self, learn): | |
| pred = learn.pred.argmax(dim=1) | |
| targ = learn.y | |
| batch_size = int(pred.shape[0]) | |
| if self.cursor + batch_size > len(self.source_ids): | |
| raise RuntimeError( | |
| "Validation source tracking exceeded the configured source sequence." | |
| ) | |
| for batch_idx in range(batch_size): | |
| source_id = self.source_ids[self.cursor + batch_idx] or "unspecified" | |
| if source_id != self.target_source_id: | |
| continue | |
| valid = targ[batch_idx] != IGNORE_INDEX | |
| water_pred = (pred[batch_idx] == 1) & valid | |
| water_targ = (targ[batch_idx] == 1) & valid | |
| self.intersection += (water_pred & water_targ).float().sum().item() | |
| self.union += (water_pred | water_targ).float().sum().item() | |
| self.cursor += batch_size | |
| def value(self): | |
| return self.intersection / self.union if self.union > 0 else 0.0 | |
| def name(self): | |
| return "target_source_water_iou" | |
| class NonFiniteLossCallback(Callback): | |
| """Stop loudly on NaN/Inf instead of silently cancelling the fit.""" | |
| order = TerminateOnNaNCallback.order | |
| def after_batch(self): | |
| if not self.training or self.loss is None: | |
| return | |
| loss = self.loss.detach().float() | |
| if torch.isfinite(loss).all(): | |
| return | |
| lr = None | |
| if getattr(self.learn, "opt", None) is not None and len(self.opt.hypers) > 0: | |
| lr = self.opt.hypers[-1].get("lr") | |
| debug_summary = { | |
| "phase": getattr(self.learn, "training_phase", "unknown"), | |
| "epoch": int(getattr(self.learn, "epoch", -1)), | |
| "iter": int(getattr(self.learn, "iter", -1)), | |
| "loss": float(loss.item()), | |
| "lr": None if lr is None else float(lr), | |
| } | |
| print({"non_finite_loss": debug_summary}) | |
| raise RuntimeError("Non-finite loss detected during {phase}.".format(**debug_summary)) | |
| class EpochTimerCallback(Callback): | |
| """Track wall-clock epoch durations for benchmark summaries.""" | |
| def before_fit(self): | |
| self.epoch_times = [] | |
| self._epoch_start = None | |
| def before_epoch(self): | |
| self._epoch_start = time.perf_counter() | |
| def after_epoch(self): | |
| if self._epoch_start is None: | |
| return | |
| self.epoch_times.append(time.perf_counter() - self._epoch_start) | |
| self._epoch_start = None | |
| def load_segformer_components(): | |
| try: | |
| transformers = importlib.import_module("transformers") | |
| except ModuleNotFoundError as error: | |
| raise ModuleNotFoundError( | |
| "SegFormer support requires the 'transformers' package. " | |
| "Use a SegFormer-capable environment or keep --model_family=resnet34_unet." | |
| ) from error | |
| return transformers.SegformerConfig, transformers.SegformerForSemanticSegmentation | |
| class AEFBottleneckUNet(nn.Module): | |
| """Wrap a U-Net with a learned raw-AEF bottleneck projection.""" | |
| def __init__( | |
| self, | |
| unet_model: nn.Module, | |
| n_s1: int = 3, | |
| n_aef: int = 64, | |
| n_proj: int = 4, | |
| n_prior: int = 0, | |
| prior_dropout_p: float = 0.0, | |
| aef_dropout_p: float = 0.0, | |
| ): | |
| super().__init__() | |
| self.aef_dropout_p = aef_dropout_p | |
| self.n_s1 = n_s1 | |
| self.n_aef = n_aef | |
| self.n_proj = n_proj | |
| self.n_prior = n_prior | |
| self.prior_dropout_p = prior_dropout_p | |
| self.aef_proj = nn.Conv2d(n_aef, n_proj, kernel_size=1, bias=False) | |
| nn.init.xavier_uniform_(self.aef_proj.weight, gain=0.1) | |
| self.unet = unet_model | |
| def forward(self, x): | |
| s1 = x[:, : self.n_s1] | |
| aef = x[:, self.n_s1 : self.n_s1 + self.n_aef] | |
| aef_projected = self.aef_proj(aef) | |
| if self.training and self.aef_dropout_p > 0: | |
| # factorial_v1: per-sample AEF dropout (whole embedding zeroed, no rescaling), training only | |
| keep = ( | |
| torch.rand((aef_projected.shape[0], 1, 1, 1), device=aef_projected.device) | |
| >= self.aef_dropout_p | |
| ).to(aef_projected.dtype) | |
| aef_projected = aef_projected * keep | |
| components = [s1, aef_projected] | |
| if self.n_prior: | |
| prior = x[ | |
| :, | |
| self.n_s1 + self.n_aef : self.n_s1 + self.n_aef + self.n_prior, | |
| ] | |
| if self.training and self.prior_dropout_p > 0: | |
| keep = ( | |
| torch.rand((prior.shape[0], 1, 1, 1), device=prior.device) | |
| >= self.prior_dropout_p | |
| ).to(prior.dtype) | |
| prior = prior * keep | |
| components.append(prior) | |
| fused = torch.cat(components, dim=1) | |
| return self.unet(fused) | |
| class AEFBottleneckSegFormer(nn.Module): | |
| """Wrap a SegFormer with the same learned AEF projection used by the U-Net.""" | |
| def __init__( | |
| self, | |
| model_name: str, | |
| num_labels: int, | |
| n_s1: int = 3, | |
| n_aef: int = 64, | |
| n_proj: int = 4, | |
| hf_cache_dir: Optional[Path] = None, | |
| pretrained: bool = True, | |
| local_files_only: bool = False, | |
| ): | |
| super().__init__() | |
| self.n_s1 = n_s1 | |
| self.n_aef = n_aef | |
| self.n_proj = n_proj | |
| self.aef_proj = nn.Conv2d(n_aef, n_proj, kernel_size=1, bias=False) | |
| nn.init.xavier_uniform_(self.aef_proj.weight, gain=0.1) | |
| SegformerConfig, SegformerForSemanticSegmentation = load_segformer_components() | |
| cache_dir = str(hf_cache_dir) if hf_cache_dir is not None else None | |
| if pretrained: | |
| config = SegformerConfig.from_pretrained( | |
| model_name, | |
| cache_dir=cache_dir, | |
| local_files_only=local_files_only, | |
| ) | |
| config.num_labels = num_labels | |
| self.segformer = SegformerForSemanticSegmentation.from_pretrained( | |
| model_name, | |
| config=config, | |
| ignore_mismatched_sizes=True, | |
| cache_dir=cache_dir, | |
| local_files_only=local_files_only, | |
| ) | |
| else: | |
| config = SegformerConfig.from_pretrained( | |
| model_name, | |
| cache_dir=cache_dir, | |
| local_files_only=local_files_only, | |
| ) | |
| config.num_labels = num_labels | |
| self.segformer = SegformerForSemanticSegmentation(config) | |
| self._adapt_patch_embedding(in_channels=n_s1 + n_proj) | |
| def _adapt_patch_embedding(self, in_channels: int) -> None: | |
| old_proj = self.segformer.segformer.encoder.patch_embeddings[0].proj | |
| new_proj = nn.Conv2d( | |
| in_channels, | |
| old_proj.out_channels, | |
| kernel_size=old_proj.kernel_size, | |
| stride=old_proj.stride, | |
| padding=old_proj.padding, | |
| bias=old_proj.bias is not None, | |
| ) | |
| with torch.no_grad(): | |
| new_proj.weight.zero_() | |
| copied_channels = min(old_proj.in_channels, in_channels) | |
| if copied_channels > 0: | |
| new_proj.weight[:, :copied_channels] = old_proj.weight[:, :copied_channels] | |
| if in_channels > copied_channels: | |
| mean_weight = old_proj.weight.mean(dim=1, keepdim=True) | |
| new_proj.weight[:, copied_channels:] = mean_weight | |
| if old_proj.bias is not None and new_proj.bias is not None: | |
| new_proj.bias.copy_(old_proj.bias) | |
| self.segformer.segformer.encoder.patch_embeddings[0].proj = new_proj | |
| def forward(self, x: torch.Tensor) -> torch.Tensor: | |
| s1 = x[:, : self.n_s1] | |
| aef = x[:, self.n_s1 : self.n_s1 + self.n_aef] | |
| aef_projected = self.aef_proj(aef) | |
| fused = torch.cat([s1, aef_projected], dim=1) | |
| logits = self.segformer(pixel_values=fused).logits | |
| if logits.shape[-2:] != fused.shape[-2:]: | |
| logits = F.interpolate( | |
| logits, | |
| size=fused.shape[-2:], | |
| mode="bilinear", | |
| align_corners=False, | |
| ) | |
| return logits | |
| class S1AEFCropDataset(Dataset): | |
| """Training dataset that samples aligned random crops from triplet tiles.""" | |
| def __init__( | |
| self, | |
| records: Sequence[TripletRecord], | |
| crop_size: int, | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| n_s1_bands: int, | |
| rotate_max_deg: float, | |
| rotate_p: float, | |
| scale_jitter_min: float, | |
| scale_jitter_max: float, | |
| s1_gain_jitter: float, | |
| s1_bias_jitter: float, | |
| gaussian_noise_std: float, | |
| gaussian_noise_p: float, | |
| positive_crop_p: float, | |
| min_water_fraction: float, | |
| positive_crop_attempts: int, | |
| ): | |
| self.records = list(records) | |
| self.crop_size = crop_size | |
| self.band_mean = torch.from_numpy(band_mean.astype(np.float32)).view(-1, 1, 1) | |
| self.band_std = torch.from_numpy(band_std.astype(np.float32)).view(-1, 1, 1) | |
| self.n_s1_bands = n_s1_bands | |
| self.rotate_max_deg = rotate_max_deg | |
| self.rotate_p = rotate_p | |
| self.scale_jitter_min = scale_jitter_min | |
| self.scale_jitter_max = scale_jitter_max | |
| self.s1_gain_jitter = s1_gain_jitter | |
| self.s1_bias_jitter = s1_bias_jitter | |
| self.gaussian_noise_std = gaussian_noise_std | |
| self.gaussian_noise_p = gaussian_noise_p | |
| self.positive_crop_p = positive_crop_p | |
| self.min_water_fraction = min_water_fraction | |
| self.positive_crop_attempts = positive_crop_attempts | |
| def __len__(self): | |
| return len(self.records) | |
| def __getitem__(self, idx): | |
| image, mask = load_random_triplet_window( | |
| self.records[idx], | |
| self.crop_size, | |
| seek_positive=random.random() < self.positive_crop_p, | |
| min_water_fraction=self.min_water_fraction, | |
| attempts=self.positive_crop_attempts, | |
| ) | |
| image, mask = apply_train_augmentations( | |
| image=image, | |
| mask=mask, | |
| crop_size=self.crop_size, | |
| n_s1_bands=self.n_s1_bands, | |
| rotate_max_deg=self.rotate_max_deg, | |
| rotate_p=self.rotate_p, | |
| scale_jitter_min=self.scale_jitter_min, | |
| scale_jitter_max=self.scale_jitter_max, | |
| s1_gain_jitter=self.s1_gain_jitter, | |
| s1_bias_jitter=self.s1_bias_jitter, | |
| gaussian_noise_std=self.gaussian_noise_std, | |
| gaussian_noise_p=self.gaussian_noise_p, | |
| normalized_band_count=int(self.band_mean.shape[0]), | |
| ) | |
| image = normalize_image(image, self.band_mean, self.band_std) | |
| return image, mask.long() | |
| class S1AEFValidationPatchDataset(Dataset): | |
| """Stable validation patches used by fastai during each training epoch.""" | |
| def __init__( | |
| self, | |
| records: Sequence[TripletRecord], | |
| crop_size: int, | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| ): | |
| self.records = list(records) | |
| self.crop_size = crop_size | |
| self.band_mean = torch.from_numpy(band_mean.astype(np.float32)).view(-1, 1, 1) | |
| self.band_std = torch.from_numpy(band_std.astype(np.float32)).view(-1, 1, 1) | |
| def __len__(self): | |
| return len(self.records) | |
| def __getitem__(self, idx): | |
| image, mask = load_center_triplet_window(self.records[idx], self.crop_size) | |
| image = normalize_image(image, self.band_mean, self.band_std) | |
| return image, mask.long() | |
| def read_tile_index(path: Path, records: Sequence[TripletRecord], split: str) -> List[Tuple[TripletRecord, int, int]]: | |
| """full_coverage_v1: tiles (record, top, left) of the given split with at least one observed label pixel.""" | |
| table = pd.read_csv(path) | |
| table = table[(table["split"] == split) & (table["observed_frac"] > 0)] | |
| by_name = {record.name: record for record in records} | |
| table = table[table["tile_name"].isin(by_name)] | |
| missing = sorted(set(by_name) - set(table["tile_name"])) | |
| if missing: | |
| raise ValueError("{n} {split} records have no tile in {path}, e.g. {ex}".format(n=len(missing), split=split, path=path, ex=missing[:3])) | |
| return [(by_name[name], int(top), int(left)) for name, top, left in zip(table["tile_name"], table["top"], table["left"])] | |
| class S1AEFTileDataset(Dataset): | |
| """full_coverage_v1 training set: every tile of every training chip once per epoch (no random crop).""" | |
| def __init__(self, tiles, crop_size, band_mean, band_std, n_s1_bands, rotate_max_deg, rotate_p, scale_jitter_min, | |
| scale_jitter_max, s1_gain_jitter, s1_bias_jitter, gaussian_noise_std, gaussian_noise_p): | |
| self.tiles = list(tiles) | |
| self.records = sorted({tile[0].name: tile[0] for tile in self.tiles}.values(), key=lambda r: r.name) | |
| self.crop_size = crop_size | |
| self.band_mean = torch.from_numpy(band_mean.astype(np.float32)).view(-1, 1, 1) | |
| self.band_std = torch.from_numpy(band_std.astype(np.float32)).view(-1, 1, 1) | |
| self.aug = dict(n_s1_bands=n_s1_bands, rotate_max_deg=rotate_max_deg, rotate_p=rotate_p, scale_jitter_min=scale_jitter_min, | |
| scale_jitter_max=scale_jitter_max, s1_gain_jitter=s1_gain_jitter, s1_bias_jitter=s1_bias_jitter, | |
| gaussian_noise_std=gaussian_noise_std, gaussian_noise_p=gaussian_noise_p) | |
| def __len__(self): | |
| return len(self.tiles) | |
| def __getitem__(self, idx): | |
| record, top, left = self.tiles[idx] | |
| image, mask = load_triplet_window(record, top=top, left=left, height=self.crop_size, width=self.crop_size) | |
| image, mask = apply_train_augmentations(image=image, mask=mask, crop_size=self.crop_size, | |
| normalized_band_count=int(self.band_mean.shape[0]), **self.aug) | |
| image = normalize_image(image, self.band_mean, self.band_std) | |
| return image, mask.long() | |
| class S1AEFValidationTileDataset(Dataset): | |
| """full_coverage_v1 validation: every tile of every validation chip, fixed order. Exposes the whole-chip records | |
| (for the windowed end-of-training evaluation) and one source id per tile (for the per-epoch metrics).""" | |
| def __init__(self, tiles, records, crop_size, band_mean, band_std): | |
| self.tiles = list(tiles) | |
| self.records = list(records) | |
| self.item_source_ids = [tile[0].source_id for tile in self.tiles] | |
| self.crop_size = crop_size | |
| self.band_mean = torch.from_numpy(band_mean.astype(np.float32)).view(-1, 1, 1) | |
| self.band_std = torch.from_numpy(band_std.astype(np.float32)).view(-1, 1, 1) | |
| def __len__(self): | |
| return len(self.tiles) | |
| def __getitem__(self, idx): | |
| record, top, left = self.tiles[idx] | |
| image, mask = load_triplet_window(record, top=top, left=left, height=self.crop_size, width=self.crop_size) | |
| image = normalize_image(image, self.band_mean, self.band_std) | |
| return image, mask.long() | |
| def add_bool_arg(parser: argparse.ArgumentParser, name: str, default: bool, help_text: str) -> None: | |
| parser.add_argument("--{name}".format(name=name), dest=name, action="store_true", help=help_text) | |
| parser.add_argument( | |
| "--no-{name}".format(name=name), | |
| dest=name, | |
| action="store_false", | |
| help="Disable {name}".format(name=name.replace("_", " ")), | |
| ) | |
| parser.set_defaults(**{name: default}) | |
| def using_aef(args: argparse.Namespace) -> bool: | |
| return args.n_aef_bands > 0 | |
| def using_prior(args: argparse.Namespace) -> bool: | |
| return bool(getattr(args, "use_permanent_prior", False)) | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Train S1 segmentation models with optional raw-AEF fusion.", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| parser.add_argument("--input_dir", type=Path, default=DEFAULT_INPUT_DIR) | |
| parser.add_argument("--aef_dir", type=Path, default=DEFAULT_AEF_DIR) | |
| parser.add_argument("--label_dir", type=Path, default=DEFAULT_LABEL_DIR) | |
| parser.add_argument( | |
| "--triplet_manifest", | |
| type=Path, | |
| default=None, | |
| help="CSV with explicit tile_name/name, s1_path, label_path, and optional aef_path rows.", | |
| ) | |
| parser.add_argument( | |
| "--triplet_sample_id", | |
| default=None, | |
| help="Optional sample_id filter for --triplet_manifest. Required when it contains multiple labels.", | |
| ) | |
| parser.add_argument( | |
| "--fixed_split_manifest", | |
| type=Path, | |
| default=None, | |
| help="CSV with split and tile_name columns. Locks train/validation membership across experiments.", | |
| ) | |
| parser.add_argument("--artifact_dir", type=Path, default=DEFAULT_ARTIFACT_DIR) | |
| parser.add_argument( | |
| "--warm_start_run_dir", | |
| type=Path, | |
| default=None, | |
| help="Parent run directory with a compatible best checkpoint and band_stats.npz.", | |
| ) | |
| parser.add_argument( | |
| "--warm_start_checkpoint", | |
| type=Path, | |
| default=None, | |
| help="Explicit checkpoint for strict warm-start loading.", | |
| ) | |
| parser.add_argument( | |
| "--band_stats_path", | |
| type=Path, | |
| default=None, | |
| help="Precomputed band_stats.npz to copy into the new artifact directory.", | |
| ) | |
| parser.add_argument( | |
| "--model_family", | |
| choices=["resnet34_unet", "resnet50_unet", "resnet101_unet", "segformer"], | |
| default="resnet34_unet", | |
| ) | |
| parser.add_argument( | |
| "--segformer_variant", | |
| choices=list(SEGFORMER_MODEL_MAP.keys()), | |
| default="segformer_b4", | |
| ) | |
| parser.add_argument("--hf_cache_dir", type=Path, default=DEFAULT_HF_CACHE_DIR) | |
| parser.add_argument("--benchmark_summary_path", type=Path, default=None) | |
| parser.add_argument("--mode", choices=["benchmark", "train"], default="train") | |
| parser.add_argument("--crop_size", type=int, default=512) | |
| parser.add_argument("--tile_overlap", type=int, default=64) | |
| parser.add_argument("--batch_size", type=int, default=4) | |
| parser.add_argument("--num_workers", type=int, default=8) | |
| parser.add_argument( | |
| "--disable_pin_memory", | |
| action="store_true", | |
| help="Disable DataLoader pinned memory for clusters where CUDA pinning is unreliable.", | |
| ) | |
| parser.add_argument("--device_id", type=int, default=0) | |
| parser.add_argument("--seed", type=int, default=42) | |
| parser.add_argument( | |
| "--split_seed", | |
| type=int, | |
| default=None, | |
| help="Seed for the train/validation split. Defaults to --seed.", | |
| ) | |
| parser.add_argument( | |
| "--train_subset_seed", | |
| type=int, | |
| default=None, | |
| help="Seed for deterministic train-record subsampling. Defaults to --seed.", | |
| ) | |
| parser.add_argument("--valid_pct", type=float, default=0.2) | |
| parser.add_argument( | |
| "--max_train_records", | |
| type=int, | |
| default=None, | |
| help="Cap the number of training records after the validation split.", | |
| ) | |
| parser.add_argument("--stats_sample_size", type=int, default=256) | |
| parser.add_argument("--freeze_epochs", type=int, default=2) | |
| parser.add_argument("--partial_unfreeze_epochs", type=int, default=2) | |
| parser.add_argument("--finetune_epochs", type=int, default=12) | |
| parser.add_argument("--finetune_tail_epochs", type=int, default=10) | |
| parser.add_argument("--warmup_lr", type=float, default=1e-4) | |
| parser.add_argument("--partial_unfreeze_lr", type=float, default=1e-5) | |
| parser.add_argument("--unfreeze_lr", type=float, default=3e-6) | |
| parser.add_argument("--finetune_tail_lr", type=float, default=1.5e-6) | |
| parser.add_argument( | |
| "--tuning_strategy", | |
| choices=["staged", "head_only", "decoder_aef", "last_encoder_decoder_aef", "full_low_lr"], | |
| default="staged", | |
| help="Explicit trainable scope for transfer-learning strategy comparisons.", | |
| ) | |
| parser.add_argument("--strategy_epochs", type=int, default=6) | |
| parser.add_argument("--strategy_lr", type=float, default=1e-5) | |
| parser.add_argument("--weight_decay", type=float, default=1e-2) | |
| parser.add_argument("--grad_clip", type=float, default=1.0) | |
| parser.add_argument("--grad_accum_steps", type=int, default=1) | |
| parser.add_argument("--optimizer", choices=["ranger", "adamw"], default="ranger") | |
| parser.add_argument("--schedule_policy", choices=["one_cycle", "flat_cos"], default="one_cycle") | |
| parser.add_argument("--patience", type=int, default=4) | |
| parser.add_argument( | |
| "--selection_metric", | |
| choices=["dice", "water_iou", "source_balanced_water_iou", "target_source_water_iou"], | |
| default="water_iou", | |
| ) | |
| parser.add_argument( | |
| "--target_source_id", | |
| default="global_postflood", | |
| help="Source to oversample when --target_source_fraction is set.", | |
| ) | |
| parser.add_argument( | |
| "--target_source_fraction", | |
| type=float, | |
| default=None, | |
| help="Expected fraction of each training epoch drawn from --target_source_id.", | |
| ) | |
| parser.add_argument("--min_delta", type=float, default=0.001) | |
| parser.add_argument( | |
| "--loss", | |
| choices=[ | |
| "focal_dice", | |
| "ce_dice", | |
| "lovasz", | |
| "ce_soft_iou", | |
| "focal_tversky", | |
| "ce_dice_boundary", | |
| "ce_dice_cldice", | |
| "sce_dice", | |
| ], | |
| default="focal_dice", | |
| ) | |
| parser.add_argument("--loss_aux_weight", type=float, default=None) | |
| parser.add_argument("--tversky_alpha", type=float, default=0.3) | |
| parser.add_argument("--tversky_beta", type=float, default=0.7) | |
| parser.add_argument("--tversky_gamma", type=float, default=1.33) | |
| parser.add_argument("--cldice_iterations", type=int, default=10) | |
| parser.add_argument("--rotate_max_deg", type=float, default=5.0) | |
| parser.add_argument("--rotate_p", type=float, default=0.5) | |
| parser.add_argument("--scale_jitter_min", type=float, default=1.0) | |
| parser.add_argument("--scale_jitter_max", type=float, default=1.0) | |
| parser.add_argument("--s1_gain_jitter", type=float, default=0.0) | |
| parser.add_argument("--s1_bias_jitter", type=float, default=0.0) | |
| parser.add_argument("--gaussian_noise_std", type=float, default=0.0) | |
| parser.add_argument("--gaussian_noise_p", type=float, default=0.0) | |
| parser.add_argument( | |
| "--positive_crop_p", | |
| type=float, | |
| default=0.0, | |
| help="Probability of trying to draw a crop with at least --min_water_fraction water.", | |
| ) | |
| parser.add_argument("--min_water_fraction", type=float, default=0.005) | |
| parser.add_argument("--positive_crop_attempts", type=int, default=8) | |
| parser.add_argument("--train_tile_index", type=Path, default=None, | |
| help="full_coverage_v1: tile index CSV; train on every tile of every chip each epoch.") | |
| parser.add_argument("--valid_tile_index", type=Path, default=None, | |
| help="full_coverage_v1: tile index CSV; per-epoch validation on every tile of every validation chip.") | |
| parser.add_argument("--max_train_batches", type=int, default=None) | |
| parser.add_argument("--max_valid_batches", type=int, default=None) | |
| parser.add_argument("--probe_batch_sizes", type=int, nargs="+", default=[12, 8, 4, 2]) | |
| parser.add_argument("--max_benchmark_batch_size", type=int, default=None) | |
| parser.add_argument("--eval_thresholds", type=float, nargs="+", default=None) | |
| parser.add_argument("--worst_tile_count", type=int, default=25) | |
| parser.add_argument("--n_s1_bands", type=int, default=3) | |
| parser.add_argument("--n_aef_bands", type=int, default=64) | |
| parser.add_argument("--n_proj_bands", type=int, default=4) | |
| parser.add_argument( | |
| "--aef_dropout_p", | |
| type=float, | |
| default=0.0, | |
| help="factorial_v1: per-sample probability of zeroing the projected AEF input during training.", | |
| ) | |
| parser.add_argument( | |
| "--prior_dropout_p", | |
| type=float, | |
| default=0.0, | |
| help="Per-sample probability of zeroing the permanent-water prior during training.", | |
| ) | |
| add_bool_arg( | |
| parser, | |
| "use_permanent_prior", | |
| False, | |
| "Append prior_path probability maps from the triplet manifest as a raw input channel.", | |
| ) | |
| add_bool_arg(parser, "use_fp16", True, "Enable mixed precision on GPU.") | |
| add_bool_arg(parser, "pretrained", True, "Use ImageNet-pretrained encoder weights.") | |
| add_bool_arg(parser, "hf_local_files_only", False, "Restrict SegFormer loading to the local Hugging Face cache.") | |
| add_bool_arg(parser, "force_tiled_validation", False, "Force tiled inference during validation and threshold sweeps.") | |
| args = parser.parse_args() | |
| args.codes = ["other", "water"] | |
| if args.n_aef_bands < 0: | |
| parser.error("--n_aef_bands must be >= 0") | |
| if args.n_aef_bands == 0 and args.n_proj_bands != 0: | |
| parser.error("--n_proj_bands must be 0 when --n_aef_bands=0") | |
| if args.n_aef_bands == 0 and args.model_family == "segformer": | |
| parser.error("--model_family=segformer is not yet supported with --n_aef_bands=0") | |
| if using_prior(args) and args.model_family == "segformer": | |
| parser.error("--use_permanent_prior is currently supported only with ResNet U-Nets") | |
| if not 0 <= args.prior_dropout_p < 1: | |
| parser.error("--prior_dropout_p must be in [0, 1)") | |
| if args.prior_dropout_p > 0 and not using_prior(args): | |
| parser.error("--prior_dropout_p requires --use_permanent_prior") | |
| if args.model_family == "segformer": | |
| variant_name = args.segformer_variant.replace("segformer_", "") | |
| if using_aef(args): | |
| args.best_model_name = "s1aef_bottleneck_segformer_{variant}_best".format( | |
| variant=variant_name | |
| ) | |
| args.history_csv = "s1aef_bottleneck_segformer_{variant}_history.csv".format( | |
| variant=variant_name | |
| ) | |
| args.threshold_sweep_csv = "s1aef_bottleneck_segformer_{variant}_threshold_sweep.csv".format( | |
| variant=variant_name | |
| ) | |
| args.eval_summary_json = "s1aef_bottleneck_segformer_{variant}_eval_summary.json".format( | |
| variant=variant_name | |
| ) | |
| args.tile_metrics_csv = "s1aef_bottleneck_segformer_{variant}_tile_metrics.csv".format( | |
| variant=variant_name | |
| ) | |
| args.worst_tiles_csv = "s1aef_bottleneck_segformer_{variant}_worst_tiles.csv".format( | |
| variant=variant_name | |
| ) | |
| else: | |
| encoder_name = args.model_family.replace("_unet", "") | |
| if using_aef(args): | |
| args.best_model_name = "s1aef_bottleneck_{encoder}_best".format(encoder=encoder_name) | |
| args.history_csv = "s1aef_bottleneck_{encoder}_history.csv".format(encoder=encoder_name) | |
| args.threshold_sweep_csv = "s1aef_bottleneck_{encoder}_threshold_sweep.csv".format(encoder=encoder_name) | |
| args.eval_summary_json = "s1aef_bottleneck_{encoder}_eval_summary.json".format(encoder=encoder_name) | |
| args.tile_metrics_csv = "s1aef_bottleneck_{encoder}_tile_metrics.csv".format(encoder=encoder_name) | |
| args.worst_tiles_csv = "s1aef_bottleneck_{encoder}_worst_tiles.csv".format(encoder=encoder_name) | |
| else: | |
| args.best_model_name = "s1dw_{encoder}_best".format(encoder=encoder_name) | |
| args.history_csv = "s1dw_{encoder}_history.csv".format(encoder=encoder_name) | |
| args.threshold_sweep_csv = "s1dw_{encoder}_threshold_sweep.csv".format(encoder=encoder_name) | |
| args.eval_summary_json = "s1dw_{encoder}_eval_summary.json".format(encoder=encoder_name) | |
| args.tile_metrics_csv = "s1dw_{encoder}_tile_metrics.csv".format(encoder=encoder_name) | |
| args.worst_tiles_csv = "s1dw_{encoder}_worst_tiles.csv".format(encoder=encoder_name) | |
| if using_prior(args): | |
| encoder_name = args.model_family.replace("_unet", "") | |
| args.best_model_name = "s1aef_prior_{encoder}_best".format(encoder=encoder_name) | |
| args.history_csv = "s1aef_prior_{encoder}_history.csv".format(encoder=encoder_name) | |
| args.threshold_sweep_csv = "s1aef_prior_{encoder}_threshold_sweep.csv".format(encoder=encoder_name) | |
| args.eval_summary_json = "s1aef_prior_{encoder}_eval_summary.json".format(encoder=encoder_name) | |
| args.tile_metrics_csv = "s1aef_prior_{encoder}_tile_metrics.csv".format(encoder=encoder_name) | |
| args.worst_tiles_csv = "s1aef_prior_{encoder}_worst_tiles.csv".format(encoder=encoder_name) | |
| args.benchmark_summary_json = "benchmark_summary.json" | |
| if args.eval_thresholds is None: | |
| coarse_low = np.arange(0.30, 0.55, 0.05) | |
| focused_mid = np.arange(0.55, 0.71, 0.01) | |
| coarse_high = np.arange(0.75, 0.81, 0.05) | |
| args.eval_thresholds = sorted( | |
| {round(float(v), 2) for v in np.concatenate([coarse_low, focused_mid, coarse_high])} | |
| ) | |
| args.use_tta_eval = True | |
| args.tta_eval_mode_count = 4 | |
| args.input_dir = args.input_dir.expanduser().resolve() | |
| args.aef_dir = args.aef_dir.expanduser().resolve() | |
| args.label_dir = args.label_dir.expanduser().resolve() | |
| if args.triplet_manifest is not None: | |
| args.triplet_manifest = args.triplet_manifest.expanduser().resolve() | |
| if args.fixed_split_manifest is not None: | |
| args.fixed_split_manifest = args.fixed_split_manifest.expanduser().resolve() | |
| args.artifact_dir = args.artifact_dir.expanduser().resolve() | |
| if args.warm_start_run_dir is not None: | |
| args.warm_start_run_dir = args.warm_start_run_dir.expanduser().resolve() | |
| if args.warm_start_checkpoint is not None: | |
| args.warm_start_checkpoint = args.warm_start_checkpoint.expanduser().resolve() | |
| if args.band_stats_path is not None: | |
| args.band_stats_path = args.band_stats_path.expanduser().resolve() | |
| args.hf_cache_dir = args.hf_cache_dir.expanduser().resolve() | |
| if args.benchmark_summary_path is not None: | |
| args.benchmark_summary_path = args.benchmark_summary_path.expanduser().resolve() | |
| if args.crop_size < 1: | |
| parser.error("--crop_size must be >= 1") | |
| if args.tile_overlap < 0 or args.tile_overlap >= args.crop_size: | |
| parser.error("--tile_overlap must be >= 0 and < --crop_size") | |
| if args.scale_jitter_min <= 0 or args.scale_jitter_max <= 0: | |
| parser.error("--scale_jitter_min and --scale_jitter_max must be > 0") | |
| if args.scale_jitter_min > args.scale_jitter_max: | |
| parser.error("--scale_jitter_min must be <= --scale_jitter_max") | |
| if args.batch_size < 1: | |
| parser.error("--batch_size must be >= 1") | |
| if args.grad_accum_steps < 1: | |
| parser.error("--grad_accum_steps must be >= 1") | |
| if args.target_source_fraction is not None and not 0 < args.target_source_fraction <= 1: | |
| parser.error("--target_source_fraction must be greater than 0 and at most 1") | |
| if not 0 <= args.positive_crop_p <= 1: | |
| parser.error("--positive_crop_p must be in [0, 1]") | |
| if not 0 <= args.min_water_fraction <= 1: | |
| parser.error("--min_water_fraction must be in [0, 1]") | |
| if args.positive_crop_attempts < 1: | |
| parser.error("--positive_crop_attempts must be >= 1") | |
| if args.loss_aux_weight is not None and args.loss_aux_weight < 0: | |
| parser.error("--loss_aux_weight must be non-negative") | |
| if args.tversky_alpha < 0 or args.tversky_beta < 0: | |
| parser.error("--tversky_alpha and --tversky_beta must be non-negative") | |
| if args.tversky_gamma <= 0: | |
| parser.error("--tversky_gamma must be > 0") | |
| if args.cldice_iterations < 1: | |
| parser.error("--cldice_iterations must be >= 1") | |
| if args.strategy_epochs < 1: | |
| parser.error("--strategy_epochs must be >= 1") | |
| if args.strategy_lr <= 0: | |
| parser.error("--strategy_lr must be > 0") | |
| if args.max_benchmark_batch_size is not None and args.max_benchmark_batch_size < 1: | |
| parser.error("--max_benchmark_batch_size must be >= 1") | |
| if args.worst_tile_count < 1: | |
| parser.error("--worst_tile_count must be >= 1") | |
| if args.triplet_sample_id and args.triplet_manifest is None: | |
| parser.error("--triplet_sample_id requires --triplet_manifest") | |
| return args | |
| def seed_everything(seed: int) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(seed) | |
| def seed_worker(worker_id: Optional[int] = None) -> None: | |
| worker_seed = torch.initial_seed() % (2**32) | |
| random.seed(worker_seed) | |
| np.random.seed(worker_seed) | |
| def setup_environment(args: argparse.Namespace) -> torch.device: | |
| seed_everything(args.seed) | |
| args.artifact_dir.mkdir(parents=True, exist_ok=True) | |
| (args.artifact_dir / "models").mkdir(parents=True, exist_ok=True) | |
| if torch.cuda.is_available(): | |
| device_count = torch.cuda.device_count() | |
| actual_device = args.device_id if args.device_id < device_count else 0 | |
| torch.cuda.set_device(actual_device) | |
| device = torch.device("cuda:{idx}".format(idx=actual_device)) | |
| torch.backends.cudnn.benchmark = True | |
| print( | |
| { | |
| "device": str(device), | |
| "device_name": torch.cuda.get_device_name(actual_device), | |
| "device_count": device_count, | |
| } | |
| ) | |
| else: | |
| device = torch.device("cpu") | |
| print({"device": "cpu"}) | |
| n_input = args.n_s1_bands + args.n_aef_bands | |
| n_unet_input = args.n_s1_bands + (args.n_proj_bands if using_aef(args) else 0) | |
| if using_aef(args): | |
| dataloader_bands = "{s1} S1 + {aef} AEF = {n}".format( | |
| s1=args.n_s1_bands, aef=args.n_aef_bands, n=n_input | |
| ) | |
| model_input_bands = "{s1} S1 + {proj} projected = {n}".format( | |
| s1=args.n_s1_bands, proj=args.n_proj_bands, n=n_unet_input | |
| ) | |
| else: | |
| dataloader_bands = "{s1} S1 = {n}".format(s1=args.n_s1_bands, n=n_input) | |
| model_input_bands = "{s1} S1 = {n}".format(s1=args.n_s1_bands, n=n_unet_input) | |
| print( | |
| { | |
| "artifact_dir": str(args.artifact_dir), | |
| "mode": args.mode, | |
| "model_family": args.model_family, | |
| "segformer_variant": args.segformer_variant if args.model_family == "segformer" else None, | |
| "dataloader_bands": dataloader_bands, | |
| "model_input_bands": model_input_bands, | |
| "crop_size": args.crop_size, | |
| "batch_size": args.batch_size, | |
| "num_workers": args.num_workers, | |
| "loss": args.loss, | |
| "tuning_strategy": args.tuning_strategy, | |
| "grad_accum_steps": args.grad_accum_steps, | |
| "selection_metric": args.selection_metric, | |
| "target_source_id": args.target_source_id, | |
| "target_source_fraction": args.target_source_fraction, | |
| "eval_thresholds": args.eval_thresholds, | |
| "force_tiled_validation": args.force_tiled_validation, | |
| "use_fp16": args.use_fp16, | |
| "pretrained": args.pretrained, | |
| } | |
| ) | |
| return device | |
| def build_aef_index(aef_dir: Path) -> Dict[str, Path]: | |
| if not aef_dir.exists(): | |
| raise FileNotFoundError("AEF directory does not exist: {path}".format(path=aef_dir)) | |
| index: Dict[str, Path] = {} | |
| duplicates: List[str] = [] | |
| for path in sorted(aef_dir.rglob("*.tif")): | |
| rel_parts = path.relative_to(aef_dir).parts | |
| if any(part.startswith(".") for part in rel_parts): | |
| continue | |
| if path.name in index: | |
| duplicates.append(path.name) | |
| else: | |
| index[path.name] = path | |
| if duplicates: | |
| raise RuntimeError( | |
| "Found duplicate AEF filenames across recursive folders. Examples: {examples}".format( | |
| examples=duplicates[:5] | |
| ) | |
| ) | |
| if not index: | |
| raise FileNotFoundError("No AEF GeoTIFFs found under {path}".format(path=aef_dir)) | |
| return index | |
| def collect_triplets( | |
| input_dir: Path, | |
| aef_dir: Optional[Path], | |
| label_dir: Path, | |
| ) -> List[TripletRecord]: | |
| if not input_dir.exists(): | |
| raise FileNotFoundError("Input directory does not exist: {path}".format(path=input_dir)) | |
| if not label_dir.exists(): | |
| raise FileNotFoundError("Label directory does not exist: {path}".format(path=label_dir)) | |
| use_aef = aef_dir is not None | |
| aef_index = build_aef_index(aef_dir) if use_aef else {} | |
| # Build the label index from directory entries once. Calling Path.exists() | |
| # for every recovered-label symlink is extremely slow on Perlmutter's | |
| # scratch filesystem and previously consumed the entire smoke-job limit. | |
| label_index = {path.name: path for path in label_dir.glob("*.tif")} | |
| s1_paths = sorted(input_dir.glob("*.tif")) | |
| triplets: List[TripletRecord] = [] | |
| missing_aef: List[str] = [] | |
| missing_labels: List[str] = [] | |
| for s1_path in s1_paths: | |
| aef_path = aef_index.get(s1_path.name) if use_aef else None | |
| label_path = label_index.get(s1_path.name) | |
| if use_aef and aef_path is None: | |
| missing_aef.append(s1_path.name) | |
| continue | |
| if label_path is None: | |
| missing_labels.append(s1_path.name) | |
| continue | |
| triplets.append( | |
| TripletRecord( | |
| name=s1_path.name, | |
| s1_path=s1_path, | |
| aef_path=aef_path, | |
| label_path=label_path, | |
| group_id=s1_path.name, | |
| ) | |
| ) | |
| if not triplets: | |
| raise FileNotFoundError( | |
| "No aligned S1/AEF/label triplets found. Missing AEF: {ma}, missing labels: {ml}".format( | |
| ma=len(missing_aef), ml=len(missing_labels) | |
| ) | |
| ) | |
| print( | |
| { | |
| "triplet_count": len(triplets), | |
| "missing_aef_count": len(missing_aef) if use_aef else 0, | |
| "missing_label_count": len(missing_labels), | |
| "missing_aef_examples": missing_aef[:3] if use_aef else [], | |
| "missing_label_examples": missing_labels[:3], | |
| } | |
| ) | |
| return triplets | |
| def collect_triplets_from_manifest( | |
| manifest_path: Path, | |
| sample_id: Optional[str], | |
| expect_aef: bool, | |
| expect_prior: bool = False, | |
| ) -> List[TripletRecord]: | |
| if not manifest_path.exists(): | |
| raise FileNotFoundError("Triplet manifest does not exist: {path}".format(path=manifest_path)) | |
| with manifest_path.open(newline="") as handle: | |
| rows = list(csv.DictReader(handle)) | |
| required = {"s1_path", "label_path"} | |
| if expect_aef: | |
| required.add("aef_path") | |
| if expect_prior: | |
| required.add("prior_path") | |
| missing = required.difference(rows[0].keys() if rows else []) | |
| if missing: | |
| raise ValueError("Triplet manifest is missing columns: {cols}".format(cols=", ".join(sorted(missing)))) | |
| manifest_sample_ids = sorted({row.get("sample_id", "") for row in rows if row.get("sample_id", "")}) | |
| if sample_id is None and len(manifest_sample_ids) > 1: | |
| raise ValueError( | |
| "Triplet manifest has multiple sample_id values; pass --triplet_sample_id. Examples: {ids}".format( | |
| ids=", ".join(manifest_sample_ids[:5]) | |
| ) | |
| ) | |
| selected_rows = [row for row in rows if sample_id is None or row.get("sample_id", "") == sample_id] | |
| if not selected_rows: | |
| raise ValueError("Triplet manifest has no rows for sample_id {sample}".format(sample=sample_id or "<all>")) | |
| records: List[TripletRecord] = [] | |
| missing_paths: List[str] = [] | |
| for row in selected_rows: | |
| name = row.get("tile_name") or row.get("name") or Path(row["s1_path"]).name | |
| s1_path = Path(row["s1_path"]).expanduser() | |
| label_path = Path(row["label_path"]).expanduser() | |
| aef_value = row.get("aef_path", "") | |
| aef_path = Path(aef_value).expanduser() if expect_aef and aef_value else None | |
| prior_value = row.get("prior_path", "") | |
| prior_path = Path(prior_value).expanduser() if expect_prior and prior_value else None | |
| path_checks = [("s1", s1_path), ("label", label_path)] | |
| if expect_aef: | |
| if aef_path is None: | |
| missing_paths.append("{name}: empty aef_path".format(name=name)) | |
| continue | |
| path_checks.append(("aef", aef_path)) | |
| if expect_prior: | |
| if prior_path is None: | |
| missing_paths.append("{name}: empty prior_path".format(name=name)) | |
| continue | |
| path_checks.append(("prior", prior_path)) | |
| missing_for_row = ["{kind}={path}".format(kind=kind, path=path) for kind, path in path_checks if not path.exists()] | |
| if missing_for_row: | |
| missing_paths.append("{name}: {paths}".format(name=name, paths=", ".join(missing_for_row))) | |
| continue | |
| records.append( | |
| TripletRecord( | |
| name=name, | |
| s1_path=s1_path.resolve(), | |
| aef_path=aef_path.resolve() if aef_path is not None else None, | |
| label_path=label_path.resolve(), | |
| sample_id=row.get("sample_id", sample_id or ""), | |
| group_id=row.get("grid_id") or row.get("group_id") or name, | |
| source_id=row.get("source_id", ""), | |
| prior_path=prior_path.resolve() if prior_path is not None else None, | |
| ) | |
| ) | |
| if missing_paths: | |
| raise FileNotFoundError( | |
| "Triplet manifest has missing or incomplete paths for {n} row(s). Examples: {examples}".format( | |
| n=len(missing_paths), | |
| examples="; ".join(missing_paths[:5]), | |
| ) | |
| ) | |
| if not records: | |
| raise FileNotFoundError("Triplet manifest did not produce any usable records: {}".format(manifest_path)) | |
| print( | |
| { | |
| "triplet_manifest": str(manifest_path), | |
| "triplet_sample_id": sample_id or (manifest_sample_ids[0] if len(manifest_sample_ids) == 1 else None), | |
| "triplet_count": len(records), | |
| } | |
| ) | |
| return sorted(records, key=lambda record: record.name) | |
| def split_grouped_triplets( | |
| records: Sequence[TripletRecord], | |
| valid_pct: float, | |
| seed: int, | |
| ) -> Tuple[List[TripletRecord], List[TripletRecord]]: | |
| if not records: | |
| raise ValueError("Cannot split an empty record list.") | |
| grouped: Dict[str, List[TripletRecord]] = {} | |
| for record in records: | |
| grouped.setdefault(record.group_id or record.name, []).append(record) | |
| if len(grouped) < 2: | |
| raise ValueError("At least two independent groups are required for a train/validation split.") | |
| rng = random.Random(seed) | |
| shuffled_groups = list(grouped.items()) | |
| rng.shuffle(shuffled_groups) | |
| target_valid_count = max(1, int(round(len(records) * valid_pct))) | |
| valid_group_ids = set() | |
| valid_count = 0 | |
| for group_id, group_records in shuffled_groups: | |
| if valid_count >= target_valid_count: | |
| break | |
| valid_group_ids.add(group_id) | |
| valid_count += len(group_records) | |
| valid_records = sorted( | |
| [record for record in records if (record.group_id or record.name) in valid_group_ids], | |
| key=lambda r: r.name, | |
| ) | |
| train_records = sorted( | |
| [record for record in records if (record.group_id or record.name) not in valid_group_ids], | |
| key=lambda r: r.name, | |
| ) | |
| if not train_records: | |
| raise ValueError("Train split is empty; reduce --valid_pct.") | |
| train_group_ids = {record.group_id or record.name for record in train_records} | |
| if train_group_ids.intersection(valid_group_ids): | |
| raise RuntimeError("Group leakage detected between train and validation splits.") | |
| return train_records, valid_records | |
| def split_triplets(records: Sequence[TripletRecord], valid_pct: float, seed: int) -> Tuple[List[TripletRecord], List[TripletRecord]]: | |
| source_ids = sorted({record.source_id for record in records if record.source_id}) | |
| if len(source_ids) <= 1: | |
| return split_grouped_triplets(records, valid_pct=valid_pct, seed=seed) | |
| train_records: List[TripletRecord] = [] | |
| valid_records: List[TripletRecord] = [] | |
| for source_id in source_ids: | |
| source_records = [record for record in records if record.source_id == source_id] | |
| source_train, source_valid = split_grouped_triplets( | |
| source_records, | |
| valid_pct=valid_pct, | |
| seed=seed, | |
| ) | |
| train_records.extend(source_train) | |
| valid_records.extend(source_valid) | |
| assigned = len(train_records) + len(valid_records) | |
| if assigned != len(records): | |
| raise RuntimeError( | |
| "Source-stratified split assigned {assigned} of {total} records.".format( | |
| assigned=assigned, | |
| total=len(records), | |
| ) | |
| ) | |
| return sorted(train_records, key=lambda r: r.name), sorted(valid_records, key=lambda r: r.name) | |
| def split_triplets_from_manifest( | |
| records: Sequence[TripletRecord], manifest_path: Path | |
| ) -> Tuple[List[TripletRecord], List[TripletRecord]]: | |
| """Apply an immutable train/validation membership manifest by tile name.""" | |
| if not manifest_path.exists(): | |
| raise FileNotFoundError("Fixed split manifest does not exist: {path}".format(path=manifest_path)) | |
| with manifest_path.open(newline="") as handle: | |
| rows = list(csv.DictReader(handle)) | |
| required = {"split", "tile_name"} | |
| missing_columns = required.difference(rows[0].keys() if rows else []) | |
| if missing_columns: | |
| raise ValueError( | |
| "Fixed split manifest is missing columns: {columns}".format( | |
| columns=", ".join(sorted(missing_columns)) | |
| ) | |
| ) | |
| assignments: Dict[str, str] = {} | |
| for row in rows: | |
| name = row["tile_name"].strip() | |
| split = row["split"].strip().lower() | |
| if split not in {"train", "valid", "validation"}: | |
| raise ValueError("Unknown split {split!r} for {name}".format(split=split, name=name)) | |
| split = "valid" if split == "validation" else split | |
| if name in assignments: | |
| raise ValueError("Duplicate tile in fixed split manifest: {name}".format(name=name)) | |
| assignments[name] = split | |
| records_by_name = {record.name: record for record in records} | |
| if len(records_by_name) != len(records): | |
| raise ValueError("Training records contain duplicate tile names; fixed split is ambiguous.") | |
| missing_records = sorted(set(assignments).difference(records_by_name)) | |
| unassigned_records = sorted(set(records_by_name).difference(assignments)) | |
| if missing_records or unassigned_records: | |
| raise ValueError( | |
| "Fixed split population mismatch: missing_records={missing}, unassigned_records={unassigned}, " | |
| "missing_examples={missing_examples}, unassigned_examples={unassigned_examples}".format( | |
| missing=len(missing_records), | |
| unassigned=len(unassigned_records), | |
| missing_examples=missing_records[:5], | |
| unassigned_examples=unassigned_records[:5], | |
| ) | |
| ) | |
| train_records = sorted( | |
| [records_by_name[name] for name, split in assignments.items() if split == "train"], | |
| key=lambda record: record.name, | |
| ) | |
| valid_records = sorted( | |
| [records_by_name[name] for name, split in assignments.items() if split == "valid"], | |
| key=lambda record: record.name, | |
| ) | |
| if not train_records or not valid_records: | |
| raise ValueError("Fixed split manifest must contain at least one train and one valid tile.") | |
| print( | |
| { | |
| "fixed_split_manifest": str(manifest_path), | |
| "fixed_train_tiles": len(train_records), | |
| "fixed_valid_tiles": len(valid_records), | |
| } | |
| ) | |
| return train_records, valid_records | |
| def limit_train_records( | |
| records: Sequence[TripletRecord], | |
| max_records: Optional[int], | |
| subset_seed: int, | |
| ) -> List[TripletRecord]: | |
| selected = list(records) | |
| if max_records is None: | |
| return selected | |
| if max_records <= 0: | |
| raise ValueError("--max_train_records must be positive when set.") | |
| rng = random.Random(subset_seed) | |
| shuffled = list(selected) | |
| rng.shuffle(shuffled) | |
| return sorted(shuffled[: min(max_records, len(shuffled))], key=lambda r: r.name) | |
| def write_split_manifest( | |
| artifact_dir: Path, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| ) -> Path: | |
| split_path = artifact_dir / "triplet_split_manifest.csv" | |
| fields = ( | |
| "split", | |
| "sample_id", | |
| "source_id", | |
| "group_id", | |
| "name", | |
| "s1_path", | |
| "aef_path", | |
| "label_path", | |
| "prior_path", | |
| ) | |
| rows = [("train", record) for record in train_records] + [("valid", record) for record in valid_records] | |
| with split_path.open("w", newline="") as handle: | |
| writer = csv.DictWriter(handle, fieldnames=fields) | |
| writer.writeheader() | |
| for split, record in rows: | |
| writer.writerow( | |
| { | |
| "split": split, | |
| "sample_id": record.sample_id, | |
| "source_id": record.source_id, | |
| "group_id": record.group_id or record.name, | |
| "name": record.name, | |
| "s1_path": str(record.s1_path), | |
| "aef_path": "" if record.aef_path is None else str(record.aef_path), | |
| "label_path": str(record.label_path), | |
| "prior_path": "" if record.prior_path is None else str(record.prior_path), | |
| } | |
| ) | |
| print( | |
| { | |
| "triplet_split_manifest": str(split_path), | |
| "train_tiles": len(train_records), | |
| "valid_tiles": len(valid_records), | |
| "train_groups": len({record.group_id or record.name for record in train_records}), | |
| "valid_groups": len({record.group_id or record.name for record in valid_records}), | |
| } | |
| ) | |
| return split_path | |
| def subset_records(records: Sequence[TripletRecord], max_batches: Optional[int], batch_size: int) -> List[TripletRecord]: | |
| selected = list(records) | |
| if max_batches is None: | |
| return selected | |
| max_items = max(batch_size * max_batches, batch_size) | |
| max_items = min(len(selected), max_items) | |
| records_by_source: Dict[str, List[TripletRecord]] = {} | |
| for record in selected: | |
| records_by_source.setdefault(record.source_id or "unspecified", []).append(record) | |
| if len(records_by_source) <= 1: | |
| return selected[:max_items] | |
| # Small benchmark/smoke caps must not accidentally remove an entire label | |
| # source merely because the full split is sorted by tile name. | |
| source_ids = sorted(records_by_source) | |
| source_positions = {source_id: 0 for source_id in source_ids} | |
| source_stratified: List[TripletRecord] = [] | |
| while len(source_stratified) < max_items: | |
| added_record = False | |
| for source_id in source_ids: | |
| source_records = records_by_source[source_id] | |
| source_position = source_positions[source_id] | |
| if source_position >= len(source_records): | |
| continue | |
| source_stratified.append(source_records[source_position]) | |
| source_positions[source_id] += 1 | |
| added_record = True | |
| if len(source_stratified) >= max_items: | |
| break | |
| if not added_record: | |
| break | |
| return source_stratified | |
| def open_geotiff_float(path: Path) -> torch.Tensor: | |
| with rio.open(str(path)) as src: | |
| data = src.read().astype(np.float32) | |
| data = np.nan_to_num(data, nan=0.0, posinf=0.0, neginf=0.0) | |
| return torch.from_numpy(data) | |
| def open_mask_binary(path: Path) -> torch.Tensor: | |
| """0 = dry, 1 = water, IGNORE_INDEX = Dynamic World did not observe.""" | |
| with rio.open(str(path)) as src: | |
| data = src.read(1).astype(np.float32) | |
| return torch.from_numpy(binarize_label_array(data)) | |
| def open_prior_probability(path: Path) -> torch.Tensor: | |
| with rio.open(str(path)) as src: | |
| data = src.read(1).astype(np.float32) | |
| nodata = src.nodata | |
| invalid = ~np.isfinite(data) | |
| if nodata is not None: | |
| invalid |= data == nodata | |
| data = np.where(invalid, 0.0, data) | |
| data = np.clip(data, 0.0, 1.0) | |
| return torch.from_numpy(data).unsqueeze(0) | |
| def triplet_shape(record: TripletRecord) -> Tuple[int, int]: | |
| with rio.open(str(record.s1_path)) as s1_src: | |
| height, width = s1_src.height, s1_src.width | |
| with rio.open(str(record.label_path)) as label_src: | |
| label_shape = (label_src.height, label_src.width) | |
| if label_shape != (height, width): | |
| raise ValueError( | |
| "Triplet {name} has mismatched S1/label shapes: s1={s1}, label={label}".format( | |
| name=record.name, | |
| s1=(height, width), | |
| label=label_shape, | |
| ) | |
| ) | |
| if record.aef_path is not None: | |
| with rio.open(str(record.aef_path)) as aef_src: | |
| aef_shape = (aef_src.height, aef_src.width) | |
| if aef_shape != (height, width): | |
| raise ValueError( | |
| "Triplet {name} has mismatched S1/AEF shapes: s1={s1}, aef={aef}".format( | |
| name=record.name, | |
| s1=(height, width), | |
| aef=aef_shape, | |
| ) | |
| ) | |
| if record.prior_path is not None: | |
| with rio.open(str(record.prior_path)) as prior_src: | |
| prior_shape = (prior_src.height, prior_src.width) | |
| prior_count = prior_src.count | |
| if prior_count != 1 or prior_shape != (height, width): | |
| raise ValueError( | |
| "Triplet {name} has mismatched prior: s1={s1}, prior_shape={prior}, prior_bands={bands}".format( | |
| name=record.name, | |
| s1=(height, width), | |
| prior=prior_shape, | |
| bands=prior_count, | |
| ) | |
| ) | |
| return height, width | |
| def load_triplet_window( | |
| record: TripletRecord, | |
| top: int, | |
| left: int, | |
| height: int, | |
| width: int, | |
| ) -> Tuple[torch.Tensor, torch.Tensor]: | |
| """Read one aligned source window instead of materializing a full 67-band tile.""" | |
| window = rio.windows.Window(col_off=left, row_off=top, width=width, height=height) | |
| with rio.open(str(record.s1_path)) as src: | |
| s1_data = src.read(window=window).astype(np.float32) | |
| with rio.open(str(record.label_path)) as src: | |
| mask_data = src.read(1, window=window).astype(np.float32) | |
| s1_missing = ~np.isfinite(s1_data[:2]).all(axis=0) | (s1_data[:2] == 0).any(axis=0) # full_coverage_v1 fix 2 | |
| mask_data = np.where(s1_missing, float(IGNORE_INDEX), mask_data).astype(np.float32) | |
| arrays = [s1_data] | |
| if record.aef_path is not None: | |
| with rio.open(str(record.aef_path)) as src: | |
| arrays.append(src.read(window=window).astype(np.float32)) | |
| if record.prior_path is not None: | |
| with rio.open(str(record.prior_path)) as src: | |
| prior_data = src.read(1, window=window).astype(np.float32) | |
| prior_nodata = src.nodata | |
| prior_invalid = ~np.isfinite(prior_data) | |
| if prior_nodata is not None: | |
| prior_invalid |= prior_data == prior_nodata | |
| prior_data = np.where(prior_invalid, 0.0, prior_data) | |
| arrays.append(np.clip(prior_data, 0.0, 1.0)[None, :, :]) | |
| image_data = np.concatenate(arrays, axis=0) if len(arrays) > 1 else arrays[0] | |
| image_data = np.nan_to_num(image_data, nan=0.0, posinf=0.0, neginf=0.0) | |
| image = torch.from_numpy(image_data).float() | |
| mask = torch.from_numpy(binarize_label_array(mask_data)).long() | |
| return image, mask | |
| def load_random_triplet_window( | |
| record: TripletRecord, | |
| crop_size: int, | |
| seek_positive: bool = False, | |
| min_water_fraction: float = 0.005, | |
| attempts: int = 8, | |
| ) -> Tuple[torch.Tensor, torch.Tensor]: | |
| height, width = triplet_shape(record) | |
| if height < crop_size or width < crop_size: | |
| raise ValueError( | |
| "Crop size {crop} exceeds tile shape ({height}, {width}) for {name}".format( | |
| crop=crop_size, | |
| height=height, | |
| width=width, | |
| name=record.name, | |
| ) | |
| ) | |
| top = random.randint(0, height - crop_size) | |
| left = random.randint(0, width - crop_size) | |
| if seek_positive: | |
| with rio.open(str(record.label_path)) as label_src: | |
| for _ in range(attempts): | |
| candidate_top = random.randint(0, height - crop_size) | |
| candidate_left = random.randint(0, width - crop_size) | |
| window = rio.windows.Window( | |
| col_off=candidate_left, | |
| row_off=candidate_top, | |
| width=crop_size, | |
| height=crop_size, | |
| ) | |
| candidate_mask = binarize_label_array(label_src.read(1, window=window)) | |
| top, left = candidate_top, candidate_left | |
| water_pixels = float(np.count_nonzero(candidate_mask == 1)) | |
| if water_pixels / candidate_mask.size >= min_water_fraction: | |
| break | |
| return load_triplet_window(record, top=top, left=left, height=crop_size, width=crop_size) | |
| def load_center_triplet_window(record: TripletRecord, crop_size: int) -> Tuple[torch.Tensor, torch.Tensor]: | |
| height, width = triplet_shape(record) | |
| if height < crop_size or width < crop_size: | |
| raise ValueError( | |
| "Crop size {crop} exceeds tile shape ({height}, {width}) for {name}".format( | |
| crop=crop_size, | |
| height=height, | |
| width=width, | |
| name=record.name, | |
| ) | |
| ) | |
| top = (height - crop_size) // 2 | |
| left = (width - crop_size) // 2 | |
| return load_triplet_window(record, top=top, left=left, height=crop_size, width=crop_size) | |
| def load_triplet_tensors(record: TripletRecord) -> Tuple[torch.Tensor, torch.Tensor]: | |
| s1 = open_geotiff_float(record.s1_path) | |
| mask = open_mask_binary(record.label_path) | |
| mask[(s1[:2] == 0).any(dim=0)] = IGNORE_INDEX # full_coverage_v1 fix 2 (NaN already set to 0 by open_geotiff_float) | |
| if record.aef_path is None: | |
| if s1.shape[1:] != mask.shape: | |
| raise ValueError( | |
| "Triplet {name} has mismatched shapes: s1={s1}, mask={mask}".format( | |
| name=record.name, s1=tuple(s1.shape), mask=tuple(mask.shape) | |
| ) | |
| ) | |
| image = s1 | |
| else: | |
| aef = open_geotiff_float(record.aef_path) | |
| if s1.shape[1:] != aef.shape[1:] or s1.shape[1:] != mask.shape: | |
| raise ValueError( | |
| "Triplet {name} has mismatched shapes: s1={s1}, aef={aef}, mask={mask}".format( | |
| name=record.name, s1=tuple(s1.shape), aef=tuple(aef.shape), mask=tuple(mask.shape) | |
| ) | |
| ) | |
| image = torch.cat([s1, aef], dim=0) | |
| if record.prior_path is not None: | |
| prior = open_prior_probability(record.prior_path) | |
| if prior.shape[1:] != mask.shape: | |
| raise ValueError( | |
| "Triplet {name} has mismatched prior/mask shapes: prior={prior}, mask={mask}".format( | |
| name=record.name, | |
| prior=tuple(prior.shape), | |
| mask=tuple(mask.shape), | |
| ) | |
| ) | |
| image = torch.cat([image, prior], dim=0) | |
| return image.float(), mask.long() | |
| def compute_band_stats( | |
| records: Sequence[TripletRecord], | |
| max_files: Optional[int], | |
| normalized_band_count: Optional[int] = None, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| all_records = list(records) | |
| source_ids = sorted({record.source_id for record in all_records if record.source_id}) | |
| if max_files is None or max_files >= len(all_records): | |
| selected = all_records | |
| elif len(source_ids) <= 1: | |
| selected = all_records[:max_files] | |
| else: | |
| selected = [] | |
| base_count, remainder = divmod(max_files, len(source_ids)) | |
| for source_idx, source_id in enumerate(source_ids): | |
| source_records = [record for record in all_records if record.source_id == source_id] | |
| source_limit = base_count + (1 if source_idx < remainder else 0) | |
| selected.extend(source_records[: min(source_limit, len(source_records))]) | |
| if len(selected) < max_files: | |
| selected_names = {record.name for record in selected} | |
| selected.extend( | |
| record for record in all_records if record.name not in selected_names | |
| ) | |
| selected = selected[:max_files] | |
| if not selected: | |
| raise ValueError("Cannot compute stats without at least one training record.") | |
| print( | |
| { | |
| "band_stats_record_count": len(selected), | |
| "band_stats_source_counts": dict(Counter(record.source_id or "unspecified" for record in selected)), | |
| } | |
| ) | |
| channel_sum = None | |
| channel_sumsq = None | |
| pixel_count = 0 | |
| for record in selected: | |
| image, _ = load_triplet_tensors(record) | |
| if normalized_band_count is not None: | |
| image = image[:normalized_band_count] | |
| flat = image.numpy().reshape(image.shape[0], -1) | |
| if channel_sum is None: | |
| channel_sum = flat.sum(axis=1) | |
| channel_sumsq = np.square(flat).sum(axis=1) | |
| else: | |
| channel_sum += flat.sum(axis=1) | |
| channel_sumsq += np.square(flat).sum(axis=1) | |
| pixel_count += flat.shape[1] | |
| mean = channel_sum / pixel_count | |
| variance = np.maximum((channel_sumsq / pixel_count) - np.square(mean), 1e-8) | |
| std = np.sqrt(variance) | |
| return mean.astype(np.float32), std.astype(np.float32) | |
| def get_band_stats( | |
| train_records: Sequence[TripletRecord], | |
| artifact_dir: Path, | |
| stats_sample_size: int, | |
| source_stats_path: Optional[Path] = None, | |
| expected_bands: Optional[int] = None, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| stats_cache = artifact_dir / "band_stats.npz" | |
| if source_stats_path is not None: | |
| if not source_stats_path.exists(): | |
| raise FileNotFoundError("Band stats source does not exist: {path}".format(path=source_stats_path)) | |
| if source_stats_path.resolve() != stats_cache.resolve(): | |
| shutil.copy2(source_stats_path, stats_cache) | |
| save_json( | |
| artifact_dir / "band_stats_source.json", | |
| {"source_band_stats": str(source_stats_path.resolve()), "artifact_band_stats": str(stats_cache)}, | |
| ) | |
| print("Copied band stats from {source} to {dest}".format(source=source_stats_path, dest=stats_cache)) | |
| if stats_cache.exists(): | |
| cached = np.load(stats_cache) | |
| validate_band_stats(cached["mean"], cached["std"], expected_bands, stats_cache) | |
| print("Loaded cached band stats from {path}".format(path=stats_cache)) | |
| return cached["mean"], cached["std"] | |
| band_mean, band_std = compute_band_stats( | |
| train_records, | |
| max_files=stats_sample_size, | |
| normalized_band_count=expected_bands, | |
| ) | |
| validate_band_stats(band_mean, band_std, expected_bands, stats_cache) | |
| np.savez(stats_cache, mean=band_mean, std=band_std) | |
| print("Saved band stats to {path}".format(path=stats_cache)) | |
| return band_mean, band_std | |
| def validate_band_stats(mean: np.ndarray, std: np.ndarray, expected_bands: Optional[int], path: Path) -> None: | |
| if mean.shape != std.shape: | |
| raise ValueError("Band stats mean/std shape mismatch at {path}: {mean} vs {std}".format(path=path, mean=mean.shape, std=std.shape)) | |
| if expected_bands is not None and len(mean) != expected_bands: | |
| raise ValueError( | |
| "Band stats at {path} have {actual} bands; expected {expected}".format( | |
| path=path, | |
| actual=len(mean), | |
| expected=expected_bands, | |
| ) | |
| ) | |
| def normalize_image(image: torch.Tensor, band_mean: torch.Tensor, band_std: torch.Tensor) -> torch.Tensor: | |
| normalized_band_count = int(band_mean.shape[0]) | |
| if image.shape[0] < normalized_band_count: | |
| raise ValueError( | |
| "Image has {actual} bands but normalization expects {expected}.".format( | |
| actual=image.shape[0], | |
| expected=normalized_band_count, | |
| ) | |
| ) | |
| normalized = image.clone() | |
| normalized[:normalized_band_count] = ( | |
| image[:normalized_band_count] - band_mean | |
| ) / band_std | |
| return normalized | |
| def random_crop(image: torch.Tensor, mask: torch.Tensor, crop_size: int) -> Tuple[torch.Tensor, torch.Tensor]: | |
| _, height, width = image.shape | |
| if height < crop_size or width < crop_size: | |
| raise ValueError( | |
| "Crop size {crop} exceeds tile shape ({h}, {w})".format( | |
| crop=crop_size, h=height, w=width | |
| ) | |
| ) | |
| top = random.randint(0, height - crop_size) | |
| left = random.randint(0, width - crop_size) | |
| return image[:, top : top + crop_size, left : left + crop_size], mask[top : top + crop_size, left : left + crop_size] | |
| def apply_train_augmentations( | |
| image: torch.Tensor, | |
| mask: torch.Tensor, | |
| crop_size: int, | |
| n_s1_bands: int, | |
| rotate_max_deg: float, | |
| rotate_p: float, | |
| scale_jitter_min: float, | |
| scale_jitter_max: float, | |
| s1_gain_jitter: float, | |
| s1_bias_jitter: float, | |
| gaussian_noise_std: float, | |
| gaussian_noise_p: float, | |
| normalized_band_count: int, | |
| ) -> Tuple[torch.Tensor, torch.Tensor]: | |
| if random.random() < 0.5: | |
| image = torch.flip(image, dims=(-1,)) | |
| mask = torch.flip(mask, dims=(-1,)) | |
| rotations = random.randint(0, 3) | |
| if rotations: | |
| image = torch.rot90(image, rotations, dims=(-2, -1)) | |
| mask = torch.rot90(mask, rotations, dims=(-2, -1)) | |
| if scale_jitter_min != 1.0 or scale_jitter_max != 1.0: | |
| scale = random.uniform(scale_jitter_min, scale_jitter_max) | |
| scaled_size = max(32, int(round(crop_size * scale))) | |
| if scaled_size != crop_size: | |
| image = F.interpolate( | |
| image.unsqueeze(0), | |
| size=(scaled_size, scaled_size), | |
| mode="bilinear", | |
| align_corners=False, | |
| ).squeeze(0) | |
| mask = F.interpolate( | |
| mask.unsqueeze(0).unsqueeze(0).float(), | |
| size=(scaled_size, scaled_size), | |
| mode="nearest", | |
| ).squeeze(0).squeeze(0) | |
| if scaled_size > crop_size: | |
| image, mask = random_crop(image, mask, crop_size) | |
| else: | |
| pad_h = crop_size - scaled_size | |
| pad_w = crop_size - scaled_size | |
| top = random.randint(0, pad_h) | |
| left = random.randint(0, pad_w) | |
| bottom = pad_h - top | |
| right = pad_w - left | |
| image = F.pad(image, (left, right, top, bottom), mode="reflect") | |
| mask = F.pad(mask, (left, right, top, bottom), mode="constant", value=0) | |
| if rotate_max_deg > 0 and random.random() < rotate_p: | |
| angle = random.uniform(-rotate_max_deg, rotate_max_deg) | |
| image = TVF.rotate(image, angle=angle, interpolation=InterpolationMode.BILINEAR) | |
| mask = TVF.rotate( | |
| mask.unsqueeze(0).float(), | |
| angle=angle, | |
| interpolation=InterpolationMode.NEAREST, | |
| ).squeeze(0) | |
| if s1_gain_jitter > 0 or s1_bias_jitter > 0: | |
| gain = 1.0 + random.uniform(-s1_gain_jitter, s1_gain_jitter) | |
| bias = random.uniform(-s1_bias_jitter, s1_bias_jitter) | |
| image[:n_s1_bands] = (image[:n_s1_bands] * gain) + bias | |
| if gaussian_noise_std > 0 and random.random() < gaussian_noise_p: | |
| image[:normalized_band_count] = image[:normalized_band_count] + ( | |
| torch.randn_like(image[:normalized_band_count]) * gaussian_noise_std | |
| ) | |
| return image.float(), mask.long() | |
| def build_dataloaders( | |
| args: argparse.Namespace, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| device: torch.device, | |
| ) -> DataLoaders: | |
| train_dataset = S1AEFCropDataset( | |
| records=train_records, | |
| crop_size=args.crop_size, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| n_s1_bands=args.n_s1_bands, | |
| rotate_max_deg=args.rotate_max_deg, | |
| rotate_p=args.rotate_p, | |
| scale_jitter_min=args.scale_jitter_min, | |
| scale_jitter_max=args.scale_jitter_max, | |
| s1_gain_jitter=args.s1_gain_jitter, | |
| s1_bias_jitter=args.s1_bias_jitter, | |
| gaussian_noise_std=args.gaussian_noise_std, | |
| gaussian_noise_p=args.gaussian_noise_p, | |
| positive_crop_p=args.positive_crop_p, | |
| min_water_fraction=args.min_water_fraction, | |
| positive_crop_attempts=args.positive_crop_attempts, | |
| ) | |
| valid_dataset = S1AEFValidationPatchDataset( | |
| valid_records, | |
| crop_size=args.crop_size, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| ) | |
| if args.train_tile_index is not None: # full_coverage_v1 | |
| train_dataset = S1AEFTileDataset( | |
| read_tile_index(args.train_tile_index, train_records, "train"), crop_size=args.crop_size, band_mean=band_mean, | |
| band_std=band_std, n_s1_bands=args.n_s1_bands, rotate_max_deg=args.rotate_max_deg, rotate_p=args.rotate_p, | |
| scale_jitter_min=args.scale_jitter_min, scale_jitter_max=args.scale_jitter_max, s1_gain_jitter=args.s1_gain_jitter, | |
| s1_bias_jitter=args.s1_bias_jitter, gaussian_noise_std=args.gaussian_noise_std, gaussian_noise_p=args.gaussian_noise_p) | |
| print({"full_coverage": "train", "tiles": len(train_dataset), "chips": len(train_records), | |
| "tiles_by_source": dict(Counter(tile[0].source_id for tile in train_dataset.tiles))}) | |
| if args.valid_tile_index is not None: # full_coverage_v1 | |
| valid_dataset = S1AEFValidationTileDataset( | |
| read_tile_index(args.valid_tile_index, valid_records, "valid"), valid_records, crop_size=args.crop_size, | |
| band_mean=band_mean, band_std=band_std) | |
| print({"full_coverage": "valid", "tiles": len(valid_dataset), "chips": len(valid_records), | |
| "tiles_by_source": dict(Counter(valid_dataset.item_source_ids))}) | |
| pin_memory = device.type == "cuda" and not args.disable_pin_memory | |
| train_loader_kwargs = { | |
| "dataset": train_dataset, | |
| "bs": args.batch_size, | |
| "shuffle": True, | |
| "num_workers": args.num_workers, | |
| "pin_memory": pin_memory, | |
| "persistent_workers": args.num_workers > 0, | |
| "device": None, | |
| "wif": seed_worker, | |
| } | |
| if args.target_source_fraction is not None: | |
| target_indices = [ | |
| idx for idx, record in enumerate(train_records) if record.source_id == args.target_source_id | |
| ] | |
| other_indices = [ | |
| idx for idx, record in enumerate(train_records) if record.source_id != args.target_source_id | |
| ] | |
| if not target_indices or not other_indices: | |
| raise ValueError( | |
| "Source-aware sampling requires both target source {target} and non-target records. " | |
| "Observed counts: {counts}".format( | |
| target=args.target_source_id, | |
| counts=dict(Counter(record.source_id or "unspecified" for record in train_records)), | |
| ) | |
| ) | |
| target_weight = args.target_source_fraction / len(target_indices) | |
| other_weight = (1.0 - args.target_source_fraction) / len(other_indices) | |
| record_weights = [ | |
| target_weight if record.source_id == args.target_source_id else other_weight | |
| for record in train_records | |
| ] | |
| source_sampler_rng = random.Random(args.seed) | |
| def source_weighted_indices(): | |
| return source_sampler_rng.choices( | |
| list(range(len(record_weights))), | |
| weights=record_weights, | |
| k=len(record_weights), | |
| ) | |
| train_loader_kwargs["shuffle"] = False | |
| train_loader_kwargs["get_idxs"] = source_weighted_indices | |
| print( | |
| { | |
| "source_sampling": "weighted_with_replacement", | |
| "target_source_id": args.target_source_id, | |
| "target_source_fraction": args.target_source_fraction, | |
| "epoch_samples": len(train_dataset), | |
| "source_record_counts": dict( | |
| Counter(record.source_id or "unspecified" for record in train_records) | |
| ), | |
| } | |
| ) | |
| else: | |
| print( | |
| { | |
| "source_sampling": "natural_without_replacement", | |
| "epoch_samples": len(train_dataset), | |
| "source_record_counts": dict( | |
| Counter(record.source_id or "unspecified" for record in train_records) | |
| ), | |
| } | |
| ) | |
| train_dl = DataLoader(**train_loader_kwargs) | |
| valid_dl = DataLoader( | |
| valid_dataset, | |
| bs=1, | |
| shuffle=False, | |
| num_workers=args.num_workers, | |
| pin_memory=pin_memory, | |
| persistent_workers=args.num_workers > 0, | |
| device=None, | |
| wif=seed_worker, | |
| ) | |
| dls = DataLoaders(train_dl, valid_dl, path=args.artifact_dir, device=device) | |
| dls.c = len(args.codes) | |
| dls.vocab = args.codes | |
| return dls | |
| def format_lr(lr_max): | |
| if isinstance(lr_max, slice): | |
| return {"low": lr_max.start, "high": lr_max.stop} | |
| return lr_max | |
| class GlobalBestSaveModelCallback(SaveModelCallback): | |
| """full_coverage_v1: best checkpoint over all training stages (the stock callback restarts its best at each stage).""" | |
| def before_fit(self): | |
| super().before_fit() | |
| best = getattr(self.learn, "global_best_metric", None) | |
| if best is not None: | |
| self.best = best | |
| def after_epoch(self): | |
| super().after_epoch() | |
| if self.new_best: | |
| self.learn.global_best_metric = self.best | |
| self.learn.global_best_where = {"phase": getattr(self.learn, "training_phase", None), "epoch_in_phase": int(self.epoch), | |
| "value": float(self.best)} | |
| print({"global_best": self.learn.global_best_where}) | |
| def build_callbacks(args: argparse.Namespace, epoch_timer: Optional[EpochTimerCallback] = None) -> List[Callback]: | |
| callbacks: List[Callback] = [ | |
| GradientClip(args.grad_clip), | |
| NonFiniteLossCallback(), | |
| GlobalBestSaveModelCallback( | |
| monitor=args.selection_metric, | |
| comp=np.greater, | |
| min_delta=args.min_delta, | |
| fname=args.best_model_name, | |
| ), | |
| EarlyStoppingCallback( | |
| monitor=args.selection_metric, | |
| comp=np.greater, | |
| min_delta=args.min_delta, | |
| patience=args.patience, | |
| ), | |
| ] | |
| if args.grad_accum_steps > 1: | |
| callbacks.append(GradientAccumulation(n_acc=args.grad_accum_steps)) | |
| if epoch_timer is not None: | |
| callbacks.append(epoch_timer) | |
| return callbacks | |
| def recorder_history_to_df(learn: Learner, phase: str) -> pd.DataFrame: | |
| values = list(learn.recorder.values) | |
| if not values: | |
| return pd.DataFrame(columns=["phase"]) | |
| column_count = len(values[0]) | |
| metric_names = list(learn.recorder.metric_names[1:]) | |
| if metric_names and metric_names[-1] == "time" and len(metric_names) == column_count + 1: | |
| metric_names = metric_names[:-1] | |
| if len(metric_names) != column_count: | |
| metric_names = ["value_{idx}".format(idx=idx) for idx in range(column_count)] | |
| return pd.DataFrame(values, columns=metric_names).assign(phase=phase) | |
| def run_phase( | |
| learn: Learner, | |
| args: argparse.Namespace, | |
| phase: str, | |
| epochs: int, | |
| lr_max, | |
| epoch_timer: Optional[EpochTimerCallback] = None, | |
| ) -> pd.DataFrame: | |
| if epochs <= 0: | |
| return pd.DataFrame(columns=["phase"]) | |
| print({"phase": phase, "epochs": epochs, "lr_max": format_lr(lr_max)}) | |
| learn.training_phase = phase | |
| try: | |
| fit_kwargs = { | |
| "wd": args.weight_decay, | |
| "cbs": build_callbacks(args, epoch_timer=epoch_timer), | |
| "reset_opt": True, | |
| } | |
| if args.schedule_policy == "flat_cos": | |
| learn.fit_flat_cos(epochs, lr=lr_max, **fit_kwargs) | |
| else: | |
| learn.fit_one_cycle(epochs, lr_max=lr_max, **fit_kwargs) | |
| finally: | |
| learn.training_phase = None | |
| return recorder_history_to_df(learn, phase=phase) | |
| def build_schedule(args: argparse.Namespace) -> List[Tuple[str, int, object]]: | |
| if args.mode == "benchmark": | |
| return [("freeze", 2, args.warmup_lr)] | |
| if args.tuning_strategy != "staged": | |
| return [(args.tuning_strategy, args.strategy_epochs, args.strategy_lr)] | |
| schedule: List[Tuple[str, int, object]] = [ | |
| ("freeze", args.freeze_epochs, args.warmup_lr), | |
| ] | |
| bridge_epochs = min(args.partial_unfreeze_epochs, args.finetune_epochs) | |
| remaining_finetune_epochs = max(args.finetune_epochs - bridge_epochs, 0) | |
| if bridge_epochs > 0: | |
| schedule.append( | |
| ( | |
| "partial_unfreeze", | |
| bridge_epochs, | |
| slice(args.partial_unfreeze_lr / 20, args.partial_unfreeze_lr), | |
| ) | |
| ) | |
| if remaining_finetune_epochs > 0: | |
| schedule.append( | |
| ( | |
| "finetune", | |
| remaining_finetune_epochs, | |
| slice(args.unfreeze_lr / 50, args.unfreeze_lr), | |
| ) | |
| ) | |
| if args.finetune_tail_epochs > 0: | |
| schedule.append( | |
| ( | |
| "finetune_tail", | |
| args.finetune_tail_epochs, | |
| slice(args.finetune_tail_lr / 10, args.finetune_tail_lr), | |
| ) | |
| ) | |
| return schedule | |
| def set_requires_grad(module: nn.Module, trainable: bool) -> None: | |
| for parameter in module.parameters(): | |
| parameter.requires_grad = trainable | |
| def trainable_parameter_summary(model: nn.Module) -> Dict[str, int]: | |
| trainable = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad) | |
| total = sum(parameter.numel() for parameter in model.parameters()) | |
| return {"trainable_parameters": int(trainable), "total_parameters": int(total)} | |
| def resnet_unet_children(model: nn.Module) -> List[nn.Module]: | |
| if not isinstance(model, AEFBottleneckUNet): | |
| raise ValueError("Explicit tuning strategies currently require AEFBottleneckUNet.") | |
| children = list(model.unet.layers) if hasattr(model.unet, "layers") else list(model.unet.children()) | |
| if len(children) < 3: | |
| raise ValueError("Unexpected DynamicUnet layout for tuning strategy.") | |
| return children | |
| def enable_head_only(model: nn.Module) -> None: | |
| children = resnet_unet_children(model) | |
| parameterized_children = [module for module in children if any(True for _ in module.parameters())] | |
| if not parameterized_children: | |
| raise ValueError("DynamicUnet head did not expose trainable parameters.") | |
| head = parameterized_children[-1] | |
| set_requires_grad(head, True) | |
| def enable_decoder(model: nn.Module) -> None: | |
| children = resnet_unet_children(model) | |
| for module in children[1:]: | |
| set_requires_grad(module, True) | |
| def enable_aef_projection(model: nn.Module) -> None: | |
| if not isinstance(model, AEFBottleneckUNet): | |
| raise ValueError("AEF projection strategy requires AEFBottleneckUNet.") | |
| set_requires_grad(model.aef_proj, True) | |
| def enable_last_encoder_stage(model: nn.Module) -> None: | |
| encoder = resnet_unet_children(model)[0] | |
| encoder_children = list(encoder.children()) | |
| if not encoder_children: | |
| raise ValueError("DynamicUnet encoder did not expose child stages.") | |
| set_requires_grad(encoder_children[-1], True) | |
| def apply_explicit_tuning_strategy(learn: Learner, strategy: str) -> None: | |
| set_requires_grad(learn.model, False) | |
| if strategy == "head_only": | |
| enable_head_only(learn.model) | |
| elif strategy == "decoder_aef": | |
| enable_decoder(learn.model) | |
| enable_aef_projection(learn.model) | |
| elif strategy == "last_encoder_decoder_aef": | |
| enable_decoder(learn.model) | |
| enable_aef_projection(learn.model) | |
| enable_last_encoder_stage(learn.model) | |
| elif strategy == "full_low_lr": | |
| set_requires_grad(learn.model, True) | |
| else: | |
| raise ValueError("Unknown explicit tuning strategy: {}".format(strategy)) | |
| summary = trainable_parameter_summary(learn.model) | |
| if summary["trainable_parameters"] == 0: | |
| raise ValueError("Tuning strategy {} left no trainable parameters.".format(strategy)) | |
| print({"tuning_strategy_scope": strategy, **summary}) | |
| def apply_phase_freeze(learn: Learner, phase: str) -> None: | |
| if phase in {"head_only", "decoder_aef", "last_encoder_decoder_aef", "full_low_lr"}: | |
| apply_explicit_tuning_strategy(learn, phase) | |
| return | |
| if phase == "freeze": | |
| learn.freeze() | |
| elif phase == "partial_unfreeze": | |
| learn.freeze_to(-2) | |
| else: | |
| learn.unfreeze() | |
| def build_wrapped_splitter(base_splitter): | |
| def wrapped_splitter(model: AEFBottleneckUNet): | |
| groups = list(base_splitter(model.unet)) | |
| if not groups: | |
| return L([L(model.parameters())]) | |
| final_group = L(groups[-1]) + L(model.aef_proj.parameters()) | |
| return L(groups[:-1]) + L([final_group]) | |
| return wrapped_splitter | |
| def build_segformer_splitter(): | |
| def splitter(model: AEFBottleneckSegFormer): | |
| encoder = model.segformer.segformer.encoder | |
| groups: List[L] = [] | |
| for patch_embedding, blocks, norm in zip( | |
| encoder.patch_embeddings, | |
| encoder.block, | |
| encoder.layer_norm, | |
| ): | |
| groups.append( | |
| L(patch_embedding.parameters()) + L(blocks.parameters()) + L(norm.parameters()) | |
| ) | |
| groups.append(L(model.segformer.decode_head.parameters()) + L(model.aef_proj.parameters())) | |
| return L(groups) | |
| return splitter | |
| def build_loss(args: argparse.Namespace) -> nn.Module: | |
| loss_name = args.loss | |
| if loss_name == "focal_dice": | |
| return CombinedLoss() | |
| if loss_name == "ce_dice": | |
| return CrossEntropyDiceLoss() | |
| if loss_name == "lovasz": | |
| return LovaszSoftmaxLoss() | |
| if loss_name == "ce_soft_iou": | |
| weight = 1.0 if args.loss_aux_weight is None else args.loss_aux_weight | |
| return CrossEntropySoftIoULoss(aux_weight=weight) | |
| if loss_name == "focal_tversky": | |
| weight = 1.0 if args.loss_aux_weight is None else args.loss_aux_weight | |
| return FocalTverskyCELoss( | |
| alpha=args.tversky_alpha, | |
| beta=args.tversky_beta, | |
| gamma=args.tversky_gamma, | |
| aux_weight=weight, | |
| ) | |
| if loss_name == "ce_dice_boundary": | |
| weight = 0.2 if args.loss_aux_weight is None else args.loss_aux_weight | |
| return BoundaryAwareCEDiceLoss(aux_weight=weight) | |
| if loss_name == "ce_dice_cldice": | |
| weight = 0.2 if args.loss_aux_weight is None else args.loss_aux_weight | |
| return TopologyAwareCEDiceLoss( | |
| aux_weight=weight, | |
| skeleton_iterations=args.cldice_iterations, | |
| ) | |
| if loss_name == "sce_dice": | |
| weight = 0.1 if args.loss_aux_weight is None else args.loss_aux_weight | |
| return SymmetricCrossEntropyDiceLoss(aux_weight=weight) | |
| raise ValueError("Unknown loss: {name}".format(name=loss_name)) | |
| def build_optimizer(args: argparse.Namespace): | |
| if args.optimizer == "ranger": | |
| return ranger | |
| if args.optimizer == "adamw": | |
| return Adam | |
| raise ValueError("Unknown optimizer: {name}".format(name=args.optimizer)) | |
| def build_learner( | |
| args: argparse.Namespace, | |
| dls: DataLoaders, | |
| valid_records: Sequence[TripletRecord], | |
| ) -> Learner: | |
| n_prior_bands = 1 if using_prior(args) else 0 | |
| n_total_in = args.n_s1_bands + args.n_aef_bands + n_prior_bands | |
| n_model_in = ( | |
| args.n_s1_bands | |
| + (args.n_proj_bands if using_aef(args) else 0) | |
| + n_prior_bands | |
| ) | |
| loss_func = build_loss(args) | |
| opt_func = build_optimizer(args) | |
| metrics = [ | |
| MaskedJaccardCoeff(), | |
| MaskedDice(), | |
| WaterIoU(), | |
| SourceBalancedWaterIoU(getattr(dls.valid.dataset, "item_source_ids", [record.source_id for record in valid_records])), | |
| TargetSourceWaterIoU( | |
| getattr(dls.valid.dataset, "item_source_ids", [record.source_id for record in valid_records]), | |
| target_source_id=args.target_source_id, | |
| ), | |
| ] | |
| if args.model_family == "segformer": | |
| wrapped_model = AEFBottleneckSegFormer( | |
| model_name=SEGFORMER_MODEL_MAP[args.segformer_variant], | |
| num_labels=len(args.codes), | |
| n_s1=args.n_s1_bands, | |
| n_aef=args.n_aef_bands, | |
| n_proj=args.n_proj_bands, | |
| hf_cache_dir=args.hf_cache_dir, | |
| pretrained=args.pretrained, | |
| local_files_only=args.hf_local_files_only, | |
| ) | |
| wrapped_model = wrapped_model.to(dls.device) | |
| learn = Learner( | |
| dls, | |
| wrapped_model, | |
| loss_func=loss_func, | |
| metrics=metrics, | |
| opt_func=opt_func, | |
| splitter=build_segformer_splitter(), | |
| path=args.artifact_dir, | |
| model_dir="models", | |
| ) | |
| model_name = "AEFBottleneckSegFormer({variant})".format( | |
| variant=args.segformer_variant | |
| ) | |
| else: | |
| encoder_arches = { | |
| "resnet34_unet": resnet34, | |
| "resnet50_unet": resnet50, | |
| "resnet101_unet": resnet101, | |
| } | |
| encoder_arch = encoder_arches[args.model_family] | |
| learn = unet_learner( | |
| dls, | |
| encoder_arch, | |
| normalize=False, | |
| n_in=n_model_in, | |
| n_out=len(args.codes), | |
| metrics=metrics, | |
| loss_func=loss_func, | |
| opt_func=opt_func, | |
| act_cls=Mish, | |
| path=args.artifact_dir, | |
| model_dir="models", | |
| pretrained=args.pretrained, | |
| ) | |
| if using_aef(args): | |
| base_splitter = learn.splitter | |
| wrapped_model = AEFBottleneckUNet( | |
| unet_model=learn.model, | |
| n_s1=args.n_s1_bands, | |
| n_aef=args.n_aef_bands, | |
| n_proj=args.n_proj_bands, | |
| n_prior=n_prior_bands, | |
| prior_dropout_p=args.prior_dropout_p, | |
| aef_dropout_p=getattr(args, "aef_dropout_p", 0.0), | |
| ) | |
| learn.model = wrapped_model.to(dls.device) | |
| learn.splitter = build_wrapped_splitter(base_splitter) | |
| model_name = "AEFBottleneckUNet({family})".format(family=args.model_family) | |
| else: | |
| model_name = "S1UNet({family})".format(family=args.model_family) | |
| learn.model = learn.model.to(dls.device) | |
| if args.use_fp16 and torch.cuda.is_available(): | |
| learn = learn.to_fp16() | |
| model_summary = { | |
| "model": model_name, | |
| "dataloader_input_bands": n_total_in, | |
| "backbone_input_bands": n_model_in, | |
| "loss": args.loss, | |
| "loss_aux_weight": args.loss_aux_weight, | |
| "optimizer": args.optimizer, | |
| "schedule_policy": args.schedule_policy, | |
| "selection_metric": args.selection_metric, | |
| "tuning_strategy": args.tuning_strategy, | |
| "grad_accum_steps": args.grad_accum_steps, | |
| "use_permanent_prior": using_prior(args), | |
| "prior_dropout_p": args.prior_dropout_p, | |
| "aef_dropout_p": getattr(args, "aef_dropout_p", 0.0), | |
| "precision": "fp16" if args.use_fp16 and torch.cuda.is_available() else "fp32", | |
| "best_model_path": str(args.artifact_dir / "models" / "{name}.pth".format(name=args.best_model_name)), | |
| } | |
| if using_aef(args): | |
| model_summary["bottleneck"] = "{aef}->{proj}".format( | |
| aef=args.n_aef_bands, proj=args.n_proj_bands | |
| ) | |
| print( | |
| model_summary | |
| ) | |
| return learn | |
| def resolve_warm_start_checkpoint(args: argparse.Namespace) -> Optional[Path]: | |
| if args.warm_start_checkpoint is not None: | |
| return args.warm_start_checkpoint | |
| if args.warm_start_run_dir is None: | |
| return None | |
| return args.warm_start_run_dir / "models" / "{name}.pth".format(name=args.best_model_name) | |
| def resolve_band_stats_source(args: argparse.Namespace) -> Optional[Path]: | |
| if args.band_stats_path is not None: | |
| return args.band_stats_path | |
| if args.warm_start_run_dir is not None: | |
| return args.warm_start_run_dir / "band_stats.npz" | |
| return None | |
| def unwrap_state_dict(checkpoint: object) -> Dict[str, torch.Tensor]: | |
| if not isinstance(checkpoint, dict): | |
| raise ValueError("Warm-start checkpoint must contain a state dict mapping.") | |
| for key in ("model", "state_dict"): | |
| value = checkpoint.get(key) | |
| if isinstance(value, dict): | |
| checkpoint = value | |
| break | |
| if not checkpoint or not all(isinstance(key, str) for key in checkpoint.keys()): | |
| raise ValueError("Warm-start checkpoint does not expose string state-dict keys.") | |
| return checkpoint | |
| def validate_warm_start_projection( | |
| state_dict: Dict[str, torch.Tensor], | |
| model: nn.Module, | |
| args: argparse.Namespace, | |
| checkpoint_path: Path, | |
| ) -> None: | |
| if not using_aef(args): | |
| return | |
| key = "aef_proj.weight" | |
| if key not in state_dict: | |
| raise ValueError("Warm-start checkpoint is missing {key}: {path}".format(key=key, path=checkpoint_path)) | |
| checkpoint_shape = tuple(state_dict[key].shape) | |
| model_shape = tuple(model.state_dict()[key].shape) | |
| expected_shape = (args.n_proj_bands, args.n_aef_bands, 1, 1) | |
| if checkpoint_shape != expected_shape or model_shape != expected_shape: | |
| raise ValueError( | |
| "Warm-start AEF projection shape mismatch at {path}: checkpoint={ckpt}, model={model}, expected={expected}".format( | |
| path=checkpoint_path, | |
| ckpt=checkpoint_shape, | |
| model=model_shape, | |
| expected=expected_shape, | |
| ) | |
| ) | |
| def maybe_warm_start(learn: Learner, args: argparse.Namespace) -> Optional[Path]: | |
| checkpoint_path = resolve_warm_start_checkpoint(args) | |
| if checkpoint_path is None: | |
| return None | |
| if not checkpoint_path.exists(): | |
| raise FileNotFoundError("Warm-start checkpoint does not exist: {path}".format(path=checkpoint_path)) | |
| state_dict = unwrap_state_dict(torch.load(checkpoint_path, map_location="cpu")) | |
| validate_warm_start_projection(state_dict, learn.model, args, checkpoint_path) | |
| try: | |
| learn.model.load_state_dict(state_dict, strict=True) | |
| except RuntimeError as exc: | |
| raise RuntimeError("Warm-start checkpoint is incompatible with this model: {path}\n{error}".format(path=checkpoint_path, error=exc)) from exc | |
| save_json( | |
| args.artifact_dir / "warm_start.json", | |
| { | |
| "checkpoint": str(checkpoint_path.resolve()), | |
| "parent_run_dir": "" if args.warm_start_run_dir is None else str(args.warm_start_run_dir), | |
| "n_aef_bands": args.n_aef_bands, | |
| "n_proj_bands": args.n_proj_bands, | |
| }, | |
| ) | |
| print({"warm_start_checkpoint": str(checkpoint_path), "warm_start_strict": True}) | |
| return checkpoint_path | |
| def save_json(path: Path, payload: dict) -> None: | |
| path.write_text(json.dumps(payload, indent=2)) | |
| def inspect_bottleneck(model: nn.Module, artifact_dir: Path) -> dict: | |
| weight = model.aef_proj.weight.detach().cpu().squeeze(-1).squeeze(-1) | |
| abs_weight = weight.abs() | |
| top_k = 5 | |
| report = { | |
| "projection_shape": list(weight.shape), | |
| "weight_norm_per_proj_band": [float(v) for v in weight.norm(dim=1).tolist()], | |
| "weight_norm_per_aef_band": [float(v) for v in weight.norm(dim=0).tolist()], | |
| } | |
| for idx in range(weight.shape[0]): | |
| top_vals, top_idx = abs_weight[idx].topk(top_k) | |
| report["proj_band_{idx}_top{top}_aef_bands".format(idx=idx, top=top_k)] = top_idx.tolist() | |
| report["proj_band_{idx}_top{top}_weights".format(idx=idx, top=top_k)] = [ | |
| round(v, 6) for v in top_vals.tolist() | |
| ] | |
| np.save(artifact_dir / "aef_bottleneck_weights.npy", weight.numpy()) | |
| save_json(artifact_dir / "aef_bottleneck_report.json", report) | |
| print( | |
| { | |
| "bottleneck_weight_file": str(artifact_dir / "aef_bottleneck_weights.npy"), | |
| "bottleneck_report_file": str(artifact_dir / "aef_bottleneck_report.json"), | |
| } | |
| ) | |
| return report | |
| def get_tta_transforms(mode_count: int): | |
| transforms = [ | |
| ("identity", lambda x: x), | |
| ("flip_horizontal", lambda x: torch.flip(x, dims=(-1,))), | |
| ("flip_vertical", lambda x: torch.flip(x, dims=(-2,))), | |
| ("flip_both", lambda x: torch.flip(x, dims=(-2, -1))), | |
| ] | |
| return transforms[: max(1, min(mode_count, len(transforms)))] | |
| def is_cuda_oom(error: RuntimeError) -> bool: | |
| message = str(error).lower() | |
| return "out of memory" in message or "cuda error: out of memory" in message | |
| def sliding_positions(size: int, tile_size: int, overlap: int) -> List[int]: | |
| if size <= tile_size: | |
| return [0] | |
| stride = max(1, tile_size - overlap) | |
| positions = list(range(0, size - tile_size + 1, stride)) | |
| if positions[-1] != size - tile_size: | |
| positions.append(size - tile_size) | |
| return positions | |
| def predict_record_logits_windowed( | |
| model: nn.Module, | |
| record: TripletRecord, | |
| band_mean: torch.Tensor, | |
| band_std: torch.Tensor, | |
| tile_size: int, | |
| overlap: int, | |
| device: torch.device, | |
| use_tta: bool = False, | |
| tta_mode_count: int = 4, | |
| ) -> torch.Tensor: | |
| """Blend window predictions on CPU while only one input patch resides on the GPU.""" | |
| height, width = triplet_shape(record) | |
| y_positions = sliding_positions(height, tile_size, overlap) | |
| x_positions = sliding_positions(width, tile_size, overlap) | |
| logits_sum = None | |
| weight_sum = torch.zeros((height, width), dtype=torch.float32) | |
| transforms = get_tta_transforms(tta_mode_count) if use_tta else [("identity", lambda x: x)] | |
| for top in y_positions: | |
| for left in x_positions: | |
| window_height = min(tile_size, height - top) | |
| window_width = min(tile_size, width - left) | |
| image, _ = load_triplet_window( | |
| record, | |
| top=top, | |
| left=left, | |
| height=window_height, | |
| width=window_width, | |
| ) | |
| image = normalize_image(image, band_mean, band_std) | |
| xb = image.unsqueeze(0).to(device, non_blocking=False) | |
| patch_logits_sum = None | |
| for _, transform in transforms: | |
| augmented_x = transform(xb) | |
| augmented_logits = model(augmented_x).float() | |
| restored_logits = transform(augmented_logits) | |
| patch_logits_sum = ( | |
| restored_logits if patch_logits_sum is None else patch_logits_sum + restored_logits | |
| ) | |
| patch_logits = (patch_logits_sum / len(transforms))[0].detach().cpu() | |
| if logits_sum is None: | |
| logits_sum = torch.zeros( | |
| (patch_logits.shape[0], height, width), | |
| dtype=torch.float32, | |
| ) | |
| bottom = top + window_height | |
| right = left + window_width | |
| logits_sum[:, top:bottom, left:right] += patch_logits[:, :window_height, :window_width] | |
| weight_sum[top:bottom, left:right] += 1.0 | |
| del image, xb, patch_logits, patch_logits_sum | |
| if logits_sum is None: | |
| raise RuntimeError("No inference windows were produced for {name}".format(name=record.name)) | |
| return logits_sum / weight_sum.clamp_min(1.0).unsqueeze(0) | |
| def windowed_validation_context(learn: Learner) -> Tuple[List[TripletRecord], torch.Tensor, torch.Tensor]: | |
| dataset = learn.dls.valid.dataset | |
| if not hasattr(dataset, "records") or not hasattr(dataset, "band_mean") or not hasattr(dataset, "band_std"): | |
| raise TypeError("Validation dataset does not expose records and normalization statistics.") | |
| return list(dataset.records), dataset.band_mean, dataset.band_std | |
| def predict_logits_tiled(model: nn.Module, xb: torch.Tensor, tile_size: int, overlap: int) -> torch.Tensor: | |
| batch_size, _, height, width = xb.shape | |
| if batch_size != 1: | |
| raise ValueError("Tiled inference expects batch size 1, got {bs}".format(bs=batch_size)) | |
| y_positions = sliding_positions(height, tile_size, overlap) | |
| x_positions = sliding_positions(width, tile_size, overlap) | |
| logits_sum = None | |
| weight_sum = torch.zeros((1, 1, height, width), device=xb.device, dtype=torch.float32) | |
| for top in y_positions: | |
| for left in x_positions: | |
| bottom = min(top + tile_size, height) | |
| right = min(left + tile_size, width) | |
| tile = xb[:, :, top:bottom, left:right] | |
| logits = model(tile).float() | |
| if logits_sum is None: | |
| logits_sum = torch.zeros( | |
| (1, logits.shape[1], height, width), device=xb.device, dtype=torch.float32 | |
| ) | |
| logits_sum[:, :, top:bottom, left:right] += logits | |
| weight_sum[:, :, top:bottom, left:right] += 1.0 | |
| return logits_sum / weight_sum.clamp_min(1.0) | |
| def predict_logits_with_fallback( | |
| model: nn.Module, | |
| xb: torch.Tensor, | |
| tile_size: int, | |
| overlap: int, | |
| force_tiled: bool = False, | |
| ) -> Tuple[torch.Tensor, bool]: | |
| if force_tiled: | |
| return predict_logits_tiled(model, xb, tile_size=tile_size, overlap=overlap), True | |
| try: | |
| return model(xb).float(), False | |
| except RuntimeError as error: | |
| if not is_cuda_oom(error): | |
| raise | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| print({"eval_fallback": "switching to tiled inference"}) | |
| return predict_logits_tiled(model, xb, tile_size=tile_size, overlap=overlap), True | |
| def predict_water_probabilities( | |
| model: nn.Module, | |
| xb: torch.Tensor, | |
| tile_size: int, | |
| overlap: int, | |
| use_tta: bool = False, | |
| tta_mode_count: int = 4, | |
| force_tiled: bool = False, | |
| ) -> Tuple[torch.Tensor, bool]: | |
| if not use_tta: | |
| logits, used_tiled = predict_logits_with_fallback( | |
| model, xb, tile_size=tile_size, overlap=overlap, force_tiled=force_tiled | |
| ) | |
| return F.softmax(logits, dim=1)[:, 1], used_tiled | |
| transforms = get_tta_transforms(tta_mode_count) | |
| probs_sum = None | |
| used_tiled_any = force_tiled | |
| for _, transform in transforms: | |
| augmented_x = transform(xb) | |
| logits, used_tiled = predict_logits_with_fallback( | |
| model, augmented_x, tile_size=tile_size, overlap=overlap, force_tiled=used_tiled_any | |
| ) | |
| augmented_probs = F.softmax(logits, dim=1)[:, 1:2] | |
| restored_probs = transform(augmented_probs) | |
| probs_sum = restored_probs if probs_sum is None else probs_sum + restored_probs | |
| used_tiled_any = used_tiled_any or used_tiled | |
| return (probs_sum / len(transforms))[:, 0], used_tiled_any | |
| def metrics_from_binary_confusion(tp: int, fp: int, tn: int, fn: int) -> dict: | |
| precision = tp / max(tp + fp, 1) | |
| recall = tp / max(tp + fn, 1) | |
| water_iou = tp / max(tp + fp + fn, 1) | |
| dice = (2 * tp) / max((2 * tp) + fp + fn, 1) | |
| accuracy = (tp + tn) / max(tp + tn + fp + fn, 1) | |
| other_iou = tn / max(tn + fp + fn, 1) | |
| other_dice = (2 * tn) / max((2 * tn) + fp + fn, 1) | |
| mean_iou = (other_iou + water_iou) / 2.0 | |
| mean_dice = (other_dice + dice) / 2.0 | |
| return { | |
| "precision": float(precision), | |
| "recall": float(recall), | |
| "water_iou": float(water_iou), | |
| "dice": float(mean_dice), | |
| "accuracy": float(accuracy), | |
| "jaccard": float(mean_iou), | |
| } | |
| def validate_model( | |
| learn: Learner, | |
| tile_size: int, | |
| overlap: int, | |
| force_tiled: bool = False, | |
| max_valid_batches: Optional[int] = None, | |
| ) -> dict: | |
| device = next(learn.model.parameters()).device | |
| loss_func = learn.loss_func | |
| records, band_mean, band_std = windowed_validation_context(learn) | |
| total_loss = 0.0 | |
| batch_count = 0 | |
| tp = fp = tn = fn = 0 | |
| use_tiled = False | |
| was_training = learn.model.training | |
| learn.model.eval() | |
| with torch.inference_mode(): | |
| for batch_idx, record in enumerate(records): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| logits = predict_record_logits_windowed( | |
| learn.model, | |
| record, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| device=device, | |
| ) | |
| yb = open_mask_binary(record.label_path).long() | |
| batch_loss = loss_func( | |
| logits.unsqueeze(0).to(device), | |
| yb.unsqueeze(0).to(device), | |
| ).detach().float().item() | |
| pred = logits.argmax(dim=0) | |
| observed = yb != IGNORE_INDEX | |
| total_loss += batch_loss | |
| batch_count += 1 | |
| tp += int(((pred == 1) & (yb == 1) & observed).sum().item()) | |
| fp += int(((pred == 1) & (yb != 1) & observed).sum().item()) | |
| tn += int(((pred != 1) & (yb != 1) & observed).sum().item()) | |
| fn += int(((pred != 1) & (yb == 1) & observed).sum().item()) | |
| use_tiled = True | |
| del logits, yb, pred, observed | |
| learn.model.train(was_training) | |
| metric_summary = metrics_from_binary_confusion(tp=tp, fp=fp, tn=tn, fn=fn) | |
| metric_summary.update( | |
| { | |
| "loss": float(total_loss / max(batch_count, 1)), | |
| "used_tiled_inference": use_tiled, | |
| "tp": tp, | |
| "fp": fp, | |
| "tn": tn, | |
| "fn": fn, | |
| } | |
| ) | |
| return metric_summary | |
| def evaluate_threshold_sweep( | |
| learn: Learner, | |
| thresholds: Sequence[float], | |
| tile_size: int, | |
| overlap: int, | |
| use_tta: bool = False, | |
| tta_mode_count: int = 4, | |
| force_tiled: bool = False, | |
| max_valid_batches: Optional[int] = None, | |
| ) -> pd.DataFrame: | |
| device = next(learn.model.parameters()).device | |
| records, band_mean, band_std = windowed_validation_context(learn) | |
| counts = {name: torch.zeros(len(thresholds), dtype=torch.long) for name in ["tp", "fp", "tn", "fn"]} | |
| eval_mode = "tta" if use_tta else "plain" | |
| if force_tiled: | |
| eval_mode = "{mode}_tiled".format(mode=eval_mode) | |
| use_tiled = force_tiled | |
| was_training = learn.model.training | |
| learn.model.eval() | |
| with torch.inference_mode(): | |
| for batch_idx, record in enumerate(records): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| logits = predict_record_logits_windowed( | |
| learn.model, | |
| record, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| device=device, | |
| use_tta=use_tta, | |
| tta_mode_count=tta_mode_count, | |
| ) | |
| probs = F.softmax(logits, dim=0)[1] | |
| labels = open_mask_binary(record.label_path) | |
| observed = labels != IGNORE_INDEX | |
| targ = labels == 1 | |
| for threshold_idx, threshold in enumerate(thresholds): | |
| pred = probs >= float(threshold) | |
| counts["tp"][threshold_idx] += (pred & targ & observed).sum() | |
| counts["fp"][threshold_idx] += (pred & ~targ & observed).sum() | |
| counts["tn"][threshold_idx] += ((~pred) & ~targ & observed).sum() | |
| counts["fn"][threshold_idx] += ((~pred) & targ & observed).sum() | |
| use_tiled = True | |
| del logits, probs, targ, labels, observed | |
| learn.model.train(was_training) | |
| records: List[dict] = [] | |
| for idx, threshold in enumerate(thresholds): | |
| tp = int(counts["tp"][idx].item()) | |
| fp = int(counts["fp"][idx].item()) | |
| tn = int(counts["tn"][idx].item()) | |
| fn = int(counts["fn"][idx].item()) | |
| metrics = metrics_from_binary_confusion(tp=tp, fp=fp, tn=tn, fn=fn) | |
| records.append( | |
| { | |
| "eval_mode": eval_mode, | |
| "threshold": float(threshold), | |
| "used_tiled_inference": use_tiled, | |
| **metrics, | |
| "tp": tp, | |
| "fp": fp, | |
| "tn": tn, | |
| "fn": fn, | |
| } | |
| ) | |
| return pd.DataFrame(records) | |
| def evaluate_tiles_at_threshold( | |
| learn: Learner, | |
| valid_records: Sequence[TripletRecord], | |
| threshold: float, | |
| tile_size: int, | |
| overlap: int, | |
| use_tta: bool = False, | |
| tta_mode_count: int = 4, | |
| force_tiled: bool = False, | |
| max_valid_batches: Optional[int] = None, | |
| ) -> pd.DataFrame: | |
| device = next(learn.model.parameters()).device | |
| _, band_mean, band_std = windowed_validation_context(learn) | |
| rows: List[dict] = [] | |
| eval_mode = "tta" if use_tta else "plain" | |
| if force_tiled: | |
| eval_mode = "{mode}_tiled".format(mode=eval_mode) | |
| was_training = learn.model.training | |
| learn.model.eval() | |
| with torch.inference_mode(): | |
| for batch_idx, record in enumerate(valid_records): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| logits = predict_record_logits_windowed( | |
| learn.model, | |
| record, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| device=device, | |
| use_tta=use_tta, | |
| tta_mode_count=tta_mode_count, | |
| ) | |
| probs = F.softmax(logits, dim=0)[1] | |
| yb = open_mask_binary(record.label_path).long() | |
| pred = probs >= threshold | |
| observed = yb != IGNORE_INDEX | |
| targ = yb == 1 | |
| tp = int((pred & targ & observed).sum().item()) | |
| fp = int((pred & ~targ & observed).sum().item()) | |
| tn = int(((~pred) & ~targ & observed).sum().item()) | |
| fn = int(((~pred) & targ & observed).sum().item()) | |
| metrics = metrics_from_binary_confusion(tp=tp, fp=fp, tn=tn, fn=fn) | |
| rows.append( | |
| { | |
| "tile_name": record.name, | |
| "source_id": record.source_id, | |
| "eval_mode": eval_mode, | |
| "threshold": float(threshold), | |
| "used_tiled_inference": True, | |
| "water_pixels": int(targ.sum().item()), | |
| **metrics, | |
| "tp": tp, | |
| "fp": fp, | |
| "tn": tn, | |
| "fn": fn, | |
| } | |
| ) | |
| del logits, probs, yb, pred, targ, observed | |
| learn.model.train(was_training) | |
| return pd.DataFrame(rows) | |
| def summarize_threshold_results(threshold_df: pd.DataFrame) -> dict: | |
| summary: dict = {} | |
| if threshold_df.empty: | |
| return summary | |
| for eval_mode in threshold_df["eval_mode"].unique(): | |
| mode_df = threshold_df[threshold_df["eval_mode"] == eval_mode].sort_values( | |
| ["water_iou", "dice", "threshold"], | |
| ascending=[False, False, True], | |
| ) | |
| best = mode_df.iloc[0] | |
| summary.update( | |
| { | |
| "{mode}_best_threshold".format(mode=eval_mode): round(float(best["threshold"]), 4), | |
| "{mode}_best_water_iou".format(mode=eval_mode): round(float(best["water_iou"]), 6), | |
| "{mode}_best_dice".format(mode=eval_mode): round(float(best["dice"]), 6), | |
| "{mode}_best_precision".format(mode=eval_mode): round(float(best["precision"]), 6), | |
| "{mode}_best_recall".format(mode=eval_mode): round(float(best["recall"]), 6), | |
| } | |
| ) | |
| best_row = threshold_df.sort_values(["water_iou", "dice", "threshold"], ascending=[False, False, True]).iloc[0] | |
| summary.update( | |
| { | |
| "best_eval_mode": str(best_row["eval_mode"]), | |
| "best_eval_threshold": round(float(best_row["threshold"]), 4), | |
| "best_eval_water_iou": round(float(best_row["water_iou"]), 6), | |
| "best_eval_dice": round(float(best_row["dice"]), 6), | |
| "best_eval_precision": round(float(best_row["precision"]), 6), | |
| "best_eval_recall": round(float(best_row["recall"]), 6), | |
| } | |
| ) | |
| return summary | |
| def summarize_worst_tiles(tile_df: pd.DataFrame, worst_tile_count: int) -> pd.DataFrame: | |
| if tile_df.empty: | |
| return tile_df | |
| return tile_df.sort_values( | |
| ["water_iou", "precision", "recall", "tile_name"], | |
| ascending=[True, True, True, True], | |
| ).head(worst_tile_count) | |
| def summarize_source_metrics(tile_df: pd.DataFrame) -> Dict[str, dict]: | |
| if tile_df.empty or "source_id" not in tile_df.columns: | |
| return {} | |
| summaries: Dict[str, dict] = {} | |
| for source_id, source_df in tile_df.groupby("source_id", dropna=False): | |
| source_name = str(source_id) if str(source_id) else "unspecified" | |
| tp = int(source_df["tp"].sum()) | |
| fp = int(source_df["fp"].sum()) | |
| tn = int(source_df["tn"].sum()) | |
| fn = int(source_df["fn"].sum()) | |
| metrics = metrics_from_binary_confusion(tp=tp, fp=fp, tn=tn, fn=fn) | |
| metrics["water_f1"] = float((2 * tp) / max((2 * tp) + fp + fn, 1)) | |
| summaries[source_name] = { | |
| "tile_count": int(len(source_df)), | |
| **{key: round(float(value), 6) for key, value in metrics.items()}, | |
| "tp": tp, | |
| "fp": fp, | |
| "tn": tn, | |
| "fn": fn, | |
| } | |
| return summaries | |
| def run_training_schedule( | |
| learn: Learner, | |
| args: argparse.Namespace, | |
| schedule: Sequence[Tuple[str, int, object]], | |
| epoch_timer: Optional[EpochTimerCallback] = None, | |
| ) -> pd.DataFrame: | |
| training_history = [] | |
| for phase_name, epochs, lr_max in schedule: | |
| apply_phase_freeze(learn, phase_name) | |
| training_history.append( | |
| run_phase( | |
| learn=learn, | |
| args=args, | |
| phase=phase_name, | |
| epochs=epochs, | |
| lr_max=lr_max, | |
| epoch_timer=epoch_timer, | |
| ) | |
| ) | |
| if epochs > 0: | |
| learn.load(args.best_model_name, with_opt=False) | |
| save_json(args.artifact_dir / "global_best.json", getattr(learn, "global_best_where", {})) # full_coverage_v1 | |
| return pd.concat(training_history, ignore_index=True) if training_history else pd.DataFrame(columns=["phase"]) | |
| def effective_epoch_count(args: argparse.Namespace) -> int: | |
| return int(args.freeze_epochs + args.partial_unfreeze_epochs + args.finetune_epochs + args.finetune_tail_epochs) | |
| def probe_batch_size( | |
| args: argparse.Namespace, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| device: torch.device, | |
| ) -> dict: | |
| if device.type != "cuda": | |
| return { | |
| "selected_batch_size": args.batch_size, | |
| "probe_results": [], | |
| "probe_skipped": "CUDA unavailable", | |
| } | |
| results: List[dict] = [] | |
| selected_batch_size = None | |
| train_probe_records = list(train_records[: max(max(args.probe_batch_sizes), 16)]) | |
| valid_probe_records = list(valid_records[:1]) | |
| for candidate in args.probe_batch_sizes: | |
| candidate_args = copy_module.deepcopy(args) | |
| candidate_args.batch_size = candidate | |
| candidate_args.num_workers = min(candidate_args.num_workers, 2) | |
| try: | |
| dls = build_dataloaders( | |
| candidate_args, | |
| train_records=train_probe_records, | |
| valid_records=valid_probe_records, | |
| band_mean=band_mean, | |
| band_std=band_std, | |
| device=device, | |
| ) | |
| learn = build_learner(candidate_args, dls, valid_records=valid_probe_records) | |
| learn.model.train() | |
| optimizer = torch.optim.AdamW(learn.model.parameters(), lr=args.warmup_lr) | |
| xb, yb = dls.one_batch() | |
| optimizer.zero_grad(set_to_none=True) | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| torch.cuda.reset_peak_memory_stats(device) | |
| start = time.perf_counter() | |
| if args.use_fp16: | |
| scaler = torch.cuda.amp.GradScaler(enabled=True) | |
| with torch.cuda.amp.autocast(enabled=True): | |
| pred = learn.model(xb) | |
| loss = learn.loss_func(pred, yb) | |
| scaler.scale(loss).backward() | |
| scaler.step(optimizer) | |
| scaler.update() | |
| else: | |
| pred = learn.model(xb) | |
| loss = learn.loss_func(pred, yb) | |
| loss.backward() | |
| optimizer.step() | |
| torch.cuda.synchronize(device) | |
| elapsed = time.perf_counter() - start | |
| peak_mem_gb = torch.cuda.max_memory_allocated(device) / (1024**3) | |
| result = { | |
| "batch_size": candidate, | |
| "success": True, | |
| "loss": float(loss.detach().float().item()), | |
| "step_seconds": round(elapsed, 4), | |
| "max_memory_gb": round(peak_mem_gb, 4), | |
| } | |
| results.append(result) | |
| selected_batch_size = candidate | |
| del pred, loss, xb, yb, learn, dls, optimizer | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| break | |
| except RuntimeError as error: | |
| if not is_cuda_oom(error): | |
| raise | |
| results.append({"batch_size": candidate, "success": False, "error": "cuda_oom"}) | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| if selected_batch_size is None: | |
| raise RuntimeError("Batch size probe failed for all candidates: {candidates}".format(candidates=args.probe_batch_sizes)) | |
| probe_summary = {"selected_batch_size": selected_batch_size, "probe_results": results} | |
| print(probe_summary) | |
| return probe_summary | |
| def maybe_override_batch_size_from_summary(args: argparse.Namespace) -> None: | |
| if args.benchmark_summary_path is None: | |
| return | |
| if not args.benchmark_summary_path.exists(): | |
| raise FileNotFoundError( | |
| "Benchmark summary path does not exist: {path}".format(path=args.benchmark_summary_path) | |
| ) | |
| summary = json.loads(args.benchmark_summary_path.read_text()) | |
| selected = summary.get("selected_batch_size") | |
| if not selected: | |
| raise ValueError( | |
| "Benchmark summary at {path} is missing selected_batch_size".format(path=args.benchmark_summary_path) | |
| ) | |
| args.batch_size = int(selected) | |
| print( | |
| { | |
| "batch_size_override": args.batch_size, | |
| "benchmark_summary_path": str(args.benchmark_summary_path), | |
| } | |
| ) | |
| def build_audit_summary( | |
| args: argparse.Namespace, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| dls: DataLoaders, | |
| ) -> dict: | |
| sample_image, sample_mask = load_center_triplet_window(train_records[0], args.crop_size) | |
| batch_images, batch_masks = dls.one_batch() | |
| return { | |
| "paired_tiles": len(train_records) + len(valid_records), | |
| "train_tiles": len(train_records), | |
| "valid_tiles": len(valid_records), | |
| "sample_image_shape": tuple(sample_image.shape), | |
| "sample_mask_shape": tuple(sample_mask.shape), | |
| "sample_mask_values": sorted(torch.unique(sample_mask).tolist()), | |
| "band_mean_s1": band_mean[:3].round(6).tolist(), | |
| "band_std_s1": band_std[:3].round(6).tolist(), | |
| "band_mean_aef_first5": band_mean[3:8].round(6).tolist() if using_aef(args) else [], | |
| "band_std_aef_first5": band_std[3:8].round(6).tolist() if using_aef(args) else [], | |
| "train_batch_images": tuple(batch_images.shape), | |
| "train_batch_masks": tuple(batch_masks.shape), | |
| "model_family": args.model_family, | |
| "segformer_variant": args.segformer_variant if args.model_family == "segformer" else None, | |
| "loss": args.loss, | |
| "loss_aux_weight": args.loss_aux_weight, | |
| "optimizer": args.optimizer, | |
| "schedule_policy": args.schedule_policy, | |
| "tuning_strategy": args.tuning_strategy, | |
| "augmentation_rotate_max_deg": getattr(dls.train.dataset, "rotate_max_deg", None), | |
| "augmentation_scale_jitter": [ | |
| getattr(dls.train.dataset, "scale_jitter_min", None), | |
| getattr(dls.train.dataset, "scale_jitter_max", None), | |
| ], | |
| "augmentation_s1_gain_jitter": getattr(dls.train.dataset, "s1_gain_jitter", None), | |
| "augmentation_s1_bias_jitter": getattr(dls.train.dataset, "s1_bias_jitter", None), | |
| "augmentation_gaussian_noise_std": getattr(dls.train.dataset, "gaussian_noise_std", None), | |
| "positive_crop_p": getattr(dls.train.dataset, "positive_crop_p", None), | |
| "min_water_fraction": getattr(dls.train.dataset, "min_water_fraction", None), | |
| "positive_crop_attempts": getattr(dls.train.dataset, "positive_crop_attempts", None), | |
| "target_source_id": args.target_source_id, | |
| "target_source_fraction": args.target_source_fraction, | |
| "use_permanent_prior": using_prior(args), | |
| "prior_dropout_p": args.prior_dropout_p, | |
| "aef_dropout_p": getattr(args, "aef_dropout_p", 0.0), | |
| } | |
| def run_benchmark_mode( | |
| args: argparse.Namespace, | |
| device: torch.device, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| ) -> dict: | |
| planned_full_epochs = effective_epoch_count(args) | |
| probe_summary = probe_batch_size(args, train_records, valid_records, band_mean, band_std, device) | |
| args.batch_size = int(probe_summary["selected_batch_size"]) | |
| if args.max_benchmark_batch_size is not None: | |
| args.batch_size = min(args.batch_size, args.max_benchmark_batch_size) | |
| probe_summary["selected_batch_size"] = args.batch_size | |
| train_records = subset_records(train_records, args.max_train_batches, args.batch_size) | |
| valid_records = subset_records(valid_records, args.max_valid_batches, 1) | |
| dls = build_dataloaders(args, train_records, valid_records, band_mean, band_std, device) | |
| print(build_audit_summary(args, train_records, valid_records, band_mean, band_std, dls)) | |
| learn = build_learner(args, dls, valid_records=valid_records) | |
| maybe_warm_start(learn, args) | |
| epoch_timer = EpochTimerCallback() | |
| history_df = run_training_schedule( | |
| learn=learn, | |
| args=args, | |
| schedule=build_schedule(args), | |
| epoch_timer=epoch_timer, | |
| ) | |
| history_path = args.artifact_dir / args.history_csv | |
| history_df.to_csv(history_path, index=False) | |
| validation = validate_model( | |
| learn, | |
| tile_size=args.crop_size, | |
| overlap=args.tile_overlap, | |
| force_tiled=args.force_tiled_validation, | |
| max_valid_batches=args.max_valid_batches, | |
| ) | |
| seconds_per_epoch = sum(epoch_timer.epoch_times) / max(len(epoch_timer.epoch_times), 1) | |
| samples_per_second = ( | |
| (len(train_records) / seconds_per_epoch) if seconds_per_epoch > 0 else None | |
| ) | |
| benchmark_summary = { | |
| "mode": "benchmark", | |
| "model_family": args.model_family, | |
| "segformer_variant": args.segformer_variant if args.model_family == "segformer" else None, | |
| "selected_batch_size": args.batch_size, | |
| "probe_results": probe_summary["probe_results"], | |
| "epoch_times_seconds": [round(v, 4) for v in epoch_timer.epoch_times], | |
| "avg_seconds_per_epoch": round(seconds_per_epoch, 4), | |
| "samples_per_second": None if samples_per_second is None else round(samples_per_second, 4), | |
| "projected_full_train_seconds": round(seconds_per_epoch * planned_full_epochs, 1), | |
| "projected_full_train_hours": round((seconds_per_epoch * planned_full_epochs) / 3600.0, 3), | |
| "train_records_used": len(train_records), | |
| "valid_records_used": len(valid_records), | |
| "validation": validation, | |
| "history_csv": str(history_path), | |
| "loss": args.loss, | |
| "selection_metric": args.selection_metric, | |
| "tuning_strategy": args.tuning_strategy, | |
| "force_tiled_validation": bool(args.force_tiled_validation), | |
| "split_seed": args.seed if args.split_seed is None else args.split_seed, | |
| "train_subset_seed": args.seed if args.train_subset_seed is None else args.train_subset_seed, | |
| "max_train_records": args.max_train_records, | |
| "target_source_id": args.target_source_id, | |
| "target_source_fraction": args.target_source_fraction, | |
| "use_permanent_prior": using_prior(args), | |
| "prior_dropout_p": args.prior_dropout_p, | |
| "aef_dropout_p": getattr(args, "aef_dropout_p", 0.0), | |
| } | |
| save_json(args.artifact_dir / args.benchmark_summary_json, benchmark_summary) | |
| print(benchmark_summary) | |
| return benchmark_summary | |
| def run_train_mode( | |
| args: argparse.Namespace, | |
| device: torch.device, | |
| train_records: Sequence[TripletRecord], | |
| valid_records: Sequence[TripletRecord], | |
| band_mean: np.ndarray, | |
| band_std: np.ndarray, | |
| ) -> dict: | |
| maybe_override_batch_size_from_summary(args) | |
| train_records = subset_records(train_records, args.max_train_batches, args.batch_size) | |
| valid_records = subset_records(valid_records, args.max_valid_batches, 1) | |
| dls = build_dataloaders(args, train_records, valid_records, band_mean, band_std, device) | |
| print(build_audit_summary(args, train_records, valid_records, band_mean, band_std, dls)) | |
| learn = build_learner(args, dls, valid_records=valid_records) | |
| maybe_warm_start(learn, args) | |
| history_df = run_training_schedule(learn=learn, args=args, schedule=build_schedule(args)) | |
| history_path = args.artifact_dir / args.history_csv | |
| history_df.to_csv(history_path, index=False) | |
| print(history_df.tail()) | |
| if using_aef(args): | |
| bottleneck_report = inspect_bottleneck(learn.model, args.artifact_dir) | |
| print({"bottleneck_weight_norms": bottleneck_report["weight_norm_per_proj_band"]}) | |
| validation = validate_model( | |
| learn, | |
| tile_size=args.crop_size, | |
| overlap=args.tile_overlap, | |
| force_tiled=args.force_tiled_validation, | |
| max_valid_batches=args.max_valid_batches, | |
| ) | |
| threshold_results = [ | |
| evaluate_threshold_sweep( | |
| learn, | |
| thresholds=args.eval_thresholds, | |
| tile_size=args.crop_size, | |
| overlap=args.tile_overlap, | |
| use_tta=False, | |
| force_tiled=args.force_tiled_validation, | |
| max_valid_batches=args.max_valid_batches, | |
| ) | |
| ] | |
| if args.use_tta_eval: | |
| threshold_results.append( | |
| evaluate_threshold_sweep( | |
| learn, | |
| thresholds=args.eval_thresholds, | |
| tile_size=args.crop_size, | |
| overlap=args.tile_overlap, | |
| use_tta=True, | |
| tta_mode_count=args.tta_eval_mode_count, | |
| force_tiled=args.force_tiled_validation, | |
| max_valid_batches=args.max_valid_batches, | |
| ) | |
| ) | |
| threshold_df = pd.concat(threshold_results, ignore_index=True) | |
| threshold_path = args.artifact_dir / args.threshold_sweep_csv | |
| threshold_df.to_csv(threshold_path, index=False) | |
| print( | |
| threshold_df.sort_values( | |
| ["eval_mode", "water_iou", "precision", "threshold"], | |
| ascending=[True, False, False, True], | |
| ).groupby("eval_mode").head(5) | |
| ) | |
| best_eval_row = threshold_df.sort_values( | |
| ["water_iou", "dice", "threshold"], | |
| ascending=[False, False, True], | |
| ).iloc[0] | |
| best_eval_mode = str(best_eval_row["eval_mode"]) | |
| tile_metrics_df = evaluate_tiles_at_threshold( | |
| learn, | |
| valid_records=valid_records, | |
| threshold=float(best_eval_row["threshold"]), | |
| tile_size=args.crop_size, | |
| overlap=args.tile_overlap, | |
| use_tta=best_eval_mode.startswith("tta"), | |
| tta_mode_count=args.tta_eval_mode_count, | |
| force_tiled=best_eval_mode.endswith("_tiled"), | |
| max_valid_batches=args.max_valid_batches, | |
| ) | |
| tile_metrics_path = args.artifact_dir / args.tile_metrics_csv | |
| tile_metrics_df.to_csv(tile_metrics_path, index=False) | |
| worst_tiles_df = summarize_worst_tiles(tile_metrics_df, worst_tile_count=args.worst_tile_count) | |
| worst_tiles_path = args.artifact_dir / args.worst_tiles_csv | |
| worst_tiles_df.to_csv(worst_tiles_path, index=False) | |
| summary = { | |
| "model_family": args.model_family, | |
| "segformer_variant": args.segformer_variant if args.model_family == "segformer" else None, | |
| "final_valid_loss": round(float(validation["loss"]), 6), | |
| "final_valid_jaccard": round(float(validation["jaccard"]), 6), | |
| "final_valid_dice": round(float(validation["dice"]), 6), | |
| "final_valid_water_iou": round(float(validation["water_iou"]), 6), | |
| "final_valid_precision": round(float(validation["precision"]), 6), | |
| "final_valid_recall": round(float(validation["recall"]), 6), | |
| "used_tiled_validation": bool(validation["used_tiled_inference"]), | |
| "best_model_path": str(args.artifact_dir / "models" / "{name}.pth".format(name=args.best_model_name)), | |
| "history_csv": str(history_path), | |
| "threshold_sweep_csv": str(threshold_path), | |
| "tile_metrics_csv": str(tile_metrics_path), | |
| "worst_tiles_csv": str(worst_tiles_path), | |
| "loss": args.loss, | |
| "selection_metric": args.selection_metric, | |
| "tuning_strategy": args.tuning_strategy, | |
| "force_tiled_validation": bool(args.force_tiled_validation), | |
| "split_seed": args.seed if args.split_seed is None else args.split_seed, | |
| "train_subset_seed": args.seed if args.train_subset_seed is None else args.train_subset_seed, | |
| "max_train_records": args.max_train_records, | |
| "target_source_id": args.target_source_id, | |
| "target_source_fraction": args.target_source_fraction, | |
| "use_permanent_prior": using_prior(args), | |
| "prior_dropout_p": args.prior_dropout_p, | |
| "aef_dropout_p": getattr(args, "aef_dropout_p", 0.0), | |
| "train_records_used": len(train_records), | |
| "valid_records_used": len(valid_records), | |
| "source_metrics_at_best_threshold": summarize_source_metrics(tile_metrics_df), | |
| } | |
| if using_aef(args): | |
| summary["bottleneck_proj_shape"] = "{aef}->{proj}".format( | |
| aef=args.n_aef_bands, proj=args.n_proj_bands | |
| ) | |
| else: | |
| summary["input_bands"] = args.n_s1_bands | |
| summary.update(summarize_threshold_results(threshold_df)) | |
| if not worst_tiles_df.empty: | |
| summary["worst_tile_examples"] = worst_tiles_df[ | |
| ["tile_name", "water_iou", "precision", "recall"] | |
| ].round(6).to_dict(orient="records") | |
| save_json(args.artifact_dir / args.eval_summary_json, summary) | |
| print(summary) | |
| return summary | |
| def main() -> None: | |
| args = parse_args() | |
| device = setup_environment(args) | |
| if args.triplet_manifest is not None: | |
| triplets = collect_triplets_from_manifest( | |
| args.triplet_manifest, | |
| args.triplet_sample_id, | |
| expect_aef=using_aef(args), | |
| expect_prior=using_prior(args), | |
| ) | |
| else: | |
| triplets = collect_triplets( | |
| args.input_dir, | |
| args.aef_dir if using_aef(args) else None, | |
| args.label_dir, | |
| ) | |
| split_seed = args.seed if args.split_seed is None else args.split_seed | |
| train_subset_seed = args.seed if args.train_subset_seed is None else args.train_subset_seed | |
| if args.fixed_split_manifest is not None: | |
| full_train_records, valid_records = split_triplets_from_manifest( | |
| triplets, args.fixed_split_manifest | |
| ) | |
| split_protocol = "fixed_manifest" | |
| else: | |
| full_train_records, valid_records = split_triplets( | |
| triplets, valid_pct=args.valid_pct, seed=split_seed | |
| ) | |
| split_protocol = "seeded" | |
| train_records = limit_train_records( | |
| full_train_records, | |
| max_records=args.max_train_records, | |
| subset_seed=train_subset_seed, | |
| ) | |
| print( | |
| { | |
| "split_seed": split_seed, | |
| "split_protocol": split_protocol, | |
| "fixed_split_manifest": None if args.fixed_split_manifest is None else str(args.fixed_split_manifest), | |
| "train_subset_seed": train_subset_seed, | |
| "full_train_tiles": len(full_train_records), | |
| "train_tiles_after_record_cap": len(train_records), | |
| "valid_tiles": len(valid_records), | |
| "max_train_records": args.max_train_records, | |
| } | |
| ) | |
| write_split_manifest(args.artifact_dir, train_records, valid_records) | |
| band_mean, band_std = get_band_stats( | |
| train_records, | |
| args.artifact_dir, | |
| args.stats_sample_size, | |
| source_stats_path=resolve_band_stats_source(args), | |
| expected_bands=args.n_s1_bands + args.n_aef_bands, | |
| ) | |
| if args.mode == "benchmark": | |
| run_benchmark_mode(args, device, train_records, valid_records, band_mean, band_std) | |
| else: | |
| run_train_mode(args, device, train_records, valid_records, band_mean, band_std) | |
| if __name__ == "__main__": | |
| main() | |