File size: 6,528 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 | """
Preprocessing for SEN2SR inference.
Steps
-----
1. Normalize : uint16 β float32, divide by 10 000, clip to [0, 1].
2. Patch grid : compute (row, col) top-left coordinates for a sliding window
with configurable size and stride.
3. Extract : cut patches from the full array; optionally use multiprocessing
for the extraction step (useful when patch count is large and
copy overhead dominates GPU transfer).
Design note
-----------
Patch extraction is kept **separate** from model inference so that large images
(> 10 000 Γ 10 000 px) never have to be fully loaded into GPU memory.
The caller controls batch assembly.
"""
from __future__ import annotations
import multiprocessing as mp
import warnings
from dataclasses import dataclass, field
from functools import partial
from typing import List, Tuple
import numpy as np
from s2sr_pipe.utils.logging_utils import get_logger
logger = get_logger("preprocessing")
# ββ Normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
NORM_SCALE: float = 10_000.0
def normalize(array: np.ndarray) -> np.ndarray:
"""
Normalize a uint16 Sentinel-2 array to float32 in [0, 1].
Parameters
----------
array : np.ndarray β shape (C, H, W), dtype uint16 or float32.
Returns
-------
np.ndarray β shape (C, H, W), dtype float32, values in [0, 1].
"""
if array.dtype == np.float32 and float(array.max()) < 2.0:
warnings.warn(
"normalize() reΓ§oit un tableau float32 avec des valeurs < 2.0 β "
"il est peut-Γͺtre dΓ©jΓ normalisΓ©. La division par 10000 va produire "
"des valeurs quasi nulles.",
UserWarning,
stacklevel=2,
)
out = array.astype(np.float32) / NORM_SCALE
np.clip(out, 0.0, 1.0, out=out)
return out
def denormalize(array: np.ndarray) -> np.ndarray:
"""
Reverse normalisation: float32 [0, 1] β uint16 [0, 10 000].
Parameters
----------
array : np.ndarray β shape (C, H, W), dtype float32.
Returns
-------
np.ndarray β shape (C, H, W), dtype uint16.
"""
out = array * NORM_SCALE
np.clip(out, 0, 10_000, out=out)
return out.astype(np.uint16)
# ββ Patch grid ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class PatchConfig:
"""Configuration for sliding-window patch extraction."""
patch_size: int = 128
stride: int = 64
def __post_init__(self) -> None:
if self.stride > self.patch_size:
raise ValueError(
f"stride ({self.stride}) must be β€ patch_size ({self.patch_size})"
)
if self.patch_size <= 0 or self.stride <= 0:
raise ValueError("patch_size and stride must be positive integers.")
def compute_patch_coords(
height: int,
width: int,
cfg: PatchConfig,
) -> List[Tuple[int, int]]:
"""
Compute (row, col) top-left coordinates for all patches.
The window always covers the full image: if the last patch would go
beyond the border it is snapped inward (no border reflection or padding).
Parameters
----------
height, width : Image spatial dimensions (after band alignment).
cfg : PatchConfig instance.
Returns
-------
List of (row, col) tuples.
"""
ps, st = cfg.patch_size, cfg.stride
if height < ps or width < ps:
raise ValueError(
f"L'image ({height}Γ{width} px) est plus petite que patch_size ({ps}). "
"RΓ©duire patch_size ou utiliser une image plus grande."
)
rows = list(range(0, height - ps, st)) + [height - ps]
cols = list(range(0, width - ps, st)) + [width - ps]
# Deduplicate while preserving order
rows = sorted(set(rows))
cols = sorted(set(cols))
coords = [(r, c) for r in rows for c in cols]
logger.debug(
"Patch grid: %d rows x %d cols = %d patches "
"(patch_size=%d, stride=%d, image=%dx%d)",
len(rows),
len(cols),
len(coords),
ps,
st,
width,
height,
)
return coords
# ββ Patch extraction ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _extract_one(
coord: Tuple[int, int],
array: np.ndarray,
patch_size: int,
) -> np.ndarray:
"""Extract a single (C, patch_size, patch_size) patch β pickleable worker."""
r, c = coord
return array[:, r : r + patch_size, c : c + patch_size].copy()
def extract_patches(
array: np.ndarray,
coords: List[Tuple[int, int]],
patch_size: int,
num_workers: int = 0,
) -> List[np.ndarray]:
"""
Extract all patches from *array* at positions given by *coords*.
Parameters
----------
array : (C, H, W) float32 normalised array.
coords : List of (row, col) top-left positions.
patch_size : Spatial size of each patch.
num_workers : Number of worker processes.
0 = single-threaded (safe for small images or debugging).
>0 = use multiprocessing.Pool (useful for very large images
with hundreds of thousands of patches).
Returns
-------
List[np.ndarray] β each element has shape (C, patch_size, patch_size).
Notes
-----
* Multiprocessing on large numpy arrays uses shared memory via fork (Linux).
On Windows/macOS, the array is serialised per chunk β use a smaller
num_workers or num_workers=0 if memory is limited on those platforms.
* For GPU-bound workloads the extraction is rarely the bottleneck;
num_workers=0 is the safe default.
"""
worker = partial(_extract_one, array=array, patch_size=patch_size)
if num_workers > 0:
logger.info("Extracting %d patches using %d workers ...", len(coords), num_workers)
with mp.Pool(num_workers) as pool:
patches = pool.map(worker, coords)
else:
logger.info("Extracting %d patches (single-threaded) ...", len(coords))
patches = [worker(c) for c in coords]
logger.info("Patch extraction complete.")
return patches
|