Image Segmentation
fastai
PyTorch
geospatial
remote-sensing
semantic-segmentation
sentinel-1
alphaearth
surface-water
Instructions to use rohitm9/surfaceWaterGlobal with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- fastai
How to use rohitm9/surfaceWaterGlobal with fastai:
from huggingface_hub import from_pretrained_fastai learn = from_pretrained_fastai("rohitm9/surfaceWaterGlobal") - Notebooks
- Google Colab
- Kaggle
Download src/train.py from rohitm9/surfaceWaterGlobal: direct link, hf CLI and curl.
- Browser
- Download file 99.8 kB
-
https://huggingface.co/rohitm9/surfaceWaterGlobal/resolve/main/src/train.py
- Command line
-
hf download hf://rohitm9/surfaceWaterGlobal/src/train.py
-
curl -L -o train.py https://huggingface.co/rohitm9/surfaceWaterGlobal/resolve/main/src/train.py
99.8 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 uses fixed 512x512 random crops to support batching across the | |
| variable tile sizes in the dataset. | |
| - Validation keeps full tiles (bs=1) so the reported metrics stay tied to the | |
| original segmentation task rather than crop-only evaluation. | |
| """ | |
| 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 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 * | |
| REPOSITORY_ROOT = Path(__file__).resolve().parents[1] | |
| DEFAULT_INPUT_DIR = REPOSITORY_ROOT / "data" / "s1" | |
| DEFAULT_AEF_DIR = REPOSITORY_ROOT / "data" / "aef" | |
| DEFAULT_LABEL_DIR = REPOSITORY_ROOT / "data" / "labels" | |
| DEFAULT_ARTIFACT_DIR = REPOSITORY_ROOT / "runs" / "s1aef_resnet34" | |
| DEFAULT_HF_CACHE_DIR = Path.home() / ".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 = "" | |
| class CombinedLoss(nn.Module): | |
| """Focal + Dice, carried over from the working baseline.""" | |
| def __init__(self, axis=1, smooth=1.0, alpha=1.0): | |
| super().__init__() | |
| self.axis = axis | |
| self.alpha = alpha | |
| self.focal_loss = FocalLossFlat(axis=axis) | |
| self.dice_loss = DiceLoss(axis, smooth) | |
| def forward(self, pred, targ): | |
| pred = pred.float() | |
| targ = targ.long() | |
| return self.focal_loss(pred, targ) + (self.alpha * self.dice_loss(pred, targ)) | |
| 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) | |
| 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.cross_entropy = CrossEntropyLossFlat(axis=axis) | |
| self.dice_loss = DiceLoss(axis, smooth) | |
| def forward(self, pred: torch.Tensor, targ: torch.Tensor) -> torch.Tensor: | |
| pred = pred.float() | |
| targ = targ.long() | |
| return self.cross_entropy(pred, targ) + (self.alpha * self.dice_loss(pred, targ)) | |
| 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 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 | |
| water_pred = pred == 1 | |
| water_targ = targ == 1 | |
| 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 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): | |
| 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) | |
| self.unet = unet_model | |
| def forward(self, x): | |
| s1 = x[:, : self.n_s1] | |
| aef = x[:, self.n_s1 :] | |
| aef_projected = self.aef_proj(aef) | |
| fused = torch.cat([s1, aef_projected], 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 :] | |
| 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, | |
| ): | |
| 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 | |
| def __len__(self): | |
| return len(self.records) | |
| def __getitem__(self, idx): | |
| image, mask = load_triplet_tensors(self.records[idx]) | |
| image, mask = random_crop(image, mask, self.crop_size) | |
| 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, | |
| ) | |
| image = normalize_image(image, self.band_mean, self.band_std) | |
| return image, mask.long() | |
| class S1AEFFullTileDataset(Dataset): | |
| """Validation dataset that keeps full aligned tiles (bs=1).""" | |
| def __init__(self, records: Sequence[TripletRecord], band_mean: np.ndarray, band_std: np.ndarray): | |
| self.records = list(records) | |
| 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_triplet_tensors(self.records[idx]) | |
| 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 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 tile_name and immutable train/valid membership for the full population.", | |
| ) | |
| 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", "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( | |
| "--split_mode", | |
| choices=("paper_tile", "grouped"), | |
| default="paper_tile", | |
| help=( | |
| "paper_tile reproduces the published seeded tile-level split; grouped keeps " | |
| "all rows sharing grid_id/group_id together when a manifest supplies them." | |
| ), | |
| ) | |
| 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("--patience", type=int, default=4) | |
| parser.add_argument("--selection_metric", choices=["dice", "water_iou"], default="water_iou") | |
| parser.add_argument("--min_delta", type=float, default=0.001) | |
| parser.add_argument("--loss", choices=["focal_dice", "ce_dice", "lovasz"], default="focal_dice") | |
| 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("--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) | |
| 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 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: | |
| if using_aef(args): | |
| args.best_model_name = "s1aef_bottleneck_resnet34_best" | |
| args.history_csv = "s1aef_bottleneck_resnet34_history.csv" | |
| args.threshold_sweep_csv = "s1aef_bottleneck_resnet34_threshold_sweep.csv" | |
| args.eval_summary_json = "s1aef_bottleneck_resnet34_eval_summary.json" | |
| args.tile_metrics_csv = "s1aef_bottleneck_resnet34_tile_metrics.csv" | |
| args.worst_tiles_csv = "s1aef_bottleneck_resnet34_worst_tiles.csv" | |
| else: | |
| args.best_model_name = "s1dw_resnet34_best" | |
| args.history_csv = "s1dw_resnet34_history.csv" | |
| args.threshold_sweep_csv = "s1dw_resnet34_threshold_sweep.csv" | |
| args.eval_summary_json = "s1dw_resnet34_eval_summary.json" | |
| args.tile_metrics_csv = "s1dw_resnet34_tile_metrics.csv" | |
| args.worst_tiles_csv = "s1dw_resnet34_worst_tiles.csv" | |
| 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.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, | |
| "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 {} | |
| 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_dir / s1_path.name | |
| if use_aef and aef_path is None: | |
| missing_aef.append(s1_path.name) | |
| continue | |
| if not label_path.exists(): | |
| missing_labels.append(label_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, | |
| ) -> 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") | |
| 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 | |
| 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)) | |
| 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, | |
| ) | |
| ) | |
| 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_triplets( | |
| records: Sequence[TripletRecord], | |
| valid_pct: float, | |
| seed: int, | |
| split_mode: str = "paper_tile", | |
| ) -> Tuple[List[TripletRecord], List[TripletRecord]]: | |
| if not records: | |
| raise ValueError("Cannot split an empty record list.") | |
| rng = random.Random(seed) | |
| if split_mode == "paper_tile": | |
| shuffled = list(records) | |
| rng.shuffle(shuffled) | |
| valid_count = max(1, int(round(len(shuffled) * valid_pct))) | |
| valid_records = sorted(shuffled[:valid_count], key=lambda r: r.name) | |
| train_records = sorted(shuffled[valid_count:], key=lambda r: r.name) | |
| if not train_records: | |
| raise ValueError("Train split is empty; reduce --valid_pct.") | |
| return train_records, valid_records | |
| if split_mode != "grouped": | |
| raise ValueError("Unknown split mode: {}".format(split_mode)) | |
| 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.") | |
| 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_from_manifest( | |
| records: Sequence[TripletRecord], manifest_path: Path | |
| ) -> Tuple[List[TripletRecord], List[TripletRecord]]: | |
| """Apply immutable train/validation membership by tile name.""" | |
| if not manifest_path.exists(): | |
| raise FileNotFoundError("Fixed split manifest does not exist: {}".format(manifest_path)) | |
| with manifest_path.open(newline="") as handle: | |
| rows = list(csv.DictReader(handle)) | |
| required = {"split", "tile_name"} | |
| missing = required.difference(rows[0] if rows else {}) | |
| if missing: | |
| raise ValueError("Fixed split manifest is missing columns: {}".format(", ".join(sorted(missing)))) | |
| assignments: Dict[str, str] = {} | |
| for row in rows: | |
| name, split = row["tile_name"].strip(), row["split"].strip().lower() | |
| split = "valid" if split == "validation" else split | |
| if split not in {"train", "valid"}: | |
| raise ValueError("Unknown split {!r} for {}".format(split, name)) | |
| if name in assignments: | |
| raise ValueError("Duplicate tile in fixed split manifest: {}".format(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") | |
| 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={}, unassigned={}, examples={}".format( | |
| len(missing_records), len(unassigned_records), | |
| (missing_records + 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 train and valid tiles") | |
| 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", "group_id", "name", "s1_path", "aef_path", "label_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, | |
| "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), | |
| } | |
| ) | |
| 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) | |
| return selected[: min(len(selected), max_items)] | |
| 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: | |
| with rio.open(str(path)) as src: | |
| data = src.read(1).astype(np.float32) | |
| data = np.nan_to_num(data, nan=0.0, posinf=0.0, neginf=0.0) | |
| mask = (data > 0.5).astype(np.int64) | |
| return torch.from_numpy(mask) | |
| 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) | |
| 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) | |
| return image.float(), mask.long() | |
| def compute_band_stats(records: Sequence[TripletRecord], max_files: Optional[int]) -> Tuple[np.ndarray, np.ndarray]: | |
| selected = list(records)[: min(len(records), max_files)] if max_files is not None else list(records) | |
| if not selected: | |
| raise ValueError("Cannot compute stats without at least one training record.") | |
| channel_sum = None | |
| channel_sumsq = None | |
| pixel_count = 0 | |
| for record in selected: | |
| image, _ = load_triplet_tensors(record) | |
| 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) | |
| 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: | |
| return (image - band_mean) / band_std | |
| 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, | |
| ) -> 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 = image + torch.randn_like(image) * 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, | |
| ) | |
| valid_dataset = S1AEFFullTileDataset(valid_records, band_mean=band_mean, band_std=band_std) | |
| pin_memory = device.type == "cuda" and not args.disable_pin_memory | |
| train_dl = DataLoader( | |
| 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, | |
| ) | |
| 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 | |
| def build_callbacks(args: argparse.Namespace, epoch_timer: Optional[EpochTimerCallback] = None) -> List[Callback]: | |
| callbacks: List[Callback] = [ | |
| GradientClip(args.grad_clip), | |
| NonFiniteLossCallback(), | |
| SaveModelCallback( | |
| 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: | |
| learn.fit_one_cycle( | |
| epochs, | |
| lr_max=lr_max, | |
| wd=args.weight_decay, | |
| cbs=build_callbacks(args, epoch_timer=epoch_timer), | |
| reset_opt=True, | |
| ) | |
| 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(loss_name: str) -> nn.Module: | |
| if loss_name == "focal_dice": | |
| return CombinedLoss() | |
| if loss_name == "ce_dice": | |
| return CrossEntropyDiceLoss() | |
| if loss_name == "lovasz": | |
| return LovaszSoftmaxLoss() | |
| raise ValueError("Unknown loss: {name}".format(name=loss_name)) | |
| def build_learner(args: argparse.Namespace, dls: DataLoaders) -> Learner: | |
| n_total_in = args.n_s1_bands + args.n_aef_bands | |
| n_model_in = args.n_s1_bands + (args.n_proj_bands if using_aef(args) else 0) | |
| loss_func = build_loss(args.loss) | |
| 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=[JaccardCoeff(), Dice(), WaterIoU()], | |
| opt_func=ranger, | |
| splitter=build_segformer_splitter(), | |
| path=args.artifact_dir, | |
| model_dir="models", | |
| ) | |
| model_name = "AEFBottleneckSegFormer({variant})".format( | |
| variant=args.segformer_variant | |
| ) | |
| else: | |
| learn = unet_learner( | |
| dls, | |
| resnet34, | |
| normalize=False, | |
| n_in=n_model_in, | |
| n_out=len(args.codes), | |
| metrics=[JaccardCoeff(), Dice(), WaterIoU()], | |
| loss_func=loss_func, | |
| opt_func=ranger, | |
| 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, | |
| ) | |
| learn.model = wrapped_model.to(dls.device) | |
| learn.splitter = build_wrapped_splitter(base_splitter) | |
| model_name = "AEFBottleneckUNet(resnet34_unet)" | |
| else: | |
| model_name = "S1UNet(resnet34_unet)" | |
| 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, | |
| "selection_metric": args.selection_metric, | |
| "tuning_strategy": args.tuning_strategy, | |
| "grad_accum_steps": args.grad_accum_steps, | |
| "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_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 | |
| 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, (xb, yb) in enumerate(learn.dls.valid): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| xb = xb.to(device, non_blocking=True) | |
| yb = yb.to(device, non_blocking=True) | |
| logits, used_tiled = predict_logits_with_fallback( | |
| learn.model, | |
| xb, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| force_tiled=force_tiled or use_tiled, | |
| ) | |
| use_tiled = use_tiled or used_tiled | |
| batch_loss = loss_func(logits, yb).detach().float().item() | |
| pred = logits.argmax(dim=1) | |
| total_loss += batch_loss | |
| batch_count += 1 | |
| tp += int(((pred == 1) & (yb == 1)).sum().item()) | |
| fp += int(((pred == 1) & (yb != 1)).sum().item()) | |
| tn += int(((pred != 1) & (yb != 1)).sum().item()) | |
| fn += int(((pred != 1) & (yb == 1)).sum().item()) | |
| 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 | |
| threshold_tensor = torch.tensor(thresholds, device=device, dtype=torch.float32).view(-1, 1, 1, 1) | |
| 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, (xb, yb) in enumerate(learn.dls.valid): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| xb = xb.to(device, non_blocking=True) | |
| yb = yb.to(device, non_blocking=True) | |
| probs, used_tiled_now = predict_water_probabilities( | |
| learn.model, | |
| xb, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| use_tta=use_tta, | |
| tta_mode_count=tta_mode_count, | |
| force_tiled=force_tiled or use_tiled, | |
| ) | |
| use_tiled = use_tiled or used_tiled_now | |
| pred = probs.unsqueeze(0) >= threshold_tensor | |
| targ = (yb == 1).unsqueeze(0) | |
| counts["tp"] += (pred & targ).sum(dim=(1, 2, 3)).cpu() | |
| counts["fp"] += (pred & ~targ).sum(dim=(1, 2, 3)).cpu() | |
| counts["tn"] += ((~pred) & ~targ).sum(dim=(1, 2, 3)).cpu() | |
| counts["fn"] += ((~pred) & targ).sum(dim=(1, 2, 3)).cpu() | |
| 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 | |
| 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, ((xb, yb), record) in enumerate(zip(learn.dls.valid, valid_records)): | |
| if max_valid_batches is not None and batch_idx >= max_valid_batches: | |
| break | |
| xb = xb.to(device, non_blocking=True) | |
| yb = yb.to(device, non_blocking=True) | |
| probs, used_tiled = predict_water_probabilities( | |
| learn.model, | |
| xb, | |
| tile_size=tile_size, | |
| overlap=overlap, | |
| use_tta=use_tta, | |
| tta_mode_count=tta_mode_count, | |
| force_tiled=force_tiled, | |
| ) | |
| pred = probs >= threshold | |
| targ = yb == 1 | |
| tp = int((pred & targ).sum().item()) | |
| fp = int((pred & ~targ).sum().item()) | |
| tn = int(((~pred) & ~targ).sum().item()) | |
| fn = int(((~pred) & targ).sum().item()) | |
| metrics = metrics_from_binary_confusion(tp=tp, fp=fp, tn=tn, fn=fn) | |
| rows.append( | |
| { | |
| "tile_name": record.name, | |
| "eval_mode": eval_mode, | |
| "threshold": float(threshold), | |
| "used_tiled_inference": bool(used_tiled), | |
| "water_pixels": int(targ.sum().item()), | |
| **metrics, | |
| "tp": tp, | |
| "fp": fp, | |
| "tn": tn, | |
| "fn": fn, | |
| } | |
| ) | |
| 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 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) | |
| 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) | |
| 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_triplet_tensors(train_records[0]) | |
| 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, | |
| "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), | |
| } | |
| 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) | |
| 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, | |
| "split_mode": args.split_mode, | |
| "train_subset_seed": args.seed if args.train_subset_seed is None else args.train_subset_seed, | |
| "max_train_records": args.max_train_records, | |
| } | |
| 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) | |
| 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, | |
| "split_mode": args.split_mode, | |
| "train_subset_seed": args.seed if args.train_subset_seed is None else args.train_subset_seed, | |
| "max_train_records": args.max_train_records, | |
| "train_records_used": len(train_records), | |
| "valid_records_used": len(valid_records), | |
| } | |
| 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), | |
| ) | |
| 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 | |
| ) | |
| args.split_mode = "fixed_manifest" | |
| else: | |
| full_train_records, valid_records = split_triplets( | |
| triplets, | |
| valid_pct=args.valid_pct, | |
| seed=split_seed, | |
| split_mode=args.split_mode, | |
| ) | |
| 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_mode": args.split_mode, | |
| "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() | |