589 lines
18 KiB
Python
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())
|