feat: embed gf3 water extraction workflow

This commit is contained in:
2026-06-15 11:45:11 +08:00
parent a0388de9d4
commit f726367cbb
23 changed files with 2037 additions and 14 deletions
@@ -0,0 +1,8 @@
"""GF-3 HH/HV water extraction package."""
from .api import run_water_extraction
from .cli import main
from .config import WaterExtractionConfig
from .pipeline import run_from_args
__all__ = ["WaterExtractionConfig", "main", "run_from_args", "run_water_extraction"]
+25
View File
@@ -0,0 +1,25 @@
"""Programmatic API for GF-3 water extraction."""
from __future__ import annotations
from argparse import Namespace
from dataclasses import asdict
from pathlib import Path
from .config import WaterExtractionConfig
from .pipeline import run_from_args
def run_water_extraction(config: WaterExtractionConfig) -> int:
"""Run the processor from a typed config object.
The return value matches the CLI process exit code. Outputs are written under
``config.out_dir`` and optional vector output paths.
"""
data = asdict(config)
for key, value in list(data.items()):
if isinstance(value, Path):
data[key] = value
elif isinstance(value, list):
data[key] = [Path(item) for item in value]
return run_from_args(Namespace(**data))
+64
View File
@@ -0,0 +1,64 @@
"""Command-line interface for GF-3 water extraction."""
from __future__ import annotations
import argparse
from pathlib import Path
from .pipeline import run_from_args
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Extract water from GF-3 HH/HV ENVI images with a non-DL baseline.")
parser.add_argument("--hh", required=True, type=Path)
parser.add_argument("--hv", required=True, type=Path)
parser.add_argument("--out-dir", required=True, type=Path)
parser.add_argument("--dem", type=Path, default=None)
parser.add_argument("--dltb-gdb", type=Path, default=None, help="DLTB FileGDB path used as soft land-use prior.")
parser.add_argument("--dltb-cache-dir", type=Path, default=None, help="Directory containing water_prior.shp, paddy.shp, strict_review.shp DLTB cache layers.")
parser.add_argument("--dltb-layer", default="DLTB")
parser.add_argument("--dltb-field", default="DLMC")
parser.add_argument("--dltb-mode", choices=["soft", "strict", "off"], default="soft")
parser.add_argument("--dltb-max-features", type=int, default=None, help="Safety limit for DLTB features read from the source.")
parser.add_argument("--water-vector", type=Path, action="append", default=[], help="Known river/lake shapefile. Can be passed multiple times.")
parser.add_argument("--paddy-vector", type=Path, action="append", default=[], help="Paddy field/farmland water-sensitive vector. Can be passed multiple times.")
parser.add_argument("--river-buffer-meters", type=float, default=120.0, help="Buffer width for line river vectors.")
parser.add_argument("--paddy-buffer-meters", type=float, default=0.0, help="Buffer width for line paddy vectors, if any.")
parser.add_argument("--threshold-method", choices=["otsu", "percentile"], default="otsu")
parser.add_argument("--score-percentile", type=float, default=92.0)
parser.add_argument("--hv-percentile", type=float, default=35.0)
parser.add_argument("--prior-score-percentile", type=float, default=85.0)
parser.add_argument("--prior-hv-percentile", type=float, default=45.0)
parser.add_argument("--paddy-score-percentile", type=float, default=88.0)
parser.add_argument("--paddy-hv-percentile", type=float, default=45.0)
parser.add_argument("--candidate-score-percentile", type=float, default=90.0)
parser.add_argument("--candidate-hv-percentile", type=float, default=50.0)
parser.add_argument("--slope-max", type=float, default=8.0)
parser.add_argument("--close-pixels", type=int, default=0, help="Binary closing radius for final water mask before vectorization.")
parser.add_argument("--open-pixels", type=int, default=0, help="Binary opening radius for final water mask after hole filling.")
parser.add_argument("--paddy-close-pixels", type=int, default=0, help="Binary closing radius for paddy water-like mask.")
parser.add_argument("--paddy-open-pixels", type=int, default=0, help="Binary opening radius for paddy water-like mask.")
parser.add_argument("--candidate-close-pixels", type=int, default=0, help="Binary closing radius for low-confidence candidate mask.")
parser.add_argument("--candidate-open-pixels", type=int, default=0, help="Binary opening radius for low-confidence candidate mask.")
parser.add_argument("--cartographic-water", action="store_true", help="Export a map-production layer that merges high-confidence and review candidate water.")
parser.add_argument("--cartographic-include-paddy", action="store_true", help="Include paddy water-like pixels in cartographic water.")
parser.add_argument("--cartographic-close-pixels", type=int, default=3)
parser.add_argument("--cartographic-open-pixels", type=int, default=0)
parser.add_argument("--cartographic-fill-hole-pixels", type=int, default=4096)
parser.add_argument("--cartographic-min-component-pixels", type=int, default=512)
parser.add_argument("--min-component-pixels", type=int, default=128)
parser.add_argument("--fill-hole-pixels", type=int, default=512)
parser.add_argument("--no-morphology", action="store_true")
parser.add_argument("--out-vector-gpkg", type=Path, default=None, help="Write cartographic vector products to this GeoPackage.")
parser.add_argument("--out-vector-shp-dir", type=Path, default=None, help="Write one ESRI Shapefile per cartographic vector layer.")
parser.add_argument("--min-polygon-area-m2", type=float, default=1000.0)
parser.add_argument("--simplify-meters", type=float, default=0.0)
parser.add_argument("--smooth-meters", type=float, default=0.0, help="Vector boundary smoothing distance using buffer(+d)/buffer(-d).")
parser.add_argument("--min-hole-area-m2", type=float, default=0.0, help="Remove polygon interior holes smaller than this area.")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
return run_from_args(parser.parse_args(argv))
@@ -0,0 +1,55 @@
"""Configuration objects for GF-3 water extraction."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class WaterExtractionConfig:
hh: Path
hv: Path
out_dir: Path
dem: Path | None = None
dltb_gdb: Path | None = None
dltb_cache_dir: Path | None = None
dltb_layer: str = "DLTB"
dltb_field: str = "DLMC"
dltb_mode: str = "soft"
dltb_max_features: int | None = None
water_vector: list[Path] = field(default_factory=list)
paddy_vector: list[Path] = field(default_factory=list)
river_buffer_meters: float = 120.0
paddy_buffer_meters: float = 0.0
threshold_method: str = "otsu"
score_percentile: float = 92.0
hv_percentile: float = 35.0
prior_score_percentile: float = 85.0
prior_hv_percentile: float = 45.0
paddy_score_percentile: float = 88.0
paddy_hv_percentile: float = 45.0
candidate_score_percentile: float = 90.0
candidate_hv_percentile: float = 50.0
slope_max: float = 8.0
close_pixels: int = 0
open_pixels: int = 0
paddy_close_pixels: int = 0
paddy_open_pixels: int = 0
candidate_close_pixels: int = 0
candidate_open_pixels: int = 0
cartographic_water: bool = False
cartographic_include_paddy: bool = False
cartographic_close_pixels: int = 3
cartographic_open_pixels: int = 0
cartographic_fill_hole_pixels: int = 4096
cartographic_min_component_pixels: int = 512
min_component_pixels: int = 128
fill_hole_pixels: int = 512
no_morphology: bool = False
out_vector_gpkg: Path | None = None
out_vector_shp_dir: Path | None = None
min_polygon_area_m2: float = 1000.0
simplify_meters: float = 0.0
smooth_meters: float = 0.0
min_hole_area_m2: float = 0.0
@@ -0,0 +1,20 @@
"""Class ids and product labels used by GF-3 water extraction."""
CLASS_NON_WATER = 0
CLASS_HIGH_CONFIDENCE_WATER = 1
CLASS_KNOWN_WATER = 2
CLASS_PADDY_WATER_LIKE = 3
CLASS_LOW_CONFIDENCE_WATER = 4
CLASS_CARTOGRAPHIC_WATER = 5
CLASS_INVALID = 255
CLASS_NAMES = {
CLASS_NON_WATER: "non_water",
CLASS_HIGH_CONFIDENCE_WATER: "high_confidence_water",
CLASS_KNOWN_WATER: "known_river_lake_water",
CLASS_PADDY_WATER_LIKE: "paddy_water_like",
CLASS_LOW_CONFIDENCE_WATER: "low_confidence_water",
CLASS_CARTOGRAPHIC_WATER: "cartographic_water",
CLASS_INVALID: "invalid",
}
+146
View File
@@ -0,0 +1,146 @@
"""DLTB land-use prior definitions.
DLTB should be treated as a soft background layer for flood-period mapping:
it can change interpretation and confidence, but should not hard-exclude strong
water evidence outside normal water classes.
"""
from __future__ import annotations
from dataclasses import dataclass, field as dc_field
from pathlib import Path
import numpy as np
from shapely.geometry import box, shape
from shapely.ops import transform as shapely_transform
try:
import fiona
except Exception: # pragma: no cover - optional at runtime
fiona = None
try:
from pyproj import Transformer
except Exception: # pragma: no cover - optional at runtime
Transformer = None
@dataclass(frozen=True)
class DltbConfig:
gdb: Path
layer: str = "DLTB"
field: str = "DLMC"
mode: str = "soft"
max_features: int | None = None
water_names: tuple[str, ...] = dc_field(default_factory=lambda: ("河流水面", "湖泊水面", "水库水面", "坑塘水面", "沟渠"))
paddy_names: tuple[str, ...] = dc_field(default_factory=lambda: ("水田",))
strict_names: tuple[str, ...] = dc_field(default_factory=lambda: ("城镇村道路用地", "公路用地", "农村道路", "设施农用地"))
@dataclass
class DltbSceneZones:
water_geoms: list
paddy_geoms: list
strict_geoms: list
normal_geoms: list
feature_count: int
class_counts: dict[str, int]
dlmc_values: list[str]
crs: str | None
def classify_dlmc(name: str, config: DltbConfig) -> str:
"""Classify one DLMC value into a soft policy zone."""
value = (name or "").strip()
if value in config.water_names:
return "water_prior"
if value in config.paddy_names:
return "paddy"
if value in config.strict_names:
return "strict_review"
return "normal"
def _transform_bounds(bounds: tuple[float, float, float, float], src_crs: str, dst_crs) -> tuple[float, float, float, float]:
if Transformer is None:
raise RuntimeError("pyproj is required to use DLTB priors with CRS transformation")
transformer = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
left, bottom, right, top = bounds
xs = [left, left, right, right]
ys = [bottom, top, bottom, top]
tx, ty = transformer.transform(xs, ys)
return min(tx), min(ty), max(tx), max(ty)
def _transform_geom(geom, src_crs, dst_crs: str):
if Transformer is None:
raise RuntimeError("pyproj is required to transform DLTB geometries")
transformer = Transformer.from_crs(src_crs, dst_crs, always_xy=True)
return shapely_transform(lambda x, y, z=None: transformer.transform(np.asarray(x), np.asarray(y)), geom)
def load_dltb_scene_zones(config: DltbConfig, sar_bounds_wgs84: tuple[float, float, float, float]) -> DltbSceneZones:
"""Load only DLTB features intersecting one SAR scene.
The input bounds are WGS84 lon/lat. Returned geometries are transformed to
WGS84 and clipped to the SAR bounds.
"""
if fiona is None:
raise RuntimeError("fiona is required for DLTB geodatabase access")
if not config.gdb.exists():
raise FileNotFoundError(f"DLTB geodatabase not found: {config.gdb}")
roi_wgs84 = box(*sar_bounds_wgs84)
water_geoms = []
paddy_geoms = []
strict_geoms = []
normal_geoms = []
class_counts = {"water_prior": 0, "paddy": 0, "strict_review": 0, "normal": 0}
dlmc_values = set()
feature_count = 0
with fiona.open(config.gdb, layer=config.layer) as src:
src_crs = src.crs_wkt or src.crs
bbox = sar_bounds_wgs84
if src_crs:
bbox = _transform_bounds(sar_bounds_wgs84, "EPSG:4326", src_crs)
for feat in src.filter(bbox=bbox):
if config.max_features is not None and feature_count >= config.max_features:
break
geom_data = feat.get("geometry")
if not geom_data:
continue
geom = shape(geom_data)
if geom.is_empty:
continue
if src_crs:
geom = _transform_geom(geom, src_crs, "EPSG:4326")
geom = geom.intersection(roi_wgs84)
if geom.is_empty:
continue
dlmc = str(feat.get("properties", {}).get(config.field, "") or "").strip()
zone = classify_dlmc(dlmc, config)
dlmc_values.add(dlmc)
class_counts[zone] += 1
feature_count += 1
if zone == "water_prior":
water_geoms.append(geom)
elif zone == "paddy":
paddy_geoms.append(geom)
elif zone == "strict_review":
strict_geoms.append(geom)
else:
normal_geoms.append(geom)
return DltbSceneZones(
water_geoms=water_geoms,
paddy_geoms=paddy_geoms,
strict_geoms=strict_geoms,
normal_geoms=normal_geoms,
feature_count=feature_count,
class_counts=class_counts,
dlmc_values=sorted(v for v in dlmc_values if v),
crs=str(src_crs) if "src_crs" in locals() else None,
)
+188
View File
@@ -0,0 +1,188 @@
"""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)
+25
View File
@@ -0,0 +1,25 @@
"""Small geospatial math helpers for lon/lat products."""
from __future__ import annotations
import math
def meters_to_degrees(meters: float, lat: float) -> tuple[float, float]:
deg_lat = meters / 111_320.0
deg_lon = meters / max(111_320.0 * math.cos(math.radians(lat)), 1.0)
return deg_lon, deg_lat
def pixel_area_m2(info) -> float:
center_lat = info.y0 - info.lines * info.dy * 0.5
meters_per_deg_lat = 111_320.0
meters_per_deg_lon = 111_320.0 * math.cos(math.radians(center_lat))
return abs(info.dx * meters_per_deg_lon * info.dy * meters_per_deg_lat)
def polygon_area_m2(geom, center_lat: float) -> float:
meters_per_deg_lat = 111_320.0
meters_per_deg_lon = 111_320.0 * math.cos(math.radians(center_lat))
return float(abs(geom.area) * meters_per_deg_lon * meters_per_deg_lat)
@@ -0,0 +1,404 @@
"""High-level GF-3 HH/HV water extraction pipeline."""
from __future__ import annotations
import json
from argparse import Namespace
import numpy as np
from .constants import (
CLASS_HIGH_CONFIDENCE_WATER,
CLASS_INVALID,
CLASS_KNOWN_WATER,
CLASS_LOW_CONFIDENCE_WATER,
CLASS_NAMES,
CLASS_NON_WATER,
CLASS_PADDY_WATER_LIKE,
)
from .dltb import DltbConfig, load_dltb_scene_zones
from .envi import parse_envi, read_dem_for_sar, read_envi_band, write_tif
from .previews import save_class_preview, save_dltb_zone_preview, save_gray_png, save_mask_png, save_preview
from .raster_ops import close_mask, fill_small_holes, open_mask, otsu_threshold, remove_small_components, robust_normalize, slope_degrees, to_db
from .vector_io import rasterize_geometries, rasterize_vector_mask, rasterize_water_prior
from .vector_products import write_classified_vectors
def _load_dltb_masks(args: Namespace, info) -> tuple[dict | None, np.ndarray, np.ndarray, np.ndarray]:
water_mask = np.zeros((info.lines, info.samples), dtype=bool)
paddy_mask = np.zeros((info.lines, info.samples), dtype=bool)
strict_mask = np.zeros((info.lines, info.samples), dtype=bool)
if args.dltb_cache_dir is not None and args.dltb_mode != "off":
water_cache = args.dltb_cache_dir / "water_prior.shp"
paddy_cache = args.dltb_cache_dir / "paddy.shp"
strict_cache = args.dltb_cache_dir / "strict_review.shp"
if water_cache.exists():
water_mask = rasterize_vector_mask([water_cache], info, 0.0, "--dltb-cache-dir water_prior")
if paddy_cache.exists():
paddy_mask = rasterize_vector_mask([paddy_cache], info, 0.0, "--dltb-cache-dir paddy")
if strict_cache.exists():
strict_mask = rasterize_vector_mask([strict_cache], info, 0.0, "--dltb-cache-dir strict_review")
stats = {
"cache_dir": str(args.dltb_cache_dir),
"mode": args.dltb_mode,
"source": "cache",
"water_prior_path": str(water_cache) if water_cache.exists() else None,
"paddy_path": str(paddy_cache) if paddy_cache.exists() else None,
"strict_review_path": str(strict_cache) if strict_cache.exists() else None,
}
return stats, water_mask, paddy_mask, strict_mask
if args.dltb_gdb is not None and args.dltb_mode != "off":
zones = load_dltb_scene_zones(
DltbConfig(gdb=args.dltb_gdb, layer=args.dltb_layer, field=args.dltb_field, mode=args.dltb_mode, max_features=args.dltb_max_features),
info.bounds,
)
water_mask = rasterize_geometries(zones.water_geoms, info)
paddy_mask = rasterize_geometries(zones.paddy_geoms, info)
strict_mask = rasterize_geometries(zones.strict_geoms, info)
stats = {
"gdb": str(args.dltb_gdb),
"source": "gdb",
"layer": args.dltb_layer,
"field": args.dltb_field,
"mode": args.dltb_mode,
"source_crs": zones.crs,
"features_in_scene": zones.feature_count,
"class_counts": zones.class_counts,
"dlmc_values_in_scene": zones.dlmc_values,
}
return stats, water_mask, paddy_mask, strict_mask
return None, water_mask, paddy_mask, strict_mask
def _ensure_matching_grids(hh_info, hv_info) -> None:
hh_grid = (hh_info.samples, hh_info.lines, hh_info.x0, hh_info.y0, hh_info.dx, hh_info.dy)
hv_grid = (hv_info.samples, hv_info.lines, hv_info.x0, hv_info.y0, hv_info.dx, hv_info.dy)
if hh_grid != hv_grid:
raise ValueError("HH and HV grids do not match")
def _classify_products(mask: np.ndarray, low_confidence_candidate: np.ndarray, paddy_candidate: np.ndarray, prior_mask: np.ndarray, valid: np.ndarray) -> np.ndarray:
high_confidence_mask = mask & ~paddy_candidate
known_water_mask = mask & prior_mask
classified = np.full(mask.shape, CLASS_INVALID, dtype=np.uint8)
classified[valid] = CLASS_NON_WATER
classified[low_confidence_candidate & valid & ~mask & ~paddy_candidate] = CLASS_LOW_CONFIDENCE_WATER
classified[high_confidence_mask & valid] = CLASS_HIGH_CONFIDENCE_WATER
classified[paddy_candidate & valid] = CLASS_PADDY_WATER_LIKE
classified[known_water_mask & valid] = CLASS_KNOWN_WATER
return classified
def _write_rasters(
args: Namespace,
info,
probability: np.ndarray,
valid: np.ndarray,
mask: np.ndarray,
raw_mask: np.ndarray,
classified: np.ndarray,
cartographic_water: np.ndarray | None,
prior_mask: np.ndarray,
paddy_mask: np.ndarray,
paddy_candidate: np.ndarray,
dltb_enabled: bool,
dltb_water_mask: np.ndarray,
dltb_paddy_mask: np.ndarray,
dltb_strict_mask: np.ndarray,
) -> None:
mask_u8 = np.where(valid, mask.astype(np.uint8), 255).astype(np.uint8)
raw_mask_u8 = np.where(valid, raw_mask.astype(np.uint8), 255).astype(np.uint8)
write_tif(args.out_dir / "water_score.tif", np.where(np.isfinite(probability), probability, -9999.0), info, "float32", nodata=-9999.0)
write_tif(args.out_dir / "water_mask.tif", mask_u8, info, "uint8", nodata=255)
write_tif(args.out_dir / "water_mask_raw.tif", raw_mask_u8, info, "uint8", nodata=255)
write_tif(args.out_dir / "classified_water.tif", classified, info, "uint8", nodata=CLASS_INVALID)
if cartographic_water is not None:
write_tif(args.out_dir / "cartographic_water.tif", np.where(valid, cartographic_water.astype(np.uint8), 255).astype(np.uint8), info, "uint8", nodata=255)
if args.water_vector:
write_tif(args.out_dir / "known_water_prior.tif", prior_mask.astype(np.uint8), info, "uint8", nodata=0)
if args.paddy_vector or dltb_enabled:
write_tif(args.out_dir / "paddy_prior.tif", paddy_mask.astype(np.uint8), info, "uint8", nodata=0)
write_tif(args.out_dir / "paddy_water_like.tif", np.where(valid, paddy_candidate.astype(np.uint8), 255).astype(np.uint8), info, "uint8", nodata=255)
if dltb_enabled:
write_tif(args.out_dir / "dltb_water_prior.tif", dltb_water_mask.astype(np.uint8), info, "uint8", nodata=0)
write_tif(args.out_dir / "dltb_paddy_prior.tif", dltb_paddy_mask.astype(np.uint8), info, "uint8", nodata=0)
write_tif(args.out_dir / "dltb_strict_zone.tif", dltb_strict_mask.astype(np.uint8), info, "uint8", nodata=0)
def _write_previews(
args: Namespace,
probability: np.ndarray,
valid: np.ndarray,
mask: np.ndarray,
raw_mask: np.ndarray,
hh_norm: np.ndarray,
hv_norm: np.ndarray,
classified: np.ndarray,
prior_mask: np.ndarray,
paddy_candidate: np.ndarray,
dltb_enabled: bool,
dltb_water_mask: np.ndarray,
dltb_paddy_mask: np.ndarray,
dltb_strict_mask: np.ndarray,
cartographic_water: np.ndarray | None,
) -> None:
save_gray_png(args.out_dir / "water_score.png", probability, valid)
save_mask_png(args.out_dir / "water_mask.png", mask)
save_mask_png(args.out_dir / "water_mask_raw.png", raw_mask)
if args.water_vector:
save_mask_png(args.out_dir / "known_water_prior.png", prior_mask)
if args.paddy_vector or dltb_enabled:
save_mask_png(args.out_dir / "paddy_water_like.png", paddy_candidate)
if dltb_enabled:
save_dltb_zone_preview(args.out_dir / "dltb_zone_preview.png", dltb_water_mask, dltb_paddy_mask, dltb_strict_mask)
if cartographic_water is not None:
save_mask_png(args.out_dir / "cartographic_water.png", cartographic_water)
save_preview(args.out_dir / "preview_overlay.png", hh_norm, hv_norm, mask, valid)
save_class_preview(args.out_dir / "classified_preview.png", classified, hh_norm, hv_norm, valid)
def run_from_args(args: Namespace) -> int:
args.out_dir.mkdir(parents=True, exist_ok=True)
hh_info = parse_envi(args.hh)
hv_info = parse_envi(args.hv)
_ensure_matching_grids(hh_info, hv_info)
dltb_stats, dltb_water_mask, dltb_paddy_mask, dltb_strict_mask = _load_dltb_masks(args, hh_info)
dltb_enabled = dltb_stats is not None
hh = read_envi_band(hh_info)
hv = read_envi_band(hv_info)
valid = np.isfinite(hh) & np.isfinite(hv) & (hh > 0) & (hv > 0)
hh_db = to_db(hh)
hv_db = to_db(hv)
valid &= np.isfinite(hh_db) & np.isfinite(hv_db)
hh_norm, hh_lo, hh_hi = robust_normalize(hh_db, valid)
hv_norm, hv_lo, hv_hi = robust_normalize(hv_db, valid)
low_backscatter = 1.0 - 0.5 * (hh_norm + hv_norm)
low_backscatter[~valid] = np.nan
values = low_backscatter[valid]
if args.threshold_method == "otsu":
score_threshold = otsu_threshold(values)
else:
score_threshold = float(np.nanpercentile(values, args.score_percentile))
hv_dark_threshold = float(np.nanpercentile(hv_norm[valid], args.hv_percentile))
mask = (low_backscatter >= score_threshold) & (hv_norm <= hv_dark_threshold) & valid
prior_mask = np.zeros(mask.shape, dtype=bool)
prior_candidate_pixels = 0
prior_score_threshold = None
prior_hv_threshold = None
if args.water_vector:
prior_mask = rasterize_water_prior(args.water_vector, hh_info, args.river_buffer_meters) & valid
prior_score_threshold = float(np.nanpercentile(values, args.prior_score_percentile))
prior_hv_threshold = float(np.nanpercentile(hv_norm[valid], args.prior_hv_percentile))
prior_candidate = prior_mask & (low_backscatter >= prior_score_threshold) & (hv_norm <= prior_hv_threshold)
prior_candidate_pixels = int(prior_candidate.sum())
mask |= prior_candidate
if dltb_enabled:
prior_mask |= dltb_water_mask & valid
prior_score_threshold = prior_score_threshold if prior_score_threshold is not None else float(np.nanpercentile(values, args.prior_score_percentile))
prior_hv_threshold = prior_hv_threshold if prior_hv_threshold is not None else float(np.nanpercentile(hv_norm[valid], args.prior_hv_percentile))
dltb_prior_candidate = prior_mask & (low_backscatter >= prior_score_threshold) & (hv_norm <= prior_hv_threshold)
prior_candidate_pixels += int((dltb_water_mask & dltb_prior_candidate).sum())
mask |= dltb_prior_candidate
paddy_mask = np.zeros(mask.shape, dtype=bool)
paddy_candidate = np.zeros(mask.shape, dtype=bool)
paddy_score_threshold = None
paddy_hv_threshold = None
if args.paddy_vector:
paddy_mask = rasterize_vector_mask(args.paddy_vector, hh_info, args.paddy_buffer_meters, "--paddy-vector") & valid
paddy_score_threshold = float(np.nanpercentile(values, args.paddy_score_percentile))
paddy_hv_threshold = float(np.nanpercentile(hv_norm[valid], args.paddy_hv_percentile))
paddy_candidate = paddy_mask & (low_backscatter >= paddy_score_threshold) & (hv_norm <= paddy_hv_threshold)
if dltb_enabled:
paddy_mask |= dltb_paddy_mask & valid
paddy_score_threshold = paddy_score_threshold if paddy_score_threshold is not None else float(np.nanpercentile(values, args.paddy_score_percentile))
paddy_hv_threshold = paddy_hv_threshold if paddy_hv_threshold is not None else float(np.nanpercentile(hv_norm[valid], args.paddy_hv_percentile))
paddy_candidate |= paddy_mask & (low_backscatter >= paddy_score_threshold) & (hv_norm <= paddy_hv_threshold)
candidate_score_threshold = float(np.nanpercentile(values, args.candidate_score_percentile))
candidate_hv_threshold = float(np.nanpercentile(hv_norm[valid], args.candidate_hv_percentile))
low_confidence_candidate = (low_backscatter >= candidate_score_threshold) & (hv_norm <= candidate_hv_threshold) & valid
if dltb_enabled and args.dltb_mode == "soft":
strong_candidate = (low_backscatter >= score_threshold) & (hv_norm <= hv_dark_threshold) & valid
low_confidence_candidate &= ~dltb_strict_mask | strong_candidate
elif dltb_enabled and args.dltb_mode == "strict":
low_confidence_candidate &= ~dltb_strict_mask
dem_used = False
slope_threshold = None
slope = None
if args.dem is not None:
dem_info = parse_envi(args.dem)
dem = read_dem_for_sar(dem_info, hh_info)
slope = slope_degrees(dem, hh_info.dx, hh_info.dy, center_lat=hh_info.y0 - hh_info.lines * hh_info.dy * 0.5)
slope_threshold = float(args.slope_max)
mask &= np.isfinite(slope) & (slope <= args.slope_max)
paddy_candidate &= np.isfinite(slope) & (slope <= args.slope_max)
low_confidence_candidate &= np.isfinite(slope) & (slope <= args.slope_max)
valid &= np.isfinite(slope)
dem_used = True
save_gray_png(args.out_dir / "slope_preview.png", slope, np.isfinite(slope))
write_tif(args.out_dir / "slope_degrees.tif", np.where(np.isfinite(slope), slope, -9999.0), hh_info, "float32", nodata=-9999.0)
raw_mask = mask.copy()
if not args.no_morphology:
mask = close_mask(mask, args.close_pixels, valid)
mask = remove_small_components(mask, args.min_component_pixels)
mask = fill_small_holes(mask, args.fill_hole_pixels)
mask = open_mask(mask, args.open_pixels, valid)
paddy_candidate = close_mask(paddy_candidate, args.paddy_close_pixels, valid)
paddy_candidate = open_mask(paddy_candidate, args.paddy_open_pixels, valid)
low_confidence_candidate = close_mask(low_confidence_candidate, args.candidate_close_pixels, valid)
low_confidence_candidate = open_mask(low_confidence_candidate, args.candidate_open_pixels, valid)
probability = np.clip(low_backscatter, 0.0, 1.0)
probability[~valid] = np.nan
classified = _classify_products(mask, low_confidence_candidate, paddy_candidate, prior_mask, valid)
cartographic_water = None
if args.cartographic_water:
cartographic_water = (mask | low_confidence_candidate) & valid
if args.cartographic_include_paddy:
cartographic_water |= paddy_candidate & valid
else:
cartographic_water &= ~paddy_candidate
if not args.no_morphology:
cartographic_water = close_mask(cartographic_water, args.cartographic_close_pixels, valid)
cartographic_water = fill_small_holes(cartographic_water, args.cartographic_fill_hole_pixels)
cartographic_water = remove_small_components(cartographic_water, args.cartographic_min_component_pixels)
cartographic_water = open_mask(cartographic_water, args.cartographic_open_pixels, valid)
_write_rasters(
args,
hh_info,
probability,
valid,
mask,
raw_mask,
classified,
cartographic_water,
prior_mask,
paddy_mask,
paddy_candidate,
dltb_enabled,
dltb_water_mask,
dltb_paddy_mask,
dltb_strict_mask,
)
_write_previews(
args,
probability,
valid,
mask,
raw_mask,
hh_norm,
hv_norm,
classified,
prior_mask,
paddy_candidate,
dltb_enabled,
dltb_water_mask,
dltb_paddy_mask,
dltb_strict_mask,
cartographic_water,
)
vector_stats = None
if args.out_vector_gpkg is not None or args.out_vector_shp_dir is not None:
vector_stats = write_classified_vectors(
args.out_vector_gpkg,
args.out_vector_shp_dir,
classified,
cartographic_water,
hh_info,
probability,
hh_db,
hv_db,
slope,
prior_mask,
paddy_mask,
args.min_polygon_area_m2,
args.simplify_meters,
args.smooth_meters,
args.min_hole_area_m2,
)
valid_count = int(valid.sum())
stats = {
"hh": str(args.hh),
"hv": str(args.hv),
"dem": str(args.dem) if args.dem else None,
"dltb": dltb_stats,
"water_vectors": [str(p) for p in args.water_vector],
"paddy_vectors": [str(p) for p in args.paddy_vector],
"river_buffer_meters": float(args.river_buffer_meters),
"paddy_buffer_meters": float(args.paddy_buffer_meters),
"shape": [hh_info.lines, hh_info.samples],
"bounds_wgs84": list(hh_info.bounds),
"valid_pixels": valid_count,
"valid_ratio": float(valid_count / valid.size),
"hh_db_percentile_2_98": [hh_lo, hh_hi],
"hv_db_percentile_2_98": [hv_lo, hv_hi],
"threshold_method": args.threshold_method,
"score_threshold": float(score_threshold),
"hv_norm_dark_threshold": float(hv_dark_threshold),
"prior_score_threshold": prior_score_threshold,
"prior_hv_norm_dark_threshold": prior_hv_threshold,
"paddy_score_threshold": paddy_score_threshold,
"paddy_hv_norm_dark_threshold": paddy_hv_threshold,
"candidate_score_threshold": candidate_score_threshold,
"candidate_hv_norm_dark_threshold": candidate_hv_threshold,
"slope_threshold_degrees": slope_threshold,
"dem_used": dem_used,
"known_water_prior_pixels": int(prior_mask.sum()),
"known_water_prior_ratio_valid": float(prior_mask.sum() / max(valid_count, 1)),
"dltb_water_prior_pixels": int(dltb_water_mask.sum()),
"dltb_paddy_prior_pixels": int(dltb_paddy_mask.sum()),
"dltb_strict_zone_pixels": int(dltb_strict_mask.sum()),
"prior_candidate_pixels": prior_candidate_pixels,
"paddy_prior_pixels": int(paddy_mask.sum()),
"paddy_water_like_pixels": int(paddy_candidate.sum()),
"paddy_water_like_ratio_valid": float(paddy_candidate.sum() / max(valid_count, 1)),
"low_confidence_water_pixels": int((classified == CLASS_LOW_CONFIDENCE_WATER).sum()),
"cartographic_water_pixels": int(cartographic_water.sum()) if cartographic_water is not None else 0,
"cartographic_water_ratio_valid": float(cartographic_water.sum() / max(valid_count, 1)) if cartographic_water is not None else 0.0,
"raw_water_pixels": int(raw_mask.sum()),
"raw_water_ratio_valid": float(raw_mask.sum() / max(valid_count, 1)),
"water_pixels": int(mask.sum()),
"water_ratio_valid": float(mask.sum() / max(valid_count, 1)),
"classified_counts": {CLASS_NAMES[class_id]: int((classified == class_id).sum()) for class_id in CLASS_NAMES},
"min_component_pixels": int(args.min_component_pixels),
"fill_hole_pixels": int(args.fill_hole_pixels),
"close_pixels": int(args.close_pixels),
"open_pixels": int(args.open_pixels),
"paddy_close_pixels": int(args.paddy_close_pixels),
"paddy_open_pixels": int(args.paddy_open_pixels),
"candidate_close_pixels": int(args.candidate_close_pixels),
"candidate_open_pixels": int(args.candidate_open_pixels),
"cartographic_water_enabled": bool(args.cartographic_water),
"cartographic_include_paddy": bool(args.cartographic_include_paddy),
"cartographic_close_pixels": int(args.cartographic_close_pixels),
"cartographic_open_pixels": int(args.cartographic_open_pixels),
"cartographic_fill_hole_pixels": int(args.cartographic_fill_hole_pixels),
"cartographic_min_component_pixels": int(args.cartographic_min_component_pixels),
"min_polygon_area_m2": float(args.min_polygon_area_m2),
"simplify_meters": float(args.simplify_meters),
"smooth_meters": float(args.smooth_meters),
"min_hole_area_m2": float(args.min_hole_area_m2),
"vector_output": vector_stats,
}
(args.out_dir / "metadata.json").write_text(json.dumps(stats, indent=2), encoding="utf-8")
print(json.dumps(stats, indent=2))
return 0
@@ -0,0 +1,67 @@
"""PNG preview writers for extraction products."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from PIL import Image
from .constants import (
CLASS_HIGH_CONFIDENCE_WATER,
CLASS_KNOWN_WATER,
CLASS_LOW_CONFIDENCE_WATER,
CLASS_PADDY_WATER_LIKE,
)
def save_gray_png(path: Path, arr: np.ndarray, valid: np.ndarray) -> None:
out = np.zeros(arr.shape, dtype=np.uint8)
vals = arr[valid & np.isfinite(arr)]
if vals.size:
lo, hi = np.nanpercentile(vals, [2, 98])
scaled = np.clip((arr - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
out[valid & np.isfinite(arr)] = (scaled[valid & np.isfinite(arr)] * 255).astype(np.uint8)
Image.fromarray(out).save(path)
def save_mask_png(path: Path, mask: np.ndarray) -> None:
Image.fromarray(np.where(mask, 255, 0).astype(np.uint8)).save(path)
def save_preview(path: Path, hh_norm: np.ndarray, hv_norm: np.ndarray, mask: np.ndarray, valid: np.ndarray) -> None:
rgb = np.zeros((*mask.shape, 3), dtype=np.uint8)
rgb[..., 0] = np.nan_to_num(hh_norm * 255.0, nan=0.0).astype(np.uint8)
rgb[..., 1] = np.nan_to_num(hv_norm * 255.0, nan=0.0).astype(np.uint8)
rgb[..., 2] = np.nan_to_num((1.0 - 0.5 * (hh_norm + hv_norm)) * 255.0, nan=0.0).astype(np.uint8)
rgb[mask] = (0.35 * rgb[mask] + np.array([0, 120, 255]) * 0.65).astype(np.uint8)
rgb[~valid] = 0
Image.fromarray(rgb).save(path)
def save_class_preview(path: Path, classified: np.ndarray, hh_norm: np.ndarray, hv_norm: np.ndarray, valid: np.ndarray) -> None:
rgb = np.zeros((*classified.shape, 3), dtype=np.uint8)
base = np.nan_to_num((0.55 * hh_norm + 0.45 * hv_norm) * 180.0, nan=0.0).astype(np.uint8)
rgb[..., 0] = base
rgb[..., 1] = base
rgb[..., 2] = base
colors = {
CLASS_HIGH_CONFIDENCE_WATER: np.array([0, 92, 230], dtype=np.uint8),
CLASS_KNOWN_WATER: np.array([0, 170, 255], dtype=np.uint8),
CLASS_PADDY_WATER_LIKE: np.array([0, 210, 170], dtype=np.uint8),
CLASS_LOW_CONFIDENCE_WATER: np.array([245, 166, 35], dtype=np.uint8),
}
for class_id, color in colors.items():
idx = classified == class_id
rgb[idx] = (0.30 * rgb[idx] + 0.70 * color).astype(np.uint8)
rgb[~valid] = 0
Image.fromarray(rgb).save(path)
def save_dltb_zone_preview(path: Path, water_mask: np.ndarray, paddy_mask: np.ndarray, strict_mask: np.ndarray) -> None:
rgb = np.zeros((*water_mask.shape, 3), dtype=np.uint8)
rgb[water_mask] = (0, 120, 255)
rgb[paddy_mask] = (0, 210, 170)
rgb[strict_mask] = (220, 80, 40)
Image.fromarray(rgb).save(path)
@@ -0,0 +1,14 @@
"""Backward-compatible processor facade.
New code should import from ``gf3_water.pipeline`` or ``gf3_water.cli``. This
module remains so existing scripts and integrations that import
``gf3_water.processor`` continue to work.
"""
from __future__ import annotations
from .cli import build_arg_parser, main
from .pipeline import run_from_args
__all__ = ["build_arg_parser", "main", "run_from_args"]
@@ -0,0 +1,98 @@
"""Raster transforms, thresholding, and morphology."""
from __future__ import annotations
import math
import numpy as np
from scipy import ndimage as ndi
def slope_degrees(dem: np.ndarray, dx_deg: float, dy_deg: float, center_lat: float) -> np.ndarray:
meters_per_deg_lat = 111_320.0
meters_per_deg_lon = 111_320.0 * math.cos(math.radians(center_lat))
dz_dy, dz_dx = np.gradient(dem.astype(np.float32), dy_deg * meters_per_deg_lat, dx_deg * meters_per_deg_lon)
slope = np.degrees(np.arctan(np.sqrt(dz_dx * dz_dx + dz_dy * dz_dy)))
slope[~np.isfinite(slope)] = np.nan
return slope.astype(np.float32)
def to_db(arr: np.ndarray, eps: float = 1e-8) -> np.ndarray:
out = np.full(arr.shape, np.nan, dtype=np.float32)
valid = np.isfinite(arr) & (arr > 0)
out[valid] = 10.0 * np.log10(arr[valid] + eps)
return out
def robust_normalize(arr: np.ndarray, valid: np.ndarray, q_low: float = 2.0, q_high: float = 98.0) -> tuple[np.ndarray, float, float]:
values = arr[valid]
lo, hi = np.nanpercentile(values, [q_low, q_high])
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
lo, hi = float(np.nanmin(values)), float(np.nanmax(values))
norm = np.clip((arr - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
norm[~valid] = np.nan
return norm.astype(np.float32), float(lo), float(hi)
def otsu_threshold(values: np.ndarray, bins: int = 512) -> float:
values = values[np.isfinite(values)]
if values.size == 0:
raise ValueError("No finite values for thresholding")
hist, edges = np.histogram(values, bins=bins)
centers = (edges[:-1] + edges[1:]) * 0.5
weight1 = np.cumsum(hist).astype(np.float64)
weight2 = np.cumsum(hist[::-1]).astype(np.float64)[::-1]
mean1 = np.cumsum(hist * centers) / np.maximum(weight1, 1.0)
mean2 = (np.cumsum((hist * centers)[::-1]) / np.maximum(weight2[::-1], 1.0))[::-1]
variance12 = weight1[:-1] * weight2[1:] * (mean1[:-1] - mean2[1:]) ** 2
return float(centers[:-1][np.argmax(variance12)])
def remove_small_components(mask: np.ndarray, min_pixels: int) -> np.ndarray:
if min_pixels <= 1:
return mask
labels, count = ndi.label(mask)
if count == 0:
return mask
sizes = np.bincount(labels.ravel())
keep = sizes >= min_pixels
keep[0] = False
return keep[labels]
def fill_small_holes(mask: np.ndarray, max_pixels: int) -> np.ndarray:
if max_pixels <= 0:
return mask
inv = ~mask
labels, count = ndi.label(inv)
if count == 0:
return mask
border = np.unique(np.concatenate([labels[0, :], labels[-1, :], labels[:, 0], labels[:, -1]]))
sizes = np.bincount(labels.ravel())
fill = sizes <= max_pixels
fill[border] = False
out = mask.copy()
out[fill[labels]] = True
return out
def disk_structure(radius: int) -> np.ndarray:
if radius <= 0:
return np.ones((1, 1), dtype=bool)
y, x = np.ogrid[-radius : radius + 1, -radius : radius + 1]
return (x * x + y * y) <= radius * radius
def close_mask(mask: np.ndarray, radius: int, valid: np.ndarray) -> np.ndarray:
if radius <= 0:
return mask
closed = ndi.binary_closing(mask & valid, structure=disk_structure(radius))
return closed & valid
def open_mask(mask: np.ndarray, radius: int, valid: np.ndarray) -> np.ndarray:
if radius <= 0:
return mask
opened = ndi.binary_opening(mask & valid, structure=disk_structure(radius))
return opened & valid
@@ -0,0 +1,88 @@
"""Vector loading and rasterization helpers."""
from __future__ import annotations
from pathlib import Path
import numpy as np
from shapely.geometry import box, shape
from shapely.ops import transform as shapely_transform
from .envi import EnviInfo
from .geo import meters_to_degrees
try:
import fiona
except Exception: # pragma: no cover - optional at runtime
fiona = None
try:
from rasterio.features import rasterize
except Exception: # pragma: no cover - optional at runtime
rasterize = None
def load_vector_geometries(paths: list[Path], bounds: tuple[float, float, float, float], line_buffer_meters: float) -> list:
if not paths:
return []
if fiona is None:
raise RuntimeError("fiona is required for vector inputs")
roi = box(*bounds)
center_lat = (bounds[1] + bounds[3]) * 0.5
buffer_lon, buffer_lat = meters_to_degrees(max(line_buffer_meters, 0.1), center_lat)
search_roi = box(bounds[0] - buffer_lon, bounds[1] - buffer_lat, bounds[2] + buffer_lon, bounds[3] + buffer_lat)
geoms = []
for path in paths:
with fiona.open(path) as src:
for feat in src:
if not feat.get("geometry"):
continue
geom = shape(feat["geometry"])
if geom.is_empty or not geom.intersects(search_roi):
continue
if geom.geom_type in ("LineString", "MultiLineString"):
geom = shapely_transform(lambda x, y, z=None: (np.asarray(x) / buffer_lon, np.asarray(y) / buffer_lat), geom)
geom = geom.buffer(1.0)
geom = shapely_transform(lambda x, y, z=None: (np.asarray(x) * buffer_lon, np.asarray(y) * buffer_lat), geom)
geom = geom.intersection(search_roi)
if not geom.is_empty and geom.intersects(roi):
geoms.append(geom)
return geoms
def rasterize_vector_mask(paths: list[Path], info: EnviInfo, line_buffer_meters: float, label: str) -> np.ndarray:
if rasterize is None:
raise RuntimeError(f"rasterio.features.rasterize is required for {label}")
geoms = load_vector_geometries(paths, info.bounds, line_buffer_meters)
if not geoms:
return np.zeros((info.lines, info.samples), dtype=bool)
return rasterize(
[(geom, 1) for geom in geoms],
out_shape=(info.lines, info.samples),
transform=info.transform,
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
def rasterize_water_prior(paths: list[Path], info: EnviInfo, river_buffer_meters: float) -> np.ndarray:
return rasterize_vector_mask(paths, info, river_buffer_meters, "--water-vector")
def rasterize_geometries(geoms: list, info: EnviInfo) -> np.ndarray:
if rasterize is None:
raise RuntimeError("rasterio.features.rasterize is required to rasterize geometry priors")
if not geoms:
return np.zeros((info.lines, info.samples), dtype=bool)
return rasterize(
[(geom, 1) for geom in geoms if not geom.is_empty],
out_shape=(info.lines, info.samples),
transform=info.transform,
fill=0,
dtype="uint8",
all_touched=True,
).astype(bool)
@@ -0,0 +1,269 @@
"""Cartographic vector product writing."""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
from shapely.geometry import MultiPolygon, Polygon, shape
from shapely.ops import transform as shapely_transform
from .constants import (
CLASS_CARTOGRAPHIC_WATER,
CLASS_HIGH_CONFIDENCE_WATER,
CLASS_KNOWN_WATER,
CLASS_LOW_CONFIDENCE_WATER,
CLASS_NAMES,
CLASS_NON_WATER,
CLASS_PADDY_WATER_LIKE,
)
from .envi import EnviInfo
from .geo import meters_to_degrees, pixel_area_m2, polygon_area_m2
try:
import fiona
except Exception: # pragma: no cover - optional at runtime
fiona = None
try:
from rasterio.features import shapes
except Exception: # pragma: no cover - optional at runtime
shapes = None
def remove_small_polygon_holes(geom, min_hole_area_m2: float, center_lat: float):
if min_hole_area_m2 <= 0 or geom.is_empty:
return geom
def clean_polygon(poly: Polygon) -> Polygon:
interiors = []
for ring in poly.interiors:
hole = Polygon(ring)
if polygon_area_m2(hole, center_lat) >= min_hole_area_m2:
interiors.append(ring)
return Polygon(poly.exterior, interiors)
if geom.geom_type == "Polygon":
return clean_polygon(geom)
if geom.geom_type == "MultiPolygon":
parts = [clean_polygon(poly) for poly in geom.geoms if not poly.is_empty]
return MultiPolygon(parts) if parts else geom
return geom
def smooth_geometry_meters(geom, smooth_meters: float, center_lat: float):
if smooth_meters <= 0 or geom.is_empty:
return geom
smooth_lon, smooth_lat = meters_to_degrees(smooth_meters, center_lat)
scaled = shapely_transform(lambda x, y, z=None: (np.asarray(x) / smooth_lon, np.asarray(y) / smooth_lat), geom)
smoothed = scaled.buffer(1.0).buffer(-1.0)
return shapely_transform(lambda x, y, z=None: (np.asarray(x) * smooth_lon, np.asarray(y) * smooth_lat), smoothed)
def write_classified_vectors(
gpkg_path: Path | None,
shp_dir: Path | None,
classified: np.ndarray,
cartographic_water: np.ndarray | None,
info: EnviInfo,
score: np.ndarray,
hh_db: np.ndarray,
hv_db: np.ndarray,
slope: np.ndarray | None,
known_water: np.ndarray,
paddy: np.ndarray,
min_area_m2: float,
simplify_meters: float,
smooth_meters: float,
min_hole_area_m2: float,
) -> dict:
if fiona is None or shapes is None:
raise RuntimeError("fiona and rasterio.features.shapes are required for vector output")
if gpkg_path is not None:
gpkg_path.parent.mkdir(parents=True, exist_ok=True)
if gpkg_path.exists():
gpkg_path.unlink()
if shp_dir is not None:
shp_dir.mkdir(parents=True, exist_ok=True)
px_area = pixel_area_m2(info)
center_lat = info.y0 - info.lines * info.dy * 0.5
simplify_lon, _ = meters_to_degrees(simplify_meters, center_lat)
schema = {
"geometry": "Polygon",
"properties": {
"class_id": "int",
"class_name": "str:32",
"confidence": "str:16",
"area_m2": "float",
"pixels": "int",
"mean_score": "float",
"mean_hh_db": "float",
"mean_hv_db": "float",
"mean_slope": "float",
"known_water": "int",
"paddy": "int",
"review_flag": "int",
},
}
crs = "EPSG:4326"
counts = {name: 0 for name in CLASS_NAMES.values()}
layers = {
CLASS_CARTOGRAPHIC_WATER: "cartographic_water",
CLASS_HIGH_CONFIDENCE_WATER: "high_confidence_water",
CLASS_KNOWN_WATER: "known_water",
CLASS_PADDY_WATER_LIKE: "paddy_water_like",
CLASS_LOW_CONFIDENCE_WATER: "review_candidates",
}
handles = {}
shp_handles = {}
try:
if gpkg_path is not None:
for class_id, layer_name in layers.items():
handles[class_id] = fiona.open(gpkg_path, "w", driver="GPKG", layer=layer_name, crs=crs, schema=schema)
if shp_dir is not None:
for class_id, layer_name in layers.items():
shp_path = shp_dir / f"{layer_name}.shp"
for suffix in (".shp", ".shx", ".dbf", ".prj", ".cpg"):
sidecar = shp_path.with_suffix(suffix)
if sidecar.exists():
sidecar.unlink()
shp_handles[class_id] = fiona.open(shp_path, "w", driver="ESRI Shapefile", crs=crs, schema=schema, encoding="UTF-8")
vector_classes = classified.copy()
class_mask = np.isin(vector_classes, [CLASS_HIGH_CONFIDENCE_WATER, CLASS_KNOWN_WATER, CLASS_PADDY_WATER_LIKE, CLASS_LOW_CONFIDENCE_WATER])
if cartographic_water is not None:
class_mask |= cartographic_water
for geom_mapping, value in shapes(vector_classes.astype(np.uint8), mask=class_mask, transform=info.transform):
class_id = int(value)
if cartographic_water is not None and class_id == CLASS_NON_WATER:
continue
if class_id not in layers:
continue
geom = shape(geom_mapping)
if geom.is_empty:
continue
geom = smooth_geometry_meters(geom, smooth_meters, center_lat)
geom = remove_small_polygon_holes(geom, min_hole_area_m2, center_lat)
if geom.is_empty:
continue
if simplify_meters > 0:
geom = geom.simplify(simplify_lon, preserve_topology=True)
if geom.is_empty:
continue
geom_area_m2 = polygon_area_m2(geom, center_lat)
minx, miny, maxx, maxy = geom.bounds
col0 = max(0, int(math.floor((minx - info.x0) / info.dx)) - 1)
col1 = min(info.samples, int(math.ceil((maxx - info.x0) / info.dx)) + 1)
row0 = max(0, int(math.floor((info.y0 - maxy) / info.dy)) - 1)
row1 = min(info.lines, int(math.ceil((info.y0 - miny) / info.dy)) + 1)
if col1 <= col0 or row1 <= row0:
continue
if class_id == CLASS_CARTOGRAPHIC_WATER and cartographic_water is not None:
window = cartographic_water[row0:row1, col0:col1]
else:
window = classified[row0:row1, col0:col1] == class_id
pixels = int(window.sum())
area_m2 = pixels * px_area
if max(area_m2, geom_area_m2) < min_area_m2:
continue
score_window = score[row0:row1, col0:col1]
hh_window = hh_db[row0:row1, col0:col1]
hv_window = hv_db[row0:row1, col0:col1]
slope_window = slope[row0:row1, col0:col1] if slope is not None else None
mean_slope = float(np.nanmean(slope_window[window])) if slope_window is not None and np.any(np.isfinite(slope_window[window])) else -9999.0
review_flag = 1 if class_id in (CLASS_PADDY_WATER_LIKE, CLASS_LOW_CONFIDENCE_WATER, CLASS_CARTOGRAPHIC_WATER) else 0
confidence = "map" if class_id == CLASS_CARTOGRAPHIC_WATER else ("high" if class_id in (CLASS_HIGH_CONFIDENCE_WATER, CLASS_KNOWN_WATER) else "review")
feature = {
"geometry": geom.__geo_interface__,
"properties": {
"class_id": class_id,
"class_name": CLASS_NAMES[class_id],
"confidence": confidence,
"area_m2": float(geom_area_m2),
"pixels": pixels,
"mean_score": float(np.nanmean(score_window[window])),
"mean_hh_db": float(np.nanmean(hh_window[window])),
"mean_hv_db": float(np.nanmean(hv_window[window])),
"mean_slope": mean_slope,
"known_water": int(np.any(known_water[row0:row1, col0:col1] & window)),
"paddy": int(np.any(paddy[row0:row1, col0:col1] & window)),
"review_flag": review_flag,
},
}
if class_id in handles:
handles[class_id].write(feature)
if class_id in shp_handles:
shp_handles[class_id].write(feature)
counts[CLASS_NAMES[class_id]] += 1
if cartographic_water is not None:
for geom_mapping, value in shapes(np.where(cartographic_water, CLASS_CARTOGRAPHIC_WATER, CLASS_NON_WATER).astype(np.uint8), mask=cartographic_water, transform=info.transform):
class_id = int(value)
geom = shape(geom_mapping)
if geom.is_empty:
continue
geom = smooth_geometry_meters(geom, smooth_meters, center_lat)
geom = remove_small_polygon_holes(geom, min_hole_area_m2, center_lat)
if geom.is_empty:
continue
if simplify_meters > 0:
geom = geom.simplify(simplify_lon, preserve_topology=True)
if geom.is_empty:
continue
geom_area_m2 = polygon_area_m2(geom, center_lat)
if geom_area_m2 < min_area_m2:
continue
minx, miny, maxx, maxy = geom.bounds
col0 = max(0, int(math.floor((minx - info.x0) / info.dx)) - 1)
col1 = min(info.samples, int(math.ceil((maxx - info.x0) / info.dx)) + 1)
row0 = max(0, int(math.floor((info.y0 - maxy) / info.dy)) - 1)
row1 = min(info.lines, int(math.ceil((info.y0 - miny) / info.dy)) + 1)
if col1 <= col0 or row1 <= row0:
continue
window = cartographic_water[row0:row1, col0:col1]
pixels = int(window.sum())
score_window = score[row0:row1, col0:col1]
hh_window = hh_db[row0:row1, col0:col1]
hv_window = hv_db[row0:row1, col0:col1]
slope_window = slope[row0:row1, col0:col1] if slope is not None else None
mean_slope = float(np.nanmean(slope_window[window])) if slope_window is not None and np.any(np.isfinite(slope_window[window])) else -9999.0
feature = {
"geometry": geom.__geo_interface__,
"properties": {
"class_id": CLASS_CARTOGRAPHIC_WATER,
"class_name": CLASS_NAMES[CLASS_CARTOGRAPHIC_WATER],
"confidence": "map",
"area_m2": float(geom_area_m2),
"pixels": pixels,
"mean_score": float(np.nanmean(score_window[window])),
"mean_hh_db": float(np.nanmean(hh_window[window])),
"mean_hv_db": float(np.nanmean(hv_window[window])),
"mean_slope": mean_slope,
"known_water": int(np.any(known_water[row0:row1, col0:col1] & window)),
"paddy": int(np.any(paddy[row0:row1, col0:col1] & window)),
"review_flag": 1,
},
}
if CLASS_CARTOGRAPHIC_WATER in handles:
handles[CLASS_CARTOGRAPHIC_WATER].write(feature)
if CLASS_CARTOGRAPHIC_WATER in shp_handles:
shp_handles[CLASS_CARTOGRAPHIC_WATER].write(feature)
counts[CLASS_NAMES[CLASS_CARTOGRAPHIC_WATER]] += 1
finally:
for handle in list(handles.values()) + list(shp_handles.values()):
handle.close()
return {
"gpkg_path": str(gpkg_path) if gpkg_path is not None else None,
"shp_dir": str(shp_dir) if shp_dir is not None else None,
"layers": layers,
"feature_counts": counts,
"min_area_m2": float(min_area_m2),
"simplify_meters": float(simplify_meters),
"smooth_meters": float(smooth_meters),
"min_hole_area_m2": float(min_hole_area_m2),
}