460 lines
16 KiB
Python
460 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import asdict, dataclass, field, fields, replace
|
|
from typing import Any, Dict
|
|
|
|
import tomllib
|
|
import tomli_w
|
|
|
|
|
|
DEFAULT_CONFIG_PATH = "geodata_config.toml"
|
|
|
|
|
|
@dataclass
|
|
class RawConfig:
|
|
dgm1_dir: str = "raw/dgm1"
|
|
dop20_dir: str = "raw/dop20/jp2"
|
|
citygml_lod1_dir: str = "raw/citygml/lod1"
|
|
citygml_lod2_dir: str = "raw/citygml/lod2"
|
|
hydrolakes_dir: str = "raw/hydrolakes/HydroLAKES_polys_v10_shp"
|
|
|
|
|
|
@dataclass
|
|
class ArchiveConfig:
|
|
dgm1_dir: str = "archive/dgm1"
|
|
dop20_dir: str = "archive/dop20"
|
|
citygml_lod1_dir: str = "archive/citygml/lod1"
|
|
citygml_lod2_dir: str = "archive/citygml/lod2"
|
|
|
|
|
|
@dataclass
|
|
class WorkConfig:
|
|
work_dir: str = "work"
|
|
heightmap_vrt: str = "work/dgm.vrt"
|
|
ortho_vrt: str = "work/dop.vrt"
|
|
|
|
|
|
@dataclass
|
|
class ExportConfig:
|
|
heightmap_dir: str = "export_unity/height_png16"
|
|
ortho_dir: str = "export_unity/ortho_jpg"
|
|
manifest_path: str = "export_unity/tile_index.csv"
|
|
|
|
|
|
@dataclass
|
|
class HeightmapConfig:
|
|
out_res: int = 1025
|
|
resample: str = "bilinear"
|
|
tile_size_m: int = 1000
|
|
use_tile_minmax: bool = True
|
|
|
|
|
|
@dataclass
|
|
class RiverErosionProfileConfig:
|
|
order_field: str = "ORD_STRA"
|
|
invert_order: bool = False
|
|
invert_max_order: int = 10
|
|
min_order: int = 1
|
|
width_base_m: float = 8.0
|
|
width_per_order_m: float = 6.0
|
|
width_min_m: float = 4.0
|
|
width_max_m: float = 90.0
|
|
depth_base_m: float = 0.2
|
|
depth_per_order_m: float = 0.44
|
|
depth_min_m: float = 0.2
|
|
depth_max_m: float = 6.0
|
|
smooth_sigma_m: float = 3.0
|
|
fallback_depth_m: float = 2.0
|
|
flow_distance_field: str = "DIST_DN_KM"
|
|
flow_slope_m_per_km: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class RiverErosionLidarConfig:
|
|
enabled: bool = True
|
|
source_dir: str = "raw/bdom20rgbi"
|
|
classification_water: int = 9
|
|
depth_m: float = 3.5
|
|
bank_slope_sigma: float = 2.0
|
|
fill_holes_radius: int = 3
|
|
|
|
|
|
@dataclass
|
|
class LakeConfig:
|
|
enabled: bool = True
|
|
source_path: str = "raw/hydrolakes/HydroLAKES_polys_v10_shp/HydroLAKES_polys_v10.shp"
|
|
default_depth_m: float = 5.0
|
|
match_tolerance_m: float = 100.0
|
|
|
|
|
|
def _default_river_profile_vr() -> "RiverErosionProfileConfig":
|
|
return RiverErosionProfileConfig(
|
|
order_field="ORD_STRA",
|
|
invert_order=False,
|
|
invert_max_order=10,
|
|
min_order=1,
|
|
width_base_m=8.0,
|
|
width_per_order_m=6.0,
|
|
width_min_m=4.0,
|
|
width_max_m=90.0,
|
|
depth_base_m=0.2,
|
|
depth_per_order_m=0.44,
|
|
depth_min_m=0.2,
|
|
depth_max_m=6.0,
|
|
smooth_sigma_m=3.0,
|
|
fallback_depth_m=2.0,
|
|
flow_distance_field="DIST_DN_KM",
|
|
flow_slope_m_per_km=0.0,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class RiverErosionConfig:
|
|
enabled: bool = True
|
|
source_path: str = "raw/HydroRIVERS_v10_eu_shp/HydroRIVERS_v10_eu.shp"
|
|
source_layer: str = "HydroRIVERS_v10_eu"
|
|
output_dir: str = "export_unity/height_png16_river"
|
|
manifest_vr: str = "export_unity/tile_index_river_vr.csv"
|
|
vr: RiverErosionProfileConfig = field(default_factory=_default_river_profile_vr)
|
|
lidar: RiverErosionLidarConfig = field(default_factory=RiverErosionLidarConfig)
|
|
lakes: LakeConfig = field(default_factory=LakeConfig)
|
|
bridge_source: str = "dom1"
|
|
bridge_height_min_m: float = 2.0
|
|
bridge_height_max_m: float = 12.0
|
|
bridge_near_water_m: float = 20.0
|
|
bridge_min_area_m2: float = 20.0
|
|
bridge_chm_smooth_m: float = 0.0
|
|
bridge_function_codes: list[str] = field(default_factory=lambda: ["1610"])
|
|
bridge_buffer_m: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class OrthoConfig:
|
|
out_res: int = 2048
|
|
jpeg_quality: int = 90
|
|
apply_water_mask: bool = False
|
|
water_blend: float = 0.85
|
|
bridge_blend: float = 0.85
|
|
water_fallback_rgb: tuple[float, float, float] = (30.0, 50.0, 60.0)
|
|
water_mask_threshold: float = 0.1
|
|
river_dir: str = "export_unity/ortho_jpg_river"
|
|
water_color_mode: str = "median"
|
|
|
|
|
|
@dataclass
|
|
class TileKeyConfig:
|
|
tile_size_x: float = 1000.0
|
|
tile_size_y: float = 1000.0
|
|
overlap_x: float = 0.5
|
|
overlap_y: float = 0.5
|
|
enabled: bool = True
|
|
|
|
|
|
@dataclass
|
|
class BuildingConfig:
|
|
out_dir: str = "export_unity/buildings_tiles"
|
|
work_cityjson_dir: str = "work/cityjson_lod2"
|
|
work_rebased_dir: str = "work/cityjson_lod2_local"
|
|
work_glb_dir: str = "work/buildings_glb_tmp"
|
|
triangle_budget_min: int = 200000
|
|
triangle_budget_max: int = 350000
|
|
roof_unlit: bool = True
|
|
|
|
|
|
@dataclass
|
|
class TreeConfig:
|
|
csv_dir: str = "export_unity/trees"
|
|
glb_dir: str = "export_unity/trees_tiles"
|
|
proxy_library: str = "export_unity/tree_proxies.glb"
|
|
grid_res_m: float = 2.0
|
|
min_height_m: float = 2.0
|
|
max_trees: int = 5000
|
|
chunk_grid: int = 4
|
|
proxy_variants: int = 16
|
|
proxy_min_tris: int = 120
|
|
proxy_max_tris: int = 180
|
|
building_buffer_m: float = 1.5
|
|
instancing: bool = False
|
|
|
|
|
|
@dataclass
|
|
class PointCloudConfig:
|
|
"""Configuration for point cloud data sources."""
|
|
lpg_dir: str = "raw/lpg"
|
|
lpo_dir: str = "raw/lpo"
|
|
lpolpg_dir: str = "raw/lpolpg"
|
|
bdom_dir: str = "raw/bdom20rgbi"
|
|
dom1_dir: str = "raw/dom1"
|
|
chunk_size: int = 5_000_000
|
|
use_lpg_validation: bool = True
|
|
use_lpo_refinement: bool = True
|
|
|
|
|
|
@dataclass
|
|
class EnhancedHeightmapConfig:
|
|
"""Configuration for enhanced heightmap export with LPG validation."""
|
|
lpg_validation_threshold_m: float = 0.5
|
|
export_quality_map: bool = True
|
|
quality_map_dir: str = "export_unity/height_quality"
|
|
|
|
|
|
@dataclass
|
|
class EnhancedBuildingConfig:
|
|
"""Configuration for enhanced building export with point cloud fusion."""
|
|
out_dir: str = "export_unity/buildings_enhanced"
|
|
ransac_threshold_m: float = 0.15
|
|
ransac_iterations: int = 1000
|
|
max_roof_planes: int = 6
|
|
min_plane_inliers: int = 50
|
|
use_bdom_roof: bool = True
|
|
use_lpo_refinement: bool = True
|
|
use_dop20_textures: bool = True
|
|
fallback_to_citygml: bool = True
|
|
|
|
|
|
@dataclass
|
|
class StreetFurnitureConfig:
|
|
"""Configuration for street furniture detection."""
|
|
enabled: bool = True
|
|
csv_dir: str = "export_unity/street_furniture"
|
|
min_height_m: float = 0.3
|
|
max_height_m: float = 8.0
|
|
min_confidence: float = 0.6
|
|
use_lpo_refinement: bool = True
|
|
lamp_height_range: tuple = (4.0, 8.0)
|
|
bench_height_range: tuple = (0.4, 1.0)
|
|
sign_height_range: tuple = (2.0, 4.0)
|
|
bollard_height_range: tuple = (0.5, 1.2)
|
|
|
|
|
|
@dataclass
|
|
class EnhancedTreeConfig:
|
|
"""Configuration for enhanced tree detection."""
|
|
csv_dir: str = "export_unity/trees_enhanced"
|
|
glb_dir: str = "export_unity/trees_enhanced_tiles"
|
|
exclude_furniture: bool = True
|
|
use_lpo_refinement: bool = True
|
|
sample_canopy_color: bool = True
|
|
lpo_search_radius_m: float = 5.0
|
|
canopy_sample_radius_factor: float = 0.5
|
|
|
|
|
|
@dataclass
|
|
class SweLodLevelConfig:
|
|
name: str
|
|
tile_size_m: float
|
|
resolution: int
|
|
|
|
|
|
def _default_swe_lods() -> list["SweLodLevelConfig"]:
|
|
return [
|
|
SweLodLevelConfig(name="lod0", tile_size_m=1000.0, resolution=128),
|
|
SweLodLevelConfig(name="lod1", tile_size_m=1000.0, resolution=256),
|
|
SweLodLevelConfig(name="lod2", tile_size_m=500.0, resolution=256),
|
|
SweLodLevelConfig(name="lod3", tile_size_m=250.0, resolution=256),
|
|
SweLodLevelConfig(name="lod4", tile_size_m=125.0, resolution=256),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class SweLodConfig:
|
|
enabled: bool = True
|
|
out_dir: str = "export_swe"
|
|
tile_index_path: str = "export_swe/swe_tile_index.csv"
|
|
manifest_path: str = "export_swe/swe_manifest.json"
|
|
boundary_manifest_path: str = "export_swe/swe_boundaries.json"
|
|
boundary_inflow_mask_dir: str = "raw/water_source_masks"
|
|
source_area_mask_dir: str = "raw/water_source_area_masks"
|
|
source_mask_dir: str = "raw/water_source_masks"
|
|
sink_mask_dir: str = "raw/water_sink_masks"
|
|
boundary_inflow_params_toml: str = "raw/water_source_masks/sources.toml"
|
|
source_area_params_toml: str = "raw/water_source_masks/sources.toml"
|
|
source_params_toml: str = "raw/water_source_masks/sources.toml"
|
|
sink_params_toml: str = "raw/water_sink_masks/sinks.toml"
|
|
height_source: str = "river_erosion"
|
|
height_source_dir: str = ""
|
|
height_source_manifest: str = ""
|
|
lpo_dir: str = ""
|
|
lpo_base_res: int = 1025
|
|
lpo_density_threshold: int = 2
|
|
lpo_height_percentile: float = 95.0
|
|
lpo_min_height_m: float = 0.5
|
|
origin_x: float | None = None
|
|
origin_y: float | None = None
|
|
height_resample: str = "bilinear"
|
|
height_nodata: float | None = None
|
|
porosity_source: str = ""
|
|
porosity_resample: str = "average"
|
|
porosity_nodata: float | None = 0.0
|
|
solid_bias: float = 3.0
|
|
include_buildings: bool = False
|
|
building_height_source: str = ""
|
|
building_resample: str = "average"
|
|
building_nodata: float | None = 0.0
|
|
prefer_float16: bool = True
|
|
lods: list[SweLodLevelConfig] = field(default_factory=_default_swe_lods)
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
raw: RawConfig = field(default_factory=RawConfig)
|
|
archives: ArchiveConfig = field(default_factory=ArchiveConfig)
|
|
work: WorkConfig = field(default_factory=WorkConfig)
|
|
export: ExportConfig = field(default_factory=ExportConfig)
|
|
heightmap: HeightmapConfig = field(default_factory=HeightmapConfig)
|
|
river_erosion: RiverErosionConfig = field(default_factory=RiverErosionConfig)
|
|
ortho: OrthoConfig = field(default_factory=OrthoConfig)
|
|
tile_key: TileKeyConfig = field(default_factory=TileKeyConfig)
|
|
buildings: BuildingConfig = field(default_factory=BuildingConfig)
|
|
trees: TreeConfig = field(default_factory=TreeConfig)
|
|
# Enhanced pipeline configs
|
|
pointcloud: PointCloudConfig = field(default_factory=PointCloudConfig)
|
|
heightmap_enhanced: EnhancedHeightmapConfig = field(default_factory=EnhancedHeightmapConfig)
|
|
buildings_enhanced: EnhancedBuildingConfig = field(default_factory=EnhancedBuildingConfig)
|
|
street_furniture: StreetFurnitureConfig = field(default_factory=StreetFurnitureConfig)
|
|
trees_enhanced: EnhancedTreeConfig = field(default_factory=EnhancedTreeConfig)
|
|
swe_lod: SweLodConfig = field(default_factory=SweLodConfig)
|
|
|
|
@classmethod
|
|
def default(cls) -> "Config":
|
|
return cls()
|
|
|
|
@classmethod
|
|
def load(cls, path: str = DEFAULT_CONFIG_PATH) -> "Config":
|
|
with open(path, "rb") as f:
|
|
data = tomllib.load(f)
|
|
return cls.from_dict(data)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "Config":
|
|
return cls(
|
|
raw=RawConfig(**_filter_kwargs(RawConfig, data.get("raw", {}))),
|
|
archives=ArchiveConfig(**_filter_kwargs(ArchiveConfig, data.get("archives", {}))),
|
|
work=WorkConfig(**_filter_kwargs(WorkConfig, data.get("work", {}))),
|
|
export=ExportConfig(**_filter_kwargs(ExportConfig, data.get("export", {}))),
|
|
heightmap=HeightmapConfig(**_filter_kwargs(HeightmapConfig, data.get("heightmap", {}))),
|
|
river_erosion=_river_erosion_from_dict(data.get("river_erosion", {})),
|
|
ortho=OrthoConfig(**_filter_kwargs(OrthoConfig, data.get("ortho", {}))),
|
|
tile_key=TileKeyConfig(**_filter_kwargs(TileKeyConfig, data.get("tile_key", {}))),
|
|
buildings=BuildingConfig(**_filter_kwargs(BuildingConfig, data.get("buildings", {}))),
|
|
trees=TreeConfig(**_filter_kwargs(TreeConfig, data.get("trees", {}))),
|
|
# Enhanced pipeline configs (with defaults for backward compat)
|
|
pointcloud=PointCloudConfig(**_filter_kwargs(PointCloudConfig, data.get("pointcloud", {}))),
|
|
heightmap_enhanced=EnhancedHeightmapConfig(**_filter_kwargs(
|
|
EnhancedHeightmapConfig,
|
|
data.get("heightmap_enhanced", {}),
|
|
)),
|
|
buildings_enhanced=EnhancedBuildingConfig(**_filter_kwargs(
|
|
EnhancedBuildingConfig,
|
|
data.get("buildings_enhanced", {}),
|
|
)),
|
|
street_furniture=StreetFurnitureConfig(**_filter_kwargs(
|
|
StreetFurnitureConfig,
|
|
_coerce_ranges(data.get("street_furniture", {})),
|
|
)),
|
|
trees_enhanced=EnhancedTreeConfig(**_filter_kwargs(
|
|
EnhancedTreeConfig,
|
|
data.get("trees_enhanced", {}),
|
|
)),
|
|
swe_lod=_swe_lod_from_dict(data.get("swe_lod", {})),
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
def save(self, path: str = DEFAULT_CONFIG_PATH) -> None:
|
|
payload = tomli_w.dumps(self.to_dict())
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(payload)
|
|
|
|
def with_overrides(self, raw_dgm1_path: str | None = None, raw_dop20_path: str | None = None) -> "Config":
|
|
cfg = self
|
|
if raw_dgm1_path:
|
|
cfg = replace(cfg, raw=replace(cfg.raw, dgm1_dir=raw_dgm1_path))
|
|
if raw_dop20_path:
|
|
cfg = replace(cfg, raw=replace(cfg.raw, dop20_dir=raw_dop20_path))
|
|
return cfg
|
|
|
|
|
|
def ensure_default_config(path: str = DEFAULT_CONFIG_PATH) -> Config:
|
|
if not os.path.exists(path):
|
|
cfg = Config.default()
|
|
cfg.save(path)
|
|
return cfg
|
|
return Config.load(path)
|
|
|
|
|
|
def _filter_kwargs(cls: type, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
allowed = {field.name for field in fields(cls)}
|
|
return {key: value for key, value in data.items() if key in allowed}
|
|
|
|
|
|
def _coerce_ranges(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
out = dict(data)
|
|
for key in ("lamp_height_range", "bench_height_range", "sign_height_range", "bollard_height_range"):
|
|
value = out.get(key)
|
|
if isinstance(value, list):
|
|
out[key] = tuple(value)
|
|
value = out.get("water_fallback_rgb")
|
|
if isinstance(value, list):
|
|
out["water_fallback_rgb"] = tuple(value)
|
|
return out
|
|
|
|
|
|
def _river_erosion_from_dict(data: Dict[str, Any]) -> RiverErosionConfig:
|
|
if not isinstance(data, dict):
|
|
return RiverErosionConfig()
|
|
base = _filter_kwargs(RiverErosionConfig, data)
|
|
base.pop("vr", None)
|
|
base.pop("lidar", None)
|
|
base.pop("lakes", None)
|
|
vr_cfg = RiverErosionProfileConfig(**_filter_kwargs(RiverErosionProfileConfig, data.get("vr", {})))
|
|
lidar_cfg = RiverErosionLidarConfig(**_filter_kwargs(RiverErosionLidarConfig, data.get("lidar", {})))
|
|
lakes_cfg = LakeConfig(**_filter_kwargs(LakeConfig, data.get("lakes", {})))
|
|
return RiverErosionConfig(**base, vr=vr_cfg, lidar=lidar_cfg, lakes=lakes_cfg)
|
|
|
|
|
|
def _swe_lod_from_dict(data: Dict[str, Any]) -> SweLodConfig:
|
|
if not isinstance(data, dict):
|
|
return SweLodConfig()
|
|
|
|
base = _filter_kwargs(SweLodConfig, data)
|
|
for key in ("origin_x", "origin_y", "height_nodata", "porosity_nodata", "building_nodata"):
|
|
value = base.get(key)
|
|
if isinstance(value, str) and value.strip() == "":
|
|
base[key] = None
|
|
# In SWE, 0.0 is a valid value for porosity/building rasters. Treating it as
|
|
# nodata causes GDAL to remap real zeros and emit warning floods.
|
|
if base.get("porosity_nodata") == 0 or base.get("porosity_nodata") == 0.0:
|
|
base["porosity_nodata"] = None
|
|
if base.get("building_nodata") == 0 or base.get("building_nodata") == 0.0:
|
|
base["building_nodata"] = None
|
|
raw_lods = data.get("lods")
|
|
lods: list[SweLodLevelConfig] = []
|
|
if isinstance(raw_lods, list):
|
|
for entry in raw_lods:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = entry.get("name", "")
|
|
tile_size_m = entry.get("tile_size_m")
|
|
resolution = entry.get("resolution")
|
|
if tile_size_m is None or resolution is None:
|
|
continue
|
|
lods.append(
|
|
SweLodLevelConfig(
|
|
name=str(name),
|
|
tile_size_m=float(tile_size_m),
|
|
resolution=int(resolution),
|
|
)
|
|
)
|
|
|
|
if lods:
|
|
base["lods"] = lods
|
|
|
|
return SweLodConfig(**base)
|