gdubrasquetd's picture
deploy: bundle s2sr_pipe, fix requirements
e854df7 verified
Raw
History Blame Contribute Delete
7.58 kB
"""
Rasterio / geospatial helpers used across the pipeline.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, Tuple
import numpy as np
import rasterio
from rasterio.crs import CRS
from rasterio.enums import Resampling
from rasterio.transform import Affine
from rasterio.warp import transform_bounds
from s2sr_pipe.utils.logging_utils import get_logger
logger = get_logger("geo_utils")
def assert_same_crs(profile_a: dict, profile_b: dict) -> None:
"""Raise if two rasterio profiles have different CRS."""
crs_a = profile_a.get("crs")
crs_b = profile_b.get("crs")
if crs_a != crs_b:
raise ValueError(f"CRS mismatch: {crs_a} vs {crs_b}")
def build_output_profile(
ref_profile: dict,
n_bands: int,
scale: int = 1,
dtype: str = "uint16",
compress: str = "deflate",
predictor: int = 2,
tiled: bool = True,
blockxsize: int = 512,
blockysize: int = 512,
) -> dict:
"""
Build a rasterio write profile suitable for large Cloud-Optimised GeoTIFF.
Parameters
----------
ref_profile : Profile from the 10 m reference band.
n_bands : Number of output bands.
scale : Super-resolution upscale factor (1 = no change, 4 = 10m→2.5m).
dtype : Output data type.
compress : Compression codec.
predictor : DEFLATE predictor (2 = horizontal differencing, good for int).
tiled : Write as tiled TIFF (required for COG / streaming reads).
blockxsize/blockysize : Internal tile size.
Returns
-------
dict : rasterio write profile.
"""
profile = ref_profile.copy()
# Scale the transform for super-resolved output (e.g. ×4: 10 m → 2.5 m)
if scale > 1:
original_transform = profile["transform"]
sx = original_transform.a / scale # pixel width (positive)
sy = original_transform.e / scale # pixel height (negative)
new_transform = Affine(
sx, 0.0, original_transform.c,
0.0, sy, original_transform.f,
)
profile["transform"] = new_transform
profile["width"] = profile["width"] * scale
profile["height"] = profile["height"] * scale
profile.update(
{
"driver": "GTiff",
"count": n_bands,
"dtype": dtype,
"compress": compress,
"predictor": predictor,
"tiled": tiled,
"blockxsize": blockxsize,
"blockysize": blockysize,
"interleave": "pixel",
"BIGTIFF": "YES", # mandatory for files > 4 GB (43920×43920×10 ≈ 18 GB)
}
)
return profile
def write_geotiff(
array: np.ndarray,
profile: dict,
output_path: Path | str,
) -> None:
"""
Write a (C, H, W) numpy array to a GeoTIFF.
The array is clipped to the dtype's valid range before writing.
Parameters
----------
array : (C, H, W) numpy array.
profile : rasterio write profile (must include crs, transform, dtype).
output_path : Destination path.
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
dtype = profile["dtype"]
if dtype == "uint16":
array = np.clip(array, 0, 10_000).astype(np.uint16)
elif dtype == "float32":
array = array.astype(np.float32)
logger.info(f"Writing GeoTIFF -> {output_path} shape={array.shape} dtype={dtype}")
with rasterio.open(output_path, "w", **profile) as dst:
dst.write(array)
logger.info("GeoTIFF written successfully.")
def get_pixel_size(transform: Affine) -> Tuple[float, float]:
"""Return (pixel_width, pixel_height) in CRS units from an Affine transform."""
return abs(transform.a), abs(transform.e)
def roi_wgs84_to_pixel_window(
roi: Tuple[float, float, float, float],
profile: dict,
buffer_px: int = 128,
) -> Dict:
"""
Convertit un ROI WGS84 en fenêtres pixel sur l'image LR.
Parameters
----------
roi : (min_lon, min_lat, max_lon, max_lat) en WGS84 (EPSG:4326).
profile : Profil rasterio de l'image LR (doit avoir crs, transform, width, height).
buffer_px : Buffer en pixels LR ajouté de chaque côté pour le contexte du modèle.
Returns
-------
dict :
"read" — (rr_min, rr_max, rc_min, rc_max) : fenêtre à lire (avec buffer)
"inner" — (ir_start, ir_end, ic_start, ic_end) : position du ROI dans le crop
"roi_transform" — Affine : transform du coin sup-gauche du ROI exact
"roi_width" — int
"roi_height" — int
Raises
------
ValueError si le ROI ne chevauche pas la dalle.
"""
min_lon, min_lat, max_lon, max_lat = roi
T = profile["transform"]
H, W = profile["height"], profile["width"]
# WGS84 → CRS image
xmin, ymin, xmax, ymax = transform_bounds(
CRS.from_epsg(4326), profile["crs"],
min_lon, min_lat, max_lon, max_lat,
)
# World coords → pixel coords (inverse affine)
col_min_f, row_min_f = ~T * (xmin, ymax) # coin haut-gauche
col_max_f, row_max_f = ~T * (xmax, ymin) # coin bas-droit
col_min = int(np.floor(col_min_f))
row_min = int(np.floor(row_min_f))
col_max = int(np.ceil(col_max_f))
row_max = int(np.ceil(row_max_f))
# Vérifier que le ROI chevauche la dalle avant clamp
if col_max <= 0 or col_min >= W or row_max <= 0 or row_min >= H:
raise ValueError(
f"Le ROI ({min_lon},{min_lat},{max_lon},{max_lat}) ne chevauche pas "
f"la dalle (CRS: {profile.get('crs')}, taille: {W}x{H} px). "
f"Pixel bounds calculés : cols {col_min}..{col_max}, rows {row_min}..{row_max}."
)
# Clamp ROI au bord de l'image
col_min_orig, row_min_orig = col_min, row_min
col_max_orig, row_max_orig = col_max, row_max
col_min = max(0, col_min)
row_min = max(0, row_min)
col_max = min(W, col_max)
row_max = min(H, row_max)
clipped = []
if col_min_orig < 0:
clipped.append(f"gauche ({col_min_orig}px -> 0)")
if row_min_orig < 0:
clipped.append(f"haut ({row_min_orig}px -> 0)")
if col_max_orig > W:
clipped.append(f"droite ({col_max_orig}px -> {W})")
if row_max_orig > H:
clipped.append(f"bas ({row_max_orig}px -> {H})")
if clipped:
logger.warning(
"Le ROI depasse la dalle et a ete clippe : %s. "
"Seule la partie disponible sera traitee.",
", ".join(clipped),
)
# Fenêtre bufférisée (clampée aux bords)
rc_min = max(0, col_min - buffer_px)
rc_max = min(W, col_max + buffer_px)
rr_min = max(0, row_min - buffer_px)
rr_max = min(H, row_max + buffer_px)
logger.debug(
"ROI pixel bounds: cols %d..%d rows %d..%d | "
"buffered read: cols %d..%d rows %d..%d",
col_min, col_max, row_min, row_max,
rc_min, rc_max, rr_min, rr_max,
)
# Position du ROI dans le crop bufférisé
ic_start = col_min - rc_min
ic_end = col_max - rc_min
ir_start = row_min - rr_min
ir_end = row_max - rr_min
# Transform du coin sup-gauche du ROI exact (pour le profil de sortie)
roi_transform = T * Affine.translation(col_min, row_min)
return {
"read": (rr_min, rr_max, rc_min, rc_max),
"inner": (ir_start, ir_end, ic_start, ic_end),
"roi_transform": roi_transform,
"roi_width": col_max - col_min,
"roi_height": row_max - row_min,
}