marklpd commited on
Commit
60b6587
·
verified ·
1 Parent(s): 4bfcd55

PSRGAN v1.0

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ images/psrgan_city.png filter=lfs diff=lfs merge=lfs -text
37
+ images/psrgan_uncertainty.png filter=lfs diff=lfs merge=lfs -text
38
+ psrgan_grid.jpg filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Mark-Daniel Leupold
4
+ Copyright (c) 2021, Xintao Wang (Real-ESRGAN, on whose weights and RRDBNet architecture PSRGAN is based)
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ 3. Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: bsd-3-clause
3
+ library_name: pytorch
4
+ pipeline_tag: image-to-image
5
+ language:
6
+ - en
7
+ tags:
8
+ - super-resolution
9
+ - sentinel-2
10
+ - remote-sensing
11
+ - earth-observation
12
+ - uncertainty
13
+ - bayesian-deep-learning
14
+ - swag
15
+ - esrgan
16
+ - gan
17
+ datasets:
18
+ - marklpd/PSRGAN-Sentinel-2
19
+ base_model: xinntao/Real-ESRGAN
20
+ metrics:
21
+ - psnr
22
+ - ssim
23
+ - lpips
24
+ model-index:
25
+ - name: PSRGAN
26
+ results:
27
+ - task:
28
+ type: image-to-image
29
+ name: 4x super-resolution
30
+ dataset:
31
+ type: marklpd/PSRGAN-Sentinel-2
32
+ name: PSRGAN Sentinel-2 Super-Resolution (test split)
33
+ split: test
34
+ metrics:
35
+ - type: psnr
36
+ value: 21.28
37
+ name: PSNR (dB)
38
+ - type: ssim
39
+ value: 0.5694
40
+ name: SSIM
41
+ - type: lpips
42
+ value: 0.1879
43
+ name: LPIPS (AlexNet)
44
+ ---
45
+ ![PSRGAN grid](psrgan_grid.jpg)
46
+
47
+ *Test patches from regions the model never saw during training. Error and uncertainty share one color scale per row.*
48
+
49
+ `PSRGAN` is a probabilistic 4× super-resolution model for Sentinel-2 RGB imagery. Next to the sharpened image it predicts a per-pixel uncertainty map, so you can see where the model is guessing. It is a Bayesian Real-ESRGAN with 16.7 million parameters, trained on the Alpine region with [PSRGAN Sentinel-2](https://huggingface.co/datasets/marklpd/PSRGAN-Sentinel-2).
50
+
51
+ # Key Features
52
+
53
+ 1. Every output comes with an uncertainty map that combines aleatoric uncertainty (missing information in the input) and epistemic uncertainty (uncertainty of the model itself, from SWAG weight samples).
54
+ 2. The uncertainty tracks the error. Pixel-wise uncertainty and absolute error correlate with Pearson r = 0.59 (Spearman ρ = 0.70), and the 10% most uncertain pixels have 14.3× the error of the 10% most confident ones.
55
+ 3. On 1,056 test patches from unseen regions, PSRGAN reaches an LPIPS of 0.188, against 0.348 for Real-ESRGAN x4plus and 0.599 for bicubic upsampling. It also beats Real-ESRGAN in PSNR (21.28 vs. 20.22 dB) and SSIM (0.569 vs. 0.475).
56
+ 4. Two sets of weights. The final adversarial weights have the lowest LPIPS, the weights after the likelihood stage the highest PSNR (21.94 dB) and SSIM (0.598) of all four methods tested.
57
+ 5. Trained on randomized degradations (Gaussian, motion or defocus blur, then bicubic, bilinear or area downsampling), so it learns super-resolution instead of inverting one fixed downscaling operation.
58
+
59
+ # How the uncertainty map is made
60
+
61
+ A 64×64 input does not contain the detail of a 256×256 image, so every super-resolution model has to guess. PSRGAN models each output pixel as a Gaussian instead of a single value, and the uncertainty map is its standard deviation σ.
62
+
63
+ **Aleatoric uncertainty** comes from the data. The last convolution of Real-ESRGAN is replaced by two Bayesian 3×3 heads: the μ-head outputs the image, the s-head the log-variance s = log σ² of every pixel. A Gaussian negative log-likelihood loss punishes large errors and confident errors alike, so σ_aleatoric = exp(s / 2) grows where the input is ambiguous.
64
+
65
+ **Epistemic uncertainty** comes from the model. During the last 8 adversarial epochs, SWAG stores 32 weight snapshots and fits a Gaussian over the weights. Sampling 16 weight sets from it gives 16 slightly different images, and their per-pixel spread is σ_epistemic. Both parts combine to σ_total = √(σ²_aleatoric + 10 · σ²_epistemic).
66
+
67
+ <center>
68
+ <img src="images/psrgan_uncertainty.png" width="900px">
69
+ <br><em>From prediction to uncertainty map, each map on its own color scale.</em>
70
+ </center>
71
+
72
+ In the patches shown here, forests, water and shaded slopes stay dark in both the error and the uncertainty map, while ridges, snow edges, roads, buildings and field borders light up in both.
73
+
74
+ # Usage
75
+
76
+ The model code is a single file, [`psrgan.py`](psrgan.py), that only needs `torch`, `huggingface_hub` and `safetensors`.
77
+
78
+ ```shell
79
+ pip install torch huggingface_hub safetensors
80
+ ```
81
+
82
+ ```python
83
+ import torch
84
+ from huggingface_hub import hf_hub_download
85
+
86
+ hf_hub_download("marklpd/PSRGAN", "psrgan.py", local_dir=".")
87
+ from psrgan import PSRGAN
88
+
89
+ model = PSRGAN.from_pretrained("marklpd/PSRGAN").eval()
90
+ model.load_swag(hf_hub_download("marklpd/PSRGAN", "psrgan_x4_swag.safetensors"))
91
+
92
+ lr = torch.rand(1, 3, 64, 64) # RGB (B04, B03, B02), stretched to [0, 1]
93
+ out = model.super_resolve(lr, swag_samples=16)
94
+ out["sr"] # (1, 3, 256, 256) super-resolved image
95
+ out["sigma_total"] # (1, 3, 256, 256) uncertainty map
96
+ out["sigma_aleatoric"], out["sigma_epistemic"]
97
+ ```
98
+
99
+ Without `load_swag`, `model.super_resolve(lr)` returns the image and the aleatoric map in one forward pass. `mc_samples=8` samples the Bayesian heads instead of SWAG, which is cheaper but only covers the last layer.
100
+
101
+ The model expects Sentinel-2 L2A RGB (B04, B03, B02) after a per-image 1 to 99% percentile stretch, the same preprocessing as in the dataset. `psrgan.stretch(reflectance)` applies it. The network is fully convolutional, so any input size works. Large scenes should be processed in overlapping tiles.
102
+
103
+ ## Files
104
+
105
+ | file | content |
106
+ |---|---|
107
+ | `model.safetensors` | final weights after adversarial training (use this) |
108
+ | `psrgan_x4_psnr.safetensors` | weights after the likelihood stage, smoother and higher PSNR |
109
+ | `psrgan_x4_swag.safetensors` | SWAG mean and variance (32 snapshots) |
110
+ | `config.json`, `psrgan.py` | model hyperparameters and code |
111
+ | `eval/metrics.json` | every number on this page |
112
+ | `notebooks/` | dataset creation and training notebooks |
113
+
114
+ # Evaluation
115
+
116
+ All numbers are on the test split of [PSRGAN Sentinel-2](https://huggingface.co/datasets/marklpd/PSRGAN-Sentinel-2) (1,056 patches), whose tiles lie in 0.5° blocks that share no pixel with training or validation. PSNR, SSIM and LPIPS are averaged per image (± is the 95% confidence interval), VGG is the perceptual loss used in training and OT the patchwise Sinkhorn distance between pixel color distributions.
117
+
118
+ | method | PSNR (dB) ↑ | SSIM ↑ | LPIPS ↓ | VGG ↓ | OT ↓ |
119
+ |---|---:|---:|---:|---:|---:|
120
+ | Bicubic | 21.56 ± 0.17 | 0.5428 ± 0.0077 | 0.5987 ± 0.0050 | 8.135 | 0.00321 |
121
+ | Real-ESRGAN | 20.22 ± 0.15 | 0.4753 ± 0.0068 | 0.3482 ± 0.0043 | 8.640 | 0.00399 |
122
+ | PSRGAN (PSNR) | **21.94 ± 0.17** | **0.5981 ± 0.0063** | 0.2082 ± 0.0027 | **4.877** | 0.00267 |
123
+ | **PSRGAN** | 21.28 ± 0.17 | 0.5694 ± 0.0063 | **0.1879 ± 0.0026** | 5.859 | **0.00135** |
124
+
125
+ PSRGAN beats Real-ESRGAN in PSNR on 97% of test patches and in LPIPS on 100%. Compared with the likelihood stage, the adversarial stage gives up 0.66 dB PSNR and changes LPIPS from 0.208 to 0.188 (lower is better). Bicubic upsampling keeps a higher PSNR than the final model on 70% of patches, because PSNR rewards smooth averages over sharp but slightly misplaced detail. LPIPS, which follows perceived similarity, is lower for PSRGAN than for bicubic on 100% of patches.
126
+
127
+ On the 128 test patches from Herzogenaurach, Wolfsburg and München:
128
+
129
+ | method | PSNR (dB) ↑ | SSIM ↑ | LPIPS ↓ | VGG ↓ | OT ↓ |
130
+ |---|---:|---:|---:|---:|---:|
131
+ | Bicubic | 21.15 ± 0.25 | 0.5522 ± 0.0206 | 0.6825 ± 0.0142 | 9.868 | 0.00321 |
132
+ | Real-ESRGAN | 20.24 ± 0.23 | 0.5147 ± 0.0190 | 0.3988 ± 0.0150 | 9.620 | 0.00308 |
133
+ | PSRGAN (PSNR) | **21.30 ± 0.28** | **0.5960 ± 0.0199** | 0.2118 ± 0.0086 | **5.983** | 0.00310 |
134
+ | **PSRGAN** | 20.61 ± 0.26 | 0.5620 ± 0.0188 | **0.1938 ± 0.0087** | 7.124 | **0.00146** |
135
+
136
+ <center>
137
+ <img src="images/psrgan_city.png" width="900px">
138
+ <br><em>A test patch from Herzogenaurach.</em>
139
+ </center>
140
+
141
+ ## Calibration
142
+
143
+ | | ECE (σ vs. RMSE) | error within 1σ | error within 2σ |
144
+ |---|---:|---:|---:|
145
+ | Aleatoric + SWAG | 0.0240 | 86.6% | 98.4% |
146
+ | Aleatoric + Bayesian heads | 0.0289 | 88.4% | 98.7% |
147
+
148
+ A perfectly calibrated Gaussian would cover 68.3% and 95.4%. Both variants cover more, and the predicted σ is larger than the observed RMSE in every calibration bin, so the uncertainty is conservative. The uncertainty-error correlation is stronger in flat areas (Pearson r = 0.59) than on edges (r = 0.37).
149
+
150
+ # Training
151
+
152
+ PSRGAN starts from the Real-ESRGAN x4plus generator (RRDBNet, 23 blocks) and its U-Net discriminator.
153
+
154
+ 1. **Likelihood stage (768 epochs of 64 batches, batch size 16).** 0.6 · Gaussian NLL + 0.7 · (1 − SSIM) + 1.0 · VGG19 perceptual loss. All parameters loaded from Real-ESRGAN stay frozen for the first 168 epochs, so the new heads can settle first.
155
+ 2. **Adversarial stage (64 epochs).** Relativistic GAN loss (weight 0.01) plus contextual bilateral loss (weight 0.2), Adam with a learning rate of 2·10⁻⁴ halved every 32 epochs. SWAG collects a weight snapshot every 16 batches during the last 8 epochs.
156
+
157
+ Augmentation (flips, 90° rotations) is applied to the training split only, and the test split is only used for the final evaluation. Training ran on one NVIDIA A100-SXM4-40GB in Google Colab.
158
+
159
+ # Limitations
160
+
161
+ - The model is trained on synthetically degraded Sentinel-2 images. It sharpens Sentinel-2 data at its own resolution and does not recover detail from a finer sensor. Details beyond what the input contains are estimates, not measurements.
162
+ - Its training data are 98.6% Alpine tiles (Switzerland, Austria, northern Italy, south-eastern France, southern Germany, Slovenia) from late September and early October 2025. Expect worse results on lowland farmland, other climates, other seasons, deserts and dense city centres.
163
+ - The uncertainty maps rank pixels by expected error, but the absolute σ is too large: 87% of pixel errors fall within 1σ instead of 68%. Treat σ as a conservative bound, or recalibrate it on your own data.
164
+ - The final model trades pixel accuracy for sharpness. Its PSNR is 0.29 dB below bicubic upsampling, so use `psrgan_x4_psnr.safetensors` when pixel accuracy matters more than texture.
165
+ - The training pixels are not square (about 10 m east-west, 15.6 m north-south), because tiles were requested on a square WGS84 grid.
166
+
167
+ # Out-of-Scope Use
168
+
169
+ - Measuring, mapping or deciding about individual parcels, buildings or people from super-resolved pixels.
170
+ - Replacing actual higher-resolution imagery in legal, insurance, military or safety-critical decisions.
171
+ - Presenting super-resolved images as original satellite observations.
172
+
173
+ # License
174
+
175
+ The code and weights are released under the BSD 3-Clause License, the same license as Real-ESRGAN, on which the weights are based. Training data contains modified Copernicus Sentinel data (2025).
176
+
177
+ # Citation
178
+
179
+ ```
180
+ @misc{leupold2026psrgan,
181
+ author = {Leupold, Mark-Daniel},
182
+ title = {PSRGAN: Probabilistic Super-Resolution for Sentinel-2 with Uncertainty Estimates},
183
+ year = {2026},
184
+ publisher = {Hugging Face},
185
+ url = {https://huggingface.co/marklpd/PSRGAN}
186
+ }
187
+ ```
config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "num_feat": 64,
3
+ "num_block": 23,
4
+ "num_grow_ch": 32,
5
+ "scale": 4,
6
+ "bayesian_heads": true,
7
+ "logvar_min": -10.0,
8
+ "logvar_max": 10.0
9
+ }
eval/metrics.json ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "test": {
3
+ "n_patches": 1056,
4
+ "results": {
5
+ "Bicubic": {
6
+ "PSNR": 21.5607197952993,
7
+ "PSNR \u00b1": 0.1711068225145145,
8
+ "SSIM": 0.5427891769813318,
9
+ "SSIM \u00b1": 0.007653797755572996,
10
+ "LPIPS": 0.5987126496479367,
11
+ "LPIPS \u00b1": 0.004994828619425444,
12
+ "VGG": 8.134999072912967,
13
+ "OT": 0.003209914177811394
14
+ },
15
+ "Real-ESRGAN": {
16
+ "PSNR": 20.215020929322097,
17
+ "PSNR \u00b1": 0.15033991375476158,
18
+ "SSIM": 0.47526671260482434,
19
+ "SSIM \u00b1": 0.006763881811721902,
20
+ "LPIPS": 0.34822498551202996,
21
+ "LPIPS \u00b1": 0.004308759976577521,
22
+ "VGG": 8.63969765287457,
23
+ "OT": 0.00399009935614286
24
+ },
25
+ "PSRGAN (PSNR)": {
26
+ "PSNR": 21.936194533651527,
27
+ "PSNR \u00b1": 0.17383839192396056,
28
+ "SSIM": 0.5981000966646455,
29
+ "SSIM \u00b1": 0.006328675695824681,
30
+ "LPIPS": 0.20817970013895043,
31
+ "LPIPS \u00b1": 0.0027403351497943132,
32
+ "VGG": 4.8766192811908144,
33
+ "OT": 0.002672349598442176
34
+ },
35
+ "PSRGAN": {
36
+ "PSNR": 21.275709489981335,
37
+ "PSNR \u00b1": 0.16942986974481397,
38
+ "SSIM": 0.5693713306929126,
39
+ "SSIM \u00b1": 0.006270623736088413,
40
+ "LPIPS": 0.18790546653679374,
41
+ "LPIPS \u00b1": 0.002612060048786746,
42
+ "VGG": 5.858746337168144,
43
+ "OT": 0.0013482042025619496
44
+ }
45
+ },
46
+ "psrgan_win_rate": {
47
+ "Bicubic": {
48
+ "PSNR": 0.29829545454545453,
49
+ "SSIM": 0.6903409090909091,
50
+ "LPIPS": 1.0
51
+ },
52
+ "Real-ESRGAN": {
53
+ "PSNR": 0.9706439393939394,
54
+ "SSIM": 1.0,
55
+ "LPIPS": 1.0
56
+ },
57
+ "PSRGAN (PSNR)": {
58
+ "PSNR": 0.000946969696969697,
59
+ "SSIM": 0.002840909090909091,
60
+ "LPIPS": 0.9261363636363636
61
+ }
62
+ }
63
+ },
64
+ "test_city": {
65
+ "n_patches": 128,
66
+ "results": {
67
+ "Bicubic": {
68
+ "PSNR": 21.145912691950798,
69
+ "PSNR \u00b1": 0.2489663205630983,
70
+ "SSIM": 0.5521608623676002,
71
+ "SSIM \u00b1": 0.020581239817082063,
72
+ "LPIPS": 0.6825457969680429,
73
+ "LPIPS \u00b1": 0.014193458656650901,
74
+ "VGG": 9.868039011955261,
75
+ "OT": 0.003211126459063962
76
+ },
77
+ "Real-ESRGAN": {
78
+ "PSNR": 20.24102108180523,
79
+ "PSNR \u00b1": 0.22995863706651176,
80
+ "SSIM": 0.5146662218030542,
81
+ "SSIM \u00b1": 0.01896918156259728,
82
+ "LPIPS": 0.39876320981420577,
83
+ "LPIPS \u00b1": 0.014998464281860064,
84
+ "VGG": 9.620419383049011,
85
+ "OT": 0.003078749228734523
86
+ },
87
+ "PSRGAN (PSNR)": {
88
+ "PSNR": 21.299197003245354,
89
+ "PSNR \u00b1": 0.27709311095960687,
90
+ "SSIM": 0.5960347084328532,
91
+ "SSIM \u00b1": 0.019889227648886978,
92
+ "LPIPS": 0.21178198768757284,
93
+ "LPIPS \u00b1": 0.00864762965393086,
94
+ "VGG": 5.9829049706459045,
95
+ "OT": 0.003099578752880916
96
+ },
97
+ "PSRGAN": {
98
+ "PSNR": 20.610211864113808,
99
+ "PSNR \u00b1": 0.2630898367096838,
100
+ "SSIM": 0.5620126791764051,
101
+ "SSIM \u00b1": 0.018756582941474018,
102
+ "LPIPS": 0.19378441805019975,
103
+ "LPIPS \u00b1": 0.008681403045909196,
104
+ "VGG": 7.124138653278351,
105
+ "OT": 0.001463380001951009
106
+ }
107
+ }
108
+ },
109
+ "calibration": {
110
+ "swag": {
111
+ "ECE": 0.02397674263321663,
112
+ "coverage_1sigma": 0.8664602438608805,
113
+ "coverage_2sigma": 0.9841360251108805,
114
+ "reliability": {
115
+ "pred_std": [
116
+ 0.020123129710555077,
117
+ 0.042232051491737366,
118
+ 0.06536165624856949,
119
+ 0.08905356377363205,
120
+ 0.11278203874826431,
121
+ 0.1365380734205246,
122
+ 0.16020965576171875,
123
+ 0.18388722836971283,
124
+ 0.2074737250804901,
125
+ 0.2310594618320465,
126
+ 0.2546865940093994,
127
+ 0.2783450484275818,
128
+ 0.3019865155220032,
129
+ 0.32563266158103943,
130
+ 0.3493105471134186
131
+ ],
132
+ "rmse": [
133
+ 0.014095807448029518,
134
+ 0.02998480387032032,
135
+ 0.04914991185069084,
136
+ 0.0686262995004654,
137
+ 0.08680041134357452,
138
+ 0.1045115739107132,
139
+ 0.12249297648668289,
140
+ 0.14000065624713898,
141
+ 0.1562192142009735,
142
+ 0.17163224518299103,
143
+ 0.18531100451946259,
144
+ 0.19668403267860413,
145
+ 0.20926719903945923,
146
+ 0.21913963556289673,
147
+ 0.2296137809753418
148
+ ]
149
+ }
150
+ },
151
+ "bayesian_heads": {
152
+ "ECE": 0.02886166655259949,
153
+ "coverage_1sigma": 0.8842587471008301,
154
+ "coverage_2sigma": 0.986927350362142,
155
+ "reliability": {
156
+ "pred_std": [
157
+ 0.02026936411857605,
158
+ 0.04286534711718559,
159
+ 0.0662725418806076,
160
+ 0.08985785394906998,
161
+ 0.11369309574365616,
162
+ 0.1376895308494568,
163
+ 0.16160856187343597,
164
+ 0.18553175032138824,
165
+ 0.20935258269309998,
166
+ 0.233138307929039,
167
+ 0.25700125098228455,
168
+ 0.28088292479515076,
169
+ 0.30468234419822693,
170
+ 0.32858404517173767,
171
+ 0.35245150327682495
172
+ ],
173
+ "rmse": [
174
+ 0.012933187186717987,
175
+ 0.027884116396307945,
176
+ 0.045200906693935394,
177
+ 0.0637541264295578,
178
+ 0.08282162249088287,
179
+ 0.10149621963500977,
180
+ 0.1198882907629013,
181
+ 0.13830198347568512,
182
+ 0.15485383570194244,
183
+ 0.17070721089839935,
184
+ 0.1843496859073639,
185
+ 0.19707782566547394,
186
+ 0.2094695121049881,
187
+ 0.21937879920005798,
188
+ 0.23037448525428772
189
+ ]
190
+ }
191
+ }
192
+ },
193
+ "uncertainty_error_correlation": {
194
+ "pearson": 0.5869906544685364,
195
+ "spearman": 0.7000287473727114,
196
+ "by_region": {
197
+ "edges": {
198
+ "pearson": 0.3714364469051361,
199
+ "spearman": 0.37498411900628265
200
+ },
201
+ "flat": {
202
+ "pearson": 0.5925312638282776,
203
+ "spearman": 0.6675135785054067
204
+ }
205
+ }
206
+ },
207
+ "error_by_uncertainty_decile": [
208
+ 0.009789826348423958,
209
+ 0.016854647547006607,
210
+ 0.024157894775271416,
211
+ 0.03273877874016762,
212
+ 0.04215969517827034,
213
+ 0.052353471517562866,
214
+ 0.06426148116588593,
215
+ 0.07902297377586365,
216
+ 0.09887537360191345,
217
+ 0.13998572528362274
218
+ ],
219
+ "training": {
220
+ "train_patches": 8171,
221
+ "val_patches": 1008,
222
+ "test_patches": 1056,
223
+ "batch_size": 16,
224
+ "batches_per_epoch": 64,
225
+ "pretrain_epochs": 768,
226
+ "freeze_rrdb_epochs": 168,
227
+ "adversarial_epochs": 64,
228
+ "lr_G": 0.0002,
229
+ "lr_D": 0.0002,
230
+ "loss_weights_pretrain": {
231
+ "NLL": 0.6,
232
+ "SSIM": 0.7,
233
+ "VGG": 1.0
234
+ },
235
+ "loss_weights_adversarial": {
236
+ "ADV": 0.01,
237
+ "CX": 0.2
238
+ },
239
+ "swag": {
240
+ "snapshots": 32,
241
+ "start_epoch": 56,
242
+ "collect_every_batches": 16
243
+ },
244
+ "gpu": "NVIDIA A100-SXM4-40GB",
245
+ "seed": 42
246
+ }
247
+ }
images/psrgan_city.png ADDED

Git LFS Details

  • SHA256: d74e274873cb54bfd4442a9b829dd03ea14e1993d22cf87fbafa5b2576a454f2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.11 MB
images/psrgan_uncertainty.png ADDED

Git LFS Details

  • SHA256: 75adb9844000bbdce47bb3a5bcadef1afdf4e9fe710fa6c7e55b916589d70393
  • Pointer size: 132 Bytes
  • Size of remote file: 3.23 MB
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8708e583a031ef01cd9d3cb29a6bdce842f1ba1a64ac5ed9e9ee68e995c17f58
3
+ size 66892468
notebooks/PSRGAN_dataset.ipynb ADDED
@@ -0,0 +1,1334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# PSRGAN – Datensatz\n",
8
+ "\n",
9
+ "`Sentinel-2 Kacheln laden, Splits auf Kachelebene festlegen und LR/HR-Patches für Hugging Face erzeugen.`\n",
10
+ "\n",
11
+ "Ablauf: Kacheln (alt + neu) → Split pro Kachel (räumlich getrennt, 0,5°-Blöcke) → Patches → Parquet in `hf/dataset`.\n",
12
+ "Läuft auf CPU-Runtime, keine GPU nötig."
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "markdown",
17
+ "metadata": {},
18
+ "source": [
19
+ "# 0. Setup"
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "metadata": {},
25
+ "source": [
26
+ "### Installationen"
27
+ ]
28
+ },
29
+ {
30
+ "cell_type": "code",
31
+ "metadata": {},
32
+ "source": [
33
+ "!pip install rasterio cartopy --quiet"
34
+ ],
35
+ "execution_count": null,
36
+ "outputs": []
37
+ },
38
+ {
39
+ "cell_type": "markdown",
40
+ "metadata": {},
41
+ "source": [
42
+ "### Imports"
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "code",
47
+ "metadata": {},
48
+ "source": [
49
+ "import os\n",
50
+ "import io\n",
51
+ "import csv\n",
52
+ "import json\n",
53
+ "import math\n",
54
+ "import time\n",
55
+ "import random\n",
56
+ "import shutil\n",
57
+ "import threading\n",
58
+ "import warnings\n",
59
+ "import logging\n",
60
+ "from glob import glob\n",
61
+ "from io import BytesIO\n",
62
+ "from concurrent.futures import ThreadPoolExecutor\n",
63
+ "\n",
64
+ "import numpy as np\n",
65
+ "import pandas as pd\n",
66
+ "import cv2\n",
67
+ "import requests\n",
68
+ "import rasterio\n",
69
+ "from rasterio.env import Env\n",
70
+ "from PIL import Image\n",
71
+ "from tqdm import tqdm\n",
72
+ "\n",
73
+ "import matplotlib.pyplot as plt\n",
74
+ "\n",
75
+ "import datasets\n",
76
+ "from datasets import Dataset, Features, Value, Array2D, Array3D\n",
77
+ "from datasets import Image as HFImage\n",
78
+ "\n",
79
+ "print(\"datasets:\", datasets.__version__)"
80
+ ],
81
+ "execution_count": null,
82
+ "outputs": []
83
+ },
84
+ {
85
+ "cell_type": "markdown",
86
+ "metadata": {},
87
+ "source": [
88
+ "### Google Drive Mounten"
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "code",
93
+ "metadata": {},
94
+ "source": [
95
+ "from google.colab import drive\n",
96
+ "drive.mount(\"/content/drive\")"
97
+ ],
98
+ "execution_count": null,
99
+ "outputs": []
100
+ },
101
+ {
102
+ "cell_type": "code",
103
+ "metadata": {},
104
+ "source": [
105
+ "logging.getLogger(\"rasterio\").setLevel(logging.ERROR)\n",
106
+ "logging.getLogger(\"rasterio._env\").setLevel(logging.ERROR)\n",
107
+ "\n",
108
+ "gdal_env = Env(GDAL_DISABLE_READDIR_ON_OPEN=\"YES\")\n",
109
+ "gdal_env.__enter__()\n",
110
+ "\n",
111
+ "warnings.filterwarnings(\"ignore\", category=rasterio.errors.NotGeoreferencedWarning)\n",
112
+ "warnings.filterwarnings(\"ignore\", message=\".*TIFFReadDirectory.*ExtraSamples.*\")"
113
+ ],
114
+ "execution_count": null,
115
+ "outputs": []
116
+ },
117
+ {
118
+ "cell_type": "markdown",
119
+ "metadata": {},
120
+ "source": [
121
+ "# 1. Konfiguration"
122
+ ]
123
+ },
124
+ {
125
+ "cell_type": "code",
126
+ "metadata": {},
127
+ "source": [
128
+ "config = {\n",
129
+ " # Sentinel-2\n",
130
+ " \"BANDS\": [\"B4\", \"B3\", \"B2\"],\n",
131
+ "\n",
132
+ " # Geometrie\n",
133
+ " \"PATCH_SIZE\": 256,\n",
134
+ " \"UPSCALE\": 4,\n",
135
+ " \"LR_PATCH_SIZE\": 64,\n",
136
+ "\n",
137
+ " # AOI\n",
138
+ " \"AOI_KM\": 8.0,\n",
139
+ " \"REQUEST_PIXELS\": 512,\n",
140
+ "\n",
141
+ " # Sampling\n",
142
+ " \"TARGET_PATCHES\": 10000,\n",
143
+ " \"PATCHES_PER_TILE\": 16,\n",
144
+ " \"MAX_CLOUD_TILE\": 0.25,\n",
145
+ " \"MAX_CLOUD_PATCH\": 0.15,\n",
146
+ " \"MAX_WATER_TILE\": 0.5, # neu: Meer/Seen raus\n",
147
+ " \"MAX_NODATA_TILE\": 0.01, # neu: Swath-Rand raus\n",
148
+ "\n",
149
+ " # Region (alt, erste 192 Kacheln)\n",
150
+ " \"REGION_V1\": {\n",
151
+ " \"lon_min\": 7.5,\n",
152
+ " \"lon_max\": 13.5,\n",
153
+ " \"lat_min\": 46.5,\n",
154
+ " \"lat_max\": 51.5,\n",
155
+ " },\n",
156
+ "\n",
157
+ " # Region (neu, Mitteleuropa)\n",
158
+ " \"REGION\": {\n",
159
+ " \"lon_min\": 4.0,\n",
160
+ " \"lon_max\": 19.5,\n",
161
+ " \"lat_min\": 45.5,\n",
162
+ " \"lat_max\": 54.5,\n",
163
+ " },\n",
164
+ "\n",
165
+ " # Zeitfenster (wie bisher)\n",
166
+ " \"TIME_FROM\": \"2025-09-20T00:00:00Z\",\n",
167
+ " \"TIME_TO\": \"2025-10-07T23:59:59Z\",\n",
168
+ "\n",
169
+ " # Splits\n",
170
+ " \"SPLIT_FRACTIONS\": {\"train\": 0.8, \"validation\": 0.1, \"test\": 0.1},\n",
171
+ " \"BLOCK_DEG\": 0.5,\n",
172
+ "\n",
173
+ " # Download\n",
174
+ " \"N_WORKERS\": 6,\n",
175
+ "\n",
176
+ " # Zufall\n",
177
+ " \"SEED\": 42,\n",
178
+ "}\n",
179
+ "\n",
180
+ "config[\"AOI_SIZE_DEG\"] = config[\"AOI_KM\"] / 111.0\n",
181
+ "config[\"LR_PATCH_SIZE\"] = config[\"PATCH_SIZE\"] // config[\"UPSCALE\"]\n",
182
+ "\n",
183
+ "for k, v in config.items():\n",
184
+ " print(f\"{k:22s}: {v}\")"
185
+ ],
186
+ "execution_count": null,
187
+ "outputs": []
188
+ },
189
+ {
190
+ "cell_type": "code",
191
+ "metadata": {},
192
+ "source": [
193
+ "def set_seed(seed: int):\n",
194
+ " random.seed(seed)\n",
195
+ " np.random.seed(seed)\n",
196
+ " print(f\"Seed set to {seed}\")\n",
197
+ "\n",
198
+ "set_seed(config[\"SEED\"])"
199
+ ],
200
+ "execution_count": null,
201
+ "outputs": []
202
+ },
203
+ {
204
+ "cell_type": "markdown",
205
+ "metadata": {},
206
+ "source": [
207
+ "### Pfade"
208
+ ]
209
+ },
210
+ {
211
+ "cell_type": "code",
212
+ "metadata": {},
213
+ "source": [
214
+ "DATA_ROOT = \"/content/drive/MyDrive/OT_SRGAN/data\"\n",
215
+ "DOWNLOAD_DIR = os.path.join(DATA_ROOT, \"s2_aoi_tiles\")\n",
216
+ "CUSTOM_DOWNLOAD_DIR = os.path.join(DATA_ROOT, \"custom_s2_tiles\")\n",
217
+ "SPLITS_PATH = os.path.join(DATA_ROOT, \"tile_splits.csv\")\n",
218
+ "\n",
219
+ "HF_DATASET_DIR = \"/content/drive/MyDrive/OT_SRGAN/hf/dataset\"\n",
220
+ "HF_DATA_DIR = os.path.join(HF_DATASET_DIR, \"data\")\n",
221
+ "HF_IMAGES_DIR = os.path.join(HF_DATASET_DIR, \"images\")\n",
222
+ "HF_META_DIR = os.path.join(HF_DATASET_DIR, \"metadata\")\n",
223
+ "\n",
224
+ "# Lokal bauen, dann auf Drive kopieren\n",
225
+ "LOCAL_OUT = \"/content/hf_dataset_build\"\n",
226
+ "LOCAL_CACHE = \"/content/hf_cache\"\n",
227
+ "\n",
228
+ "for d in [DOWNLOAD_DIR, CUSTOM_DOWNLOAD_DIR, HF_DATA_DIR, HF_IMAGES_DIR, HF_META_DIR, LOCAL_OUT, LOCAL_CACHE]:\n",
229
+ " os.makedirs(d, exist_ok=True)\n",
230
+ "\n",
231
+ "PROCESS_URL = \"https://sh.dataspace.copernicus.eu/api/v1/process\""
232
+ ],
233
+ "execution_count": null,
234
+ "outputs": []
235
+ },
236
+ {
237
+ "cell_type": "markdown",
238
+ "metadata": {},
239
+ "source": [
240
+ "# 2. Kacheln laden"
241
+ ]
242
+ },
243
+ {
244
+ "cell_type": "markdown",
245
+ "metadata": {},
246
+ "source": [
247
+ "### Token"
248
+ ]
249
+ },
250
+ {
251
+ "cell_type": "code",
252
+ "metadata": {},
253
+ "source": [
254
+ "from google.colab import userdata\n",
255
+ "\n",
256
+ "CLIENT_ID = userdata.get(\"COPERNICUS_CLIENT_ID\")\n",
257
+ "CLIENT_SECRET = userdata.get(\"COPERNICUS_CLIENT_SECRET\")\n",
258
+ "\n",
259
+ "token_url = (\n",
260
+ " \"https://identity.dataspace.copernicus.eu/auth/\"\n",
261
+ " \"realms/CDSE/protocol/openid-connect/token\"\n",
262
+ ")\n",
263
+ "\n",
264
+ "HEADERS = {\"Content-Type\": \"application/json\"}\n",
265
+ "token_lock = threading.Lock()\n",
266
+ "\n",
267
+ "def refresh_token():\n",
268
+ " # Token läuft nach ~10 min ab\n",
269
+ " resp = requests.post(\n",
270
+ " token_url,\n",
271
+ " data={\n",
272
+ " \"grant_type\": \"client_credentials\",\n",
273
+ " \"client_id\": CLIENT_ID,\n",
274
+ " \"client_secret\": CLIENT_SECRET,\n",
275
+ " },\n",
276
+ " )\n",
277
+ " resp.raise_for_status()\n",
278
+ " HEADERS[\"Authorization\"] = f\"Bearer {resp.json()['access_token']}\"\n",
279
+ "\n",
280
+ "refresh_token()\n",
281
+ "print(\"Access token acquired.\")"
282
+ ],
283
+ "execution_count": null,
284
+ "outputs": []
285
+ },
286
+ {
287
+ "cell_type": "markdown",
288
+ "metadata": {},
289
+ "source": [
290
+ "### Request"
291
+ ]
292
+ },
293
+ {
294
+ "cell_type": "code",
295
+ "metadata": {},
296
+ "source": [
297
+ "EVALSCRIPT = \"\"\"\n",
298
+ "//VERSION=3\n",
299
+ "function setup() {\n",
300
+ " return {\n",
301
+ " input: [{bands:[\"B02\",\"B03\",\"B04\",\"B08\",\"SCL\"]}],\n",
302
+ " output: {bands: 5, sampleType: \"FLOAT32\"}\n",
303
+ " };\n",
304
+ "}\n",
305
+ "\n",
306
+ "function evaluatePixel(s) {\n",
307
+ " let ndvi = (s.B08 - s.B04) / (s.B08 + s.B04 + 1e-6);\n",
308
+ " return [s.B04, s.B03, s.B02, ndvi, s.SCL];\n",
309
+ "}\n",
310
+ "\"\"\""
311
+ ],
312
+ "execution_count": null,
313
+ "outputs": []
314
+ },
315
+ {
316
+ "cell_type": "code",
317
+ "metadata": {},
318
+ "source": [
319
+ "def build_request(bbox):\n",
320
+ " return {\n",
321
+ " \"input\": {\n",
322
+ " \"bounds\": {\"bbox\": bbox},\n",
323
+ " \"data\": [{\n",
324
+ " \"type\": \"sentinel-2-l2a\",\n",
325
+ " \"dataFilter\": {\n",
326
+ " \"timeRange\": {\n",
327
+ " \"from\": config[\"TIME_FROM\"],\n",
328
+ " \"to\": config[\"TIME_TO\"],\n",
329
+ " }\n",
330
+ " },\n",
331
+ " \"processing\": {\"mosaickingOrder\": \"leastCC\"},\n",
332
+ " }],\n",
333
+ " },\n",
334
+ " \"output\": {\n",
335
+ " \"width\": config[\"REQUEST_PIXELS\"],\n",
336
+ " \"height\": config[\"REQUEST_PIXELS\"],\n",
337
+ " \"responses\": [{\n",
338
+ " \"identifier\": \"default\",\n",
339
+ " \"format\": {\"type\": \"image/tiff\"},\n",
340
+ " }],\n",
341
+ " },\n",
342
+ " \"evalscript\": EVALSCRIPT,\n",
343
+ " }"
344
+ ],
345
+ "execution_count": null,
346
+ "outputs": []
347
+ },
348
+ {
349
+ "cell_type": "code",
350
+ "metadata": {},
351
+ "source": [
352
+ "def compute_cloud_mask(scl):\n",
353
+ " cloud_classes = {3,7,8,9,10}\n",
354
+ " return np.isin(scl, list(cloud_classes))\n",
355
+ "\n",
356
+ "def compute_cloud_ratio(scl):\n",
357
+ " mask = compute_cloud_mask(scl)\n",
358
+ " return mask.sum() / scl.size\n",
359
+ "\n",
360
+ "def parse_multiband_tiff(content):\n",
361
+ " with rasterio.open(BytesIO(content)) as src:\n",
362
+ " rgb = src.read([1,2,3]).astype(np.float32)\n",
363
+ " ndvi = src.read(4).astype(np.float32)\n",
364
+ " scl = src.read(5).astype(np.float32)\n",
365
+ " return rgb, ndvi, scl\n",
366
+ "\n",
367
+ "def bbox_overlap(a, b):\n",
368
+ " # [lon_min, lat_min, lon_max, lat_max]\n",
369
+ " return not (a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1])"
370
+ ],
371
+ "execution_count": null,
372
+ "outputs": []
373
+ },
374
+ {
375
+ "cell_type": "markdown",
376
+ "metadata": {},
377
+ "source": [
378
+ "### Bestehende Kacheln"
379
+ ]
380
+ },
381
+ {
382
+ "cell_type": "code",
383
+ "metadata": {},
384
+ "source": [
385
+ "existing_tiles = sorted(glob(os.path.join(DOWNLOAD_DIR, \"aoi_*.npz\")))\n",
386
+ "custom_tiles = sorted(glob(os.path.join(CUSTOM_DOWNLOAD_DIR, \"custom_*.npz\")))\n",
387
+ "\n",
388
+ "occupied = [list(np.load(p)[\"bbox\"]) for p in tqdm(existing_tiles + custom_tiles, desc=\"Read bboxes\", ncols=120)]\n",
389
+ "\n",
390
+ "n_tiles_target = math.ceil(config[\"TARGET_PATCHES\"] / config[\"PATCHES_PER_TILE\"] * 1.03) # 3% Puffer für verworfene Patches\n",
391
+ "n_new = max(0, n_tiles_target - len(existing_tiles) - len(custom_tiles))\n",
392
+ "\n",
393
+ "print(f\"Found {len(existing_tiles)} AOI tiles + {len(custom_tiles)} custom tiles.\")\n",
394
+ "print(f\"Target {n_tiles_target} tiles → {n_new} new tiles to download.\")"
395
+ ],
396
+ "execution_count": null,
397
+ "outputs": []
398
+ },
399
+ {
400
+ "cell_type": "markdown",
401
+ "metadata": {},
402
+ "source": [
403
+ "### Neue Kacheln (parallel)\n",
404
+ "\n",
405
+ "Neue Kacheln überlappen keine bestehende Kachel. Bei erneutem Ausführen wird nur der Rest geladen."
406
+ ]
407
+ },
408
+ {
409
+ "cell_type": "code",
410
+ "metadata": {},
411
+ "source": [
412
+ "state_lock = threading.Lock()\n",
413
+ "rng_bbox = random.Random(config[\"SEED\"] + len(existing_tiles))\n",
414
+ "next_idx = max([int(os.path.basename(p)[4:8]) for p in existing_tiles], default=-1) + 1\n",
415
+ "collected = []\n",
416
+ "rejected = {\"http\": 0, \"cloud\": 0, \"water\": 0, \"nodata\": 0, \"parse\": 0}\n",
417
+ "\n",
418
+ "def random_bbox():\n",
419
+ " r = config[\"REGION\"]\n",
420
+ " lon = rng_bbox.uniform(r[\"lon_min\"], r[\"lon_max\"])\n",
421
+ " lat = rng_bbox.uniform(r[\"lat_min\"], r[\"lat_max\"])\n",
422
+ " d = config[\"AOI_SIZE_DEG\"] / 2\n",
423
+ " return [lon - d, lat - d, lon + d, lat + d]\n",
424
+ "\n",
425
+ "def reserve_bbox():\n",
426
+ " # Freie Fläche suchen und reservieren\n",
427
+ " with state_lock:\n",
428
+ " while True:\n",
429
+ " bbox = random_bbox()\n",
430
+ " if not any(bbox_overlap(bbox, o) for o in occupied):\n",
431
+ " occupied.append(bbox)\n",
432
+ " return bbox\n",
433
+ "\n",
434
+ "def release_bbox(bbox):\n",
435
+ " with state_lock:\n",
436
+ " occupied.remove(bbox)\n",
437
+ "\n",
438
+ "def post_with_retry(body, max_tries=6):\n",
439
+ " for t in range(max_tries):\n",
440
+ " r = requests.post(PROCESS_URL, headers=dict(HEADERS), json=body, timeout=120)\n",
441
+ " if r.status_code == 401:\n",
442
+ " with token_lock:\n",
443
+ " refresh_token()\n",
444
+ " continue\n",
445
+ " if r.status_code == 429 or r.status_code >= 500:\n",
446
+ " time.sleep(2 ** t)\n",
447
+ " continue\n",
448
+ " return r\n",
449
+ " return r\n",
450
+ "\n",
451
+ "def download_worker(pbar):\n",
452
+ " global next_idx\n",
453
+ " while True:\n",
454
+ " with state_lock:\n",
455
+ " if len(collected) >= n_new:\n",
456
+ " return\n",
457
+ "\n",
458
+ " bbox = reserve_bbox()\n",
459
+ " try:\n",
460
+ " r = post_with_retry(build_request(bbox))\n",
461
+ " except requests.RequestException:\n",
462
+ " r = None\n",
463
+ " if r is None or r.status_code != 200:\n",
464
+ " rejected[\"http\"] += 1\n",
465
+ " release_bbox(bbox)\n",
466
+ " if r is not None and r.status_code in (402, 403):\n",
467
+ " print(\"Quota/permission error:\", r.status_code, r.text[:200])\n",
468
+ " return\n",
469
+ " continue\n",
470
+ "\n",
471
+ " try:\n",
472
+ " rgb, ndvi, scl = parse_multiband_tiff(r.content)\n",
473
+ " except Exception:\n",
474
+ " rejected[\"parse\"] += 1\n",
475
+ " release_bbox(bbox)\n",
476
+ " continue\n",
477
+ "\n",
478
+ " if compute_cloud_ratio(scl) > config[\"MAX_CLOUD_TILE\"]:\n",
479
+ " rejected[\"cloud\"] += 1; release_bbox(bbox); continue\n",
480
+ " if (scl == 6).mean() > config[\"MAX_WATER_TILE\"]:\n",
481
+ " rejected[\"water\"] += 1; release_bbox(bbox); continue\n",
482
+ " if np.isin(scl, [0, 1]).mean() > config[\"MAX_NODATA_TILE\"]:\n",
483
+ " rejected[\"nodata\"] += 1; release_bbox(bbox); continue\n",
484
+ "\n",
485
+ " with state_lock:\n",
486
+ " if len(collected) >= n_new:\n",
487
+ " release_bbox_unlocked = True\n",
488
+ " else:\n",
489
+ " release_bbox_unlocked = False\n",
490
+ " tile_id = f\"aoi_{next_idx:04d}\"\n",
491
+ " next_idx += 1\n",
492
+ " collected.append(tile_id)\n",
493
+ " if release_bbox_unlocked:\n",
494
+ " release_bbox(bbox)\n",
495
+ " return\n",
496
+ "\n",
497
+ " np.savez_compressed(\n",
498
+ " os.path.join(DOWNLOAD_DIR, f\"{tile_id}.npz\"),\n",
499
+ " rgb=rgb,\n",
500
+ " ndvi=ndvi,\n",
501
+ " scl=scl,\n",
502
+ " bbox=bbox,\n",
503
+ " time_from=config[\"TIME_FROM\"],\n",
504
+ " time_to=config[\"TIME_TO\"],\n",
505
+ " )\n",
506
+ " pbar.update(1)\n",
507
+ "\n",
508
+ "if n_new > 0:\n",
509
+ " pbar = tqdm(total=n_new, desc=\"Downloading AOIs\", ncols=120)\n",
510
+ " with ThreadPoolExecutor(max_workers=config[\"N_WORKERS\"]) as ex:\n",
511
+ " for _ in range(config[\"N_WORKERS\"]):\n",
512
+ " ex.submit(download_worker, pbar)\n",
513
+ " pbar.close()\n",
514
+ "\n",
515
+ "print(f\"Added {len(collected)} new AOI tiles. Rejected: {rejected}\")\n",
516
+ "\n",
517
+ "existing_tiles = sorted(glob(os.path.join(DOWNLOAD_DIR, \"aoi_*.npz\")))\n",
518
+ "print(f\"Total AOI tiles on disk: {len(existing_tiles)}\")"
519
+ ],
520
+ "execution_count": null,
521
+ "outputs": []
522
+ },
523
+ {
524
+ "cell_type": "markdown",
525
+ "metadata": {},
526
+ "source": [
527
+ "# 3. Splits auf Kachelebene\n",
528
+ "\n",
529
+ "1. Überlappende Kacheln → eine Gruppe (Union-Find).\n",
530
+ "2. Gruppen nach 0,5°-Blöcken zusammenfassen, Blöcke zufällig auf Splits verteilen (räumlich getrennt).\n",
531
+ "3. Blöcke mit Stadt-Kacheln (Herzogenaurach, Wolfsburg, München) → immer `test`.\n",
532
+ "\n",
533
+ "Augmentation passiert erst im Training und nur auf `train`."
534
+ ]
535
+ },
536
+ {
537
+ "cell_type": "code",
538
+ "metadata": {},
539
+ "source": [
540
+ "# [lon_min, lat_min, lon_max, lat_max]\n",
541
+ "CUSTOM_AOIS = {\n",
542
+ " \"Herzogenaurach\": [\n",
543
+ " [10.85, 49.55, 10.95, 49.65],\n",
544
+ " [10.80, 49.57, 10.90, 49.67],\n",
545
+ " [10.87, 49.53, 10.97, 49.63],\n",
546
+ " [10.83, 49.56, 10.93, 49.66],\n",
547
+ " ],\n",
548
+ " \"Wolfsburg\": [\n",
549
+ " [10.75, 52.40, 10.85, 52.50],\n",
550
+ " [10.70, 52.42, 10.80, 52.52],\n",
551
+ " ],\n",
552
+ " \"München\": [\n",
553
+ " [11.50, 48.10, 11.60, 48.20],\n",
554
+ " [11.55, 48.12, 11.65, 48.22],\n",
555
+ " ],\n",
556
+ "}\n",
557
+ "\n",
558
+ "def city_name(bbox):\n",
559
+ " for name, boxes in CUSTOM_AOIS.items():\n",
560
+ " if any(np.allclose(bbox, b) for b in boxes):\n",
561
+ " return name\n",
562
+ " return \"unknown\"\n",
563
+ "\n",
564
+ "tiles = []\n",
565
+ "for p in existing_tiles:\n",
566
+ " tiles.append({\"tile_id\": os.path.basename(p)[:-4], \"path\": p, \"source\": \"random\", \"aoi_name\": \"\",\n",
567
+ " \"bbox\": [float(v) for v in np.load(p)[\"bbox\"]]})\n",
568
+ "for p in custom_tiles:\n",
569
+ " bbox = [float(v) for v in np.load(p)[\"bbox\"]]\n",
570
+ " tiles.append({\"tile_id\": os.path.basename(p)[:-4], \"path\": p, \"source\": \"city\", \"aoi_name\": city_name(bbox),\n",
571
+ " \"bbox\": bbox})\n",
572
+ "\n",
573
+ "print(f\"{len(tiles)} tiles total\")"
574
+ ],
575
+ "execution_count": null,
576
+ "outputs": []
577
+ },
578
+ {
579
+ "cell_type": "code",
580
+ "metadata": {},
581
+ "source": [
582
+ "# Union-Find über überlappende Kacheln\n",
583
+ "parent = list(range(len(tiles)))\n",
584
+ "\n",
585
+ "def find(i):\n",
586
+ " while parent[i] != i:\n",
587
+ " parent[i] = parent[parent[i]]\n",
588
+ " i = parent[i]\n",
589
+ " return i\n",
590
+ "\n",
591
+ "for i in range(len(tiles)):\n",
592
+ " for j in range(i + 1, len(tiles)):\n",
593
+ " if bbox_overlap(tiles[i][\"bbox\"], tiles[j][\"bbox\"]):\n",
594
+ " parent[find(i)] = find(j)\n",
595
+ "\n",
596
+ "groups = {}\n",
597
+ "for i in range(len(tiles)):\n",
598
+ " groups.setdefault(find(i), []).append(i)\n",
599
+ "\n",
600
+ "sizes = sorted([len(g) for g in groups.values()], reverse=True)\n",
601
+ "print(f\"{len(groups)} overlap groups, largest: {sizes[:5]}\")"
602
+ ],
603
+ "execution_count": null,
604
+ "outputs": []
605
+ },
606
+ {
607
+ "cell_type": "code",
608
+ "metadata": {},
609
+ "source": [
610
+ "# Blöcke\n",
611
+ "B = config[\"BLOCK_DEG\"]\n",
612
+ "\n",
613
+ "def block_of(idxs):\n",
614
+ " lon = np.mean([(tiles[i][\"bbox\"][0] + tiles[i][\"bbox\"][2]) / 2 for i in idxs])\n",
615
+ " lat = np.mean([(tiles[i][\"bbox\"][1] + tiles[i][\"bbox\"][3]) / 2 for i in idxs])\n",
616
+ " return (int(math.floor(lon / B)), int(math.floor(lat / B)))\n",
617
+ "\n",
618
+ "block_groups = {}\n",
619
+ "for g, idxs in groups.items():\n",
620
+ " block_groups.setdefault(block_of(idxs), []).append(g)\n",
621
+ "\n",
622
+ "block_tiles = {b: sum(len(groups[g]) for g in gs) for b, gs in block_groups.items()}\n",
623
+ "city_blocks = {b for b, gs in block_groups.items()\n",
624
+ " if any(tiles[i][\"source\"] == \"city\" for g in gs for i in groups[g])}\n",
625
+ "\n",
626
+ "# Greedy: Block in den Split mit dem größten relativen Defizit\n",
627
+ "fractions = config[\"SPLIT_FRACTIONS\"]\n",
628
+ "target = {s: f * len(tiles) for s, f in fractions.items()}\n",
629
+ "count = {s: 0 for s in fractions}\n",
630
+ "block_split = {}\n",
631
+ "\n",
632
+ "for b in sorted(city_blocks):\n",
633
+ " block_split[b] = \"test\"\n",
634
+ " count[\"test\"] += block_tiles[b]\n",
635
+ "\n",
636
+ "rest = sorted(set(block_groups) - city_blocks)\n",
637
+ "np.random.default_rng(config[\"SEED\"]).shuffle(rest)\n",
638
+ "for b in rest:\n",
639
+ " s = max(fractions, key=lambda s: (target[s] - count[s]) / target[s])\n",
640
+ " block_split[b] = s\n",
641
+ " count[s] += block_tiles[b]\n",
642
+ "\n",
643
+ "for g, idxs in groups.items():\n",
644
+ " b = block_of(idxs)\n",
645
+ " for i in idxs:\n",
646
+ " tiles[i][\"split\"] = block_split[b]\n",
647
+ " tiles[i][\"block\"] = f\"{b[0]}_{b[1]}\"\n",
648
+ " tiles[i][\"group\"] = int(g)\n",
649
+ "\n",
650
+ "print(f\"{len(block_groups)} blocks ({len(city_blocks)} city blocks → test)\")\n",
651
+ "for s in fractions:\n",
652
+ " print(f\"{s:10s}: {count[s]:4d} tiles ({count[s] / len(tiles):.1%})\")"
653
+ ],
654
+ "execution_count": null,
655
+ "outputs": []
656
+ },
657
+ {
658
+ "cell_type": "code",
659
+ "metadata": {},
660
+ "source": [
661
+ "# Check: keine Überlappung zwischen Splits\n",
662
+ "n_cross = 0\n",
663
+ "for i in range(len(tiles)):\n",
664
+ " for j in range(i + 1, len(tiles)):\n",
665
+ " if tiles[i][\"split\"] != tiles[j][\"split\"] and bbox_overlap(tiles[i][\"bbox\"], tiles[j][\"bbox\"]):\n",
666
+ " n_cross += 1\n",
667
+ "assert n_cross == 0, f\"{n_cross} overlapping tile pairs across splits\"\n",
668
+ "assert all(t[\"split\"] == \"test\" for t in tiles if t[\"source\"] == \"city\")\n",
669
+ "print(\"OK: no pixel is shared between train, validation and test.\")"
670
+ ],
671
+ "execution_count": null,
672
+ "outputs": []
673
+ },
674
+ {
675
+ "cell_type": "code",
676
+ "metadata": {},
677
+ "source": [
678
+ "tiles_df = pd.DataFrame([{\n",
679
+ " \"tile_id\": t[\"tile_id\"], \"split\": t[\"split\"], \"source\": t[\"source\"], \"aoi_name\": t[\"aoi_name\"],\n",
680
+ " \"lon_min\": t[\"bbox\"][0], \"lat_min\": t[\"bbox\"][1], \"lon_max\": t[\"bbox\"][2], \"lat_max\": t[\"bbox\"][3],\n",
681
+ " \"block\": t[\"block\"], \"group\": t[\"group\"],\n",
682
+ "} for t in tiles])\n",
683
+ "tiles_df.to_csv(SPLITS_PATH, index=False)\n",
684
+ "print(f\"Saved {SPLITS_PATH}\")\n",
685
+ "tiles_df.groupby([\"split\", \"source\"]).size()"
686
+ ],
687
+ "execution_count": null,
688
+ "outputs": []
689
+ },
690
+ {
691
+ "cell_type": "markdown",
692
+ "metadata": {},
693
+ "source": [
694
+ "# 4. Patches"
695
+ ]
696
+ },
697
+ {
698
+ "cell_type": "code",
699
+ "metadata": {},
700
+ "source": [
701
+ "PATCH_SIZE_HR = config[\"PATCH_SIZE\"]\n",
702
+ "UPSCALE = config[\"UPSCALE\"]\n",
703
+ "PATCH_SIZE_LR = config[\"LR_PATCH_SIZE\"]"
704
+ ],
705
+ "execution_count": null,
706
+ "outputs": []
707
+ },
708
+ {
709
+ "cell_type": "code",
710
+ "metadata": {},
711
+ "source": [
712
+ "def normalize_for_display(patch):\n",
713
+ " p_low = np.percentile(patch, 1)\n",
714
+ " p_high = np.percentile(patch, 99)\n",
715
+ " norm = (patch - p_low) / (p_high - p_low + 1e-8)\n",
716
+ " return np.clip(norm, 0, 1)"
717
+ ],
718
+ "execution_count": null,
719
+ "outputs": []
720
+ },
721
+ {
722
+ "cell_type": "code",
723
+ "metadata": {},
724
+ "source": [
725
+ "INTERP_NAMES = {cv2.INTER_CUBIC: \"bicubic\", cv2.INTER_LINEAR: \"bilinear\", cv2.INTER_AREA: \"area\"}\n",
726
+ "\n",
727
+ "def downsample_realistic(hr, scale, return_params=False):\n",
728
+ " # HWC für OpenCV\n",
729
+ " img = hr.transpose(1, 2, 0)\n",
730
+ "\n",
731
+ " h, w = img.shape[:2]\n",
732
+ "\n",
733
+ " # Zufallsfilter\n",
734
+ " filter_type = random.choice([\"gaussian\", \"motion\", \"defocus\"])\n",
735
+ " sigma = 0.0\n",
736
+ "\n",
737
+ " if filter_type == \"gaussian\":\n",
738
+ " # Isotropischer Gauss blur mit zufall kernel + sigma (SIGMA)\n",
739
+ " k = random.choice([3, 5, 7])\n",
740
+ " sigma = random.uniform(0.5, 1.5)\n",
741
+ " img_blur = cv2.GaussianBlur(img, (k, k), sigmaX=sigma)\n",
742
+ "\n",
743
+ " elif filter_type == \"motion\":\n",
744
+ " # Anisotropischer motion blur kernel\n",
745
+ " k = random.choice([5, 7, 9])\n",
746
+ " kernel = np.zeros((k, k), dtype=np.float32)\n",
747
+ "\n",
748
+ " # Zufall horizontal oder vertikal\n",
749
+ " if random.random() < 0.5:\n",
750
+ " kernel[k // 2, :] = 1.0\n",
751
+ " filter_type = \"motion_h\"\n",
752
+ " else:\n",
753
+ " kernel[:, k // 2] = 1.0\n",
754
+ " filter_type = \"motion_v\"\n",
755
+ "\n",
756
+ " kernel /= k\n",
757
+ " img_blur = cv2.filter2D(img, -1, kernel)\n",
758
+ "\n",
759
+ " elif filter_type == \"defocus\":\n",
760
+ " # Approximierter defocus blur mit kreiskernel\n",
761
+ " k = random.choice([5, 7, 9])\n",
762
+ " kernel = np.zeros((k, k), np.float32)\n",
763
+ " center = k // 2\n",
764
+ "\n",
765
+ " for i in range(k):\n",
766
+ " for j in range(k):\n",
767
+ " if (i - center) ** 2 + (j - center) ** 2 <= center ** 2:\n",
768
+ " kernel[i, j] = 1\n",
769
+ "\n",
770
+ " kernel /= np.sum(kernel)\n",
771
+ " img_blur = cv2.filter2D(img, -1, kernel)\n",
772
+ "\n",
773
+ " # Zufallsinterpolation\n",
774
+ " interpolation = random.choice([\n",
775
+ " cv2.INTER_CUBIC, # bikubisch\n",
776
+ " cv2.INTER_LINEAR, # bilinear\n",
777
+ " cv2.INTER_AREA # flächenbasiert\n",
778
+ " ])\n",
779
+ "\n",
780
+ " lr = cv2.resize(\n",
781
+ " img_blur,\n",
782
+ " (w // scale, h // scale),\n",
783
+ " interpolation=interpolation\n",
784
+ " )\n",
785
+ "\n",
786
+ " # Zurück zu CHW\n",
787
+ " lr = lr.transpose(2, 0, 1).astype(np.float32)\n",
788
+ " if not return_params:\n",
789
+ " return lr\n",
790
+ " return lr, {\"blur\": filter_type, \"blur_kernel\": int(k), \"blur_sigma\": float(sigma),\n",
791
+ " \"interpolation\": INTERP_NAMES[interpolation]}"
792
+ ],
793
+ "execution_count": null,
794
+ "outputs": []
795
+ },
796
+ {
797
+ "cell_type": "code",
798
+ "metadata": {},
799
+ "source": [
800
+ "def extract_patches_random(aoi_data, n_patches):\n",
801
+ " rgb, ndvi, scl = aoi_data\n",
802
+ " H, W = rgb.shape[1:]\n",
803
+ "\n",
804
+ " extracted = []\n",
805
+ " attempts = 0\n",
806
+ " max_attempts = n_patches * 10\n",
807
+ "\n",
808
+ " while len(extracted) < n_patches and attempts < max_attempts:\n",
809
+ " y = np.random.randint(0, H - PATCH_SIZE_HR)\n",
810
+ " x = np.random.randint(0, W - PATCH_SIZE_HR)\n",
811
+ "\n",
812
+ " patch_rgb = rgb[:, y:y+PATCH_SIZE_HR, x:x+PATCH_SIZE_HR]\n",
813
+ " patch_ndvi = ndvi[y:y+PATCH_SIZE_HR, x:x+PATCH_SIZE_HR]\n",
814
+ " patch_scl = scl[y:y+PATCH_SIZE_HR, x:x+PATCH_SIZE_HR]\n",
815
+ "\n",
816
+ " cloud_ratio = np.isin(\n",
817
+ " patch_scl, [3,7,8,9,10]\n",
818
+ " ).mean()\n",
819
+ "\n",
820
+ " if cloud_ratio > config[\"MAX_CLOUD_PATCH\"]:\n",
821
+ " attempts += 1\n",
822
+ " continue\n",
823
+ "\n",
824
+ " hr = normalize_for_display(patch_rgb / 10000.0)\n",
825
+ " lr, deg = downsample_realistic(hr, UPSCALE, return_params=True)\n",
826
+ "\n",
827
+ " extracted.append({\n",
828
+ " \"lr\": lr, \"hr\": hr, \"ndvi\": patch_ndvi, \"scl\": patch_scl, \"rgb\": patch_rgb,\n",
829
+ " \"y\": int(y), \"x\": int(x), \"cloud_ratio\": float(cloud_ratio),\n",
830
+ " \"p_low\": float(np.percentile(patch_rgb, 1)), \"p_high\": float(np.percentile(patch_rgb, 99)),\n",
831
+ " **deg,\n",
832
+ " })\n",
833
+ " attempts += 1\n",
834
+ "\n",
835
+ " return extracted"
836
+ ],
837
+ "execution_count": null,
838
+ "outputs": []
839
+ },
840
+ {
841
+ "cell_type": "markdown",
842
+ "metadata": {},
843
+ "source": [
844
+ "### Beispiel"
845
+ ]
846
+ },
847
+ {
848
+ "cell_type": "code",
849
+ "metadata": {},
850
+ "source": [
851
+ "data = np.load(tiles[0][\"path\"])\n",
852
+ "np.random.seed(0); random.seed(0)\n",
853
+ "ex = extract_patches_random((data[\"rgb\"], data[\"ndvi\"], data[\"scl\"]), 4)\n",
854
+ "\n",
855
+ "plt.figure(figsize=(12, 9))\n",
856
+ "for i, p in enumerate(ex):\n",
857
+ " plt.subplot(3, 4, i + 1); plt.imshow(p[\"hr\"].transpose(1, 2, 0)); plt.title(\"HR\"); plt.axis(\"off\")\n",
858
+ " plt.subplot(3, 4, i + 5); plt.imshow(np.clip(p[\"lr\"], 0, 1).transpose(1, 2, 0)); plt.title(f\"LR ({p['blur']}, {p['interpolation']})\"); plt.axis(\"off\")\n",
859
+ " plt.subplot(3, 4, i + 9); plt.imshow(p[\"ndvi\"], cmap=\"RdYlGn\", vmin=-1, vmax=1); plt.title(\"NDVI\"); plt.axis(\"off\")\n",
860
+ "plt.tight_layout(); plt.show()"
861
+ ],
862
+ "execution_count": null,
863
+ "outputs": []
864
+ },
865
+ {
866
+ "cell_type": "markdown",
867
+ "metadata": {},
868
+ "source": [
869
+ "### Hugging Face Schema"
870
+ ]
871
+ },
872
+ {
873
+ "cell_type": "code",
874
+ "metadata": {},
875
+ "source": [
876
+ "P = PATCH_SIZE_HR\n",
877
+ "\n",
878
+ "features = Features({\n",
879
+ " \"patch_id\": Value(\"string\"),\n",
880
+ " \"tile_id\": Value(\"string\"),\n",
881
+ " \"source\": Value(\"string\"),\n",
882
+ " \"aoi_name\": Value(\"string\"),\n",
883
+ " \"lr\": HFImage(),\n",
884
+ " \"hr\": HFImage(),\n",
885
+ " \"ndvi\": Array2D((P, P), \"float32\"),\n",
886
+ " \"scl\": Array2D((P, P), \"uint8\"),\n",
887
+ " \"reflectance\": Array3D((3, P, P), \"uint16\"),\n",
888
+ " \"lon\": Value(\"float64\"),\n",
889
+ " \"lat\": Value(\"float64\"),\n",
890
+ " \"lon_min\": Value(\"float64\"),\n",
891
+ " \"lat_min\": Value(\"float64\"),\n",
892
+ " \"lon_max\": Value(\"float64\"),\n",
893
+ " \"lat_max\": Value(\"float64\"),\n",
894
+ " \"crop_y\": Value(\"int32\"),\n",
895
+ " \"crop_x\": Value(\"int32\"),\n",
896
+ " \"cloud_fraction\": Value(\"float32\"),\n",
897
+ " \"water_fraction\": Value(\"float32\"),\n",
898
+ " \"stretch_low\": Value(\"float32\"),\n",
899
+ " \"stretch_high\": Value(\"float32\"),\n",
900
+ " \"blur\": Value(\"string\"),\n",
901
+ " \"blur_kernel\": Value(\"int32\"),\n",
902
+ " \"blur_sigma\": Value(\"float32\"),\n",
903
+ " \"interpolation\": Value(\"string\"),\n",
904
+ " \"time_from\": Value(\"string\"),\n",
905
+ " \"time_to\": Value(\"string\"),\n",
906
+ "})\n",
907
+ "\n",
908
+ "def to_png(arr_chw):\n",
909
+ " return Image.fromarray(np.round(np.clip(arr_chw, 0, 1).transpose(1, 2, 0) * 255).astype(np.uint8))\n",
910
+ "\n",
911
+ "def tile_rows(t, tile_index):\n",
912
+ " data = np.load(t[\"path\"])\n",
913
+ " rgb, ndvi, scl = data[\"rgb\"], data[\"ndvi\"], data[\"scl\"]\n",
914
+ "\n",
915
+ " # Reproduzierbar pro Kachel\n",
916
+ " seed = config[\"SEED\"] * 100003 + tile_index\n",
917
+ " np.random.seed(seed); random.seed(seed)\n",
918
+ " patches = extract_patches_random((rgb, ndvi, scl), n_patches=config[\"PATCHES_PER_TILE\"])\n",
919
+ "\n",
920
+ " lon_min, lat_min, lon_max, lat_max = t[\"bbox\"]\n",
921
+ " H, W = rgb.shape[1:]\n",
922
+ " dlon, dlat = (lon_max - lon_min) / W, (lat_max - lat_min) / H\n",
923
+ " t_from = str(data[\"time_from\"]) if \"time_from\" in data.files else (config[\"TIME_FROM\"] if t[\"source\"] == \"random\" else \"\")\n",
924
+ " t_to = str(data[\"time_to\"]) if \"time_to\" in data.files else (config[\"TIME_TO\"] if t[\"source\"] == \"random\" else \"\")\n",
925
+ "\n",
926
+ " for k, p in enumerate(patches):\n",
927
+ " # Zeile 0 = Norden\n",
928
+ " p_lon_min = lon_min + p[\"x\"] * dlon\n",
929
+ " p_lon_max = p_lon_min + P * dlon\n",
930
+ " p_lat_max = lat_max - p[\"y\"] * dlat\n",
931
+ " p_lat_min = p_lat_max - P * dlat\n",
932
+ " yield {\n",
933
+ " \"patch_id\": f\"{t['tile_id']}_p{k:02d}\",\n",
934
+ " \"tile_id\": t[\"tile_id\"],\n",
935
+ " \"source\": t[\"source\"],\n",
936
+ " \"aoi_name\": t[\"aoi_name\"],\n",
937
+ " \"lr\": to_png(p[\"lr\"]),\n",
938
+ " \"hr\": to_png(p[\"hr\"]),\n",
939
+ " \"ndvi\": np.clip(p[\"ndvi\"], -1, 1).astype(np.float32),\n",
940
+ " \"scl\": p[\"scl\"].astype(np.uint8),\n",
941
+ " \"reflectance\": np.round(p[\"rgb\"] * 10000).clip(0, 65535).astype(np.uint16),\n",
942
+ " \"lon\": (p_lon_min + p_lon_max) / 2,\n",
943
+ " \"lat\": (p_lat_min + p_lat_max) / 2,\n",
944
+ " \"lon_min\": p_lon_min, \"lat_min\": p_lat_min, \"lon_max\": p_lon_max, \"lat_max\": p_lat_max,\n",
945
+ " \"crop_y\": p[\"y\"], \"crop_x\": p[\"x\"],\n",
946
+ " \"cloud_fraction\": p[\"cloud_ratio\"],\n",
947
+ " \"water_fraction\": float((p[\"scl\"] == 6).mean()),\n",
948
+ " \"stretch_low\": p[\"p_low\"], \"stretch_high\": p[\"p_high\"],\n",
949
+ " \"blur\": p[\"blur\"], \"blur_kernel\": p[\"blur_kernel\"], \"blur_sigma\": p[\"blur_sigma\"],\n",
950
+ " \"interpolation\": p[\"interpolation\"],\n",
951
+ " \"time_from\": t_from, \"time_to\": t_to,\n",
952
+ " }"
953
+ ],
954
+ "execution_count": null,
955
+ "outputs": []
956
+ },
957
+ {
958
+ "cell_type": "markdown",
959
+ "metadata": {},
960
+ "source": [
961
+ "### Patches erzeugen und als Parquet schreiben\n",
962
+ "\n",
963
+ "Shards ≤ ~500 MB wie `data/train-00000-of-00012.parquet`."
964
+ ]
965
+ },
966
+ {
967
+ "cell_type": "code",
968
+ "metadata": {},
969
+ "source": [
970
+ "SHARD_ROWS = 800\n",
971
+ "\n",
972
+ "tile_index = {t[\"tile_id\"]: i for i, t in enumerate(tiles)}\n",
973
+ "split_ds = {}\n",
974
+ "\n",
975
+ "for split in [\"train\", \"validation\", \"test\"]:\n",
976
+ " split_tiles = [t for t in tiles if t[\"split\"] == split]\n",
977
+ "\n",
978
+ " def gen(split_tiles=split_tiles):\n",
979
+ " for t in tqdm(split_tiles, desc=f\"Patches {split}\", ncols=120):\n",
980
+ " yield from tile_rows(t, tile_index[t[\"tile_id\"]])\n",
981
+ "\n",
982
+ " ds = Dataset.from_generator(gen, features=features, cache_dir=LOCAL_CACHE)\n",
983
+ " ds = ds.shuffle(seed=config[\"SEED\"]).flatten_indices()\n",
984
+ " split_ds[split] = ds\n",
985
+ " print(f\"{split}: {len(ds)} patches from {len(split_tiles)} tiles\")"
986
+ ],
987
+ "execution_count": null,
988
+ "outputs": []
989
+ },
990
+ {
991
+ "cell_type": "code",
992
+ "metadata": {},
993
+ "source": [
994
+ "# Alte Shards entfernen (sonst Mischung aus Läufen)\n",
995
+ "shutil.rmtree(os.path.join(LOCAL_OUT, \"data\"), ignore_errors=True)\n",
996
+ "\n",
997
+ "for split, ds in split_ds.items():\n",
998
+ " n_shards = max(1, math.ceil(len(ds) / SHARD_ROWS))\n",
999
+ " for i in range(n_shards):\n",
1000
+ " out = os.path.join(LOCAL_OUT, \"data\", f\"{split}-{i:05d}-of-{n_shards:05d}.parquet\")\n",
1001
+ " os.makedirs(os.path.dirname(out), exist_ok=True)\n",
1002
+ " ds.shard(n_shards, i, contiguous=True).to_parquet(out)\n",
1003
+ " print(f\"{split}: {n_shards} shards\")\n",
1004
+ "\n",
1005
+ "!du -sh {LOCAL_OUT}/data"
1006
+ ],
1007
+ "execution_count": null,
1008
+ "outputs": []
1009
+ },
1010
+ {
1011
+ "cell_type": "markdown",
1012
+ "metadata": {},
1013
+ "source": [
1014
+ "### Auf Drive kopieren (ersetzt alten Stand)"
1015
+ ]
1016
+ },
1017
+ {
1018
+ "cell_type": "code",
1019
+ "metadata": {},
1020
+ "source": [
1021
+ "for f in glob(os.path.join(HF_DATA_DIR, \"*.parquet\")):\n",
1022
+ " os.remove(f)\n",
1023
+ "for f in sorted(glob(os.path.join(LOCAL_OUT, \"data\", \"*.parquet\"))):\n",
1024
+ " shutil.copy(f, HF_DATA_DIR)\n",
1025
+ "\n",
1026
+ "tiles_df.to_csv(os.path.join(HF_META_DIR, \"tiles.csv\"), index=False)\n",
1027
+ "print(sorted(os.listdir(HF_DATA_DIR)))"
1028
+ ],
1029
+ "execution_count": null,
1030
+ "outputs": []
1031
+ },
1032
+ {
1033
+ "cell_type": "markdown",
1034
+ "metadata": {},
1035
+ "source": [
1036
+ "# 5. Checks"
1037
+ ]
1038
+ },
1039
+ {
1040
+ "cell_type": "code",
1041
+ "metadata": {},
1042
+ "source": [
1043
+ "from datasets import load_dataset\n",
1044
+ "\n",
1045
+ "hf = load_dataset(\"parquet\", data_dir=HF_DATA_DIR, cache_dir=LOCAL_CACHE)\n",
1046
+ "print(hf)"
1047
+ ],
1048
+ "execution_count": null,
1049
+ "outputs": []
1050
+ },
1051
+ {
1052
+ "cell_type": "code",
1053
+ "metadata": {},
1054
+ "source": [
1055
+ "# Kachel-IDs disjunkt?\n",
1056
+ "ids = {s: set(hf[s][\"tile_id\"]) for s in hf}\n",
1057
+ "assert not (ids[\"train\"] & ids[\"validation\"]), \"train/val leak\"\n",
1058
+ "assert not (ids[\"train\"] & ids[\"test\"]), \"train/test leak\"\n",
1059
+ "assert not (ids[\"validation\"] & ids[\"test\"]), \"val/test leak\"\n",
1060
+ "\n",
1061
+ "# Patch-IDs eindeutig?\n",
1062
+ "all_ids = sum((list(hf[s][\"patch_id\"]) for s in hf), [])\n",
1063
+ "assert len(all_ids) == len(set(all_ids))\n",
1064
+ "\n",
1065
+ "# Geometrisch disjunkt (Patch-Ebene)?\n",
1066
+ "boxes = {s: hf[s].select_columns([\"lon_min\", \"lat_min\", \"lon_max\", \"lat_max\"]).to_pandas().values for s in hf}\n",
1067
+ "for a in hf:\n",
1068
+ " for b in hf:\n",
1069
+ " if a < b:\n",
1070
+ " A, Bx = boxes[a], boxes[b]\n",
1071
+ " ov = ((A[:, None, 0] < Bx[None, :, 2]) & (Bx[None, :, 0] < A[:, None, 2]) &\n",
1072
+ " (A[:, None, 1] < Bx[None, :, 3]) & (Bx[None, :, 1] < A[:, None, 3])).sum()\n",
1073
+ " assert ov == 0, f\"{a}/{b}: {ov} overlapping patches\"\n",
1074
+ "\n",
1075
+ "x = hf[\"train\"][0]\n",
1076
+ "print(x[\"lr\"].size, x[\"hr\"].size, np.array(x[\"ndvi\"]).shape, np.array(x[\"reflectance\"]).shape)\n",
1077
+ "print(\"OK: splits disjoint by tile, patch id and footprint.\")"
1078
+ ],
1079
+ "execution_count": null,
1080
+ "outputs": []
1081
+ },
1082
+ {
1083
+ "cell_type": "markdown",
1084
+ "metadata": {},
1085
+ "source": [
1086
+ "# 6. Statistiken und Abbildungen für die Dataset Card"
1087
+ ]
1088
+ },
1089
+ {
1090
+ "cell_type": "code",
1091
+ "metadata": {},
1092
+ "source": [
1093
+ "SPLITS = [\"train\", \"validation\", \"test\"]\n",
1094
+ "SPLIT_COLORS = {\"train\": \"#2a78d6\", \"validation\": \"#eb6834\", \"test\": \"#1baf7a\"}\n",
1095
+ "SURFACE, INK, INK2, GRID = \"#fcfcfb\", \"#0b0b0b\", \"#52514e\", \"#e4e3df\"\n",
1096
+ "\n",
1097
+ "plt.rcParams.update({\n",
1098
+ " \"figure.facecolor\": SURFACE, \"axes.facecolor\": SURFACE, \"savefig.facecolor\": SURFACE,\n",
1099
+ " \"axes.edgecolor\": GRID, \"axes.labelcolor\": INK2, \"xtick.color\": INK2, \"ytick.color\": INK2,\n",
1100
+ " \"text.color\": INK, \"axes.grid\": True, \"grid.color\": GRID, \"grid.linewidth\": 0.6,\n",
1101
+ " \"axes.spines.top\": False, \"axes.spines.right\": False, \"font.size\": 11,\n",
1102
+ " \"axes.titleweight\": \"bold\", \"axes.titlesize\": 13, \"legend.frameon\": False,\n",
1103
+ "})\n",
1104
+ "\n",
1105
+ "def save_fig(fig, name):\n",
1106
+ " path = os.path.join(HF_IMAGES_DIR, name)\n",
1107
+ " fig.savefig(path, dpi=160, bbox_inches=\"tight\")\n",
1108
+ " print(\"saved\", path)\n",
1109
+ "\n",
1110
+ "meta = {s: hf[s].select_columns([c for c in hf[s].column_names if c not in\n",
1111
+ " (\"lr\", \"hr\", \"ndvi\", \"scl\", \"reflectance\")]).to_pandas() for s in SPLITS}"
1112
+ ],
1113
+ "execution_count": null,
1114
+ "outputs": []
1115
+ },
1116
+ {
1117
+ "cell_type": "markdown",
1118
+ "metadata": {},
1119
+ "source": [
1120
+ "### LR/HR-Paare (Test-Split)"
1121
+ ]
1122
+ },
1123
+ {
1124
+ "cell_type": "code",
1125
+ "metadata": {},
1126
+ "source": [
1127
+ "plt.rcdefaults()\n",
1128
+ "plt.rcParams.update({\"figure.facecolor\": \"white\", \"savefig.facecolor\": \"white\", \"font.size\": 12})\n",
1129
+ "\n",
1130
+ "def plain(axs):\n",
1131
+ " for a in np.ravel(axs):\n",
1132
+ " a.set_xticks([]); a.set_yticks([])\n",
1133
+ " for sp in a.spines.values():\n",
1134
+ " sp.set_visible(False)\n",
1135
+ "\n",
1136
+ "# Ausgewählte Test-Patches für die Dataset Card\n",
1137
+ "PAIR_IDS = ['aoi_0424_p03', 'custom_0004_p02', 'aoi_0176_p10', 'aoi_0374_p02', 'custom_0002_p00', 'aoi_0261_p02']\n",
1138
+ "DEG_ID = 'custom_0006_p07'\n",
1139
+ "pid = list(meta[\"test\"].patch_id)\n",
1140
+ "pair_idx = [pid.index(p) for p in PAIR_IDS]\n",
1141
+ "\n",
1142
+ "fig, axs = plt.subplots(2, len(pair_idx), figsize=(3.1 * len(pair_idx), 6.6))\n",
1143
+ "for c, i in enumerate(pair_idx):\n",
1144
+ " r = hf[\"test\"][i]\n",
1145
+ " axs[0, c].imshow(r[\"lr\"], interpolation=\"nearest\")\n",
1146
+ " axs[1, c].imshow(r[\"hr\"])\n",
1147
+ " axs[0, c].set_title(r[\"aoi_name\"] or f\"{r['lat']:.2f}°N {r['lon']:.2f}°E\", fontsize=12)\n",
1148
+ "axs[0, 0].set_ylabel(\"LR input (64×64)\", fontsize=13)\n",
1149
+ "axs[1, 0].set_ylabel(\"HR target (256×256)\", fontsize=13)\n",
1150
+ "plain(axs)\n",
1151
+ "plt.tight_layout(pad=0.4)\n",
1152
+ "save_fig(fig, \"psrgan_s2_pairs.png\"); plt.show()"
1153
+ ],
1154
+ "execution_count": null,
1155
+ "outputs": []
1156
+ },
1157
+ {
1158
+ "cell_type": "markdown",
1159
+ "metadata": {},
1160
+ "source": [
1161
+ "### Land Cover (SCL) je Split (für README)"
1162
+ ]
1163
+ },
1164
+ {
1165
+ "cell_type": "code",
1166
+ "metadata": {},
1167
+ "source": [
1168
+ "SCL_NAMES = {2: \"Dark area\", 3: \"Cloud shadow\", 4: \"Vegetation\", 5: \"Not vegetated\", 6: \"Water\",\n",
1169
+ " 7: \"Unclassified\", 8: \"Cloud (medium)\", 9: \"Cloud (high)\", 10: \"Thin cirrus\", 11: \"Snow/ice\"}\n",
1170
+ "\n",
1171
+ "scl_frac = {}\n",
1172
+ "for s in SPLITS:\n",
1173
+ " counts = np.zeros(12)\n",
1174
+ " for batch in hf[s].select_columns([\"scl\"]).with_format(\"numpy\").iter(batch_size=256):\n",
1175
+ " counts += np.bincount(batch[\"scl\"].ravel(), minlength=12)[:12]\n",
1176
+ " scl_frac[s] = counts / counts.sum()\n",
1177
+ "\n",
1178
+ "for c in SCL_NAMES:\n",
1179
+ " print(f\"{SCL_NAMES[c]:15s}\", \" \".join(f\"{s}: {scl_frac[s][c]:6.1%}\" for s in SPLITS))"
1180
+ ],
1181
+ "execution_count": null,
1182
+ "outputs": []
1183
+ },
1184
+ {
1185
+ "cell_type": "markdown",
1186
+ "metadata": {},
1187
+ "source": [
1188
+ "### NDVI je Split (für README)"
1189
+ ]
1190
+ },
1191
+ {
1192
+ "cell_type": "code",
1193
+ "metadata": {},
1194
+ "source": [
1195
+ "bins = np.linspace(-1, 1, 81)\n",
1196
+ "ndvi_stats = {}\n",
1197
+ "for s in SPLITS:\n",
1198
+ " h = np.zeros(len(bins) - 1)\n",
1199
+ " for batch in hf[s].select_columns([\"ndvi\"]).with_format(\"numpy\").iter(batch_size=256):\n",
1200
+ " h += np.histogram(batch[\"ndvi\"].ravel(), bins=bins)[0]\n",
1201
+ " h = h / h.sum() / np.diff(bins)\n",
1202
+ " ndvi_stats[s] = float(((bins[:-1] + bins[1:]) / 2 * h * np.diff(bins)).sum())\n",
1203
+ "print(\"NDVI-Mittel:\", {s: round(v, 3) for s, v in ndvi_stats.items()})"
1204
+ ],
1205
+ "execution_count": null,
1206
+ "outputs": []
1207
+ },
1208
+ {
1209
+ "cell_type": "markdown",
1210
+ "metadata": {},
1211
+ "source": [
1212
+ "### Degradations-Pipeline an einem Patch"
1213
+ ]
1214
+ },
1215
+ {
1216
+ "cell_type": "code",
1217
+ "metadata": {},
1218
+ "source": [
1219
+ "# Echter downsample_realistic-Aufruf, je Filter ein Beispiel (unten: Ausschnitt vergrößert)\n",
1220
+ "src = pid.index(DEG_ID)\n",
1221
+ "hr_img = np.asarray(hf[\"test\"][src][\"hr\"])[..., :3].astype(np.float32) / 255\n",
1222
+ "targets = [(\"gaussian\", \"bicubic\"), (\"motion_h\", \"bilinear\"), (\"motion_v\", \"area\"), (\"defocus\", \"bicubic\")]\n",
1223
+ "found, seed = {}, 0\n",
1224
+ "while len(found) < len(targets) and seed < 5000:\n",
1225
+ " random.seed(seed)\n",
1226
+ " lr, p = downsample_realistic(hr_img.transpose(2, 0, 1), UPSCALE, return_params=True)\n",
1227
+ " if (p[\"blur\"], p[\"interpolation\"]) in targets:\n",
1228
+ " found.setdefault((p[\"blur\"], p[\"interpolation\"]), (lr, p))\n",
1229
+ " seed += 1\n",
1230
+ "\n",
1231
+ "BLUR_NAMES = {\"gaussian\": \"Gaussian\", \"motion_h\": \"Motion (horizontal)\", \"motion_v\": \"Motion (vertical)\", \"defocus\": \"Defocus\"}\n",
1232
+ "keys = [t for t in targets if t in found]\n",
1233
+ "# Ausschnitt mit den meisten Kanten (64×64 HR bzw. 16×16 LR)\n",
1234
+ "g = hr_img.mean(-1)\n",
1235
+ "gm = np.abs(np.diff(g, axis=0))[:, :-1] + np.abs(np.diff(g, axis=1))[:-1, :]\n",
1236
+ "cy, cx = max(((y, x) for y in range(0, 193, 16) for x in range(0, 193, 16)),\n",
1237
+ " key=lambda q: gm[q[0]:q[0] + 64, q[1]:q[1] + 64].sum())\n",
1238
+ "fig, axs = plt.subplots(2, 1 + len(keys), figsize=(3.3 * (1 + len(keys)), 7.2))\n",
1239
+ "axs[0, 0].imshow(hr_img); axs[0, 0].set_title(\"HR (256×256)\", fontsize=12)\n",
1240
+ "axs[0, 0].add_patch(plt.Rectangle((cx, cy), 64, 64, fill=False, edgecolor=\"white\", linewidth=1.5))\n",
1241
+ "axs[1, 0].imshow(hr_img[cy:cy + 64, cx:cx + 64], interpolation=\"nearest\")\n",
1242
+ "for c, key in enumerate(keys, start=1):\n",
1243
+ " lr, p = found[key]\n",
1244
+ " lr_img = np.clip(lr.transpose(1, 2, 0), 0, 1)\n",
1245
+ " axs[0, c].imshow(lr_img, interpolation=\"nearest\")\n",
1246
+ " axs[1, c].imshow(lr_img[cy // 4:(cy + 64) // 4, cx // 4:(cx + 64) // 4], interpolation=\"nearest\")\n",
1247
+ " blur = BLUR_NAMES[p[\"blur\"]] + f\" {p['blur_kernel']}×{p['blur_kernel']}\"\n",
1248
+ " if p[\"blur\"] == \"gaussian\":\n",
1249
+ " blur += f\", σ {p['blur_sigma']:.1f}\"\n",
1250
+ " axs[0, c].set_title(f\"{blur}\\n+ {p['interpolation']} ↓4\", fontsize=12)\n",
1251
+ "axs[0, 0].set_ylabel(\"full patch\", fontsize=12)\n",
1252
+ "axs[1, 0].set_ylabel(\"zoom (white box)\", fontsize=12)\n",
1253
+ "plain(axs)\n",
1254
+ "plt.tight_layout(pad=0.4)\n",
1255
+ "save_fig(fig, \"psrgan_s2_degradation.png\"); plt.show()"
1256
+ ],
1257
+ "execution_count": null,
1258
+ "outputs": []
1259
+ },
1260
+ {
1261
+ "cell_type": "markdown",
1262
+ "metadata": {},
1263
+ "source": [
1264
+ "### Kennzahlen (für README)"
1265
+ ]
1266
+ },
1267
+ {
1268
+ "cell_type": "code",
1269
+ "metadata": {},
1270
+ "source": [
1271
+ "def dir_bytes(pattern):\n",
1272
+ " return int(sum(os.path.getsize(f) for f in glob(pattern)))\n",
1273
+ "\n",
1274
+ "stats = {\n",
1275
+ " \"created\": time.strftime(\"%Y-%m-%d\"),\n",
1276
+ " \"region\": config[\"REGION\"],\n",
1277
+ " \"region_v1\": config[\"REGION_V1\"],\n",
1278
+ " \"time_window\": [config[\"TIME_FROM\"], config[\"TIME_TO\"]],\n",
1279
+ " \"patches_per_tile\": config[\"PATCHES_PER_TILE\"],\n",
1280
+ " \"n_tiles_total\": len(tiles),\n",
1281
+ " \"n_blocks\": len(block_groups),\n",
1282
+ " \"splits\": {},\n",
1283
+ "}\n",
1284
+ "for s in SPLITS:\n",
1285
+ " m = meta[s]\n",
1286
+ " stats[\"splits\"][s] = {\n",
1287
+ " \"num_examples\": len(hf[s]),\n",
1288
+ " \"num_tiles\": int(m.tile_id.nunique()),\n",
1289
+ " \"num_city_patches\": int((m.source == \"city\").sum()),\n",
1290
+ " \"num_bytes_parquet\": dir_bytes(os.path.join(HF_DATA_DIR, f\"{s}-*.parquet\")),\n",
1291
+ " \"num_bytes_arrow\": int(split_ds[s].data.nbytes),\n",
1292
+ " \"num_shards\": len(glob(os.path.join(HF_DATA_DIR, f\"{s}-*.parquet\"))),\n",
1293
+ " \"cloud_fraction_mean\": float(m.cloud_fraction.mean()),\n",
1294
+ " \"water_fraction_mean\": float(m.water_fraction.mean()),\n",
1295
+ " \"ndvi_mean\": ndvi_stats[s],\n",
1296
+ " \"scl_fraction\": {SCL_NAMES.get(c, str(c)): float(scl_frac[s][c]) for c in range(12) if scl_frac[s][c] > 0},\n",
1297
+ " \"lat_range\": [float(m.lat.min()), float(m.lat.max())],\n",
1298
+ " \"lon_range\": [float(m.lon.min()), float(m.lon.max())],\n",
1299
+ " }\n",
1300
+ "stats[\"num_examples_total\"] = sum(v[\"num_examples\"] for v in stats[\"splits\"].values())\n",
1301
+ "stats[\"download_size\"] = sum(v[\"num_bytes_parquet\"] for v in stats[\"splits\"].values())\n",
1302
+ "\n",
1303
+ "json.dump(stats, open(os.path.join(HF_META_DIR, \"stats.json\"), \"w\"), indent=2, ensure_ascii=False)\n",
1304
+ "print(json.dumps(stats, indent=2, ensure_ascii=False))"
1305
+ ],
1306
+ "execution_count": null,
1307
+ "outputs": []
1308
+ },
1309
+ {
1310
+ "cell_type": "code",
1311
+ "metadata": {},
1312
+ "source": [
1313
+ "!ls -la {HF_DATASET_DIR} {HF_DATA_DIR} {HF_IMAGES_DIR} {HF_META_DIR}"
1314
+ ],
1315
+ "execution_count": null,
1316
+ "outputs": []
1317
+ }
1318
+ ],
1319
+ "metadata": {
1320
+ "colab": {
1321
+ "machine_shape": "hm",
1322
+ "provenance": []
1323
+ },
1324
+ "kernelspec": {
1325
+ "display_name": "Python 3",
1326
+ "name": "python3"
1327
+ },
1328
+ "language_info": {
1329
+ "name": "python"
1330
+ }
1331
+ },
1332
+ "nbformat": 4,
1333
+ "nbformat_minor": 0
1334
+ }
notebooks/PSRGAN_training.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
psrgan.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PSRGAN: probabilistic 4x super-resolution for Sentinel-2 RGB.
2
+
3
+ Standalone model code (PyTorch only). The backbone is a copy of the RRDBNet
4
+ architecture from BasicSR / Real-ESRGAN (BSD-3-Clause), with module names kept
5
+ identical so the published weights load strictly.
6
+
7
+ from psrgan import PSRGAN
8
+ model = PSRGAN.from_pretrained("marklpd/PSRGAN").eval()
9
+ out = model.super_resolve(lr) # lr: (B, 3, H, W) in [0, 1]
10
+ out["sr"], out["sigma_aleatoric"]
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ from huggingface_hub import PyTorchModelHubMixin
18
+
19
+ __all__ = ["PSRGAN", "RRDBNet", "BayesianConv2d"]
20
+
21
+
22
+ # ---------------------------------------------------------------- backbone --
23
+ class ResidualDenseBlock(nn.Module):
24
+ def __init__(self, num_feat: int = 64, num_grow_ch: int = 32):
25
+ super().__init__()
26
+ self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1)
27
+ self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1)
28
+ self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1)
29
+ self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1)
30
+ self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1)
31
+ self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
32
+
33
+ def forward(self, x):
34
+ x1 = self.lrelu(self.conv1(x))
35
+ x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
36
+ x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
37
+ x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
38
+ x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
39
+ return x5 * 0.2 + x
40
+
41
+
42
+ class RRDB(nn.Module):
43
+ def __init__(self, num_feat: int, num_grow_ch: int = 32):
44
+ super().__init__()
45
+ self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch)
46
+ self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch)
47
+ self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch)
48
+
49
+ def forward(self, x):
50
+ out = self.rdb1(x)
51
+ out = self.rdb2(out)
52
+ out = self.rdb3(out)
53
+ return out * 0.2 + x
54
+
55
+
56
+ class RRDBNet(nn.Module):
57
+ """RRDBNet (x4) with the same parameter names as basicsr.archs.rrdbnet_arch.RRDBNet."""
58
+
59
+ def __init__(self, num_in_ch=3, num_out_ch=3, num_feat=64, num_block=23, num_grow_ch=32):
60
+ super().__init__()
61
+ self.conv_first = nn.Conv2d(num_in_ch, num_feat, 3, 1, 1)
62
+ self.body = nn.Sequential(*[RRDB(num_feat, num_grow_ch) for _ in range(num_block)])
63
+ self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
64
+ self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
65
+ self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
66
+ self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1)
67
+ # Unused by PSRGAN (the mu/s heads replace it); kept so Real-ESRGAN keys line up.
68
+ self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1)
69
+ self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True)
70
+
71
+
72
+ # ------------------------------------------------------------------- heads --
73
+ class BayesianConv2d(nn.Module):
74
+ """3x3 conv with a Gaussian over its weights (mean + log-variance)."""
75
+
76
+ def __init__(self, in_ch: int, out_ch: int, init_logvar: float = -10.0):
77
+ super().__init__()
78
+ conv = nn.Conv2d(in_ch, out_ch, 3, 1, 1)
79
+ self.weight_mu = nn.Parameter(conv.weight.data.clone())
80
+ self.weight_logvar = nn.Parameter(torch.full_like(conv.weight, init_logvar))
81
+ self.bias = conv.bias
82
+
83
+ def forward(self, x, sample: bool = True):
84
+ # Weights are only sampled in train mode, exactly as during training.
85
+ if self.training and sample:
86
+ eps = torch.randn_like(self.weight_mu)
87
+ weight = self.weight_mu + eps * torch.exp(0.5 * self.weight_logvar)
88
+ else:
89
+ weight = self.weight_mu
90
+ return F.conv2d(x, weight, self.bias, stride=1, padding=1)
91
+
92
+
93
+ # ------------------------------------------------------------------- model --
94
+ class PSRGAN(
95
+ nn.Module,
96
+ PyTorchModelHubMixin,
97
+ library_name="pytorch",
98
+ license="bsd-3-clause",
99
+ pipeline_tag="image-to-image",
100
+ tags=["super-resolution", "sentinel-2", "remote-sensing", "uncertainty", "esrgan"],
101
+ ):
102
+ """4x super-resolution with a per-pixel mean (mu) and log-variance (s) output.
103
+
104
+ forward(lr) returns (mu, s): mu in [0, 1] is the super-resolved image,
105
+ s = log(sigma^2) is the aleatoric uncertainty per pixel and channel.
106
+ """
107
+
108
+ def __init__(self, num_feat: int = 64, num_block: int = 23, num_grow_ch: int = 32,
109
+ scale: int = 4, bayesian_heads: bool = True, logvar_min: float = -10.0,
110
+ logvar_max: float = 10.0):
111
+ super().__init__()
112
+ assert scale == 4, "PSRGAN is trained for x4"
113
+ self.scale = scale
114
+ self.logvar_min, self.logvar_max = logvar_min, logvar_max
115
+ self.backbone = RRDBNet(3, 3, num_feat, num_block, num_grow_ch)
116
+ if bayesian_heads:
117
+ self.mu_head = BayesianConv2d(num_feat, 3)
118
+ self.s_head = BayesianConv2d(num_feat, 3)
119
+ else:
120
+ self.mu_head = nn.Conv2d(num_feat, 3, 3, 1, 1)
121
+ self.s_head = nn.Conv2d(num_feat, 3, 3, 1, 1)
122
+ self._swag = None
123
+
124
+ def features(self, lr):
125
+ b = self.backbone
126
+ feat = b.conv_first(lr)
127
+ feat = feat + b.conv_body(b.body(feat))
128
+ feat = F.leaky_relu(b.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest")), 0.2, inplace=True)
129
+ feat = F.leaky_relu(b.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest")), 0.2, inplace=True)
130
+ return F.leaky_relu(b.conv_hr(feat), 0.2, inplace=True)
131
+
132
+ def forward(self, lr, sample_bayes: bool = False):
133
+ feat = self.features(lr)
134
+ if isinstance(self.mu_head, BayesianConv2d):
135
+ mu = torch.sigmoid(self.mu_head(feat, sample=sample_bayes))
136
+ s = self.s_head(feat, sample=sample_bayes)
137
+ else:
138
+ mu = torch.sigmoid(self.mu_head(feat))
139
+ s = self.s_head(feat)
140
+ return mu, torch.clamp(s, self.logvar_min, self.logvar_max)
141
+
142
+ # --------------------------------------------------------------- SWAG --
143
+ def load_swag(self, path: str):
144
+ """Load SWAG moments (psrgan_x4_swag.safetensors) for epistemic uncertainty."""
145
+ from safetensors.torch import load_file
146
+
147
+ t = load_file(path)
148
+ self._swag = {
149
+ "mean": {k[5:]: v for k, v in t.items() if k.startswith("mean.")},
150
+ "var": {k[4:]: v for k, v in t.items() if k.startswith("var.")},
151
+ }
152
+ return self
153
+
154
+ @torch.no_grad()
155
+ def _set_swag_sample(self, generator=None):
156
+ for n, p in self.named_parameters():
157
+ if n in self._swag["mean"]:
158
+ m = self._swag["mean"][n].to(p.device)
159
+ v = self._swag["var"][n].to(p.device)
160
+ eps = torch.randn(m.shape, generator=generator, device="cpu").to(p.device)
161
+ p.copy_(m + v.sqrt() * eps)
162
+
163
+ # ---------------------------------------------------------- inference --
164
+ @torch.no_grad()
165
+ def super_resolve(self, lr, mc_samples: int = 0, swag_samples: int = 0, epistemic_weight: float = 10.0):
166
+ """Super-resolve a batch.
167
+
168
+ Args:
169
+ lr: (B, 3, H, W) float tensor in [0, 1], RGB = (B04, B03, B02) after a 1-99 % stretch.
170
+ mc_samples: Monte-Carlo samples of the Bayesian heads (epistemic, cheap).
171
+ swag_samples: SWAG weight samples (epistemic, needs load_swag()).
172
+ epistemic_weight: scale of the epistemic variance in sigma_total (10 as in the paper code).
173
+
174
+ Returns:
175
+ dict with sr (B,3,4H,4W), sigma_aleatoric, and if requested sigma_epistemic and sigma_total.
176
+ """
177
+ was_training = self.training
178
+ self.eval()
179
+ mu, s = self(lr, sample_bayes=False)
180
+ out = {"sr": mu.clamp(0, 1), "sigma_aleatoric": torch.exp(0.5 * s)}
181
+
182
+ mus = []
183
+ if mc_samples > 0:
184
+ self.mu_head.train()
185
+ self.s_head.train()
186
+ mus += [self(lr, sample_bayes=True)[0] for _ in range(mc_samples)]
187
+ self.mu_head.eval()
188
+ self.s_head.eval()
189
+ if swag_samples > 0:
190
+ if self._swag is None:
191
+ raise RuntimeError("call load_swag() first")
192
+ backup = {k: v.clone() for k, v in self.state_dict().items()}
193
+ for _ in range(swag_samples):
194
+ self._set_swag_sample()
195
+ mus.append(self(lr, sample_bayes=False)[0])
196
+ self.load_state_dict(backup)
197
+ if mus:
198
+ var_epi = torch.stack(mus).var(0, unbiased=False)
199
+ out["sigma_epistemic"] = var_epi.sqrt()
200
+ out["sigma_total"] = torch.sqrt(out["sigma_aleatoric"] ** 2 + epistemic_weight * var_epi + 1e-8)
201
+
202
+ self.train(was_training)
203
+ return out
204
+
205
+
206
+ def stretch(rgb_reflectance, low: float = 1.0, high: float = 99.0):
207
+ """The 1-99 % percentile stretch used to build the training data.
208
+
209
+ rgb_reflectance: (..., 3, H, W) Sentinel-2 L2A reflectance (B04, B03, B02), any scale.
210
+ Percentiles are computed over all channels and pixels of each image, like the dataset.
211
+ """
212
+ x = torch.as_tensor(rgb_reflectance, dtype=torch.float32)
213
+ flat = x.flatten(-3)
214
+ p_low = torch.quantile(flat, low / 100, dim=-1, keepdim=True)[..., None, None]
215
+ p_high = torch.quantile(flat, high / 100, dim=-1, keepdim=True)[..., None, None]
216
+ return ((x - p_low) / (p_high - p_low + 1e-8)).clamp(0, 1)
psrgan_grid.jpg ADDED

Git LFS Details

  • SHA256: 3c68453a650ce763c9273800aa8ee183e2612eb48a1309cdc98fb83839399d1e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.08 MB
psrgan_x4_psnr.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3a68bf9b007ddb4c3c172e5cca60b26e3f6acd68931adb6d472822e2ad36535c
3
+ size 66892468
psrgan_x4_swag.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9d51a3cbc734f820875320f20e434abb26c2e15becaf6365fc945999463fa0b6
3
+ size 133792144