zhangrenchao commited on
Commit
1d4cac8
·
verified ·
1 Parent(s): 64db3f3

Upload DOFA model package

Browse files
.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
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
 
 
 
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