Files
CM95/tools/cm95_bathymetry_build_v3_dense.py

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())