516 lines
15 KiB
Python
516 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from collections import deque
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
from shapely import contains_xy, line_merge, union_all
|
|
from shapely.geometry import LineString
|
|
|
|
import cm95_bathymetry_build_v1 as v1
|
|
import cm95_bathymetry_build_v2_smooth as v2
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_CONFIG = (
|
|
ROOT
|
|
/ "config"
|
|
/ "bathymetry"
|
|
/ "cm95_bathymetry_north_kyushu_v3_dense.yaml"
|
|
)
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Build dense smooth bathymetry PBF with conservative water-gap repair."
|
|
)
|
|
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
|
|
parser.add_argument("--output-dir", type=Path, default=None)
|
|
return parser.parse_args()
|
|
|
|
|
|
def make_water_grid_mask(
|
|
xs: np.ndarray,
|
|
ys: np.ndarray,
|
|
water_mask,
|
|
) -> np.ndarray:
|
|
mask = np.zeros((len(ys), len(xs)), dtype=bool)
|
|
for iy, y in enumerate(ys):
|
|
mask[iy, :] = contains_xy(
|
|
water_mask,
|
|
xs,
|
|
np.full(xs.shape, float(y), dtype=np.float64),
|
|
)
|
|
return mask
|
|
|
|
|
|
_NEIGHBORS4 = ((-1, 0), (1, 0), (0, -1), (0, 1))
|
|
_NEIGHBORS8 = (
|
|
(-1, -1), (-1, 0), (-1, 1),
|
|
(0, -1), (0, 1),
|
|
(1, -1), (1, 0), (1, 1),
|
|
)
|
|
def _collect_component(
|
|
start_y: int,
|
|
start_x: int,
|
|
missing: np.ndarray,
|
|
visited: np.ndarray,
|
|
) -> list[tuple[int, int]]:
|
|
height, width = missing.shape
|
|
queue = deque([(start_y, start_x)])
|
|
visited[start_y, start_x] = True
|
|
component: list[tuple[int, int]] = []
|
|
|
|
while queue:
|
|
iy, ix = queue.popleft()
|
|
component.append((iy, ix))
|
|
for dy, dx in _NEIGHBORS4:
|
|
ny, nx = iy + dy, ix + dx
|
|
if ny < 0 or nx < 0 or ny >= height or nx >= width:
|
|
continue
|
|
if visited[ny, nx] or not missing[ny, nx]:
|
|
continue
|
|
visited[ny, nx] = True
|
|
queue.append((ny, nx))
|
|
return component
|
|
|
|
|
|
def _component_touches_nonwater(
|
|
component: list[tuple[int, int]],
|
|
water: np.ndarray,
|
|
) -> bool:
|
|
height, width = water.shape
|
|
for iy, ix in component:
|
|
if iy == 0 or ix == 0 or iy == height - 1 or ix == width - 1:
|
|
return True
|
|
for dy, dx in _NEIGHBORS8:
|
|
ny, nx = iy + dy, ix + dx
|
|
if not water[ny, nx]:
|
|
return True
|
|
return False
|
|
def repair_small_water_gaps(
|
|
grid: np.ndarray,
|
|
water: np.ndarray,
|
|
cfg: dict[str, Any],
|
|
) -> tuple[np.ndarray, dict[str, Any]]:
|
|
repaired = grid.copy()
|
|
missing = water & ~np.isfinite(repaired)
|
|
visited = np.zeros(missing.shape, dtype=bool)
|
|
|
|
max_nodes = int(cfg["gap_fill_max_component_nodes"])
|
|
radius = int(cfg["gap_fill_radius_cells"])
|
|
min_neighbors = int(cfg["gap_fill_min_neighbors"])
|
|
|
|
components_total = 0
|
|
components_small = 0
|
|
components_repaired = 0
|
|
components_skipped_land = 0
|
|
components_skipped_large = 0
|
|
nodes_filled = 0
|
|
|
|
height, width = grid.shape
|
|
for iy in range(height):
|
|
for ix in range(width):
|
|
if visited[iy, ix] or not missing[iy, ix]:
|
|
continue
|
|
component = _collect_component(iy, ix, missing, visited)
|
|
components_total += 1
|
|
|
|
if len(component) > max_nodes:
|
|
components_skipped_large += 1
|
|
continue
|
|
components_small += 1
|
|
|
|
if _component_touches_nonwater(component, water):
|
|
components_skipped_land += 1
|
|
continue
|
|
pending: list[tuple[int, int, float]] = []
|
|
component_set = set(component)
|
|
|
|
for cy, cx in component:
|
|
samples: list[tuple[float, float]] = []
|
|
y0 = max(0, cy - radius)
|
|
y1 = min(height - 1, cy + radius)
|
|
x0 = max(0, cx - radius)
|
|
x1 = min(width - 1, cx + radius)
|
|
|
|
for ny in range(y0, y1 + 1):
|
|
for nx in range(x0, x1 + 1):
|
|
if (ny, nx) in component_set:
|
|
continue
|
|
value = repaired[ny, nx]
|
|
if not water[ny, nx] or not math.isfinite(float(value)):
|
|
continue
|
|
dy = ny - cy
|
|
dx = nx - cx
|
|
dist2 = float(dx * dx + dy * dy)
|
|
if dist2 <= 0.0 or dist2 > float(radius * radius):
|
|
continue
|
|
samples.append((dist2, float(value)))
|
|
|
|
if len(samples) < min_neighbors:
|
|
pending = []
|
|
break
|
|
|
|
samples.sort(key=lambda item: item[0])
|
|
samples = samples[:16]
|
|
weights = np.array(
|
|
[1.0 / max(item[0], 1.0) for item in samples],
|
|
dtype=np.float64,
|
|
)
|
|
values = np.array(
|
|
[item[1] for item in samples],
|
|
dtype=np.float64,
|
|
)
|
|
pending.append(
|
|
(
|
|
cy,
|
|
cx,
|
|
float(np.sum(weights * values) / np.sum(weights)),
|
|
)
|
|
)
|
|
if len(pending) != len(component):
|
|
continue
|
|
|
|
for cy, cx, value in pending:
|
|
repaired[cy, cx] = value
|
|
nodes_filled += 1
|
|
components_repaired += 1
|
|
|
|
return repaired, {
|
|
"missing_water_nodes_before": int(np.count_nonzero(missing)),
|
|
"missing_water_nodes_after": int(
|
|
np.count_nonzero(water & ~np.isfinite(repaired))
|
|
),
|
|
"components_total": components_total,
|
|
"components_small": components_small,
|
|
"components_repaired": components_repaired,
|
|
"components_skipped_land": components_skipped_land,
|
|
"components_skipped_large": components_skipped_large,
|
|
"nodes_filled": nodes_filled,
|
|
}
|
|
|
|
|
|
def _triangle_contour(
|
|
points: list[tuple[float, float]],
|
|
values: list[float],
|
|
level: float,
|
|
) -> list[LineString]:
|
|
hits: list[tuple[float, float]] = []
|
|
for a, b in ((0, 1), (1, 2), (2, 0)):
|
|
va, vb = values[a], values[b]
|
|
if (va < level and vb < level) or (va > level and vb > level):
|
|
continue
|
|
hit = v2._edge_intersection(
|
|
points[a],
|
|
points[b],
|
|
va,
|
|
vb,
|
|
level,
|
|
)
|
|
if hit is not None:
|
|
hits.append(hit)
|
|
unique: list[tuple[float, float]] = []
|
|
for hit in hits:
|
|
if not any(
|
|
math.hypot(hit[0] - old[0], hit[1] - old[1]) < 1e-6
|
|
for old in unique
|
|
):
|
|
unique.append(hit)
|
|
|
|
if len(unique) != 2 or unique[0] == unique[1]:
|
|
return []
|
|
return [LineString(unique)]
|
|
|
|
|
|
def _cell_contours(
|
|
x0: float,
|
|
x1: float,
|
|
y0: float,
|
|
y1: float,
|
|
values: tuple[float, float, float, float],
|
|
level: float,
|
|
) -> list[LineString]:
|
|
finite = [math.isfinite(value) for value in values]
|
|
count = sum(finite)
|
|
|
|
if count == 4:
|
|
return v2._march_cell(
|
|
x0,
|
|
x1,
|
|
y0,
|
|
y1,
|
|
values,
|
|
level,
|
|
)
|
|
if count != 3:
|
|
return []
|
|
|
|
all_points = [
|
|
(x0, y0),
|
|
(x1, y0),
|
|
(x1, y1),
|
|
(x0, y1),
|
|
]
|
|
indexes = [index for index, ok in enumerate(finite) if ok]
|
|
triangle_points = [all_points[index] for index in indexes]
|
|
triangle_values = [values[index] for index in indexes]
|
|
if level < min(triangle_values) or level > max(triangle_values):
|
|
return []
|
|
return _triangle_contour(
|
|
triangle_points,
|
|
triangle_values,
|
|
level,
|
|
)
|
|
def extract_dense_contours(
|
|
xs: np.ndarray,
|
|
ys: np.ndarray,
|
|
grid: np.ndarray,
|
|
water_mask,
|
|
cfg: dict[str, Any],
|
|
) -> tuple[dict[float, Any], dict[str, Any]]:
|
|
levels = sorted(float(value) for value in cfg["contour_levels_m"])
|
|
min_segment = float(cfg["min_segment_length_m"])
|
|
simplify_m = float(cfg.get("regional_simplify_m", 0))
|
|
smooth_iterations = int(cfg.get("chaikin_iterations", 0))
|
|
|
|
contours: dict[float, Any] = {}
|
|
stats: dict[str, Any] = {}
|
|
|
|
for level in levels:
|
|
raw_segments: list[LineString] = []
|
|
triangle_fallback_segments = 0
|
|
|
|
for iy in range(len(ys) - 1):
|
|
y0 = float(ys[iy])
|
|
y1 = float(ys[iy + 1])
|
|
for ix in range(len(xs) - 1):
|
|
values = (
|
|
float(grid[iy, ix]),
|
|
float(grid[iy, ix + 1]),
|
|
float(grid[iy + 1, ix + 1]),
|
|
float(grid[iy + 1, ix]),
|
|
)
|
|
finite_count = sum(math.isfinite(value) for value in values)
|
|
if finite_count < 3:
|
|
continue
|
|
|
|
finite_values = [value for value in values if math.isfinite(value)]
|
|
if level < min(finite_values) or level > max(finite_values):
|
|
continue
|
|
|
|
segments = _cell_contours(
|
|
float(xs[ix]),
|
|
float(xs[ix + 1]),
|
|
y0,
|
|
y1,
|
|
values,
|
|
level,
|
|
)
|
|
if finite_count == 3:
|
|
triangle_fallback_segments += len(segments)
|
|
raw_segments.extend(segments)
|
|
if not raw_segments:
|
|
stats[str(level)] = {
|
|
"raw_segments": 0,
|
|
"triangle_fallback_segments": 0,
|
|
"line_parts": 0,
|
|
"length_m": 0.0,
|
|
}
|
|
continue
|
|
|
|
merged = line_merge(union_all(raw_segments))
|
|
parts: list[LineString] = []
|
|
for part in v1.line_parts(merged):
|
|
if part.length < min_segment:
|
|
continue
|
|
smoothed = v2.chaikin_smooth(
|
|
part,
|
|
smooth_iterations,
|
|
)
|
|
if simplify_m > 0:
|
|
smoothed = smoothed.simplify(
|
|
simplify_m,
|
|
preserve_topology=False,
|
|
)
|
|
clipped = smoothed.intersection(water_mask)
|
|
parts.extend(
|
|
candidate
|
|
for candidate in v1.line_parts(clipped)
|
|
if candidate.length >= min_segment
|
|
)
|
|
|
|
if not parts:
|
|
stats[str(level)] = {
|
|
"raw_segments": len(raw_segments),
|
|
"triangle_fallback_segments": triangle_fallback_segments,
|
|
"line_parts": 0,
|
|
"length_m": 0.0,
|
|
}
|
|
continue
|
|
geom = union_all(parts)
|
|
contours[level] = geom
|
|
final_parts = v1.line_parts(geom)
|
|
stats[str(level)] = {
|
|
"raw_segments": len(raw_segments),
|
|
"triangle_fallback_segments": triangle_fallback_segments,
|
|
"line_parts": len(final_parts),
|
|
"length_m": round(
|
|
sum(part.length for part in final_parts),
|
|
3,
|
|
),
|
|
}
|
|
|
|
return contours, stats
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
config_path = args.config.resolve()
|
|
cfg = v1.load_config(config_path)
|
|
output_dir = (
|
|
args.output_dir.resolve()
|
|
if args.output_dir is not None
|
|
else (ROOT / str(cfg["output_dir"])).resolve()
|
|
)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
started = datetime.now(timezone.utc)
|
|
print(f"BUILD {cfg['build_id']}", flush=True)
|
|
print(f"BBOX {cfg['bbox_wgs84']}", flush=True)
|
|
print(f"OUTPUT {output_dir}", flush=True)
|
|
|
|
conn = v1.connect_db()
|
|
try:
|
|
points, sounding_stats = v1.fetch_soundings(conn, cfg)
|
|
print(
|
|
"SOUNDINGS",
|
|
json.dumps(sounding_stats, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
water_mask, water_rows = v1.fetch_water_mask(conn, cfg)
|
|
print(f"WATER_AREAS {water_rows}", flush=True)
|
|
finally:
|
|
conn.close()
|
|
xs, ys, grid, surface_stats = v2.build_idw_grid(
|
|
points,
|
|
water_mask,
|
|
cfg,
|
|
)
|
|
water_grid = make_water_grid_mask(xs, ys, water_mask)
|
|
repaired_grid, gap_stats = repair_small_water_gaps(
|
|
grid,
|
|
water_grid,
|
|
cfg,
|
|
)
|
|
|
|
print(
|
|
"SURFACE",
|
|
json.dumps(surface_stats, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
print(
|
|
"GAP_REPAIR",
|
|
json.dumps(gap_stats, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
|
|
contours, contour_stats = extract_dense_contours(
|
|
xs,
|
|
ys,
|
|
repaired_grid,
|
|
water_mask,
|
|
cfg,
|
|
)
|
|
if not contours:
|
|
raise RuntimeError("no dense contours generated")
|
|
|
|
print(
|
|
"CONTOURS",
|
|
json.dumps(contour_stats, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
|
|
debug_feature_count = v1.write_debug_geojson(
|
|
contours,
|
|
cfg,
|
|
output_dir,
|
|
)
|
|
v1.write_tilejson(cfg, output_dir)
|
|
tile_stats = v1.build_tiles(contours, cfg, output_dir)
|
|
print(
|
|
"TILES",
|
|
json.dumps(tile_stats, ensure_ascii=False),
|
|
flush=True,
|
|
)
|
|
|
|
finished = datetime.now(timezone.utc)
|
|
manifest = {
|
|
"schema": "navsea.cm95.bathymetry-build-result/v3",
|
|
"build_id": str(cfg["build_id"]),
|
|
"generated_at": finished.isoformat(),
|
|
"duration_seconds": round(
|
|
(finished - started).total_seconds(),
|
|
3,
|
|
),
|
|
"dataset_id": int(cfg["dataset_id"]),
|
|
"bbox_wgs84": [float(v) for v in cfg["bbox_wgs84"]],
|
|
"source_layer": str(cfg["source_layer"]),
|
|
"source_kind": str(cfg["source_kind"]),
|
|
"reliability": str(cfg["reliability"]),
|
|
"contour_levels_m": [
|
|
float(value) for value in cfg["contour_levels_m"]
|
|
],
|
|
"major_contours_m": [
|
|
float(value) for value in cfg["major_contours_m"]
|
|
],
|
|
"native_scale_max": int(cfg["native_scale_max"]),
|
|
"water_object_classes": [
|
|
str(value) for value in cfg["water_object_classes"]
|
|
],
|
|
"surface_method": "local_idw_regular_grid_with_small_gap_repair",
|
|
"surface_parameters": {
|
|
key: cfg[key]
|
|
for key in (
|
|
"grid_spacing_m",
|
|
"idw_search_radius_m",
|
|
"idw_max_nearest_m",
|
|
"idw_min_neighbors",
|
|
"idw_max_neighbors",
|
|
"idw_power",
|
|
"gap_fill_max_component_nodes",
|
|
"gap_fill_radius_cells",
|
|
"gap_fill_min_neighbors",
|
|
"chaikin_iterations",
|
|
)
|
|
},
|
|
"sounding_stats": sounding_stats,
|
|
"water_area_rows": water_rows,
|
|
"surface_stats": surface_stats,
|
|
"gap_repair_stats": gap_stats,
|
|
"contour_stats": contour_stats,
|
|
"debug_geojson_features": debug_feature_count,
|
|
"tile_stats": tile_stats,
|
|
}
|
|
(output_dir / "build_manifest.json").write_text(
|
|
json.dumps(
|
|
manifest,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
print(
|
|
"DONE",
|
|
output_dir / "build_manifest.json",
|
|
flush=True,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|