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

Upload SatMAE 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,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ root: data
4
+ protocol: fmow_rgb_temporal
5
+ mode: temporal
6
+ image_size: 32
7
+ channels: 3
8
+ frames: 3
9
+ timestamp_fields: [year_offset_2002, month_zero_based, hour]
10
+ num_classes: 10
11
+ train_samples: 32
12
+ test_samples: 8
13
+ mask_ratio: 0.75
14
+ model:
15
+ architecture: satmae_vit_large_patch16
16
+ runtime_profile: smoke
17
+ image_size: 32
18
+ patch_size: 4
19
+ in_channels: 3
20
+ frames: 3
21
+ embed_dim: 64
22
+ encoder_depth: 2
23
+ encoder_heads: 4
24
+ decoder_dim: 32
25
+ decoder_depth: 1
26
+ decoder_heads: 4
27
+ mode: temporal
28
+ spectral_groups: [[0, 1], [2]]
29
+ norm_pix_loss: false
30
+ same_mask: false
31
+ spatial_mask: false
32
+ training:
33
+ epochs: 2
34
+ batch_size: 4
35
+ accum_iter: 1
36
+ base_learning_rate: 0.001
37
+ learning_rate: null
38
+ min_learning_rate: 0.0
39
+ warmup_epochs: 1
40
+ weight_decay: 0.05
41
+ num_workers: 0
42
+ resume: null
43
+ save_every: 1
44
+ runtime:
45
+ device: auto
46
+ amp: true
47
+ paths:
48
+ checkpoint: result/checkpoints/satmae.pt
49
+ training_metrics: result/training/metrics.json
50
+ inference_dir: result/output
51
+ evaluation_dir: result/evaluation
config.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "SatMAE",
3
+ "model_type": "satmae",
4
+ "architectures": [
5
+ "SatMAE"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "earth-observation",
9
+ "task": "remote-sensing-representation-learning",
10
+ "implementation": {
11
+ "entry_point": "model/satmae.py",
12
+ "scope": "masked autoencoding for temporal or grouped multispectral satellite 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": "temporal and grouped multispectral masked autoencoder",
20
+ "runtime_profile": "smoke",
21
+ "mode": "temporal",
22
+ "image_size": 32,
23
+ "patch_size": 4,
24
+ "in_channels": 3,
25
+ "frames": 3,
26
+ "embed_dim": 64,
27
+ "encoder_depth": 2,
28
+ "encoder_heads": 4,
29
+ "decoder_dim": 32,
30
+ "decoder_depth": 1,
31
+ "decoder_heads": 4,
32
+ "mask_ratio": 0.75,
33
+ "spectral_groups": [[0, 1], [2]],
34
+ "norm_pix_loss": false,
35
+ "same_mask": false,
36
+ "spatial_mask": false
37
+ },
38
+ "data": {
39
+ "datasets": [
40
+ "fMoW RGB",
41
+ "fMoW-Sentinel",
42
+ "NAIP",
43
+ "EuroSAT",
44
+ "BigEarthNet",
45
+ "SpaceNet v1"
46
+ ],
47
+ "protocol": "fmow_rgb_temporal",
48
+ "format": "NPZ",
49
+ "input_key": "images",
50
+ "input_shape": ["N", 3, 3, 32, 32],
51
+ "timestamp_key": "timestamps",
52
+ "timestamp_shape": ["N", 3, 3],
53
+ "timestamp_fields": ["year_offset_2002", "month_zero_based", "hour"],
54
+ "label_key": "labels",
55
+ "label_shape": ["N"],
56
+ "num_classes": 10,
57
+ "default_train_file": "data/train.npz",
58
+ "default_test_file": "data/test.npz",
59
+ "required_metadata": ["source_protocol", "data_source"]
60
+ },
61
+ "configuration_sources": [
62
+ "conf/config.yaml",
63
+ "model/satmae.py",
64
+ "scripts/fake_data.py",
65
+ "scripts/train.py",
66
+ "scripts/inference.py",
67
+ "scripts/result.py"
68
+ ]
69
+ }
configuration.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "remote_sensing_representation_learning",
4
+ "model": "SatMAE",
5
+ "input_format": "BTCHW with BT3 timestamps, or BCHW",
6
+ "protocol": "SatMAE fMoW temporal and grouped multispectral MAE",
7
+ "implementation": "clean-room paper-aligned PyTorch implementation",
8
+ "upstream_reference_commit": "0b210aceb37a14bbbd897110db5b104b3271d818",
9
+ "default_config": "conf/config.yaml",
10
+ "train": "scripts/train.py",
11
+ "inference": "scripts/inference.py",
12
+ "evaluation": "scripts/result.py",
13
+ "visualization": "scripts/result.py"
14
+ }
model/satmae.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper-aligned SatMAE model components.
2
+
3
+ This is an original implementation of the architecture described in SatMAE.
4
+ The upstream repository was used only as a behavioral reference; no upstream
5
+ source text is incorporated here.
6
+ """
7
+
8
+ import math
9
+ from functools import partial
10
+
11
+ import torch
12
+ from torch import nn
13
+
14
+
15
+ def _sincos_1d(values, dim):
16
+ """Return a fixed sine-cosine embedding for arbitrary scalar positions."""
17
+ if dim <= 0:
18
+ return values.new_zeros((*values.shape, 0))
19
+ pairs = (dim + 1) // 2
20
+ omega = torch.arange(pairs, device=values.device, dtype=torch.float32)
21
+ omega = torch.exp(-math.log(10000.0) * omega / max(pairs - 1, 1))
22
+ phase = values.to(torch.float32).unsqueeze(-1) * omega
23
+ return torch.cat((phase.sin(), phase.cos()), dim=-1)[..., :dim]
24
+
25
+
26
+ def _sincos_2d(grid_size, dim):
27
+ """Return a fixed row-major 2D sine-cosine position embedding."""
28
+ rows, cols = torch.meshgrid(
29
+ torch.arange(grid_size, dtype=torch.float32),
30
+ torch.arange(grid_size, dtype=torch.float32),
31
+ indexing="ij",
32
+ )
33
+ row_dim = dim // 2
34
+ return torch.cat(
35
+ (_sincos_1d(rows.reshape(-1), row_dim),
36
+ _sincos_1d(cols.reshape(-1), dim - row_dim)),
37
+ dim=-1,
38
+ )
39
+
40
+
41
+ def _timestamp_embedding(timestamps, dim):
42
+ """Encode either scalar times or fMoW ``[year, month, hour]`` tuples."""
43
+ if timestamps.ndim == 2:
44
+ return _sincos_1d(timestamps, dim)
45
+ if timestamps.ndim != 3 or timestamps.shape[-1] != 3:
46
+ raise ValueError("timestamps must have shape [B, T] or [B, T, 3]")
47
+ field_dims = [dim // 3] * 3
48
+ for index in range(dim % 3):
49
+ field_dims[index] += 1
50
+ return torch.cat(
51
+ [_sincos_1d(timestamps[..., index], field_dim)
52
+ for index, field_dim in enumerate(field_dims)],
53
+ dim=-1,
54
+ )
55
+
56
+
57
+ class PatchEmbed(nn.Module):
58
+ def __init__(self, image_size, patch_size, in_channels, embed_dim):
59
+ super().__init__()
60
+ self.image_size = image_size
61
+ self.patch_size = patch_size
62
+ self.num_patches = (image_size // patch_size) ** 2
63
+ self.proj = nn.Conv2d(
64
+ in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
65
+ )
66
+
67
+ def forward(self, images):
68
+ if images.shape[-2:] != (self.image_size, self.image_size):
69
+ raise ValueError(
70
+ f"expected {self.image_size}x{self.image_size} images, "
71
+ f"got {tuple(images.shape[-2:])}"
72
+ )
73
+ return self.proj(images).flatten(2).transpose(1, 2)
74
+
75
+
76
+ class TransformerBlock(nn.Module):
77
+ def __init__(self, dim, num_heads, mlp_ratio=4.0, norm_layer=nn.LayerNorm):
78
+ super().__init__()
79
+ self.norm1 = norm_layer(dim)
80
+ self.attention = nn.MultiheadAttention(
81
+ dim, num_heads, dropout=0.0, bias=True, batch_first=True
82
+ )
83
+ self.norm2 = norm_layer(dim)
84
+ hidden_dim = int(dim * mlp_ratio)
85
+ self.mlp = nn.Sequential(
86
+ nn.Linear(dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, dim)
87
+ )
88
+
89
+ def forward(self, tokens):
90
+ normalized = self.norm1(tokens)
91
+ tokens = tokens + self.attention(
92
+ normalized, normalized, normalized, need_weights=False
93
+ )[0]
94
+ return tokens + self.mlp(self.norm2(tokens))
95
+
96
+
97
+ class SatMAE(nn.Module):
98
+ """Masked autoencoder for temporal or grouped multispectral imagery.
99
+
100
+ Temporal inputs use shape ``[B, T, C, H, W]`` and optional timestamps
101
+ ``[B, T]``. Multispectral inputs use shape ``[B, C, H, W]``.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ image_size=224,
107
+ patch_size=16,
108
+ in_channels=3,
109
+ frames=3,
110
+ embed_dim=1024,
111
+ encoder_depth=24,
112
+ encoder_heads=16,
113
+ decoder_dim=512,
114
+ decoder_depth=8,
115
+ decoder_heads=16,
116
+ mlp_ratio=4.0,
117
+ mode="temporal",
118
+ spectral_groups=None,
119
+ mask_ratio=0.75,
120
+ norm_pix_loss=False,
121
+ same_mask=False,
122
+ spatial_mask=False,
123
+ temporal_embed_dim=None,
124
+ decoder_temporal_embed_dim=None,
125
+ channel_embed_dim=None,
126
+ decoder_channel_embed_dim=None,
127
+ norm_layer=None,
128
+ ):
129
+ super().__init__()
130
+ if image_size % patch_size:
131
+ raise ValueError("image_size must be divisible by patch_size")
132
+ if not 0.0 <= mask_ratio < 1.0:
133
+ raise ValueError("mask_ratio must be in [0, 1)")
134
+ if mode not in {"temporal", "multispectral"}:
135
+ raise ValueError("mode must be temporal or multispectral")
136
+ if embed_dim % encoder_heads or decoder_dim % decoder_heads:
137
+ raise ValueError("embedding dimensions must be divisible by head counts")
138
+
139
+ norm_layer = norm_layer or partial(nn.LayerNorm, eps=1e-6)
140
+ self.image_size = image_size
141
+ self.patch_size = patch_size
142
+ self.in_channels = in_channels
143
+ self.frames = frames
144
+ self.embed_dim = embed_dim
145
+ self.decoder_dim = decoder_dim
146
+ self.mode = mode
147
+ self.mask_ratio = mask_ratio
148
+ self.norm_pix_loss = norm_pix_loss
149
+ self.same_mask = same_mask
150
+ self.spatial_mask = spatial_mask
151
+ self.grid_size = image_size // patch_size
152
+ self.num_patches = self.grid_size ** 2
153
+
154
+ if mode == "temporal":
155
+ self.spectral_groups = None
156
+ self.patch_embed = PatchEmbed(
157
+ image_size, patch_size, in_channels, embed_dim
158
+ )
159
+ self.token_groups = frames
160
+ semantic_dim = temporal_embed_dim
161
+ if semantic_dim is None:
162
+ semantic_dim = min(128, max(2, embed_dim // 4))
163
+ decoder_semantic_dim = decoder_temporal_embed_dim
164
+ if decoder_semantic_dim is None:
165
+ decoder_semantic_dim = min(64, max(2, decoder_dim // 4))
166
+ prediction_dims = [patch_size ** 2 * in_channels]
167
+ else:
168
+ groups = spectral_groups or [list(range(in_channels))]
169
+ flattened = [channel for group in groups for channel in group]
170
+ if sorted(flattened) != list(range(in_channels)):
171
+ raise ValueError("spectral_groups must partition all input channels")
172
+ self.spectral_groups = tuple(tuple(group) for group in groups)
173
+ self.patch_embed = nn.ModuleList(
174
+ PatchEmbed(image_size, patch_size, len(group), embed_dim)
175
+ for group in self.spectral_groups
176
+ )
177
+ self.token_groups = len(self.spectral_groups)
178
+ semantic_dim = channel_embed_dim
179
+ if semantic_dim is None:
180
+ semantic_dim = min(256, max(2, embed_dim // 4))
181
+ decoder_semantic_dim = decoder_channel_embed_dim
182
+ if decoder_semantic_dim is None:
183
+ decoder_semantic_dim = min(128, max(2, decoder_dim // 4))
184
+ prediction_dims = [patch_size ** 2 * len(g) for g in self.spectral_groups]
185
+
186
+ if not 0 < semantic_dim < embed_dim:
187
+ raise ValueError("encoder semantic embedding dimension is invalid")
188
+ if not 0 < decoder_semantic_dim < decoder_dim:
189
+ raise ValueError("decoder semantic embedding dimension is invalid")
190
+ self.semantic_dim = semantic_dim
191
+ self.decoder_semantic_dim = decoder_semantic_dim
192
+
193
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
194
+ self.mask_token = nn.Parameter(torch.zeros(1, 1, decoder_dim))
195
+ self.register_buffer(
196
+ "spatial_pos_embed",
197
+ _sincos_2d(self.grid_size, embed_dim - semantic_dim),
198
+ persistent=True,
199
+ )
200
+ self.register_buffer(
201
+ "decoder_spatial_pos_embed",
202
+ _sincos_2d(self.grid_size, decoder_dim - decoder_semantic_dim),
203
+ persistent=True,
204
+ )
205
+ if mode == "multispectral":
206
+ group_ids = torch.arange(self.token_groups, dtype=torch.float32)
207
+ self.register_buffer(
208
+ "group_embed", _sincos_1d(group_ids, semantic_dim), persistent=True
209
+ )
210
+ self.register_buffer(
211
+ "decoder_group_embed",
212
+ _sincos_1d(group_ids, decoder_semantic_dim),
213
+ persistent=True,
214
+ )
215
+
216
+ self.blocks = nn.ModuleList(
217
+ TransformerBlock(embed_dim, encoder_heads, mlp_ratio, norm_layer)
218
+ for _ in range(encoder_depth)
219
+ )
220
+ self.norm = norm_layer(embed_dim)
221
+ self.decoder_embed = nn.Linear(embed_dim, decoder_dim)
222
+ self.decoder_blocks = nn.ModuleList(
223
+ TransformerBlock(decoder_dim, decoder_heads, mlp_ratio, norm_layer)
224
+ for _ in range(decoder_depth)
225
+ )
226
+ self.decoder_norm = norm_layer(decoder_dim)
227
+ self.decoder_pred = nn.ModuleList(
228
+ nn.Linear(decoder_dim, output_dim) for output_dim in prediction_dims
229
+ )
230
+ self.initialize_weights()
231
+
232
+ def initialize_weights(self):
233
+ patch_embeds = (
234
+ [self.patch_embed]
235
+ if isinstance(self.patch_embed, PatchEmbed)
236
+ else self.patch_embed
237
+ )
238
+ for patch_embed in patch_embeds:
239
+ nn.init.xavier_uniform_(patch_embed.proj.weight.flatten(1))
240
+ if patch_embed.proj.bias is not None:
241
+ nn.init.zeros_(patch_embed.proj.bias)
242
+ nn.init.normal_(self.cls_token, std=0.02)
243
+ nn.init.normal_(self.mask_token, std=0.02)
244
+ for module in self.modules():
245
+ if isinstance(module, nn.Linear):
246
+ nn.init.xavier_uniform_(module.weight)
247
+ if module.bias is not None:
248
+ nn.init.zeros_(module.bias)
249
+ elif isinstance(module, nn.LayerNorm):
250
+ nn.init.ones_(module.weight)
251
+ nn.init.zeros_(module.bias)
252
+
253
+ def patchify(self, images):
254
+ if images.ndim != 4:
255
+ raise ValueError("patchify expects [B, C, H, W]")
256
+ batch, channels, height, width = images.shape
257
+ patch = self.patch_size
258
+ if height != width or height != self.image_size:
259
+ raise ValueError(f"expected square images of size {self.image_size}")
260
+ patches = images.reshape(
261
+ batch, channels, height // patch, patch, width // patch, patch
262
+ )
263
+ patches = patches.permute(0, 2, 4, 1, 3, 5)
264
+ return patches.reshape(batch, self.num_patches, channels * patch ** 2)
265
+
266
+ def unpatchify(self, patches, channels=None):
267
+ channels = channels or self.in_channels
268
+ batch = patches.shape[0]
269
+ patch = self.patch_size
270
+ expected = channels * patch ** 2
271
+ if patches.shape[1:] != (self.num_patches, expected):
272
+ raise ValueError("patch tensor has incompatible shape")
273
+ images = patches.reshape(
274
+ batch, self.grid_size, self.grid_size, channels, patch, patch
275
+ )
276
+ images = images.permute(0, 3, 1, 4, 2, 5)
277
+ return images.reshape(batch, channels, self.image_size, self.image_size)
278
+
279
+ def _random_masking(self, tokens, mask_ratio, share_spatial_mask):
280
+ batch, length, dim = tokens.shape
281
+ if share_spatial_mask:
282
+ units = self.num_patches
283
+ len_keep_units = int(units * (1.0 - mask_ratio))
284
+ noise = torch.rand(batch, units, device=tokens.device)
285
+ spatial_order = noise.argsort(dim=1)
286
+ kept = [spatial_order[:, :len_keep_units] + g * units
287
+ for g in range(self.token_groups)]
288
+ removed = [spatial_order[:, len_keep_units:] + g * units
289
+ for g in range(self.token_groups)]
290
+ ids_shuffle = torch.cat(kept + removed, dim=1)
291
+ len_keep = len_keep_units * self.token_groups
292
+ else:
293
+ len_keep = int(length * (1.0 - mask_ratio))
294
+ ids_shuffle = torch.rand(batch, length, device=tokens.device).argsort(dim=1)
295
+ ids_restore = ids_shuffle.argsort(dim=1)
296
+ ids_keep = ids_shuffle[:, :len_keep]
297
+ visible = torch.gather(tokens, 1, ids_keep.unsqueeze(-1).expand(-1, -1, dim))
298
+ mask = torch.ones(batch, length, device=tokens.device)
299
+ mask[:, :len_keep] = 0
300
+ mask = torch.gather(mask, 1, ids_restore)
301
+ return visible, mask, ids_restore
302
+
303
+ def _temporal_tokens(self, images, timestamps):
304
+ if images.ndim != 5:
305
+ raise ValueError("temporal mode expects images shaped [B, T, C, H, W]")
306
+ batch, frames, channels, _, _ = images.shape
307
+ if frames != self.frames or channels != self.in_channels:
308
+ raise ValueError(
309
+ f"expected T={self.frames}, C={self.in_channels}; got T={frames}, C={channels}"
310
+ )
311
+ if timestamps is None:
312
+ timestamps = torch.arange(frames, device=images.device).expand(batch, -1)
313
+ if timestamps.shape[:2] != (batch, frames):
314
+ raise ValueError(
315
+ f"timestamps must start with shape {(batch, frames)}, "
316
+ f"got {tuple(timestamps.shape)}"
317
+ )
318
+ spatial = self.spatial_pos_embed.to(dtype=images.dtype)
319
+ time = _timestamp_embedding(timestamps, self.semantic_dim).to(dtype=images.dtype)
320
+ position = torch.cat(
321
+ (spatial.view(1, 1, self.num_patches, -1).expand(batch, frames, -1, -1),
322
+ time.unsqueeze(2).expand(-1, -1, self.num_patches, -1)),
323
+ dim=-1,
324
+ ).reshape(batch, frames * self.num_patches, self.embed_dim)
325
+ tokens = torch.stack(
326
+ [self.patch_embed(images[:, frame]) for frame in range(frames)], dim=1
327
+ ).reshape(batch, frames * self.num_patches, self.embed_dim)
328
+ return tokens + position, timestamps
329
+
330
+ def _multispectral_tokens(self, images):
331
+ if images.ndim != 4 or images.shape[1] != self.in_channels:
332
+ raise ValueError(
333
+ f"multispectral mode expects images shaped [B, {self.in_channels}, H, W]"
334
+ )
335
+ spatial = self.spatial_pos_embed.to(dtype=images.dtype)
336
+ group = self.group_embed.to(dtype=images.dtype)
337
+ positions = torch.cat(
338
+ (spatial.view(1, self.num_patches, -1).expand(self.token_groups, -1, -1),
339
+ group.view(self.token_groups, 1, -1).expand(-1, self.num_patches, -1)),
340
+ dim=-1,
341
+ ).reshape(1, self.token_groups * self.num_patches, self.embed_dim)
342
+ tokens = torch.cat(
343
+ [embed(images[:, channels])
344
+ for embed, channels in zip(self.patch_embed, self.spectral_groups)],
345
+ dim=1,
346
+ )
347
+ return tokens + positions
348
+
349
+ def forward_encoder(self, images, timestamps=None, mask_ratio=None):
350
+ ratio = self.mask_ratio if mask_ratio is None else mask_ratio
351
+ if not 0.0 <= ratio < 1.0:
352
+ raise ValueError("mask_ratio must be in [0, 1)")
353
+ if self.mode == "temporal":
354
+ tokens, timestamps = self._temporal_tokens(images, timestamps)
355
+ shared = self.same_mask
356
+ else:
357
+ tokens = self._multispectral_tokens(images)
358
+ shared = self.spatial_mask
359
+ tokens, mask, ids_restore = self._random_masking(tokens, ratio, shared)
360
+ cls = self.cls_token.expand(tokens.shape[0], -1, -1)
361
+ tokens = torch.cat((cls, tokens), dim=1)
362
+ for block in self.blocks:
363
+ tokens = block(tokens)
364
+ return self.norm(tokens), mask, ids_restore, timestamps
365
+
366
+ def _decoder_positions(self, batch, timestamps, dtype, device):
367
+ spatial = self.decoder_spatial_pos_embed.to(device=device, dtype=dtype)
368
+ if self.mode == "temporal":
369
+ semantic = _timestamp_embedding(timestamps, self.decoder_semantic_dim).to(dtype=dtype)
370
+ else:
371
+ semantic = self.decoder_group_embed.to(device=device, dtype=dtype)
372
+ semantic = semantic.unsqueeze(0).expand(batch, -1, -1)
373
+ position = torch.cat(
374
+ (spatial.view(1, 1, self.num_patches, -1).expand(batch, self.token_groups, -1, -1),
375
+ semantic.unsqueeze(2).expand(-1, -1, self.num_patches, -1)),
376
+ dim=-1,
377
+ )
378
+ return position.reshape(batch, self.token_groups * self.num_patches, self.decoder_dim)
379
+
380
+ def forward_decoder(self, latent, ids_restore, timestamps=None):
381
+ tokens = self.decoder_embed(latent)
382
+ mask_tokens = self.mask_token.expand(
383
+ tokens.shape[0], ids_restore.shape[1] + 1 - tokens.shape[1], -1
384
+ )
385
+ restored = torch.cat((tokens[:, 1:], mask_tokens), dim=1)
386
+ restored = torch.gather(
387
+ restored, 1, ids_restore.unsqueeze(-1).expand(-1, -1, self.decoder_dim)
388
+ )
389
+ positions = self._decoder_positions(
390
+ tokens.shape[0], timestamps, tokens.dtype, tokens.device
391
+ )
392
+ tokens = torch.cat((tokens[:, :1], restored + positions), dim=1)
393
+ for block in self.decoder_blocks:
394
+ tokens = block(tokens)
395
+ decoded = self.decoder_norm(tokens)[:, 1:]
396
+
397
+ if self.mode == "temporal":
398
+ return [self.decoder_pred[0](decoded)]
399
+ decoded = decoded.reshape(
400
+ decoded.shape[0], self.token_groups, self.num_patches, self.decoder_dim
401
+ )
402
+ return [head(decoded[:, index]) for index, head in enumerate(self.decoder_pred)]
403
+
404
+ def _targets(self, images):
405
+ if self.mode == "temporal":
406
+ return [torch.cat(
407
+ [self.patchify(images[:, frame]) for frame in range(self.frames)], dim=1
408
+ )]
409
+ return [self.patchify(images[:, group]) for group in self.spectral_groups]
410
+
411
+ def forward_loss(self, targets, predictions, mask):
412
+ losses = []
413
+ if self.mode == "temporal":
414
+ pairs = [(targets[0], predictions[0], mask)]
415
+ else:
416
+ group_mask = mask.reshape(mask.shape[0], self.token_groups, self.num_patches)
417
+ pairs = [
418
+ (target, prediction, group_mask[:, index])
419
+ for index, (target, prediction) in enumerate(zip(targets, predictions))
420
+ ]
421
+ removed = mask.new_zeros(())
422
+ total = mask.new_zeros(())
423
+ for target, prediction, patch_mask in pairs:
424
+ patch_loss = (prediction - target).square().mean(dim=-1)
425
+ total = total + (patch_loss * patch_mask).sum()
426
+ removed = removed + patch_mask.sum()
427
+ losses.append(patch_loss)
428
+ return total / removed.clamp_min(1), losses
429
+
430
+ def _normalize_targets(self, targets):
431
+ if not self.norm_pix_loss:
432
+ return targets
433
+ normalized = []
434
+ for target in targets:
435
+ mean = target.mean(dim=-1, keepdim=True)
436
+ variance = target.var(dim=-1, keepdim=True, unbiased=False)
437
+ normalized.append((target - mean) / torch.sqrt(variance + 1e-6))
438
+ return normalized
439
+
440
+ def _padded_outputs(self, tensors):
441
+ if self.mode == "temporal":
442
+ return tensors[0]
443
+ width = max(tensor.shape[-1] for tensor in tensors)
444
+ padded = []
445
+ for tensor in tensors:
446
+ if tensor.shape[-1] < width:
447
+ tensor = torch.nn.functional.pad(tensor, (0, width - tensor.shape[-1]))
448
+ padded.append(tensor)
449
+ return torch.cat(padded, dim=1)
450
+
451
+ def forward(self, images, timestamps=None, mask_ratio=None):
452
+ latent, mask, ids_restore, timestamps = self.forward_encoder(
453
+ images, timestamps, mask_ratio
454
+ )
455
+ predictions = self.forward_decoder(latent, ids_restore, timestamps)
456
+ targets = self._normalize_targets(self._targets(images))
457
+ loss, patch_losses = self.forward_loss(targets, predictions, mask)
458
+ return {
459
+ "loss": loss,
460
+ "prediction": self._padded_outputs(predictions),
461
+ "target": self._padded_outputs(targets),
462
+ "mask": mask.bool(),
463
+ "features": latent,
464
+ "ids_restore": ids_restore,
465
+ "group_predictions": predictions,
466
+ "group_targets": targets,
467
+ "patch_losses": patch_losses,
468
+ }
469
+
470
+
471
+ def satmae_vit_base_patch16(**kwargs):
472
+ return SatMAE(
473
+ patch_size=16, embed_dim=768, encoder_depth=12, encoder_heads=12,
474
+ decoder_dim=512, decoder_depth=8, decoder_heads=16,
475
+ temporal_embed_dim=128, decoder_temporal_embed_dim=64,
476
+ channel_embed_dim=256, decoder_channel_embed_dim=128, **kwargs
477
+ )
478
+
479
+
480
+ def satmae_vit_large_patch16(**kwargs):
481
+ return SatMAE(
482
+ patch_size=16, embed_dim=1024, encoder_depth=24, encoder_heads=16,
483
+ decoder_dim=512, decoder_depth=8, decoder_heads=16,
484
+ temporal_embed_dim=128, decoder_temporal_embed_dim=64,
485
+ channel_embed_dim=256, decoder_channel_embed_dim=128, **kwargs
486
+ )
487
+
488
+
489
+ def satmae_vit_huge_patch14(**kwargs):
490
+ return SatMAE(
491
+ patch_size=14, embed_dim=1280, encoder_depth=32, encoder_heads=16,
492
+ decoder_dim=512, decoder_depth=8, decoder_heads=16,
493
+ temporal_embed_dim=128, decoder_temporal_embed_dim=64,
494
+ channel_embed_dim=256, decoder_channel_embed_dim=128, **kwargs
495
+ )
scripts/fake_data.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create temporary fMoW-style temporal tensors and labels."""
2
+ import json
3
+ from pathlib import Path
4
+ import numpy as np
5
+ import yaml
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+ def main():
10
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
11
+ d = config["data"]
12
+ out = ROOT / d["root"]
13
+ out.mkdir(exist_ok=True)
14
+ rng = np.random.default_rng(config["seed"])
15
+ def make_split(samples):
16
+ shape = (samples, d["frames"], d["channels"], d["image_size"], d["image_size"])
17
+ images = rng.random(shape, dtype=np.float32)
18
+ timestamps = np.stack(
19
+ (
20
+ rng.integers(0, 21, size=(samples, d["frames"])),
21
+ rng.integers(0, 12, size=(samples, d["frames"])),
22
+ rng.integers(0, 24, size=(samples, d["frames"])),
23
+ ),
24
+ axis=-1,
25
+ ).astype(np.float32)
26
+ order = np.argsort(timestamps[..., 0] * 12 * 24 + timestamps[..., 1] * 24 + timestamps[..., 2], axis=1)
27
+ images = np.take_along_axis(images, order[:, :, None, None, None], axis=1)
28
+ timestamps = np.take_along_axis(timestamps, order[..., None], axis=1)
29
+ labels = rng.integers(d["num_classes"], size=samples, dtype=np.int64)
30
+ return images, timestamps, labels
31
+
32
+ train = make_split(d["train_samples"])
33
+ test = make_split(d["test_samples"])
34
+ np.savez_compressed(out / "train.npz", images=train[0], timestamps=train[1], labels=train[2])
35
+ np.savez_compressed(out / "test.npz", images=test[0], timestamps=test[1], labels=test[2])
36
+ (out / "format.json").write_text(json.dumps({
37
+ "format": "BTCHW",
38
+ "timestamp_format": "BT3: year_offset_2002, month_zero_based, hour",
39
+ "source_protocol": d["protocol"],
40
+ "data_source": "synthetic",
41
+ }, indent=2) + "\n")
42
+ print("created", out / "train.npz", out / "test.npz")
43
+
44
+ if __name__ == "__main__":
45
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run SatMAE masked reconstruction inference."""
2
+
3
+ import argparse
4
+ import importlib.util
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import torch
9
+ import yaml
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def parse_args():
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
18
+ parser.add_argument("--data", type=Path, default=None)
19
+ parser.add_argument("--checkpoint", type=Path, default=None)
20
+ parser.add_argument("--output-dir", type=Path, default=None)
21
+ parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
22
+ parser.add_argument("--mask-ratio", type=float, default=None)
23
+ return parser.parse_args()
24
+
25
+
26
+ def main():
27
+ args = parse_args()
28
+ config = yaml.safe_load(args.config.read_text())
29
+ spec = importlib.util.spec_from_file_location("satmae", ROOT / "model/satmae.py")
30
+ module = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(module)
32
+ model_args = {
33
+ key: value for key, value in config["model"].items()
34
+ if key not in {"architecture", "runtime_profile"}
35
+ }
36
+ model = module.SatMAE(**model_args)
37
+ checkpoint_path = args.checkpoint or ROOT / config["paths"]["checkpoint"]
38
+ if not checkpoint_path.exists():
39
+ raise FileNotFoundError(f"checkpoint not found: {checkpoint_path}")
40
+ checkpoint = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
41
+ model.load_state_dict(checkpoint["model"])
42
+ use_cuda = torch.cuda.is_available() and args.device != "cpu"
43
+ if args.device == "cuda" and not torch.cuda.is_available():
44
+ raise RuntimeError("CUDA was requested but is unavailable")
45
+ device = torch.device("cuda" if use_cuda else "cpu")
46
+ model.to(device).eval()
47
+
48
+ data_path = args.data or ROOT / config["data"]["root"] / "test.npz"
49
+ archive = np.load(data_path)
50
+ images = torch.from_numpy(archive["images"]).to(device)
51
+ timestamps = None
52
+ if "timestamps" in archive:
53
+ timestamps = torch.from_numpy(archive["timestamps"]).to(device)
54
+ with torch.inference_mode():
55
+ output = model(images, timestamps=timestamps, mask_ratio=args.mask_ratio)
56
+ output_dir = args.output_dir or ROOT / config["paths"]["inference_dir"]
57
+ output_dir.mkdir(parents=True, exist_ok=True)
58
+ payload = {
59
+ "target": output["target"].cpu().numpy(),
60
+ "prediction": output["prediction"].cpu().numpy(),
61
+ "mask": output["mask"].cpu().numpy(),
62
+ "labels": archive["labels"],
63
+ }
64
+ if timestamps is not None:
65
+ payload["timestamps"] = timestamps.cpu().numpy()
66
+ for index, (prediction, target) in enumerate(zip(
67
+ output["group_predictions"], output["group_targets"]
68
+ )):
69
+ payload[f"prediction_group_{index}"] = prediction.cpu().numpy()
70
+ payload[f"target_group_{index}"] = target.cpu().numpy()
71
+ np.savez_compressed(output_dir / "reconstruction.npz", **payload)
72
+ print("inference=", output_dir / "reconstruction.npz")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
scripts/result.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate SatMAE masked reconstruction across time and channels."""
2
+ import argparse
3
+ import json
4
+ from pathlib import Path
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np, yaml
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+
10
+
11
+ def parse_args():
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
14
+ parser.add_argument("--input", type=Path, default=None)
15
+ parser.add_argument("--output-dir", type=Path, default=None)
16
+ return parser.parse_args()
17
+
18
+
19
+ def unpatchify(patches, image_size, patch_size, channels):
20
+ side = image_size // patch_size
21
+ image = patches.reshape(side, side, channels, patch_size, patch_size)
22
+ return image.transpose(2, 0, 3, 1, 4).reshape(channels, image_size, image_size)
23
+
24
+
25
+ def display_image(image):
26
+ image = image[:3].transpose(1, 2, 0)
27
+ low, high = float(image.min()), float(image.max())
28
+ return np.clip((image - low) / max(high - low, 1e-8), 0.0, 1.0)
29
+
30
+
31
+ def main():
32
+ args = parse_args()
33
+ cfg = yaml.safe_load(args.config.read_text())
34
+ source = args.input or ROOT / cfg["paths"]["inference_dir"] / "reconstruction.npz"
35
+ if not source.exists(): raise FileNotFoundError("Run inference before evaluation")
36
+ a = np.load(source)
37
+ masked = a["mask"].astype(bool)
38
+ out = args.output_dir or ROOT / cfg["paths"]["evaluation_dir"]; out.mkdir(parents=True, exist_ok=True)
39
+ if cfg["model"]["mode"] == "multispectral":
40
+ groups = cfg["model"]["spectral_groups"]
41
+ group_mask = masked.reshape(masked.shape[0], len(groups), -1)
42
+ group_mse, masked_group_mse = [], []
43
+ weighted_error = 0.0
44
+ weighted_count = 0
45
+ masked_error_sum = 0.0
46
+ masked_count = 0
47
+ for index, group in enumerate(groups):
48
+ target = a[f"target_group_{index}"]
49
+ prediction = a[f"prediction_group_{index}"]
50
+ squared = (prediction - target) ** 2
51
+ patch_error = squared.mean(axis=-1)
52
+ group_mse.append(float(squared.mean()))
53
+ selected = group_mask[:, index]
54
+ masked_group_mse.append(float(patch_error[selected].mean()))
55
+ weighted_error += float(squared.sum())
56
+ weighted_count += squared.size
57
+ masked_error_sum += float(patch_error[selected].sum())
58
+ masked_count += int(selected.sum())
59
+ result = {
60
+ "masked_mse": masked_error_sum / max(masked_count, 1),
61
+ "reconstruction_mse": weighted_error / max(weighted_count, 1),
62
+ "group_mse": group_mse,
63
+ "masked_group_mse": masked_group_mse,
64
+ "data_source": "synthetic",
65
+ "protocol": cfg["data"]["protocol"],
66
+ }
67
+ (out / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
68
+ print(json.dumps(result, indent=2)); print("evaluation=", out)
69
+ return
70
+
71
+ squared_error = (a["prediction"] - a["target"]) ** 2
72
+ patch_error = squared_error.mean(axis=-1)
73
+ error = float(squared_error.mean())
74
+ masked_error = float(patch_error[masked].mean()) if masked.any() else error
75
+ result = {"masked_mse": masked_error, "reconstruction_mse": error, "data_source": "synthetic", "protocol": cfg["data"]["protocol"]}
76
+ size = cfg["model"]["image_size"]; patch = cfg["model"]["patch_size"]; channels = cfg["model"]["in_channels"]
77
+ patch_count = (size // patch) ** 2
78
+ target = a["target"][0, :patch_count]
79
+ prediction = a["prediction"][0, :patch_count]
80
+ patch_mask = a["mask"][0, :patch_count]
81
+ masked_target = target.copy(); masked_target[patch_mask] = 0.0
82
+ panels = [
83
+ ("Original", unpatchify(target, size, patch, channels)),
84
+ ("Masked input", unpatchify(masked_target, size, patch, channels)),
85
+ ("Reconstruction", unpatchify(prediction, size, patch, channels)),
86
+ ]
87
+ figure, axes = plt.subplots(1, 3, figsize=(10, 3.4))
88
+ for axis, (title, image) in zip(axes, panels):
89
+ axis.imshow(display_image(image)); axis.set_title(title); axis.axis("off")
90
+ figure.tight_layout(); figure.savefig(out / "temporal_frame_reconstruction.png", dpi=160, bbox_inches="tight"); plt.close(figure)
91
+
92
+ frames = cfg["model"]["frames"] if cfg["model"]["mode"] == "temporal" else 1
93
+ frame_mse, masked_frame_mse = [], []
94
+ channel_mse = np.zeros(channels, dtype=np.float64)
95
+ for frame in range(frames):
96
+ start, end = frame * patch_count, (frame + 1) * patch_count
97
+ frame_target = a["target"][:, start:end]
98
+ frame_prediction = a["prediction"][:, start:end]
99
+ mse = float(np.mean((frame_prediction - frame_target) ** 2))
100
+ frame_mse.append(mse)
101
+ frame_mask = masked[:, start:end]
102
+ frame_patch_error = patch_error[:, start:end]
103
+ masked_frame_mse.append(float(frame_patch_error[frame_mask].mean()) if frame_mask.any() else mse)
104
+ shaped_error = ((frame_prediction - frame_target) ** 2).reshape(-1, channels, patch * patch).mean(axis=(0, 2))
105
+ channel_mse += shaped_error
106
+ channel_mse /= frames
107
+
108
+ figure, axis = plt.subplots(figsize=(6.2, 3.8))
109
+ frame_index = np.arange(1, frames + 1)
110
+ axis.plot(frame_index, frame_mse, marker="o", linewidth=2, label="All patches")
111
+ axis.plot(frame_index, masked_frame_mse, marker="s", linewidth=2, label="Masked patches")
112
+ axis.set(xlabel="Time frame", ylabel="MSE", title="Temporal Reconstruction Error")
113
+ axis.legend(); axis.grid(alpha=0.25); figure.tight_layout(); figure.savefig(out / "temporal_reconstruction_error.png", dpi=160); plt.close(figure)
114
+
115
+ figure, axis = plt.subplots(figsize=(6.2, 3.8))
116
+ axis.bar(np.arange(channels), channel_mse, color="#287271")
117
+ axis.set_xticks(np.arange(channels), [f"C{i + 1}" for i in range(channels)])
118
+ axis.set(xlabel="Input channel", ylabel="MSE", title="Channel Reconstruction Error")
119
+ figure.tight_layout(); figure.savefig(out / "spectral_band_reconstruction.png", dpi=160); plt.close(figure)
120
+
121
+ result["frame_mse"] = frame_mse
122
+ result["masked_frame_mse"] = masked_frame_mse
123
+ result["channel_mse"] = channel_mse.tolist()
124
+ (out / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
125
+ print(json.dumps(result, indent=2)); print("evaluation=", out)
126
+
127
+ if __name__ == "__main__": main()
scripts/train.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-train SatMAE with masked reconstruction; supports torchrun."""
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import json
6
+ import math
7
+ import os
8
+ import random
9
+ from contextlib import nullcontext
10
+ from functools import partial
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import torch
15
+ import yaml
16
+ from torch import distributed as dist
17
+ from torch.nn.parallel import DistributedDataParallel
18
+ from torch.utils.data import DataLoader, Dataset, DistributedSampler
19
+
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+
23
+
24
+ class NPZDataset(Dataset):
25
+ def __init__(self, path, mode):
26
+ archive = np.load(path)
27
+ self.images = archive["images"]
28
+ self.timestamps = archive["timestamps"] if "timestamps" in archive else None
29
+ if mode == "temporal" and self.timestamps is None:
30
+ raise ValueError("temporal datasets must contain timestamps")
31
+
32
+ def __len__(self):
33
+ return len(self.images)
34
+
35
+ def __getitem__(self, index):
36
+ images = torch.from_numpy(self.images[index])
37
+ if self.timestamps is None:
38
+ return images, torch.empty(0)
39
+ return images, torch.from_numpy(self.timestamps[index])
40
+
41
+
42
+ def load_model_class():
43
+ spec = importlib.util.spec_from_file_location("satmae", ROOT / "model/satmae.py")
44
+ module = importlib.util.module_from_spec(spec)
45
+ spec.loader.exec_module(module)
46
+ return module.SatMAE
47
+
48
+
49
+ def model_config(config):
50
+ return {
51
+ key: value for key, value in config["model"].items()
52
+ if key not in {"architecture", "runtime_profile"}
53
+ }
54
+
55
+
56
+ def parse_args():
57
+ parser = argparse.ArgumentParser(description=__doc__)
58
+ parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
59
+ parser.add_argument("--data", type=Path, default=None)
60
+ parser.add_argument("--output", type=Path, default=None)
61
+ parser.add_argument("--resume", type=Path, default=None)
62
+ parser.add_argument("--epochs", type=int, default=None)
63
+ parser.add_argument("--batch-size", type=int, default=None)
64
+ parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default=None)
65
+ return parser.parse_args()
66
+
67
+
68
+ def cosine_learning_rate(progress, config, peak_lr):
69
+ warmup = config["warmup_epochs"]
70
+ if warmup > 0 and progress < warmup:
71
+ return peak_lr * progress / warmup
72
+ span = max(config["epochs"] - warmup, 1)
73
+ phase = min(max((progress - warmup) / span, 0.0), 1.0)
74
+ return config["min_learning_rate"] + 0.5 * (
75
+ peak_lr - config["min_learning_rate"]
76
+ ) * (1.0 + math.cos(math.pi * phase))
77
+
78
+
79
+ def main():
80
+ args = parse_args()
81
+ config = yaml.safe_load(args.config.read_text())
82
+ train_config = config["training"]
83
+ if args.epochs is not None:
84
+ train_config["epochs"] = args.epochs
85
+ if args.batch_size is not None:
86
+ train_config["batch_size"] = args.batch_size
87
+
88
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
89
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
90
+ rank = int(os.environ.get("RANK", "0"))
91
+ distributed = world_size > 1
92
+ requested_device = args.device or config["runtime"]["device"]
93
+ use_cuda = torch.cuda.is_available() and requested_device != "cpu"
94
+ if requested_device == "cuda" and not torch.cuda.is_available():
95
+ raise RuntimeError("CUDA was requested but is unavailable")
96
+ if distributed:
97
+ dist.init_process_group("nccl" if use_cuda else "gloo")
98
+ device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
99
+ if use_cuda:
100
+ torch.cuda.set_device(local_rank)
101
+
102
+ seed = config["seed"] + rank
103
+ random.seed(seed)
104
+ np.random.seed(seed)
105
+ torch.manual_seed(seed)
106
+ data_path = args.data or ROOT / config["data"]["root"] / "train.npz"
107
+ if not data_path.exists():
108
+ raise FileNotFoundError(f"training data not found: {data_path}")
109
+ dataset = NPZDataset(data_path, config["model"]["mode"])
110
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
111
+ loader = DataLoader(
112
+ dataset,
113
+ batch_size=train_config["batch_size"],
114
+ shuffle=sampler is None,
115
+ sampler=sampler,
116
+ num_workers=train_config["num_workers"],
117
+ pin_memory=use_cuda,
118
+ drop_last=False,
119
+ )
120
+
121
+ model = load_model_class()(**model_config(config)).to(device)
122
+ model_without_ddp = model
123
+ if distributed:
124
+ model = DistributedDataParallel(
125
+ model, device_ids=[local_rank] if use_cuda else None
126
+ )
127
+ model_without_ddp = model.module
128
+
129
+ effective_batch = (
130
+ train_config["batch_size"] * train_config["accum_iter"] * world_size
131
+ )
132
+ peak_lr = train_config["learning_rate"]
133
+ if peak_lr is None:
134
+ peak_lr = train_config["base_learning_rate"] * effective_batch / 256
135
+ decay, no_decay = [], []
136
+ for name, parameter in model_without_ddp.named_parameters():
137
+ if not parameter.requires_grad:
138
+ continue
139
+ (no_decay if parameter.ndim == 1 or name.endswith("bias") else decay).append(parameter)
140
+ optimizer = torch.optim.AdamW(
141
+ [
142
+ {"params": decay, "weight_decay": train_config["weight_decay"]},
143
+ {"params": no_decay, "weight_decay": 0.0},
144
+ ],
145
+ lr=peak_lr,
146
+ betas=(0.9, 0.95),
147
+ )
148
+ amp_enabled = bool(config["runtime"].get("amp", True) and use_cuda)
149
+ scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled)
150
+ start_epoch = 0
151
+ history = []
152
+ resume_path = args.resume
153
+ if resume_path is None and train_config.get("resume"):
154
+ resume_path = ROOT / train_config["resume"]
155
+ if resume_path is not None:
156
+ checkpoint = torch.load(resume_path, map_location="cpu", weights_only=False)
157
+ model_without_ddp.load_state_dict(checkpoint["model"])
158
+ optimizer.load_state_dict(checkpoint["optimizer"])
159
+ if checkpoint.get("scaler") is not None:
160
+ scaler.load_state_dict(checkpoint["scaler"])
161
+ start_epoch = checkpoint["epoch"] + 1
162
+ history = checkpoint.get("history", [])
163
+
164
+ checkpoint_path = args.output or ROOT / config["paths"]["checkpoint"]
165
+ metrics_path = ROOT / config["paths"]["training_metrics"]
166
+ optimizer.zero_grad(set_to_none=True)
167
+ for epoch in range(start_epoch, train_config["epochs"]):
168
+ if sampler is not None:
169
+ sampler.set_epoch(epoch)
170
+ model.train()
171
+ total_loss = 0.0
172
+ steps = len(loader)
173
+ for step, (images, timestamps) in enumerate(loader):
174
+ progress = epoch + step / max(steps, 1)
175
+ learning_rate = cosine_learning_rate(progress, train_config, peak_lr)
176
+ for group in optimizer.param_groups:
177
+ group["lr"] = learning_rate
178
+ images = images.to(device, non_blocking=use_cuda)
179
+ timestamps = timestamps.to(device, non_blocking=use_cuda)
180
+ timestamps = timestamps if timestamps.numel() else None
181
+ autocast = partial(torch.amp.autocast, "cuda") if amp_enabled else nullcontext
182
+ with autocast():
183
+ output = model(images, timestamps=timestamps)
184
+ loss = output["loss"] / train_config["accum_iter"]
185
+ if not torch.isfinite(loss):
186
+ raise ValueError(f"non-finite loss at epoch {epoch}, step {step}")
187
+ scaler.scale(loss).backward()
188
+ update = (step + 1) % train_config["accum_iter"] == 0 or step + 1 == steps
189
+ if update:
190
+ scaler.step(optimizer)
191
+ scaler.update()
192
+ optimizer.zero_grad(set_to_none=True)
193
+ total_loss += output["loss"].detach().item()
194
+
195
+ epoch_loss = total_loss / max(steps, 1)
196
+ record = {
197
+ "epoch": epoch + 1,
198
+ "reconstruction_loss": epoch_loss,
199
+ "learning_rate": optimizer.param_groups[0]["lr"],
200
+ }
201
+ history.append(record)
202
+ if rank == 0:
203
+ print(
204
+ f"epoch={epoch + 1} reconstruction_loss={epoch_loss:.6f} "
205
+ f"lr={record['learning_rate']:.3e}"
206
+ )
207
+ if (epoch + 1) % train_config["save_every"] == 0 or epoch + 1 == train_config["epochs"]:
208
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
209
+ torch.save(
210
+ {
211
+ "model": model_without_ddp.state_dict(),
212
+ "optimizer": optimizer.state_dict(),
213
+ "scaler": scaler.state_dict() if amp_enabled else None,
214
+ "epoch": epoch,
215
+ "history": history,
216
+ "config": config,
217
+ },
218
+ checkpoint_path,
219
+ )
220
+
221
+ if rank == 0:
222
+ metrics_path.parent.mkdir(parents=True, exist_ok=True)
223
+ metrics_path.write_text(json.dumps({
224
+ "history": history,
225
+ "protocol": config["data"]["protocol"],
226
+ "data_source": "synthetic" if "synthetic" in data_path.name or (data_path.parent / "format.json").exists() else "provided",
227
+ "effective_batch_size": effective_batch,
228
+ "peak_learning_rate": peak_lr,
229
+ }, indent=2) + "\n")
230
+ print("checkpoint=", checkpoint_path)
231
+ if distributed:
232
+ dist.destroy_process_group()
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main()
weight/.gitkeep ADDED
File without changes