File size: 14,656 Bytes
e854df7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | """
Post-processing for SEN2SR: reconstruct the full image from inferred patches.
Overlap handling
----------------
Patches typically overlap (stride < patch_size). NaΓ―ve copying causes visible
seam artefacts at patch boundaries. We use **weighted averaging** instead:
each pixel in the output accumulates the weighted sum of all patches that cover
it, then divides by the total weight accumulated.
The default weight is a **2-D Hanning window** β values are 0 at the border
and 1 at the centre. This smoothly de-emphasises edges where SR quality is
lower (convolution networks have weaker receptive fields near borders).
For non-overlapping patches (stride == patch_size) the weight map degenerates
to all-ones, giving standard copy-paste.
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Tuple
import numpy as np
from s2sr_pipe.utils.logging_utils import get_logger
logger = get_logger("postprocessing")
NORM_SCALE: float = 10_000.0
TCI_GAIN: float = 1.25 # white point at 0.8 reflectance (1/0.8)
TCI_GAMMA: float = 1.25 # Mild gamma correction
def _ensure_crs(profile: dict) -> dict:
"""Log a warning if CRS is still None after safe_reader recovery."""
if not profile.get("crs"):
logger.warning(
"CRS is None in the output profile - the GeoTIFF will have no spatial "
"reference. Check that safe_reader correctly parsed the MGRS tile code."
)
return profile
# ββ Weight window βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _hanning_window(patch_size: int) -> np.ndarray:
"""
2-D Hanning window of shape (patch_size, patch_size), float32.
Constructed as the outer product of two 1-D Hanning windows.
Values range from ~0 at corners to 1.0 at the centre.
"""
w1d = np.hanning(patch_size).astype(np.float32)
w2d = np.outer(w1d, w1d)
# Guard against all-zero (degenerate case with patch_size=1)
if w2d.max() > 0:
w2d /= w2d.max()
return w2d
# ββ Reconstruction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def reconstruct_from_patches(
patches: List[np.ndarray],
coords: List[Tuple[int, int]],
image_shape: Tuple[int, int, int], # (C, H, W)
patch_size: int,
use_hanning: bool = True,
) -> np.ndarray:
"""
Reconstruct the full (C, H, W) float32 image from inferred patches.
Parameters
----------
patches : List of (C, patch_size, patch_size) float32 arrays
(model output, values already in [0, 1]).
coords : Matching list of (row, col) top-left positions.
image_shape : Target (C, H, W) shape.
patch_size : Spatial patch size (must be consistent with coords).
use_hanning : Use Hanning-weighted averaging for smooth seams.
Set False for benchmarking / unit tests.
Returns
-------
np.ndarray β (C, H, W) float32, values averaged over overlaps.
Raises
------
ValueError if len(patches) != len(coords).
"""
if len(patches) != len(coords):
raise ValueError(
f"patches ({len(patches)}) and coords ({len(coords)}) must have the same length."
)
C, H, W = image_shape
accumulator = np.zeros((C, H, W), dtype=np.float64)
weight_map = np.zeros((H, W), dtype=np.float64)
window = _hanning_window(patch_size) if use_hanning else np.ones(
(patch_size, patch_size), dtype=np.float32
)
logger.info("Reconstructing image from %d patches ...", len(patches))
for patch, (r, c) in zip(patches, coords):
# Clip patch to image bounds (edge patches may be snapped inward but
# should always be exactly patch_size β guard anyway)
ph = min(patch_size, H - r)
pw = min(patch_size, W - c)
accumulator[:, r : r + ph, c : c + pw] += patch[:, :ph, :pw] * window[:ph, :pw]
weight_map[r : r + ph, c : c + pw] += window[:ph, :pw]
# Avoid division by zero for any pixel not covered by any patch (including near-zero floats)
uncovered = int((weight_map < 1e-9).sum())
if uncovered > 0:
logger.warning(
"%d pixels ne sont couverts par aucun patch et seront mis a 0. "
"Verifier la configuration du patch grid.", uncovered
)
weight_map = np.where(weight_map < 1e-9, 1.0, weight_map)
# Broadcast weight across channels
reconstructed = (accumulator / weight_map[np.newaxis, :, :]).astype(np.float32)
logger.info(
"Reconstruction complete. Output range: [%.4f, %.4f]",
float(reconstructed.min()),
float(reconstructed.max()),
)
return reconstructed
# ββ Band name mapping βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Canonical SEN2SR band order (index β Sentinel-2 band name)
BAND_NAMES: List[str] = ["B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B11", "B12"]
# Indices of B04 (Red), B03 (Green), B02 (Blue) within BAND_NAMES for TCI
TCI_RGB_INDICES: Tuple[int, int, int] = (2, 1, 0) # R=B04, G=B03, B=B02
# ββ Low-level windowed writer βββββββββββββββββββββββββββββββββββββββββββββββββ
def _write_band_streamed(
data: np.ndarray, # (H, W) float32 in [0, 1], single band
profile: dict, # 1-band rasterio profile
output_path: Path,
tile_size: int,
desc: str,
) -> None:
"""Write a single band as float32 [0,1] directly β no denormalisation."""
import rasterio
from rasterio.windows import Window
from tqdm import tqdm
H, W = data.shape
col_starts = list(range(0, W, tile_size))
row_starts = list(range(0, H, tile_size))
n_windows = len(col_starts) * len(row_starts)
with rasterio.open(output_path, "w", **profile) as dst:
with tqdm(total=n_windows, desc=desc, unit="tile", leave=False) as pbar:
for row_off in row_starts:
row_end = min(row_off + tile_size, H)
for col_off in col_starts:
col_end = min(col_off + tile_size, W)
tile = data[row_off:row_end, col_off:col_end]
tile = np.where(np.isfinite(tile), tile, 0.0)
tile_u16 = np.clip(tile * NORM_SCALE, 0, 10_000).astype(np.uint16)
win = Window(col_off, row_off, col_end - col_off, row_end - row_off)
dst.write(tile_u16[np.newaxis, :, :], window=win)
pbar.update(1)
def _write_jpeg_preview(
data: np.ndarray,
output_path: Path,
max_size: int = 2048,
quality: int = 90,
gain: float = TCI_GAIN,
gamma: float = TCI_GAMMA,
) -> Path:
"""Write a JPEG preview of the TCI, resized so the longest side <= max_size."""
from PIL import Image
inv_gamma = 1.0 / gamma
rgb_f = np.clip(data * gain, 0.0, 1.0) ** inv_gamma # (3, H, W)
rgb_u8 = (rgb_f * 255.0).astype(np.uint8).transpose(1, 2, 0) # (H, W, 3)
img = Image.fromarray(rgb_u8)
H, W = rgb_u8.shape[:2]
if max(H, W) > max_size:
scale = max_size / max(H, W)
img = img.resize((int(W * scale), int(H * scale)), Image.LANCZOS)
img.save(output_path, format="JPEG", quality=quality)
return output_path
def _write_tci_streamed(
data: np.ndarray, # (3, H, W) float32 in [0,1]
profile: dict, # 3-band uint8 rasterio profile
output_path: Path,
tile_size: int,
gain: float = TCI_GAIN,
gamma: float = TCI_GAMMA,
) -> None:
"""Write TCI as uint8 using Sentinel Hub true color formula: gain + gamma correction."""
import rasterio
from rasterio.windows import Window
from tqdm import tqdm
_, H, W = data.shape
col_starts = list(range(0, W, tile_size))
row_starts = list(range(0, H, tile_size))
n_windows = len(col_starts) * len(row_starts)
inv_gamma = 1.0 / gamma
with rasterio.open(output_path, "w", **profile) as dst:
with tqdm(total=n_windows, desc="TCI", unit="tile", leave=False) as pbar:
for row_off in row_starts:
row_end = min(row_off + tile_size, H)
for col_off in col_starts:
col_end = min(col_off + tile_size, W)
tile = data[:, row_off:row_end, col_off:col_end]
tile = np.where(np.isfinite(tile), tile, 0.0)
# gain + gamma: matches Sentinel Hub EO Browser default True Color
tile_f = np.clip(tile * gain, 0.0, 1.0) ** inv_gamma
tile_u8 = (tile_f * 255.0).astype(np.uint8)
win = Window(col_off, row_off, col_end - col_off, row_end - row_off)
dst.write(tile_u8, window=win)
pbar.update(1)
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def export_result(
reconstructed_float: np.ndarray,
ref_profile: dict,
output_path: Path | str,
scale: int = 1,
compress: str = "deflate",
tile_size: int = 1024,
bands: List[str] | None = None,
preview: bool = False,
preview_size: int = 2048,
) -> List[Path]:
"""
Write one GeoTIFF per spectral band + one TCI (True Color Image).
Output files
------------
Given ``output_path = "results/sen2sr_2.5m.tif"``, the function creates:
results/sen2sr_2.5m_B02.tif β Blue (uint16, DN [0,10000])
results/sen2sr_2.5m_B03.tif β Green
...
results/sen2sr_2.5m_TCI.tif β RGB true color (uint8, stretched [0,255])
Parameters
----------
reconstructed_float : (C, H, W) float32 in [0, 1].
ref_profile : rasterio profile from the 10 m LR reference band.
output_path : Base path used to derive per-band filenames.
scale : SR upscale factor (4 = 10 m β 2.5 m).
compress : GeoTIFF compression codec.
tile_size : Spatial tile size for windowed writing.
bands : Liste des sorties Γ Γ©crire (ex: ["B02", "TCI"]).
None = toutes les bandes + TCI (comportement par dΓ©faut).
Valeurs valides : noms de bandes (B02..B12) + "TCI".
Returns
-------
List[Path] : Paths of all files written.
Raises
------
ValueError : si bands contient des noms inconnus.
"""
from s2sr_pipe.utils.geo_utils import build_output_profile
from s2sr_pipe.utils.logging_utils import get_logger
log = get_logger("postprocessing")
# ββ RΓ©soudre la sΓ©lection de bandes ββββββββββββββββββββββββββββββββββββββ
_valid = set(BAND_NAMES) | {"TCI"}
if bands is not None:
invalid = sorted(set(bands) - _valid)
if invalid:
raise ValueError(
f"Bandes inconnues : {invalid}. "
f"Valeurs valides : {sorted(_valid)}"
)
write_spectral = [b for b in BAND_NAMES if b in bands]
write_tci = "TCI" in bands
else:
write_spectral = list(BAND_NAMES)
write_tci = True
C, H, W = reconstructed_float.shape
output_dir = Path(output_path)
output_dir.mkdir(parents=True, exist_ok=True)
# Build base profile (1 band, uint16) β force CRS if absent
base_profile = _ensure_crs(build_output_profile(
ref_profile, n_bands=1, scale=scale, dtype="uint16", compress=compress
))
written: List[Path] = []
log.info(
"Exporting %d bande(s) spectrale(s)%s -> %s (tile_size=%d px)",
len(write_spectral),
" + TCI" if write_tci else "",
output_dir,
tile_size,
)
# ββ Per-band TIFs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
for band_name in write_spectral:
idx = BAND_NAMES.index(band_name)
out_file = output_dir / f"{band_name}.tif"
log.info(" Writing band %s -> %s", band_name, out_file.name)
_write_band_streamed(
data=reconstructed_float[idx], # (H, W) view β zero copy
profile=base_profile,
output_path=out_file,
tile_size=tile_size,
desc=band_name,
)
written.append(out_file)
# ββ TCI (RGB uint8) βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if write_tci:
r_idx, g_idx, b_idx = TCI_RGB_INDICES
rgb_data = reconstructed_float[[r_idx, g_idx, b_idx], :, :] # (3, H, W) view
tci_profile = build_output_profile(
ref_profile, n_bands=3, scale=scale, dtype="uint8", compress=compress
)
tci_file = output_dir / "TCI.tif"
log.info(" Writing TCI (R=B04 G=B03 B=B02, gain=%.1f gamma=%.1f) -> %s", TCI_GAIN, TCI_GAMMA, tci_file.name)
_write_tci_streamed(
data=rgb_data,
profile=tci_profile,
output_path=tci_file,
tile_size=tile_size,
)
written.append(tci_file)
if preview:
preview_file = output_dir / "preview.jpg"
log.info(" Writing preview -> %s (max_size=%d px)", preview_file.name, preview_size)
_write_jpeg_preview(rgb_data, preview_file, max_size=preview_size)
written.append(preview_file)
elif preview:
# --preview sans TCI dans --bands : gΓ©nΓ©rer le preview en mΓ©moire seulement
r_idx, g_idx, b_idx = TCI_RGB_INDICES
rgb_data = reconstructed_float[[r_idx, g_idx, b_idx], :, :]
preview_file = output_dir / "preview.jpg"
log.info(" Writing preview -> %s (max_size=%d px)", preview_file.name, preview_size)
_write_jpeg_preview(rgb_data, preview_file, max_size=preview_size)
written.append(preview_file)
log.info("Export complete - %d files written to %s", len(written), output_dir)
return written |