1 Commits

Author SHA1 Message Date
OpenAI Codex
0456c443de feat: checkpoint current CM95 builder and bathymetry work 2026-10-17 22:27:23 +08:00
30 changed files with 6033 additions and 60 deletions

9
.env.linux.example Normal file
View File

@@ -0,0 +1,9 @@
CM93_DB_HOST=192.168.200.230
CM93_DB_PORT=3306
CM93_DB_NAME=cm95_japan
CM93_DB_USER=root
CM93_DB_PASSWORD=change_me
CM93_DB_CHARSET=utf8mb4
# Linux CM95 日本源数据目录。
CM93_SOURCE_DIR=/root/sourceserver/cm95-data/CM93JAPAN

3
.gitignore vendored
View File

@@ -44,3 +44,6 @@ cm93_spatial_audit_*/
.idea/
.DS_Store
Thumbs.db
!.env.linux.example
.codex-build-logs/

View File

@@ -2,6 +2,8 @@
CM95 当前仓库的已验证基线,是一个将 CM93 数据解析、标准化并保存到 MySQL 5.7 的 importer。
> Linux 迁移说明(2026-09-15):历史 production baseline 仍为 MySQL 5.7;当前 Linux 环境已增加 MySQL 8.0.x 显式兼容层。MySQL 8 对 EPSG:4326 默认采用 SRS 轴顺序,因此所有 4326 WKB/WKT 构造与 WKB 读回必须通过项目 helper 强制 `axis-order=long-lat`,禁止在新代码中绕过 helper 直接拼空间 SQL。`192.168.200.230` 的 MySQL 8.0.45 已通过 schema 初始化、单 Cell 导入、64 点精度审计(0° 误差)、较大 Cell 精度审计及单 z12 PBF tile 构建;Japan 1492 Cell 全量 MySQL 8 production 验收尚未完成。
当前基线:
```text

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,30 @@
schema: navsea.cm95.bathymetry-build/v2
build_id: cm95-bathymetry-north-kyushu-v2-smooth
dataset_id: 1
bbox_wgs84: [129.7, 33.1, 130.6, 33.8]
source_layer: bathymetry_contour
source_kind: derived_sounding_idw_grid
reliability: reference_low
native_scale_max: 100000
water_object_classes: [DEPARE, DRGARE]
contour_levels_m: [2, 5, 10, 20, 30, 50]
grid_spacing_m: 300
idw_search_radius_m: 3000
idw_max_nearest_m: 1500
idw_min_neighbors: 3
idw_max_neighbors: 16
idw_power: 2.0
chaikin_iterations: 2
min_segment_length_m: 30
regional_simplify_m: 3
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-v2-smooth

View File

@@ -0,0 +1,34 @@
schema: navsea.cm95.bathymetry-build/v3
build_id: cm95-bathymetry-north-kyushu-v3-dense
dataset_id: 1
bbox_wgs84: [129.7, 33.1, 130.6, 33.8]
source_layer: bathymetry_contour
source_kind: derived_sounding_idw_grid_v3
reliability: reference_low
native_scale_max: 100000
water_object_classes: [DEPARE, DRGARE]
contour_levels_m: [2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 35, 40, 45, 50]
major_contours_m: [2, 5, 10, 20, 30, 50]
grid_spacing_m: 250
idw_search_radius_m: 3000
idw_max_nearest_m: 1800
idw_min_neighbors: 3
idw_max_neighbors: 16
idw_power: 2.0
gap_fill_max_component_nodes: 49
gap_fill_radius_cells: 4
gap_fill_min_neighbors: 4
chaikin_iterations: 2
min_segment_length_m: 25
regional_simplify_m: 2
minzoom: 8
maxzoom: 12
extent: 4096
tile_buffer_px: 8
simplify_m_by_zoom:
"8": 70
"9": 35
"10": 18
"11": 8
"12": 4
output_dir: build/bathymetry/cm95-bathymetry-north-kyushu-v3-dense

View File

@@ -0,0 +1,29 @@
schema: navsea.cm95.bathymetry-build/v4
build_id: cm95-bathymetry-north-kyushu-v4-tin-dense
dataset_id: 1
bbox_wgs84: [129.7, 33.1, 130.6, 33.8]
source_layer: bathymetry_contour
source_kind: derived_sounding_tin_surface_v4
reliability: reference_low
native_scale_max: 100000
water_object_classes: [DEPARE, DRGARE]
contour_levels_m: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50]
major_contours_m: [2, 5, 10, 20, 30, 50]
grid_spacing_m: 200
max_triangle_edge_m: 4000
surface_smooth_iterations: 1
surface_smooth_strength: 0.35
chaikin_iterations: 1
min_segment_length_m: 20
regional_simplify_m: 1.5
minzoom: 8
maxzoom: 12
extent: 4096
tile_buffer_px: 8
simplify_m_by_zoom:
"8": 60
"9": 30
"10": 15
"11": 7
"12": 3
output_dir: build/bathymetry/cm95-bathymetry-north-kyushu-v4-tin-dense

View File

@@ -0,0 +1,37 @@
schema: navsea.cm95.bathymetry-build/v5
build_id: cm95-bathymetry-north-kyushu-v5-adaptive
dataset_id: 1
bbox_wgs84: [129.7, 33.1, 130.6, 33.8]
source_layer: bathymetry_contour
source_kind: derived_sounding_adaptive_tin_v5
reliability: reference_low
native_scale_max: 100000
water_object_classes: [DEPARE, DRGARE]
contour_levels_m: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50]
major_contours_m: [2, 5, 10, 20, 30, 50]
grid_spacing_m: 150
adaptive_edge_factor: 8.0
adaptive_edge_min_m: 600
adaptive_edge_max_m: 4000
smooth_spacing_low_m: 100
smooth_spacing_high_m: 700
smooth_strength_low: 0.05
smooth_strength_high: 0.30
surface_smooth_iterations: 1
gap_fill_max_component_nodes: 49
gap_fill_radius_cells: 4
gap_fill_min_neighbors: 4
chaikin_iterations: 1
min_segment_length_m: 18
regional_simplify_m: 1.0
minzoom: 8
maxzoom: 12
extent: 4096
tile_buffer_px: 8
simplify_m_by_zoom:
"8": 55
"9": 28
"10": 12
"11": 5
"12": 2
output_dir: build/bathymetry/cm95-bathymetry-north-kyushu-v5-adaptive

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 192.168.200.184 --port 18890
```
浏览:
```text
http://192.168.200.184: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,417 @@
# CM95 S-52 120 个 FAIL 根因归并与修复批次规划 Codex 任务 v1
日期:2026-09-20
## 1. 任务目标
本轮只做“120 个 FAIL 的根因归并与修复批次规划”,不修改任何产品功能代码。
唯一输入基线:
- CM95 HEAD:`716acba74dec2c5bbc76e2cd62ca522618d810e2`
- 分支:`main`
- OpenCPN 固定基线:`f7211b4e1874c453e37a7f190aa191c55a25c32e`
- v2 审计报告:`docs/报告/CM95_S52_OpenCPN渲染流程对照审计报告_v2.md`
- v2 审计标准:`docs/CM95_S52_语义守恒与运行时能力审计标准_v2.md`
- 机器审计:`build/audits/cm95_s52_opencpn_code_first_equivalence_v2.json`
当前机器审计结论固定为:
- branch records:206
- FAIL:120
- NOT_PROVEN:86
- 正式 PBF:5064
- delivery signatures:1976
- overall Target Portrayal:FAIL
本轮不得把 86 个 NOT_PROVEN 当成已确认 Bug,也不得把 120 FAIL 直接当成 120 个独立代码缺陷。## 2. 核心问题
需要回答的不是“有哪些 120 个失败分支”,而是:
1. 这 120 个 FAIL 最少可以归并为多少个独立根因?
2. 每个根因实际发生在哪一层?
3. 哪些 FAIL 只是同一根因的不同 branch 展开?
4. 哪些看似相似的 FAIL 实际必须分开修?
5. 哪些根因可以局部修复?
6. 哪些根因说明当前架构缺少统一 Rule IR / executor?
7. 哪些修复之间存在依赖顺序?
8. 如何分批修复,尽量让一次修改消除一组 FAIL?
9. 每个修复批次完成后应重跑哪些 Gate / branch / witness?
10. 哪些 NOT_PROVEN 需要补证据,而不是写代码?
## 3. 本轮禁止事项
本轮禁止:
- 修改 `src/`、`web/`、`tools/`、runtime registry、builder、production、style compiler 等产品实现;
- 修改 PBF schema;
- 修改 release gate;
- 修改 OpenCPN 固定参考源码;
- 新增或修改 Jest;
- 为了减少 FAIL 数量而改变 v2 审计判定;
- 把多个不同根因为了“数量更少”强行合并;
- 重新扫描 5064 个 PBF;
- 重新做完整 OpenCPN 源码审计;
- 从截图推断根因。
允许读取产品代码来定位根因,但不能编辑产品代码。## 4. 根因定义
一个 Root Cause 必须满足:
- 有明确的错误机制或缺失机制;
- 能指向具体决策层、数据层或 renderer 层;
- 可以说明为什么会同时导致一组 branch FAIL;
- 修复该机制后,理论上可以消除其映射的全部或大部分 FAIL;
- 与其他 Root Cause 有清晰边界。
不要使用过宽的根因,例如:
- “CM95 和 OpenCPN 不一致”
- “renderer 不完整”
- “CSP 有问题”
应使用可执行粒度,例如:
- “hazard resolver 没有把 QUAPNT01 suffix 拼入 ordered instruction chain”
- “presentation scalarization 只保留每种 primitive 的首个值,破坏 ordered multi-opcode sequence”
- “Web SOUNDG runtime 用普通 text 代替 SNDFRM02 glyph sequence executor”
- “RESTRN 集合分类把 14/15 错误纳入 info set”
## 5. Root Cause ID
所有根因使用稳定 ID:
`RC-001`、`RC-002`、……
每个 RC 必须记录:
- title
- layer
- mechanism
- exact evidence
- affected branch_ids
- affected FAIL count
- affected CSP / opcode
- current files/functions
- required change type
- architecture impact
- risk
- dependency
- proposed batch
- verification target
- confidence## 6. 归并原则
### 6.1 可以合并
当多个 FAIL:
- 源于同一个函数/同一决策错误;
- 或源于同一个中间表示丢失;
- 或源于同一个 runtime consumer 缺失;
- 且一次修复可以共同消除,
则应归并为一个 RC。
### 6.2 不应合并
即使表现相似,如果:
- 修复位置不同;
- 条件语义不同;
- 一个是 Python resolver 错误,一个是 runtime executor 缺失;
- 一个是 lookup 错误,一个是 CSP 错误;
- 一个是 OpenCPN upstream anomaly / NOT_PROVEN;
则不得为减少数量而合并。
### 6.3 多根因 branch
一个 branch FAIL 可以同时受多个 RC 影响。
因此必须支持 many-to-many:
`branch_id <-> root_cause_id`
不能要求 120 个 FAIL 每个只能对应一个 RC。## 7. 层级分类
每个 RC 至少归入以下一类:
- `NORMALIZATION_LOOKUP`
- `CSP_DECISION`
- `NESTED_CSP_CHAIN`
- `INSTRUCTION_IR`
- `PRESENTATION_MATERIALIZATION`
- `PBF_RUNTIME_FACTS`
- `STYLE_COMPILER`
- `RUNTIME_EXECUTOR`
- `RESOURCE_CONTRACT`
- `MARINER_STATE`
- `RENDER_ORDER_PRIORITY`
- `EVIDENCE_GAP`
如果一个 RC 跨层,必须指定 primary layer 和 secondary layers。
## 8. 优先检查的已知根因簇
以下只作为 seed,不是结论,Codex 必须用 v2 JSON 验证后再决定是否拆分/合并:
1. OBSTRN/WRECKS nested suffix 丢失:
- QUAPNT01
- SOUNDG03
- OBJNAM TX
- WRECKS Wk/WRECKS07
2. ordered Rule Chain 被 scalar hint 压缩。
3. Web/runtime 缺通用 multi-opcode executor。
4. SOUNDG glyph chain 被普通文字替代。
5. multi-SY / multi-LS / multi-TX 不守恒。
6. RESTRN 集合语义错误。
7. RESARE 集合语义 + OP side effect 不等价。
8. SLCONS LOWACC / attribute presence 差异。
9. LIGHTS05/06 颜色、半径、文本/描述/单位差异。
10. DEPCNT SAFECD/SAFECN、quality、side effect 差异。
11. DEPARE bad range / two_shades / DRGARE 差异。
12. Lookup matching / fallback / $AREAS II25 等 Gate A 差异。## 9. 必须建立 FAIL → RC 覆盖矩阵
机器审计中的 120 个 FAIL branch 必须全部进入覆盖矩阵。
每条 FAIL 至少记录:
| 字段 | 要求 |
|---|---|
| branch_id | v2 JSON 原始 branch ID |
| procedure | CSP/flow |
| fail_reason | v2 已确认原因 |
| root_cause_ids | 一个或多个 RC |
| primary_rc | 主要根因 |
| secondary_rc | 可选 |
| fix_batch | 修复批次 |
| verification | 修复后验证方式 |
硬要求:
- 120/120 FAIL 都必须有 root cause 映射;
- 不允许出现未解释的 FAIL;
- 如确实无法归因,单独建 `RC-XXX UNKNOWN_ROOT_CAUSE`,并明确为何无法继续,不得静默遗漏;
- 映射总数可以大于 120,因为允许一条 FAIL 对应多个 RC。
## 10. RC → FAIL 反向矩阵
每个 RC 必须列出:
- affected_fail_count;
- affected_branch_ids;
- affected procedures;
- 是否存在 real-data signature;
- 是否只存在理论分支;
- 修复后预计可直接关闭的 FAIL;
- 修复后仍可能因其他 RC 保持 FAIL 的 branch。
必须区分:
- `directly_resolved`
- `partially_unblocked`
- `still_blocked_by_other_rc`## 11. 独立根因数量
报告必须给出:
- 根因总数;
- 按 layer 分类数量;
- 按 risk 分类数量;
- 按 proposed batch 分类数量。
同时给出两个指标:
1. `raw_fail_count = 120`
2. `deduplicated_root_cause_count = N`
不要人为追求 N 越小越好。
## 12. 修复批次规划
建立稳定 Batch ID:
`BATCH-01`、`BATCH-02`、……
每个批次必须说明:
- 目标;
- 包含哪些 RC;
- 为什么应一起修;
- 涉及的预计文件/函数;
- 是否改变公共数据契约;
- 是否改变 PBF;
- 是否改变 runtime;
- 是否需要重新 build release;
- 是否需要重新跑 5064 PBF audit;
- 是否需要视觉验收;
- 风险等级;
- 前置依赖;
- 完成判定;
- 回归范围。
## 13. 批次排序原则
默认排序:
1. 明确且局部的 semantic bug;
2. 上游数据/IR 守恒问题;
3. 通用 Rule Chain / opcode executor;
4. renderer 专项;
5. LIGHTS 等复杂专项;
6. render order / priority 等高风险结构项;
7. NOT_PROVEN 补证据任务。
但最终排序必须依据实际 RC dependency graph,不得机械照抄。## 14. 架构分叉判断
每个 RC 必须判断属于:
- `LOCAL_FIX`
- `SHARED_HELPER_FIX`
- `IR_CONTRACT_CHANGE`
- `GENERIC_EXECUTOR_REQUIRED`
- `SPECIALIZED_RENDERER_REQUIRED`
- `EVIDENCE_ONLY`
特别回答:
1. 是否继续逐 CSP 手工复制 OpenCPN 条件会制造新差异?
2. 哪些 RC 可以靠修 `csp.py` 解决?
3. 哪些 RC 即使修 `csp.py`,Web runtime 仍会 FAIL?
4. 哪些 RC 证明需要 ordered Runtime Rule IR?
5. 哪些 opcode 应由通用 executor 执行?
6. SOUNDG、CA 等哪些应保留专用 renderer?
7. 是否需要让 PBF 携带完整 ordered instruction IR,还是只需要 runtime facts + decision IR?
本轮只做设计判断,不实施。
## 15. NOT_PROVEN 单独处理
86 个 NOT_PROVEN 不是本轮根因归并主体。
但报告必须总结它们按“缺什么证据”归类,例如:
- source attributes unavailable;
- association identity unavailable;
- upstream resource snapshot incomplete;
- global render scheduling unavailable;
- runtime state snapshot unavailable。
为这些项建立 `EVIDENCE-TASK-01...`,不要建立代码 RC,除非已有明确代码缺陷证据。
## 16. 本轮正式输出
本轮只允许新增/更新以下正式产物:
1. `docs/报告/CM95_S52_120个FAIL根因归并与修复批次规划报告_v1.md`
- 完整列出 deduplicated Root Cause;
- 包含 120/120 FAIL → RC 覆盖矩阵;
- 包含 RC → FAIL 反向矩阵;
- 包含 RC dependency graph;
- 包含 BATCH-01... 修复批次规划;
- 包含 86 个 NOT_PROVEN 的 evidence-task 分类;
- 给出 raw_fail_count=120 与 deduplicated_root_cause_count=N;
- 明确哪些是局部修复、共享 helper、IR contract、通用 executor、专用 renderer、纯证据任务。
2. `build/audits/cm95_s52_120_fail_root_cause_plan_v1.json`
- 机器可读;
- 至少包含:
- cm95_head;
- opencpn_commit;
- source_audit_artifact;
- raw_fail_count;
- not_proven_count;
- deduplicated_root_cause_count;
- root_causes[];
- fail_to_root_causes[];
- batches[];
- evidence_tasks[];
- dependency_edges[];
- coverage_summary;
- validation_summary。
- 必须能机器确认 120/120 FAIL 全覆盖;
- 每个 FAIL 至少有一个 primary_rc;
- 多根因 branch 可列 secondary RC;
- 不得把 NOT_PROVEN 伪装成代码 RC。
不得创建第二份内容重复的“总结/摘要”文档。
## 17. 必做验证
完成前必须执行结构化自检,至少确认:
- [ ] 输入审计 JSON 的 overall status 仍为 FAIL;
- [ ] 输入 branch records=206;
- [ ] 输入 FAIL=120;
- [ ] 输入 NOT_PROVEN=86;
- [ ] 120/120 FAIL 全部进入 fail_to_root_causes;
- [ ] 每个 FAIL 有且只有一个 primary_rc;
- [ ] secondary RC 可为 0..N;
- [ ] 所有引用的 RC ID 都存在;
- [ ] 所有 RC 的 affected_fail_count 与实际映射一致;
- [ ] deduplicated_root_cause_count 与 root_causes 数量一致;
- [ ] 所有 batch 引用的 RC 均存在;
- [ ] dependency graph 不包含未知 RC/Batch;
- [ ] NOT_PROVEN evidence tasks 与代码 RC 分离;
- [ ] 报告中的数量与 JSON 完全一致;
- [ ] 没有修改产品实现、Jest、PBF schema、release gate;
- [ ] 没有重新扫描 5064 个 PBF;
- [ ] 原工作区已有无关修改保持原样。
如果一个 FAIL 只能暂时归入 UNKNOWN_ROOT_CAUSE,必须在报告和 JSON 中明确列出,不得为了 100% 覆盖而虚构根因。
## 18. 修复批次输出要求
每个 BATCH 必须明确区分:
- `expected_direct_fail_reduction`:若本批次单独完成,理论上可直接关闭的 FAIL 数;
- `expected_unblocked_fail_count`:本批次完成后可继续验证,但仍受其他 RC 阻塞的 FAIL 数;
- `requires_release_rebuild`;
- `requires_full_pbf_rescan`;
- `requires_runtime_validation`;
- `requires_visual_validation`;
- `risk`;
- `dependencies`。
不要把“预计可关闭”写成实际已关闭。本轮没有改代码,因此实际 FAIL 数仍是 120。
修复批次应尽量减少重复构建和重复全量验收;只有数据契约/PBF 变化时才安排全 release rebuild / 5064 PBF 重扫。纯 Python 语义修复、runtime executor 修复、renderer 专项应根据实际影响决定最小必要回归范围。
## 19. Git 收尾
当前仓库存在与本任务无关的未提交修改,必须严格隔离。
本轮只允许提交:
- `docs/CM95_S52_120个FAIL根因归并与修复批次规划_Codex任务_v1.md`
- `docs/报告/CM95_S52_120个FAIL根因归并与修复批次规划报告_v1.md`
- `build/audits/cm95_s52_120_fail_root_cause_plan_v1.json`
不得使用 `git add .`。
完成后:
1. 显式 stage 上述三个路径;
2. 检查 `git diff --cached --name-only` 与 `--stat`;
3. 确认没有产品代码/Jest/原无关修改进入 staged;
4. commit 到当前 `main`;
5. commit message:`docs: plan fixes for OpenCPN S-52 audit failures`;
6. push `origin/main`;
7. 最终完成消息记录 commit hash、push 结果、deduplicated_root_cause_count、batch 数量及最高优先级批次。
push 失败时必须明确报告,不得声称完成。
## 20. 最终判定原则
本轮不是修复任务,不能因为规划完成就改变 v2 审计状态。
结束时必须保持:
- raw FAIL = 120;
- raw NOT_PROVEN = 86;
- Target Portrayal = FAIL。
本轮成功标准是:
`120 FAIL → 可解释的 Root Cause 集合 → 有依赖关系的修复 Batch → 明确回归目标`
而不是“把失败数量写少”。

View File

@@ -0,0 +1,558 @@
# CM95 全球计划暂停与 Japan PBF Canonical 归一化缺口修复上下文 v2
> 本文取代 v1 中关于“低 Zoom retention 是主因”的判断。
>
> 后续同 Zoom、同 Tile、同 Feature 审计已经证明:相当一部分 S-52 页面可见、NavSea canonical 页面不可见的对象,实际上仍存在于同一份 PBF 中,只是停留在 S-52 fallback source-layer,没有进入 NavSea canonical Layer Contract。
## 1. 当前决定
Global CM95 继续暂停:
~~~
Global CM95 Production = PAUSED
~~~
暂停决定继续有效,但原因修正为:
- Japan 正式 PBF 中仍存在大量 S-52 fallback 对象;
- 完整 S-52 Style 可以正常显示这些对象;
- NavSea canonical Style 不消费这些 fallback layer;
- 因此 CM95 → NavSea canonical normalization 尚未完整;
- 在该缺口修复并验证前,不应把当前规则扩展到全球。
当前 Japan 优先级:
~~~
确认 S-52 fallback → canonical 缺口
→ 完善 canonical mapping / composition
→ 同 Zoom S-52 vs canonical 对照
→ 多区域验证
→ NavSea 真机回归
→ 再判断是否仍有独立 Zoom retention 问题
~~~
## 2. 关键反证
用户提供的 Japan S-52 截图显示:
- 较大 Zoom 可见大量航标;
- 较小 Zoom 仍然可见大量航标、浮标、顶标、灯标和其他 S-52 符号。
对应 X220 compare server:
~~~
web/japan_compare_server.py
~~~
实际使用:
~~~
Release:
cm95-d1-62a85bd40d3a-ae61b8866e80
PBF:
build/releases-s52-v2/cm95-d1-62a85bd40d3a-ae61b8866e80/pbf/delivery
S-52 Style:
build/s52_style_compiler_japan_compare_v1/style.s52.json
~~~
因此不能再简单用“NavSea 低 Zoom 看不到”推导为“PBF 里没有”。
## 3. 同 Zoom / 同 Tile 审计
审计对象:
~~~
Tile:
11/1765/820
~~~
同一个 z11 tile 中,带 S-52 symbol code 或 light symbol code 的 feature 共:
~~~
374
~~~
其中:
~~~
navigation_marks:
77
~~~
这 77 个对象:
~~~
chart_icon_image: 77/77
canonical_object_type: 77/77
~~~
说明已经成功 canonical 化的 navigation_marks 本身基本正常。
## 4. 真正的缺口:s52_points
同一个 z11 tile 中:
~~~
s52_points 中带 S-52 symbol 的对象:
130
~~~
而 NavSea canonical Style 对:
~~~
source-layer = s52_points
~~~
的 Layer 数量为:
~~~
0
~~~
所以这些对象:
- S-52 Style 可以显示;
- NavSea canonical Style 根本不会显示。
这是“同一个 PBF、同一个 Zoom,S-52 页面对象更多”的直接原因之一。
## 5. s52_points 中的重要对象
该 z11 tile 的 130 个 s52_points symbol 中:
~~~
fishing_facility 72
s52_boylat 19
s52_topmar 18
s52_radrfl 14
s52_marcul 3
s52_boycar 2
s52_cgusta 1
s52_mstcon 1
~~~
常见 S-52 symbol code:
~~~
FSHHAV01
RADRFL03
TOPMAR33
TOPMAR22
TOPMAR13
TOPMAR05
TOPMAR01
BOYCON60
BOYCON61
BOYPIL60
BOYPIL61
BOYPIL68
BOYSPR60
BOYSPR61
MARCUL02
CGUSTA02
MSTCON04
~~~
特别是 BOYLAT、TOPMAR、BOYCAR、RADRFL,本身就是视觉上容易被理解为航标/浮标/顶标相关对象的类别。
## 6. Trace 已确认:不是被 Zoom 删除
正式 trace:
~~~
build/releases-s52-v2/
cm95-d1-62a85bd40d3a-ae61b8866e80/
reports/delivery/11/1765/820.trace.jsonl
~~~
BOYLAT 示例:
~~~
output_layer:
s52_points
native_scale:
50000
mapping_rule_id:
S52-FALLBACK-BOYLAT
mapping_status:
S52_FALLBACK
~~~
TOPMAR 示例:
~~~
output_layer:
s52_points
native_scale:
50000
mapping_rule_id:
S52-FALLBACK-TOPMAR
mapping_status:
S52_FALLBACK
~~~
RADRFL 示例:
~~~
output_layer:
s52_points
native_scale:
50000
mapping_rule_id:
S52-FALLBACK-RADRFL
mapping_status:
S52_FALLBACK
~~~
BOYCAR 示例:
~~~
output_layer:
s52_points
mapping_rule_id:
S52-FALLBACK-BOYCAR
mapping_status:
S52_FALLBACK
~~~
这些对象在 z11 中真实存在,而且 native_scale=50000。
所以至少这批对象与“z11 不读取 20k 数据”无关。
## 7. 正式 mapping 中没有对应 canonical 规则
当前:
~~~
config/mappings/cm95_to_navsea_v1.yaml
~~~
中没有以下正式 canonical mapping:
~~~
BOYLAT
TOPMAR
RADRFL
BOYCAR
MARCUL
CGUSTA
MSTCON
~~~
所以 Builder 在 mapping.resolve 找不到规则后进入 S-52 fallback 路径。
## 8. Builder 当前 fallback 行为
当前 Builder 中的 S-52 fallback 会把无 canonical mapping、但有 S-52 lookup 的对象写入:
~~~
POINT → s52_points
LINE → s52_lines
AREA → s52_areas
~~~
并设置:
~~~
canonical_object_type = s52_<object_class>
mapping_rule_id = S52-FALLBACK-<object_class>
mapping_status = S52_FALLBACK
~~~
例如:
~~~
BOYLAT → s52_points / s52_boylat
TOPMAR → s52_points / s52_topmar
RADRFL → s52_points / s52_radrfl
~~~
这保证完整 S-52 Style 不丢对象,但 NavSea canonical Style 不消费这些 layer,因此看不到。
## 9. 另一个例子:FSHHAV
正式 mapping 中 CM95-023:
~~~
CM95 class:
FSHHAV
target_source_layer:
s52_points
canonical_object_type:
fishing_facility
mapping_status:
MAPPED
style_fields:
空
~~~
同一个 z11 tile 中这类对象有 72 个。
它们通过:
~~~
s52_symbol_code = FSHHAV01
~~~
在 S-52 Style 中显示,但没有 chart_icon_image,因此 canonical Style 不显示。
所以问题不是 BOYLAT 的单一特例,而是更广泛的:
~~~
S-52 presentation layer
vs
NavSea canonical layer contract
~~~
边界不完整。
## 10. 对 v1 Zoom retention 判断的修正
v1 曾基于 z12→z11 stable FID / 空间匹配推断低 Zoom 有对象被裁掉。
现在确认,这种统计不能直接解释为“同一个现实对象被 Builder 删除”。
原因包括:
1. 不同 native scale 可能使用不同 feature/FID 表达同一现实对象;
2. S-52 fallback 对象可能存在于另一个 source-layer;
3. canonical Style 没有消费 fallback layer;
4. 类型和位置可能因不同 cell / native scale 发生变化。
因此:
~~~
跨 Zoom stable FID retention
只能作为审计指标
不能单独证明对象被删除
~~~
当前不要据此修改 Japan z11 scale ladder。
## 11. 当前最可能主根因
当前证据最强的主链路:
~~~
CM95 PBF 中存在正式 S-52 可显示对象
↓
部分对象只有 S-52 presentation semantics
↓
Builder 输出到 s52_points / s52_lines / s52_areas
↓
NavSea canonical Style 不消费这些 Layer
↓
NavSea 看起来“丢对象”
~~~
所以当前问题首先归类为:
~~~
CM95 → NavSea canonical normalization completeness
~~~
而不是:
~~~
PBF Zoom generalization
~~~
## 12. CM95 项目下一步
### 12.1 暂时不要先改 Zoom policy
当前不要因为 v1 的判断直接修改:
~~~
z11 scale ladder
20k retention
navigation aid retention ladder
~~~
必须先完成 canonical completeness audit。
### 12.2 建立同 Zoom completeness audit
对同一个 PBF tile:
~~~
完整 S-52 Style 可显示 feature
vs
NavSea canonical Style 可显示 feature
~~~
逐 feature 分类:
~~~
A. 已 canonical,能显示
B. 已 canonical,但 Style 漏匹配
C. S52_FALLBACK,无 canonical mapping
D. MAPPED,但仍只保留在 s52_points/s52_lines/s52_areas
E. 需要 composite/relation 后才应进入 canonical
F. 明确不应该进入 NavSea canonical
~~~
### 12.3 优先审计类别
至少:
~~~
BOYLAT
TOPMAR
BOYCAR
RADRFL
MARCUL
CGUSTA
MSTCON
FSHHAV
~~~
特别是:
~~~
BOYLAT
TOPMAR
BOYCAR
~~~
应检查是否应成为 canonical navigation_marks 的组成部分或独立标准对象。
不能简单把全部 s52_points 直接显示到 NavSea。
### 12.4 必须保持 composite 语义
当前 navigation_marks 中已经存在大量:
~~~
mapping_rule_id = CM95-NAV-COMPOSITE
mapping_status = COMPOSITE
~~~
所以 BOYLAT / TOPMAR 等是否独立输出,必须考虑:
- 是否已经被 composite 消费;
- relation20;
- 浮标 + 顶标组合;
- 灯 + 标体组合;
- 是否会重复显示;
- stable FID;
- S-52 presentation order。
## 13. 历史线索
仓库历史/实验 trace 中存在某些:
~~~
S52-FALLBACK-BOYLAT
S52-FALLBACK-TOPMAR
~~~
曾输出到:
~~~
navigation_marks
~~~
而当前正式 Japan release 同类对象输出到:
~~~
s52_points
~~~
这说明项目历史上可能已经尝试过相关整合逻辑。
CM95 项目可以调查这些历史实现及其放弃/调整原因,但不要直接恢复旧代码。
## 14. Global 暂停原因更新
Global CM95 继续暂停。
正式原因更新为:
~~~
Japan 正式 Release 已证明:
S-52 presentation coverage
大于
NavSea canonical coverage
~~~
也就是 canonical normalization 尚未完整。
恢复 Global 之前至少需要:
1. Japan canonical completeness audit 完成;
2. 安全相关 S52_FALLBACK 分类完成;
3. canonical mapping/composite 规则冻结;
4. 同 Zoom S-52 vs canonical 重要对象覆盖通过;
5. 不产生 duplicate navigation mark;
6. stable FID 不破坏;
7. Pickup / ChartDanger 所需字段完整;
8. Japan 多区域抽样通过;
9. NavSea 真机回归通过;
10. 再单独判断是否仍有真正 Zoom retention 问题。
## 15. NavSea 边界保持不变
NavSea 项目不要为了 CM95 修改:
~~~
1/2/3/4 挡
Pickup
ChartDangerAlarmSource
CrossCursor
PbfLayerGroupConfig
MapLibre query logic
~~~
正确方向:
~~~
CM95 项目完善 canonical PBF contract
→ NavSea 原逻辑直接消费
~~~
## 16. 当前结论摘要
~~~
Global CM95:
PAUSED
Japan PBF:
对象并非简单低 Zoom 消失
正式 z11 PBF:
存在大量 S-52 可显示对象
navigation_marks canonical:
已映射对象基本正常
主要缺口:
s52_points / s52_lines / s52_areas 中的
S52_FALLBACK / presentation-only 对象
没有进入 NavSea canonical contract
最优先任务:
同 Zoom canonical completeness audit
当前不要先做:
z11 scale ladder 修改
20k 全局下放
navigation aid retention policy
~~~

View File

@@ -0,0 +1,655 @@
# CM95 全球计划暂停与 Japan PBF 低 Zoom 航标修复上下文 v1
## 1. 文档目的
本文用于把 NavSea 侧已经确认的事实、CM95 Japan PBF 暴露的问题,以及后续应由 CM95 项目承担的工作边界正式交接到 CM95 项目。
本文是上下文与状态交接文档,不是实现报告,也不代表 Japan PBF 修复已经完成。
后续 CM95/PBF 工作以 X220 上的实际仓库为唯一开发和生产事实源:
~~~
机器:X220
仓库:/root/sourceserver/cm95
~~~
CARBON7i 上曾做过的 PBF 实验只能作为调查线索,不作为正式实现、生产基线或最终结论。
---
## 2. 当前正式决定
### 2.1 全球 CM95 计划暂停
从现在开始:
~~~
Global CM95 Production = PAUSED
~~~
暂停原因:
Japan 区域已经确认存在与 Zoom 有关的助航对象保留缺陷。若在修复前继续扩展全球 PBF,很可能把同一生成缺陷复制到全球数据,之后产生大规模返工。
因此,在 Japan 修复、验证和冻结完成之前:
- 不继续扩大全球 CM95 生成范围;
- 不把当前 Japan 的 PBF scale/generalization 策略直接推广到全球;
- 不将当前 Japan 的低 Zoom 行为视为全球可接受基线。
### 2.2 Japan PBF 优先修复
当前 CM95 项目优先级改为:
~~~
Japan PBF correctness
→ Japan multi-region validation
→ Japan production freeze
→ 再评估 Global CM95 resume
~~~
---
## 3. NavSea 侧当前架构结论
NavSea 端已经重新收敛到以下架构:
~~~
CM95 PBF
↓
NavSea canonical source-layer / property contract
↓
NavSea canonical Style
↓
现有 1/2/3/4 挡
现有 Pickup
现有 ChartDangerAlarmSource
~~~
禁止继续走下面的方向:
~~~
CM95 raw S-52 Style
↓
让 NavSea 为 CM95 特别修改挡位 / Pickup / 报警
~~~
结论:
CM95 Provider 应负责把自己的数据生产为 NavSea 已定义好的 canonical Layer / property contract;NavSea 业务模块不应该理解 CM95 特有 Layer。
---
## 4. NavSea 真机已经验证通过的部分
测试机:
~~~
192.168.200.145
~~~
### 4.1 1/2/3/4 挡
已经实际验证:
~~~
3 → 2 → 1 → 4
~~~
均正常切换。
同时观察:
~~~
MapLibre resource cancel count = 0
ParseStyle error = 0
resourceUrl error = 0
~~~
说明当前挡位已经回到原 NavSea existing-layer visibility 热切换,不再通过重建整张 Style/MapView 实现。
### 4.2 Pickup
连续点击多个 CM95 航标,现有链路可以直接返回 NavSea 标准 Pickup 信息。
现有正式链路:
~~~
runNavSeaPickupJson
→ pickupInterpreter
→ CrossCursor
~~~
实际返回过:
- 小灯;
- 航路標識灯;
- red / green / white;
- omni;
- 距离与方位。
因此 Pickup 不需要增加 CM95 专用逻辑。
### 4.3 海图报警
现有 ChartDangerAlarmSource 仍使用 NavSea canonical render layer 和 canonical properties。
已经确认 CM95 canonical PBF 中存在相关:
- navigation_hazard_point;
- navigation_marks;
- baseline_area;
- facility / land / coast / structure 等标准 source-layer。
动态浅水报警 danger-depth-overlay 继续通过当前 Provider 的 analysisSourceId + baseline_area 生成。
CM95 的 baseline_area 已观察到标准深度分区,例如:
~~~
depth_zone_0-2m
depth_zone_0-5m
depth_zone_0-10m
depth_zone_2-5m
depth_zone_5-10m
depth_zone_10-20m
...
~~~
因此 ChartDangerAlarmSource 不应为了 CM95 改业务逻辑。
---
## 5. Canonical Style 已经排除为“丢灯”根因
在福冈湾真实 CM95 PBF 上做过逐对象检查。
对 PBF 中已经存在的灯类 navigation_marks feature,逐个执行 NavSea canonical Style 的灯标 filters。
结果:
~~~
unmatched = 0
~~~
也就是说:
只要 CM95 PBF 中已经存在该灯标,NavSea canonical Style 可以匹配并显示。
所以剩余的“低 Zoom 丢灯”问题发生在 Style 之前:
~~~
CM95 source
→ PBF Builder / scale selection / generalization / retention
→ PBF
→ NavSea canonical Style
~~~
当前不应继续通过修改 NavSea Style filter 来补偿。
---
## 6. 已确认的低 Zoom 现象
在固定福冈湾区域做过跨 Zoom 审计。
CM95 灯类对象在 Zoom 降低时存在明显减少。
特别是 z12 → z11:
- z12 中存在的一部分灯标在 z11 消失;
- 主要缺失对象中包含 minor_light;
- 使用空间匹配方式时,CM95 z12→z11 的保留率显著低于 Current NavSea。
曾观察到大致:
~~~
CM95 z12 → z11
50m 内保留率约 85%
100m 内保留率约 87%
~~~
而同类 Current NavSea 对照接近:
~~~
z12 → z11 ≈ 100%
~~~
因此 z11 已经可以明确视为 Japan PBF 的一个重点修复 Zoom。
z10 以下也存在更激进的数据缩减,但不应简单假设所有 z12 对象都必须无条件保留到全部低 Zoom;需要由 CM95 项目建立明确的安全对象 retention policy。
---
## 7. Current 与 CM95 原始数据并非完全一致
Current 与 CM95 是不同数据源。
已观察到类似差异:
~~~
Current: breakwater_lighthouse
CM95 附近对象: light_beacon
~~~
以及:
~~~
Current: harbor_lighthouse
CM95: coastal_lighthouse_over_15m
~~~
还存在几十米乃至更大的坐标差异。
因此禁止用以下方式修复:
~~~
距离很近
→ 认为是同一个对象
→ 自动把 CM95 类型改成 Current 类型
~~~
航标 canonical 分类必须依据 CM93/CM95 自身语义事实,例如:
- object class;
- relation/group;
- native scale;
- S-52 lookup;
- 原始 attributes;
- 已冻结的 mapping contract。
空间邻近只能用于审计和发现异常,不能作为生产语义改判依据。
---
## 8. CARBON7i 曾做过的调查线索
以下内容是在确认“X220 才是实际 CM95/PBF 工作机器”之前,在 CARBON7i 副本上做的实验。
这些内容只能作为 X220 调查线索,必须在 X220 当前真实代码、配置和数据上重新验证。不得直接复制实验代码。
### 8.1 一个强候选根因
CARBON7i 副本中的 Japan policy 曾显示类似:
~~~
z11:
50k
100k
200k
...
z12:
7.5k
20k
50k
100k
...
~~~
同时福冈区域数据库中大量 LIGHTS 存在于:
~~~
20k
50k
100k
...
~~~
由此得到一个需要在 X220 正式重新验证的强候选:
~~~
z12 会读取 20k LIGHTS
z11 从 50k 开始
→ 20k 独有灯标在 z11 被整体排除
~~~
### 8.2 不应把整个 z11 提升为 20k full detail
CARBON7i 的实验还显示,如果简单把整个 z11 scale ladder 改为包含 20k:
代表 tile 曾出现:
~~~
旧 z11:
约 3906 features
整个 z11 加入 20k:
约 6851 features
~~~
约增加 75%。
原因是 20k 中不仅有灯标,还包含大量:
- seabed text;
- area;
- line;
- label;
- 港区细节;
- 其他 feature。
因此不建议把整个 z11 直接升级为 20k/full detail。
### 8.3 值得在 X220 重新验证的设计方向
CARBON7i 实验曾验证一个更合理的方向:
为安全助航对象建立独立的 scale retention ladder,例如:
~~~
普通 z11 海图内容:
50k → 100k → 200k → ...
navigation_marks z11:
7.5k → 20k → 50k → 100k → ...
~~~
只针对助航组件,例如:
~~~
LIGHTS
BCNLAT
BCNSPP
BOYSPP
RTPBCN
TOWERS
_extgn
~~~
使用细尺度 quilt。
其他 source-layer 仍按正常 z11 ladder 生成。
CARBON7i 实验曾得到过以下结果:
单代表 tile:
~~~
z12 → z11 navigation_marks stable FID retention:
约 16.8% → 100%
总 feature:
3906 → 3933
PBF 体积:
约 +1%
非 navigation_marks layer count:
0 差异
~~~
福冈 12 张 z11 tile 实验曾得到:
~~~
z12 navigation_marks FID retention:
约 35.8% → 100%
非 navigation layer count 差异:
0
总 PBF 体积:
约 +1.1%
hard gate:
0
encoder drop:
0
~~~
这些只是实验线索。
正式 CM95 项目必须在 X220 当前实际 builder、policy、mapping、manifest、release 上重新实现和验证。
---
## 9. X220 当前事实源
后续开发机器:
~~~
X220
~~~
正式仓库:
~~~
/root/sourceserver/cm95
~~~
本文建立时观察到的分支:
~~~
main
~~~
本文建立前调查时观察到的 HEAD:
~~~
ed53864598f3059f524841fbe25dcd488fc1301a
~~~
注意:X220 当前 worktree 自身已经存在未提交修改。
因此后续任务必须:
1. 先读当前实际 diff;
2. 不覆盖现有未提交工作;
3. 只做本任务相关最小修改;
4. 不把无关文件一起提交;
5. 以 X220 当前实际文件为准,不使用 CARBON7i 副本替代。
X220 中已经确认存在:
~~~
/root/sourceserver/cm95/config/pbf/
/root/sourceserver/cm95/src/cm95pbf/
/root/sourceserver/cm95/build/production/
/root/sourceserver/cm95/build/releases-s52-v2/
/root/sourceserver/cm95/scripts/
/root/sourceserver/cm95/tools/
~~~
并存在 Japan production plan:
~~~
build/production/cm95_s52_japan_production_plan_v1.json
~~~
---
## 10. CM95 项目后续正式工作
### 10.1 第一阶段:X220 重新确认根因
必须基于 X220 当前实际版本重新检查:
~~~
config/pbf/cm95_tile_policy_japan_full_detail_v1.yaml
src/cm95pbf/builder.py
src/cm95pbf/production.py
build/production/cm95_s52_japan_production_plan_v1.json
~~~
至少回答:
- z11 实际 scale ladder;
- z12 实际 scale ladder;
- LIGHTS / beacon / buoy 等助航对象的 native scale 分布;
- z12 中存在但 z11 消失的对象主要来自哪些 native scale;
- 20k LIGHTS 是否确实是主要来源;
- 当前 builder 是如何做 scale-region quilt 和 finer-scale coverage suppression 的。
### 10.2 不修改 importer
当前问题属于 PBF production 阶段。
除非出现新的直接证据,否则不要修改:
~~~
CM93 parser
geometry normalization
MySQL importer
raw source records
~~~
### 10.3 优先研究 navigation aid 独立 retention
优先方向:
~~~
navigation_marks 使用独立 scale ladder
~~~
而不是:
~~~
整个 z11 直接启用 20k/full detail
~~~
实现时应保持:
- stable FID 不变;
- composite relation 语义不变;
- 多 LIGHTS 合成逻辑不变;
- S-52 presentation 字段不退化;
- trace / report / hard gate 继续准确;
- 其他 source-layer 不被细尺度数据无意放大。
### 10.4 单 Tile A/B 验证
至少比较:
~~~
旧 z11
候选修复 z11
对应 z12 四个 child tiles
~~~
指标:
- navigation_marks stable FID retention;
- canonical_object_type 分布;
- 非 navigation layer feature count;
- PBF byte size;
- duplicate FID;
- hard gate;
- encoder drop;
- build report;
- trace consistency。
### 10.5 Japan 多区域抽样
正式冻结前至少抽样:
- 函馆;
- 仙台;
- 东京湾;
- 大阪湾;
- 广岛湾;
- 福冈;
- 唐津;
- 长崎;
- 那霸。
不能只凭福冈一片区域通过就宣布 Japan 修复完成。
### 10.6 Japan 全量重建
不要边全国构建边修改 policy。
应先冻结:
~~~
builder contract
Japan tile policy
mapping
audit tool
acceptance thresholds
~~~
然后再生成正式 Japan production plan 并全量构建。
---
## 11. Global CM95 恢复条件
Global CM95 不因为 Japan 局部修复通过就立即恢复。
建议至少满足:
1. Japan z11 navigation aid retention 根因确认;
2. Japan 修复实现完成;
3. stable FID 不破坏;
4. composite navigation aids 无重复;
5. 非 navigation layer 无非预期变化;
6. hard gate PASS;
7. encoder drop = 0;
8. PBF 体积增长在可接受范围;
9. 多区域抽样通过;
10. Japan 全量 production audit 通过;
11. Japan release 完成;
12. NavSea 真机低 Zoom 显示回归通过。
达到以上条件后,才允许讨论:
~~~
Global CM95 Production = RESUME
~~~
即使恢复全球计划,也应先把 Japan 修复抽象成 provider-neutral / production-safe contract,再逐区域扩大,不应直接把 Japan 特例硬编码到全球路径。
---
## 12. NavSea 项目边界
CM95 修复期间,NavSea 项目不要再修改以下模块来补偿 CM95 数据:
~~~
1/2/3/4 挡
Pickup
ChartDangerAlarmSource
CrossCursor
PbfLayerGroupConfig
MapLibre query compatibility
~~~
CM95 最终交付的 PBF 应满足现有 NavSea canonical Layer / property contract。
NavSea 只消费,不承担 CM95 PBF 数据补全和 zoom retention。
---
## 13. 当前状态摘要
~~~
NavSea CM95 canonical 接入:
基本通过
NavSea 1/2/3/4 挡:
真机通过
NavSea Pickup:
真机通过
NavSea ChartDanger contract:
已核对
Canonical Style filter 漏灯:
已排除
CM95 Japan z11 低 Zoom 航标保留:
已确认存在问题
Global CM95:
PAUSED
Japan PBF:
下一优先修复目标
实际工作机器:
X220
正式仓库:
/root/sourceserver/cm95
~~~

51
scripts/setup_linux.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SOURCE_ROOT="${CM93_SOURCE_DIR:-/root/sourceserver/cm95-data/CM93JAPAN}"
VENV="$PROJECT_ROOT/.venv"
cd "$PROJECT_ROOT"
if [[ ! -d "$SOURCE_ROOT" ]]; then
echo "CM93 source not found: $SOURCE_ROOT" >&2
exit 2
fi
python3 - <<'PY'
import sys
if sys.version_info[:2] != (3, 12):
raise SystemExit(f"Python 3.12 required, got {sys.version.split()[0]}")
print(f"Python {sys.version.split()[0]}: PASS")
PY
if [[ ! -x "$VENV/bin/python" ]]; then
python3 -m venv "$VENV"
fi
"$VENV/bin/python" -m pip install --upgrade pip setuptools wheel
"$VENV/bin/pip" install -e .
if [[ ! -f .env ]]; then
cp .env.linux.example .env
chmod 600 .env
echo "Created .env from .env.linux.example"
else
chmod 600 .env
echo "Existing .env kept"
fi
"$VENV/bin/python" tools/cm95_linux_full_rebuild_preflight.py
"$VENV/bin/cm93db" scan "$SOURCE_ROOT"
"$VENV/bin/cm93db" inspect-dicts "$SOURCE_ROOT"
if grep -Eq '^CM93_DB_PASSWORD=(|change_me)$' .env; then
echo
echo "Linux source/code preflight: PASS"
echo "Database credential: PENDING (CM93_DB_PASSWORD is empty)"
echo "Edit $PROJECT_ROOT/.env before DB/full-rebuild operations."
else
echo
echo "Linux source/code preflight: PASS"
echo "Database password is configured; run cm93db env-check before rebuild."
fi

View File

@@ -10,7 +10,13 @@ from shapely import from_wkb
from .cm93_binary import parse_cell
from .config import DbConfig
from .database import connect, assert_mysql57, server_version
from .database import (
connect,
assert_mysql_compatible,
server_version,
mysql_compat_mode_for_connection,
as_wkb_sql,
)
from .dictionaries import load_object_dictionary, load_attribute_dictionary
from .geometry import build_coverages, build_feature
from .scanner import scan_cm93, CellFile
@@ -173,13 +179,14 @@ def _dataset_id(conn, root: Path) -> int | None:
def _precision_db_geom(conn, cell_id: int, sample: PrecisionSample):
db_mode = mysql_compat_mode_for_connection(conn)
if sample.geometry_type == "SOUNDING":
sql=("SELECT ST_AsWKB(s.geom) AS wkb FROM chart_feature f JOIN chart_sounding_point s ON s.feature_id=f.id "
sql=(f"SELECT {as_wkb_sql(db_mode, 's.geom')} AS wkb FROM chart_feature f JOIN chart_sounding_point s ON s.feature_id=f.id "
"WHERE f.cell_id=%s AND f.source_record_index=%s AND s.point_index=%s")
params=(cell_id,sample.record_index,sample.point_index or 0)
else:
table={"POINT":"chart_point_geometry","LINE":"chart_line_geometry","AREA":"chart_area_geometry"}[sample.geometry_type]
sql=(f"SELECT ST_AsWKB(g.geom) AS wkb FROM chart_feature f JOIN {table} g ON g.feature_id=f.id "
sql=(f"SELECT {as_wkb_sql(db_mode, 'g.geom')} AS wkb FROM chart_feature f JOIN {table} g ON g.feature_id=f.id "
"WHERE f.cell_id=%s AND f.source_record_index=%s")
params=(cell_id,sample.record_index)
with conn.cursor() as cur:
@@ -207,7 +214,7 @@ def run_audit(cfg: DbConfig, root: Path, limit: int | None = None, scales: set[s
conn=connect(cfg)
try:
assert_mysql57(server_version(conn))
assert_mysql_compatible(server_version(conn))
did=_dataset_id(conn,root)
if did is None:
result.db_missing_cells=len(stats)

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from pathlib import Path
from .config import load_config, load_source_dir
from .database import connect, server_version, assert_mysql57, create_database, apply_schema
from .database import connect, server_version, assert_mysql_compatible, mysql_compat_mode, create_database, apply_schema
from .scanner import scan_cm93
from .dictionaries import load_object_dictionary, load_attribute_dictionary
from .schema_contract import REQUIRED_TABLES
@@ -46,7 +46,7 @@ def cmd_env_check(_args):
try:
v = server_version(conn)
print(f"MySQL: {v}")
assert_mysql57(v)
assert_mysql_compatible(v)
with conn.cursor() as cur:
cur.execute("SHOW VARIABLES LIKE 'max_allowed_packet'")
row=cur.fetchone()
@@ -54,7 +54,7 @@ def cmd_env_check(_args):
val=int(row.get("Value",0)); print(f"max_allowed_packet: {val} bytes")
if val < 4*1024*1024:
print("WARNING: max_allowed_packet 较小;建议至少 4 MiB。raw table 已按 512 KiB 分块。")
print("MySQL 5.7 compatibility: OK")
print(f"MySQL compatibility: {mysql_compat_mode(v)} OK")
finally:
conn.close()
@@ -74,9 +74,9 @@ def cmd_db_check(_args):
conn = connect(cfg)
try:
v = server_version(conn)
assert_mysql57(v)
assert_mysql_compatible(v)
with conn.cursor() as cur:
cur.execute("SELECT table_name FROM information_schema.tables WHERE table_schema=%s", (cfg.database,))
cur.execute("SELECT TABLE_NAME AS table_name FROM information_schema.tables WHERE table_schema=%s", (cfg.database,))
tables = {row["table_name"] for row in cur.fetchall()}
missing = sorted(REQUIRED_TABLES - tables)
print(f"MySQL: {v}")

View File

@@ -27,23 +27,73 @@ def server_version(conn) -> str:
return str(cur.fetchone()["v"])
def assert_mysql57(version: str) -> None:
def mysql_version_tuple(version: str) -> tuple[int, int, int]:
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", version)
if not m:
raise RuntimeError(f"无法识别 MySQL 版本: {version}")
major, minor, _patch = map(int, m.groups())
if (major, minor) != (5, 7):
return tuple(map(int, m.groups()))
def mysql_compat_mode(version: str) -> str:
major, minor, _patch = mysql_version_tuple(version)
if (major, minor) == (5, 7):
return "mysql57"
if (major, minor) == (8, 0):
return "mysql80"
raise RuntimeError(
f"项目数据库基线是 MySQL 5.7.34;当前服务器为 {version}。"
"不要直接继续初始化,先确认兼容策略。"
f"CM95 当前仅验证 MySQL 5.7.x / 8.0.x;当前服务器为 {version}。"
)
def assert_mysql_compatible(version: str) -> None:
mysql_compat_mode(version)
_MYSQL_COMPAT_MODE_CACHE: dict[tuple[str, int, str], str] = {}
def mysql_compat_mode_for_connection(conn) -> str:
key = (
str(getattr(conn, "host", "")),
int(getattr(conn, "port", 0) or 0),
str(getattr(conn, "user", "")),
)
cached = _MYSQL_COMPAT_MODE_CACHE.get(key)
if cached is not None:
return cached
mode = mysql_compat_mode(server_version(conn))
_MYSQL_COMPAT_MODE_CACHE[key] = mode
return mode
def assert_mysql57(version: str) -> None:
# Backward-compatible public name retained for older tools/scripts.
assert_mysql_compatible(version)
def geom_from_wkb_sql(mode: str, value_sql: str = "%s", srid: int = 4326) -> str:
if mode == "mysql80":
return f"ST_GeomFromWKB({value_sql},{srid},'axis-order=long-lat')"
return f"ST_GeomFromWKB({value_sql},{srid})"
def geom_from_text_sql(mode: str, value_sql: str = "%s", srid: int = 4326) -> str:
if mode == "mysql80":
return f"ST_GeomFromText({value_sql},{srid},'axis-order=long-lat')"
return f"ST_GeomFromText({value_sql},{srid})"
def as_wkb_sql(mode: str, geom_sql: str) -> str:
if mode == "mysql80":
return f"ST_AsWKB({geom_sql},'axis-order=long-lat')"
return f"ST_AsWKB({geom_sql})"
def create_database(cfg: DbConfig) -> None:
safe = cfg.database.replace("`", "``")
conn = connect(cfg, with_database=False)
try:
assert_mysql57(server_version(conn))
assert_mysql_compatible(server_version(conn))
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{safe}` "
@@ -90,7 +140,7 @@ def apply_schema(cfg: DbConfig, schema_path: Path) -> None:
sql = schema_path.read_text(encoding="utf-8")
conn = connect(cfg)
try:
assert_mysql57(server_version(conn))
assert_mysql_compatible(server_version(conn))
with conn.cursor() as cur:
for stmt in _split_sql(sql): cur.execute(stmt)
conn.commit()

View File

@@ -12,7 +12,13 @@ from shapely.geometry.base import BaseGeometry
from . import __version__
from .cm93_binary import ParsedCell, ParsedAttribute, parse_cell, CM93ParseError
from .config import DbConfig
from .database import connect, assert_mysql57, server_version
from .database import (
connect,
assert_mysql_compatible,
server_version,
mysql_compat_mode_for_connection,
geom_from_wkb_sql,
)
from .dictionaries import load_object_dictionary, load_attribute_dictionary, ObjectDef, AttributeDef
from .geometry import build_coverages, build_feature, BuiltFeature
from .scanner import scan_cm93, CellFile
@@ -489,6 +495,8 @@ def _insert_parsed_cell(
) -> tuple[int, int, int, int, int]:
_clear_cell_payload(conn, cell_id)
h = cell.header
db_mode = mysql_compat_mode_for_connection(conn)
geom_from_wkb = geom_from_wkb_sql(db_mode)
with conn.cursor() as cur:
cur.execute(
@@ -593,7 +601,7 @@ def _insert_parsed_cell(
"wgsox,wgsoy,attributes_json,geom,"
"geometry_repaired,geometry_issue) "
"VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,"
"ST_GeomFromWKB(%s,4326),%s,%s)",
f"{geom_from_wkb},%s,%s)",
cov_rows,
)
@@ -770,19 +778,19 @@ def _insert_parsed_cell(
point_rows,
"INSERT INTO chart_point_geometry"
"(feature_id,geom) "
"VALUES(%s,ST_GeomFromWKB(%s,4326))",
f"VALUES(%s,{geom_from_wkb})",
),
(
line_rows,
"INSERT INTO chart_line_geometry"
"(feature_id,geom) "
"VALUES(%s,ST_GeomFromWKB(%s,4326))",
f"VALUES(%s,{geom_from_wkb})",
),
(
area_rows,
"INSERT INTO chart_area_geometry"
"(feature_id,geom) "
"VALUES(%s,ST_GeomFromWKB(%s,4326))",
f"VALUES(%s,{geom_from_wkb})",
),
]:
for i in range(0, len(rows), 500):
@@ -796,7 +804,7 @@ def _insert_parsed_cell(
"INSERT INTO chart_sounding_point"
"(feature_id,point_index,raw_x,raw_y,raw_z,depth,geom) "
"VALUES(%s,%s,%s,%s,%s,%s,"
"ST_GeomFromWKB(%s,4326))",
f"{geom_from_wkb})",
sounding_rows[i:i + 1000],
)
@@ -881,7 +889,7 @@ def run_import(
conn = connect(cfg)
try:
assert_mysql57(
assert_mysql_compatible(
server_version(conn)
)
dataset_id = _dataset(

View File

@@ -25,6 +25,11 @@ from shapely.geometry import (
from shapely.ops import transform, unary_union
from cm93db.config import DbConfig
from cm93db.database import (
mysql_compat_mode_for_connection,
as_wkb_sql,
geom_from_text_sql,
)
from . import BUILDER_CONTRACT_VERSION
from .identity import (
@@ -335,6 +340,18 @@ class Cm95TileBuilder:
raise RuntimeError(f"no scale ladder configured for z{z}")
return [int(v) for v in raw]
def navigation_aid_scale_ladder_for_zoom(self, z: int) -> list[int]:
ladders = self.policy.get("navigation_aid_scale_ladders", {}) or {}
raw = ladders.get(str(z), ladders.get(z))
if not raw:
return []
values = [int(v) for v in raw]
if len(values) != len(set(values)):
raise RuntimeError(
f"duplicate navigation aid scale in z{z}: {values}"
)
return values
def source_id(self, row: SourceFeature) -> str:
return feature_source_key(
self.dataset_id,
@@ -582,6 +599,23 @@ class Cm95TileBuilder:
trace_rows,
)
navigation_aid_scales = self.navigation_aid_scale_ladder_for_zoom(
job.z
)
if navigation_aid_scales:
self._replace_navigation_marks_with_retention_ladder(
conn=conn,
manifest=manifest,
job=job,
scales=navigation_aid_scales,
query_bbox_wgs=query_bbox_wgs,
expanded_box_m=expanded_box_m,
tile_box_m=tile_box_m,
layer_features=layer_features,
trace_rows=trace_rows,
report=report,
)
# Delivery label renderability gate:
# label-only source layers must contain at least one property that the
# frozen NavSea style can actually render. A label feature without
@@ -1107,6 +1141,197 @@ class Cm95TileBuilder:
regions.append((scale, visible))
return regions
def _replace_navigation_marks_with_retention_ladder(
self,
*,
conn,
manifest: FidManifest,
job: TileJob,
scales: list[int],
query_bbox_wgs: tuple[float, float, float, float],
expanded_box_m,
tile_box_m,
layer_features: dict[str, list[dict[str, Any]]],
trace_rows: list[dict[str, Any]],
report: dict[str, Any],
) -> None:
default_features = list(layer_features.get("navigation_marks", []))
default_ids = {
int(feature["id"])
for feature in default_features
if feature.get("id") is not None
}
trace_rows[:] = [
item
for item in trace_rows
if str(item.get("output_layer") or "") != "navigation_marks"
]
layer_features["navigation_marks"] = []
default_composites = dict(report.get("navigation_composites") or {})
report["navigation_composites"] = {
"groups": 0,
"component_rows": 0,
"standalone_lights": 0,
"multi_light_groups": 0,
"light_presentation_records": 0,
"named_from_direct_source": 0,
"name_conflict_groups": 0,
}
scale_regions = self._build_scale_regions(
conn,
scales,
query_bbox_wgs,
expanded_box_m,
tile_box_m,
)
retention = {
"enabled": True,
"zoom": job.z,
"scale_ladder": list(scales),
"default_output_count": len(default_features),
"default_navigation_composites": default_composites,
"retained_output_count": 0,
"added_output_ids": 0,
"removed_output_ids": 0,
"input_component_rows": 0,
"scale_regions": [
{
"scale": scale,
"coverage_area_fraction_of_tile": (
float(region.area / tile_box_m.area)
if tile_box_m.area > 0
else 0.0
),
}
for scale, region in scale_regions
if not region.is_empty
],
}
report["navigation_aid_retention"] = retention
input_component_rows = 0
for scale, visible_region in scale_regions:
if visible_region.is_empty:
continue
rows = self._fetch_scale_navigation_aid_features(
conn,
scale,
query_bbox_wgs,
)
input_component_rows += len(rows)
selected: list[SourceFeature] = []
for row in rows:
geom_m = _to_mercator_geom(row.geom_wgs84)
clipped = _extract_dimensional(
geom_m.intersection(visible_region).intersection(expanded_box_m),
row.geometry_type,
)
if clipped is None or clipped.is_empty:
continue
row.geom_wgs84 = clipped
selected.append(row)
if not selected:
continue
self._s52_context = self._build_s52_context(selected)
consumed: set[int] = set()
nav_outputs = self._compose_navigation_aids(
selected,
manifest,
scale,
consumed,
trace_rows,
)
for output in nav_outputs:
layer_features[output["layer"]].append(output["feature"])
nav_report = report["navigation_composites"]
nav_report["groups"] += sum(
1 for output in nav_outputs if output.get("composite")
)
nav_report["component_rows"] += sum(
len(output.get("component_feature_ids", []))
for output in nav_outputs
if output.get("composite")
)
nav_report["standalone_lights"] += sum(
1 for output in nav_outputs if output.get("standalone_light")
)
nav_report["multi_light_groups"] += sum(
1
for output in nav_outputs
if output.get("composite")
and int(output.get("light_record_count") or 0) > 1
)
nav_report["light_presentation_records"] += sum(
int(output.get("light_presentation_count") or 0)
for output in nav_outputs
if output.get("composite")
)
nav_report["named_from_direct_source"] += sum(
1
for output in nav_outputs
if output.get("name_source") == "direct_source"
)
nav_report["name_conflict_groups"] += sum(
1
for output in nav_outputs
if output.get("name_conflict")
)
for row in selected:
if row.feature_id in consumed:
continue
rule = self.mapping.resolve(row.object_class, row.geometry_type)
if rule is None:
continue
if str(rule.get("target_source_layer") or "") != "navigation_marks":
continue
status = str(rule.get("mapping_status") or "")
if (
self.profile == "delivery"
and status not in DELIVERY_MAIN_STATUSES
):
continue
self._append_main_output(
layer_features,
manifest,
row,
rule,
trace_rows,
)
unique_features: dict[int, dict[str, Any]] = {}
for feature in layer_features.get("navigation_marks", []):
output_id = int(feature["id"])
unique_features.setdefault(output_id, feature)
layer_features["navigation_marks"] = list(unique_features.values())
seen_trace_ids: set[int] = set()
deduped_traces: list[dict[str, Any]] = []
for item in trace_rows:
if str(item.get("output_layer") or "") != "navigation_marks":
deduped_traces.append(item)
continue
output_id = int(item["output_id"])
if output_id in seen_trace_ids:
continue
seen_trace_ids.add(output_id)
deduped_traces.append(item)
trace_rows[:] = deduped_traces
retained_ids = set(unique_features)
retention["input_component_rows"] = input_component_rows
retention["retained_output_count"] = len(retained_ids)
retention["added_output_ids"] = len(retained_ids - default_ids)
retention["removed_output_ids"] = len(default_ids - retained_ids)
def _coverage_for_scale(
self,
conn,
@@ -1114,6 +1339,8 @@ class Cm95TileBuilder:
query_bbox_wgs: tuple[float, float, float, float],
expanded_box_m,
):
db_mode = mysql_compat_mode_for_connection(conn)
coverage_wkb_sql = as_wkb_sql(db_mode, "geom")
west, south, east, north = query_bbox_wgs
with conn.cursor() as cur:
cur.execute(
@@ -1153,7 +1380,7 @@ class Cm95TileBuilder:
f"""
SELECT
cell_id,
ST_AsWKB(geom) AS geom_wkb
{coverage_wkb_sql} AS geom_wkb
FROM chart_coverage
WHERE dataset_id=%s
AND cell_id IN ({placeholders})
@@ -1198,12 +1425,102 @@ class Cm95TileBuilder:
"))"
)
def _fetch_scale_navigation_aid_features(
self,
conn,
scale: int,
bbox_wgs: tuple[float, float, float, float],
) -> list[SourceFeature]:
db_mode = mysql_compat_mode_for_connection(conn)
feature_wkb_sql = as_wkb_sql(db_mode, "g.geom")
envelope_geom_sql = geom_from_text_sql(db_mode)
west, south, east, north = bbox_wgs
envelope_wkt = self._tile_envelope_wkt(bbox_wgs)
classes = sorted(NAV_COMPONENT_CLASSES)
placeholders = ",".join(["%s"] * len(classes))
with conn.cursor() as cur:
cur.execute(
f"""
SELECT
f.id AS feature_id,
f.cell_id,
c.source_relative_path,
f.source_record_index,
f.object_class,
f.geometry_type,
f.native_scale,
f.coverage_id,
f.attributes_json,
{feature_wkb_sql} AS geom_wkb
FROM chart_point_geometry g FORCE INDEX (spx_point_geom)
STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id
JOIN chart_cell c ON c.id=f.cell_id
WHERE MBRIntersects(
g.geom,
{envelope_geom_sql}
)
AND f.dataset_id=%s
AND f.native_scale=%s
AND f.geometry_type='POINT'
AND f.object_class IN ({placeholders})
AND f.bbox_max_lon >= %s
AND f.bbox_min_lon <= %s
AND f.bbox_max_lat >= %s
AND f.bbox_min_lat <= %s
""",
(
envelope_wkt,
self.dataset_id,
scale,
*classes,
west,
east,
south,
north,
),
)
rows: list[SourceFeature] = []
for raw in cur.fetchall():
geom = _loads_wkb(raw["geom_wkb"])
if geom is None or geom.is_empty:
continue
rows.append(
SourceFeature(
feature_id=int(raw["feature_id"]),
cell_id=int(raw["cell_id"]),
source_relative_path=str(raw["source_relative_path"]),
source_record_index=int(raw["source_record_index"]),
object_class=str(raw["object_class"]),
geometry_type=str(raw["geometry_type"]),
native_scale=int(raw["native_scale"]),
coverage_id=(
int(raw["coverage_id"])
if raw["coverage_id"] is not None
else None
),
attrs=_json_dict(raw["attributes_json"]),
geom_wgs84=geom,
)
)
rows.sort(
key=lambda row: (
row.source_relative_path,
row.source_record_index,
)
)
return rows
def _fetch_scale_features(
self,
conn,
scale: int,
bbox_wgs: tuple[float, float, float, float],
) -> list[SourceFeature]:
db_mode = mysql_compat_mode_for_connection(conn)
feature_wkb_sql = as_wkb_sql(db_mode, "g.geom")
envelope_geom_sql = geom_from_text_sql(db_mode)
west, south, east, north = bbox_wgs
envelope_wkt = self._tile_envelope_wkt(bbox_wgs)
rows: list[SourceFeature] = []
@@ -1234,13 +1551,13 @@ class Cm95TileBuilder:
f.native_scale,
f.coverage_id,
f.attributes_json,
ST_AsWKB(g.geom) AS geom_wkb
{feature_wkb_sql} AS geom_wkb
FROM {table} g FORCE INDEX ({spatial_index})
STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id
JOIN chart_cell c ON c.id=f.cell_id
WHERE MBRIntersects(
g.geom,
ST_GeomFromText(%s, 4326)
{envelope_geom_sql}
)
AND f.dataset_id=%s
AND f.native_scale=%s
@@ -1304,10 +1621,13 @@ class Cm95TileBuilder:
scale: int,
bbox_wgs: tuple[float, float, float, float],
) -> list[dict[str, Any]]:
db_mode = mysql_compat_mode_for_connection(conn)
sounding_wkb_sql = as_wkb_sql(db_mode, "s.geom")
envelope_geom_sql = geom_from_text_sql(db_mode)
envelope_wkt = self._tile_envelope_wkt(bbox_wgs)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT
f.id AS feature_id,
f.cell_id,
@@ -1317,12 +1637,12 @@ class Cm95TileBuilder:
f.attributes_json,
s.point_index,
s.depth,
ST_AsWKB(s.geom) AS geom_wkb
{sounding_wkb_sql} AS geom_wkb
FROM chart_sounding_point s FORCE INDEX (spx_sounding_geom)
STRAIGHT_JOIN chart_feature f ON f.id=s.feature_id
JOIN chart_cell c ON c.id=f.cell_id
WHERE MBRContains(
ST_GeomFromText(%s, 4326),
{envelope_geom_sql},
s.geom
)
AND f.dataset_id=%s

View File

@@ -21,6 +21,7 @@ from shapely.geometry import box
from shapely.ops import transform
from cm93db.config import DbConfig
from cm93db.database import mysql_compat_mode_for_connection, as_wkb_sql
from . import BUILDER_CONTRACT_VERSION
from .builder import Cm95TileBuilder, TileJob
@@ -206,6 +207,8 @@ def _connect(db: DbConfig):
def _fetch_plan_geometries(conn, dataset_id: int, scales: list[int]):
db_mode = mysql_compat_mode_for_connection(conn)
coverage_wkb_sql = as_wkb_sql(db_mode, "geom")
placeholders = ",".join(["%s"] * len(scales))
with conn.cursor() as cur:
cur.execute(
@@ -234,7 +237,7 @@ def _fetch_plan_geometries(conn, dataset_id: int, scales: list[int]):
with conn.cursor() as cur:
cur.execute(
f"""
SELECT cell_id, ST_AsWKB(geom) AS geom_wkb
SELECT cell_id, {coverage_wkb_sql} AS geom_wkb
FROM chart_coverage
WHERE dataset_id=%s
AND cell_id IN ({ph})

View File

@@ -5,6 +5,8 @@ import argparse, csv, getpass, json, sys
from datetime import datetime
from pathlib import Path
from cm93db.database import as_wkb_sql, mysql_compat_mode_for_connection
try:
import pymysql
from pymysql.cursors import DictCursor
@@ -33,16 +35,6 @@ def as_bytes(v):
if isinstance(v, bytearray): return bytes(v)
return v
def wkb_fn(cur, table):
for fn in ("ST_AsBinary","ST_AsWKB","AsBinary","AsWKB"):
try:
cur.execute(f"SELECT {fn}(geom) AS g FROM {table} LIMIT 1")
cur.fetchone()
return fn
except Exception:
pass
raise RuntimeError(f"{table}: 无可用 Geometry->WKB 函数")
def main():
ap = argparse.ArgumentParser(description="CM93 normalization 最终全库 Geometry 验收")
ap.add_argument("--host", default="127.0.0.1")
@@ -52,6 +44,7 @@ def main():
ap.add_argument("--database", default="cm93_chart")
ap.add_argument("--dataset-id", type=int, default=1)
ap.add_argument("--bbox-tolerance", type=float, default=5e-6)
ap.add_argument("--expected-cells", type=int, default=None, help="可选:要求 chart_cell 总数匹配,例如当前日本全量为 1492")
a = ap.parse_args()
if a.password is None:
a.password = getpass.getpass("MySQL password: ")
@@ -67,8 +60,10 @@ def main():
connect_timeout=20, read_timeout=900
)
db_mode = mysql_compat_mode_for_connection(conn)
failures = []
report = {"dataset_id": a.dataset_id, "generated_at": datetime.now().isoformat()}
report = {"dataset_id": a.dataset_id, "generated_at": datetime.now().isoformat(), "mysql_compat_mode": db_mode}
try:
with conn.cursor() as cur:
@@ -90,20 +85,21 @@ def main():
report["counts"] = counts
expected = {
"cells": 784, "features": 322465,
"feature_repaired": 55, "coverage_repaired": 3,
"feature_issues": 0, "coverage_issues": 0,
"geotype16_total": 4406, "geotype16_issues": 0,
"feature_issues": 0,
"coverage_issues": 0,
"geotype16_issues": 0,
}
if a.expected_cells is not None:
expected["cells"] = a.expected_cells
for k,v in expected.items():
if counts[k] != v:
failures.append({"check":k,"expected":v,"actual":counts[k]})
afn = wkb_fn(cur, "chart_area_geometry")
area_wkb = as_wkb_sql(db_mode, "g.geom")
cur.execute(f"""
SELECT f.id,f.object_class,f.geometry_repaired,
f.bbox_min_lon,f.bbox_min_lat,f.bbox_max_lon,f.bbox_max_lat,
{afn}(g.geom) AS wkb
{area_wkb} AS wkb
FROM chart_feature f
JOIN chart_area_geometry g ON g.feature_id=f.id
WHERE f.dataset_id=%s
@@ -125,9 +121,9 @@ def main():
except Exception as e:
failures.append({"check":"area_wkb","feature_id":r["id"],"error":repr(e)})
cfn = wkb_fn(cur, "chart_coverage")
coverage_wkb = as_wkb_sql(db_mode, "geom")
cur.execute(f"""
SELECT id,cell_id,source_record_index,geometry_repaired,{cfn}(geom) AS wkb
SELECT id,cell_id,source_record_index,geometry_repaired,{coverage_wkb} AS wkb
FROM chart_coverage WHERE dataset_id=%s
""",(a.dataset_id,))
cov_total=cov_valid=0
@@ -158,6 +154,7 @@ def main():
(outdir/"最终验收报告_v1.json").write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding="utf-8")
summary = f"""CM93 normalization 最终全库 Geometry 验收 v1
Dataset ID : {a.dataset_id}
MySQL compatibility : {report['mysql_compat_mode']}
Cells : {report['counts']['cells']}
Features : {report['counts']['features']}
Feature repaired : {report['counts']['feature_repaired']}

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
import os
import resource
import shutil
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import mapbox_vector_tile
import numpy as np
from shapely.geometry import box, mapping
from shapely import union_all
import cm95_bathymetry_build_v1 as v1
import cm95_bathymetry_build_v3_dense as v3
import cm95_bathymetry_build_v5_adaptive as v5
ROOT = Path(__file__).resolve().parents[1]
BASE_CONFIG = ROOT / "config" / "bathymetry" / "cm95_bathymetry_north_kyushu_v5_adaptive.yaml"
PLAN_PATH = ROOT / "build" / "production" / "cm95_s52_japan_production_plan_v1.json"
PROBE_PATH = ROOT / "build" / "bathymetry" / "national_z8_probe.json"
OUTPUT_ROOT = ROOT / "build" / "bathymetry" / "cm95-bathymetry-japan-v5-adaptive"
AOI = [122.0, 20.0, 154.5, 46.0]
BUILD_ID = "cm95-bathymetry-japan-v5-adaptive"
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Build nationwide Japan v5 bathymetry PBF by z8 core chunks.")
p.add_argument("--only", default="", help="Comma separated z8 x/y pairs, e.g. 222/102,223/101")
p.add_argument("--limit", type=int, default=0)
p.add_argument("--overlap-m", type=float, default=6000.0)
p.add_argument("--resume", action="store_true")
p.add_argument("--workers", type=int, default=1)
p.add_argument("--shard-count", type=int, default=1)
p.add_argument("--shard-index", type=int, default=0)
p.add_argument("--no-finalize", action="store_true")
p.add_argument("--output-root", type=Path, default=OUTPUT_ROOT)
return p.parse_args()
def load_active_tiles() -> list[tuple[int, int, int, int]]:
plan = json.loads(PLAN_PATH.read_text(encoding="utf-8"))
plan_z8 = {(int(j["x"]), int(j["y"])) for j in plan["jobs"] if int(j["z"]) == 8}
probe = json.loads(PROBE_PATH.read_text(encoding="utf-8"))
active = []
for row in probe:
x = int(float(row["x8"]))
y = int(float(row["y8"]))
shallow = int(row.get("shallow60") or 0)
n = int(row.get("n") or 0)
if shallow > 0 and (x, y) in plan_z8:
active.append((x, y, n, shallow))
active.sort(key=lambda item: (item[1], item[0]))
return active
def mercator_aoi_box():
west, south = v1.lonlat_to_mercator(AOI[0], AOI[1])
east, north = v1.lonlat_to_mercator(AOI[2], AOI[3])
return box(west, south, east, north)
def core_box_for_z8(x: int, y: int):
tile = v1.xyz_tile_bounds(8, x, y)
return box(tile.west_m, tile.south_m, tile.east_m, tile.north_m).intersection(mercator_aoi_box())
def bbox_wgs84_for_mercator(geom) -> list[float]:
minx, miny, maxx, maxy = geom.bounds
west, south = v1.mercator_to_lonlat(minx, miny)
east, north = v1.mercator_to_lonlat(maxx, maxy)
return [float(west), float(south), float(east), float(north)]
def descendant_tiles(x8: int, y8: int, z: int):
factor = 1 << (z - 8)
for x in range(x8 * factor, (x8 + 1) * factor):
for y in range(y8 * factor, (y8 + 1) * factor):
yield x, y
def build_core_tiles(
contours: dict[float, Any],
cfg: dict[str, Any],
output_root: Path,
x8: int,
y8: int,
) -> dict[str, Any]:
pbf_root = output_root / "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 = BUILD_ID
major_contours = {float(v) for v in cfg.get("major_contours_m", cfg["contour_levels_m"])}
simplify_by_zoom = {int(k): float(v) for k, v in cfg.get("simplify_m_by_zoom", {}).items()}
tile_count = feature_count = total_bytes = encoder_dropped = 0
zoom_stats: dict[str, Any] = {}
for z in range(int(cfg["minzoom"]), int(cfg["maxzoom"]) + 1):
z_tiles = z_features = z_bytes = z_drop = 0
simplify_m = simplify_by_zoom.get(z, 0.0)
for x, y in descendant_tiles(x8, y8, z):
tile = v1.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 = []
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 v1.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 v1.line_survives_mvt_quantization(part, tile.mercator_tuple, extent):
continue
local_index += 1
features.append({
"id": v1.feature_id(build_id, z, x, y, depth, local_index, part),
"geometry": mapping(part),
"properties": {
"depth_m": float(depth),
"contour_class": "major" if float(depth) in major_contours else "minor",
"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},
)
actual = len(mapbox_vector_tile.decode(data).get(source_layer, {}).get("features", []))
dropped = max(0, len(features)-actual)
encoder_dropped += dropped
z_drop += dropped
tile_path = pbf_root / str(z) / str(x) / f"{y}.pbf"
tile_path.parent.mkdir(parents=True, exist_ok=True)
if tile_path.exists():
raise RuntimeError(f"tile ownership collision: {tile_path}")
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_drop, "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 extract_contours_single_pass_fast(
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[Any]] = {level: [] for level in levels}
fallback_counts: dict[float, int] = {level: 0 for level in levels}
v00 = grid[:-1, :-1]
v10 = grid[:-1, 1:]
v11 = grid[1:, 1:]
v01 = grid[1:, :-1]
finite_count = (
np.isfinite(v00).astype(np.uint8)
+ np.isfinite(v10).astype(np.uint8)
+ np.isfinite(v11).astype(np.uint8)
+ np.isfinite(v01).astype(np.uint8)
)
lo = np.fmin(np.fmin(v00, v10), np.fmin(v11, v01))
hi = np.fmax(np.fmax(v00, v10), np.fmax(v11, v01))
candidate_mask = (
(finite_count >= 3)
& np.isfinite(lo)
& np.isfinite(hi)
& (lo != hi)
& (hi >= levels[0])
& (lo <= levels[-1])
)
candidate_cells = np.argwhere(candidate_mask)
import bisect
import math
for iy, ix in candidate_cells:
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)]
cell_lo = min(finite_values)
cell_hi = max(finite_values)
start = bisect.bisect_left(levels, cell_lo)
stop = bisect.bisect_right(levels, cell_hi)
finite_n = len(finite_values)
y0 = float(ys[iy])
y1 = float(ys[iy + 1])
x0 = float(xs[ix])
x1 = float(xs[ix + 1])
for level in levels[start:stop]:
cell_segments = v3._cell_contours(
x0,
x1,
y0,
y1,
values,
level,
)
segments[level].extend(cell_segments)
if finite_n == 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))
import cm95_bathymetry_build_v2_smooth as v2
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
from shapely import line_merge
merged = line_merge(union_all(raw))
parts: list[Any] = []
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,
),
}
stats["_performance"] = {
"candidate_cells": int(len(candidate_cells)),
"all_cells": int((len(ys) - 1) * (len(xs) - 1)),
}
return contours, stats
def clip_contours_to_core(contours: dict[float, Any], core_box) -> dict[float, Any]:
out: dict[float, Any] = {}
for depth, geom in contours.items():
clipped = geom.intersection(core_box)
parts = [part for part in v1.line_parts(clipped) if not part.is_empty and part.length > 0]
if parts:
out[depth] = union_all(parts)
return out
def build_chunk(
x8: int,
y8: int,
point_hint: int,
shallow_hint: int,
overlap_m: float,
output_root: Path,
resume: bool,
) -> dict[str, Any]:
chunk_dir = output_root / "chunks"
chunk_dir.mkdir(parents=True, exist_ok=True)
report_path = chunk_dir / f"z8_{x8}_{y8}.json"
if resume and report_path.exists():
old = json.loads(report_path.read_text(encoding="utf-8"))
if old.get("status") == "PASS":
return old
started = time.monotonic()
started_at = datetime.now(timezone.utc)
cfg = v1.load_config(BASE_CONFIG)
cfg["build_id"] = BUILD_ID
cfg["source_kind"] = "derived_sounding_adaptive_tin_v5"
core_box = core_box_for_z8(x8, y8)
if core_box.is_empty:
return {"status": "SKIP", "x8": x8, "y8": y8, "reason": "outside_aoi"}
expanded = core_box.buffer(float(overlap_m), cap_style=3, join_style=2)
cfg["bbox_wgs84"] = bbox_wgs84_for_mercator(expanded)
conn = v1.connect_db()
try:
points, sounding_stats = v1.fetch_soundings(conn, cfg)
if len(points) < 3:
result = {"status": "SKIP", "x8": x8, "y8": y8, "reason": "fewer_than_3_soundings", "duration_seconds": round(time.monotonic()-started,3)}
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
water_mask, water_rows = v1.fetch_water_mask(conn, cfg)
finally:
conn.close()
xs, ys, raw_grid, water_grid, local_spacing_grid, surface_stats = v5.build_adaptive_tin_surface(points, water_mask, cfg)
repaired_grid, gap_stats = v3.repair_small_water_gaps(raw_grid, water_grid, cfg)
newly_filled = (~__import__("numpy").isfinite(raw_grid)) & __import__("numpy").isfinite(repaired_grid)
local_spacing_grid[newly_filled] = float(cfg["smooth_spacing_high_m"])
smooth_grid, smooth_stats = v5.adaptive_smooth_surface(repaired_grid, water_grid, local_spacing_grid, cfg)
contours, contour_stats = extract_contours_single_pass_fast(xs, ys, smooth_grid, water_mask, cfg)
core_contours = clip_contours_to_core(contours, core_box)
tile_stats = build_core_tiles(core_contours, cfg, output_root, x8, y8) if core_contours else {
"pbf_tiles": 0, "tile_feature_instances": 0, "encoder_dropped_features": 0, "pbf_bytes": 0, "zooms": {}
}
duration = round(time.monotonic()-started, 3)
peak_kib = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
result = {
"status": "PASS",
"x8": x8,
"y8": y8,
"point_hint": point_hint,
"shallow60_hint": shallow_hint,
"input_bbox_wgs84": cfg["bbox_wgs84"],
"core_bbox_wgs84": bbox_wgs84_for_mercator(core_box),
"overlap_m": overlap_m,
"started_at": started_at.isoformat(),
"duration_seconds": duration,
"peak_rss_kib_process": peak_kib,
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_stats,
"gap_repair_stats": gap_stats,
"surface_smooth_stats": smooth_stats,
"contour_levels_generated": sorted(float(k) for k in core_contours.keys()),
"contour_stats": contour_stats,
"tile_stats": tile_stats,
}
report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
return result
def aggregate(output_root: Path, active_count: int, started_at: datetime, elapsed: float) -> dict[str, Any]:
reports = []
for path in sorted((output_root / "chunks").glob("z8_*.json")):
reports.append(json.loads(path.read_text(encoding="utf-8")))
passed = [r for r in reports if r.get("status") == "PASS"]
skipped = [r for r in reports if r.get("status") == "SKIP"]
tile_stats = {
"pbf_tiles": sum(int(r.get("tile_stats", {}).get("pbf_tiles", 0)) for r in passed),
"tile_feature_instances": sum(int(r.get("tile_stats", {}).get("tile_feature_instances", 0)) for r in passed),
"encoder_dropped_features": sum(int(r.get("tile_stats", {}).get("encoder_dropped_features", 0)) for r in passed),
"pbf_bytes": sum(int(r.get("tile_stats", {}).get("pbf_bytes", 0)) for r in passed),
}
actual_files = list((output_root / "pbf").rglob("*.pbf")) if (output_root / "pbf").exists() else []
actual_bytes = sum(p.stat().st_size for p in actual_files)
return {
"schema": "navsea.cm95.bathymetry-national-result/v1",
"build_id": BUILD_ID,
"aoi_wgs84": AOI,
"algorithm_baseline": "v5-adaptive",
"active_z8_expected": active_count,
"chunk_reports": len(reports),
"chunks_pass": len(passed),
"chunks_skip": len(skipped),
"started_at": started_at.isoformat(),
"finished_at": datetime.now(timezone.utc).isoformat(),
"elapsed_seconds": round(elapsed, 3),
"sum_chunk_seconds": round(sum(float(r.get("duration_seconds", 0)) for r in reports), 3),
"max_chunk_seconds": round(max([float(r.get("duration_seconds", 0)) for r in reports] or [0]), 3),
"tile_stats_sum": tile_stats,
"actual_pbf_files": len(actual_files),
"actual_pbf_bytes": actual_bytes,
"disk_usage_bytes": sum(p.stat().st_size for p in output_root.rglob("*") if p.is_file()),
}
def main() -> int:
args = parse_args()
output_root = args.output_root.resolve()
output_root.mkdir(parents=True, exist_ok=True)
active = load_active_tiles()
only = set()
if args.only.strip():
for token in args.only.split(","):
x, y = token.strip().split("/")
only.add((int(x), int(y)))
active = [item for item in active if (item[0], item[1]) in only]
if args.limit > 0:
active = active[: args.limit]
if args.shard_count < 1:
raise SystemExit("--shard-count must be >= 1")
if args.shard_index < 0 or args.shard_index >= args.shard_count:
raise SystemExit("--shard-index must satisfy 0 <= index < count")
active = [
item
for index, item in enumerate(active)
if index % args.shard_count == args.shard_index
]
started_at = datetime.now(timezone.utc)
started = time.monotonic()
print(f"NATIONAL_BUILD {BUILD_ID}", flush=True)
print(f"ACTIVE_Z8 {len(active)}", flush=True)
print(f"OUTPUT {output_root}", flush=True)
workers = max(1, int(args.workers))
print(f"WORKERS {workers}", flush=True)
if workers == 1:
for index, (x8, y8, n, shallow) in enumerate(active, start=1):
t0 = time.monotonic()
print(f"CHUNK_START {index}/{len(active)} z8/{x8}/{y8} points={n} shallow60={shallow}", flush=True)
result = build_chunk(x8, y8, n, shallow, args.overlap_m, output_root, args.resume)
print(
f"CHUNK_DONE {index}/{len(active)} z8/{x8}/{y8} status={result.get('status')} "
f"seconds={result.get('duration_seconds', round(time.monotonic()-t0,3))} "
f"tiles={result.get('tile_stats',{}).get('pbf_tiles',0)} "
f"bytes={result.get('tile_stats',{}).get('pbf_bytes',0)}",
flush=True,
)
else:
from concurrent.futures import ProcessPoolExecutor, as_completed
completed = 0
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = {}
for x8, y8, n, shallow in active:
print(f"CHUNK_QUEUE z8/{x8}/{y8} points={n} shallow60={shallow}", flush=True)
future = executor.submit(
build_chunk,
x8,
y8,
n,
shallow,
args.overlap_m,
output_root,
args.resume,
)
futures[future] = (x8, y8, n, shallow)
for future in as_completed(futures):
x8, y8, n, shallow = futures[future]
completed += 1
try:
result = future.result()
except Exception as exc:
print(
f"CHUNK_FAIL {completed}/{len(active)} z8/{x8}/{y8} "
f"error={type(exc).__name__}:{exc}",
flush=True,
)
raise
print(
f"CHUNK_DONE {completed}/{len(active)} z8/{x8}/{y8} "
f"status={result.get('status')} "
f"seconds={result.get('duration_seconds',0)} "
f"tiles={result.get('tile_stats',{}).get('pbf_tiles',0)} "
f"bytes={result.get('tile_stats',{}).get('pbf_bytes',0)}",
flush=True,
)
if not args.no_finalize:
manifest = aggregate(
output_root,
len(active),
started_at,
time.monotonic()-started,
)
(output_root / "build_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(
"NATIONAL_DONE",
json.dumps(manifest, ensure_ascii=False),
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,603 @@
#!/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 cm93db.database import as_wkb_sql, geom_from_text_sql, mysql_compat_mode_for_connection
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"])
mode = mysql_compat_mode_for_connection(conn)
sql = f"""
SELECT {as_wkb_sql(mode, "s.geom")} AS geom_wkb, 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({geom_from_text_sql(mode)}, 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
point = wkb.loads(bytes(row["geom_wkb"]))
if point.is_empty:
continue
mx, my = lonlat_to_mercator(float(point.x), float(point.y))
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))
mode = mysql_compat_mode_for_connection(conn)
sql = f"""
SELECT {as_wkb_sql(mode, "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, {geom_from_text_sql(mode)})
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"])
major_contours = {
float(v)
for v in cfg.get(
"major_contours_m",
cfg.get("contour_levels_m", []),
)
}
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),
"contour_class": (
"major"
if float(depth) in major_contours
else "minor"
),
"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 = []
major_contours = {
float(v)
for v in cfg.get(
"major_contours_m",
cfg.get("contour_levels_m", []),
)
}
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),
"contour_class": (
"major"
if float(depth) in major_contours
else "minor"
),
"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",
"contour_class": "String",
"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())

View File

@@ -0,0 +1,502 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from shapely import contains_xy, line_merge, union_all
from shapely.geometry import LineString
import cm95_bathymetry_build_v1 as v1
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = (
ROOT
/ "config"
/ "bathymetry"
/ "cm95_bathymetry_north_kyushu_v2_smooth.yaml"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build smooth independent bathymetry PBF using local IDW grid interpolation."
)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def _grid_axis(start: float, stop: float, spacing: float) -> np.ndarray:
count = int(math.ceil((stop - start) / spacing)) + 1
return start + np.arange(count, dtype=np.float64) * spacing
def _bin_key(
x: float,
y: float,
minx: float,
miny: float,
bin_size: float,
) -> tuple[int, int]:
return (
int(math.floor((x - minx) / bin_size)),
int(math.floor((y - miny) / bin_size)),
)
def build_idw_grid(
points: dict[tuple[float, float], float],
water_mask,
cfg: dict[str, Any],
) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
west, south, east, north = [float(v) for v 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"])
radius = float(cfg["idw_search_radius_m"])
max_nearest = float(cfg["idw_max_nearest_m"])
min_neighbors = int(cfg["idw_min_neighbors"])
max_neighbors = int(cfg["idw_max_neighbors"])
power = float(cfg["idw_power"])
xs = _grid_axis(minx, maxx, spacing)
ys = _grid_axis(miny, maxy, spacing)
grid = np.full((len(ys), len(xs)), np.nan, dtype=np.float64)
point_rows = np.array(
[(x, y, depth) for (x, y), depth in points.items()],
dtype=np.float64,
)
bins: dict[tuple[int, int], list[int]] = {}
bin_size = radius
for index, (x, y, _depth) in enumerate(point_rows):
bins.setdefault(
_bin_key(float(x), float(y), minx, miny, bin_size),
[],
).append(index)
radius2 = radius * radius
max_nearest2 = max_nearest * max_nearest
interpolated = 0
rejected_no_neighbors = 0
rejected_nearest = 0
water_nodes = 0
for iy, y in enumerate(ys):
row_mask = contains_xy(
water_mask,
xs,
np.full(xs.shape, y, dtype=np.float64),
)
water_indexes = np.flatnonzero(row_mask)
water_nodes += int(len(water_indexes))
for ix in water_indexes:
x = float(xs[ix])
bx, by = _bin_key(x, float(y), minx, miny, bin_size)
candidate_indexes: list[int] = []
for ox in (-1, 0, 1):
for oy in (-1, 0, 1):
candidate_indexes.extend(
bins.get((bx + ox, by + oy), ())
)
if not candidate_indexes:
rejected_no_neighbors += 1
continue
candidates = point_rows[candidate_indexes]
dx = candidates[:, 0] - x
dy = candidates[:, 1] - float(y)
dist2 = dx * dx + dy * dy
inside = np.flatnonzero(dist2 <= radius2)
if len(inside) < min_neighbors:
rejected_no_neighbors += 1
continue
inside = inside[np.argsort(dist2[inside])]
if float(dist2[inside[0]]) > max_nearest2:
rejected_nearest += 1
continue
inside = inside[:max_neighbors]
chosen_d2 = dist2[inside]
chosen_depths = candidates[inside, 2]
if float(chosen_d2[0]) < 1.0:
value = float(chosen_depths[0])
else:
weights = 1.0 / np.power(
np.maximum(chosen_d2, 1.0),
power / 2.0,
)
value = float(
np.sum(weights * chosen_depths)
/ np.sum(weights)
)
grid[iy, ix] = value
interpolated += 1
stats = {
"grid_width": int(len(xs)),
"grid_height": int(len(ys)),
"grid_nodes": int(grid.size),
"water_nodes": water_nodes,
"interpolated_nodes": interpolated,
"rejected_no_neighbors": rejected_no_neighbors,
"rejected_nearest": rejected_nearest,
"finite_ratio": round(
interpolated / float(grid.size),
6,
),
}
return xs, ys, grid, stats
def _edge_intersection(
p1: tuple[float, float],
p2: tuple[float, float],
v1_value: float,
v2_value: float,
level: float,
) -> tuple[float, float] | None:
dv = v2_value - v1_value
if abs(dv) < 1e-12:
return None
t = (level - v1_value) / dv
if t < 0.0 or t > 1.0:
return None
return (
p1[0] + t * (p2[0] - p1[0]),
p1[1] + t * (p2[1] - p1[1]),
)
def _march_cell(
x0: float,
x1: float,
y0: float,
y1: float,
values: tuple[float, float, float, float],
level: float,
) -> list[LineString]:
v0, v1_value, v2, v3 = values
points = (
(x0, y0),
(x1, y0),
(x1, y1),
(x0, y1),
)
edge_vertices = ((0, 1), (1, 2), (2, 3), (3, 0))
hits: dict[int, tuple[float, float]] = {}
for edge_index, (a, b) in enumerate(edge_vertices):
va, vb = values[a], values[b]
if (va < level and vb < level) or (va > level and vb > level):
continue
hit = _edge_intersection(points[a], points[b], va, vb, level)
if hit is not None:
hits[edge_index] = hit
if len(hits) == 2:
ordered = list(hits.values())
if ordered[0] != ordered[1]:
return [LineString(ordered)]
return []
if len(hits) != 4:
return []
case_index = 0
for bit, value in enumerate(values):
if value >= level:
case_index |= 1 << bit
center_high = sum(values) * 0.25 >= level
if case_index == 5:
pairs = ((0, 1), (2, 3)) if center_high else ((3, 0), (1, 2))
elif case_index == 10:
pairs = ((3, 0), (1, 2)) if center_high else ((0, 1), (2, 3))
else:
pairs = ((0, 1), (2, 3))
return [
LineString((hits[a], hits[b]))
for a, b in pairs
if hits[a] != hits[b]
]
def chaikin_smooth(line: LineString, iterations: int) -> LineString:
coords = [(float(x), float(y)) for x, y, *rest in line.coords]
if len(coords) < 3 or iterations <= 0:
return line
closed = coords[0] == coords[-1]
if closed:
coords = coords[:-1]
for _ in range(iterations):
if len(coords) < 2:
break
output: list[tuple[float, float]] = []
if not closed:
output.append(coords[0])
pair_count = len(coords) if closed else len(coords) - 1
for index in range(pair_count):
p0 = coords[index]
p1 = coords[(index + 1) % len(coords)]
q = (
0.75 * p0[0] + 0.25 * p1[0],
0.75 * p0[1] + 0.25 * p1[1],
)
r = (
0.25 * p0[0] + 0.75 * p1[0],
0.25 * p0[1] + 0.75 * p1[1],
)
output.extend((q, r))
if not closed:
output.append(coords[-1])
coords = output
if closed and coords:
coords.append(coords[0])
return LineString(coords)
def extract_smooth_contours(
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(v) for v in cfg["contour_levels_m"])
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))
contours: dict[float, Any] = {}
stats: dict[str, Any] = {}
for level in levels:
raw_segments: list[LineString] = []
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]),
)
if not all(math.isfinite(value) for value in values):
continue
lo, hi = min(values), max(values)
if level < lo or level > hi or lo == hi:
continue
raw_segments.extend(
_march_cell(
float(xs[ix]),
float(xs[ix + 1]),
y0,
y1,
values,
level,
)
)
if not raw_segments:
stats[str(level)] = {
"raw_segments": 0,
"line_parts": 0,
"length_m": 0.0,
}
continue
merged = line_merge(union_all(raw_segments))
parts = []
for part in v1.line_parts(merged):
if part.length < min_segment:
continue
smooth = chaikin_smooth(part, smooth_iterations)
if simplify_m > 0:
smooth = smooth.simplify(
simplify_m,
preserve_topology=False,
)
clipped = smooth.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_segments),
"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_segments),
"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()
config_path = args.config.resolve()
cfg = v1.load_config(config_path)
output_dir = (
args.output_dir.resolve()
if args.output_dir is not None
else (ROOT / str(cfg["output_dir"])).resolve()
)
output_dir.mkdir(parents=True, exist_ok=True)
started = datetime.now(timezone.utc)
print(f"BUILD {cfg['build_id']}", flush=True)
print(f"BBOX {cfg['bbox_wgs84']}", flush=True)
print(f"OUTPUT {output_dir}", flush=True)
conn = v1.connect_db()
try:
points, sounding_stats = v1.fetch_soundings(conn, cfg)
print(
"SOUNDINGS",
json.dumps(sounding_stats, ensure_ascii=False),
flush=True,
)
water_mask, water_rows = v1.fetch_water_mask(conn, cfg)
print(f"WATER_AREAS {water_rows}", flush=True)
finally:
conn.close()
xs, ys, grid, surface_stats = build_idw_grid(
points,
water_mask,
cfg,
)
print(
"SURFACE",
json.dumps(surface_stats, ensure_ascii=False),
flush=True,
)
contours, contour_stats = extract_smooth_contours(
xs,
ys,
grid,
water_mask,
cfg,
)
if not contours:
raise RuntimeError("no smooth 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/v2",
"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"]),
"water_object_classes": [
str(v) for v in cfg["water_object_classes"]
],
"surface_method": "local_idw_regular_grid",
"surface_parameters": {
key: cfg[key]
for key in (
"grid_spacing_m",
"idw_search_radius_m",
"idw_max_nearest_m",
"idw_min_neighbors",
"idw_max_neighbors",
"idw_power",
"chaikin_iterations",
)
},
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_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())

View File

@@ -0,0 +1,515 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from shapely import contains_xy, line_merge, union_all
from shapely.geometry import LineString
import cm95_bathymetry_build_v1 as v1
import cm95_bathymetry_build_v2_smooth as v2
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = (
ROOT
/ "config"
/ "bathymetry"
/ "cm95_bathymetry_north_kyushu_v3_dense.yaml"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build dense smooth bathymetry PBF with conservative water-gap repair."
)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def make_water_grid_mask(
xs: np.ndarray,
ys: np.ndarray,
water_mask,
) -> np.ndarray:
mask = np.zeros((len(ys), len(xs)), dtype=bool)
for iy, y in enumerate(ys):
mask[iy, :] = contains_xy(
water_mask,
xs,
np.full(xs.shape, float(y), dtype=np.float64),
)
return mask
_NEIGHBORS4 = ((-1, 0), (1, 0), (0, -1), (0, 1))
_NEIGHBORS8 = (
(-1, -1), (-1, 0), (-1, 1),
(0, -1), (0, 1),
(1, -1), (1, 0), (1, 1),
)
def _collect_component(
start_y: int,
start_x: int,
missing: np.ndarray,
visited: np.ndarray,
) -> list[tuple[int, int]]:
height, width = missing.shape
queue = deque([(start_y, start_x)])
visited[start_y, start_x] = True
component: list[tuple[int, int]] = []
while queue:
iy, ix = queue.popleft()
component.append((iy, ix))
for dy, dx in _NEIGHBORS4:
ny, nx = iy + dy, ix + dx
if ny < 0 or nx < 0 or ny >= height or nx >= width:
continue
if visited[ny, nx] or not missing[ny, nx]:
continue
visited[ny, nx] = True
queue.append((ny, nx))
return component
def _component_touches_nonwater(
component: list[tuple[int, int]],
water: np.ndarray,
) -> bool:
height, width = water.shape
for iy, ix in component:
if iy == 0 or ix == 0 or iy == height - 1 or ix == width - 1:
return True
for dy, dx in _NEIGHBORS8:
ny, nx = iy + dy, ix + dx
if not water[ny, nx]:
return True
return False
def repair_small_water_gaps(
grid: np.ndarray,
water: np.ndarray,
cfg: dict[str, Any],
) -> tuple[np.ndarray, dict[str, Any]]:
repaired = grid.copy()
missing = water & ~np.isfinite(repaired)
visited = np.zeros(missing.shape, dtype=bool)
max_nodes = int(cfg["gap_fill_max_component_nodes"])
radius = int(cfg["gap_fill_radius_cells"])
min_neighbors = int(cfg["gap_fill_min_neighbors"])
components_total = 0
components_small = 0
components_repaired = 0
components_skipped_land = 0
components_skipped_large = 0
nodes_filled = 0
height, width = grid.shape
for iy in range(height):
for ix in range(width):
if visited[iy, ix] or not missing[iy, ix]:
continue
component = _collect_component(iy, ix, missing, visited)
components_total += 1
if len(component) > max_nodes:
components_skipped_large += 1
continue
components_small += 1
if _component_touches_nonwater(component, water):
components_skipped_land += 1
continue
pending: list[tuple[int, int, float]] = []
component_set = set(component)
for cy, cx in component:
samples: list[tuple[float, float]] = []
y0 = max(0, cy - radius)
y1 = min(height - 1, cy + radius)
x0 = max(0, cx - radius)
x1 = min(width - 1, cx + radius)
for ny in range(y0, y1 + 1):
for nx in range(x0, x1 + 1):
if (ny, nx) in component_set:
continue
value = repaired[ny, nx]
if not water[ny, nx] or not math.isfinite(float(value)):
continue
dy = ny - cy
dx = nx - cx
dist2 = float(dx * dx + dy * dy)
if dist2 <= 0.0 or dist2 > float(radius * radius):
continue
samples.append((dist2, float(value)))
if len(samples) < min_neighbors:
pending = []
break
samples.sort(key=lambda item: item[0])
samples = samples[:16]
weights = np.array(
[1.0 / max(item[0], 1.0) for item in samples],
dtype=np.float64,
)
values = np.array(
[item[1] for item in samples],
dtype=np.float64,
)
pending.append(
(
cy,
cx,
float(np.sum(weights * values) / np.sum(weights)),
)
)
if len(pending) != len(component):
continue
for cy, cx, value in pending:
repaired[cy, cx] = value
nodes_filled += 1
components_repaired += 1
return repaired, {
"missing_water_nodes_before": int(np.count_nonzero(missing)),
"missing_water_nodes_after": int(
np.count_nonzero(water & ~np.isfinite(repaired))
),
"components_total": components_total,
"components_small": components_small,
"components_repaired": components_repaired,
"components_skipped_land": components_skipped_land,
"components_skipped_large": components_skipped_large,
"nodes_filled": nodes_filled,
}
def _triangle_contour(
points: list[tuple[float, float]],
values: list[float],
level: float,
) -> list[LineString]:
hits: list[tuple[float, float]] = []
for a, b in ((0, 1), (1, 2), (2, 0)):
va, vb = values[a], values[b]
if (va < level and vb < level) or (va > level and vb > level):
continue
hit = v2._edge_intersection(
points[a],
points[b],
va,
vb,
level,
)
if hit is not None:
hits.append(hit)
unique: list[tuple[float, float]] = []
for hit in hits:
if not any(
math.hypot(hit[0] - old[0], hit[1] - old[1]) < 1e-6
for old in unique
):
unique.append(hit)
if len(unique) != 2 or unique[0] == unique[1]:
return []
return [LineString(unique)]
def _cell_contours(
x0: float,
x1: float,
y0: float,
y1: float,
values: tuple[float, float, float, float],
level: float,
) -> list[LineString]:
finite = [math.isfinite(value) for value in values]
count = sum(finite)
if count == 4:
return v2._march_cell(
x0,
x1,
y0,
y1,
values,
level,
)
if count != 3:
return []
all_points = [
(x0, y0),
(x1, y0),
(x1, y1),
(x0, y1),
]
indexes = [index for index, ok in enumerate(finite) if ok]
triangle_points = [all_points[index] for index in indexes]
triangle_values = [values[index] for index in indexes]
if level < min(triangle_values) or level > max(triangle_values):
return []
return _triangle_contour(
triangle_points,
triangle_values,
level,
)
def extract_dense_contours(
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"])
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))
contours: dict[float, Any] = {}
stats: dict[str, Any] = {}
for level in levels:
raw_segments: list[LineString] = []
triangle_fallback_segments = 0
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_count = sum(math.isfinite(value) for value in values)
if finite_count < 3:
continue
finite_values = [value for value in values if math.isfinite(value)]
if level < min(finite_values) or level > max(finite_values):
continue
segments = _cell_contours(
float(xs[ix]),
float(xs[ix + 1]),
y0,
y1,
values,
level,
)
if finite_count == 3:
triangle_fallback_segments += len(segments)
raw_segments.extend(segments)
if not raw_segments:
stats[str(level)] = {
"raw_segments": 0,
"triangle_fallback_segments": 0,
"line_parts": 0,
"length_m": 0.0,
}
continue
merged = line_merge(union_all(raw_segments))
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_segments),
"triangle_fallback_segments": triangle_fallback_segments,
"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_segments),
"triangle_fallback_segments": triangle_fallback_segments,
"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()
config_path = args.config.resolve()
cfg = v1.load_config(config_path)
output_dir = (
args.output_dir.resolve()
if args.output_dir is not None
else (ROOT / str(cfg["output_dir"])).resolve()
)
output_dir.mkdir(parents=True, exist_ok=True)
started = datetime.now(timezone.utc)
print(f"BUILD {cfg['build_id']}", flush=True)
print(f"BBOX {cfg['bbox_wgs84']}", flush=True)
print(f"OUTPUT {output_dir}", flush=True)
conn = v1.connect_db()
try:
points, sounding_stats = v1.fetch_soundings(conn, cfg)
print(
"SOUNDINGS",
json.dumps(sounding_stats, ensure_ascii=False),
flush=True,
)
water_mask, water_rows = v1.fetch_water_mask(conn, cfg)
print(f"WATER_AREAS {water_rows}", flush=True)
finally:
conn.close()
xs, ys, grid, surface_stats = v2.build_idw_grid(
points,
water_mask,
cfg,
)
water_grid = make_water_grid_mask(xs, ys, water_mask)
repaired_grid, gap_stats = repair_small_water_gaps(
grid,
water_grid,
cfg,
)
print(
"SURFACE",
json.dumps(surface_stats, ensure_ascii=False),
flush=True,
)
print(
"GAP_REPAIR",
json.dumps(gap_stats, ensure_ascii=False),
flush=True,
)
contours, contour_stats = extract_dense_contours(
xs,
ys,
repaired_grid,
water_mask,
cfg,
)
if not contours:
raise RuntimeError("no dense 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/v3",
"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(value) for value in cfg["contour_levels_m"]
],
"major_contours_m": [
float(value) for value in cfg["major_contours_m"]
],
"native_scale_max": int(cfg["native_scale_max"]),
"water_object_classes": [
str(value) for value in cfg["water_object_classes"]
],
"surface_method": "local_idw_regular_grid_with_small_gap_repair",
"surface_parameters": {
key: cfg[key]
for key in (
"grid_spacing_m",
"idw_search_radius_m",
"idw_max_nearest_m",
"idw_min_neighbors",
"idw_max_neighbors",
"idw_power",
"gap_fill_max_component_nodes",
"gap_fill_radius_cells",
"gap_fill_min_neighbors",
"chaikin_iterations",
)
},
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_stats,
"gap_repair_stats": gap_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())

View File

@@ -0,0 +1,399 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from shapely.geometry import MultiPoint
import cm95_bathymetry_build_v1 as v1
import cm95_bathymetry_build_v2_smooth as v2
import cm95_bathymetry_build_v3_dense as v3
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = (
ROOT
/ "config"
/ "bathymetry"
/ "cm95_bathymetry_north_kyushu_v4_tin_dense.yaml"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build dense bathymetry contours from a TIN-constrained continuous depth surface."
)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def _edge_length(
a: tuple[float, float],
b: tuple[float, float],
) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _triangle_grid_bounds(
coords: list[tuple[float, float]],
xs: np.ndarray,
ys: np.ndarray,
) -> tuple[int, int, int, int] | None:
minx = min(point[0] for point in coords)
maxx = max(point[0] for point in coords)
miny = min(point[1] for point in coords)
maxy = max(point[1] for point in coords)
ix0 = max(0, int(np.searchsorted(xs, minx, side="left")))
ix1 = min(len(xs) - 1, int(np.searchsorted(xs, maxx, side="right") - 1))
iy0 = max(0, int(np.searchsorted(ys, miny, side="left")))
iy1 = min(len(ys) - 1, int(np.searchsorted(ys, maxy, side="right") - 1))
if ix0 > ix1 or iy0 > iy1:
return None
return ix0, ix1, iy0, iy1
def _rasterize_triangle(
grid: np.ndarray,
water_grid: np.ndarray,
xs: np.ndarray,
ys: np.ndarray,
coords: list[tuple[float, float]],
depths: list[float],
) -> int:
bounds = _triangle_grid_bounds(coords, xs, ys)
if bounds is None:
return 0
ix0, ix1, iy0, iy1 = bounds
x0, y0 = coords[0]
x1, y1 = coords[1]
x2, y2 = coords[2]
d0, d1, d2 = depths
denom = (
(y1 - y2) * (x0 - x2)
+ (x2 - x1) * (y0 - y2)
)
if abs(denom) < 1e-12:
return 0
sub_xs = xs[ix0 : ix1 + 1]
sub_ys = ys[iy0 : iy1 + 1]
xx, yy = np.meshgrid(sub_xs, sub_ys)
w0 = (
(y1 - y2) * (xx - x2)
+ (x2 - x1) * (yy - y2)
) / denom
w1 = (
(y2 - y0) * (xx - x2)
+ (x0 - x2) * (yy - y2)
) / denom
w2 = 1.0 - w0 - w1
eps = 1e-9
inside = (
(w0 >= -eps)
& (w1 >= -eps)
& (w2 >= -eps)
)
water = water_grid[iy0 : iy1 + 1, ix0 : ix1 + 1]
target = grid[iy0 : iy1 + 1, ix0 : ix1 + 1]
writable = inside & water & ~np.isfinite(target)
if not np.any(writable):
return 0
values = w0 * d0 + w1 * d1 + w2 * d2
target[writable] = values[writable]
return int(np.count_nonzero(writable))
def build_tin_surface(
points: dict[tuple[float, float], float],
water_mask,
cfg: dict[str, Any],
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, dict[str, Any]]:
west, south, east, north = [float(value) for value in cfg["bbox_wgs84"]]
minx, miny = v1.lonlat_to_mercator(west, south)
maxx, maxy = v1.lonlat_to_mercator(east, north)
spacing = float(cfg["grid_spacing_m"])
max_edge = float(cfg["max_triangle_edge_m"])
xs = v2._grid_axis(minx, maxx, spacing)
ys = v2._grid_axis(miny, maxy, spacing)
water_grid = v3.make_water_grid_mask(xs, ys, water_mask)
grid = np.full((len(ys), len(xs)), np.nan, dtype=np.float64)
depth_by_coord = dict(points)
triangles = v1.delaunay_triangles(MultiPoint(list(points.keys())))
triangle_total = 0
triangle_accepted = 0
triangle_rejected_long_edge = 0
triangle_rejected_lookup = 0
raster_writes = 0
for triangle in triangles.geoms:
triangle_total += 1
coords = [
(float(x), float(y))
for x, y, *rest in list(triangle.exterior.coords)[:3]
]
try:
depths = [
float(depth_by_coord[v1.coord_key(*coord)])
for coord in coords
]
except KeyError:
triangle_rejected_lookup += 1
continue
longest = max(
_edge_length(coords[0], coords[1]),
_edge_length(coords[1], coords[2]),
_edge_length(coords[2], coords[0]),
)
if longest > max_edge:
triangle_rejected_long_edge += 1
continue
triangle_accepted += 1
raster_writes += _rasterize_triangle(
grid,
water_grid,
xs,
ys,
coords,
depths,
)
finite = np.isfinite(grid)
water_nodes = int(np.count_nonzero(water_grid))
covered_water_nodes = int(np.count_nonzero(finite & water_grid))
uncovered_water_nodes = int(np.count_nonzero(water_grid & ~finite))
stats = {
"grid_width": int(len(xs)),
"grid_height": int(len(ys)),
"grid_nodes": int(grid.size),
"water_nodes": water_nodes,
"covered_water_nodes": covered_water_nodes,
"uncovered_water_nodes": uncovered_water_nodes,
"coverage_ratio_of_water": round(
covered_water_nodes / float(water_nodes),
6,
) if water_nodes else 0.0,
"triangle_total": triangle_total,
"triangle_accepted": triangle_accepted,
"triangle_rejected_long_edge": triangle_rejected_long_edge,
"triangle_rejected_lookup": triangle_rejected_lookup,
"raster_writes": raster_writes,
}
return xs, ys, grid, water_grid, stats
def smooth_surface(
grid: np.ndarray,
water_grid: np.ndarray,
cfg: dict[str, Any],
) -> tuple[np.ndarray, dict[str, Any]]:
iterations = int(cfg.get("surface_smooth_iterations", 0))
strength = float(cfg.get("surface_smooth_strength", 0.0))
if iterations <= 0 or strength <= 0.0:
return grid.copy(), {
"iterations": 0,
"strength": 0.0,
"smoothed_nodes": 0,
}
current = grid.copy()
valid_base = water_grid & np.isfinite(current)
smoothed_nodes = 0
for _ in range(iterations):
values_sum = np.zeros(current.shape, dtype=np.float64)
values_count = np.zeros(current.shape, dtype=np.int16)
for dy, dx in v3._NEIGHBORS8:
src_y0 = max(0, -dy)
src_y1 = current.shape[0] - max(0, dy)
src_x0 = max(0, -dx)
src_x1 = current.shape[1] - max(0, dx)
dst_y0 = max(0, dy)
dst_y1 = current.shape[0] - max(0, -dy)
dst_x0 = max(0, dx)
dst_x1 = current.shape[1] - max(0, -dx)
source = current[src_y0:src_y1, src_x0:src_x1]
source_valid = np.isfinite(source)
target_sum = values_sum[dst_y0:dst_y1, dst_x0:dst_x1]
target_count = values_count[dst_y0:dst_y1, dst_x0:dst_x1]
target_sum[source_valid] += source[source_valid]
target_count[source_valid] += 1
can_smooth = valid_base & (values_count >= 3)
neighbor_mean = np.zeros(current.shape, dtype=np.float64)
neighbor_mean[can_smooth] = (
values_sum[can_smooth]
/ values_count[can_smooth]
)
updated = current.copy()
updated[can_smooth] = (
current[can_smooth] * (1.0 - strength)
+ neighbor_mean[can_smooth] * strength
)
current = updated
smoothed_nodes += int(np.count_nonzero(can_smooth))
return current, {
"iterations": iterations,
"strength": strength,
"smoothed_nodes": smoothed_nodes,
}
def main() -> int:
args = parse_args()
config_path = args.config.resolve()
cfg = v1.load_config(config_path)
output_dir = (
args.output_dir.resolve()
if args.output_dir is not None
else (ROOT / str(cfg["output_dir"])).resolve()
)
output_dir.mkdir(parents=True, exist_ok=True)
started = datetime.now(timezone.utc)
print(f"BUILD {cfg['build_id']}", flush=True)
print(f"BBOX {cfg['bbox_wgs84']}", flush=True)
print(f"OUTPUT {output_dir}", flush=True)
conn = v1.connect_db()
try:
points, sounding_stats = v1.fetch_soundings(conn, cfg)
print(
"SOUNDINGS",
json.dumps(sounding_stats, ensure_ascii=False),
flush=True,
)
water_mask, water_rows = v1.fetch_water_mask(conn, cfg)
print(f"WATER_AREAS {water_rows}", flush=True)
finally:
conn.close()
xs, ys, raw_grid, water_grid, surface_stats = build_tin_surface(
points,
water_mask,
cfg,
)
print(
"TIN_SURFACE",
json.dumps(surface_stats, ensure_ascii=False),
flush=True,
)
smooth_grid, smooth_stats = smooth_surface(
raw_grid,
water_grid,
cfg,
)
print(
"SURFACE_SMOOTH",
json.dumps(smooth_stats, ensure_ascii=False),
flush=True,
)
contours, contour_stats = v3.extract_dense_contours(
xs,
ys,
smooth_grid,
water_mask,
cfg,
)
if not contours:
raise RuntimeError("no v4 contours generated")
print(
"CONTOURS",
json.dumps(contour_stats, ensure_ascii=False),
flush=True,
)
debug_feature_count = v1.write_debug_geojson(
contours,
cfg,
output_dir,
)
v1.write_tilejson(cfg, output_dir)
tile_stats = v1.build_tiles(contours, cfg, output_dir)
print(
"TILES",
json.dumps(tile_stats, ensure_ascii=False),
flush=True,
)
finished = datetime.now(timezone.utc)
manifest = {
"schema": "navsea.cm95.bathymetry-build-result/v4",
"build_id": str(cfg["build_id"]),
"generated_at": finished.isoformat(),
"duration_seconds": round(
(finished - started).total_seconds(),
3,
),
"dataset_id": int(cfg["dataset_id"]),
"bbox_wgs84": [float(value) for value in cfg["bbox_wgs84"]],
"source_layer": str(cfg["source_layer"]),
"source_kind": str(cfg["source_kind"]),
"reliability": str(cfg["reliability"]),
"contour_levels_m": [
float(value) for value in cfg["contour_levels_m"]
],
"major_contours_m": [
float(value) for value in cfg["major_contours_m"]
],
"native_scale_max": int(cfg["native_scale_max"]),
"water_object_classes": [
str(value) for value in cfg["water_object_classes"]
],
"surface_method": "tin_barycentric_grid_with_light_smoothing",
"surface_parameters": {
key: cfg[key]
for key in (
"grid_spacing_m",
"max_triangle_edge_m",
"surface_smooth_iterations",
"surface_smooth_strength",
"chaikin_iterations",
)
},
"sounding_stats": sounding_stats,
"water_area_rows": water_rows,
"surface_stats": surface_stats,
"surface_smooth_stats": smooth_stats,
"contour_stats": contour_stats,
"debug_geojson_features": debug_feature_count,
"tile_stats": tile_stats,
}
(output_dir / "build_manifest.json").write_text(
json.dumps(
manifest,
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
print(
"DONE",
output_dir / "build_manifest.json",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,588 @@
#!/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())

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
from pathlib import Path
EXPECTED_PROJECT_VERSION = "0.4.7"
EXPECTED_RUNTIME_VERSION = "0.4.7-production-audited-nav-name-conflict-omission"
EXPECTED_BUILDER = "cm95pbf-builder/0.4.6-spatial-candidate-query"
def fail(message: str) -> None:
print(f"FAIL: {message}")
raise SystemExit(2)
def require_fragment(path: Path, fragment: str) -> None:
text = path.read_text(encoding="utf-8")
if fragment not in text:
fail(f"{path} 缺少: {fragment}")
def main() -> None:
root = Path.cwd()
pyproject = root / "pyproject.toml"
init_py = root / "src/cm95pbf/__init__.py"
builder_py = root / "src/cm95pbf/builder.py"
release_py = root / "src/cm95pbf/release.py"
database_py = root / "src/cm93db/database.py"
required_paths = [
pyproject,
init_py,
builder_py,
release_py,
database_py,
root / "config/mappings/cm95_to_navsea_v1.yaml",
root / "config/mappings/cm95_gap_disposition_japan_dataset_1_v1.yaml",
root / "config/pbf/cm95_tile_policy_japan_full_detail_v1.yaml",
root / "config/style/style.navsea-delivery-full-semantic-unified-20260808-cm95-v1.json",
root / "config/sprite/semantic-sprite-20260808.json",
]
for path in required_paths:
if not path.exists():
fail(f"缺少文件: {path}")
require_fragment(pyproject, f'version = "{EXPECTED_PROJECT_VERSION}"')
require_fragment(init_py, EXPECTED_RUNTIME_VERSION)
require_fragment(init_py, EXPECTED_BUILDER)
for fragment in [
"mysql_compat_mode_for_connection",
"axis-order=long-lat",
"geom_from_wkb_sql",
"geom_from_text_sql",
"as_wkb_sql",
]:
require_fragment(database_py, fragment)
for fragment in [
'"POINT": ("chart_point_geometry", "spx_point_geom")',
'"LINE": ("chart_line_geometry", "spx_line_geom")',
'"AREA": ("chart_area_geometry", "spx_area_geom")',
"FORCE INDEX ({spatial_index})",
"STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id",
]:
require_fragment(builder_py, fragment)
for fragment in [
"AMBIGUOUS_DIRECT_NAMES_OMIT_LABEL_FAIL_CLOSED",
"_collect_nav_name_conflict_expectations",
"_observe_nav_name_conflict_feature",
"_finalize_nav_name_conflict_audit",
"ambiguous_conflict_was_labeled",
'failures.append("nav_name_conflict_audit")',
'"nav_name_conflict_omissions"',
'"nav_name_conflict_unique_outputs"',
]:
require_fragment(release_py, fragment)
release_text = release_py.read_text(encoding="utf-8")
if 'failures.append("nav_name_conflicts")' in release_text:
fail("仍存在旧 nav_name_conflicts unconditional hard-fail")
try:
import cm95pbf
except Exception as exc:
fail(f"无法 import cm95pbf: {exc}")
if getattr(cm95pbf, "__version__", None) != EXPECTED_RUNTIME_VERSION:
fail(f"installed runtime={getattr(cm95pbf, '__version__', None)!r}")
if getattr(cm95pbf, "BUILDER_CONTRACT_VERSION", None) != EXPECTED_BUILDER:
fail(f"installed builder={getattr(cm95pbf, 'BUILDER_CONTRACT_VERSION', None)!r}")
print("CM95 Linux full rebuild preflight")
print("=" * 72)
print(f"Project version : {EXPECTED_PROJECT_VERSION} PASS")
print(f"Runtime version : {EXPECTED_RUNTIME_VERSION} PASS")
print(f"Builder contract : {EXPECTED_BUILDER} PASS")
print("Japan mapping/disposition : PASS")
print("Japan tile policy : PASS")
print("Style / sprite contract inputs : PASS")
print("Ambiguous-name finalizer gate : PASS")
print("Spatial-first builder invariant : PASS")
print("MySQL 5.7/8.0 spatial compat : PASS")
print("OVERALL : PASS")
if __name__ == "__main__":
main()

View File

@@ -11,6 +11,7 @@ import pymysql
from dotenv import load_dotenv
from cm93db.config import load_config
from cm93db.database import geom_from_text_sql, mysql_compat_mode_for_connection
TABLES = {
@@ -37,6 +38,11 @@ def connect() -> pymysql.connections.Connection:
)
def envelope_geom_sql(conn) -> str:
mode = mysql_compat_mode_for_connection(conn)
return geom_from_text_sql(mode, "%s", 4326)
def envelope_wkt(bbox: tuple[float, float, float, float]) -> str:
west, south, east, north = bbox
return (
@@ -76,13 +82,14 @@ def fetch_new(conn, dataset_id: int, scale: int, geometry_type: str, bbox):
west, south, east, north = bbox
table, idx = TABLES[geometry_type]
wkt = envelope_wkt(bbox)
geom_sql = envelope_geom_sql(conn)
with conn.cursor() as cur:
cur.execute(
f"""
SELECT f.id AS feature_id
FROM {table} g FORCE INDEX ({idx})
STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id
WHERE MBRIntersects(g.geom, ST_GeomFromText(%s, 4326))
WHERE MBRIntersects(g.geom, {geom_sql})
AND f.dataset_id=%s
AND f.native_scale=%s
AND f.geometry_type=%s
@@ -100,6 +107,7 @@ def explain_new(conn, dataset_id: int, scale: int, geometry_type: str, bbox):
west, south, east, north = bbox
table, idx = TABLES[geometry_type]
wkt = envelope_wkt(bbox)
geom_sql = envelope_geom_sql(conn)
with conn.cursor() as cur:
cur.execute(
f"""
@@ -107,7 +115,7 @@ def explain_new(conn, dataset_id: int, scale: int, geometry_type: str, bbox):
SELECT f.id AS feature_id
FROM {table} g FORCE INDEX ({idx})
STRAIGHT_JOIN chart_feature f ON f.id=g.feature_id
WHERE MBRIntersects(g.geom, ST_GeomFromText(%s, 4326))
WHERE MBRIntersects(g.geom, {geom_sql})
AND f.dataset_id=%s
AND f.native_scale=%s
AND f.geometry_type=%s
@@ -123,15 +131,16 @@ def explain_new(conn, dataset_id: int, scale: int, geometry_type: str, bbox):
def soundings_old(conn, dataset_id: int, scale: int, bbox):
wkt = envelope_wkt(bbox)
geom_sql = envelope_geom_sql(conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT s.id
FROM chart_sounding_point s
JOIN chart_feature f ON f.id=s.feature_id
WHERE f.dataset_id=%s
AND f.native_scale=%s
AND MBRContains(ST_GeomFromText(%s, 4326), s.geom)
AND MBRContains({geom_sql}, s.geom)
""",
(dataset_id, scale, wkt),
)
@@ -140,13 +149,14 @@ def soundings_old(conn, dataset_id: int, scale: int, bbox):
def soundings_new(conn, dataset_id: int, scale: int, bbox):
wkt = envelope_wkt(bbox)
geom_sql = envelope_geom_sql(conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
SELECT s.id
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)
WHERE MBRContains({geom_sql}, s.geom)
AND f.dataset_id=%s
AND f.native_scale=%s
""",
@@ -158,14 +168,15 @@ def soundings_new(conn, dataset_id: int, scale: int, bbox):
def soundings_explain_new(conn, dataset_id: int, scale: int, bbox):
wkt = envelope_wkt(bbox)
geom_sql = envelope_geom_sql(conn)
with conn.cursor() as cur:
cur.execute(
"""
f"""
EXPLAIN
SELECT s.id
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)
WHERE MBRContains({geom_sql}, s.geom)
AND f.dataset_id=%s
AND f.native_scale=%s
""",

191
web/bathymetry_test.html Normal file
View File

@@ -0,0 +1,191 @@
<!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:8px;left:8px;width:260px;
background:rgba(20,22,26,.92);color:#eef1f5;border:1px solid #555;border-radius:7px;
font-size:11px;line-height:1.35;box-shadow:0 2px 8px rgba(0,0,0,.3)}
.panel-titlebar{display:flex;align-items:center;gap:7px;padding:5px 7px;min-height:24px}
.panel-titlebar label{display:flex;align-items:center;gap:5px;margin:0;font-weight:600}
.panel-titlebar .runtime{margin-left:auto;color:#9fe5ac;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
.panel button{border:1px solid #666;background:#2a2e34;color:#fff;border-radius:4px;padding:2px 6px;font-size:11px;cursor:pointer}
.panel-body{padding:6px 8px 8px;border-top:1px solid #444}
.panel.collapsed{width:auto;min-width:190px}.panel.collapsed .panel-body{display:none}
.panel-body label{display:flex;gap:7px;align-items:center;margin:5px 0}
.note{color:#b8c1cd;margin-top:5px}.stats{margin-top:6px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
white-space:pre-wrap;color:#d7dde6}.legend{display:flex;gap:5px;flex-wrap:wrap;margin-top:5px}
.legend span{padding:1px 4px;border:1px solid #666;border-radius:3px}
</style>
</head>
<body>
<div id="map"></div>
<div class="panel collapsed" id="panel">
<div class="panel-titlebar">
<label><input id="toggle" type="checkbox" checked />等深线</label>
<span class="runtime" id="runtime-badge">加载中</span>
<button id="collapse-btn" type="button">展开</button>
</div>
<div class="panel-body">
<label>透明度 <input id="opacity" type="range" min="0.15" max="1" step="0.05" value="0.95" /></label>
<div class="note">独立 source:<code>bathymetry</code> / <code>bathymetry_contour</code></div>
<div class="note">v5 Adaptive:高密度区小 TIN/少平滑;稀疏区 fallback 保持连续。</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="runtime-detail" class="stats">等待地图加载…</div>
<div id="stats" class="stats">加载 build manifest…</div>
</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-major";
const MINOR_LAYER_ID="bathymetry-contours-minor";
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.62}},
{
id:MINOR_LAYER_ID,
type:"line",
source:SOURCE_ID,
"source-layer":"bathymetry_contour",
filter:["==",["get","contour_class"],"minor"],
paint:{
"line-color":"#55aa86",
"line-width":0.68,
"line-opacity":0.68
}
},
{
id:LAYER_ID,
type:"line",
source:SOURCE_ID,
"source-layer":"bathymetry_contour",
filter:["==",["get","contour_class"],"major"],
paint:{
"line-color":"#278f78",
"line-width":1.05,
"line-opacity":0.86
}
}
]
};
const map=new maplibregl.Map({
container:"map",style,center:START.center,zoom:START.zoom,bearing:0,pitch:0,
attributionControl:false
});
window.__bathymetryMap=map;
map.addControl(new maplibregl.NavigationControl(),"top-right");
const panel=document.getElementById("panel");
const collapseBtn=document.getElementById("collapse-btn");
const runtimeBadge=document.getElementById("runtime-badge");
const runtimeDetail=document.getElementById("runtime-detail");
let runtimeError="";
collapseBtn.addEventListener("click",function(){
const collapsed=panel.classList.toggle("collapsed");
collapseBtn.textContent=collapsed?"展开":"折叠";
});
function updateRuntimeStatus(){
let sourceCount=0,renderedCount=0,sourceLoaded=false;
try{
sourceLoaded=map.isSourceLoaded(SOURCE_ID);
sourceCount=map.querySourceFeatures(SOURCE_ID,{sourceLayer:"bathymetry_contour"}).length;
renderedCount=map.queryRenderedFeatures({layers:[MINOR_LAYER_ID,LAYER_ID]}).length;
}catch(err){
runtimeError=String(err);
}
if(runtimeError){
runtimeBadge.textContent="错误";
runtimeBadge.style.color="#ff8c8c";
}else if(!sourceLoaded){
runtimeBadge.textContent="加载中";
runtimeBadge.style.color="#ffd27a";
}else{
runtimeBadge.textContent="线 "+renderedCount;
runtimeBadge.style.color=renderedCount>0?"#9fe5ac":"#ff8c8c";
}
runtimeDetail.textContent=[
"sourceLoaded="+sourceLoaded,
"sourceFeatures="+sourceCount,
"renderedFeatures="+renderedCount,
"zoom="+map.getZoom().toFixed(2),
runtimeError?"error="+runtimeError:""
].filter(Boolean).join("\n");
window.__bathymetryDebug={sourceLoaded,sourceCount,renderedCount,runtimeError};
}
map.on("sourcedata",function(e){
if(e && e.sourceId===SOURCE_ID)updateRuntimeStatus();
});
map.on("idle",updateRuntimeStatus);
map.on("moveend",updateRuntimeStatus);
map.on("error",function(e){
const message=e&&e.error&&e.error.message?e.error.message:"";
if(message && /bathymetry|vector tile|pbf/i.test(message)){
runtimeError=message;
updateRuntimeStatus();
}
});
function setVisible(visible){
const value=visible?"visible":"none";
if(map.getLayer(MINOR_LAYER_ID))map.setLayoutProperty(MINOR_LAYER_ID,"visibility",value);
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){
const value=Number(e.target.value);
if(map.getLayer(LAYER_ID))map.setPaintProperty(LAYER_ID,"line-opacity",value);
if(map.getLayer(MINOR_LAYER_ID))map.setPaintProperty(MINOR_LAYER_ID,"line-opacity",Math.max(0.15,value*0.65));
});
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-v5-adaptive"
def parse_args():
p = argparse.ArgumentParser(description="CM95 independent bathymetry PBF test server")
p.add_argument("--host", default="192.168.200.184")
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()