feat: add independent derived bathymetry PBF build
This commit is contained in:
16
AGENTS.md
16
AGENTS.md
@@ -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 证据。
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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 合并。
|
||||||
|
|||||||
24
config/bathymetry/cm95_bathymetry_north_kyushu_v1.yaml
Normal file
24
config/bathymetry/cm95_bathymetry_north_kyushu_v1.yaml
Normal 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
|
||||||
199
docs/CM95_Bathymetry独立PBF_v1.md
Normal file
199
docs/CM95_Bathymetry独立PBF_v1.md
Normal 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` 必须保持不变。
|
||||||
572
tools/cm95_bathymetry_build_v1.py
Normal file
572
tools/cm95_bathymetry_build_v1.py
Normal 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
123
web/bathymetry_test.html
Normal 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>
|
||||||
93
web/bathymetry_test_server.py
Normal file
93
web/bathymetry_test_server.py
Normal 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()
|
||||||
Reference in New Issue
Block a user