zhangrenchao commited on
Commit
7a2d30b
·
verified ·
1 Parent(s): a094e43

Update SpectralGPT model package

Browse files
conf/config.yaml CHANGED
@@ -1,28 +1,42 @@
1
  model:
2
- image_size: 24
3
  in_channels: 12
4
  patch_size: 8
5
  spectral_patch_size: 3
6
- embed_dim: 48
7
- encoder_depth: 2
8
  encoder_heads: 4
9
  decoder_dim: 32
10
  decoder_depth: 1
11
  decoder_heads: 4
12
  mask_ratio: 0.90
13
- spectral_loss_weight: 1.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  data:
15
- path: ./data/fake_spectralgpt.npz
16
- samples: 8
17
- protocol: synthetic_sentinel2_npz
18
  training:
19
- epochs: 2
20
- batch_size: 2
21
  learning_rate: 0.0001
22
  weight_decay: 0.05
23
  save_dir: ./result/checkpoints
24
- checkpoint: ./result/checkpoints/best.pth
 
 
25
  runtime:
26
- device: auto
27
  seed: 42
28
  output_dir: ./result/output
 
1
  model:
 
2
  in_channels: 12
3
  patch_size: 8
4
  spectral_patch_size: 3
5
+ embed_dim: 32
6
+ encoder_depth: 1
7
  encoder_heads: 4
8
  decoder_dim: 32
9
  decoder_depth: 1
10
  decoder_heads: 4
11
  mask_ratio: 0.90
12
+ spectral_angle_weight: 0.1
13
+ spectral_gradient_weight: 0.1
14
+ stages:
15
+ - name: stage1
16
+ dataset: fMoW-Sentinel
17
+ image_size: 96
18
+ train_path: ./data/stage1_train.npz
19
+ train_samples: 2
20
+ epochs: 1
21
+ - name: stage2
22
+ dataset: BigEarthNet
23
+ image_size: 128
24
+ train_path: ./data/stage2_train.npz
25
+ train_samples: 2
26
+ epochs: 1
27
  data:
28
+ test_path: ./data/stage2_test.npz
29
+ test_samples: 1
30
+ protocol: spectralgpt_progressive_s2_v2
31
  training:
32
+ batch_size: 1
 
33
  learning_rate: 0.0001
34
  weight_decay: 0.05
35
  save_dir: ./result/checkpoints
36
+ checkpoint: ./result/checkpoints/final.pth
37
+ metrics: ./result/training/metrics.json
38
+ amp: true
39
  runtime:
40
+ device: cpu
41
  seed: 42
42
  output_dir: ./result/output
config.json CHANGED
@@ -5,42 +5,62 @@
5
  "SpectralGPT"
6
  ],
7
  "framework": "PyTorch",
8
- "domain": "earth-science",
9
  "task": "remote-sensing-masked-image-modeling",
10
  "implementation": {
11
  "entry_point": "model/spectralgpt.py",
12
- "scope": "compact 12-band Sentinel-2 masked autoencoder reproduction"
13
  },
14
  "architecture": {
15
- "family": "spectral-spatial masked autoencoder",
16
- "input_format": "NCHW multispectral images",
17
  "input_channels": 12,
18
- "image_size": 24,
 
 
 
 
 
 
 
 
 
19
  "patch_size": 8,
20
  "spectral_patch_size": 3,
21
- "embed_dim": 48,
22
- "encoder_depth": 2,
 
23
  "decoder_dim": 32,
24
  "decoder_depth": 1,
 
25
  "mask_ratio": 0.9,
26
- "training_objective": "masked reconstruction with spectral loss"
 
27
  },
28
  "data": {
29
  "datasets": [
30
- "fMoW-S2",
31
- "BigEarthNet-S2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  ],
33
- "official_paper": "https://arxiv.org/abs/2311.07113",
34
- "protocol": "synthetic_sentinel2_npz",
35
- "synthetic_samples": 8
36
  },
37
- "metrics": [
38
- "mse",
39
- "mae",
40
- "psnr_db",
41
- "per_band_rmse"
42
- ],
43
  "configuration_sources": [
 
44
  "conf/config.yaml",
45
  "model/spectralgpt.py"
46
  ]
 
5
  "SpectralGPT"
6
  ],
7
  "framework": "PyTorch",
8
+ "domain": "earth-observation",
9
  "task": "remote-sensing-masked-image-modeling",
10
  "implementation": {
11
  "entry_point": "model/spectralgpt.py",
12
+ "scope": "compact spatial-spectral masked autoencoder with progressive two-stage pretraining"
13
  },
14
  "architecture": {
15
+ "family": "3D spatial-spectral masked autoencoder",
 
16
  "input_channels": 12,
17
+ "stage_image_shapes": [
18
+ [
19
+ 96,
20
+ 96
21
+ ],
22
+ [
23
+ 128,
24
+ 128
25
+ ]
26
+ ],
27
  "patch_size": 8,
28
  "spectral_patch_size": 3,
29
+ "embed_dim": 32,
30
+ "encoder_depth": 1,
31
+ "encoder_heads": 4,
32
  "decoder_dim": 32,
33
  "decoder_depth": 1,
34
+ "decoder_heads": 4,
35
  "mask_ratio": 0.9,
36
+ "spectral_angle_weight": 0.1,
37
+ "spectral_gradient_weight": 0.1
38
  },
39
  "data": {
40
  "datasets": [
41
+ "fMoW-Sentinel",
42
+ "BigEarthNet"
43
+ ],
44
+ "protocol": "spectralgpt_progressive_s2_v2",
45
+ "input_format": "NCHW_NPZ",
46
+ "stages": [
47
+ {
48
+ "name": "stage1",
49
+ "dataset": "fMoW-Sentinel",
50
+ "image_size": 96,
51
+ "train_samples": 2
52
+ },
53
+ {
54
+ "name": "stage2",
55
+ "dataset": "BigEarthNet",
56
+ "image_size": 128,
57
+ "train_samples": 2
58
+ }
59
  ],
60
+ "test_samples": 1
 
 
61
  },
 
 
 
 
 
 
62
  "configuration_sources": [
63
+ "configuration.json",
64
  "conf/config.yaml",
65
  "model/spectralgpt.py"
66
  ]
configuration.json CHANGED
@@ -1,9 +1,10 @@
1
  {
2
  "framework": "PyTorch",
 
3
  "task": "remote_sensing_masked_image_modeling",
4
  "model": "SpectralGPT",
5
  "input_format": "NCHW_NPZ",
6
- "protocol": "synthetic_sentinel2_npz",
7
  "default_config": "conf/config.yaml",
8
  "train": "scripts/train.py",
9
  "inference": "scripts/inference.py",
 
1
  {
2
  "framework": "PyTorch",
3
+ "license": "GPL-3.0",
4
  "task": "remote_sensing_masked_image_modeling",
5
  "model": "SpectralGPT",
6
  "input_format": "NCHW_NPZ",
7
+ "protocol": "spectralgpt_progressive_s2_v2",
8
  "default_config": "conf/config.yaml",
9
  "train": "scripts/train.py",
10
  "inference": "scripts/inference.py",
model/spectralgpt.py CHANGED
@@ -8,7 +8,8 @@ class SpectralGPT(nn.Module):
8
  def __init__(self, image_size=24, in_channels=12, patch_size=8,
9
  spectral_patch_size=3, embed_dim=48, encoder_depth=2,
10
  encoder_heads=4, decoder_dim=32, decoder_depth=1,
11
- decoder_heads=4, mask_ratio=0.9, spectral_loss_weight=1.0):
 
12
  super().__init__()
13
  if image_size % patch_size or in_channels % spectral_patch_size:
14
  raise ValueError("Image and spectral dimensions must be divisible by token sizes")
@@ -21,7 +22,8 @@ class SpectralGPT(nn.Module):
21
  self.num_tokens = self.spatial_tokens * self.spectral_tokens
22
  self.token_pixels = patch_size * patch_size * spectral_patch_size
23
  self.mask_ratio = mask_ratio
24
- self.spectral_loss_weight = spectral_loss_weight
 
25
 
26
  self.patch_embed = nn.Conv3d(
27
  1, embed_dim,
@@ -96,17 +98,31 @@ class SpectralGPT(nn.Module):
96
  prediction = self.decoder_pred(self.decoder_norm(self.decoder(full + self.decoder_pos(positions))))
97
  target = self.patchify(images)
98
  token_error = (prediction - target).pow(2).mean(-1)
99
- token_loss = (token_error * mask).sum() / mask.sum().clamp_min(1)
100
- n = images.shape[0]
101
- predicted_grid = prediction.reshape(n, self.spectral_tokens, self.spatial_tokens, -1)
102
- target_grid = target.reshape(n, self.spectral_tokens, self.spatial_tokens, -1)
103
- spectral_loss = (predicted_grid[:, 1:] - target_grid[:, 1:]).pow(2).mean()
104
- loss = token_loss + self.spectral_loss_weight * spectral_loss
 
 
 
 
 
 
 
 
 
 
 
105
  return {
106
  "loss": loss,
107
- "token_loss": token_loss,
108
- "spectral_loss": spectral_loss,
 
109
  "prediction": prediction,
110
  "mask": mask,
111
- "reconstruction": self.unpatchify(prediction),
 
 
112
  }
 
8
  def __init__(self, image_size=24, in_channels=12, patch_size=8,
9
  spectral_patch_size=3, embed_dim=48, encoder_depth=2,
10
  encoder_heads=4, decoder_dim=32, decoder_depth=1,
11
+ decoder_heads=4, mask_ratio=0.9, spectral_angle_weight=0.1,
12
+ spectral_gradient_weight=0.1):
13
  super().__init__()
14
  if image_size % patch_size or in_channels % spectral_patch_size:
15
  raise ValueError("Image and spectral dimensions must be divisible by token sizes")
 
22
  self.num_tokens = self.spatial_tokens * self.spectral_tokens
23
  self.token_pixels = patch_size * patch_size * spectral_patch_size
24
  self.mask_ratio = mask_ratio
25
+ self.spectral_angle_weight = spectral_angle_weight
26
+ self.spectral_gradient_weight = spectral_gradient_weight
27
 
28
  self.patch_embed = nn.Conv3d(
29
  1, embed_dim,
 
98
  prediction = self.decoder_pred(self.decoder_norm(self.decoder(full + self.decoder_pos(positions))))
99
  target = self.patchify(images)
100
  token_error = (prediction - target).pow(2).mean(-1)
101
+ masked_mse = (token_error * mask).sum() / mask.sum().clamp_min(1)
102
+ mask_image = self.unpatchify(mask.unsqueeze(-1).expand(-1, -1, self.token_pixels))
103
+ predicted_image = self.unpatchify(prediction)
104
+ completed = images * (1.0 - mask_image) + predicted_image * mask_image
105
+ spectral_mask = mask_image.any(dim=1)
106
+ completed_norm = completed.norm(dim=1)
107
+ target_norm = images.norm(dim=1)
108
+ valid_sam = spectral_mask & (completed_norm > 1e-6) & (target_norm > 1e-6)
109
+ cosine = (completed * images).sum(dim=1) / (completed_norm * target_norm).clamp_min(1e-6)
110
+ angles = torch.acos(cosine.clamp(-1.0, 1.0))
111
+ spectral_angle = (angles * valid_sam).sum() / valid_sam.sum().clamp_min(1)
112
+ completed_gradient = completed[:, 1:] - completed[:, :-1]
113
+ target_gradient = images[:, 1:] - images[:, :-1]
114
+ gradient_mask = torch.maximum(mask_image[:, 1:], mask_image[:, :-1]).bool()
115
+ spectral_gradient = ((completed_gradient - target_gradient).abs() * gradient_mask).sum() / gradient_mask.sum().clamp_min(1)
116
+ loss = (masked_mse + self.spectral_angle_weight * spectral_angle
117
+ + self.spectral_gradient_weight * spectral_gradient)
118
  return {
119
  "loss": loss,
120
+ "masked_mse": masked_mse,
121
+ "spectral_angle": spectral_angle,
122
+ "spectral_gradient": spectral_gradient,
123
  "prediction": prediction,
124
  "mask": mask,
125
+ "mask_image": mask_image,
126
+ "prediction_image": predicted_image,
127
+ "reconstruction": completed,
128
  }
scripts/fake_data.py CHANGED
@@ -5,15 +5,16 @@ import numpy as np
5
  import yaml
6
 
7
 
8
- def real_images(directory, size, samples):
9
  import tifffile
10
 
11
- paths = sorted(Path(directory).rglob("*.tif"))[:samples]
12
  if not paths:
13
- raise FileNotFoundError(f"No TIFF files found under {directory}")
14
  images = []
 
15
  for path in paths:
16
- image = tifffile.imread(path).astype(np.float32)
 
17
  if image.ndim != 3:
18
  raise ValueError(f"Expected a 13-band TIFF, got {image.shape} from {path}")
19
  if image.shape[0] == 13:
@@ -24,43 +25,86 @@ def real_images(directory, size, samples):
24
  y = np.linspace(0, image.shape[0] - 1, size).round().astype(int)
25
  x = np.linspace(0, image.shape[1] - 1, size).round().astype(int)
26
  image = image[y][:, x]
27
- images.append(np.clip(image / 10000.0, 0, 1).transpose(2, 0, 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return np.asarray(images, dtype=np.float32)
29
 
30
 
 
 
 
 
 
 
 
 
 
 
31
  def main():
32
  parser = argparse.ArgumentParser(description="Generate compact 12-band spectral data")
33
  parser.add_argument("--config", default="conf/config.yaml")
34
  parser.add_argument("--real-dir", help="Convert official 13-band Sentinel-2 TIFF files")
 
 
 
 
35
  args = parser.parse_args()
36
  with open(args.config, encoding="utf-8") as handle:
37
  config = yaml.safe_load(handle)
38
- size = config["model"]["image_size"]
39
- samples = config["data"]["samples"]
 
 
 
 
40
  if args.real_dir:
41
- images = real_images(args.real_dir, size, samples)
42
- source = "real"
43
- protocol = "real_sentinel2_npz"
 
 
 
 
 
 
 
 
 
 
 
44
  else:
45
- rng = np.random.default_rng(config["runtime"]["seed"])
46
- y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1)
47
- images = []
48
- for index in range(samples):
49
- phase = rng.uniform(0, 2 * np.pi)
50
- bands = []
51
- for band in range(12):
52
- pattern = 0.45 + 0.22 * np.sin((band + 1) * x + phase)
53
- pattern += 0.18 * np.cos((band / 3 + 1) * y - phase)
54
- pattern += rng.normal(0, 0.025, (size, size))
55
- bands.append(np.clip(pattern, 0, 1))
56
- images.append(bands)
57
- images = np.asarray(images, dtype=np.float32)
58
- source = "synthetic"
59
- protocol = config["data"]["protocol"]
60
- output = Path(config["data"]["path"])
61
- output.parent.mkdir(parents=True, exist_ok=True)
62
- np.savez_compressed(output, images=images, data_source=np.asarray(source), protocol=np.asarray(protocol))
63
- print(f"saved: {output} shape={images.shape} data_source={source} protocol={protocol}")
64
 
65
 
66
  if __name__ == "__main__":
 
5
  import yaml
6
 
7
 
8
+ def real_images(paths, size, scale_factor):
9
  import tifffile
10
 
 
11
  if not paths:
12
+ raise FileNotFoundError("No TIFF files supplied")
13
  images = []
14
+ scale_factors = []
15
  for path in paths:
16
+ image = tifffile.imread(path)
17
+ original_dtype = image.dtype
18
  if image.ndim != 3:
19
  raise ValueError(f"Expected a 13-band TIFF, got {image.shape} from {path}")
20
  if image.shape[0] == 13:
 
25
  y = np.linspace(0, image.shape[0] - 1, size).round().astype(int)
26
  x = np.linspace(0, image.shape[1] - 1, size).round().astype(int)
27
  image = image[y][:, x]
28
+ factor = scale_factor
29
+ if factor is None:
30
+ minimum = float(np.nanmin(image))
31
+ maximum = float(np.nanmax(image))
32
+ already_normalized = minimum >= 0.0 and maximum <= 1.0
33
+ factor = 10000.0 if not already_normalized and (
34
+ np.issubdtype(original_dtype, np.integer) or maximum > 1.0
35
+ ) else 1.0
36
+ images.append(np.clip(image.astype(np.float32) / factor, 0, 1).transpose(2, 0, 1))
37
+ scale_factors.append(factor)
38
+ return np.asarray(images, dtype=np.float32), np.asarray(scale_factors, dtype=np.float32)
39
+
40
+
41
+ def synthetic_images(count, size, seed):
42
+ rng = np.random.default_rng(seed)
43
+ y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1)
44
+ images = []
45
+ for index in range(count):
46
+ phase = rng.uniform(0, 2 * np.pi)
47
+ bands = []
48
+ for band in range(12):
49
+ pattern = 0.45 + 0.22 * np.sin((band + 1) * x + phase)
50
+ pattern += 0.18 * np.cos((band / 3 + 1) * y - phase)
51
+ pattern += rng.normal(0, 0.025, (size, size))
52
+ bands.append(np.clip(pattern, 0, 1))
53
+ images.append(bands)
54
  return np.asarray(images, dtype=np.float32)
55
 
56
 
57
+ def save_npz(output, images, source, protocol, normalization, scale_factors, stage):
58
+ band_order = np.asarray(["B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B8A", "B9", "B11", "B12"])
59
+ output.parent.mkdir(parents=True, exist_ok=True)
60
+ np.savez_compressed(output, images=images, data_source=np.asarray(source),
61
+ protocol=np.asarray(protocol), band_order=band_order,
62
+ normalization=np.asarray(normalization), scale_factors=scale_factors,
63
+ stage=np.asarray(stage))
64
+ print(f"saved: {output} shape={images.shape} stage={stage} data_source={source}")
65
+
66
+
67
  def main():
68
  parser = argparse.ArgumentParser(description="Generate compact 12-band spectral data")
69
  parser.add_argument("--config", default="conf/config.yaml")
70
  parser.add_argument("--real-dir", help="Convert official 13-band Sentinel-2 TIFF files")
71
+ parser.add_argument("--scale-factor", default="auto",
72
+ help="TIFF divisor, or 'auto' (10000 for integer/range > 1; otherwise 1)")
73
+ parser.add_argument("--stage", choices=("stage1", "stage2"), default="stage2",
74
+ help="Target stage for real TIFF conversion")
75
  args = parser.parse_args()
76
  with open(args.config, encoding="utf-8") as handle:
77
  config = yaml.safe_load(handle)
78
+ if args.scale_factor == "auto":
79
+ scale_factor = None
80
+ else:
81
+ scale_factor = float(args.scale_factor)
82
+ if not np.isfinite(scale_factor) or scale_factor <= 0:
83
+ raise ValueError("--scale-factor must be a positive finite number or 'auto'")
84
  if args.real_dir:
85
+ stage = next(item for item in config["stages"] if item["name"] == args.stage)
86
+ count = stage["train_samples"] + (config["data"]["test_samples"] if args.stage == "stage2" else 0)
87
+ paths = sorted(Path(args.real_dir).rglob("*.tif"))
88
+ if len(paths) < count:
89
+ raise ValueError(f"Need at least {count} TIFF files, found {len(paths)}")
90
+ images, scale_factors = real_images(paths[:count], stage["image_size"], scale_factor)
91
+ normalization = "divide_by_scale_factor_then_clip_0_1"
92
+ save_npz(Path(stage["train_path"]), images[:stage["train_samples"]], "real",
93
+ config["data"]["protocol"], normalization,
94
+ scale_factors[:stage["train_samples"]], args.stage)
95
+ if args.stage == "stage2":
96
+ save_npz(Path(config["data"]["test_path"]), images[stage["train_samples"]:], "real",
97
+ config["data"]["protocol"], normalization,
98
+ scale_factors[stage["train_samples"]:], "stage2")
99
  else:
100
+ for index, stage in enumerate(config["stages"]):
101
+ images = synthetic_images(stage["train_samples"], stage["image_size"], config["runtime"]["seed"] + index)
102
+ save_npz(Path(stage["train_path"]), images, "synthetic", config["data"]["protocol"],
103
+ "already_0_1", np.ones(len(images), np.float32), stage["name"])
104
+ test_size = config["stages"][-1]["image_size"]
105
+ images = synthetic_images(config["data"]["test_samples"], test_size, config["runtime"]["seed"] + 2)
106
+ save_npz(Path(config["data"]["test_path"]), images, "synthetic", config["data"]["protocol"],
107
+ "already_0_1", np.ones(len(images), np.float32), "stage2")
 
 
 
 
 
 
 
 
 
 
 
108
 
109
 
110
  if __name__ == "__main__":
scripts/inference.py CHANGED
@@ -14,40 +14,61 @@ def main():
14
  parser = argparse.ArgumentParser(description="Run SpectralGPT reconstruction")
15
  parser.add_argument("--config", default="conf/config.yaml")
16
  parser.add_argument("--checkpoint")
 
17
  args = parser.parse_args()
18
  with open(args.config, encoding="utf-8") as handle:
19
  config = yaml.safe_load(handle)
20
  requested = config["runtime"]["device"]
21
  device = torch.device("cuda" if torch.cuda.is_available() and requested != "cpu" else "cpu")
22
  torch.manual_seed(config["runtime"]["seed"])
23
- model = SpectralGPT(**config["model"]).to(device)
 
24
  checkpoint_path = args.checkpoint or config["training"]["checkpoint"]
25
  if not Path(checkpoint_path).exists():
26
  raise FileNotFoundError(
27
  f"Missing checkpoint: {checkpoint_path}. Run `python scripts/train.py` first."
28
  )
29
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
 
 
30
  model.load_state_dict(checkpoint["model"])
31
  model.eval()
32
- data_path = Path(config["data"]["path"])
33
  if not data_path.exists():
34
  raise FileNotFoundError(
35
  f"Missing inference data: {data_path}. Run `python scripts/fake_data.py` first."
36
  )
37
  with np.load(data_path) as data:
38
- images = torch.from_numpy(data["images"]).to(device)
39
  data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
40
  protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
41
- with torch.no_grad():
42
- output = model(images)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  output_dir = Path(config["runtime"]["output_dir"])
44
  output_dir.mkdir(parents=True, exist_ok=True)
45
  np.savez_compressed(output_dir / "reconstruction.npz",
46
- inputs=images.cpu().numpy(),
47
- reconstructions=output["reconstruction"].cpu().numpy(),
48
- masks=output["mask"].cpu().numpy(),
49
- data_source=np.asarray(data_source),
50
- protocol=np.asarray(protocol))
 
 
 
51
  print(
52
  f"saved: {output_dir / 'reconstruction.npz'} "
53
  f"data_source={data_source} protocol={protocol}"
 
14
  parser = argparse.ArgumentParser(description="Run SpectralGPT reconstruction")
15
  parser.add_argument("--config", default="conf/config.yaml")
16
  parser.add_argument("--checkpoint")
17
+ parser.add_argument("--batch-size", type=int)
18
  args = parser.parse_args()
19
  with open(args.config, encoding="utf-8") as handle:
20
  config = yaml.safe_load(handle)
21
  requested = config["runtime"]["device"]
22
  device = torch.device("cuda" if torch.cuda.is_available() and requested != "cpu" else "cpu")
23
  torch.manual_seed(config["runtime"]["seed"])
24
+ stage = config["stages"][-1]
25
+ model = SpectralGPT(image_size=stage["image_size"], **config["model"]).to(device)
26
  checkpoint_path = args.checkpoint or config["training"]["checkpoint"]
27
  if not Path(checkpoint_path).exists():
28
  raise FileNotFoundError(
29
  f"Missing checkpoint: {checkpoint_path}. Run `python scripts/train.py` first."
30
  )
31
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
32
+ if checkpoint.get("stage") != stage["name"] or checkpoint.get("image_size") != stage["image_size"]:
33
+ raise ValueError("Checkpoint is not the configured final stage2 checkpoint")
34
  model.load_state_dict(checkpoint["model"])
35
  model.eval()
36
+ data_path = Path(config["data"]["test_path"])
37
  if not data_path.exists():
38
  raise FileNotFoundError(
39
  f"Missing inference data: {data_path}. Run `python scripts/fake_data.py` first."
40
  )
41
  with np.load(data_path) as data:
42
+ images = data["images"].copy()
43
  data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
44
  protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
45
+ normalization = str(data["normalization"]) if "normalization" in data.files else "unknown"
46
+ scale_factors = data["scale_factors"].copy() if "scale_factors" in data.files else np.ones(len(images), np.float32)
47
+ stored_stage = str(data["stage"]) if "stage" in data.files else "unknown"
48
+ expected = (config["model"]["in_channels"], stage["image_size"], stage["image_size"])
49
+ if images.dtype != np.float32 or images.ndim != 4 or tuple(images.shape[1:]) != expected:
50
+ raise ValueError(f"Expected float32 stage2 test [N,{','.join(map(str, expected))}], got {images.dtype} {images.shape}")
51
+ if stored_stage != stage["name"]:
52
+ raise ValueError(f"Expected test stage {stage['name']}, got {stored_stage}")
53
+ batch_size = args.batch_size or config["training"]["batch_size"]
54
+ collected = {name: [] for name in ("reconstruction", "prediction_image", "mask", "mask_image")}
55
+ with torch.inference_mode():
56
+ for start in range(0, len(images), batch_size):
57
+ batch = torch.from_numpy(images[start:start + batch_size]).to(device)
58
+ output = model(batch)
59
+ for name in collected:
60
+ collected[name].append(output[name].cpu().numpy())
61
  output_dir = Path(config["runtime"]["output_dir"])
62
  output_dir.mkdir(parents=True, exist_ok=True)
63
  np.savez_compressed(output_dir / "reconstruction.npz",
64
+ inputs=images,
65
+ reconstructions=np.concatenate(collected["reconstruction"]),
66
+ predictions=np.concatenate(collected["prediction_image"]),
67
+ masks=np.concatenate(collected["mask"]),
68
+ mask_images=np.concatenate(collected["mask_image"]),
69
+ data_source=np.asarray(data_source),
70
+ protocol=np.asarray(protocol), normalization=np.asarray(normalization),
71
+ scale_factors=scale_factors, stage=np.asarray(stored_stage))
72
  print(
73
  f"saved: {output_dir / 'reconstruction.npz'} "
74
  f"data_source={data_source} protocol={protocol}"
scripts/result.py CHANGED
@@ -4,6 +4,7 @@ from pathlib import Path
4
 
5
  import numpy as np
6
  import yaml
 
7
 
8
 
9
  def rgb(image):
@@ -27,24 +28,41 @@ def main():
27
  with np.load(reconstruction_path) as data:
28
  inputs = data["inputs"]
29
  reconstructions = data["reconstructions"]
 
 
30
  data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
31
  protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
32
- mse = float(np.mean((inputs - reconstructions) ** 2))
33
- mae = float(np.mean(np.abs(inputs - reconstructions)))
 
 
34
  psnr = float(-10 * np.log10(max(mse, 1e-12)))
35
- spectral_rmse = np.sqrt(np.mean((inputs - reconstructions) ** 2, axis=(0, 2, 3)))
36
- metrics = {"mse": mse, "mae": mae, "psnr_db": psnr,
 
 
 
 
 
 
37
  "data_source": data_source, "protocol": protocol,
 
38
  "per_band_rmse": spectral_rmse.tolist()}
39
  with open(output_dir / "metrics.json", "w", encoding="utf-8") as handle:
40
  json.dump(metrics, handle, indent=2)
41
- panel = np.concatenate([rgb(inputs[0]), rgb(reconstructions[0])], axis=1)
42
- with open(output_dir / "reconstruction.ppm", "wb") as handle:
43
- handle.write(f"P6\n{panel.shape[1]} {panel.shape[0]}\n255\n".encode("ascii"))
44
- handle.write(panel.tobytes())
 
 
 
 
 
 
45
  print(json.dumps(metrics, indent=2))
46
  print(f"saved: {output_dir / 'metrics.json'}")
47
- print(f"saved: {output_dir / 'reconstruction.ppm'}")
48
 
49
 
50
  if __name__ == "__main__":
 
4
 
5
  import numpy as np
6
  import yaml
7
+ import matplotlib.pyplot as plt
8
 
9
 
10
  def rgb(image):
 
28
  with np.load(reconstruction_path) as data:
29
  inputs = data["inputs"]
30
  reconstructions = data["reconstructions"]
31
+ predictions = data["predictions"]
32
+ mask_images = data["mask_images"]
33
  data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
34
  protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
35
+ normalization = str(data["normalization"]) if "normalization" in data.files else "unknown"
36
+ denominator = max(float(mask_images.sum()), 1.0)
37
+ mse = float((((inputs - predictions) ** 2) * mask_images).sum() / denominator)
38
+ mae = float((np.abs(inputs - predictions) * mask_images).sum() / denominator)
39
  psnr = float(-10 * np.log10(max(mse, 1e-12)))
40
+ per_band_denominator = np.maximum(mask_images.sum(axis=(0, 2, 3)), 1)
41
+ spectral_rmse = np.sqrt((((inputs - predictions) ** 2) * mask_images).sum(axis=(0, 2, 3)) / per_band_denominator)
42
+ dot = (inputs * reconstructions).sum(axis=1)
43
+ norms = np.linalg.norm(inputs, axis=1) * np.linalg.norm(reconstructions, axis=1)
44
+ pixel_mask = (mask_images > 0).any(axis=1) & (norms > 1e-8)
45
+ sam = np.arccos(np.clip(dot / np.maximum(norms, 1e-8), -1, 1))
46
+ metrics = {"masked_mse": mse, "masked_mae": mae, "masked_psnr_db": psnr,
47
+ "masked_spectral_angle_deg": float(np.degrees(sam[pixel_mask]).mean()),
48
  "data_source": data_source, "protocol": protocol,
49
+ "normalization": normalization,
50
  "per_band_rmse": spectral_rmse.tolist()}
51
  with open(output_dir / "metrics.json", "w", encoding="utf-8") as handle:
52
  json.dump(metrics, handle, indent=2)
53
+ masked = inputs[0] * (1.0 - mask_images[0])
54
+ figure, axes = plt.subplots(1, 4, figsize=(13, 3.5))
55
+ for axis, image, title in zip(axes, [inputs[0], masked, predictions[0], reconstructions[0]],
56
+ ["Input", "Visible tokens", "MAE prediction", "Composite"]):
57
+ axis.imshow(rgb(image))
58
+ axis.set_title(title)
59
+ axis.axis("off")
60
+ figure.tight_layout()
61
+ figure.savefig(output_dir / "reconstruction.png", dpi=140)
62
+ plt.close(figure)
63
  print(json.dumps(metrics, indent=2))
64
  print(f"saved: {output_dir / 'metrics.json'}")
65
+ print(f"saved: {output_dir / 'reconstruction.png'}")
66
 
67
 
68
  if __name__ == "__main__":
scripts/train.py CHANGED
@@ -1,10 +1,13 @@
1
  import argparse
 
 
2
  import os
3
  from pathlib import Path
4
  import sys
5
 
6
  import numpy as np
7
  import torch
 
8
  import yaml
9
  from torch.nn.parallel import DistributedDataParallel
10
  from torch.utils.data import DataLoader, Dataset, DistributedSampler
@@ -14,13 +17,20 @@ from model.spectralgpt import SpectralGPT
14
 
15
 
16
  class SpectralDataset(Dataset):
17
- def __init__(self, path):
18
  with np.load(path) as data:
19
  if "images" not in data.files:
20
- raise ValueError(f"Dataset {path} is missing the images array")
21
  self.images = data["images"].copy()
22
  self.data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
23
  self.protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
 
 
 
 
 
 
 
24
 
25
  def __len__(self):
26
  return len(self.images)
@@ -29,85 +39,105 @@ class SpectralDataset(Dataset):
29
  return torch.from_numpy(self.images[index])
30
 
31
 
32
- def load_config(path):
33
- with open(path, encoding="utf-8") as handle:
34
- return yaml.safe_load(handle)
35
-
36
-
37
- def build_model(config):
38
- return SpectralGPT(**config["model"])
 
 
 
 
39
 
40
 
41
  def main():
42
- parser = argparse.ArgumentParser(description="Train compact SpectralGPT")
43
  parser.add_argument("--config", default="conf/config.yaml")
44
- parser.add_argument("--data")
45
- parser.add_argument("--epochs", type=int)
46
  args = parser.parse_args()
47
- config = load_config(args.config)
 
48
  distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
 
49
  local_rank = int(os.environ.get("LOCAL_RANK", "0"))
50
- if distributed:
51
- torch.distributed.init_process_group("nccl" if torch.cuda.is_available() else "gloo")
52
  requested = config["runtime"]["device"]
53
- use_accelerator = torch.cuda.is_available() and requested != "cpu"
54
- device = torch.device(f"cuda:{local_rank}" if use_accelerator else "cpu")
55
- torch.manual_seed(config["runtime"]["seed"] + local_rank)
56
- data_path = Path(args.data or config["data"]["path"])
57
- if not data_path.exists():
58
- raise FileNotFoundError(
59
- f"Missing training data: {data_path}. "
60
- "Run `python scripts/fake_data.py` for a synthetic connectivity test."
61
- )
62
- dataset = SpectralDataset(data_path)
63
- if dataset.images.ndim != 4 or tuple(dataset.images.shape[1:]) != (
64
- config["model"]["in_channels"], config["model"]["image_size"], config["model"]["image_size"]
65
- ):
66
- raise ValueError(
67
- f"Expected images shaped [N,{config['model']['in_channels']},"
68
- f"{config['model']['image_size']},{config['model']['image_size']}], "
69
- f"got {dataset.images.shape}"
70
- )
71
- if local_rank == 0:
72
- print(
73
- f"data_source={dataset.data_source} protocol={dataset.protocol} "
74
- f"samples={len(dataset)}"
75
- )
76
- sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
77
- loader = DataLoader(dataset, batch_size=config["training"]["batch_size"],
78
- sampler=sampler, shuffle=sampler is None)
79
- model = build_model(config).to(device)
80
  if distributed:
81
- model = DistributedDataParallel(model, device_ids=[local_rank] if use_accelerator else None)
82
- optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
83
- weight_decay=config["training"]["weight_decay"], betas=(0.9, 0.95))
84
  save_dir = Path(config["training"]["save_dir"])
85
- best = float("inf")
86
- for epoch in range(args.epochs or config["training"]["epochs"]):
87
- if sampler is not None:
88
- sampler.set_epoch(epoch)
89
- model.train()
90
- losses = []
91
- for images in loader:
92
- output = model(images.to(device))
93
- optimizer.zero_grad()
94
- output["loss"].backward()
95
- optimizer.step()
96
- losses.append(output["loss"].item())
97
- mean_loss = float(np.mean(losses))
98
- if local_rank == 0:
99
- print(f"epoch={epoch + 1} reconstruction_loss={mean_loss:.6f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  save_dir.mkdir(parents=True, exist_ok=True)
101
- state = model.module.state_dict() if distributed else model.state_dict()
102
- checkpoint = {"model": state, "epoch": epoch + 1, "loss": mean_loss,
103
  "data_source": dataset.data_source, "protocol": dataset.protocol,
104
- "format": "spectralgpt-compact-v1"}
105
- torch.save(checkpoint, save_dir / "last.pth")
106
- if mean_loss < best:
107
- best = mean_loss
108
- torch.save(checkpoint, save_dir / "best.pth")
109
- if local_rank == 0:
110
- print(f"saved: {save_dir / 'best.pth'}")
 
 
 
 
 
111
  if distributed:
112
  torch.distributed.destroy_process_group()
113
 
 
1
  import argparse
2
+ import json
3
+ import math
4
  import os
5
  from pathlib import Path
6
  import sys
7
 
8
  import numpy as np
9
  import torch
10
+ import torch.nn.functional as F
11
  import yaml
12
  from torch.nn.parallel import DistributedDataParallel
13
  from torch.utils.data import DataLoader, Dataset, DistributedSampler
 
17
 
18
 
19
  class SpectralDataset(Dataset):
20
+ def __init__(self, path, image_size, stage):
21
  with np.load(path) as data:
22
  if "images" not in data.files:
23
+ raise ValueError(f"Dataset {path} is missing images")
24
  self.images = data["images"].copy()
25
  self.data_source = str(data["data_source"]) if "data_source" in data.files else "unknown"
26
  self.protocol = str(data["protocol"]) if "protocol" in data.files else "unknown"
27
+ self.normalization = str(data["normalization"]) if "normalization" in data.files else "unknown"
28
+ stored_stage = str(data["stage"]) if "stage" in data.files else "unknown"
29
+ expected = (12, image_size, image_size)
30
+ if self.images.dtype != np.float32 or self.images.ndim != 4 or tuple(self.images.shape[1:]) != expected:
31
+ raise ValueError(f"Expected float32 [N,{','.join(map(str, expected))}], got {self.images.dtype} {self.images.shape}")
32
+ if stored_stage != stage:
33
+ raise ValueError(f"Expected stage metadata {stage}, got {stored_stage}")
34
 
35
  def __len__(self):
36
  return len(self.images)
 
39
  return torch.from_numpy(self.images[index])
40
 
41
 
42
+ def resize_spatial_position(state, old_size, new_size, patch_size):
43
+ if old_size == new_size:
44
+ return state
45
+ key = "spatial_pos"
46
+ position = state[key]
47
+ old_grid, new_grid = old_size // patch_size, new_size // patch_size
48
+ if position.shape[1] != old_grid * old_grid:
49
+ raise ValueError("Checkpoint spatial position shape does not match previous stage")
50
+ position = position.reshape(1, old_grid, old_grid, -1).permute(0, 3, 1, 2)
51
+ state[key] = F.interpolate(position, size=(new_grid, new_grid), mode="bicubic", align_corners=False).permute(0, 2, 3, 1).reshape(1, new_grid * new_grid, -1)
52
+ return state
53
 
54
 
55
  def main():
56
+ parser = argparse.ArgumentParser(description="Progressive two-stage SpectralGPT training")
57
  parser.add_argument("--config", default="conf/config.yaml")
 
 
58
  args = parser.parse_args()
59
+ with open(args.config, encoding="utf-8") as handle:
60
+ config = yaml.safe_load(handle)
61
  distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
62
+ rank = int(os.environ.get("RANK", "0"))
63
  local_rank = int(os.environ.get("LOCAL_RANK", "0"))
 
 
64
  requested = config["runtime"]["device"]
65
+ device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() and requested != "cpu" else "cpu")
66
+ if device.type == "cuda":
67
+ torch.cuda.set_device(device)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  if distributed:
69
+ torch.distributed.init_process_group("nccl" if device.type == "cuda" else "gloo")
70
+ torch.manual_seed(config["runtime"]["seed"] + rank)
71
+ amp_enabled = bool(config["training"].get("amp", True) and device.type == "cuda")
72
  save_dir = Path(config["training"]["save_dir"])
73
+ history = []
74
+ previous_state = None
75
+ previous_size = None
76
+
77
+ for stage in config["stages"]:
78
+ path = Path(stage["train_path"])
79
+ if not path.exists():
80
+ raise FileNotFoundError(f"Missing {stage['name']} data: {path}. Run scripts/fake_data.py")
81
+ dataset = SpectralDataset(path, stage["image_size"], stage["name"])
82
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
83
+ loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler,
84
+ shuffle=sampler is None)
85
+ model = SpectralGPT(image_size=stage["image_size"], **config["model"])
86
+ if previous_state is not None:
87
+ model.load_state_dict(resize_spatial_position(previous_state, previous_size,
88
+ stage["image_size"], config["model"]["patch_size"]))
89
+ model = model.to(device)
90
+ if distributed:
91
+ model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
92
+ optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
93
+ weight_decay=config["training"]["weight_decay"], betas=(0.9, 0.95))
94
+ scaler = torch.amp.GradScaler("cuda", enabled=amp_enabled)
95
+ for epoch in range(stage["epochs"]):
96
+ if sampler is not None:
97
+ sampler.set_epoch(epoch)
98
+ model.train()
99
+ totals = torch.zeros(5, dtype=torch.float64, device=device)
100
+ for images in loader:
101
+ images = images.to(device)
102
+ optimizer.zero_grad(set_to_none=True)
103
+ with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=amp_enabled):
104
+ output = model(images)
105
+ scaler.scale(output["loss"]).backward()
106
+ scaler.step(optimizer)
107
+ scaler.update()
108
+ count = images.shape[0]
109
+ totals += torch.tensor([output[name].item() * count for name in
110
+ ("loss", "masked_mse", "spectral_angle", "spectral_gradient")] + [count],
111
+ dtype=torch.float64, device=device)
112
+ if distributed:
113
+ torch.distributed.all_reduce(totals)
114
+ values = (totals[:4] / totals[4]).tolist()
115
+ record = {"stage": stage["name"], "dataset": stage["dataset"], "image_size": stage["image_size"],
116
+ "patch_size": config["model"]["patch_size"], "epoch": epoch + 1,
117
+ **dict(zip(("loss", "masked_mse", "spectral_angle", "spectral_gradient"), values))}
118
+ history.append(record)
119
+ if rank == 0:
120
+ print(f"stage={stage['name']} epoch={epoch + 1} size={stage['image_size']} loss={values[0]:.6f}")
121
+ base_model = model.module if distributed else model
122
+ previous_state = {key: value.detach().cpu() for key, value in base_model.state_dict().items()}
123
+ previous_size = stage["image_size"]
124
+ if rank == 0:
125
  save_dir.mkdir(parents=True, exist_ok=True)
126
+ checkpoint = {"model": previous_state, "config": config, "stage": stage["name"],
127
+ "image_size": stage["image_size"], "stage_history": history,
128
  "data_source": dataset.data_source, "protocol": dataset.protocol,
129
+ "normalization": dataset.normalization, "backward_completed": True,
130
+ "format": "spectralgpt-progressive-v2"}
131
+ torch.save(checkpoint, save_dir / f"{stage['name']}.pth")
132
+ if stage is config["stages"][-1]:
133
+ torch.save(checkpoint, Path(config["training"]["checkpoint"]))
134
+
135
+ if rank == 0:
136
+ metrics = Path(config["training"]["metrics"])
137
+ metrics.parent.mkdir(parents=True, exist_ok=True)
138
+ metrics.write_text(json.dumps({"stage_history": history, "backward_completed": True,
139
+ "amp_enabled": amp_enabled}, indent=2) + "\n", encoding="utf-8")
140
+ print(f"saved: {config['training']['checkpoint']}")
141
  if distributed:
142
  torch.distributed.destroy_process_group()
143