yzt15806542928 commited on
Commit
439c523
·
verified ·
1 Parent(s): c1c27f7

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: PyTorch
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Precipitation Nowcasting
10
+ - Weather Forecast
11
+ - MRMS
12
+ tasks: []
13
+ datasets:
14
+ - OneScience/MRMS
15
+ ---
16
+ <p align="center">
17
+ <strong>
18
+ <span style="font-size: 30px;">NowcastNet_Earth</span>
19
+ </strong>
20
+ </p>
21
+
22
+ # Model Introduction
23
+
24
+ NowcastNet is a large model for extreme-precipitation nowcasting proposed by a team from Tsinghua University. The research was published in the main edition of *Nature*.
25
+
26
+ Paper: Skilful nowcasting of extreme precipitation with NowcastNet
27
+
28
+ https://www.nature.com/articles/s41586-023-06184-4
29
+
30
+ # Model Description
31
+
32
+ NowcastNet combines data-driven deep learning with numerical methods based on physical equations in a unified framework. Two core networks work together to model precipitation processes at different spatial scales.
33
+
34
+ # Use Cases
35
+
36
+ | Scenario | Description |
37
+ | :---: | :--- |
38
+ | Short-term precipitation nowcasting training | Train NowcastNet with MRMS data. |
39
+ | Local quick validation | Use synthetic data to check data loading, model training and inference, and visualization of inference results. |
40
+ | ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
41
+ | Multi-GPU training | Use `torchrun` for data-parallel training across multiple GPUs or accelerators on one host. |
42
+
43
+ # Usage Guide
44
+
45
+ ## 1. OneCode Usage
46
+
47
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
48
+
49
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
50
+
51
+ ## 2. Manual Installation and Usage
52
+
53
+ **Hardware Requirements**
54
+
55
+ - A GPU or DCU is recommended.
56
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
57
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
58
+
59
+ ### Download the Model Package
60
+
61
+ ```bash
62
+ hf download OneScience-Group/NowcastNet --local-dir ./NowcastNet
63
+ cd NowcastNet
64
+ ```
65
+
66
+ ### Install the Runtime Environment
67
+
68
+ **DCU Environment**
69
+
70
+ ```bash
71
+ # Please activate DTK and CONDA first
72
+ conda create -n onescience311 python=3.11 -y
73
+ conda activate onescience311
74
+ # uv installation is supported
75
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
76
+ ```
77
+
78
+ **GPU Environment**
79
+ ```bash
80
+ # Please activate CONDA first
81
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
82
+ conda activate onescience311
83
+ # uv installation is supported
84
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
85
+ ```
86
+
87
+ ### Generate Synthetic Data
88
+
89
+ Synthetic data is only used to check the data protocol and program flow; it does not represent real MRMS data or forecast quality:
90
+
91
+ ```bash
92
+ python scripts/fake_data.py
93
+ ```
94
+
95
+ ### Training
96
+
97
+ Single GPU:
98
+
99
+ ```bash
100
+ python scripts/train.py
101
+ ```
102
+
103
+ Multi-GPU:
104
+
105
+ ```bash
106
+ torchrun --nproc_per_node=8 scripts/train.py
107
+ ```
108
+
109
+ Training weights are saved to `data/checkpoints/` by default.
110
+
111
+ ### Training Weights
112
+
113
+ This repository provides weights trained on MRMS data in the `weight/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
114
+
115
+ ### Inference
116
+
117
+ Inference reads the training weights from `data/checkpoints/` by default:
118
+
119
+ ```bash
120
+ python scripts/inference.py
121
+ ```
122
+
123
+ ### Evaluation and Visualization
124
+
125
+ ```bash
126
+ python scripts/result.py
127
+ ```
128
+
129
+ # Official OneScience Resources
130
+
131
+ | Platform | OneScience Main Repository | Skills Repository |
132
+ | --- | --- | --- |
133
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
134
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
135
+
136
+ # Citation and License
137
+
138
+ - This repository is a reproduction of the original NowcastNet paper.
conf/config.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ input_length: 9
3
+ total_length: 29
4
+ img_height: 512
5
+ img_width: 512
6
+ img_ch: 2
7
+ ngf: 32
8
+ device: auto
9
+ pretrained_model: ""
10
+ checkpoint_dir: "./data/checkpoint"
11
+ checkpoint_prefix: "model_bak"
12
+
13
+ data:
14
+ data_dir: "./data/data"
15
+ split: train
16
+ image_height: 512
17
+ image_width: 512
18
+ total_length: 29
19
+ input_data_type: float32
20
+ scale_divisor: 10.0
21
+ offset: 3.0
22
+ clip_max: 128.0
23
+ batch_size: 1
24
+ num_workers: 0
25
+
26
+ training:
27
+ epochs: 10
28
+ lr: 0.0003
29
+ weight_decay: 0.0
30
+ seed: 42
31
+ mode: generator
32
+ output_dir: "./results/training"
33
+ loss_history_file: "loss_history.json"
34
+
35
+ inference:
36
+ output_dir: "./results/inference"
37
+ num_samples: 1
38
+ threshold: 16.0
39
+
40
+ visualization:
41
+ output_dir: "./results/visualization"
config.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "NowcastNet",
3
+ "model_type": "nowcastnet",
4
+ "architectures": [
5
+ "Net"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "atmosphere",
9
+ "task": "extreme-precipitation-nowcasting",
10
+ "implementation": {
11
+ "entry_point": "model/nowcastnet.py",
12
+ "scope": "evolution and generative networks for radar precipitation nowcasting"
13
+ },
14
+ "architecture": {
15
+ "family": "physics-guided evolution network plus conditional generative network",
16
+ "input_length": 9,
17
+ "total_length": 29,
18
+ "prediction_length": 20,
19
+ "image_shape": [
20
+ 512,
21
+ 512
22
+ ],
23
+ "input_channels": 1,
24
+ "base_channels": 32,
25
+ "noise_channels": 32
26
+ },
27
+ "data": {
28
+ "dataset": "MRMS",
29
+ "protocol": "synthetic_radar_precipitation",
30
+ "scale_divisor": 10.0,
31
+ "offset": 3.0,
32
+ "clip_max": 128.0
33
+ },
34
+ "configuration_sources": [
35
+ "conf/config.yaml",
36
+ "model/nowcastnet.py",
37
+ "model/evolution_network.py",
38
+ "model/generative_network.py"
39
+ ]
40
+ }
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Jax","task":"other"}
model/evolution_module.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from torch.nn.utils import spectral_norm
5
+
6
+ class DoubleConv(nn.Module):
7
+
8
+ def __init__(self, in_channels, out_channels, kernel=3, mid_channels=None):
9
+ super().__init__()
10
+ if not mid_channels:
11
+ mid_channels = out_channels
12
+
13
+ self.double_conv = nn.Sequential(
14
+ nn.BatchNorm2d(in_channels),
15
+ nn.ReLU(inplace=True),
16
+ spectral_norm(nn.Conv2d(in_channels, mid_channels, kernel_size=kernel, padding=kernel//2)),
17
+ nn.BatchNorm2d(mid_channels),
18
+ nn.ReLU(inplace=True),
19
+ spectral_norm(nn.Conv2d(mid_channels, out_channels, kernel_size=kernel, padding=kernel//2)),
20
+ )
21
+ self.single_conv = nn.Sequential(
22
+ nn.BatchNorm2d(in_channels),
23
+ spectral_norm(nn.Conv2d(in_channels, out_channels, kernel_size=kernel, padding=kernel // 2))
24
+ )
25
+
26
+ def forward(self, x):
27
+ shortcut = self.single_conv(x)
28
+ x = self.double_conv(x)
29
+ x = x + shortcut
30
+ return x
31
+
32
+ class Down(nn.Module):
33
+
34
+ def __init__(self, in_channels, out_channels, kernel=3):
35
+ super().__init__()
36
+ self.maxpool_conv = nn.Sequential(
37
+ nn.MaxPool2d(2),
38
+ DoubleConv(in_channels, out_channels, kernel)
39
+ )
40
+
41
+ def forward(self, x):
42
+ x = self.maxpool_conv(x)
43
+ return x
44
+
45
+ class Up(nn.Module):
46
+
47
+ def __init__(self, in_channels, out_channels, bilinear=True, kernel=3):
48
+ super().__init__()
49
+ if bilinear:
50
+ self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
51
+ self.conv = DoubleConv(in_channels, out_channels, kernel=kernel, mid_channels=in_channels // 2)
52
+ else:
53
+ self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
54
+ self.conv = DoubleConv(in_channels, out_channels, kernel)
55
+
56
+ def forward(self, x1, x2):
57
+ x1 = self.up(x1)
58
+ # input is CHW
59
+ diffY = x2.size()[2] - x1.size()[2]
60
+ diffX = x2.size()[3] - x1.size()[3]
61
+
62
+ x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2,
63
+ diffY // 2, diffY - diffY // 2])
64
+ x = torch.cat([x2, x1], dim=1)
65
+ return self.conv(x)
66
+
67
+
68
+ class Up_S(nn.Module):
69
+
70
+ def __init__(self, in_channels, out_channels, bilinear=True, kernel=3):
71
+ super().__init__()
72
+ if bilinear:
73
+ self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
74
+ self.conv = DoubleConv(in_channels, out_channels, kernel=kernel, mid_channels=in_channels)
75
+ else:
76
+ self.up = nn.ConvTranspose2d(in_channels, in_channels, kernel_size=2, stride=2)
77
+ self.conv = DoubleConv(in_channels, out_channels, kernel)
78
+
79
+ def forward(self, x):
80
+ x = self.up(x)
81
+ return self.conv(x)
82
+
83
+
84
+ class OutConv(nn.Module):
85
+ def __init__(self, in_channels, out_channels):
86
+ super(OutConv, self).__init__()
87
+ self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1)
88
+
89
+ def forward(self, x):
90
+ return self.conv(x)
91
+
model/evolution_network.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn.functional as F
2
+ from .evolution_module import *
3
+
4
+ class Evolution_Network(nn.Module):
5
+ def __init__(self, n_channels, n_classes, base_c=64, bilinear=True):
6
+ super(Evolution_Network, self).__init__()
7
+ self.n_channels = n_channels
8
+ self.n_classes = n_classes
9
+ self.bilinear = bilinear
10
+ base_c = base_c
11
+ self.inc = DoubleConv(n_channels, base_c)
12
+ self.down1 = Down(base_c * 1, base_c * 2)
13
+ self.down2 = Down(base_c * 2, base_c * 4)
14
+ self.down3 = Down(base_c * 4, base_c * 8)
15
+ factor = 2 if bilinear else 1
16
+ self.down4 = Down(base_c * 8, base_c * 16 // factor)
17
+
18
+ self.up1 = Up(base_c * 16, base_c * 8 // factor, bilinear)
19
+ self.up2 = Up(base_c * 8, base_c * 4 // factor, bilinear)
20
+ self.up3 = Up(base_c * 4, base_c * 2 // factor, bilinear)
21
+ self.up4 = Up(base_c * 2, base_c * 1, bilinear)
22
+ self.outc = OutConv(base_c * 1, n_classes)
23
+ self.gamma = nn.Parameter(torch.zeros(1, n_classes, 1, 1), requires_grad=True)
24
+
25
+ self.up1_v = Up(base_c * 16, base_c * 8 // factor, bilinear)
26
+ self.up2_v = Up(base_c * 8, base_c * 4 // factor, bilinear)
27
+ self.up3_v = Up(base_c * 4, base_c * 2 // factor, bilinear)
28
+ self.up4_v = Up(base_c * 2, base_c * 1, bilinear)
29
+ self.outc_v = OutConv(base_c * 1, n_classes * 2)
30
+
31
+ def forward(self, x):
32
+ x1 = self.inc(x)
33
+ x2 = self.down1(x1)
34
+ x3 = self.down2(x2)
35
+ x4 = self.down3(x3)
36
+ x5 = self.down4(x4)
37
+ x = self.up1(x5, x4)
38
+ x = self.up2(x, x3)
39
+ x = self.up3(x, x2)
40
+ x = self.up4(x, x1)
41
+ x = self.outc(x) * self.gamma
42
+
43
+ v = self.up1_v(x5, x4)
44
+ v = self.up2_v(v, x3)
45
+ v = self.up3_v(v, x2)
46
+ v = self.up4_v(v, x1)
47
+ v = self.outc_v(v)
48
+ return x, v
model/generation_module.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPADE Module and Block are adapted from Nvidia SPADE project (https://github.com/NVlabs/SPADE).
2
+
3
+ import re
4
+ import sys
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+ import torch.nn.utils.spectral_norm as spectral_norm
10
+
11
+ class GenBlock(nn.Module):
12
+ def __init__(self, fin, fout, opt, use_se=False, dilation=1, double_conv=False):
13
+ super().__init__()
14
+ self.learned_shortcut = (fin != fout)
15
+ fmiddle = min(fin, fout)
16
+ self.opt = opt
17
+ self.double_conv = double_conv
18
+
19
+ self.pad = nn.ReflectionPad2d(dilation)
20
+ self.conv_0 = nn.Conv2d(fin, fmiddle, kernel_size=3, padding=0, dilation=dilation)
21
+ self.conv_1 = nn.Conv2d(fmiddle, fout, kernel_size=3, padding=0, dilation=dilation)
22
+
23
+ if self.learned_shortcut:
24
+ self.conv_s = nn.Conv2d(fin, fout, kernel_size=1, bias=False)
25
+
26
+ self.conv_0 = spectral_norm(self.conv_0)
27
+ self.conv_1 = spectral_norm(self.conv_1)
28
+ if self.learned_shortcut:
29
+ self.conv_s = spectral_norm(self.conv_s)
30
+
31
+ ic = opt.evo_ic
32
+
33
+ self.norm_0 = SPADE(fin, ic)
34
+ self.norm_1 = SPADE(fmiddle, ic)
35
+ if self.learned_shortcut:
36
+ self.norm_s = SPADE(fin, ic)
37
+
38
+ def forward(self, x, evo):
39
+ x_s = self.shortcut(x, evo)
40
+ dx = self.conv_0(self.pad(self.actvn(self.norm_0(x, evo))))
41
+ if self.double_conv:
42
+ dx = self.conv_1(self.pad(self.actvn(self.norm_1(dx, evo))))
43
+
44
+ out = x_s + dx
45
+
46
+ return out
47
+
48
+ def shortcut(self, x, evo):
49
+ if self.learned_shortcut:
50
+ x_s = self.conv_s(self.norm_s(x, evo))
51
+ else:
52
+ x_s = x
53
+ return x_s
54
+
55
+ def actvn(self, x):
56
+ return F.leaky_relu(x, 2e-1)
57
+
58
+
59
+ class SPADE(nn.Module):
60
+ def __init__(self, norm_nc, label_nc):
61
+ super().__init__()
62
+
63
+ ks = 3
64
+
65
+ self.param_free_norm = nn.InstanceNorm2d(norm_nc, affine=False)
66
+ nhidden = 64
67
+ ks = 3
68
+ pw = ks // 2
69
+ self.mlp_shared = nn.Sequential(
70
+ nn.ReflectionPad2d(pw),
71
+ nn.Conv2d(label_nc, nhidden, kernel_size=ks, padding=0),
72
+ nn.ReLU()
73
+ )
74
+ self.pad = nn.ReflectionPad2d(pw)
75
+ self.mlp_gamma = nn.Conv2d(nhidden, norm_nc, kernel_size=ks, padding=0)
76
+ self.mlp_beta = nn.Conv2d(nhidden, norm_nc, kernel_size=ks, padding=0)
77
+
78
+ def forward(self, x, evo):
79
+
80
+ normalized = self.param_free_norm(x)
81
+ evo = F.adaptive_avg_pool2d(evo, output_size=x.size()[2:])
82
+
83
+ actv = self.mlp_shared(evo)
84
+
85
+ gamma = self.mlp_gamma(self.pad(actv))
86
+ beta = self.mlp_beta(self.pad(actv))
87
+
88
+ out = normalized * (1 + gamma) + beta
89
+
90
+ return out
91
+
92
+
93
+
94
+
model/generative_network.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn as nn
4
+ import torch.nn.functional as F
5
+ from torch.autograd import Function
6
+ from .generation_module import GenBlock
7
+ from .evolution_module import *
8
+
9
+ class Generative_Encoder(nn.Module):
10
+ def __init__(self, n_channels, base_c=64):
11
+ super(Generative_Encoder, self).__init__()
12
+ base_c = base_c
13
+ self.inc = DoubleConv(n_channels, base_c, kernel=3)
14
+ self.down1 = Down(base_c * 1, base_c * 2, 3)
15
+ self.down2 = Down(base_c * 2, base_c * 4, 3)
16
+ self.down3 = Down(base_c * 4, base_c * 8, 3)
17
+
18
+ def forward(self, x):
19
+ x = self.inc(x)
20
+ x = self.down1(x)
21
+ x = self.down2(x)
22
+ x = self.down3(x)
23
+ return x
24
+
25
+ class Generative_Decoder(nn.Module):
26
+ def __init__(self, opt):
27
+ super().__init__()
28
+ self.opt = opt
29
+ nf = opt.ngf
30
+
31
+ ic = opt.ic_feature
32
+ self.fc = nn.Conv2d(ic, 8 * nf, 3, padding=1)
33
+
34
+ self.head_0 = GenBlock(8 * nf, 8 * nf, opt)
35
+
36
+ self.G_middle_0 = GenBlock(8 * nf, 4 * nf, opt, double_conv=True)
37
+ self.G_middle_1 = GenBlock(4 * nf, 4 * nf, opt, double_conv=True)
38
+
39
+ self.up_0 = GenBlock(4 * nf, 2 * nf, opt)
40
+
41
+ self.up_1 = GenBlock(2 * nf, 1 * nf, opt, double_conv=True)
42
+ self.up_2 = GenBlock(1 * nf, 1 * nf, opt, double_conv=True)
43
+
44
+ final_nc = nf * 1
45
+
46
+ self.conv_img = nn.Conv2d(final_nc, self.opt.gen_oc, 3, padding=1)
47
+ self.up = nn.Upsample(scale_factor=2)
48
+
49
+ def forward(self, x, evo):
50
+ x = self.fc(x)
51
+ x = self.head_0(x, evo)
52
+ x = self.up(x)
53
+ x = self.G_middle_0(x, evo)
54
+ x = self.G_middle_1(x, evo)
55
+ x = self.up(x)
56
+ x = self.up_0(x, evo)
57
+ x = self.up(x)
58
+ x = self.up_1(x, evo)
59
+ x = self.up_2(x, evo)
60
+ x = self.conv_img(F.leaky_relu(x, 2e-1))
61
+ return x
model/layers_utils.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch.nn.functional as F
2
+ import torch
3
+ import torch.nn as nn
4
+
5
+
6
+ def make_grid(input):
7
+ B, C, H, W = input.size()
8
+ # mesh grid
9
+ device = input.device
10
+ xx = torch.arange(0, W, device=device).view(1, -1).repeat(H, 1)
11
+ yy = torch.arange(0, H, device=device).view(-1, 1).repeat(1, W)
12
+ xx = xx.view(1, 1, H, W).repeat(B, 1, 1, 1)
13
+ yy = yy.view(1, 1, H, W).repeat(B, 1, 1, 1)
14
+ grid = torch.cat((xx, yy), 1).float()
15
+
16
+ return grid
17
+
18
+ def warp(input, flow, grid, mode="bilinear", padding_mode="zeros"):
19
+
20
+ B, C, H, W = input.size()
21
+ vgrid = grid + flow
22
+
23
+ vgrid[:, 0, :, :] = 2.0 * vgrid[:, 0, :, :].clone() / max(W - 1, 1) - 1.0
24
+ vgrid[:, 1, :, :] = 2.0 * vgrid[:, 1, :, :].clone() / max(H - 1, 1) - 1.0
25
+ vgrid = vgrid.permute(0, 2, 3, 1)
26
+ output = torch.nn.functional.grid_sample(input, vgrid, padding_mode=padding_mode, mode=mode, align_corners=True)
27
+ return output
28
+
29
+ def l2normalize(v, eps=1e-12):
30
+ return v / (v.norm() + eps)
31
+
32
+
33
+ class spectral_norm(nn.Module):
34
+ def __init__(self, module, name='weight', power_iterations=1):
35
+ super(spectral_norm, self).__init__()
36
+ self.module = module
37
+ self.name = name
38
+ self.power_iterations = power_iterations
39
+ if not self._made_params():
40
+ self._make_params()
41
+
42
+ def _update_u_v(self):
43
+ u = getattr(self.module, self.name + "_u")
44
+ v = getattr(self.module, self.name + "_v")
45
+ w = getattr(self.module, self.name + "_bar")
46
+
47
+ height = w.data.shape[0]
48
+ for _ in range(self.power_iterations):
49
+ v.data = l2normalize(torch.mv(torch.t(w.view(height,-1).data), u.data))
50
+ u.data = l2normalize(torch.mv(w.view(height,-1).data, v.data))
51
+
52
+ sigma = u.dot(w.view(height, -1).mv(v))
53
+ setattr(self.module, self.name, w / sigma.expand_as(w))
54
+
55
+ def _made_params(self):
56
+ try:
57
+ u = getattr(self.module, self.name + "_u")
58
+ v = getattr(self.module, self.name + "_v")
59
+ w = getattr(self.module, self.name + "_bar")
60
+ return True
61
+ except AttributeError:
62
+ return False
63
+
64
+ def _make_params(self):
65
+ w = getattr(self.module, self.name)
66
+
67
+ height = w.data.shape[0]
68
+ width = w.view(height, -1).data.shape[1]
69
+
70
+ u = nn.Parameter(w.data.new(height).normal_(0, 1), requires_grad=False)
71
+ v = nn.Parameter(w.data.new(width).normal_(0, 1), requires_grad=False)
72
+ u.data = l2normalize(u.data)
73
+ v.data = l2normalize(v.data)
74
+ w_bar = nn.Parameter(w.data)
75
+
76
+ del self.module._parameters[self.name]
77
+
78
+ self.module.register_parameter(self.name + "_u", u)
79
+ self.module.register_parameter(self.name + "_v", v)
80
+ self.module.register_parameter(self.name + "_bar", w_bar)
81
+
82
+
83
+ def forward(self, *args):
84
+ self._update_u_v()
85
+ return self.module.forward(*args)
model/model_factory.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Factory and checkpoint helpers for the project-local NowcastNet model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+ from typing import Mapping
7
+
8
+ import torch
9
+
10
+ from .nowcastnet import Net
11
+
12
+
13
+ def build_model(config, device: torch.device | str = "cpu") -> Net:
14
+ """Build a model from a mapping or namespace without requiring OneScience."""
15
+ if isinstance(config, Mapping):
16
+ config = SimpleNamespace(**config)
17
+ config.device = torch.device(device)
18
+ config.evo_ic = config.total_length - config.input_length
19
+ config.gen_oc = config.total_length - config.input_length
20
+ config.ic_feature = config.ngf * 10
21
+ return Net(config).to(config.device)
22
+
23
+
24
+ def load_checkpoint(model: torch.nn.Module, path: str, device: torch.device | str = "cpu") -> Mapping:
25
+ try:
26
+ state = torch.load(path, map_location=device, weights_only=True)
27
+ except TypeError: # torch < 2.0
28
+ state = torch.load(path, map_location=device)
29
+ checkpoint = state if isinstance(state, Mapping) else {}
30
+ model_state = checkpoint["state_dict"] if "state_dict" in checkpoint else state
31
+ model.load_state_dict(model_state, strict=True)
32
+ return checkpoint
model/noise_projector.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from .layers_utils import spectral_norm
4
+
5
+ class Noise_Projector(nn.Module):
6
+ def __init__(self, input_length, configs):
7
+ super(Noise_Projector, self).__init__()
8
+ self.input_length = input_length
9
+ self.conv_first = spectral_norm(nn.Conv2d(self.input_length, self.input_length * 2, kernel_size=3, padding=1))
10
+ self.L1 = ProjBlock(self.input_length * 2, self.input_length * 4)
11
+ self.L2 = ProjBlock(self.input_length * 4, self.input_length * 8)
12
+ self.L3 = ProjBlock(self.input_length * 8, self.input_length * 16)
13
+ self.L4 = ProjBlock(self.input_length * 16, self.input_length * 32)
14
+
15
+ def forward(self, x):
16
+ x = self.conv_first(x)
17
+ x = self.L1(x)
18
+ x = self.L2(x)
19
+ x = self.L3(x)
20
+ x = self.L4(x)
21
+
22
+ return x
23
+
24
+
25
+ class ProjBlock(nn.Module):
26
+ def __init__(self, in_channel, out_channel):
27
+ super(ProjBlock, self).__init__()
28
+ self.one_conv = spectral_norm(nn.Conv2d(in_channel, out_channel-in_channel, kernel_size=1, padding=0))
29
+ self.double_conv = nn.Sequential(
30
+ spectral_norm(nn.Conv2d(in_channel, out_channel, kernel_size=3, padding=1)),
31
+ nn.ReLU(),
32
+ spectral_norm(nn.Conv2d(out_channel, out_channel, kernel_size=3, padding=1))
33
+ )
34
+
35
+ def forward(self, x):
36
+ x1 = torch.cat([x, self.one_conv(x)], dim=1)
37
+ x2 = self.double_conv(x)
38
+ output = x1 + x2
39
+ return output
model/nowcastnet.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ import numpy as np
5
+ from .layers_utils import warp, make_grid
6
+ from .generative_network import Generative_Encoder, Generative_Decoder
7
+ from .evolution_network import Evolution_Network
8
+ from .noise_projector import Noise_Projector
9
+
10
+ class Net(nn.Module):
11
+ def __init__(self, configs):
12
+ super(Net, self).__init__()
13
+ self.configs = configs
14
+ self.pred_length = self.configs.total_length - self.configs.input_length
15
+
16
+ self.evo_net = Evolution_Network(self.configs.input_length, self.pred_length, base_c=32)
17
+ self.gen_enc = Generative_Encoder(self.configs.total_length, base_c=self.configs.ngf)
18
+ self.gen_dec = Generative_Decoder(self.configs)
19
+ self.proj = Noise_Projector(self.configs.ngf, configs)
20
+
21
+ sample_tensor = torch.zeros(1, 1, self.configs.img_height, self.configs.img_width)
22
+ self.register_buffer("grid", make_grid(sample_tensor), persistent=False)
23
+
24
+ def forward(self, all_frames):
25
+ if all_frames.ndim == 4:
26
+ all_frames = all_frames.unsqueeze(-1)
27
+ if all_frames.ndim != 5 or all_frames.shape[-1] < 1:
28
+ raise ValueError("Expected frames with shape [B,T,H,W,C], C>=1")
29
+ all_frames = all_frames[:, :, :, :, :1]
30
+
31
+ frames = all_frames.permute(0, 1, 4, 2, 3)
32
+ batch = frames.shape[0]
33
+ height = frames.shape[3]
34
+ width = frames.shape[4]
35
+
36
+ # Input Frames
37
+ input_frames = frames[:, :self.configs.input_length]
38
+ input_frames = input_frames.reshape(batch, self.configs.input_length, height, width)
39
+
40
+ # Evolution Network
41
+ intensity, motion = self.evo_net(input_frames)
42
+ motion_ = motion.reshape(batch, self.pred_length, 2, height, width)
43
+ intensity_ = intensity.reshape(batch, self.pred_length, 1, height, width)
44
+ series = []
45
+ last_frames = all_frames[:, (self.configs.input_length - 1):self.configs.input_length, :, :, 0]
46
+ grid = self.grid.to(frames.device).repeat(batch, 1, 1, 1)
47
+ for i in range(self.pred_length):
48
+ last_frames = warp(last_frames, motion_[:, i], grid, mode="nearest", padding_mode="border")
49
+ last_frames = last_frames + intensity_[:, i]
50
+ series.append(last_frames)
51
+ evo_result = torch.cat(series, dim=1)
52
+
53
+ evo_result = evo_result/128
54
+
55
+ # Generative Network
56
+ evo_feature = self.gen_enc(torch.cat([input_frames, evo_result], dim=1))
57
+
58
+ noise = torch.randn(batch, self.configs.ngf, max(1, height // 32), max(1, width // 32), device=frames.device)
59
+ projected = self.proj(noise)
60
+ # The official reshape/permute is a 4x pixel shuffle: 32*ngf
61
+ # projected channels become 2*ngf channels at H/8 x W/8. Expressing
62
+ # it directly preserves the checkpoint layout and also works at the
63
+ # official 512x512 resolution (where the released literal reshape is
64
+ # dimensionally inconsistent).
65
+ if projected.shape[2] * 4 == height // 8 and projected.shape[3] * 4 == width // 8:
66
+ noise_feature = F.pixel_shuffle(projected, 4)
67
+ else:
68
+ target_hw = (max(1, height // 8), max(1, width // 8))
69
+ noise_feature = F.interpolate(projected[:, : 2 * self.configs.ngf], size=target_hw, mode="nearest")
70
+
71
+ feature = torch.cat([evo_feature, noise_feature], dim=1)
72
+ gen_result = self.gen_dec(feature, evo_result)
73
+
74
+ return gen_result.unsqueeze(-1)
scripts/data_loader.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MRMS data loading utilities matching the official NowcastNet contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ import zlib
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+ from torch.utils.data import Dataset
12
+
13
+
14
+ def _read_png_gray16(path: Path) -> np.ndarray:
15
+ """Read the 16-bit grayscale PNG subset used by MRMS."""
16
+ raw = path.read_bytes()
17
+ if raw[:8] != b"\x89PNG\r\n\x1a\n":
18
+ raise ValueError(f"Not a PNG file: {path}")
19
+ pos = 8
20
+ idat: list[bytes] = []
21
+ width = height = bit_depth = color_type = None
22
+ while pos < len(raw):
23
+ length = struct.unpack(">I", raw[pos:pos + 4])[0]
24
+ kind = raw[pos + 4:pos + 8]
25
+ payload = raw[pos + 8:pos + 8 + length]
26
+ pos += length + 12
27
+ if kind == b"IHDR":
28
+ width, height, bit_depth, color_type = struct.unpack(">IIBB", payload[:10])
29
+ elif kind == b"IDAT":
30
+ idat.append(payload)
31
+ elif kind == b"IEND":
32
+ break
33
+ if width is None or height is None or bit_depth != 16 or color_type != 0:
34
+ raise ValueError(f"Expected 16-bit grayscale PNG: {path}")
35
+
36
+ decoded = zlib.decompress(b"".join(idat))
37
+ row_bytes = width * 2
38
+ previous = np.zeros(row_bytes, dtype=np.uint8)
39
+ rows: list[np.ndarray] = []
40
+ cursor = 0
41
+ for _ in range(height):
42
+ filter_type = decoded[cursor]
43
+ row = np.frombuffer(decoded[cursor + 1:cursor + 1 + row_bytes], dtype=np.uint8).copy()
44
+ cursor += row_bytes + 1
45
+ for index in range(row_bytes):
46
+ left = row[index - 2] if index >= 2 else 0
47
+ up = previous[index]
48
+ upper_left = previous[index - 2] if index >= 2 else 0
49
+ if filter_type == 1:
50
+ value = left
51
+ elif filter_type == 2:
52
+ value = up
53
+ elif filter_type == 3:
54
+ value = (int(left) + int(up)) // 2
55
+ elif filter_type == 4:
56
+ predictor = int(left) + int(up) - int(upper_left)
57
+ distances = (abs(predictor - int(left)), abs(predictor - int(up)), abs(predictor - int(upper_left)))
58
+ value = (left, up, upper_left)[int(np.argmin(distances))]
59
+ elif filter_type == 0:
60
+ value = 0
61
+ else:
62
+ raise ValueError(f"Unsupported PNG filter {filter_type}")
63
+ row[index] = (int(row[index]) + int(value)) & 255
64
+ rows.append(row)
65
+ previous = row
66
+ return np.frombuffer(b"".join(row.tobytes() for row in rows), dtype=">u2").astype(np.uint16).reshape(height, width)
67
+
68
+
69
+ def decode_mrms_event(event_dir: str | Path, image_height: int = 512, image_width: int = 512, total_length: int = 29) -> np.ndarray:
70
+ event_dir = Path(event_dir)
71
+ event_name = event_dir.name
72
+ frames = [_read_png_gray16(event_dir / f"{event_name}-{index:02d}.png") for index in range(total_length)]
73
+ data = np.stack(frames).astype(np.float32) / 10.0 - 3.0
74
+ if data.shape[1:] != (image_height, image_width):
75
+ raise ValueError(f"MRMS frame shape {data.shape[1:]} != {(image_height, image_width)}")
76
+ mask = np.ones_like(data, dtype=np.float32)
77
+ mask[data < 0] = 0
78
+ data[data < 0] = 0
79
+ data = np.clip(data, 0, 128)
80
+ return np.stack([data, mask], axis=-1)
81
+
82
+
83
+ class MRMSDataset(Dataset):
84
+ def __init__(self, data_dir: str | Path, image_height: int = 512, image_width: int = 512, total_length: int = 29, split: str = "train"):
85
+ self.data_dir = Path(data_dir)
86
+ self.events = sorted(path for path in self.data_dir.iterdir() if path.is_dir())
87
+ if split == "train":
88
+ self.events = [path for path in self.events if len(list(path.glob("*.png"))) == total_length]
89
+ elif split != "test":
90
+ raise ValueError("split must be train or test")
91
+ self.image_height = image_height
92
+ self.image_width = image_width
93
+ self.total_length = total_length
94
+
95
+ def __len__(self) -> int:
96
+ return len(self.events)
97
+
98
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor | str]:
99
+ event = self.events[index]
100
+ frames = decode_mrms_event(event, self.image_height, self.image_width, self.total_length)
101
+ return {"radar_frames": torch.from_numpy(frames), "event": event.name}
scripts/fake_data.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate MRMS-shaped synthetic events for contract and smoke checks."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import struct
7
+ import zlib
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+
13
+ def write_png_gray16(path: Path, array: np.ndarray) -> None:
14
+ array = np.asarray(array, dtype=">u2")
15
+ raw = b"".join(b"\x00" + row.tobytes() for row in array)
16
+ def chunk(kind: bytes, payload: bytes) -> bytes:
17
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xffffffff)
18
+ header = struct.pack(">IIBBBBB", array.shape[1], array.shape[0], 16, 0, 0, 0, 0)
19
+ path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(raw, 1)) + chunk(b"IEND", b""))
20
+
21
+
22
+ def generate(output_dir: str | Path, events: int = 2, height: int = 512, width: int = 512, total_length: int = 29, seed: int = 42) -> None:
23
+ root = Path(output_dir)
24
+ rng = np.random.default_rng(seed)
25
+ yy, xx = np.mgrid[:height, :width]
26
+ for event_index in range(events):
27
+ event = root / f"synthetic_{event_index:04d}"
28
+ event.mkdir(parents=True, exist_ok=True)
29
+ cx, cy = width * (0.25 + 0.2 * event_index), height * 0.45
30
+ for frame in range(total_length):
31
+ center_x = cx + frame * 1.5
32
+ rain = 12.0 * np.exp(-((xx - center_x) ** 2 + (yy - cy) ** 2) / (2 * (max(height, width) * 0.12) ** 2))
33
+ rain += rng.normal(0, 0.08, size=(height, width))
34
+ # Inverse official encoding: decoded = uint16 / 10 - 3.
35
+ encoded = np.clip(np.rint((np.maximum(rain, 0) + 3.0) * 10), 0, 65535).astype(np.uint16)
36
+ write_png_gray16(event / f"{event.name}-{frame:02d}.png", encoded)
37
+ print(f"generated {events} MRMS events at {root} with shape ({total_length},{height},{width})")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ parser = argparse.ArgumentParser()
42
+ parser.add_argument("--output-dir", type=str, default="data/data")
43
+ parser.add_argument("--events", type=int, default=2)
44
+ parser.add_argument("--height", type=int, default=512)
45
+ parser.add_argument("--width", type=int, default=512)
46
+ parser.add_argument("--seed", type=int, default=42)
47
+ args = parser.parse_args()
48
+ generate(args.output_dir, args.events, args.height, args.width, seed=args.seed)
scripts/inference.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+ import argparse
4
+ from pathlib import Path
5
+ import sys
6
+ import numpy as np
7
+ import torch
8
+ import yaml
9
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(PROJECT_ROOT))
11
+ from scripts.data_loader import MRMSDataset
12
+ from model.model_factory import build_model, load_checkpoint
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(description="NowcastNet inference")
16
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
17
+ parser.add_argument("--data-dir", help="MRMS event directory; defaults to data.data_dir in config.yaml")
18
+ parser.add_argument("--checkpoint")
19
+ parser.add_argument("--output-dir")
20
+ parser.add_argument("--device", default="auto")
21
+ parser.add_argument("--height", type=int)
22
+ parser.add_argument("--width", type=int)
23
+ parser.add_argument("--ngf", type=int)
24
+ args = parser.parse_args()
25
+ cfg = yaml.safe_load(Path(args.config).read_text())
26
+ mc, dc, ic = cfg["model"], cfg["data"], cfg["inference"]
27
+ height, width = args.height or dc["image_height"], args.width or dc["image_width"]
28
+ if args.ngf: mc["ngf"] = args.ngf
29
+ mc["img_height"], mc["img_width"] = height, width
30
+ device = torch.device("cuda" if args.device == "auto" and torch.cuda.is_available() else ("cpu" if args.device == "auto" else args.device))
31
+ checkpoint = Path(args.checkpoint) if args.checkpoint else PROJECT_ROOT / mc["checkpoint_dir"] / f"{mc.get('checkpoint_prefix', 'model_bak')}.pth"
32
+ if not checkpoint.is_file():
33
+ raise FileNotFoundError(f"Checkpoint not found: {checkpoint}. Run scripts/train.py first or pass --checkpoint.")
34
+ model = build_model(mc, device); load_checkpoint(model, checkpoint, device); model.eval()
35
+ data_dir = Path(args.data_dir) if args.data_dir else PROJECT_ROOT / dc["data_dir"]
36
+ ds = MRMSDataset(data_dir, height, width, dc["total_length"], "test")
37
+ out = Path(args.output_dir) if args.output_dir else PROJECT_ROOT / ic["output_dir"]
38
+ out.mkdir(parents=True, exist_ok=True)
39
+ with torch.no_grad():
40
+ for item in ds:
41
+ frames = item["radar_frames"].unsqueeze(0).to(device=device, dtype=torch.float32)
42
+ pred = model(frames).squeeze(0).squeeze(-1).cpu().numpy()
43
+ target = frames[0, mc["input_length"]:, :, :, 0].cpu().numpy()
44
+ np.save(out / f"{item['event']}_pred.npy", pred)
45
+ np.save(out / f"{item['event']}_input.npy", frames[0, :mc["input_length"], :, :, 0].cpu().numpy())
46
+ np.save(out / f"{item['event']}_target.npy", target)
47
+ print(item["event"], pred.shape, float(pred.min()), float(pred.max()))
48
+
49
+ if __name__ == "__main__": main()
scripts/result.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render NowcastNet predictions and truth comparisons as RGB PNG files."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+ import struct
10
+ import zlib
11
+
12
+ import numpy as np
13
+ import yaml
14
+
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
17
+
18
+ RAIN_THRESHOLDS = np.asarray([0.1, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0], dtype=np.float32)
19
+ RAIN_COLORS = np.asarray(
20
+ [
21
+ [0, 0, 0],
22
+ [70, 70, 70],
23
+ [0, 110, 255],
24
+ [0, 205, 255],
25
+ [0, 190, 80],
26
+ [255, 230, 0],
27
+ [255, 145, 0],
28
+ [235, 35, 30],
29
+ [205, 0, 180],
30
+ ],
31
+ dtype=np.uint8,
32
+ )
33
+ ERROR_THRESHOLDS = np.asarray([0.1, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0], dtype=np.float32)
34
+ ERROR_COLORS = np.asarray(
35
+ [
36
+ [0, 0, 0],
37
+ [40, 40, 40],
38
+ [35, 80, 170],
39
+ [30, 165, 215],
40
+ [80, 200, 120],
41
+ [245, 225, 65],
42
+ [245, 145, 45],
43
+ [220, 55, 40],
44
+ [245, 245, 245],
45
+ ],
46
+ dtype=np.uint8,
47
+ )
48
+
49
+
50
+ def _png_chunk(kind: bytes, payload: bytes) -> bytes:
51
+ checksum = zlib.crc32(kind + payload) & 0xFFFFFFFF
52
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum)
53
+
54
+
55
+ def write_png(path: Path, image: np.ndarray) -> None:
56
+ """Write an H x W x 3 uint8 array as a standards-compliant RGB PNG."""
57
+ image = np.asarray(image, dtype=np.uint8)
58
+ if image.ndim != 3 or image.shape[2] != 3:
59
+ raise ValueError(f"Expected RGB image [H,W,3], got {image.shape}")
60
+ raw = b"".join(b"\x00" + row.tobytes() for row in image)
61
+ header = struct.pack(">IIBBBBB", image.shape[1], image.shape[0], 8, 2, 0, 0, 0)
62
+ path.write_bytes(
63
+ b"\x89PNG\r\n\x1a\n"
64
+ + _png_chunk(b"IHDR", header)
65
+ + _png_chunk(b"IDAT", zlib.compress(raw, 1))
66
+ + _png_chunk(b"IEND", b"")
67
+ )
68
+
69
+
70
+ def colorize(image: np.ndarray, thresholds: np.ndarray, colors: np.ndarray) -> np.ndarray:
71
+ values = np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=128.0, neginf=0.0)
72
+ return colors[np.searchsorted(thresholds, np.maximum(values, 0.0), side="right")]
73
+
74
+
75
+ def comparison_image(truth: np.ndarray, prediction: np.ndarray) -> np.ndarray:
76
+ truth_rgb = colorize(truth, RAIN_THRESHOLDS, RAIN_COLORS)
77
+ prediction_rgb = colorize(prediction, RAIN_THRESHOLDS, RAIN_COLORS)
78
+ error_rgb = colorize(np.abs(prediction - truth), ERROR_THRESHOLDS, ERROR_COLORS)
79
+ separator = np.full((truth.shape[0], 4, 3), 255, dtype=np.uint8)
80
+ return np.concatenate([truth_rgb, separator, prediction_rgb, separator, error_rgb], axis=1)
81
+
82
+
83
+ def main() -> None:
84
+ parser = argparse.ArgumentParser(description="Render NowcastNet inference results as PNG images")
85
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
86
+ parser.add_argument("--input-dir", help="directory containing *_pred.npy and *_target.npy")
87
+ parser.add_argument("--output-dir")
88
+ parser.add_argument("--threshold", type=float)
89
+ args = parser.parse_args()
90
+
91
+ cfg = yaml.safe_load(Path(args.config).read_text())
92
+ src = Path(args.input_dir) if args.input_dir else PROJECT_ROOT / cfg["inference"]["output_dir"]
93
+ out = Path(args.output_dir) if args.output_dir else PROJECT_ROOT / cfg["visualization"]["output_dir"]
94
+ threshold = args.threshold if args.threshold is not None else float(cfg["inference"]["threshold"])
95
+ expected_frames = int(cfg["model"]["total_length"]) - int(cfg["model"]["input_length"])
96
+ prediction_dir = out / "predictions"
97
+ comparison_dir = out / "comparison"
98
+ prediction_dir.mkdir(parents=True, exist_ok=True)
99
+ comparison_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ summary: dict[str, dict[str, object]] = {}
102
+ pred_paths = sorted(src.glob("*_pred.npy"))
103
+ if not pred_paths:
104
+ raise FileNotFoundError(f"No *_pred.npy inference results found under {src}")
105
+
106
+ for pred_path in pred_paths:
107
+ event = pred_path.name.removesuffix("_pred.npy")
108
+ target_path = src / f"{event}_target.npy"
109
+ if not target_path.is_file():
110
+ raise FileNotFoundError(
111
+ f"Truth file not found: {target_path}. Rerun scripts/inference.py to export targets."
112
+ )
113
+ prediction = np.load(pred_path)
114
+ truth = np.load(target_path)
115
+ if prediction.shape != truth.shape:
116
+ raise ValueError(f"Prediction shape {prediction.shape} != truth shape {truth.shape} for {event}")
117
+ if prediction.ndim != 3 or prediction.shape[0] != expected_frames:
118
+ raise ValueError(
119
+ f"Expected {expected_frames} frames [T,H,W] for {event}, got {prediction.shape}"
120
+ )
121
+
122
+ absolute_error = np.abs(prediction - truth)
123
+ mae_by_lead = absolute_error.mean(axis=(1, 2))
124
+ rmse_by_lead = np.sqrt(np.square(prediction - truth).mean(axis=(1, 2)))
125
+ for index in range(expected_frames):
126
+ filename = f"{event}_t{index + 1:02d}.png"
127
+ write_png(
128
+ prediction_dir / filename,
129
+ colorize(prediction[index], RAIN_THRESHOLDS, RAIN_COLORS),
130
+ )
131
+ write_png(
132
+ comparison_dir / filename,
133
+ comparison_image(truth[index], prediction[index]),
134
+ )
135
+
136
+ summary[event] = {
137
+ "shape": list(prediction.shape),
138
+ "prediction_png_count": expected_frames,
139
+ "comparison_png_count": expected_frames,
140
+ "comparison_layout": ["truth", "prediction", "absolute_error"],
141
+ "prediction_min": float(prediction.min()),
142
+ "prediction_max": float(prediction.max()),
143
+ "prediction_mean": float(prediction.mean()),
144
+ "threshold": threshold,
145
+ "threshold_fraction": float((prediction >= threshold).mean()),
146
+ "mae": float(absolute_error.mean()),
147
+ "rmse": float(np.sqrt(np.square(prediction - truth).mean())),
148
+ "mae_by_lead": [float(value) for value in mae_by_lead],
149
+ "rmse_by_lead": [float(value) for value in rmse_by_lead],
150
+ }
151
+
152
+ summary_path = out / "summary.json"
153
+ summary_path.write_text(json.dumps(summary, indent=2) + "\n")
154
+ print(f"prediction_png_dir={prediction_dir}")
155
+ print(f"comparison_png_dir={comparison_dir}")
156
+ print(f"summary={summary_path}")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
scripts/train.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import random
7
+ from pathlib import Path
8
+ import sys
9
+
10
+ import numpy as np
11
+ import torch
12
+ import yaml
13
+ from torch.utils.data import DataLoader
14
+ from torch.utils.data.distributed import DistributedSampler
15
+
16
+ # ``model`` and ``scripts`` are namespace packages; no __init__.py is required.
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+ from scripts.data_loader import MRMSDataset
20
+ from model.model_factory import build_model, load_checkpoint
21
+
22
+
23
+ def clone_state_dict(model: torch.nn.Module) -> dict[str, torch.Tensor]:
24
+ """Copy the current parameters to CPU so later epochs cannot mutate them."""
25
+ network = model.module if hasattr(model, "module") else model
26
+ return {name: tensor.detach().cpu().clone() for name, tensor in network.state_dict().items()}
27
+
28
+
29
+ def main() -> None:
30
+ parser = argparse.ArgumentParser(description="NowcastNet single-card training")
31
+ parser.add_argument("--config", default=str(Path(__file__).parents[1] / "conf/config.yaml"))
32
+ parser.add_argument("--data-dir", help="MRMS event directory; defaults to data.data_dir in config.yaml")
33
+ parser.add_argument("--device", default="auto")
34
+ parser.add_argument("--epochs", type=int)
35
+ parser.add_argument("--checkpoint")
36
+ parser.add_argument("--output-dir")
37
+ parser.add_argument("--log-dir", help="training log directory; defaults to training.output_dir in config.yaml")
38
+ parser.add_argument("--checkpoint-prefix")
39
+ parser.add_argument("--height", type=int)
40
+ parser.add_argument("--width", type=int)
41
+ parser.add_argument("--ngf", type=int)
42
+ parser.add_argument("--distributed", action="store_true", help="use torchrun for single-node or multi-node DDP")
43
+ args = parser.parse_args()
44
+ cfg = yaml.safe_load(Path(args.config).read_text())
45
+ mc, dc, tc = cfg["model"], cfg["data"], cfg["training"]
46
+ if args.height: mc["img_height"] = dc["image_height"] = args.height
47
+ if args.width: mc["img_width"] = dc["image_width"] = args.width
48
+ if args.ngf: mc["ngf"] = args.ngf
49
+ data_dir = Path(args.data_dir) if args.data_dir else PROJECT_ROOT / dc["data_dir"]
50
+ distributed = args.distributed or int(__import__("os").environ.get("WORLD_SIZE", "1")) > 1
51
+ rank = 0
52
+ if distributed:
53
+ import os, torch.distributed as dist
54
+ dist.init_process_group(backend="nccl" if torch.cuda.is_available() else "gloo", init_method="env://")
55
+ rank = dist.get_rank()
56
+ if args.device == "auto" and torch.cuda.is_available():
57
+ torch.cuda.set_device(int(os.environ.get("LOCAL_RANK", 0)))
58
+ if distributed and torch.cuda.is_available():
59
+ device = torch.device("cuda", int(__import__("os").environ.get("LOCAL_RANK", 0)))
60
+ else:
61
+ device = torch.device("cuda" if args.device == "auto" and torch.cuda.is_available() else ("cpu" if args.device == "auto" else args.device))
62
+ seed = int(tc["seed"]); random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
63
+ model = build_model(mc, device)
64
+ best_loss = float("inf")
65
+ best_epoch = 0
66
+ best_state = None
67
+ if args.checkpoint:
68
+ checkpoint = load_checkpoint(model, args.checkpoint, device)
69
+ if "best_loss" in checkpoint:
70
+ best_loss = float(checkpoint["best_loss"])
71
+ best_epoch = int(checkpoint.get("epoch", 0))
72
+ best_state = clone_state_dict(model)
73
+ dataset = MRMSDataset(data_dir, dc["image_height"], dc["image_width"], dc["total_length"], "train")
74
+ if not dataset:
75
+ raise RuntimeError(f"No complete MRMS events found under {data_dir}")
76
+ sampler = DistributedSampler(dataset, shuffle=True) if distributed else None
77
+ loader = DataLoader(dataset, batch_size=int(dc["batch_size"]), shuffle=sampler is None, sampler=sampler, num_workers=int(dc["num_workers"]), drop_last=False)
78
+ if distributed:
79
+ from torch.nn.parallel import DistributedDataParallel as DDP
80
+ # The official generator keeps architectural branches whose
81
+ # parameters are not touched by the L1 smoke-training path.
82
+ model = DDP(
83
+ model,
84
+ device_ids=[device.index] if device.type == "cuda" else None,
85
+ find_unused_parameters=True,
86
+ )
87
+ optimizer = torch.optim.Adam(model.parameters(), lr=float(tc["lr"]), weight_decay=float(tc["weight_decay"]))
88
+ epochs = args.epochs or int(tc["epochs"])
89
+ checkpoint_dir = Path(args.output_dir) if args.output_dir else PROJECT_ROOT / mc["checkpoint_dir"]
90
+ checkpoint_prefix = args.checkpoint_prefix or mc.get("checkpoint_prefix", "model_bak")
91
+ log_dir = Path(args.log_dir) if args.log_dir else PROJECT_ROOT / tc["output_dir"]
92
+ loss_history_path = log_dir / tc.get("loss_history_file", "loss_history.json")
93
+ loss_history: list[dict[str, float | int]] = []
94
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
95
+ if rank == 0:
96
+ log_dir.mkdir(parents=True, exist_ok=True)
97
+ for epoch in range(epochs):
98
+ if sampler is not None: sampler.set_epoch(epoch)
99
+ model.train()
100
+ loss_sum = torch.zeros(1, device=device)
101
+ sample_count = torch.zeros(1, device=device)
102
+ for batch in loader:
103
+ frames = batch["radar_frames"].to(device=device, dtype=torch.float32)
104
+ pred = model(frames)
105
+ target = frames[:, int(mc["input_length"]):, :, :, :1].squeeze(-1)
106
+ loss = torch.mean(torch.abs(pred.squeeze(-1) - target))
107
+ optimizer.zero_grad(set_to_none=True)
108
+ loss.backward()
109
+ optimizer.step()
110
+ loss_sum += loss.detach() * frames.shape[0]
111
+ sample_count += frames.shape[0]
112
+ if distributed:
113
+ torch.distributed.all_reduce(loss_sum)
114
+ torch.distributed.all_reduce(sample_count)
115
+ epoch_loss = (loss_sum / sample_count.clamp_min(1)).item()
116
+ if rank == 0:
117
+ print(f"epoch={epoch + 1}/{epochs} loss={epoch_loss:.6f}")
118
+ if epoch_loss < best_loss:
119
+ best_loss = epoch_loss
120
+ best_epoch = epoch + 1
121
+ best_state = clone_state_dict(model)
122
+ loss_history.append({"epoch": epoch + 1, "loss": epoch_loss})
123
+ loss_history_path.write_text(
124
+ json.dumps(
125
+ {
126
+ "epochs": loss_history,
127
+ "best_epoch": best_epoch,
128
+ "best_loss": best_loss,
129
+ },
130
+ indent=2,
131
+ )
132
+ + "\n"
133
+ )
134
+ if rank == 0:
135
+ if best_state is None:
136
+ raise RuntimeError("Training finished without producing a model state")
137
+ checkpoint_path = checkpoint_dir / f"{checkpoint_prefix}.pth"
138
+ torch.save(
139
+ {"state_dict": best_state, "epoch": best_epoch, "best_loss": best_loss},
140
+ checkpoint_path,
141
+ )
142
+ print(f"checkpoint={checkpoint_path}")
143
+ print(f"loss_history={loss_history_path}")
144
+ if distributed:
145
+ torch.distributed.barrier()
146
+ torch.distributed.destroy_process_group()
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
weight/.gitkeep ADDED
File without changes