400 lines
12 KiB
Python
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())
|