"""ENVI raster parsing and GeoTIFF writing.""" from __future__ import annotations import math import re from dataclasses import dataclass from pathlib import Path import numpy as np try: import rasterio from rasterio.crs import CRS from rasterio.transform import Affine except Exception: # pragma: no cover - optional at runtime rasterio = None CRS = None Affine = None ENVI_DTYPES = { 1: np.uint8, 2: np.int16, 3: np.int32, 4: np.float32, 5: np.float64, 12: np.uint16, 13: np.uint32, 14: np.int64, 15: np.uint64, } @dataclass(frozen=True) class EnviInfo: path: Path hdr_path: Path samples: int lines: int bands: int header_offset: int dtype: np.dtype byte_order: int interleave: str x0: float y0: float dx: float dy: float crs_wkt: str | None @property def bounds(self) -> tuple[float, float, float, float]: left = self.x0 top = self.y0 right = left + self.samples * self.dx bottom = top - self.lines * self.dy return left, bottom, right, top @property def transform(self): if Affine is None: return None return Affine(self.dx, 0.0, self.x0, 0.0, -self.dy, self.y0) def read_hdr_text(data_path: Path) -> tuple[Path, str]: hdr_path = data_path.with_suffix(data_path.suffix + ".hdr") if data_path.suffix else Path(str(data_path) + ".hdr") if not hdr_path.exists(): alt = data_path.with_suffix(".hdr") if alt.exists(): hdr_path = alt if not hdr_path.exists(): raise FileNotFoundError(f"ENVI header not found for {data_path}") return hdr_path, hdr_path.read_text(encoding="utf-8", errors="ignore") def hdr_value(text: str, key: str, default: str | None = None) -> str: match = re.search(rf"(?im)^\s*{re.escape(key)}\s*=\s*(.+?)\s*$", text) if match: return match.group(1).strip() if default is not None: return default raise ValueError(f"Missing ENVI header key: {key}") def parse_map_info(text: str) -> tuple[float, float, float, float]: match = re.search(r"(?is)map info\s*=\s*\{(.+?)\}", text) if not match: raise ValueError("Missing map info in ENVI header") parts = [p.strip() for p in match.group(1).replace("\n", " ").split(",")] if len(parts) < 7: raise ValueError(f"Unexpected map info: {match.group(0)}") return float(parts[3]), float(parts[4]), abs(float(parts[5])), abs(float(parts[6])) def parse_crs_wkt(text: str) -> str | None: match = re.search(r"(?is)coordinate system string\s*=\s*\{(.+?)\}", text) return match.group(1).strip() if match else None def parse_envi(path: Path) -> EnviInfo: hdr_path, text = read_hdr_text(path) dtype_code = int(hdr_value(text, "data type")) if dtype_code not in ENVI_DTYPES: raise ValueError(f"Unsupported ENVI data type: {dtype_code}") x0, y0, dx, dy = parse_map_info(text) dtype = np.dtype(ENVI_DTYPES[dtype_code]) byte_order = int(hdr_value(text, "byte order", "0")) if byte_order == 1: dtype = dtype.newbyteorder(">") else: dtype = dtype.newbyteorder("<") return EnviInfo( path=path, hdr_path=hdr_path, samples=int(hdr_value(text, "samples")), lines=int(hdr_value(text, "lines")), bands=int(hdr_value(text, "bands", "1")), header_offset=int(hdr_value(text, "header offset", "0")), dtype=dtype, byte_order=byte_order, interleave=hdr_value(text, "interleave", "bsq").lower(), x0=x0, y0=y0, dx=dx, dy=dy, crs_wkt=parse_crs_wkt(text), ) def read_envi_band(info: EnviInfo) -> np.ndarray: if info.bands != 1 or info.interleave != "bsq": raise ValueError("This baseline expects one-band BSQ ENVI inputs") count = info.lines * info.samples data = np.memmap(info.path, dtype=info.dtype, mode="r", offset=info.header_offset, shape=(count,)) arr = np.asarray(data.reshape(info.lines, info.samples), dtype=np.float32) arr = arr.copy() arr[~np.isfinite(arr)] = np.nan return arr def read_dem_for_sar(dem_info: EnviInfo, sar_info: EnviInfo) -> np.ndarray: left, bottom, right, top = sar_info.bounds pad = 2 col0 = max(0, int(math.floor((left - dem_info.x0) / dem_info.dx)) - pad) col1 = min(dem_info.samples, int(math.ceil((right - dem_info.x0) / dem_info.dx)) + pad) row0 = max(0, int(math.floor((dem_info.y0 - top) / dem_info.dy)) - pad) row1 = min(dem_info.lines, int(math.ceil((dem_info.y0 - bottom) / dem_info.dy)) + pad) if col1 <= col0 or row1 <= row0: raise ValueError("SAR image does not overlap DEM") mm = np.memmap(dem_info.path, dtype=dem_info.dtype, mode="r", offset=dem_info.header_offset, shape=(dem_info.lines, dem_info.samples)) dem_window = np.asarray(mm[row0:row1, col0:col1], dtype=np.float32).copy() dem_window[~np.isfinite(dem_window)] = np.nan x = sar_info.x0 + (np.arange(sar_info.samples) + 0.5) * sar_info.dx y = sar_info.y0 - (np.arange(sar_info.lines) + 0.5) * sar_info.dy dem_cols = np.clip(np.rint((x - dem_info.x0) / dem_info.dx - 0.5).astype(np.int64) - col0, 0, dem_window.shape[1] - 1) dem_rows = np.clip(np.rint((dem_info.y0 - y) / dem_info.dy - 0.5).astype(np.int64) - row0, 0, dem_window.shape[0] - 1) return dem_window[dem_rows[:, None], dem_cols[None, :]] def write_tif(path: Path, arr: np.ndarray, info: EnviInfo, dtype: str, nodata=None) -> None: if rasterio is None: return crs = None if CRS is not None: try: crs = CRS.from_epsg(4326) except Exception: crs = None profile = { "driver": "GTiff", "height": arr.shape[0], "width": arr.shape[1], "count": 1, "dtype": dtype, "compress": "deflate", "predictor": 2 if dtype.startswith("float") else 1, "transform": info.transform, "nodata": nodata, } if crs is not None: profile["crs"] = crs with rasterio.open(path, "w", **profile) as dst: dst.write(arr.astype(dtype), 1)