feat: add independent derived bathymetry PBF build

This commit is contained in:
kaotei
2026-10-01 12:02:44 +09:00
parent ed53864598
commit ba6955bf96
7 changed files with 1041 additions and 9 deletions

View File

@@ -18,7 +18,7 @@ Geometry Audit: OVERALL: PASS
Non-Geometry Audit: OVERALL: PASS Non-Geometry Audit: OVERALL: PASS
``` ```
PBF 当前继续冻结。 Importer 冻结基线继续保持不变;PBF 已在后续下游阶段由用户明确启动,PBF 工作不得反向修改冻结 importer。
--- ---
@@ -343,12 +343,14 @@ cm93-mysql-importer-baseline-v1
当前: 当前:
```text ```text
PBF = OUT OF SCOPE / FROZEN Importer scope: PBF = OUT OF SCOPE
Downstream PBF stage: ACTIVE
``` ```
除非用户明确启动 PBF 阶段: 用户已明确启动独立 PBF 下游阶段,因此允许在 `src/cm95pbf/`、`tools/`、`config/`、`web/` 和独立 build 输出中继续实现 PBF 能力。
- 不新增 PBF schema; 仍然禁止:
- 不新增 PBF exporter;
- 不修改 importer 为 PBF 服务; - 为 PBF 修改冻结的 parser / geometry / importer 主流程;
- 不以 PBF 需求反向破坏当前 MySQL/raw 数据模型。 - 为 PBF 改坏当前 MySQL/raw 数据模型;
- 把 PBF 派生结果回写覆盖原始 CM93/CM95 证据。

View File

@@ -21,7 +21,7 @@ CM93 source
→ audit / traceability → audit / traceability
``` ```
PBF 当前继续冻结,不属于本阶段范围。 本文 importer 冻结基线本身不因下游 PBF 工作而改变;PBF 已在后续独立阶段单独启动,见本文末尾。
--- ---
@@ -464,4 +464,23 @@ tag 应保持不可变。
- 为了下游显示而损失 raw CM93 数据; - 为了下游显示而损失 raw CM93 数据;
- 对原始 CM93 文件进行写回。 - 对原始 CM93 文件进行写回。
PBF 只有在单独明确启动下一阶段后才进入设计和实现。 该限制针对 importer 冻结阶段;后续 PBF 阶段已由用户明确启动,但 PBF 实现不得反向破坏已冻结的 importer / MySQL / raw 数据模型。
---
## 13. 后续独立 PBF 阶段
CM95 → NavSea 的 PBF 下游阶段已经单独启动;importer 冻结基线继续保持不变。
2026-10-01 新增独立 bathymetry PBF 首批 build:
```text
config/bathymetry/cm95_bathymetry_north_kyushu_v1.yaml
tools/cm95_bathymetry_build_v1.py
web/bathymetry_test.html
web/bathymetry_test_server.py
docs/CM95_Bathymetry独立PBF_v1.md
```
该 bathymetry PBF 是第二个独立 vector source,等深线由 SOUNDG 测深点派生,标记为 `reliability=reference_low`,不与基础 PBF 合并。

View File

@@ -0,0 +1,24 @@
schema: navsea.cm95.bathymetry-build/v1
build_id: cm95-bathymetry-north-kyushu-v1
dataset_id: 1
bbox_wgs84: [129.7, 33.1, 130.6, 33.8]
source_layer: bathymetry_contour
source_kind: derived_sounding_tin
reliability: reference_low
native_scale_max: 100000
water_object_classes: [DEPARE, DRGARE]
contour_levels_m: [2, 5, 10, 20, 30, 50]
max_triangle_edge_m: 4000
min_segment_length_m: 20
regional_simplify_m: 2
minzoom: 8
maxzoom: 12
extent: 4096
tile_buffer_px: 8
simplify_m_by_zoom:
"8": 80
"9": 40
"10": 20
"11": 10
"12": 5
output_dir: build/bathymetry/cm95-bathymetry-north-kyushu-v1

View File

@@ -0,0 +1,199 @@
# CM95 Bathymetry 独立 PBF v1
日期:2026-10-01
## 1. 目的
本阶段新增一套与现有 CM95 基础 PBF 完全独立的 bathymetry PBF。
客户端需要显示等深线时增加第二个 vector source;关闭等深线时可以不加载该 source。
现有基础 PBF 的 schema、tile 和发布生命周期不因本 build 改变。
本 PBF 中的等深线属于 NavSea/本项目自行生产的派生参考数据,不视为 CM95 原始等深线、官方水路资料或安全航行依据。
## 2. 首批 build
Build ID:
```text
cm95-bathymetry-north-kyushu-v1
```
范围:
```text
W 129.7
S 33.1
E 130.6
N 33.8
```
对应北九州、唐津—福冈附近的首批验证区域。
生成配置:
```text
config/bathymetry/cm95_bathymetry_north_kyushu_v1.yaml
```
生成器:
```text
tools/cm95_bathymetry_build_v1.py
```
本地生成目录(build/ 被 Git 忽略):
```text
build/bathymetry/cm95-bathymetry-north-kyushu-v1/
```
目录内包含:
```text
build_manifest.json
tilejson.json
contours.geojson
pbf/{z}/{x}/{y}.pbf
```
## 3. 输入与生成规则
首批 build 只使用 Dataset ID=1 的 SOUNDG sounding points 作为连续水深面的数值输入。
为避免低分辨率点拉坏近岸插值,当前只使用:
```text
native_scale <= 100000
```
同坐标存在重复 sounding 时取较浅值。
生成流程:
```text
SOUNDG sounding points
-> Web Mercator
-> Delaunay / TIN
-> 丢弃最大边 > 4000m 的三角形
-> 三角形内线性等值线
-> DEPARE + DRGARE 水域 union 裁剪
-> 区域级合并/简化
-> 按 XYZ tile 切片
-> MVT/PBF
```
首批 contour levels:
```text
2m / 5m / 10m / 20m / 30m / 50m
```
先生成区域连续线,再切 tile;不得每个 tile 独立插值,以避免 tile seam。
## 4. PBF contract
独立 source-layer:
```text
bathymetry_contour
```
每个 LineString feature 至少包含:
```text
depth_m
source_kind = derived_sounding_tin
reliability = reference_low
build_id = cm95-bathymetry-north-kyushu-v1
```
首批 zoom:
```text
z8-z12
extent = 4096
```
粗 zoom 中亚像素短线可能被 MVT encoder 丢弃。生成器不把这种情况静默处理:
实际 drop 数写入 `build_manifest.json`,并以 decode 后实际 feature 数作为 build 真值。
## 5. 首批结果
输入:
```text
raw sounding rows 10035
unique sounding points 10028
duplicate rows 7
water area rows 1884
```
TIN:
```text
triangles total 20028
accepted 19773
rejected long edge 255
```
PBF:
```text
tiles 93
actual feature instances 15529
encoder dropped 76
total bytes 612694
```
其中 encoder drop 主要集中在 z8 的亚像素短线。
全量解码审计结果:
```text
tiles 93 / manifest 93
features 15529 / manifest 15529
geometry types: LineString 15529
bad layers: 0
bad depth values: 0
bad properties: 0
AUDIT PASS
```
## 6. 本地测试
测试页:
```text
web/bathymetry_test.html
```
测试 server:
```text
web/bathymetry_test_server.py
```
启动:
```powershell
python web\bathymetry_test_server.py --host 0.0.0.0 --port 18890
```
浏览:
```text
http://127.0.0.1:18890/
```
测试页使用在线 GSI raster 作为普通背景,只加载这一套新的 bathymetry vector source,因此不依赖现有 CM95 大 build。
## 7. 当前边界
本 v1 只验证“独立 bathymetry PBF”生产链可行,不代表全国参数已经冻结。
当前参数仍需通过实际海图目视检查决定是否调整,重点包括:
- 4000m 最大 TIN 边长是否过大或过小;
- 2/5/10/20/30/50m 层级是否合适;
- 岛屿、港池和狭窄水道附近是否存在不合理跨越;
- DEPARE/DRGARE 水域 mask 是否存在局部缺口;
- z8-z12 的简化强度是否合适;
- 是否需要在后续 build 引入额外约束数据。
在这些项目通过人工检查前,`reliability=reference_low` 必须保持不变。

View File

@@ -0,0 +1,572 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import mapbox_vector_tile
import pymysql
import yaml
from dotenv import load_dotenv
from shapely import delaunay_triangles, line_merge, union_all, wkb
from shapely.geometry import LineString, MultiPoint, box, mapping
from shapely.ops import transform
from cm95pbf.policy import (
WEB_MERCATOR_HALF_WORLD,
lonlat_to_mercator,
mercator_to_lonlat,
xyz_tile_bounds,
)
DEFAULT_CONFIG = ROOT / "config" / "bathymetry" / "cm95_bathymetry_north_kyushu_v1.yaml"
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Build an independent low-reliability bathymetry contour PBF from CM95 sounding points."
)
p.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
p.add_argument("--output-dir", type=Path, default=None)
return p.parse_args()
def load_config(path: Path) -> dict[str, Any]:
obj = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(obj, dict):
raise RuntimeError(f"invalid config: {path}")
return obj
def connect_db():
load_dotenv(ROOT / ".env")
return pymysql.connect(
host=os.getenv("CM93_DB_HOST", "127.0.0.1"),
port=int(os.getenv("CM93_DB_PORT", "3306")),
user=os.getenv("CM93_DB_USER", "root"),
password=os.getenv("CM93_DB_PASSWORD"),
database=os.getenv("CM93_DB_NAME", "cm93_chart"),
charset=os.getenv("CM93_DB_CHARSET", "utf8mb4"),
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
connect_timeout=20,
)
def envelope_wkt(bbox_wgs84: list[float]) -> str:
west, south, east, north = [float(v) for v in bbox_wgs84]
return (
"POLYGON(("
f"{west:.17g} {south:.17g},"
f"{east:.17g} {south:.17g},"
f"{east:.17g} {north:.17g},"
f"{west:.17g} {north:.17g},"
f"{west:.17g} {south:.17g}"
"))"
)
def coord_key(x: float, y: float) -> tuple[float, float]:
return round(float(x), 3), round(float(y), 3)
def fetch_soundings(conn, cfg: dict[str, Any]) -> tuple[dict[tuple[float, float], float], dict[str, int]]:
bbox = cfg["bbox_wgs84"]
native_scale_max = int(cfg["native_scale_max"])
sql = """
SELECT ST_X(s.geom) AS lon, ST_Y(s.geom) AS lat, s.depth, f.native_scale
FROM chart_sounding_point s FORCE INDEX (spx_sounding_geom)
STRAIGHT_JOIN chart_feature f ON f.id=s.feature_id
WHERE MBRContains(ST_GeomFromText(%s, 4326), s.geom)
AND f.dataset_id=%s
AND f.native_scale <= %s
"""
with conn.cursor() as cur:
cur.execute(sql, (envelope_wkt(bbox), int(cfg["dataset_id"]), native_scale_max))
rows = cur.fetchall()
points: dict[tuple[float, float], float] = {}
duplicate_count = 0
for row in rows:
depth = float(row["depth"])
if not math.isfinite(depth) or depth < 0:
continue
mx, my = lonlat_to_mercator(float(row["lon"]), float(row["lat"]))
key = coord_key(mx, my)
previous = points.get(key)
if previous is not None:
duplicate_count += 1
if depth < previous:
points[key] = depth
else:
points[key] = depth
if len(points) < 3:
raise RuntimeError("fewer than three usable sounding points")
return points, {
"raw_sounding_rows": len(rows),
"unique_sounding_points": len(points),
"duplicate_sounding_rows": duplicate_count,
}
def fetch_water_mask(conn, cfg: dict[str, Any]):
classes = [str(v) for v in cfg.get("water_object_classes", ["DEPARE"])]
if not classes:
raise RuntimeError("water_object_classes must not be empty")
placeholders = ",".join(["%s"] * len(classes))
sql = f"""
SELECT ST_AsWKB(g.geom) AS geom_wkb
FROM chart_area_geometry g FORCE INDEX (spx_area_geom)
STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id
WHERE MBRIntersects(g.geom, ST_GeomFromText(%s, 4326))
AND f.dataset_id=%s
AND f.native_scale <= %s
AND f.object_class IN ({placeholders})
"""
params: list[Any] = [
envelope_wkt(cfg["bbox_wgs84"]),
int(cfg["dataset_id"]),
int(cfg["native_scale_max"]),
*classes,
]
with conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
west, south, east, north = [float(v) for v in cfg["bbox_wgs84"]]
west_m, south_m = lonlat_to_mercator(west, south)
east_m, north_m = lonlat_to_mercator(east, north)
region_box = box(west_m, south_m, east_m, north_m)
geoms = []
for row in rows:
geom = wkb.loads(bytes(row["geom_wkb"]))
if geom.is_empty:
continue
geom_m = transform(lambda x, y, z=None: lonlat_to_mercator(x, y), geom)
clipped = geom_m.intersection(region_box)
if not clipped.is_empty:
geoms.append(clipped)
if not geoms:
raise RuntimeError("no DEPARE/DRGARE water geometry found in build bbox")
return union_all(geoms), len(rows)
def edge_length(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def contour_segment(
coords: list[tuple[float, float]],
depths: list[float],
level: float,
) -> LineString | None:
eps = 1e-9
hits: list[tuple[float, float]] = []
for i, j in ((0, 1), (1, 2), (2, 0)):
p1, p2 = coords[i], coords[j]
d1, d2 = depths[i], depths[j]
if abs(d1 - level) <= eps and abs(d2 - level) <= eps:
hits.extend((p1, p2))
continue
if abs(d1 - d2) <= eps:
continue
if (level - d1) * (level - d2) > eps:
continue
t = (level - d1) / (d2 - d1)
if -eps <= t <= 1.0 + eps:
t = max(0.0, min(1.0, t))
hits.append((p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])))
unique: list[tuple[float, float]] = []
seen: set[tuple[float, float]] = set()
for hit in hits:
key = coord_key(*hit)
if key not in seen:
seen.add(key)
unique.append(hit)
if len(unique) < 2:
return None
best_pair = None
best_distance = -1.0
for i in range(len(unique)):
for j in range(i + 1, len(unique)):
distance = edge_length(unique[i], unique[j])
if distance > best_distance:
best_distance = distance
best_pair = (unique[i], unique[j])
if best_pair is None or best_distance <= 0:
return None
return LineString(best_pair)
def line_parts(geom) -> list[LineString]:
if geom is None or geom.is_empty:
return []
if geom.geom_type == "LineString":
return [geom]
if geom.geom_type == "MultiLineString":
return list(geom.geoms)
out: list[LineString] = []
for part in getattr(geom, "geoms", []):
out.extend(line_parts(part))
return out
def extract_contours(
points: dict[tuple[float, float], float],
water_mask,
cfg: dict[str, Any],
) -> tuple[dict[float, Any], dict[str, Any]]:
levels = sorted(float(v) for v in cfg["contour_levels_m"])
max_edge = float(cfg["max_triangle_edge_m"])
min_segment = float(cfg["min_segment_length_m"])
regional_simplify = float(cfg.get("regional_simplify_m", 0))
depth_by_coord = dict(points)
triangles = delaunay_triangles(MultiPoint(list(points.keys())))
segments: dict[float, list[LineString]] = {level: [] for level in levels}
triangle_total = 0
triangle_accepted = 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 = [depth_by_coord[coord_key(*coord)] for coord in coords]
except KeyError:
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:
continue
triangle_accepted += 1
lo, hi = min(depths), max(depths)
for level in levels:
if level < lo or level > hi:
continue
segment = contour_segment(coords, depths, level)
if segment is not None and segment.length >= min_segment:
segments[level].append(segment)
contours: dict[float, Any] = {}
per_level: dict[str, Any] = {}
for level in levels:
raw = segments[level]
if not raw:
per_level[str(level)] = {"raw_segments": 0, "line_parts": 0, "length_m": 0.0}
continue
merged = line_merge(union_all(raw))
clipped = merged.intersection(water_mask)
parts = [part for part in line_parts(clipped) if part.length >= min_segment]
if regional_simplify > 0:
parts = [
part.simplify(regional_simplify, preserve_topology=False)
for part in parts
if not part.is_empty
]
parts = [part for part in parts if not part.is_empty and part.length >= min_segment]
if not parts:
per_level[str(level)] = {
"raw_segments": len(raw),
"line_parts": 0,
"length_m": 0.0,
}
continue
geom = union_all(parts)
contours[level] = geom
per_level[str(level)] = {
"raw_segments": len(raw),
"line_parts": len(line_parts(geom)),
"length_m": round(sum(part.length for part in line_parts(geom)), 3),
}
return contours, {
"triangle_total": triangle_total,
"triangle_accepted": triangle_accepted,
"triangle_rejected_long_edge": triangle_total - triangle_accepted,
"levels": per_level,
}
def mercator_tile_xy(mx: float, my: float, z: int) -> tuple[int, int]:
n = 1 << int(z)
world = WEB_MERCATOR_HALF_WORLD * 2.0
x = int(math.floor((mx + WEB_MERCATOR_HALF_WORLD) / world * n))
y = int(math.floor((WEB_MERCATOR_HALF_WORLD - my) / world * n))
return max(0, min(n - 1, x)), max(0, min(n - 1, y))
def tile_range(bbox_wgs84: list[float], z: int) -> tuple[range, range]:
west, south, east, north = [float(v) for v in bbox_wgs84]
west_m, south_m = lonlat_to_mercator(west, south)
east_m, north_m = lonlat_to_mercator(east, north)
x0, y1 = mercator_tile_xy(west_m, south_m, z)
x1, y0 = mercator_tile_xy(east_m, north_m, z)
return range(min(x0, x1), max(x0, x1) + 1), range(min(y0, y1), max(y0, y1) + 1)
def line_survives_mvt_quantization(geom: LineString, bounds, extent: int) -> bool:
minx, miny, maxx, maxy = bounds
sx = float(extent) / float(maxx - minx)
sy = float(extent) / float(maxy - miny)
quantized = {
(
int((float(x) - float(minx)) * sx),
int((float(y) - float(miny)) * sy),
)
for x, y, *rest in geom.coords
}
return len(quantized) >= 2
def feature_id(build_id: str, z: int, x: int, y: int, depth: float, index: int, geom) -> int:
payload = f"{build_id}|{z}|{x}|{y}|{depth:.6f}|{index}|{geom.wkb_hex}".encode("ascii")
value = int.from_bytes(hashlib.blake2b(payload, digest_size=8).digest(), "big") & ((1 << 63) - 1)
return value or 1
def build_tiles(contours: dict[float, Any], cfg: dict[str, Any], output_dir: Path) -> dict[str, Any]:
pbf_root = output_dir / "pbf"
pbf_root.mkdir(parents=True, exist_ok=True)
source_layer = str(cfg["source_layer"])
extent = int(cfg["extent"])
buffer_px = float(cfg.get("tile_buffer_px", 8))
build_id = str(cfg["build_id"])
simplify_by_zoom = {int(k): float(v) for k, v in cfg.get("simplify_m_by_zoom", {}).items()}
tile_count = 0
feature_count = 0
total_bytes = 0
encoder_dropped = 0
zoom_stats: dict[str, Any] = {}
for z in range(int(cfg["minzoom"]), int(cfg["maxzoom"]) + 1):
z_tiles = 0
z_features = 0
z_bytes = 0
z_encoder_dropped = 0
xr, yr = tile_range(cfg["bbox_wgs84"], z)
for x in xr:
for y in yr:
tile = xyz_tile_bounds(z, x, y)
pad = tile.width_m * buffer_px / 256.0
clip_box = box(
tile.west_m - pad,
tile.south_m - pad,
tile.east_m + pad,
tile.north_m + pad,
)
features = []
simplify_m = simplify_by_zoom.get(z, 0.0)
local_index = 0
for depth, contour_geom in sorted(contours.items()):
if contour_geom.is_empty or not contour_geom.intersects(clip_box):
continue
clipped = contour_geom.intersection(clip_box)
for part in line_parts(clipped):
if simplify_m > 0:
part = part.simplify(simplify_m, preserve_topology=False)
if part.is_empty or part.length <= 0:
continue
if not line_survives_mvt_quantization(part, tile.mercator_tuple, extent):
continue
local_index += 1
features.append(
{
"id": feature_id(build_id, z, x, y, depth, local_index, part),
"geometry": mapping(part),
"properties": {
"depth_m": float(depth),
"source_kind": str(cfg["source_kind"]),
"reliability": str(cfg["reliability"]),
"build_id": build_id,
},
}
)
if not features:
continue
data = mapbox_vector_tile.encode(
[{"name": source_layer, "features": features}],
default_options={
"quantize_bounds": tile.mercator_tuple,
"extents": extent,
},
)
decoded = mapbox_vector_tile.decode(data)
actual = len(decoded.get(source_layer, {}).get("features", []))
dropped = max(0, len(features) - actual)
encoder_dropped += dropped
z_encoder_dropped += dropped
tile_path = pbf_root / str(z) / str(x) / f"{y}.pbf"
tile_path.parent.mkdir(parents=True, exist_ok=True)
tile_path.write_bytes(data)
tile_count += 1
feature_count += actual
total_bytes += len(data)
z_tiles += 1
z_features += actual
z_bytes += len(data)
zoom_stats[str(z)] = {
"tiles": z_tiles,
"features": z_features,
"encoder_dropped_features": z_encoder_dropped,
"bytes": z_bytes,
}
return {
"pbf_tiles": tile_count,
"tile_feature_instances": feature_count,
"encoder_dropped_features": encoder_dropped,
"pbf_bytes": total_bytes,
"zooms": zoom_stats,
}
def write_debug_geojson(contours: dict[float, Any], cfg: dict[str, Any], output_dir: Path) -> int:
features = []
for depth, geom in sorted(contours.items()):
geom_wgs = transform(lambda x, y, z=None: mercator_to_lonlat(x, y), geom)
features.append(
{
"type": "Feature",
"geometry": mapping(geom_wgs),
"properties": {
"depth_m": float(depth),
"source_kind": str(cfg["source_kind"]),
"reliability": str(cfg["reliability"]),
"build_id": str(cfg["build_id"]),
},
}
)
payload = {"type": "FeatureCollection", "features": features}
(output_dir / "contours.geojson").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
return len(features)
def write_tilejson(cfg: dict[str, Any], output_dir: Path) -> None:
payload = {
"tilejson": "3.0.0",
"name": str(cfg["build_id"]),
"bounds": [float(v) for v in cfg["bbox_wgs84"]],
"minzoom": int(cfg["minzoom"]),
"maxzoom": int(cfg["maxzoom"]),
"tiles": ["pbf/{z}/{x}/{y}.pbf"],
"vector_layers": [
{
"id": str(cfg["source_layer"]),
"description": "Derived reference bathymetry contours generated from CM95 SOUNDG points.",
"minzoom": int(cfg["minzoom"]),
"maxzoom": int(cfg["maxzoom"]),
"fields": {
"depth_m": "Number",
"source_kind": "String",
"reliability": "String",
"build_id": "String",
},
}
],
}
(output_dir / "tilejson.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def main() -> int:
args = parse_args()
config_path = args.config.resolve()
cfg = 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']}")
print(f"BBOX {cfg['bbox_wgs84']}")
print(f"OUTPUT {output_dir}")
conn = connect_db()
try:
points, sounding_stats = fetch_soundings(conn, cfg)
print("SOUNDINGS", json.dumps(sounding_stats, ensure_ascii=False))
water_mask, water_rows = fetch_water_mask(conn, cfg)
print(f"WATER_AREAS {water_rows}")
finally:
conn.close()
contours, contour_stats = extract_contours(points, water_mask, cfg)
if not contours:
raise RuntimeError("no contours generated")
print("CONTOURS", json.dumps(contour_stats, ensure_ascii=False))
debug_feature_count = write_debug_geojson(contours, cfg, output_dir)
write_tilejson(cfg, output_dir)
tile_stats = build_tiles(contours, cfg, output_dir)
print("TILES", json.dumps(tile_stats, ensure_ascii=False))
finished = datetime.now(timezone.utc)
manifest = {
"schema": "navsea.cm95.bathymetry-build-result/v1",
"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(v) for v in cfg["contour_levels_m"]],
"native_scale_max": int(cfg["native_scale_max"]),
"max_triangle_edge_m": float(cfg["max_triangle_edge_m"]),
"water_object_classes": [str(v) for v in cfg["water_object_classes"]],
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"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")
return 0
if __name__ == "__main__":
raise SystemExit(main())

123
web/bathymetry_test.html Normal file
View File

@@ -0,0 +1,123 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>CM95 派生等深线独立 PBF 测试</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style>
html,body,#map{margin:0;width:100%;height:100%;overflow:hidden}
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.panel{position:absolute;z-index:10;top:10px;left:10px;max-width:370px;padding:10px 12px;
background:rgba(20,22,26,.92);color:#eef1f5;border:1px solid #555;border-radius:8px;
font-size:12px;line-height:1.45;box-shadow:0 3px 12px rgba(0,0,0,.35)}
.panel h1{font-size:14px;margin:0 0 7px}.panel label{display:flex;gap:7px;align-items:center;margin:6px 0}
.note{color:#b8c1cd}.stats{margin-top:8px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
white-space:pre-wrap;color:#d7dde6}.legend{display:flex;gap:8px;flex-wrap:wrap;margin-top:7px}
.legend span{padding:2px 5px;border:1px solid #666;border-radius:4px}
</style>
</head>
<body>
<div id="map"></div>
<div class="panel">
<h1>独立 bathymetry PBF</h1>
<label><input id="toggle" type="checkbox" checked />显示派生等深线</label>
<label>透明度 <input id="opacity" type="range" min="0.15" max="1" step="0.05" value="0.8" /></label>
<div class="note">单独 vector source:<code>bathymetry</code>;source-layer:<code>bathymetry_contour</code>。</div>
<div class="note">底图仅用 GSI raster 便于检查,不依赖现有 CM95 大 build。</div>
<div class="note">等深线由 SOUNDG 测深点经 TIN 插值、水域裁剪生成,仅作低可靠性参考。</div>
<div class="legend"><span>2m</span><span>5m</span><span>10m</span><span>20m</span><span>30m</span><span>50m</span></div>
<div id="stats" class="stats">加载 build manifest…</div>
</div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script>
const SOURCE_ID="bathymetry";
const LAYER_ID="bathymetry-contours";
const LABEL_ID="bathymetry-labels";
const START={center:[130.05,33.45],zoom:9.4,bearing:0,pitch:0};
const style={
version:8,
sources:{
gsi:{
type:"raster",
tiles:["https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png"],
tileSize:256,
maxzoom:18
},
bathymetry:{
type:"vector",
minzoom:8,
maxzoom:12,
tiles:[location.origin+"/bathymetry/{z}/{x}/{y}.pbf"]
}
},
layers:[
{id:"background",type:"background",paint:{"background-color":"#f4f4f1"}},
{id:"gsi",type:"raster",source:"gsi",paint:{"raster-opacity":0.82}},
{
id:LAYER_ID,type:"line",source:SOURCE_ID,"source-layer":"bathymetry_contour",
paint:{
"line-color":[
"match",["to-number",["get","depth_m"]],
2,"#425f88",5,"#4e6f99",10,"#5b7dab",
20,"#6c8db4",30,"#7b9cc0",50,"#8ba9c8","#6f8da8"
],
"line-width":["case",[">=",["to-number",["get","depth_m"]],20],1.35,1.0],
"line-opacity":0.8
}
},
{
id:LABEL_ID,type:"symbol",source:SOURCE_ID,"source-layer":"bathymetry_contour",minzoom:10,
layout:{
"symbol-placement":"line","symbol-spacing":260,
"text-field":["concat",["to-string",["get","depth_m"]]," m"],
"text-size":10,"text-allow-overlap":false
},
paint:{
"text-color":"#40566d",
"text-halo-color":"rgba(255,255,255,.88)",
"text-halo-width":1
}
}
]
};
const map=new maplibregl.Map({
container:"map",style,center:START.center,zoom:START.zoom,bearing:0,pitch:0,
attributionControl:false
});
map.addControl(new maplibregl.NavigationControl(),"top-right");
function setVisible(visible){
const value=visible?"visible":"none";
if(map.getLayer(LAYER_ID))map.setLayoutProperty(LAYER_ID,"visibility",value);
if(map.getLayer(LABEL_ID))map.setLayoutProperty(LABEL_ID,"visibility",value);
}
document.getElementById("toggle").addEventListener("change",function(e){
setVisible(e.target.checked);
});
document.getElementById("opacity").addEventListener("input",function(e){
if(map.getLayer(LAYER_ID))map.setPaintProperty(LAYER_ID,"line-opacity",Number(e.target.value));
});
fetch("/bathymetry-manifest.json",{cache:"no-store"})
.then(r=>r.json())
.then(m=>{
const t=m.tile_stats||{},s=m.sounding_stats||{};
document.getElementById("stats").textContent=[
"build="+m.build_id,
"bbox="+JSON.stringify(m.bbox_wgs84),
"soundings="+(s.unique_sounding_points??"?"),
"levels="+(m.contour_levels_m||[]).join(",")+" m",
"tiles="+(t.pbf_tiles??"?"),
"features="+(t.tile_feature_instances??"?"),
"encoder_drop="+(t.encoder_dropped_features??"?"),
"bytes="+(t.pbf_bytes??"?")
].join("\n");
})
.catch(err=>{
document.getElementById("stats").textContent="manifest load failed: "+String(err);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
BATHY_BUILD_ID = "cm95-bathymetry-north-kyushu-v1"
def parse_args():
p = argparse.ArgumentParser(description="CM95 independent bathymetry PBF test server")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int, default=18890)
p.add_argument("--bathymetry-build-id", default=BATHY_BUILD_ID)
return p.parse_args()
def main():
args = parse_args()
web_root = Path(__file__).resolve().parent
project_root = web_root.parent
html_path = web_root / "bathymetry_test.html"
bathy_root = project_root / "build" / "bathymetry" / args.bathymetry_build_id
bathy_pbf_root = bathy_root / "pbf"
bathy_manifest = bathy_root / "build_manifest.json"
required = [html_path, bathy_pbf_root, bathy_manifest]
missing = [str(path) for path in required if not path.exists()]
if missing:
raise SystemExit("Missing required path(s):\n" + "\n".join(missing))
manifest_obj = json.loads(bathy_manifest.read_text(encoding="utf-8"))
class Handler(BaseHTTPRequestHandler):
def send_bytes(self, data: bytes, content_type: str, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def do_GET(self):
path = urlparse(self.path).path
if path in ("/", "/index.html"):
self.send_bytes(html_path.read_bytes(), "text/html; charset=utf-8")
return
if path == "/bathymetry-manifest.json":
self.send_bytes(
json.dumps(manifest_obj, ensure_ascii=False, indent=2).encode("utf-8"),
"application/json; charset=utf-8",
)
return
if path.startswith("/bathymetry/") and path.endswith(".pbf"):
relative = path[len("/bathymetry/"):]
parts = relative.split("/")
if len(parts) != 3:
self.send_error(404)
return
z, x, yfile = parts
if not (z.isdigit() and x.isdigit() and yfile.endswith(".pbf")):
self.send_error(404)
return
y = yfile[:-4]
if not y.isdigit():
self.send_error(404)
return
tile_path = bathy_pbf_root / z / x / yfile
if not tile_path.is_file():
self.send_bytes(b"", "application/vnd.mapbox-vector-tile", status=204)
return
self.send_bytes(tile_path.read_bytes(), "application/vnd.mapbox-vector-tile")
return
self.send_error(404)
def log_message(self, fmt, *args):
print(f"{self.address_string()} - {fmt % args}")
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"Bathymetry test: http://{args.host}:{args.port}/")
print(f"Bathymetry PBF: {bathy_pbf_root}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()