Files
CM95/tools/cm95_bathymetry_build_v4_tin_dense.py

400 lines
12 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from shapely.geometry import MultiPoint
import cm95_bathymetry_build_v1 as v1
import cm95_bathymetry_build_v2_smooth as v2
import cm95_bathymetry_build_v3_dense as v3
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = (
ROOT
/ "config"
/ "bathymetry"
/ "cm95_bathymetry_north_kyushu_v4_tin_dense.yaml"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build dense bathymetry contours from a TIN-constrained continuous depth surface."
)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def _edge_length(
a: tuple[float, float],
b: tuple[float, float],
) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _triangle_grid_bounds(
coords: list[tuple[float, float]],
xs: np.ndarray,
ys: np.ndarray,
) -> tuple[int, int, int, int] | None:
minx = min(point[0] for point in coords)
maxx = max(point[0] for point in coords)
miny = min(point[1] for point in coords)
maxy = max(point[1] for point in coords)
ix0 = max(0, int(np.searchsorted(xs, minx, side="left")))
ix1 = min(len(xs) - 1, int(np.searchsorted(xs, maxx, side="right") - 1))
iy0 = max(0, int(np.searchsorted(ys, miny, side="left")))
iy1 = min(len(ys) - 1, int(np.searchsorted(ys, maxy, side="right") - 1))
if ix0 > ix1 or iy0 > iy1:
return None
return ix0, ix1, iy0, iy1
def _rasterize_triangle(
grid: np.ndarray,
water_grid: np.ndarray,
xs: np.ndarray,
ys: np.ndarray,
coords: list[tuple[float, float]],
depths: list[float],
) -> int:
bounds = _triangle_grid_bounds(coords, xs, ys)
if bounds is None:
return 0
ix0, ix1, iy0, iy1 = bounds
x0, y0 = coords[0]
x1, y1 = coords[1]
x2, y2 = coords[2]
d0, d1, d2 = depths
denom = (
(y1 - y2) * (x0 - x2)
+ (x2 - x1) * (y0 - y2)
)
if abs(denom) < 1e-12:
return 0
sub_xs = xs[ix0 : ix1 + 1]
sub_ys = ys[iy0 : iy1 + 1]
xx, yy = np.meshgrid(sub_xs, sub_ys)
w0 = (
(y1 - y2) * (xx - x2)
+ (x2 - x1) * (yy - y2)
) / denom
w1 = (
(y2 - y0) * (xx - x2)
+ (x0 - x2) * (yy - y2)
) / denom
w2 = 1.0 - w0 - w1
eps = 1e-9
inside = (
(w0 >= -eps)
& (w1 >= -eps)
& (w2 >= -eps)
)
water = water_grid[iy0 : iy1 + 1, ix0 : ix1 + 1]
target = grid[iy0 : iy1 + 1, ix0 : ix1 + 1]
writable = inside & water & ~np.isfinite(target)
if not np.any(writable):
return 0
values = w0 * d0 + w1 * d1 + w2 * d2
target[writable] = values[writable]
return int(np.count_nonzero(writable))
def build_tin_surface(
points: dict[tuple[float, float], float],
water_mask,
cfg: dict[str, Any],
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
west, south, east, north = [float(value) for value in cfg["bbox_wgs84"]]
minx, miny = v1.lonlat_to_mercator(west, south)
maxx, maxy = v1.lonlat_to_mercator(east, north)
spacing = float(cfg["grid_spacing_m"])
max_edge = float(cfg["max_triangle_edge_m"])
xs = v2._grid_axis(minx, maxx, spacing)
ys = v2._grid_axis(miny, maxy, spacing)
water_grid = v3.make_water_grid_mask(xs, ys, water_mask)
grid = np.full((len(ys), len(xs)), np.nan, dtype=np.float64)
depth_by_coord = dict(points)
triangles = v1.delaunay_triangles(MultiPoint(list(points.keys())))
triangle_total = 0
triangle_accepted = 0
triangle_rejected_long_edge = 0
triangle_rejected_lookup = 0
raster_writes = 0
for triangle in triangles.geoms:
triangle_total += 1
coords = [
(float(x), float(y))
for x, y, *rest in list(triangle.exterior.coords)[:3]
]
try:
depths = [
float(depth_by_coord[v1.coord_key(*coord)])
for coord in coords
]
except KeyError:
triangle_rejected_lookup += 1
continue
longest = max(
_edge_length(coords[0], coords[1]),
_edge_length(coords[1], coords[2]),
_edge_length(coords[2], coords[0]),
)
if longest > max_edge:
triangle_rejected_long_edge += 1
continue
triangle_accepted += 1
raster_writes += _rasterize_triangle(
grid,
water_grid,
xs,
ys,
coords,
depths,
)
finite = np.isfinite(grid)
water_nodes = int(np.count_nonzero(water_grid))
covered_water_nodes = int(np.count_nonzero(finite & water_grid))
uncovered_water_nodes = int(np.count_nonzero(water_grid & ~finite))
stats = {
"grid_width": int(len(xs)),
"grid_height": int(len(ys)),
"grid_nodes": int(grid.size),
"water_nodes": water_nodes,
"covered_water_nodes": covered_water_nodes,
"uncovered_water_nodes": uncovered_water_nodes,
"coverage_ratio_of_water": round(
covered_water_nodes / float(water_nodes),
6,
) if water_nodes else 0.0,
"triangle_total": triangle_total,
"triangle_accepted": triangle_accepted,
"triangle_rejected_long_edge": triangle_rejected_long_edge,
"triangle_rejected_lookup": triangle_rejected_lookup,
"raster_writes": raster_writes,
}
return xs, ys, grid, water_grid, stats
def smooth_surface(
grid: np.ndarray,
water_grid: np.ndarray,
cfg: dict[str, Any],
) -> tuple[np.ndarray, dict[str, Any]]:
iterations = int(cfg.get("surface_smooth_iterations", 0))
strength = float(cfg.get("surface_smooth_strength", 0.0))
if iterations <= 0 or strength <= 0.0:
return grid.copy(), {
"iterations": 0,
"strength": 0.0,
"smoothed_nodes": 0,
}
current = grid.copy()
valid_base = water_grid & np.isfinite(current)
smoothed_nodes = 0
for _ in range(iterations):
values_sum = np.zeros(current.shape, dtype=np.float64)
values_count = np.zeros(current.shape, dtype=np.int16)
for dy, dx in v3._NEIGHBORS8:
src_y0 = max(0, -dy)
src_y1 = current.shape[0] - max(0, dy)
src_x0 = max(0, -dx)
src_x1 = current.shape[1] - max(0, dx)
dst_y0 = max(0, dy)
dst_y1 = current.shape[0] - max(0, -dy)
dst_x0 = max(0, dx)
dst_x1 = current.shape[1] - max(0, -dx)
source = current[src_y0:src_y1, src_x0:src_x1]
source_valid = np.isfinite(source)
target_sum = values_sum[dst_y0:dst_y1, dst_x0:dst_x1]
target_count = values_count[dst_y0:dst_y1, dst_x0:dst_x1]
target_sum[source_valid] += source[source_valid]
target_count[source_valid] += 1
can_smooth = valid_base & (values_count >= 3)
neighbor_mean = np.zeros(current.shape, dtype=np.float64)
neighbor_mean[can_smooth] = (
values_sum[can_smooth]
/ values_count[can_smooth]
)
updated = current.copy()
updated[can_smooth] = (
current[can_smooth] * (1.0 - strength)
+ neighbor_mean[can_smooth] * strength
)
current = updated
smoothed_nodes += int(np.count_nonzero(can_smooth))
return current, {
"iterations": iterations,
"strength": strength,
"smoothed_nodes": smoothed_nodes,
}
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, raw_grid, water_grid, surface_stats = build_tin_surface(
points,
water_mask,
cfg,
)
print(
"TIN_SURFACE",
json.dumps(surface_stats, ensure_ascii=False),
flush=True,
)
smooth_grid, smooth_stats = smooth_surface(
raw_grid,
water_grid,
cfg,
)
print(
"SURFACE_SMOOTH",
json.dumps(smooth_stats, ensure_ascii=False),
flush=True,
)
contours, contour_stats = v3.extract_dense_contours(
xs,
ys,
smooth_grid,
water_mask,
cfg,
)
if not contours:
raise RuntimeError("no v4 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/v4",
"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(value) for value 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": "tin_barycentric_grid_with_light_smoothing",
"surface_parameters": {
key: cfg[key]
for key in (
"grid_spacing_m",
"max_triangle_edge_m",
"surface_smooth_iterations",
"surface_smooth_strength",
"chaikin_iterations",
)
},
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_stats,
"surface_smooth_stats": smooth_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())