Update SkySense model package
Browse files- conf/config.yaml +19 -11
- config.json +42 -22
- configuration.json +2 -1
- model/skysense.py +44 -12
- scripts/fake_data.py +33 -6
- scripts/inference.py +46 -7
- scripts/result.py +4 -5
- scripts/train.py +61 -15
conf/config.yaml
CHANGED
|
@@ -1,30 +1,38 @@
|
|
| 1 |
seed: 42
|
| 2 |
data:
|
| 3 |
root: data
|
| 4 |
-
protocol:
|
| 5 |
-
train_samples:
|
| 6 |
-
test_samples:
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
num_classes: 6
|
| 9 |
hr_channels: 3
|
| 10 |
s2_channels: 10
|
| 11 |
s1_channels: 2
|
| 12 |
-
|
| 13 |
-
|
|
|
|
| 14 |
model:
|
| 15 |
-
embed_dim:
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
temporal_heads: 4
|
| 19 |
num_regions: 16
|
| 20 |
prototypes_per_region: 4
|
| 21 |
train:
|
| 22 |
-
epochs:
|
| 23 |
-
batch_size:
|
| 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-
|
| 9 |
-
"task": "remote-sensing-
|
| 10 |
"implementation": {
|
| 11 |
"entry_point": "model/skysense.py",
|
| 12 |
-
"scope": "compact
|
| 13 |
},
|
| 14 |
"architecture": {
|
| 15 |
-
"family": "
|
| 16 |
-
"input_format": "NPZ multimodal temporal arrays",
|
| 17 |
"modalities": [
|
| 18 |
"high-resolution RGB",
|
| 19 |
-
"Sentinel-2
|
| 20 |
-
"Sentinel-1
|
| 21 |
],
|
| 22 |
-
"
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
"temporal_heads": 4,
|
| 27 |
-
"
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
"data": {
|
| 30 |
-
"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
|
|
|
|
|
|
|
| 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,
|
| 45 |
-
self.s2_encoder = SpatialEncoder(s2_channels, embed_dim,
|
| 46 |
-
self.s1_encoder = SpatialEncoder(s1_channels, embed_dim,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
fused = fused.reshape(batch, out_height, out_width, dim)
|
| 92 |
|
| 93 |
-
regional_prototypes = self.prototypes[region
|
| 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 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
with torch.inference_mode():
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
output_dir = ROOT / config["paths"]["inference_dir"]
|
| 55 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 56 |
-
np.save(output_dir / "predictions.npy",
|
| 57 |
-
np.save(output_dir / "probabilities.npy",
|
| 58 |
-
np.save(output_dir / "targets.npy",
|
| 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(
|
| 50 |
-
for index, axis in enumerate(axes
|
| 51 |
-
|
| 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(
|
| 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
|
| 55 |
dist.init_process_group(backend=backend)
|
| 56 |
-
if
|
| 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)}
|
| 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 |
-
|
| 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 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|