Upload DOFA model package
Browse files- .gitattributes +5 -27
- conf/config.yaml +53 -0
- config.json +104 -0
- configuration.json +13 -0
- model/dofa.py +134 -0
- scripts/fake_data.py +72 -0
- scripts/inference.py +104 -0
- scripts/result.py +83 -0
- scripts/train.py +167 -0
- weight/.gitkeep +0 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,13 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
|
| 27 |
-
*.
|
|
|
|
|
|
|
|
|
|
| 28 |
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
| 1 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 6 |
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 12 |
*.tar filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
seed: 42
|
| 2 |
+
data:
|
| 3 |
+
root: data
|
| 4 |
+
protocol: dofa_multisensor_npz_v1
|
| 5 |
+
image_size: 224
|
| 6 |
+
data_range: 1.0
|
| 7 |
+
train_samples_per_modality: 1
|
| 8 |
+
test_samples_per_modality: 1
|
| 9 |
+
modalities:
|
| 10 |
+
sentinel1:
|
| 11 |
+
channels: 2
|
| 12 |
+
wavelengths: [3.5, 4.0]
|
| 13 |
+
sentinel2:
|
| 14 |
+
channels: 9
|
| 15 |
+
wavelengths: [0.443, 0.490, 0.560, 0.665, 0.705, 0.740, 0.783, 0.842, 1.610]
|
| 16 |
+
naip:
|
| 17 |
+
channels: 3
|
| 18 |
+
wavelengths: [0.490, 0.560, 0.665]
|
| 19 |
+
gaofen:
|
| 20 |
+
channels: 4
|
| 21 |
+
wavelengths: [0.490, 0.560, 0.665, 0.830]
|
| 22 |
+
enmap:
|
| 23 |
+
channels: 202
|
| 24 |
+
wavelength_mode: synthetic_uniform
|
| 25 |
+
wavelength_start: 0.420
|
| 26 |
+
wavelength_step: 0.006
|
| 27 |
+
model:
|
| 28 |
+
image_size: 224
|
| 29 |
+
patch_size: 16
|
| 30 |
+
embed_dim: 32
|
| 31 |
+
encoder_depth: 1
|
| 32 |
+
encoder_heads: 4
|
| 33 |
+
decoder_dim: 24
|
| 34 |
+
decoder_depth: 1
|
| 35 |
+
decoder_heads: 4
|
| 36 |
+
wavelength_embed_dim: 16
|
| 37 |
+
hypernetwork_heads: 4
|
| 38 |
+
mask_ratio: 0.75
|
| 39 |
+
training:
|
| 40 |
+
epochs: 1
|
| 41 |
+
batch_size: 1
|
| 42 |
+
learning_rate: 0.0001
|
| 43 |
+
weight_decay: 0.05
|
| 44 |
+
num_workers: 0
|
| 45 |
+
amp: true
|
| 46 |
+
runtime:
|
| 47 |
+
device: auto
|
| 48 |
+
inference_batch_size: 1
|
| 49 |
+
paths:
|
| 50 |
+
checkpoint: result/checkpoints/dofa.pt
|
| 51 |
+
training_metrics: result/training/metrics.json
|
| 52 |
+
inference_dir: result/output
|
| 53 |
+
evaluation_dir: result/evaluation
|
config.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "DOFA",
|
| 3 |
+
"model_type": "dofa",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"DOFA"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "earth-observation",
|
| 9 |
+
"task": "multisensor-masked-image-modeling",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/dofa.py",
|
| 12 |
+
"scope": "wavelength-conditioned dynamic patch embedding and reconstruction for variable-channel Earth-observation imagery",
|
| 13 |
+
"train_script": "scripts/train.py",
|
| 14 |
+
"inference_script": "scripts/inference.py",
|
| 15 |
+
"evaluation_script": "scripts/result.py",
|
| 16 |
+
"synthetic_data_script": "scripts/fake_data.py"
|
| 17 |
+
},
|
| 18 |
+
"architecture": {
|
| 19 |
+
"family": "wavelength-conditioned masked autoencoder",
|
| 20 |
+
"image_size": 224,
|
| 21 |
+
"patch_size": 16,
|
| 22 |
+
"num_patches": 196,
|
| 23 |
+
"embed_dim": 32,
|
| 24 |
+
"encoder_depth": 1,
|
| 25 |
+
"encoder_heads": 4,
|
| 26 |
+
"decoder_dim": 24,
|
| 27 |
+
"decoder_depth": 1,
|
| 28 |
+
"decoder_heads": 4,
|
| 29 |
+
"wavelength_embed_dim": 16,
|
| 30 |
+
"hypernetwork_heads": 4,
|
| 31 |
+
"mask_ratio": 0.75,
|
| 32 |
+
"dynamic_components": [
|
| 33 |
+
"encoder patch weights",
|
| 34 |
+
"encoder patch bias",
|
| 35 |
+
"decoder reconstruction weights",
|
| 36 |
+
"decoder reconstruction bias"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
"data": {
|
| 40 |
+
"datasets": [
|
| 41 |
+
"Sentinel-1",
|
| 42 |
+
"Sentinel-2",
|
| 43 |
+
"NAIP",
|
| 44 |
+
"EnMAP",
|
| 45 |
+
"Gaofen"
|
| 46 |
+
],
|
| 47 |
+
"protocol": "dofa_multisensor_npz_v1",
|
| 48 |
+
"format": "NPZ",
|
| 49 |
+
"image_key": "images",
|
| 50 |
+
"image_shape": [
|
| 51 |
+
"N",
|
| 52 |
+
"C",
|
| 53 |
+
224,
|
| 54 |
+
224
|
| 55 |
+
],
|
| 56 |
+
"wavelength_key": "wavelengths",
|
| 57 |
+
"wavelength_shape": [
|
| 58 |
+
"C"
|
| 59 |
+
],
|
| 60 |
+
"required_metadata": [
|
| 61 |
+
"modality",
|
| 62 |
+
"protocol"
|
| 63 |
+
],
|
| 64 |
+
"provenance_and_metric_metadata": [
|
| 65 |
+
"data_source",
|
| 66 |
+
"data_range",
|
| 67 |
+
"wavelength_mode"
|
| 68 |
+
],
|
| 69 |
+
"modalities": {
|
| 70 |
+
"sentinel1": {
|
| 71 |
+
"channels": 2,
|
| 72 |
+
"wavelengths": [3.5, 4.0]
|
| 73 |
+
},
|
| 74 |
+
"sentinel2": {
|
| 75 |
+
"channels": 9,
|
| 76 |
+
"wavelengths": [0.443, 0.49, 0.56, 0.665, 0.705, 0.74, 0.783, 0.842, 1.61]
|
| 77 |
+
},
|
| 78 |
+
"naip": {
|
| 79 |
+
"channels": 3,
|
| 80 |
+
"wavelengths": [0.49, 0.56, 0.665]
|
| 81 |
+
},
|
| 82 |
+
"gaofen": {
|
| 83 |
+
"channels": 4,
|
| 84 |
+
"wavelengths": [0.49, 0.56, 0.665, 0.83]
|
| 85 |
+
},
|
| 86 |
+
"enmap": {
|
| 87 |
+
"channels": 202,
|
| 88 |
+
"wavelength_mode": "synthetic_uniform",
|
| 89 |
+
"wavelength_start": 0.42,
|
| 90 |
+
"wavelength_step": 0.006
|
| 91 |
+
}
|
| 92 |
+
},
|
| 93 |
+
"default_data_range": 1.0,
|
| 94 |
+
"default_file_pattern": "data/{split}_{modality}.npz"
|
| 95 |
+
},
|
| 96 |
+
"configuration_sources": [
|
| 97 |
+
"conf/config.yaml",
|
| 98 |
+
"model/dofa.py",
|
| 99 |
+
"scripts/fake_data.py",
|
| 100 |
+
"scripts/train.py",
|
| 101 |
+
"scripts/inference.py",
|
| 102 |
+
"scripts/result.py"
|
| 103 |
+
]
|
| 104 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "multimodal_earth_observation_pretraining",
|
| 4 |
+
"model": "DOFA",
|
| 5 |
+
"license": "MIT",
|
| 6 |
+
"input_format": "BCHW_WITH_WAVELENGTHS",
|
| 7 |
+
"protocol": "dofa_multisensor_npz_v1",
|
| 8 |
+
"default_config": "conf/config.yaml",
|
| 9 |
+
"train": "scripts/train.py",
|
| 10 |
+
"inference": "scripts/inference.py",
|
| 11 |
+
"evaluation": "scripts/result.py",
|
| 12 |
+
"visualization": "scripts/result.py"
|
| 13 |
+
}
|
model/dofa.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wavelength-conditioned, visible-token DOFA masked autoencoder."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
from torch.nn import functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class WavelengthEncoder(nn.Module):
|
| 11 |
+
def __init__(self, dimension):
|
| 12 |
+
super().__init__()
|
| 13 |
+
self.dimension = dimension
|
| 14 |
+
self.mlp = nn.Sequential(nn.Linear(dimension, dimension), nn.GELU(),
|
| 15 |
+
nn.Linear(dimension, dimension))
|
| 16 |
+
|
| 17 |
+
def forward(self, wavelengths):
|
| 18 |
+
half = self.dimension // 2
|
| 19 |
+
scale = torch.exp(torch.arange(half, device=wavelengths.device,
|
| 20 |
+
dtype=wavelengths.dtype) *
|
| 21 |
+
(-math.log(10000.0) / max(half - 1, 1)))
|
| 22 |
+
phase = wavelengths[:, None] * 1000.0 * scale[None]
|
| 23 |
+
encoded = torch.cat((phase.sin(), phase.cos()), -1)
|
| 24 |
+
encoded = F.pad(encoded, (0, self.dimension - encoded.shape[-1]))
|
| 25 |
+
return encoded + self.mlp(encoded)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class DynamicPatchWeights(nn.Module):
|
| 29 |
+
def __init__(self, wavelength_dim, embed_dim, decoder_dim, patch_size, heads):
|
| 30 |
+
super().__init__()
|
| 31 |
+
layer = nn.TransformerEncoderLayer(wavelength_dim, heads, wavelength_dim * 2,
|
| 32 |
+
batch_first=True, norm_first=True, dropout=0.0,
|
| 33 |
+
activation="gelu")
|
| 34 |
+
self.context = nn.TransformerEncoder(layer, 1)
|
| 35 |
+
self.patch_size = patch_size
|
| 36 |
+
self.embed_dim = embed_dim
|
| 37 |
+
self.decoder_dim = decoder_dim
|
| 38 |
+
self.encoder_weight = nn.Linear(wavelength_dim, embed_dim * patch_size**2)
|
| 39 |
+
self.encoder_bias = nn.Linear(wavelength_dim, embed_dim)
|
| 40 |
+
self.decoder_weight = nn.Linear(wavelength_dim, decoder_dim * patch_size**2)
|
| 41 |
+
self.decoder_bias = nn.Linear(wavelength_dim, patch_size**2)
|
| 42 |
+
|
| 43 |
+
def forward(self, wavelength_features):
|
| 44 |
+
context = self.context(wavelength_features.unsqueeze(0)).squeeze(0)
|
| 45 |
+
channels = len(context)
|
| 46 |
+
encoder_weight = self.encoder_weight(context).reshape(
|
| 47 |
+
channels, self.embed_dim, self.patch_size, self.patch_size).permute(1, 0, 2, 3)
|
| 48 |
+
encoder_bias = self.encoder_bias(context).mean(0)
|
| 49 |
+
decoder_weight = self.decoder_weight(context).reshape(
|
| 50 |
+
channels, self.patch_size**2, self.decoder_dim)
|
| 51 |
+
decoder_bias = self.decoder_bias(context)
|
| 52 |
+
return encoder_weight, encoder_bias, decoder_weight, decoder_bias
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class DOFA(nn.Module):
|
| 56 |
+
def __init__(self, image_size=224, patch_size=16, embed_dim=64, encoder_depth=2,
|
| 57 |
+
encoder_heads=4, decoder_dim=32, decoder_depth=1, decoder_heads=4,
|
| 58 |
+
wavelength_embed_dim=32, hypernetwork_heads=4, mask_ratio=0.75):
|
| 59 |
+
super().__init__()
|
| 60 |
+
if image_size % patch_size:
|
| 61 |
+
raise ValueError("image_size must be divisible by patch_size")
|
| 62 |
+
self.image_size, self.patch_size = image_size, patch_size
|
| 63 |
+
self.mask_ratio = mask_ratio
|
| 64 |
+
self.num_patches = (image_size // patch_size) ** 2
|
| 65 |
+
self.wavelength_encoder = WavelengthEncoder(wavelength_embed_dim)
|
| 66 |
+
self.dynamic_weights = DynamicPatchWeights(wavelength_embed_dim, embed_dim, decoder_dim,
|
| 67 |
+
patch_size, hypernetwork_heads)
|
| 68 |
+
self.encoder_position = nn.Parameter(torch.zeros(1, self.num_patches, embed_dim))
|
| 69 |
+
encoder_layer = nn.TransformerEncoderLayer(embed_dim, encoder_heads, embed_dim * 4,
|
| 70 |
+
batch_first=True, norm_first=True,
|
| 71 |
+
dropout=0.0, activation="gelu")
|
| 72 |
+
self.encoder = nn.TransformerEncoder(encoder_layer, encoder_depth)
|
| 73 |
+
self.decoder_input = nn.Linear(embed_dim, decoder_dim)
|
| 74 |
+
self.decoder_position = nn.Parameter(torch.zeros(1, self.num_patches, decoder_dim))
|
| 75 |
+
self.mask_token = nn.Parameter(torch.zeros(1, 1, decoder_dim))
|
| 76 |
+
decoder_layer = nn.TransformerEncoderLayer(decoder_dim, decoder_heads, decoder_dim * 4,
|
| 77 |
+
batch_first=True, norm_first=True,
|
| 78 |
+
dropout=0.0, activation="gelu")
|
| 79 |
+
self.decoder = nn.TransformerEncoder(decoder_layer, decoder_depth)
|
| 80 |
+
nn.init.normal_(self.encoder_position, std=0.02)
|
| 81 |
+
nn.init.normal_(self.decoder_position, std=0.02)
|
| 82 |
+
nn.init.normal_(self.mask_token, std=0.02)
|
| 83 |
+
|
| 84 |
+
def patchify(self, images):
|
| 85 |
+
p = self.patch_size
|
| 86 |
+
batch, channels, height, width = images.shape
|
| 87 |
+
patches = images.reshape(batch, channels, height // p, p, width // p, p)
|
| 88 |
+
return patches.permute(0, 2, 4, 1, 3, 5).reshape(batch, self.num_patches,
|
| 89 |
+
channels, p * p)
|
| 90 |
+
|
| 91 |
+
def unpatchify(self, patches):
|
| 92 |
+
p, side = self.patch_size, self.image_size // self.patch_size
|
| 93 |
+
batch, _, channels, _ = patches.shape
|
| 94 |
+
return patches.reshape(batch, side, side, channels, p, p).permute(
|
| 95 |
+
0, 3, 1, 4, 2, 5).reshape(batch, channels, self.image_size, self.image_size)
|
| 96 |
+
|
| 97 |
+
def random_mask(self, batch, ratio, device):
|
| 98 |
+
visible = max(1, int(self.num_patches * (1 - ratio)))
|
| 99 |
+
order = torch.rand(batch, self.num_patches, device=device).argsort(1)
|
| 100 |
+
visible_indices = order[:, :visible]
|
| 101 |
+
mask = torch.ones(batch, self.num_patches, dtype=torch.bool, device=device)
|
| 102 |
+
mask.scatter_(1, visible_indices, False)
|
| 103 |
+
return visible_indices, mask
|
| 104 |
+
|
| 105 |
+
def forward(self, images, wavelengths, mask_ratio=None):
|
| 106 |
+
if images.shape[-2:] != (self.image_size, self.image_size):
|
| 107 |
+
raise ValueError(f"Expected {self.image_size}x{self.image_size} images")
|
| 108 |
+
if images.shape[1] != wavelengths.numel():
|
| 109 |
+
raise ValueError("Image channels and wavelengths must have equal lengths")
|
| 110 |
+
wavelength_features = self.wavelength_encoder(wavelengths)
|
| 111 |
+
encoder_weight, encoder_bias, decoder_weight, decoder_bias = self.dynamic_weights(
|
| 112 |
+
wavelength_features)
|
| 113 |
+
tokens = F.conv2d(images, encoder_weight, encoder_bias,
|
| 114 |
+
stride=self.patch_size).flatten(2).transpose(1, 2)
|
| 115 |
+
visible_indices, mask = self.random_mask(len(images), self.mask_ratio if mask_ratio is None
|
| 116 |
+
else mask_ratio, images.device)
|
| 117 |
+
gather = visible_indices.unsqueeze(-1).expand(-1, -1, tokens.shape[-1])
|
| 118 |
+
visible_tokens = torch.gather(tokens + self.encoder_position, 1, gather)
|
| 119 |
+
encoded = self.encoder(visible_tokens)
|
| 120 |
+
decoded_tokens = self.mask_token.expand(len(images), self.num_patches, -1).clone()
|
| 121 |
+
visible_decoded = self.decoder_input(encoded).to(decoded_tokens.dtype)
|
| 122 |
+
decoded_tokens.scatter_(1, visible_indices.unsqueeze(-1).expand(-1, -1,
|
| 123 |
+
decoded_tokens.shape[-1]), visible_decoded)
|
| 124 |
+
decoded = self.decoder(decoded_tokens + self.decoder_position)
|
| 125 |
+
predictions = torch.einsum("bnd,cpd->bncp", decoded, decoder_weight)
|
| 126 |
+
predictions = predictions + decoder_bias[None, None]
|
| 127 |
+
targets = self.patchify(images)
|
| 128 |
+
patch_error = (predictions - targets).square().mean((2, 3))
|
| 129 |
+
loss = (patch_error * mask).sum() / mask.sum().clamp_min(1)
|
| 130 |
+
return {"loss": loss, "reconstruction": self.unpatchify(predictions), "mask": mask,
|
| 131 |
+
"visible_indices": visible_indices, "features": encoded}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
__all__ = ["DOFA", "WavelengthEncoder", "DynamicPatchWeights"]
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate deterministic multi-sensor data for DOFA pipeline validation."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def make_images(count, channels, size, seed):
|
| 13 |
+
rng = np.random.default_rng(seed)
|
| 14 |
+
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1)
|
| 15 |
+
images = np.empty((count, channels, size, size), dtype=np.float32)
|
| 16 |
+
for sample in range(count):
|
| 17 |
+
for channel in range(channels):
|
| 18 |
+
phase = rng.uniform(0, 2 * np.pi)
|
| 19 |
+
pattern = 0.45 + 0.25 * np.sin((channel + 1) * np.pi * x + phase)
|
| 20 |
+
pattern += 0.2 * np.cos((sample % 4 + 1) * np.pi * y - phase)
|
| 21 |
+
pattern += rng.normal(0, 0.02, (size, size))
|
| 22 |
+
images[sample, channel] = np.clip(pattern, 0, 1)
|
| 23 |
+
return images
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
|
| 28 |
+
config = yaml.safe_load(handle)
|
| 29 |
+
data = config["data"]
|
| 30 |
+
root = ROOT / data["root"]
|
| 31 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
for index, (name, modality) in enumerate(data["modalities"].items()):
|
| 33 |
+
if modality.get("wavelength_mode") == "synthetic_uniform":
|
| 34 |
+
wavelengths = modality["wavelength_start"] + np.arange(
|
| 35 |
+
modality["channels"], dtype=np.float32
|
| 36 |
+
) * modality["wavelength_step"]
|
| 37 |
+
else:
|
| 38 |
+
wavelengths = np.asarray(modality["wavelengths"], dtype=np.float32)
|
| 39 |
+
if wavelengths.shape != (modality["channels"],):
|
| 40 |
+
raise ValueError(
|
| 41 |
+
f"{name} has {modality['channels']} channels but "
|
| 42 |
+
f"{wavelengths.size} wavelengths"
|
| 43 |
+
)
|
| 44 |
+
for split, count in (
|
| 45 |
+
("train", data["train_samples_per_modality"]),
|
| 46 |
+
("test", data["test_samples_per_modality"]),
|
| 47 |
+
):
|
| 48 |
+
images = make_images(
|
| 49 |
+
count,
|
| 50 |
+
modality["channels"],
|
| 51 |
+
data["image_size"],
|
| 52 |
+
config["seed"] + index * 10 + (split == "test"),
|
| 53 |
+
)
|
| 54 |
+
output = root / f"{split}_{name}.npz"
|
| 55 |
+
np.savez_compressed(
|
| 56 |
+
output,
|
| 57 |
+
images=images,
|
| 58 |
+
wavelengths=wavelengths,
|
| 59 |
+
modality=np.asarray(name),
|
| 60 |
+
data_source=np.asarray("synthetic"),
|
| 61 |
+
protocol=np.asarray(data["protocol"]),
|
| 62 |
+
data_range=np.asarray(data["data_range"], dtype=np.float32),
|
| 63 |
+
wavelength_mode=np.asarray(modality.get("wavelength_mode", "configured")),
|
| 64 |
+
)
|
| 65 |
+
print(
|
| 66 |
+
f"generated={output.relative_to(ROOT)} shape={images.shape} "
|
| 67 |
+
f"modality={name} channels={modality['channels']} data_source=synthetic"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reconstruct validated sensor NPZ files in bounded device batches."""
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_model_class():
|
| 14 |
+
spec = importlib.util.spec_from_file_location("dofa_model", ROOT / "model" / "dofa.py")
|
| 15 |
+
module = importlib.util.module_from_spec(spec)
|
| 16 |
+
spec.loader.exec_module(module)
|
| 17 |
+
return module.DOFA
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def expected_wavelengths(modality):
|
| 21 |
+
if modality.get("wavelength_mode") == "synthetic_uniform":
|
| 22 |
+
return modality["wavelength_start"] + np.arange(modality["channels"], dtype=np.float32) * modality["wavelength_step"]
|
| 23 |
+
return np.asarray(modality["wavelengths"], dtype=np.float32)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def scalar(archive, key, default=None):
|
| 27 |
+
if key not in archive:
|
| 28 |
+
if default is not None:
|
| 29 |
+
return default
|
| 30 |
+
raise ValueError(f"NPZ is missing required metadata: {key}")
|
| 31 |
+
if archive[key].ndim != 0:
|
| 32 |
+
raise ValueError(f"NPZ metadata {key} must be a scalar")
|
| 33 |
+
return archive[key].item()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def validate_archive(archive, path, name, data_config):
|
| 37 |
+
if "images" not in archive or "wavelengths" not in archive:
|
| 38 |
+
raise ValueError(f"{path}: images and wavelengths are required")
|
| 39 |
+
images, wavelengths = archive["images"], archive["wavelengths"]
|
| 40 |
+
modality_config = data_config["modalities"][name]
|
| 41 |
+
if images.ndim != 4 or images.dtype != np.float32:
|
| 42 |
+
raise ValueError(f"{path}: images must be float32 NCHW")
|
| 43 |
+
expected_shape = (modality_config["channels"], data_config["image_size"], data_config["image_size"])
|
| 44 |
+
if images.shape[1:] != expected_shape:
|
| 45 |
+
raise ValueError(f"{path}: expected [N,{expected_shape[0]},224,224], got {images.shape}")
|
| 46 |
+
expected = expected_wavelengths(modality_config)
|
| 47 |
+
if wavelengths.shape != (modality_config["channels"],) or not np.issubdtype(wavelengths.dtype, np.floating) or not np.isfinite(wavelengths).all() or not np.allclose(wavelengths, expected, rtol=1e-5, atol=1e-6):
|
| 48 |
+
raise ValueError(f"{path}: wavelengths do not match configured sensor wavelengths")
|
| 49 |
+
modality = str(scalar(archive, "modality"))
|
| 50 |
+
protocol = str(scalar(archive, "protocol"))
|
| 51 |
+
source = str(scalar(archive, "data_source", "unknown"))
|
| 52 |
+
if modality != name or protocol != data_config["protocol"]:
|
| 53 |
+
raise ValueError(f"{path}: modality/protocol metadata does not match config")
|
| 54 |
+
data_range = float(scalar(archive, "data_range", data_config.get("data_range")))
|
| 55 |
+
if not np.isfinite(data_range) or data_range <= 0:
|
| 56 |
+
raise ValueError(f"{path}: PSNR requires a positive data_range metadata or config value")
|
| 57 |
+
return images, wavelengths.astype("float32", copy=False), protocol, source, data_range
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main():
|
| 61 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text(encoding="utf-8"))
|
| 62 |
+
checkpoint_path = ROOT / config["paths"]["checkpoint"]
|
| 63 |
+
if not checkpoint_path.exists():
|
| 64 |
+
raise FileNotFoundError("Missing checkpoint. Run `python scripts/train.py` first.")
|
| 65 |
+
device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
|
| 66 |
+
checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 67 |
+
if checkpoint.get("protocol") != config["data"]["protocol"]:
|
| 68 |
+
raise ValueError("Checkpoint and configured protocols do not match")
|
| 69 |
+
model = load_model_class()(**config["model"]).to(device)
|
| 70 |
+
model.load_state_dict(checkpoint["model"])
|
| 71 |
+
model.eval()
|
| 72 |
+
output_dir = ROOT / config["paths"]["inference_dir"]
|
| 73 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
data_root = ROOT / config["data"]["root"]
|
| 75 |
+
batch_size = config["runtime"]["inference_batch_size"]
|
| 76 |
+
torch.manual_seed(config["seed"])
|
| 77 |
+
for modality in config["data"]["modalities"]:
|
| 78 |
+
path = data_root / f"test_{modality}.npz"
|
| 79 |
+
if not path.exists():
|
| 80 |
+
raise FileNotFoundError(f"Missing test data: {path.relative_to(ROOT)}")
|
| 81 |
+
archive = np.load(path)
|
| 82 |
+
images, wavelengths, protocol, source, data_range = validate_archive(
|
| 83 |
+
archive, path, modality, config["data"])
|
| 84 |
+
reconstructions = np.empty_like(images)
|
| 85 |
+
masks = np.empty((len(images), model.num_patches), dtype=bool)
|
| 86 |
+
wavelength_tensor = torch.from_numpy(wavelengths).to(device)
|
| 87 |
+
with torch.inference_mode():
|
| 88 |
+
for start in range(0, len(images), batch_size):
|
| 89 |
+
stop = min(start + batch_size, len(images))
|
| 90 |
+
batch = torch.from_numpy(images[start:stop]).to(device)
|
| 91 |
+
output = model(batch, wavelength_tensor)
|
| 92 |
+
reconstructions[start:stop] = output["reconstruction"].cpu().numpy()
|
| 93 |
+
masks[start:stop] = output["mask"].cpu().numpy()
|
| 94 |
+
del batch, output
|
| 95 |
+
target = output_dir / f"{modality}_reconstruction.npz"
|
| 96 |
+
np.savez_compressed(target, inputs=images, reconstructions=reconstructions, masks=masks,
|
| 97 |
+
wavelengths=wavelengths, modality=np.asarray(modality),
|
| 98 |
+
data_source=np.asarray(source), protocol=np.asarray(protocol),
|
| 99 |
+
data_range=np.asarray(data_range, dtype=np.float32))
|
| 100 |
+
print(f"output={target.relative_to(ROOT)} channels={images.shape[1]} batch_size={batch_size}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate DOFA on masked pixels with explicit PSNR data ranges."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import matplotlib
|
| 7 |
+
matplotlib.use("Agg")
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import numpy as np
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def scalar(archive, key):
|
| 16 |
+
if key not in archive or archive[key].ndim != 0:
|
| 17 |
+
raise ValueError(f"Inference metadata {key} must be present as a scalar")
|
| 18 |
+
return archive[key].item()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def display(array):
|
| 22 |
+
selected = array[:3] if len(array) >= 3 else np.repeat(array[:1], 3, 0)
|
| 23 |
+
selected = selected.transpose(1, 2, 0)
|
| 24 |
+
return np.clip((selected - selected.min()) / max(np.ptp(selected), 1e-6), 0, 1)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def pixel_mask(patch_mask, image_size, patch_size):
|
| 28 |
+
side = image_size // patch_size
|
| 29 |
+
return np.repeat(np.repeat(patch_mask.reshape(side, side), patch_size, 0), patch_size, 1)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main():
|
| 33 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text(encoding="utf-8"))
|
| 34 |
+
input_dir = ROOT / config["paths"]["inference_dir"]
|
| 35 |
+
output_dir = ROOT / config["paths"]["evaluation_dir"]
|
| 36 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
metrics, sources, protocols = {}, set(), set()
|
| 38 |
+
for modality, modality_config in config["data"]["modalities"].items():
|
| 39 |
+
path = input_dir / f"{modality}_reconstruction.npz"
|
| 40 |
+
if not path.is_file():
|
| 41 |
+
raise FileNotFoundError("Run inference before result evaluation")
|
| 42 |
+
archive = np.load(path)
|
| 43 |
+
inputs, reconstructions, masks = archive["inputs"], archive["reconstructions"], archive["masks"]
|
| 44 |
+
protocol = str(scalar(archive, "protocol"))
|
| 45 |
+
source = str(scalar(archive, "data_source"))
|
| 46 |
+
output_modality = str(scalar(archive, "modality"))
|
| 47 |
+
data_range = float(scalar(archive, "data_range"))
|
| 48 |
+
expected_shape = (modality_config["channels"], config["data"]["image_size"], config["data"]["image_size"])
|
| 49 |
+
if inputs.ndim != 4 or inputs.shape[1:] != expected_shape or reconstructions.shape != inputs.shape:
|
| 50 |
+
raise ValueError(f"{path}: invalid reconstruction NCHW shape")
|
| 51 |
+
if protocol != config["data"]["protocol"] or output_modality != modality:
|
| 52 |
+
raise ValueError(f"{path}: protocol/modality metadata does not match config")
|
| 53 |
+
if not np.isfinite(data_range) or data_range <= 0:
|
| 54 |
+
raise ValueError(f"{path}: PSNR requires a positive data_range")
|
| 55 |
+
sources.add(source)
|
| 56 |
+
protocols.add(protocol)
|
| 57 |
+
expanded = np.stack([pixel_mask(mask, config["data"]["image_size"],
|
| 58 |
+
config["model"]["patch_size"]) for mask in masks])[:, None]
|
| 59 |
+
errors = (inputs - reconstructions)[np.broadcast_to(expanded, inputs.shape)]
|
| 60 |
+
mse, mae = float(np.mean(errors**2)), float(np.mean(np.abs(errors)))
|
| 61 |
+
metrics[modality] = {"masked_mse": mse, "masked_mae": mae,
|
| 62 |
+
"masked_psnr_db": float(10 * np.log10(data_range**2 / max(mse, 1e-12))),
|
| 63 |
+
"psnr_data_range": data_range, "channels": int(inputs.shape[1]),
|
| 64 |
+
"masked_fraction": float(expanded.mean()), "data_source": source,
|
| 65 |
+
"protocol": protocol}
|
| 66 |
+
masked_input = inputs[0].copy()
|
| 67 |
+
masked_input[:, expanded[0, 0].astype(bool)] = 0
|
| 68 |
+
figure, axes = plt.subplots(1, 3, figsize=(10, 3))
|
| 69 |
+
for axis, image, title in zip(axes, (inputs[0], masked_input, reconstructions[0]),
|
| 70 |
+
("target", "masked input", "reconstruction")):
|
| 71 |
+
axis.imshow(display(image)); axis.set_title(f"{modality}: {title}"); axis.axis("off")
|
| 72 |
+
figure.tight_layout()
|
| 73 |
+
figure.savefig(output_dir / f"{modality}_comparison.png", dpi=120)
|
| 74 |
+
plt.close(figure)
|
| 75 |
+
payload = {"modalities": metrics, "data_sources": sorted(sources),
|
| 76 |
+
"protocols": sorted(protocols), "protocol": config["data"]["protocol"],
|
| 77 |
+
"metric_scope": "masked_pixels_only"}
|
| 78 |
+
(output_dir / "metrics.json").write_text(json.dumps(payload, indent=2) + "\n")
|
| 79 |
+
print(json.dumps(payload, indent=2)); print(f"evaluation={output_dir}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train compact DOFA across strictly validated, variable-channel sensors."""
|
| 2 |
+
|
| 3 |
+
import importlib.util
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
from contextlib import nullcontext
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import yaml
|
| 13 |
+
from torch import distributed as dist
|
| 14 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 15 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def load_model_class():
|
| 21 |
+
spec = importlib.util.spec_from_file_location("dofa_model", ROOT / "model" / "dofa.py")
|
| 22 |
+
module = importlib.util.module_from_spec(spec)
|
| 23 |
+
spec.loader.exec_module(module)
|
| 24 |
+
return module.DOFA
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def expected_wavelengths(modality):
|
| 28 |
+
if modality.get("wavelength_mode") == "synthetic_uniform":
|
| 29 |
+
return modality["wavelength_start"] + np.arange(modality["channels"], dtype=np.float32) * modality["wavelength_step"]
|
| 30 |
+
return np.asarray(modality["wavelengths"], dtype=np.float32)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def scalar(archive, key, default=None):
|
| 34 |
+
if key not in archive:
|
| 35 |
+
if default is not None:
|
| 36 |
+
return default
|
| 37 |
+
raise ValueError(f"NPZ is missing required metadata: {key}")
|
| 38 |
+
if archive[key].ndim != 0:
|
| 39 |
+
raise ValueError(f"NPZ metadata {key} must be a scalar")
|
| 40 |
+
return archive[key].item()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SensorDataset(Dataset):
|
| 44 |
+
def __init__(self, path, name, data_config):
|
| 45 |
+
archive = np.load(path)
|
| 46 |
+
if "images" not in archive or "wavelengths" not in archive:
|
| 47 |
+
raise ValueError(f"{path} must contain images and wavelengths")
|
| 48 |
+
self.images = archive["images"]
|
| 49 |
+
wavelengths = archive["wavelengths"]
|
| 50 |
+
modality = data_config["modalities"][name]
|
| 51 |
+
expected = expected_wavelengths(modality)
|
| 52 |
+
if self.images.ndim != 4:
|
| 53 |
+
raise ValueError(f"{path}: images must be NCHW")
|
| 54 |
+
if self.images.dtype != np.float32:
|
| 55 |
+
raise ValueError(f"{path}: images must use float32")
|
| 56 |
+
if self.images.shape[1:] != (modality["channels"], data_config["image_size"], data_config["image_size"]):
|
| 57 |
+
raise ValueError(f"{path}: image shape does not match configured channels/224x224")
|
| 58 |
+
if wavelengths.shape != (modality["channels"],) or not np.issubdtype(wavelengths.dtype, np.floating):
|
| 59 |
+
raise ValueError(f"{path}: wavelengths must be a floating [C] array")
|
| 60 |
+
if not np.isfinite(wavelengths).all() or not np.allclose(wavelengths, expected, rtol=1e-5, atol=1e-6):
|
| 61 |
+
raise ValueError(f"{path}: wavelengths do not match configured sensor wavelengths")
|
| 62 |
+
self.modality = str(scalar(archive, "modality"))
|
| 63 |
+
self.protocol = str(scalar(archive, "protocol"))
|
| 64 |
+
self.data_source = str(scalar(archive, "data_source", "unknown"))
|
| 65 |
+
if self.modality != name:
|
| 66 |
+
raise ValueError(f"{path}: modality {self.modality} does not match {name}")
|
| 67 |
+
if self.protocol != data_config["protocol"]:
|
| 68 |
+
raise ValueError(f"{path}: protocol {self.protocol} does not match config")
|
| 69 |
+
self.wavelengths = torch.from_numpy(wavelengths.astype("float32", copy=False))
|
| 70 |
+
|
| 71 |
+
def __len__(self):
|
| 72 |
+
return len(self.images)
|
| 73 |
+
|
| 74 |
+
def __getitem__(self, index):
|
| 75 |
+
return torch.from_numpy(self.images[index])
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def reduced_average(total, count, device, distributed):
|
| 79 |
+
values = torch.tensor([total, count], dtype=torch.float64, device=device)
|
| 80 |
+
if distributed:
|
| 81 |
+
dist.all_reduce(values, op=dist.ReduceOp.SUM)
|
| 82 |
+
if values[1].item() == 0:
|
| 83 |
+
raise RuntimeError("Training processed no batches")
|
| 84 |
+
return (values[0] / values[1]).item()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main():
|
| 88 |
+
config = yaml.safe_load((ROOT / "conf/config.yaml").read_text(encoding="utf-8"))
|
| 89 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 90 |
+
global_rank = int(os.environ.get("RANK", "0"))
|
| 91 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 92 |
+
distributed = world_size > 1
|
| 93 |
+
if distributed:
|
| 94 |
+
dist.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
|
| 95 |
+
requested = config["runtime"]["device"]
|
| 96 |
+
use_accelerator = torch.cuda.is_available() and requested != "cpu"
|
| 97 |
+
device = torch.device(f"cuda:{local_rank}" if use_accelerator else "cpu")
|
| 98 |
+
if use_accelerator:
|
| 99 |
+
torch.cuda.set_device(local_rank)
|
| 100 |
+
seed = config["seed"] + global_rank
|
| 101 |
+
random.seed(seed)
|
| 102 |
+
np.random.seed(seed)
|
| 103 |
+
torch.manual_seed(seed)
|
| 104 |
+
|
| 105 |
+
data_root = ROOT / config["data"]["root"]
|
| 106 |
+
loaders = []
|
| 107 |
+
for modality in config["data"]["modalities"]:
|
| 108 |
+
path = data_root / f"train_{modality}.npz"
|
| 109 |
+
if not path.exists():
|
| 110 |
+
raise FileNotFoundError(f"Missing training data: {path.relative_to(ROOT)}. Run `python scripts/fake_data.py` first.")
|
| 111 |
+
dataset = SensorDataset(path, modality, config["data"])
|
| 112 |
+
sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
|
| 113 |
+
loader = DataLoader(dataset, batch_size=config["training"]["batch_size"],
|
| 114 |
+
shuffle=sampler is None, sampler=sampler,
|
| 115 |
+
num_workers=config["training"]["num_workers"])
|
| 116 |
+
loaders.append((dataset, loader, sampler))
|
| 117 |
+
|
| 118 |
+
model = load_model_class()(**config["model"]).to(device)
|
| 119 |
+
if distributed:
|
| 120 |
+
model = DistributedDataParallel(model, device_ids=[local_rank] if use_accelerator else None)
|
| 121 |
+
optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
|
| 122 |
+
weight_decay=config["training"]["weight_decay"])
|
| 123 |
+
amp = bool(config["training"]["amp"] and use_accelerator)
|
| 124 |
+
scaler = torch.amp.GradScaler("cuda", enabled=amp)
|
| 125 |
+
autocast = (lambda: torch.amp.autocast("cuda", enabled=True)) if amp else nullcontext
|
| 126 |
+
history = []
|
| 127 |
+
for epoch in range(config["training"]["epochs"]):
|
| 128 |
+
model.train()
|
| 129 |
+
records = {}
|
| 130 |
+
for dataset, loader, sampler in loaders:
|
| 131 |
+
if sampler is not None:
|
| 132 |
+
sampler.set_epoch(epoch)
|
| 133 |
+
total = count = 0
|
| 134 |
+
for images in loader:
|
| 135 |
+
optimizer.zero_grad(set_to_none=True)
|
| 136 |
+
with autocast():
|
| 137 |
+
output = model(images.to(device), dataset.wavelengths.to(device))
|
| 138 |
+
scaler.scale(output["loss"]).backward()
|
| 139 |
+
scaler.step(optimizer)
|
| 140 |
+
scaler.update()
|
| 141 |
+
total += output["loss"].item()
|
| 142 |
+
count += 1
|
| 143 |
+
records[dataset.modality] = reduced_average(total, count, device, distributed)
|
| 144 |
+
history.append({"epoch": epoch + 1, "reconstruction_loss": records})
|
| 145 |
+
if global_rank == 0:
|
| 146 |
+
print(f"epoch={epoch + 1} " + " ".join(f"{name}={value:.6f}" for name, value in records.items()))
|
| 147 |
+
|
| 148 |
+
if global_rank == 0:
|
| 149 |
+
checkpoint = ROOT / config["paths"]["checkpoint"]
|
| 150 |
+
metrics = ROOT / config["paths"]["training_metrics"]
|
| 151 |
+
checkpoint.parent.mkdir(parents=True, exist_ok=True)
|
| 152 |
+
metrics.parent.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
base_model = model.module if hasattr(model, "module") else model
|
| 154 |
+
sources = sorted({dataset.data_source for dataset, _, _ in loaders})
|
| 155 |
+
torch.save({"model": base_model.state_dict(), "config": config,
|
| 156 |
+
"protocol": config["data"]["protocol"], "data_sources": sources}, checkpoint)
|
| 157 |
+
metrics.write_text(json.dumps({"history": history,
|
| 158 |
+
"modalities": list(config["data"]["modalities"]),
|
| 159 |
+
"protocol": config["data"]["protocol"],
|
| 160 |
+
"data_sources": sources}, indent=2) + "\n", encoding="utf-8")
|
| 161 |
+
print(f"checkpoint={checkpoint.relative_to(ROOT)}")
|
| 162 |
+
if distributed:
|
| 163 |
+
dist.destroy_process_group()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|