Files
CM95/tools/cm95_bathymetry_build_v5_adaptive.py

589 lines
18 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import bisect
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from shapely import line_merge, union_all
from shapely.geometry import LineString, MultiPoint
import cm95_bathymetry_build_v1 as v1
import cm95_bathymetry_build_v2_smooth as v2
import cm95_bathymetry_build_v3_dense as v3
import cm95_bathymetry_build_v4_tin_dense as v4
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = (
ROOT
/ "config"
/ "bathymetry"
/ "cm95_bathymetry_north_kyushu_v5_adaptive.yaml"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build adaptive-TIN dense bathymetry contours."
)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def _percentiles(values: list[float]) -> dict[str, float]:
if not values:
return {}
ordered = sorted(float(value) for value in values)
result: dict[str, float] = {}
for label, fraction in (
("p10", 0.10),
("p25", 0.25),
("p50", 0.50),
("p75", 0.75),
("p90", 0.90),
("p95", 0.95),
):
index = min(
len(ordered) - 1,
int(round((len(ordered) - 1) * fraction)),
)
result[label] = round(ordered[index], 3)
return result
def prepare_adaptive_triangles(
points: dict[tuple[float, float], float],
cfg: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[tuple[float, float], float], dict[str, Any]]:
triangle_geoms = v1.delaunay_triangles(MultiPoint(list(points.keys())))
records: list[dict[str, Any]] = []
local_spacing: dict[tuple[float, float], float] = {}
lookup_failures = 0
for triangle in triangle_geoms.geoms:
coords = [
(float(x), float(y))
for x, y, *rest in list(triangle.exterior.coords)[:3]
]
try:
depths = [
float(points[v1.coord_key(*coord)])
for coord in coords
]
except KeyError:
lookup_failures += 1
continue
edges = (
v4._edge_length(coords[0], coords[1]),
v4._edge_length(coords[1], coords[2]),
v4._edge_length(coords[2], coords[0]),
)
records.append(
{
"coords": coords,
"depths": depths,
"edges": edges,
"longest": max(edges),
}
)
for index, coord in enumerate(coords):
key = v1.coord_key(*coord)
if index == 0:
incident = min(edges[0], edges[2])
elif index == 1:
incident = min(edges[0], edges[1])
else:
incident = min(edges[1], edges[2])
previous = local_spacing.get(key)
if previous is None or incident < previous:
local_spacing[key] = incident
factor = float(cfg["adaptive_edge_factor"])
min_limit = float(cfg["adaptive_edge_min_m"])
max_limit = float(cfg["adaptive_edge_max_m"])
limits: list[float] = []
primary = 0
fallback = 0
rejected_global = 0
for record in records:
spacings = sorted(
local_spacing[v1.coord_key(*coord)]
for coord in record["coords"]
)
median_spacing = float(spacings[1])
adaptive_limit = max(
min_limit,
min(max_limit, median_spacing * factor),
)
record["local_spacing"] = median_spacing
record["adaptive_limit"] = adaptive_limit
if record["longest"] <= adaptive_limit:
triangle_class = "primary"
primary += 1
elif record["longest"] <= max_limit:
triangle_class = "fallback"
fallback += 1
else:
triangle_class = "rejected_global"
rejected_global += 1
record["triangle_class"] = triangle_class
limits.append(adaptive_limit)
spacing_values = list(local_spacing.values())
stats = {
"triangle_total": int(len(triangle_geoms.geoms)),
"triangle_records": len(records),
"triangle_lookup_failures": lookup_failures,
"triangle_primary": primary,
"triangle_fallback": fallback,
"triangle_rejected_global": rejected_global,
"point_spacing_percentiles_m": _percentiles(spacing_values),
"adaptive_limit_percentiles_m": _percentiles(limits),
"adaptive_edge_factor": factor,
"adaptive_edge_min_m": min_limit,
"adaptive_edge_max_m": max_limit,
}
return records, local_spacing, stats
def build_adaptive_tin_surface(
points: dict[tuple[float, float], float],
water_mask,
cfg: dict[str, Any],
) -> tuple[np.ndarray, 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"])
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)
local_spacing_grid = np.full(
(len(ys), len(xs)),
np.nan,
dtype=np.float64,
)
records, _point_spacing, triangle_stats = prepare_adaptive_triangles(
points,
cfg,
)
primary_writes = 0
fallback_writes = 0
fallback_spacing = float(cfg["smooth_spacing_high_m"])
for triangle_class in ("primary", "fallback"):
for record in records:
if record["triangle_class"] != triangle_class:
continue
before = np.isfinite(grid)
written_count = v4._rasterize_triangle(
grid,
water_grid,
xs,
ys,
record["coords"],
record["depths"],
)
written = np.isfinite(grid) & ~before
if triangle_class == "primary":
primary_writes += written_count
if np.any(written):
local_spacing_grid[written] = float(
record["local_spacing"]
)
else:
fallback_writes += written_count
if np.any(written):
local_spacing_grid[written] = fallback_spacing
finite = np.isfinite(grid)
water_nodes = int(np.count_nonzero(water_grid))
covered = int(np.count_nonzero(finite & water_grid))
uncovered = int(np.count_nonzero(water_grid & ~finite))
stats = {
**triangle_stats,
"grid_width": int(len(xs)),
"grid_height": int(len(ys)),
"grid_nodes": int(grid.size),
"water_nodes": water_nodes,
"covered_water_nodes": covered,
"uncovered_water_nodes": uncovered,
"coverage_ratio_of_water": round(
covered / float(water_nodes),
6,
) if water_nodes else 0.0,
"primary_raster_writes": primary_writes,
"fallback_raster_writes": fallback_writes,
"raster_writes": primary_writes + fallback_writes,
}
return xs, ys, grid, water_grid, local_spacing_grid, stats
def adaptive_smooth_surface(
grid: np.ndarray,
water_grid: np.ndarray,
local_spacing_grid: np.ndarray,
cfg: dict[str, Any],
) -> tuple[np.ndarray, dict[str, Any]]:
iterations = int(cfg.get("surface_smooth_iterations", 1))
spacing_low = float(cfg["smooth_spacing_low_m"])
spacing_high = float(cfg["smooth_spacing_high_m"])
strength_low = float(cfg["smooth_strength_low"])
strength_high = float(cfg["smooth_strength_high"])
current = grid.copy()
valid_base = water_grid & np.isfinite(current)
total_smoothed = 0
spacing_for_strength = local_spacing_grid.copy()
spacing_for_strength[
valid_base & ~np.isfinite(spacing_for_strength)
] = spacing_high
denominator = max(spacing_high - spacing_low, 1.0)
t = np.clip(
(spacing_for_strength - spacing_low) / denominator,
0.0,
1.0,
)
strength_grid = strength_low + t * (
strength_high - strength_low
)
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()
local_strength = strength_grid[can_smooth]
updated[can_smooth] = (
current[can_smooth] * (1.0 - local_strength)
+ neighbor_mean[can_smooth] * local_strength
)
current = updated
total_smoothed += int(np.count_nonzero(can_smooth))
valid_strengths = strength_grid[
valid_base & np.isfinite(strength_grid)
]
return current, {
"iterations": iterations,
"spacing_low_m": spacing_low,
"spacing_high_m": spacing_high,
"strength_low": strength_low,
"strength_high": strength_high,
"smoothed_nodes": total_smoothed,
"strength_percentiles": _percentiles(
[float(value) for value in valid_strengths]
),
}
def extract_contours_single_pass(
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"])
segments: dict[float, list[LineString]] = {
level: [] for level in levels
}
fallback_counts: dict[float, int] = {
level: 0 for level in levels
}
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_values = [
value for value in values if math.isfinite(value)
]
if len(finite_values) < 3:
continue
lo = min(finite_values)
hi = max(finite_values)
if lo == hi:
continue
start = bisect.bisect_left(levels, lo)
stop = bisect.bisect_right(levels, hi)
if start >= stop:
continue
finite_count = len(finite_values)
for level in levels[start:stop]:
cell_segments = v3._cell_contours(
float(xs[ix]),
float(xs[ix + 1]),
y0,
y1,
values,
level,
)
segments[level].extend(cell_segments)
if finite_count == 3:
fallback_counts[level] += len(cell_segments)
contours: dict[float, Any] = {}
stats: dict[str, Any] = {}
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))
for level in levels:
raw = segments[level]
if not raw:
stats[str(level)] = {
"raw_segments": 0,
"triangle_fallback_segments": 0,
"line_parts": 0,
"length_m": 0.0,
}
continue
merged = line_merge(union_all(raw))
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),
"triangle_fallback_segments": fallback_counts[level],
"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),
"triangle_fallback_segments": fallback_counts[level],
"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()
cfg = v1.load_config(args.config.resolve())
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,
local_spacing_grid,
surface_stats,
) = build_adaptive_tin_surface(
points,
water_mask,
cfg,
)
print(
"ADAPTIVE_TIN_SURFACE",
json.dumps(surface_stats, ensure_ascii=False),
flush=True,
)
repaired_grid, gap_stats = v3.repair_small_water_gaps(
raw_grid,
water_grid,
cfg,
)
newly_filled = (
np.isfinite(repaired_grid)
& ~np.isfinite(raw_grid)
)
local_spacing_grid[newly_filled] = float(
cfg["smooth_spacing_high_m"]
)
print(
"GAP_REPAIR",
json.dumps(gap_stats, ensure_ascii=False),
flush=True,
)
smooth_grid, smooth_stats = adaptive_smooth_surface(
repaired_grid,
water_grid,
local_spacing_grid,
cfg,
)
print(
"ADAPTIVE_SMOOTH",
json.dumps(smooth_stats, ensure_ascii=False),
flush=True,
)
contours, contour_stats = extract_contours_single_pass(
xs,
ys,
smooth_grid,
water_mask,
cfg,
)
if not contours:
raise RuntimeError("no v5 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/v5",
"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"]
],
"surface_method": "adaptive_tin_barycentric_with_density_smoothing",
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_stats,
"gap_repair_stats": gap_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())