Refactor GF3 L1A to L2 pipeline
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
/.tmp_tests/
|
||||
logs/
|
||||
GMTED2010.jp2
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Compatibility wrapper for archive extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path(__file__).resolve().parent / "src"
|
||||
if _SRC.is_dir():
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from gf3_l1a2l2.decompress import extract_compressed_files
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract GF3 archives")
|
||||
parser.add_argument("input", type=Path, help="folder containing .tar.gz or .zip archives")
|
||||
parser.add_argument("--overwrite", action="store_true", help="overwrite existing extracted folders")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = extract_compressed_files(args.input, overwrite=args.overwrite)
|
||||
failed = [item for item in results if item.status == "failed"]
|
||||
for item in results:
|
||||
print(f"{item.status}\t{item.archive}\t{item.output_dir}\t{item.message}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
# GF3 L1A to L2 Pipeline
|
||||
|
||||
This repository contains a Python package and CLI for converting GF3 L1A
|
||||
products to L2 GeoTIFF outputs.
|
||||
|
||||
The heavy work is still handled by GDAL, Rasterio, and NumPy. The package adds
|
||||
pipeline structure around the original script: explicit configuration, safe path
|
||||
handling, logging, restart-friendly skipping, per-scene error isolation, and
|
||||
block-based radiometric calibration.
|
||||
|
||||
## Install
|
||||
|
||||
Use an environment that already has a working GDAL/OSGeo Python binding. On
|
||||
Windows, Conda/Miniforge is usually the least fragile route.
|
||||
|
||||
```powershell
|
||||
conda env create -f environment.yml
|
||||
conda activate gf3-l1a2l2
|
||||
pip install -e . --no-deps
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
gf3-l1a2l2 run --input D:\GF3\L1A_BATCH --output E:\GF3\L2_Image --dem .\GMTED2010.jp2
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
```powershell
|
||||
gf3-l1a2l2 run `
|
||||
--input D:\GF3\L1A_BATCH `
|
||||
--output E:\GF3\L2_Image `
|
||||
--dem .\GMTED2010.jp2 `
|
||||
--workers 1 `
|
||||
--x-res 0.0002 `
|
||||
--y-res 0.0002 `
|
||||
--dst-srs EPSG:4326
|
||||
```
|
||||
|
||||
By default, output files are written under one subdirectory per discovered
|
||||
scene. Use `--flat-output` to write all outputs directly into the output
|
||||
directory.
|
||||
|
||||
The legacy files are now compatibility wrappers:
|
||||
|
||||
```powershell
|
||||
python gf3_L1A_To_L2.py run --input D:\GF3\L1A_BATCH --output E:\GF3\L2_Image --dem .\GMTED2010.jp2
|
||||
python Decompression.py D:\GF3\L1A_BATCH
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The CLI extracts `.tar.gz` and `.zip` products before processing unless
|
||||
`--skip-decompress` is used.
|
||||
- Existing L2 outputs are skipped unless `--overwrite` is used.
|
||||
- Logs are written to `<output>\logs`.
|
||||
- Without representative GF3 products, verification is limited to parser unit
|
||||
tests and Python syntax/import checks.
|
||||
@@ -0,0 +1,10 @@
|
||||
name: gf3-l1a2l2
|
||||
channels:
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- python=3.11
|
||||
- gdal
|
||||
- rasterio
|
||||
- numpy
|
||||
- tqdm
|
||||
- pip
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Compatibility wrapper for the packaged GF3 L1A to L2 pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SRC = Path(__file__).resolve().parent / "src"
|
||||
if _SRC.is_dir():
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from gf3_l1a2l2.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gf3-l1a2l2-pipeline"
|
||||
version = "0.1.0"
|
||||
description = "GF3 L1A to L2 processing pipeline"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"numpy>=1.22",
|
||||
"rasterio>=1.3",
|
||||
"tqdm>=4.64",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gf3-l1a2l2 = "gf3_l1a2l2.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""GF3 L1A to L2 processing pipeline."""
|
||||
|
||||
__all__ = ["PipelineConfig", "run_pipeline"]
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "PipelineConfig":
|
||||
from gf3_l1a2l2.config import PipelineConfig
|
||||
|
||||
return PipelineConfig
|
||||
if name == "run_pipeline":
|
||||
from gf3_l1a2l2.pipeline import run_pipeline
|
||||
|
||||
return run_pipeline
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,5 @@
|
||||
from gf3_l1a2l2.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from rasterio.windows import Window
|
||||
|
||||
from gf3_l1a2l2.errors import ProcessingError
|
||||
from gf3_l1a2l2.models import CalibrationValue
|
||||
from gf3_l1a2l2.paths import output_name
|
||||
|
||||
|
||||
def radiometric_calibrate_l1a_to_l1b(
|
||||
image_path: Path | str,
|
||||
output_dir: Path | str,
|
||||
calibration: CalibrationValue,
|
||||
*,
|
||||
block_size: int,
|
||||
overwrite: bool = False,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> Path:
|
||||
source = Path(image_path)
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
l1b_path = out_dir / output_name(source, "L1A", "L1B")
|
||||
|
||||
if l1b_path.exists() and not overwrite:
|
||||
if logger:
|
||||
logger.info("Skip existing L1B: %s", l1b_path)
|
||||
return l1b_path
|
||||
|
||||
if not math.isfinite(calibration.qualify_value):
|
||||
raise ProcessingError(f"Invalid QualifyValue for {source.name}: {calibration.qualify_value}")
|
||||
if not math.isfinite(calibration.calibration_const):
|
||||
raise ProcessingError(
|
||||
f"Invalid CalibrationConst for {source.name}: {calibration.calibration_const}"
|
||||
)
|
||||
|
||||
if logger:
|
||||
logger.info("Calibrating %s -> %s", source, l1b_path)
|
||||
|
||||
try:
|
||||
with rasterio.open(source) as src:
|
||||
if src.count < 2:
|
||||
raise ProcessingError(f"Expected at least 2 bands in L1A image: {source}")
|
||||
|
||||
profile = src.profile.copy()
|
||||
profile.update(
|
||||
driver="GTiff",
|
||||
count=1,
|
||||
dtype="float32",
|
||||
compress="lzw",
|
||||
BIGTIFF="YES",
|
||||
tiled=True,
|
||||
)
|
||||
|
||||
with rasterio.open(l1b_path, "w", **profile) as dst:
|
||||
for window in _iter_windows(src.width, src.height, block_size):
|
||||
real = src.read(1, window=window, out_dtype="float32")
|
||||
imag = src.read(2, window=window, out_dtype="float32")
|
||||
db = _calibrate_window(real, imag, calibration)
|
||||
dst.write(db, 1, window=window)
|
||||
except ProcessingError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ProcessingError(f"Failed to calibrate {source}") from exc
|
||||
|
||||
return l1b_path
|
||||
|
||||
|
||||
def _calibrate_window(real: np.ndarray, imag: np.ndarray, calibration: CalibrationValue) -> np.ndarray:
|
||||
amplitude = np.hypot(real, imag)
|
||||
scaled = (amplitude / np.float32(32767.0)) * np.float32(calibration.qualify_value)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
db = np.float32(20.0) * np.log10(scaled) - np.float32(calibration.calibration_const)
|
||||
return db.astype("float32", copy=False)
|
||||
|
||||
|
||||
def _iter_windows(width: int, height: int, block_size: int) -> Iterator[Window]:
|
||||
for row_off in range(0, height, block_size):
|
||||
window_height = min(block_size, height - row_off)
|
||||
for col_off in range(0, width, block_size):
|
||||
window_width = min(block_size, width - col_off)
|
||||
yield Window(col_off, row_off, window_width, window_height)
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from gf3_l1a2l2.config import PipelineConfig
|
||||
from gf3_l1a2l2.decompress import extract_compressed_files
|
||||
from gf3_l1a2l2.discovery import discover_scenes
|
||||
from gf3_l1a2l2.errors import GF3PipelineError
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not hasattr(args, "handler"):
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
try:
|
||||
return args.handler(args)
|
||||
except GF3PipelineError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="gf3-l1a2l2", description="GF3 L1A to L2 pipeline")
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
run_parser = subparsers.add_parser("run", help="extract and process GF3 L1A products")
|
||||
run_parser.add_argument("--input", required=True, type=Path, help="input folder containing archives or scenes")
|
||||
run_parser.add_argument("--output", required=True, type=Path, help="output folder")
|
||||
run_parser.add_argument("--dem", type=Path, default=None, help="DEM file for RPC orthorectification")
|
||||
run_parser.add_argument("--x-res", type=float, default=0.0002, help="output x resolution")
|
||||
run_parser.add_argument("--y-res", type=float, default=0.0002, help="output y resolution")
|
||||
run_parser.add_argument("--dst-srs", default="EPSG:4326", help="destination spatial reference")
|
||||
run_parser.add_argument("--block-size", type=int, default=2048, help="calibration block size in pixels")
|
||||
run_parser.add_argument("--workers", type=int, default=1, help="number of scenes to process concurrently")
|
||||
run_parser.add_argument("--skip-decompress", action="store_true", help="do not extract archives before processing")
|
||||
run_parser.add_argument("--overwrite", action="store_true", help="overwrite existing extraction and output files")
|
||||
run_parser.add_argument("--flat-output", action="store_true", help="write all outputs directly to output folder")
|
||||
run_parser.add_argument("--fail-fast", action="store_true", help="stop on the first failed scene or polarization")
|
||||
run_parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
||||
run_parser.set_defaults(handler=_run)
|
||||
|
||||
decompress_parser = subparsers.add_parser("decompress", help="extract .tar.gz and .zip archives")
|
||||
decompress_parser.add_argument("input", type=Path, help="folder containing archives")
|
||||
decompress_parser.add_argument("--overwrite", action="store_true", help="overwrite existing extracted folders")
|
||||
decompress_parser.set_defaults(handler=_decompress)
|
||||
|
||||
discover_parser = subparsers.add_parser("discover", help="list discovered GF3 scenes")
|
||||
discover_parser.add_argument("input", type=Path, help="input folder")
|
||||
discover_parser.set_defaults(handler=_discover)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _run(args: argparse.Namespace) -> int:
|
||||
from gf3_l1a2l2.pipeline import run_pipeline
|
||||
|
||||
dem_path = args.dem or _default_dem_path()
|
||||
config = PipelineConfig(
|
||||
input_dir=args.input,
|
||||
output_dir=args.output,
|
||||
dem_path=dem_path,
|
||||
x_res=args.x_res,
|
||||
y_res=args.y_res,
|
||||
dst_srs=args.dst_srs,
|
||||
block_size=args.block_size,
|
||||
workers=args.workers,
|
||||
skip_decompress=args.skip_decompress,
|
||||
overwrite=args.overwrite,
|
||||
flat_output=args.flat_output,
|
||||
fail_fast=args.fail_fast,
|
||||
log_level=args.log_level,
|
||||
)
|
||||
summary = run_pipeline(config)
|
||||
return 1 if summary.failed else 0
|
||||
|
||||
|
||||
def _decompress(args: argparse.Namespace) -> int:
|
||||
results = extract_compressed_files(args.input, overwrite=args.overwrite)
|
||||
failed = [item for item in results if item.status == "failed"]
|
||||
for item in results:
|
||||
print(f"{item.status}\t{item.archive}\t{item.output_dir}\t{item.message}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
def _discover(args: argparse.Namespace) -> int:
|
||||
scenes = discover_scenes(args.input)
|
||||
for scene in scenes:
|
||||
polarizations = ",".join(scene.available_polarizations)
|
||||
print(f"{scene.root}\tmetadata={scene.metadata_xml.name}\tpol={polarizations}")
|
||||
return 0
|
||||
|
||||
|
||||
def _default_dem_path() -> Path | None:
|
||||
candidates = [
|
||||
Path.cwd() / "GMTED2010.jp2",
|
||||
Path(__file__).resolve().parents[2] / "GMTED2010.jp2",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from gf3_l1a2l2.constants import (
|
||||
DEFAULT_BLOCK_SIZE,
|
||||
DEFAULT_DST_SRS,
|
||||
DEFAULT_X_RES,
|
||||
DEFAULT_Y_RES,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipelineConfig:
|
||||
input_dir: Path
|
||||
output_dir: Path
|
||||
dem_path: Optional[Path] = None
|
||||
x_res: float = DEFAULT_X_RES
|
||||
y_res: float = DEFAULT_Y_RES
|
||||
dst_srs: str = DEFAULT_DST_SRS
|
||||
block_size: int = DEFAULT_BLOCK_SIZE
|
||||
workers: int = 1
|
||||
skip_decompress: bool = False
|
||||
overwrite: bool = False
|
||||
flat_output: bool = False
|
||||
keep_l1b: bool = True
|
||||
fail_fast: bool = False
|
||||
log_level: str = "INFO"
|
||||
|
||||
def resolved(self) -> "PipelineConfig":
|
||||
dem_path = self.dem_path.resolve() if self.dem_path else None
|
||||
return replace(
|
||||
self,
|
||||
input_dir=self.input_dir.resolve(),
|
||||
output_dir=self.output_dir.resolve(),
|
||||
dem_path=dem_path,
|
||||
workers=max(1, int(self.workers)),
|
||||
block_size=max(128, int(self.block_size)),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
POLARIZATIONS = ("HH", "HV", "VH", "VV")
|
||||
|
||||
DEFAULT_X_RES = 0.0002
|
||||
DEFAULT_Y_RES = 0.0002
|
||||
DEFAULT_DST_SRS = "EPSG:4326"
|
||||
DEFAULT_BLOCK_SIZE = 2048
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except Exception: # pragma: no cover - only used when tqdm is unavailable
|
||||
def tqdm(iterable: Iterable, **_: object) -> Iterable:
|
||||
return iterable
|
||||
|
||||
from gf3_l1a2l2.errors import ProcessingError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractionResult:
|
||||
archive: Path
|
||||
output_dir: Path
|
||||
status: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
def extract_compressed_files(
|
||||
folder_path: Path | str,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> list[ExtractionResult]:
|
||||
folder = Path(folder_path).resolve()
|
||||
if not folder.is_dir():
|
||||
raise ProcessingError(f"Input folder does not exist: {folder}")
|
||||
|
||||
archives = sorted(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file()
|
||||
and (path.name.lower().endswith(".tar.gz") or path.suffix.lower() == ".zip")
|
||||
)
|
||||
|
||||
results: list[ExtractionResult] = []
|
||||
if not archives:
|
||||
if logger:
|
||||
logger.info("No .tar.gz or .zip archives found in %s", folder)
|
||||
return results
|
||||
|
||||
for archive in tqdm(archives, desc="Extract archives", unit="file"):
|
||||
output_dir = _archive_output_dir(archive)
|
||||
try:
|
||||
if output_dir.exists():
|
||||
if not overwrite:
|
||||
results.append(ExtractionResult(archive, output_dir, "skipped", "output exists"))
|
||||
if logger:
|
||||
logger.info("Skip existing extraction: %s", output_dir)
|
||||
continue
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if archive.name.lower().endswith(".tar.gz"):
|
||||
_extract_tar_gz(archive, output_dir)
|
||||
elif archive.suffix.lower() == ".zip":
|
||||
_extract_zip(archive, output_dir)
|
||||
else:
|
||||
continue
|
||||
|
||||
results.append(ExtractionResult(archive, output_dir, "done"))
|
||||
if logger:
|
||||
logger.info("Extracted %s -> %s", archive, output_dir)
|
||||
except Exception as exc:
|
||||
results.append(ExtractionResult(archive, output_dir, "failed", str(exc)))
|
||||
if logger:
|
||||
logger.exception("Failed to extract %s", archive)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _archive_output_dir(archive: Path) -> Path:
|
||||
if archive.name.lower().endswith(".tar.gz"):
|
||||
return archive.with_name(archive.name[:-7])
|
||||
return archive.with_suffix("")
|
||||
|
||||
|
||||
def _extract_tar_gz(archive: Path, output_dir: Path) -> None:
|
||||
base = output_dir.resolve()
|
||||
with tarfile.open(archive, "r:gz") as tar:
|
||||
members = tar.getmembers()
|
||||
for member in tqdm(members, desc=f"Extract {archive.name}", leave=False, unit="file"):
|
||||
if member.issym() or member.islnk():
|
||||
raise ProcessingError(f"Refusing to extract link from archive: {member.name}")
|
||||
_ensure_safe_target(base, member.name)
|
||||
tar.extract(member, path=base)
|
||||
|
||||
|
||||
def _extract_zip(archive: Path, output_dir: Path) -> None:
|
||||
base = output_dir.resolve()
|
||||
with zipfile.ZipFile(archive, "r") as zip_ref:
|
||||
names = zip_ref.namelist()
|
||||
for name in tqdm(names, desc=f"Extract {archive.name}", leave=False, unit="file"):
|
||||
_ensure_safe_target(base, name)
|
||||
zip_ref.extract(name, base)
|
||||
|
||||
|
||||
def _ensure_safe_target(base: Path, member_name: str) -> None:
|
||||
target = (base / member_name).resolve()
|
||||
if target == base:
|
||||
return
|
||||
if base not in target.parents:
|
||||
raise ProcessingError(f"Unsafe archive path: {member_name}")
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from gf3_l1a2l2.errors import DiscoveryError
|
||||
from gf3_l1a2l2.models import SceneFiles
|
||||
from gf3_l1a2l2.paths import detect_polarization
|
||||
|
||||
SKIP_DIR_NAMES = {".git", ".venv", ".tmp_tests", "__pycache__", ".pytest_cache", "logs"}
|
||||
|
||||
|
||||
def discover_scenes(input_dir: Path | str, logger: Optional[logging.Logger] = None) -> list[SceneFiles]:
|
||||
root_dir = Path(input_dir).resolve()
|
||||
if not root_dir.is_dir():
|
||||
raise DiscoveryError(f"Input directory does not exist: {root_dir}")
|
||||
|
||||
scenes: list[SceneFiles] = []
|
||||
for directory in _walk_directories(root_dir):
|
||||
try:
|
||||
files = [path for path in directory.iterdir() if path.is_file()]
|
||||
except OSError as exc:
|
||||
if logger:
|
||||
logger.warning("Skipping unreadable directory %s: %s", directory, exc)
|
||||
continue
|
||||
|
||||
metadata_files = sorted(path for path in files if path.name.lower().endswith(".meta.xml"))
|
||||
image_files = sorted(
|
||||
path
|
||||
for path in files
|
||||
if path.suffix.lower() in (".tif", ".tiff") and detect_polarization(path)
|
||||
)
|
||||
|
||||
if not metadata_files or not image_files:
|
||||
continue
|
||||
|
||||
metadata_xml = metadata_files[0]
|
||||
if len(metadata_files) > 1 and logger:
|
||||
logger.warning("Multiple metadata files in %s; using %s", directory, metadata_xml.name)
|
||||
|
||||
images = _collect_by_polarization(image_files, logger)
|
||||
rpcs = _collect_by_polarization(
|
||||
sorted(path for path in files if path.suffix.lower() in (".rpc", ".rpb")),
|
||||
logger,
|
||||
)
|
||||
|
||||
scenes.append(SceneFiles(root=directory, metadata_xml=metadata_xml, images=images, rpcs=rpcs))
|
||||
|
||||
scenes.sort(key=lambda scene: str(scene.root).lower())
|
||||
if logger:
|
||||
logger.info("Discovered %s scene(s) under %s", len(scenes), root_dir)
|
||||
return scenes
|
||||
|
||||
|
||||
def _walk_directories(root_dir: Path) -> Iterable[Path]:
|
||||
stack = [root_dir]
|
||||
while stack:
|
||||
directory = stack.pop()
|
||||
if directory.name in SKIP_DIR_NAMES:
|
||||
continue
|
||||
yield directory
|
||||
try:
|
||||
children = list(directory.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
stack.extend(path for path in children if path.is_dir() and path.name not in SKIP_DIR_NAMES)
|
||||
|
||||
|
||||
def _collect_by_polarization(
|
||||
files: Iterable[Path],
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> dict[str, Path]:
|
||||
collected: dict[str, Path] = {}
|
||||
for path in files:
|
||||
polarization = detect_polarization(path)
|
||||
if not polarization:
|
||||
continue
|
||||
if polarization in collected:
|
||||
if logger:
|
||||
logger.warning(
|
||||
"Duplicate %s file in %s; using %s and ignoring %s",
|
||||
polarization,
|
||||
path.parent,
|
||||
collected[polarization].name,
|
||||
path.name,
|
||||
)
|
||||
continue
|
||||
collected[polarization] = path
|
||||
return collected
|
||||
@@ -0,0 +1,18 @@
|
||||
class GF3PipelineError(Exception):
|
||||
"""Base error for pipeline failures."""
|
||||
|
||||
|
||||
class DiscoveryError(GF3PipelineError):
|
||||
"""Raised when input products cannot be discovered."""
|
||||
|
||||
|
||||
class MetadataError(GF3PipelineError):
|
||||
"""Raised when GF3 metadata cannot be parsed."""
|
||||
|
||||
|
||||
class RpcError(GF3PipelineError):
|
||||
"""Raised when RPC metadata cannot be parsed."""
|
||||
|
||||
|
||||
class ProcessingError(GF3PipelineError):
|
||||
"""Raised when image processing fails."""
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from gf3_l1a2l2.errors import ProcessingError
|
||||
from gf3_l1a2l2.paths import output_name
|
||||
from gf3_l1a2l2.rpc import read_rpc_metadata
|
||||
|
||||
|
||||
def geometric_correction(
|
||||
l1b_path: Path | str,
|
||||
rpc_path: Path | str,
|
||||
output_dir: Path | str,
|
||||
*,
|
||||
dem_path: Optional[Path],
|
||||
x_res: float,
|
||||
y_res: float,
|
||||
dst_srs: str,
|
||||
overwrite: bool = False,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> Path:
|
||||
source = Path(l1b_path)
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
l2_path = out_dir / output_name(source, "L1B", "L2")
|
||||
|
||||
if l2_path.exists() and not overwrite:
|
||||
if logger:
|
||||
logger.info("Skip existing L2: %s", l2_path)
|
||||
return l2_path
|
||||
|
||||
if dem_path and not Path(dem_path).is_file():
|
||||
raise ProcessingError(f"DEM file does not exist: {dem_path}")
|
||||
|
||||
rpc_metadata = read_rpc_metadata(rpc_path)
|
||||
|
||||
try:
|
||||
from osgeo import gdal
|
||||
except Exception as exc:
|
||||
raise ProcessingError("Cannot import osgeo.gdal. Install GDAL Python bindings.") from exc
|
||||
|
||||
if logger:
|
||||
logger.info("Geocorrecting %s -> %s", source, l2_path)
|
||||
|
||||
dataset = gdal.Open(str(source), gdal.GA_Update)
|
||||
if dataset is None:
|
||||
raise ProcessingError(f"GDAL cannot open L1B image: {source}")
|
||||
|
||||
try:
|
||||
dataset.SetMetadata(rpc_metadata, "RPC")
|
||||
transformer_options = []
|
||||
if dem_path:
|
||||
transformer_options.append(f"RPC_DEM={Path(dem_path)}")
|
||||
|
||||
warp_options = gdal.WarpOptions(
|
||||
dstSRS=dst_srs,
|
||||
xRes=x_res,
|
||||
yRes=y_res,
|
||||
rpc=True,
|
||||
multithread=True,
|
||||
transformerOptions=transformer_options,
|
||||
creationOptions=["COMPRESS=LZW", "TILED=YES", "BIGTIFF=YES"],
|
||||
)
|
||||
result = gdal.Warp(str(l2_path), dataset, options=warp_options)
|
||||
if result is None:
|
||||
raise ProcessingError(f"GDAL Warp failed for {source}")
|
||||
result.FlushCache()
|
||||
result = None
|
||||
except ProcessingError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ProcessingError(f"Failed to geocorrect {source}") from exc
|
||||
finally:
|
||||
dataset = None
|
||||
|
||||
return l2_path
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def configure_logging(output_dir: Path, level: str = "INFO") -> logging.Logger:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_dir = output_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("gf3_l1a2l2")
|
||||
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
console.setLevel(getattr(logging, level.upper(), logging.INFO))
|
||||
logger.addHandler(console)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
file_handler = logging.FileHandler(log_dir / f"gf3_l1a2l2_{timestamp}.log", encoding="utf-8")
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from gf3_l1a2l2.constants import POLARIZATIONS
|
||||
from gf3_l1a2l2.errors import MetadataError
|
||||
from gf3_l1a2l2.models import CalibrationMetadata, CalibrationValue
|
||||
from gf3_l1a2l2.paths import detect_polarization
|
||||
|
||||
|
||||
QUALIFY_KEYWORDS = ("qualifyvalue", "qualityvalue")
|
||||
CALIBRATION_KEYWORDS = ("calibrationconst", "calibrationconstant")
|
||||
|
||||
|
||||
def read_calibration_metadata(xml_path: Path | str) -> CalibrationMetadata:
|
||||
path = Path(xml_path)
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
raise MetadataError(f"Invalid metadata XML: {path}") from exc
|
||||
except OSError as exc:
|
||||
raise MetadataError(f"Cannot read metadata XML: {path}") from exc
|
||||
|
||||
qualify_values = _find_values(root, QUALIFY_KEYWORDS) or _legacy_values(root, (17, 13))
|
||||
calibration_values = _find_values(root, CALIBRATION_KEYWORDS) or _legacy_values(root, (18, 3))
|
||||
|
||||
if not qualify_values:
|
||||
raise MetadataError(f"Cannot find QualifyValue values in {path}")
|
||||
if not calibration_values:
|
||||
raise MetadataError(f"Cannot find CalibrationConst values in {path}")
|
||||
|
||||
values: dict[str, CalibrationValue] = {}
|
||||
for polarization in POLARIZATIONS:
|
||||
if polarization not in qualify_values or polarization not in calibration_values:
|
||||
continue
|
||||
values[polarization] = CalibrationValue(
|
||||
qualify_value=qualify_values[polarization],
|
||||
calibration_const=calibration_values[polarization],
|
||||
)
|
||||
|
||||
if not values:
|
||||
raise MetadataError(f"No usable calibration values found in {path}")
|
||||
|
||||
return CalibrationMetadata(values=values)
|
||||
|
||||
|
||||
def _find_values(root: ET.Element, keywords: Iterable[str]) -> Optional[dict[str, float]]:
|
||||
lowered = tuple(keyword.lower() for keyword in keywords)
|
||||
for element in root.iter():
|
||||
name = _local_name(element.tag).lower()
|
||||
if not any(keyword in name for keyword in lowered):
|
||||
continue
|
||||
|
||||
values = _values_by_child_polarization(element)
|
||||
if values:
|
||||
return values
|
||||
|
||||
values = _values_by_child_order(element)
|
||||
if values:
|
||||
return values
|
||||
|
||||
values = _values_from_text(element.text)
|
||||
if values:
|
||||
return values
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _legacy_values(root: ET.Element, indexes: tuple[int, int]) -> Optional[dict[str, float]]:
|
||||
try:
|
||||
node = root
|
||||
for index in indexes:
|
||||
node = list(node)[index]
|
||||
values = [_parse_float(child.text) for child in list(node)[:4]]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if len(values) < 4:
|
||||
return None
|
||||
return dict(zip(POLARIZATIONS, values))
|
||||
|
||||
|
||||
def _values_by_child_polarization(element: ET.Element) -> Optional[dict[str, float]]:
|
||||
values: dict[str, float] = {}
|
||||
for child in list(element):
|
||||
polarization = _element_polarization(child)
|
||||
if not polarization:
|
||||
continue
|
||||
try:
|
||||
values[polarization] = _parse_float(child.text)
|
||||
except ValueError:
|
||||
continue
|
||||
return values if values else None
|
||||
|
||||
|
||||
def _values_by_child_order(element: ET.Element) -> Optional[dict[str, float]]:
|
||||
numeric_values: list[float] = []
|
||||
for child in list(element):
|
||||
try:
|
||||
numeric_values.append(_parse_float(child.text))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(numeric_values) < 4:
|
||||
return None
|
||||
return dict(zip(POLARIZATIONS, numeric_values[:4]))
|
||||
|
||||
|
||||
def _values_from_text(text: Optional[str]) -> Optional[dict[str, float]]:
|
||||
if not text:
|
||||
return None
|
||||
normalized = text.replace(",", " ").replace(";", " ")
|
||||
parts = [part for part in normalized.split() if part]
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
try:
|
||||
values = [_parse_float(part) for part in parts[:4]]
|
||||
except ValueError:
|
||||
return None
|
||||
return dict(zip(POLARIZATIONS, values))
|
||||
|
||||
|
||||
def _element_polarization(element: ET.Element) -> Optional[str]:
|
||||
for value in element.attrib.values():
|
||||
polarization = detect_polarization(value)
|
||||
if polarization:
|
||||
return polarization
|
||||
return detect_polarization(_local_name(element.tag))
|
||||
|
||||
|
||||
def _parse_float(text: Optional[str]) -> float:
|
||||
if text is None:
|
||||
raise ValueError("missing value")
|
||||
value = text.strip().strip("=")
|
||||
if not value or value.upper() == "NULL":
|
||||
return math.nan
|
||||
return float(value)
|
||||
|
||||
|
||||
def _local_name(tag: str) -> str:
|
||||
if "}" in tag:
|
||||
return tag.rsplit("}", 1)[1]
|
||||
return tag
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationValue:
|
||||
qualify_value: float
|
||||
calibration_const: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationMetadata:
|
||||
values: Mapping[str, CalibrationValue]
|
||||
|
||||
def for_polarization(self, polarization: str) -> CalibrationValue:
|
||||
return self.values[polarization]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneFiles:
|
||||
root: Path
|
||||
metadata_xml: Path
|
||||
images: Mapping[str, Path]
|
||||
rpcs: Mapping[str, Path]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.root.name
|
||||
|
||||
@property
|
||||
def available_polarizations(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self.images))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskResult:
|
||||
scene: str
|
||||
polarization: str
|
||||
status: str
|
||||
l1b_path: Optional[Path] = None
|
||||
l2_path: Optional[Path] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineSummary:
|
||||
results: list[TaskResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> int:
|
||||
return sum(1 for item in self.results if item.status == "done")
|
||||
|
||||
@property
|
||||
def skipped(self) -> int:
|
||||
return sum(1 for item in self.results if item.status == "skipped")
|
||||
|
||||
@property
|
||||
def failed(self) -> int:
|
||||
return sum(1 for item in self.results if item.status == "failed")
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.results)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from gf3_l1a2l2.constants import POLARIZATIONS
|
||||
|
||||
|
||||
def detect_polarization(path_or_name: Path | str) -> Optional[str]:
|
||||
name = Path(path_or_name).name.upper()
|
||||
for polarization in POLARIZATIONS:
|
||||
if polarization in name:
|
||||
return polarization
|
||||
return None
|
||||
|
||||
|
||||
def output_name(source: Path, source_level: str, target_level: str) -> str:
|
||||
name = source.name
|
||||
index = name.lower().find(source_level.lower())
|
||||
if index >= 0:
|
||||
return name[:index] + target_level + name[index + len(source_level) :]
|
||||
|
||||
stem = source.stem
|
||||
suffix = "".join(source.suffixes)
|
||||
if suffix and stem.endswith(suffix):
|
||||
stem = stem[: -len(suffix)]
|
||||
return f"{stem}_{target_level}{source.suffix}"
|
||||
|
||||
|
||||
def scene_output_dir(input_dir: Path, output_dir: Path, scene_dir: Path, flat: bool) -> Path:
|
||||
if flat:
|
||||
return output_dir
|
||||
|
||||
try:
|
||||
relative = scene_dir.relative_to(input_dir)
|
||||
except ValueError:
|
||||
relative = Path(scene_dir.name)
|
||||
|
||||
if str(relative) in ("", "."):
|
||||
relative = Path(scene_dir.name)
|
||||
|
||||
safe_name = "_".join(part for part in relative.parts if part not in ("", "."))
|
||||
return output_dir / safe_name
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from gf3_l1a2l2.calibration import radiometric_calibrate_l1a_to_l1b
|
||||
from gf3_l1a2l2.config import PipelineConfig
|
||||
from gf3_l1a2l2.constants import POLARIZATIONS
|
||||
from gf3_l1a2l2.decompress import extract_compressed_files
|
||||
from gf3_l1a2l2.discovery import discover_scenes
|
||||
from gf3_l1a2l2.errors import GF3PipelineError
|
||||
from gf3_l1a2l2.geocorrection import geometric_correction
|
||||
from gf3_l1a2l2.log import configure_logging
|
||||
from gf3_l1a2l2.metadata import read_calibration_metadata
|
||||
from gf3_l1a2l2.models import PipelineSummary, SceneFiles, TaskResult
|
||||
from gf3_l1a2l2.paths import scene_output_dir
|
||||
|
||||
|
||||
def run_pipeline(config: PipelineConfig) -> PipelineSummary:
|
||||
config = config.resolved()
|
||||
logger = configure_logging(config.output_dir, config.log_level)
|
||||
_validate_config(config)
|
||||
|
||||
logger.info("Input: %s", config.input_dir)
|
||||
logger.info("Output: %s", config.output_dir)
|
||||
if config.dem_path:
|
||||
logger.info("DEM: %s", config.dem_path)
|
||||
|
||||
if not config.skip_decompress:
|
||||
extract_compressed_files(config.input_dir, overwrite=config.overwrite, logger=logger)
|
||||
|
||||
scenes = discover_scenes(config.input_dir, logger=logger)
|
||||
if not scenes:
|
||||
logger.warning("No GF3 scenes found under %s", config.input_dir)
|
||||
return PipelineSummary()
|
||||
|
||||
summary = PipelineSummary()
|
||||
for results in _process_scenes(scenes, config, logger):
|
||||
summary.results.extend(results)
|
||||
|
||||
logger.info(
|
||||
"Pipeline finished: total=%s done=%s skipped=%s failed=%s",
|
||||
summary.total,
|
||||
summary.succeeded,
|
||||
summary.skipped,
|
||||
summary.failed,
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def _validate_config(config: PipelineConfig) -> None:
|
||||
if not config.input_dir.is_dir():
|
||||
raise GF3PipelineError(f"Input directory does not exist: {config.input_dir}")
|
||||
if config.dem_path and not config.dem_path.is_file():
|
||||
raise GF3PipelineError(f"DEM file does not exist: {config.dem_path}")
|
||||
if config.x_res <= 0 or config.y_res <= 0:
|
||||
raise GF3PipelineError("x_res and y_res must be positive")
|
||||
|
||||
|
||||
def _process_scenes(
|
||||
scenes: list[SceneFiles],
|
||||
config: PipelineConfig,
|
||||
logger: logging.Logger,
|
||||
) -> Iterable[list[TaskResult]]:
|
||||
if config.workers == 1:
|
||||
for scene in scenes:
|
||||
yield _process_scene(scene, config, logger)
|
||||
return
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=config.workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(_process_scene, scene, config, logger): scene
|
||||
for scene in scenes
|
||||
}
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
scene = future_map[future]
|
||||
try:
|
||||
yield future.result()
|
||||
except Exception as exc:
|
||||
logger.exception("Scene failed: %s", scene.root)
|
||||
if config.fail_fast:
|
||||
raise
|
||||
yield [
|
||||
TaskResult(
|
||||
scene=scene.name,
|
||||
polarization=polarization,
|
||||
status="failed",
|
||||
message=str(exc),
|
||||
)
|
||||
for polarization in scene.available_polarizations
|
||||
]
|
||||
|
||||
|
||||
def _process_scene(
|
||||
scene: SceneFiles,
|
||||
config: PipelineConfig,
|
||||
logger: logging.Logger,
|
||||
) -> list[TaskResult]:
|
||||
logger.info("Processing scene: %s", scene.root)
|
||||
out_dir = scene_output_dir(config.input_dir, config.output_dir, scene.root, config.flat_output)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
metadata = read_calibration_metadata(scene.metadata_xml)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to parse metadata for %s", scene.root)
|
||||
if config.fail_fast:
|
||||
raise
|
||||
return [
|
||||
TaskResult(scene=scene.name, polarization=pol, status="failed", message=str(exc))
|
||||
for pol in scene.available_polarizations
|
||||
]
|
||||
|
||||
results: list[TaskResult] = []
|
||||
for polarization in POLARIZATIONS:
|
||||
image_path = scene.images.get(polarization)
|
||||
if not image_path:
|
||||
continue
|
||||
|
||||
rpc_path = scene.rpcs.get(polarization)
|
||||
if not rpc_path:
|
||||
message = f"Missing RPC/RPB file for {polarization}"
|
||||
logger.error("%s in %s", message, scene.root)
|
||||
results.append(TaskResult(scene=scene.name, polarization=polarization, status="failed", message=message))
|
||||
if config.fail_fast:
|
||||
raise GF3PipelineError(message)
|
||||
continue
|
||||
|
||||
if polarization not in metadata.values:
|
||||
message = f"Missing calibration metadata for {polarization}"
|
||||
logger.error("%s in %s", message, scene.metadata_xml)
|
||||
results.append(TaskResult(scene=scene.name, polarization=polarization, status="failed", message=message))
|
||||
if config.fail_fast:
|
||||
raise GF3PipelineError(message)
|
||||
continue
|
||||
|
||||
try:
|
||||
calibration = metadata.for_polarization(polarization)
|
||||
l1b_path = radiometric_calibrate_l1a_to_l1b(
|
||||
image_path,
|
||||
out_dir,
|
||||
calibration,
|
||||
block_size=config.block_size,
|
||||
overwrite=config.overwrite,
|
||||
logger=logger,
|
||||
)
|
||||
l2_path = geometric_correction(
|
||||
l1b_path,
|
||||
rpc_path,
|
||||
out_dir,
|
||||
dem_path=config.dem_path,
|
||||
x_res=config.x_res,
|
||||
y_res=config.y_res,
|
||||
dst_srs=config.dst_srs,
|
||||
overwrite=config.overwrite,
|
||||
logger=logger,
|
||||
)
|
||||
results.append(
|
||||
TaskResult(
|
||||
scene=scene.name,
|
||||
polarization=polarization,
|
||||
status="done",
|
||||
l1b_path=l1b_path,
|
||||
l2_path=l2_path,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed %s %s", scene.name, polarization)
|
||||
results.append(
|
||||
TaskResult(
|
||||
scene=scene.name,
|
||||
polarization=polarization,
|
||||
status="failed",
|
||||
message=str(exc),
|
||||
)
|
||||
)
|
||||
if config.fail_fast:
|
||||
raise
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from gf3_l1a2l2.errors import RpcError
|
||||
|
||||
|
||||
RPC_KEY_MAP = {
|
||||
"errBias": "ERR_BIAS",
|
||||
"errRand": "ERR_RAND",
|
||||
"lineOffset": "LINE_OFF",
|
||||
"sampOffset": "SAMP_OFF",
|
||||
"latOffset": "LAT_OFF",
|
||||
"longOffset": "LONG_OFF",
|
||||
"heightOffset": "HEIGHT_OFF",
|
||||
"lineScale": "LINE_SCALE",
|
||||
"sampScale": "SAMP_SCALE",
|
||||
"latScale": "LAT_SCALE",
|
||||
"longScale": "LONG_SCALE",
|
||||
"heightScale": "HEIGHT_SCALE",
|
||||
"lineNumCoef": "LINE_NUM_COEFF",
|
||||
"lineDenCoef": "LINE_DEN_COEFF",
|
||||
"sampNumCoef": "SAMP_NUM_COEFF",
|
||||
"sampDenCoef": "SAMP_DEN_COEFF",
|
||||
}
|
||||
|
||||
COEFFICIENT_KEYS = {
|
||||
"lineNumCoef",
|
||||
"lineDenCoef",
|
||||
"sampNumCoef",
|
||||
"sampDenCoef",
|
||||
}
|
||||
|
||||
|
||||
def read_rpc_metadata(rpc_path: Path | str) -> dict[str, str]:
|
||||
path = Path(rpc_path)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError as exc:
|
||||
raise RpcError(f"Cannot read RPC file: {path}") from exc
|
||||
|
||||
metadata: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for source_key, target_key in RPC_KEY_MAP.items():
|
||||
value = _find_rpc_value(text, source_key)
|
||||
if value is None:
|
||||
missing.append(source_key)
|
||||
continue
|
||||
metadata[target_key] = _normalize_rpc_value(source_key, value)
|
||||
|
||||
if missing:
|
||||
raise RpcError(f"RPC file {path} is missing fields: {', '.join(missing)}")
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def _find_rpc_value(text: str, key: str) -> str | None:
|
||||
pattern = re.compile(rf"\b{re.escape(key)}\b\s*[:=]?\s*(.*?);", re.IGNORECASE | re.DOTALL)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _normalize_rpc_value(key: str, raw_value: str) -> str:
|
||||
value = raw_value.strip().strip("=")
|
||||
if key in COEFFICIENT_KEYS:
|
||||
value = value.replace("(", " ").replace(")", " ").replace(",", " ")
|
||||
return " ".join(value.split())
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
<root>
|
||||
<QualifyValue>
|
||||
<HH>1.0</HH><HV>2.0</HV><VH>NULL</VH><VV>4.0</VV>
|
||||
</QualifyValue>
|
||||
<CalibrationConst>
|
||||
<HH>10.0</HH><HV>20.0</HV><VH>30.0</VH><VV>40.0</VV>
|
||||
</CalibrationConst>
|
||||
</root>
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
errBias = -1;
|
||||
errRand = -1;
|
||||
lineOffset = 10;
|
||||
sampOffset = 20;
|
||||
latOffset = 30;
|
||||
longOffset = 40;
|
||||
heightOffset = 50;
|
||||
lineScale = 60;
|
||||
sampScale = 70;
|
||||
latScale = 80;
|
||||
longScale = 90;
|
||||
heightScale = 100;
|
||||
lineNumCoef = (1, 2, 3);
|
||||
lineDenCoef = (4, 5, 6);
|
||||
sampNumCoef = (7, 8, 9);
|
||||
sampDenCoef = (10, 11, 12);
|
||||
@@ -0,0 +1,19 @@
|
||||
import math
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from gf3_l1a2l2.metadata import read_calibration_metadata
|
||||
|
||||
|
||||
class MetadataTests(unittest.TestCase):
|
||||
def test_reads_named_calibration_values(self):
|
||||
path = Path(__file__).parent / "fixtures" / "sample.meta.xml"
|
||||
metadata = read_calibration_metadata(path)
|
||||
|
||||
self.assertEqual(metadata.for_polarization("HH").qualify_value, 1.0)
|
||||
self.assertEqual(metadata.for_polarization("HV").calibration_const, 20.0)
|
||||
self.assertTrue(math.isnan(metadata.for_polarization("VH").qualify_value))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,18 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from gf3_l1a2l2.rpc import read_rpc_metadata
|
||||
|
||||
|
||||
class RpcTests(unittest.TestCase):
|
||||
def test_reads_rpc_metadata(self):
|
||||
path = Path(__file__).parent / "fixtures" / "sample.rpb"
|
||||
metadata = read_rpc_metadata(path)
|
||||
|
||||
self.assertEqual(metadata["ERR_BIAS"], "-1")
|
||||
self.assertEqual(metadata["LINE_OFF"], "10")
|
||||
self.assertEqual(metadata["LINE_NUM_COEFF"], "1 2 3")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user