zhangrenchao commited on
Commit
2d7eead
·
verified ·
1 Parent(s): 1558db5

Update SkySense model package

Browse files
conf/config.yaml CHANGED
@@ -1,30 +1,38 @@
1
  seed: 42
2
  data:
3
  root: data
4
- protocol: synthetic_skysense_npz
5
- train_samples: 24
6
- test_samples: 8
7
- image_size: 32
 
 
8
  num_classes: 6
9
  hr_channels: 3
10
  s2_channels: 10
11
  s1_channels: 2
12
- s2_timesteps: 4
13
- s1_timesteps: 3
 
14
  model:
15
- embed_dim: 32
16
- patch_size: 4
17
- temporal_depth: 2
 
 
18
  temporal_heads: 4
19
  num_regions: 16
20
  prototypes_per_region: 4
21
  train:
22
- epochs: 3
23
- batch_size: 4
24
  learning_rate: 0.001
25
  weight_decay: 0.0001
26
  alignment_weight: 0.05
27
  num_workers: 0
 
 
 
28
  paths:
29
  checkpoint: result/checkpoints/skysense.pt
30
  training_metrics: result/training/metrics.json
 
1
  seed: 42
2
  data:
3
  root: data
4
+ protocol: skysense_multires_multitemporal_v2
5
+ train_samples: 2
6
+ test_samples: 1
7
+ hr_size: 224
8
+ s2_size: 64
9
+ s1_size: 64
10
  num_classes: 6
11
  hr_channels: 3
12
  s2_channels: 10
13
  s1_channels: 2
14
+ hr_timesteps: 1
15
+ s2_timesteps: 20
16
+ s1_timesteps: 10
17
  model:
18
+ embed_dim: 24
19
+ hr_patch_size: 16
20
+ s2_patch_size: 8
21
+ s1_patch_size: 8
22
+ temporal_depth: 1
23
  temporal_heads: 4
24
  num_regions: 16
25
  prototypes_per_region: 4
26
  train:
27
+ epochs: 1
28
+ batch_size: 1
29
  learning_rate: 0.001
30
  weight_decay: 0.0001
31
  alignment_weight: 0.05
32
  num_workers: 0
33
+ amp: true
34
+ runtime:
35
+ device: cpu
36
  paths:
37
  checkpoint: result/checkpoints/skysense.pt
38
  training_metrics: result/training/metrics.json
config.json CHANGED
@@ -5,40 +5,60 @@
5
  "SkySense"
6
  ],
7
  "framework": "PyTorch",
8
- "domain": "earth-science",
9
- "task": "remote-sensing-multimodal-semantic-segmentation",
10
  "implementation": {
11
  "entry_point": "model/skysense.py",
12
- "scope": "compact multi-modal temporal SkySense reproduction"
13
  },
14
  "architecture": {
15
- "family": "multi-modal temporal remote sensing encoder",
16
- "input_format": "NPZ multimodal temporal arrays",
17
  "modalities": [
18
  "high-resolution RGB",
19
- "Sentinel-2 multispectral",
20
- "Sentinel-1 SAR"
21
  ],
22
- "image_size": 32,
23
- "embed_dim": 32,
24
- "patch_size": 4,
25
- "temporal_depth": 2,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  "temporal_heads": 4,
27
- "task": "semantic segmentation"
 
 
28
  },
29
  "data": {
30
- "dataset": "SkySense pretraining data",
31
- "official_paper": "https://arxiv.org/abs/2312.10115",
32
- "protocol": "synthetic_skysense_npz",
33
- "synthetic_train_samples": 24,
34
- "synthetic_test_samples": 8
 
 
 
 
35
  },
36
- "metrics": [
37
- "pixel_accuracy",
38
- "mean_iou",
39
- "per_class_iou"
40
- ],
41
  "configuration_sources": [
 
42
  "conf/config.yaml",
43
  "model/skysense.py"
44
  ]
 
5
  "SkySense"
6
  ],
7
  "framework": "PyTorch",
8
+ "domain": "earth-observation",
9
+ "task": "remote-sensing-semantic-segmentation",
10
  "implementation": {
11
  "entry_point": "model/skysense.py",
12
+ "scope": "compact multimodal spatial-temporal encoder with modality fusion and regional geo-context prototypes"
13
  },
14
  "architecture": {
15
+ "family": "factorized multimodal spatial-temporal encoder with geo-context prototypes",
 
16
  "modalities": [
17
  "high-resolution RGB",
18
+ "Sentinel-2",
19
+ "Sentinel-1"
20
  ],
21
+ "hr_shape": [
22
+ 1,
23
+ 3,
24
+ 224,
25
+ 224
26
+ ],
27
+ "sentinel_2_shape": [
28
+ 20,
29
+ 10,
30
+ 64,
31
+ 64
32
+ ],
33
+ "sentinel_1_shape": [
34
+ 10,
35
+ 2,
36
+ 64,
37
+ 64
38
+ ],
39
+ "embed_dim": 24,
40
+ "hr_patch_size": 16,
41
+ "sentinel_2_patch_size": 8,
42
+ "sentinel_1_patch_size": 8,
43
+ "temporal_depth": 1,
44
  "temporal_heads": 4,
45
+ "num_regions": 16,
46
+ "prototypes_per_region": 4,
47
+ "num_classes": 6
48
  },
49
  "data": {
50
+ "datasets": [
51
+ "High-resolution RGB imagery",
52
+ "Sentinel-1",
53
+ "Sentinel-2"
54
+ ],
55
+ "protocol": "skysense_multires_multitemporal_v2",
56
+ "input_format": "multimodal_npz",
57
+ "train_samples": 2,
58
+ "test_samples": 1
59
  },
 
 
 
 
 
60
  "configuration_sources": [
61
+ "configuration.json",
62
  "conf/config.yaml",
63
  "model/skysense.py"
64
  ]
configuration.json CHANGED
@@ -1,9 +1,10 @@
1
  {
2
  "framework": "PyTorch",
 
3
  "task": "remote_sensing_semantic_segmentation",
4
  "model": "SkySense",
5
  "input_format": "multimodal_npz",
6
- "protocol": "synthetic_skysense_npz",
7
  "default_config": "conf/config.yaml",
8
  "train": "scripts/train.py",
9
  "inference": "scripts/inference.py",
 
1
  {
2
  "framework": "PyTorch",
3
+ "license": "other",
4
  "task": "remote_sensing_semantic_segmentation",
5
  "model": "SkySense",
6
  "input_format": "multimodal_npz",
7
+ "protocol": "skysense_multires_multitemporal_v2",
8
  "default_config": "conf/config.yaml",
9
  "train": "scripts/train.py",
10
  "inference": "scripts/inference.py",
model/skysense.py CHANGED
@@ -31,7 +31,9 @@ class SkySense(nn.Module):
31
  s2_channels=10,
32
  s1_channels=2,
33
  embed_dim=32,
34
- patch_size=4,
 
 
35
  temporal_depth=2,
36
  temporal_heads=4,
37
  num_regions=16,
@@ -39,11 +41,10 @@ class SkySense(nn.Module):
39
  num_classes=6,
40
  ):
41
  super().__init__()
42
- self.patch_size = patch_size
43
  self.num_regions = num_regions
44
- self.hr_encoder = SpatialEncoder(hr_channels, embed_dim, patch_size)
45
- self.s2_encoder = SpatialEncoder(s2_channels, embed_dim, patch_size)
46
- self.s1_encoder = SpatialEncoder(s1_channels, embed_dim, patch_size)
47
  self.date_embedding = nn.Embedding(366, embed_dim)
48
  self.modality_embedding = nn.Parameter(torch.zeros(3, embed_dim))
49
  self.fusion_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
@@ -57,6 +58,12 @@ class SkySense(nn.Module):
57
  norm_first=True,
58
  )
59
  self.temporal_fusion = nn.TransformerEncoder(layer, temporal_depth)
 
 
 
 
 
 
60
  self.prototypes = nn.Parameter(
61
  torch.randn(num_regions, prototypes_per_region, embed_dim) * 0.02
62
  )
@@ -68,9 +75,16 @@ class SkySense(nn.Module):
68
  nn.init.normal_(self.date_embedding.weight, std=0.02)
69
  nn.init.normal_(self.modality_embedding, std=0.02)
70
  nn.init.normal_(self.fusion_token, std=0.02)
 
71
 
72
  def _add_context(self, features, dates, modality_index):
73
- date_context = self.date_embedding(dates.clamp(0, 365)).unsqueeze(-1).unsqueeze(-1)
 
 
 
 
 
 
74
  modality = self.modality_embedding[modality_index].view(1, 1, -1, 1, 1)
75
  return features + date_context + modality
76
 
@@ -81,16 +95,34 @@ class SkySense(nn.Module):
81
  self._add_context(self.s1_encoder(s1), dates_s1, 2),
82
  )
83
 
 
 
 
 
 
 
 
84
  def forward(self, hr, s2, s1, dates_hr, dates_s2, dates_s1, region):
 
 
 
 
 
 
85
  modality_features = self.encode_modalities(hr, s2, s1, dates_hr, dates_s2, dates_s1)
86
- temporal = torch.cat(modality_features, dim=1)
87
- batch, time, dim, out_height, out_width = temporal.shape
88
- temporal = temporal.permute(0, 3, 4, 1, 2).reshape(-1, time, dim)
89
- token = self.fusion_token.expand(temporal.shape[0], -1, -1)
90
- fused = self.temporal_fusion(torch.cat([token, temporal], dim=1))[:, 0]
 
 
 
 
 
91
  fused = fused.reshape(batch, out_height, out_width, dim)
92
 
93
- regional_prototypes = self.prototypes[region.remainder(self.num_regions)]
94
  query = F.normalize(fused, dim=-1)
95
  keys = F.normalize(regional_prototypes, dim=-1)
96
  attention = torch.einsum("bhwd,bpd->bhwp", query, keys).softmax(dim=-1)
 
31
  s2_channels=10,
32
  s1_channels=2,
33
  embed_dim=32,
34
+ hr_patch_size=16,
35
+ s2_patch_size=8,
36
+ s1_patch_size=8,
37
  temporal_depth=2,
38
  temporal_heads=4,
39
  num_regions=16,
 
41
  num_classes=6,
42
  ):
43
  super().__init__()
 
44
  self.num_regions = num_regions
45
+ self.hr_encoder = SpatialEncoder(hr_channels, embed_dim, hr_patch_size)
46
+ self.s2_encoder = SpatialEncoder(s2_channels, embed_dim, s2_patch_size)
47
+ self.s1_encoder = SpatialEncoder(s1_channels, embed_dim, s1_patch_size)
48
  self.date_embedding = nn.Embedding(366, embed_dim)
49
  self.modality_embedding = nn.Parameter(torch.zeros(3, embed_dim))
50
  self.fusion_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
 
58
  norm_first=True,
59
  )
60
  self.temporal_fusion = nn.TransformerEncoder(layer, temporal_depth)
61
+ modality_layer = nn.TransformerEncoderLayer(
62
+ d_model=embed_dim, nhead=temporal_heads, dim_feedforward=embed_dim * 4,
63
+ dropout=0.0, activation="gelu", batch_first=True, norm_first=True,
64
+ )
65
+ self.modality_fusion = nn.TransformerEncoder(modality_layer, 1)
66
+ self.modality_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
67
  self.prototypes = nn.Parameter(
68
  torch.randn(num_regions, prototypes_per_region, embed_dim) * 0.02
69
  )
 
75
  nn.init.normal_(self.date_embedding.weight, std=0.02)
76
  nn.init.normal_(self.modality_embedding, std=0.02)
77
  nn.init.normal_(self.fusion_token, std=0.02)
78
+ nn.init.normal_(self.modality_token, std=0.02)
79
 
80
  def _add_context(self, features, dates, modality_index):
81
+ if dates.dtype != torch.long:
82
+ raise TypeError(f"dates must use torch.int64, got {dates.dtype}")
83
+ if dates.shape != features.shape[:2]:
84
+ raise ValueError(f"dates shape {tuple(dates.shape)} does not match image batch/time {tuple(features.shape[:2])}")
85
+ if torch.any((dates < 0) | (dates > 364)):
86
+ raise ValueError("dates must contain day-of-year values in [0, 364]")
87
+ date_context = self.date_embedding(dates).unsqueeze(-1).unsqueeze(-1)
88
  modality = self.modality_embedding[modality_index].view(1, 1, -1, 1, 1)
89
  return features + date_context + modality
90
 
 
95
  self._add_context(self.s1_encoder(s1), dates_s1, 2),
96
  )
97
 
98
+ def _aggregate_time(self, features):
99
+ batch, time, dim, height, width = features.shape
100
+ sequence = features.permute(0, 3, 4, 1, 2).reshape(-1, time, dim)
101
+ token = self.fusion_token.expand(sequence.shape[0], -1, -1)
102
+ fused = self.temporal_fusion(torch.cat([token, sequence], dim=1))[:, 0]
103
+ return fused.reshape(batch, height, width, dim).permute(0, 3, 1, 2)
104
+
105
  def forward(self, hr, s2, s1, dates_hr, dates_s2, dates_s1, region):
106
+ if region.dtype != torch.long:
107
+ raise TypeError(f"region must use torch.int64, got {region.dtype}")
108
+ if region.shape != (hr.shape[0],):
109
+ raise ValueError(f"region must have shape [{hr.shape[0]}], got {tuple(region.shape)}")
110
+ if torch.any((region < 0) | (region >= self.num_regions)):
111
+ raise ValueError(f"region IDs must be in [0, {self.num_regions - 1}]")
112
  modality_features = self.encode_modalities(hr, s2, s1, dates_hr, dates_s2, dates_s1)
113
+ aggregated = [self._aggregate_time(feature) for feature in modality_features]
114
+ target_size = aggregated[0].shape[-2:]
115
+ aligned = [aggregated[0]] + [
116
+ F.interpolate(feature, size=target_size, mode="bilinear", align_corners=False)
117
+ for feature in aggregated[1:]
118
+ ]
119
+ batch, dim, out_height, out_width = aligned[0].shape
120
+ modalities = torch.stack(aligned, dim=1).permute(0, 3, 4, 1, 2).reshape(-1, 3, dim)
121
+ token = self.modality_token.expand(modalities.shape[0], -1, -1)
122
+ fused = self.modality_fusion(torch.cat([token, modalities], dim=1))[:, 0]
123
  fused = fused.reshape(batch, out_height, out_width, dim)
124
 
125
+ regional_prototypes = self.prototypes[region]
126
  query = F.normalize(fused, dim=-1)
127
  keys = F.normalize(regional_prototypes, dim=-1)
128
  attention = torch.einsum("bhwd,bpd->bhwp", query, keys).softmax(dim=-1)
scripts/fake_data.py CHANGED
@@ -18,15 +18,39 @@ def load_config():
18
  def make_split(path, count, config, seed):
19
  rng = np.random.default_rng(seed)
20
  data = config["data"]
21
- size = data["image_size"]
22
- hr = rng.normal(size=(count, 1, data["hr_channels"], size, size)).astype("float32")
23
- s2 = rng.normal(size=(count, data["s2_timesteps"], data["s2_channels"], size, size)).astype("float32")
24
- s1 = rng.normal(size=(count, data["s1_timesteps"], data["s1_channels"], size, size)).astype("float32")
25
- dates_hr = rng.integers(0, 365, size=(count, 1), dtype=np.int64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  dates_s2 = rng.integers(0, 365, size=(count, data["s2_timesteps"]), dtype=np.int64)
27
  dates_s1 = rng.integers(0, 365, size=(count, data["s1_timesteps"]), dtype=np.int64)
28
  region = rng.integers(0, config["model"]["num_regions"], size=count, dtype=np.int64)
29
- signal = hr[:, 0, 0] + 0.35 * s2[:, :, 3].mean(axis=1) - 0.2 * s1[:, :, 0].mean(axis=1)
 
 
 
30
  bins = np.quantile(signal, np.linspace(0, 1, data["num_classes"] + 1)[1:-1])
31
  labels = np.digitize(signal, bins).astype(np.int64)
32
  np.savez_compressed(
@@ -41,6 +65,9 @@ def make_split(path, count, config, seed):
41
  labels=labels,
42
  data_source=np.asarray("synthetic"),
43
  protocol=np.asarray(config["data"]["protocol"]),
 
 
 
44
  )
45
 
46
 
 
18
  def make_split(path, count, config, seed):
19
  rng = np.random.default_rng(seed)
20
  data = config["data"]
21
+ hr_size = data["hr_size"]
22
+ low_size = data["s2_size"]
23
+ if low_size != data["s1_size"]:
24
+ raise ValueError("The fake generator expects equal S1/S2 grid sizes")
25
+ y, x = np.mgrid[0:hr_size, 0:hr_size].astype(np.float32) / (hr_size - 1)
26
+ low_y, low_x = np.mgrid[0:low_size, 0:low_size].astype(np.float32) / (low_size - 1)
27
+ land = np.stack([x, y, np.sin(4 * np.pi * x) * np.cos(3 * np.pi * y)], axis=0)
28
+ hr = np.empty((count, data["hr_timesteps"], data["hr_channels"], hr_size, hr_size), np.float32)
29
+ s2 = np.empty((count, data["s2_timesteps"], data["s2_channels"], low_size, low_size), np.float32)
30
+ s1 = np.empty((count, data["s1_timesteps"], data["s1_channels"], low_size, low_size), np.float32)
31
+ for sample in range(count):
32
+ phase = rng.uniform(0, 2 * np.pi)
33
+ hr[sample, 0] = land + rng.normal(0, 0.04, land.shape)
34
+ for time in range(data["s2_timesteps"]):
35
+ seasonal = np.sin(2 * np.pi * time / data["s2_timesteps"] + phase)
36
+ s2[sample, time] = np.stack([
37
+ 0.5 + 0.2 * np.sin((band + 1) * low_x + phase) + 0.1 * seasonal * low_y
38
+ for band in range(data["s2_channels"])
39
+ ]) + rng.normal(0, 0.025, (data["s2_channels"], low_size, low_size))
40
+ for time in range(data["s1_timesteps"]):
41
+ seasonal = np.cos(2 * np.pi * time / data["s1_timesteps"] + phase)
42
+ s1[sample, time, 0] = -0.6 + 0.3 * np.sin(4 * np.pi * low_x) * np.cos(3 * np.pi * low_y) + 0.1 * seasonal
43
+ s1[sample, time, 1] = -0.8 + 0.2 * low_y - 0.1 * seasonal
44
+ hr[sample] = np.clip(hr[sample], -1, 1)
45
+ s2[sample] = np.clip(s2[sample], 0, 1)
46
+ dates_hr = rng.integers(0, 365, size=(count, data["hr_timesteps"]), dtype=np.int64)
47
  dates_s2 = rng.integers(0, 365, size=(count, data["s2_timesteps"]), dtype=np.int64)
48
  dates_s1 = rng.integers(0, 365, size=(count, data["s1_timesteps"]), dtype=np.int64)
49
  region = rng.integers(0, config["model"]["num_regions"], size=count, dtype=np.int64)
50
+ indices = np.linspace(0, low_size - 1, hr_size).round().astype(int)
51
+ s2_hr = s2[:, :, 3].mean(axis=1)[:, indices][:, :, indices]
52
+ s1_hr = s1[:, :, 0].mean(axis=1)[:, indices][:, :, indices]
53
+ signal = hr[:, 0, 0] + 0.35 * s2_hr - 0.2 * s1_hr
54
  bins = np.quantile(signal, np.linspace(0, 1, data["num_classes"] + 1)[1:-1])
55
  labels = np.digitize(signal, bins).astype(np.int64)
56
  np.savez_compressed(
 
65
  labels=labels,
66
  data_source=np.asarray("synthetic"),
67
  protocol=np.asarray(config["data"]["protocol"]),
68
+ band_order_hr=np.asarray(["R", "G", "B"]),
69
+ band_order_s2=np.asarray(["B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B11", "B12"]),
70
+ band_order_s1=np.asarray(["VV", "VH"]),
71
  )
72
 
73
 
scripts/inference.py CHANGED
@@ -1,6 +1,7 @@
1
  """Run SkySense inference and save arrays for evaluation."""
2
 
3
  import importlib.util
 
4
  from pathlib import Path
5
 
6
  import numpy as np
@@ -19,6 +20,9 @@ def load_model_class():
19
 
20
 
21
  def main():
 
 
 
22
  with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
23
  config = yaml.safe_load(handle)
24
  checkpoint_path = ROOT / config["paths"]["checkpoint"]
@@ -27,7 +31,8 @@ def main():
27
  f"Missing checkpoint: {checkpoint_path.relative_to(ROOT)}. "
28
  "Run `python scripts/train.py` first."
29
  )
30
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
31
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
32
  SkySense = load_model_class()
33
  model = SkySense(
@@ -47,15 +52,49 @@ def main():
47
  )
48
  archive = np.load(test_path)
49
  keys = ["hr", "s2", "s1", "dates_hr", "dates_s2", "dates_s1", "region"]
50
- tensors = {key: torch.from_numpy(archive[key]).to(device) for key in keys}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  with torch.inference_mode():
52
- output = model(tensors["hr"], tensors["s2"], tensors["s1"], tensors["dates_hr"], tensors["dates_s2"], tensors["dates_s1"], tensors["region"])
53
- probabilities = output["logits"].softmax(dim=1)
 
 
 
 
 
54
  output_dir = ROOT / config["paths"]["inference_dir"]
55
  output_dir.mkdir(parents=True, exist_ok=True)
56
- np.save(output_dir / "predictions.npy", probabilities.argmax(dim=1).cpu().numpy())
57
- np.save(output_dir / "probabilities.npy", probabilities.cpu().numpy())
58
- np.save(output_dir / "targets.npy", archive["labels"])
59
  data_source = str(archive["data_source"]) if "data_source" in archive.files else "unknown"
60
  protocol = str(archive["protocol"]) if "protocol" in archive.files else "unknown"
61
  np.savez(output_dir / "metadata.npz", data_source=data_source, protocol=protocol)
 
1
  """Run SkySense inference and save arrays for evaluation."""
2
 
3
  import importlib.util
4
+ import argparse
5
  from pathlib import Path
6
 
7
  import numpy as np
 
20
 
21
 
22
  def main():
23
+ parser = argparse.ArgumentParser(description="Run batched SkySense segmentation inference")
24
+ parser.add_argument("--batch-size", type=int)
25
+ args = parser.parse_args()
26
  with (ROOT / "conf" / "config.yaml").open(encoding="utf-8") as handle:
27
  config = yaml.safe_load(handle)
28
  checkpoint_path = ROOT / config["paths"]["checkpoint"]
 
31
  f"Missing checkpoint: {checkpoint_path.relative_to(ROOT)}. "
32
  "Run `python scripts/train.py` first."
33
  )
34
+ use_accelerator = torch.cuda.is_available() and config["runtime"].get("device", "auto") != "cpu"
35
+ device = torch.device("cuda" if use_accelerator else "cpu")
36
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
37
  SkySense = load_model_class()
38
  model = SkySense(
 
52
  )
53
  archive = np.load(test_path)
54
  keys = ["hr", "s2", "s1", "dates_hr", "dates_s2", "dates_s1", "region"]
55
+ arrays = {key: archive[key] for key in keys}
56
+ expected = {
57
+ "hr": (config["data"]["hr_timesteps"], config["data"]["hr_channels"], config["data"]["hr_size"], config["data"]["hr_size"]),
58
+ "s2": (config["data"]["s2_timesteps"], config["data"]["s2_channels"], config["data"]["s2_size"], config["data"]["s2_size"]),
59
+ "s1": (config["data"]["s1_timesteps"], config["data"]["s1_channels"], config["data"]["s1_size"], config["data"]["s1_size"]),
60
+ "dates_hr": (config["data"]["hr_timesteps"],),
61
+ "dates_s2": (config["data"]["s2_timesteps"],),
62
+ "dates_s1": (config["data"]["s1_timesteps"],),
63
+ "region": (),
64
+ }
65
+ sample_count = len(arrays["hr"])
66
+ for key, shape in expected.items():
67
+ if len(arrays[key]) != sample_count or tuple(arrays[key].shape[1:]) != shape:
68
+ raise ValueError(f"Invalid test {key} shape {arrays[key].shape}; expected [N,{','.join(map(str, shape))}]")
69
+ for key in ("hr", "s2", "s1"):
70
+ if not np.issubdtype(arrays[key].dtype, np.floating):
71
+ raise TypeError(f"{key} must use a floating dtype")
72
+ for key in ("dates_hr", "dates_s2", "dates_s1", "region"):
73
+ if arrays[key].dtype != np.int64:
74
+ raise TypeError(f"{key} must use int64")
75
+ if any(np.any((arrays[key] < 0) | (arrays[key] > 364)) for key in ("dates_hr", "dates_s2", "dates_s1")):
76
+ raise ValueError("Test dates must be in [0, 364]")
77
+ if np.any((arrays["region"] < 0) | (arrays["region"] >= config["model"]["num_regions"])):
78
+ raise ValueError("Test region IDs are out of range")
79
+ labels = archive["labels"]
80
+ if labels.dtype != np.int64 or labels.shape != (sample_count, config["data"]["hr_size"], config["data"]["hr_size"]):
81
+ raise ValueError("Test labels must be int64 [N,hr_size,hr_size]")
82
+ batch_size = args.batch_size or config["train"]["batch_size"]
83
+ predictions = []
84
+ all_probabilities = []
85
  with torch.inference_mode():
86
+ for start in range(0, len(arrays["hr"]), batch_size):
87
+ tensors = {key: torch.from_numpy(value[start:start + batch_size]).to(device)
88
+ for key, value in arrays.items()}
89
+ output = model(tensors["hr"], tensors["s2"], tensors["s1"], tensors["dates_hr"], tensors["dates_s2"], tensors["dates_s1"], tensors["region"])
90
+ probabilities = output["logits"].softmax(dim=1).cpu().numpy()
91
+ all_probabilities.append(probabilities)
92
+ predictions.append(probabilities.argmax(axis=1))
93
  output_dir = ROOT / config["paths"]["inference_dir"]
94
  output_dir.mkdir(parents=True, exist_ok=True)
95
+ np.save(output_dir / "predictions.npy", np.concatenate(predictions))
96
+ np.save(output_dir / "probabilities.npy", np.concatenate(all_probabilities))
97
+ np.save(output_dir / "targets.npy", labels)
98
  data_source = str(archive["data_source"]) if "data_source" in archive.files else "unknown"
99
  protocol = str(archive["protocol"]) if "protocol" in archive.files else "unknown"
100
  np.savez(output_dir / "metadata.npz", data_source=data_source, protocol=protocol)
scripts/result.py CHANGED
@@ -46,12 +46,11 @@ def main():
46
  output_dir.mkdir(parents=True, exist_ok=True)
47
  with (output_dir / "metrics.json").open("w", encoding="utf-8") as handle:
48
  json.dump(metrics, handle, indent=2)
49
- figure, axes = plt.subplots(2, 2, figsize=(7, 7))
50
- for index, axis in enumerate(axes.flat):
51
- sample = index // 2
52
- image = targets[sample] if index % 2 == 0 else predictions[sample]
53
  axis.imshow(image, vmin=0, vmax=classes - 1, cmap="terrain")
54
- axis.set_title(("Target" if index % 2 == 0 else "Prediction") + f" {sample}")
55
  axis.axis("off")
56
  figure.tight_layout()
57
  figure.savefig(output_dir / "comparison.png", dpi=120)
 
46
  output_dir.mkdir(parents=True, exist_ok=True)
47
  with (output_dir / "metrics.json").open("w", encoding="utf-8") as handle:
48
  json.dump(metrics, handle, indent=2)
49
+ figure, axes = plt.subplots(1, 2, figsize=(8, 4))
50
+ for index, axis in enumerate(axes):
51
+ image = targets[0] if index == 0 else predictions[0]
 
52
  axis.imshow(image, vmin=0, vmax=classes - 1, cmap="terrain")
53
+ axis.set_title("Target" if index == 0 else "Prediction")
54
  axis.axis("off")
55
  figure.tight_layout()
56
  figure.savefig(output_dir / "comparison.png", dpi=120)
scripts/train.py CHANGED
@@ -47,13 +47,14 @@ class NPZDataset(Dataset):
47
  return {key: torch.as_tensor(value[index]) for key, value in self.arrays.items()}
48
 
49
 
50
- def setup_device():
51
  world_size = int(os.environ.get("WORLD_SIZE", "1"))
52
  local_rank = int(os.environ.get("LOCAL_RANK", "0"))
 
53
  if world_size > 1:
54
- backend = "nccl" if torch.cuda.is_available() else "gloo"
55
  dist.init_process_group(backend=backend)
56
- if torch.cuda.is_available():
57
  torch.cuda.set_device(local_rank)
58
  return torch.device("cuda", local_rank), world_size, local_rank
59
  return torch.device("cpu"), world_size, local_rank
@@ -65,7 +66,8 @@ def main():
65
  random.seed(seed)
66
  np.random.seed(seed)
67
  torch.manual_seed(seed)
68
- device, world_size, local_rank = setup_device()
 
69
  dataset_path = ROOT / config["data"]["root"] / "train.npz"
70
  if not dataset_path.exists():
71
  raise FileNotFoundError(
@@ -73,10 +75,38 @@ def main():
73
  "Run `python scripts/fake_data.py` for a synthetic connectivity test."
74
  )
75
  dataset = NPZDataset(dataset_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  if local_rank == 0:
77
  print(
78
  f"data_source={dataset.data_source} protocol={dataset.protocol} "
79
- f"samples={len(dataset)} image_size={config['data']['image_size']}x{config['data']['image_size']}"
80
  )
81
  sampler = DistributedSampler(dataset, shuffle=True) if world_size > 1 else None
82
  loader = DataLoader(
@@ -97,24 +127,36 @@ def main():
97
  if world_size > 1:
98
  model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
99
  optimizer = torch.optim.AdamW(model.parameters(), lr=config["train"]["learning_rate"], weight_decay=config["train"]["weight_decay"])
 
100
  final_loss = float("nan")
 
 
101
  for epoch in range(config["train"]["epochs"]):
102
  if sampler is not None:
103
  sampler.set_epoch(epoch)
104
  model.train()
105
- total = 0.0
106
  for batch in loader:
107
  batch = {key: value.to(device) for key, value in batch.items()}
108
- output = model(batch["hr"], batch["s2"], batch["s1"], batch["dates_hr"], batch["dates_s2"], batch["dates_s1"], batch["region"])
109
- base_model = model.module if hasattr(model, "module") else model
110
- segmentation = torch.nn.functional.cross_entropy(output["logits"], batch["labels"])
111
- alignment = base_model.cross_modal_alignment_loss(output["features"])
112
- loss = segmentation + config["train"]["alignment_weight"] * alignment
113
  optimizer.zero_grad(set_to_none=True)
114
- loss.backward()
115
- optimizer.step()
116
- total += loss.item()
117
- final_loss = total / len(loader)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  if local_rank == 0:
119
  print(f"epoch={epoch + 1} loss={final_loss:.6f}")
120
  if local_rank == 0:
@@ -128,6 +170,10 @@ def main():
128
  json.dumps(
129
  {
130
  "final_loss": final_loss,
 
 
 
 
131
  "epochs": config["train"]["epochs"],
132
  "samples": len(dataset),
133
  "data_source": dataset.data_source,
 
47
  return {key: torch.as_tensor(value[index]) for key, value in self.arrays.items()}
48
 
49
 
50
+ def setup_device(config):
51
  world_size = int(os.environ.get("WORLD_SIZE", "1"))
52
  local_rank = int(os.environ.get("LOCAL_RANK", "0"))
53
+ use_accelerator = torch.cuda.is_available() and config["runtime"].get("device", "auto") != "cpu"
54
  if world_size > 1:
55
+ backend = "nccl" if use_accelerator else "gloo"
56
  dist.init_process_group(backend=backend)
57
+ if use_accelerator:
58
  torch.cuda.set_device(local_rank)
59
  return torch.device("cuda", local_rank), world_size, local_rank
60
  return torch.device("cpu"), world_size, local_rank
 
66
  random.seed(seed)
67
  np.random.seed(seed)
68
  torch.manual_seed(seed)
69
+ device, world_size, local_rank = setup_device(config)
70
+ amp_enabled = bool(config["train"].get("amp", True) and device.type == "cuda")
71
  dataset_path = ROOT / config["data"]["root"] / "train.npz"
72
  if not dataset_path.exists():
73
  raise FileNotFoundError(
 
75
  "Run `python scripts/fake_data.py` for a synthetic connectivity test."
76
  )
77
  dataset = NPZDataset(dataset_path)
78
+ expected = {
79
+ "hr": (config["data"]["hr_timesteps"], config["data"]["hr_channels"], config["data"]["hr_size"], config["data"]["hr_size"]),
80
+ "s2": (config["data"]["s2_timesteps"], config["data"]["s2_channels"], config["data"]["s2_size"], config["data"]["s2_size"]),
81
+ "s1": (config["data"]["s1_timesteps"], config["data"]["s1_channels"], config["data"]["s1_size"], config["data"]["s1_size"]),
82
+ "labels": (config["data"]["hr_size"], config["data"]["hr_size"]),
83
+ "dates_hr": (config["data"]["hr_timesteps"],),
84
+ "dates_s2": (config["data"]["s2_timesteps"],),
85
+ "dates_s1": (config["data"]["s1_timesteps"],),
86
+ "region": (),
87
+ }
88
+ for key, shape in expected.items():
89
+ if tuple(dataset.arrays[key].shape[1:]) != shape:
90
+ raise ValueError(f"Expected {key} shaped [N,{','.join(map(str, shape))}], got {dataset.arrays[key].shape}")
91
+ if len(dataset.arrays[key]) != len(dataset):
92
+ raise ValueError(f"Array {key} has {len(dataset.arrays[key])} samples, expected {len(dataset)}")
93
+ for key in ("hr", "s2", "s1"):
94
+ if not np.issubdtype(dataset.arrays[key].dtype, np.floating):
95
+ raise TypeError(f"{key} must use a floating dtype, got {dataset.arrays[key].dtype}")
96
+ for key in ("dates_hr", "dates_s2", "dates_s1", "region", "labels"):
97
+ if dataset.arrays[key].dtype != np.int64:
98
+ raise TypeError(f"{key} must use int64, got {dataset.arrays[key].dtype}")
99
+ for key in ("dates_hr", "dates_s2", "dates_s1"):
100
+ if np.any((dataset.arrays[key] < 0) | (dataset.arrays[key] > 364)):
101
+ raise ValueError(f"{key} must contain day-of-year values in [0, 364]")
102
+ if np.any((dataset.arrays["region"] < 0) | (dataset.arrays["region"] >= config["model"]["num_regions"])):
103
+ raise ValueError(f"region IDs must be in [0, {config['model']['num_regions'] - 1}]")
104
+ if np.any((dataset.arrays["labels"] < 0) | (dataset.arrays["labels"] >= config["data"]["num_classes"])):
105
+ raise ValueError(f"labels must be in [0, {config['data']['num_classes'] - 1}]")
106
  if local_rank == 0:
107
  print(
108
  f"data_source={dataset.data_source} protocol={dataset.protocol} "
109
+ f"samples={len(dataset)} hr={config['data']['hr_size']} s2={config['data']['s2_size']} s1={config['data']['s1_size']}"
110
  )
111
  sampler = DistributedSampler(dataset, shuffle=True) if world_size > 1 else None
112
  loader = DataLoader(
 
127
  if world_size > 1:
128
  model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
129
  optimizer = torch.optim.AdamW(model.parameters(), lr=config["train"]["learning_rate"], weight_decay=config["train"]["weight_decay"])
130
+ scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled)
131
  final_loss = float("nan")
132
+ final_segmentation = float("nan")
133
+ final_alignment = float("nan")
134
  for epoch in range(config["train"]["epochs"]):
135
  if sampler is not None:
136
  sampler.set_epoch(epoch)
137
  model.train()
138
+ totals = torch.zeros(4, dtype=torch.float64, device=device)
139
  for batch in loader:
140
  batch = {key: value.to(device) for key, value in batch.items()}
 
 
 
 
 
141
  optimizer.zero_grad(set_to_none=True)
142
+ with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=amp_enabled):
143
+ output = model(batch["hr"], batch["s2"], batch["s1"], batch["dates_hr"], batch["dates_s2"], batch["dates_s1"], batch["region"])
144
+ base_model = model.module if hasattr(model, "module") else model
145
+ segmentation = torch.nn.functional.cross_entropy(output["logits"], batch["labels"])
146
+ alignment = base_model.cross_modal_alignment_loss(output["features"])
147
+ loss = segmentation + config["train"]["alignment_weight"] * alignment
148
+ scaler.scale(loss).backward()
149
+ scaler.step(optimizer)
150
+ scaler.update()
151
+ batch_size = batch["hr"].shape[0]
152
+ totals += torch.tensor([loss.item() * batch_size, segmentation.item() * batch_size,
153
+ alignment.item() * batch_size, batch_size],
154
+ dtype=torch.float64, device=device)
155
+ if world_size > 1:
156
+ dist.all_reduce(totals, op=dist.ReduceOp.SUM)
157
+ final_loss = float((totals[0] / totals[3]).item())
158
+ final_segmentation = float((totals[1] / totals[3]).item())
159
+ final_alignment = float((totals[2] / totals[3]).item())
160
  if local_rank == 0:
161
  print(f"epoch={epoch + 1} loss={final_loss:.6f}")
162
  if local_rank == 0:
 
170
  json.dumps(
171
  {
172
  "final_loss": final_loss,
173
+ "segmentation_loss": final_segmentation,
174
+ "alignment_loss": final_alignment,
175
+ "backward_completed": True,
176
+ "amp_enabled": amp_enabled,
177
  "epochs": config["train"]["epochs"],
178
  "samples": len(dataset),
179
  "data_source": dataset.data_source,