docs: audit OpenCPN S-52 render equivalence

This commit is contained in:
OpenAI Codex
2026-09-20 15:59:56 +08:00
parent fe008ef6eb
commit f97fe1473e

View File

@@ -0,0 +1,566 @@
# CM95 S-52 OpenCPN 渲染流程对照审计报告 v1
日期:2026-09-20
## 1. 审计目的
本报告不从最终截图差异反推代码缺陷,而是以 OpenCPN 源码的真实渲染执行流程为审计基准,逐阶段对照 CM95 当前实现。
目标是回答:
1. OpenCPN 从 CM93/S-57 对象到最终屏幕绘制,实际经过哪些步骤;
2. CM95 当前每一步是否等价;
3. Conditional Symbology Procedure(CSP)返回的完整 rule chain 是否被完整保存和执行;
4. 哪些当前缺陷可以在人工看图之前由源码/结构审计直接发现;
5. 后续应建立怎样的自动硬门槛,避免继续依赖人工发现遗漏。
## 2. 固定基线
OpenCPN 参考提交:
`f7211b4e1874c453e37a7f190aa191c55a25c32e`
主要参考源码:
- `gui/src/cm93.cpp`
- `libs/s52plib/src/s52cnsy.cpp`
- `libs/s52plib/src/s52plib.cpp`
- 同版本 chartsymbols / lookup 资源
CM95 审计时 HEAD:
`fe008ef6eb527d7980728de694a1c7bc81d38b3c`
主要对照文件:
- `src/cm95pbf/s52.py`
- `src/cm95pbf/csp.py`
- `src/cm95pbf/presentation.py`
- `tools/cm95_s52_style_compiler.py`
- `web/kyushu_compare.html`
## 3. OpenCPN 实际渲染流程
OpenCPN 的流程不是“CSP 选择一个最终符号”。
真实执行链是:
`CM93 object → CM93→S57 class normalization → S-52 Lookup → 初始 rule list → CS(...) → CSP 返回 instruction string → 再次解析为 rule list → 每条 primitive 逐条渲染`
### 3.1 CM93 → S57/S52 对象视图
`cm93.cpp` 在创建 S57Obj 时包含 CM93 类别替换和属性适配。
例如已确认:
- `ITDARE → DEPARE`
- `SPOGRD → DMPGRD`
- `FSHHAV → FSHFAC`
- `OFSPRD → CTNARE`
这一步决定后续采用哪一个 S-52 Lookup/CSP。
### 3.2 Lookup
Presentation Library Lookup 返回普通绘制指令和/或:
`CS(XXXXXXXX)`
OpenCPN `_LUP2rules()` 将 INST 解析成 Rule 链。
### 3.3 CSP
当遇到 `RUL_CND_SY` 时:
`GetAndAddCSRules() → RenderCS() → s52cnsy.cpp 对应 CSP`
CSP 返回的不是单一图标,而是完整 instruction string,例如可能同时包含:
`SY + TX + SY + SOUNDG03 + QUAPNT01`
或:
`AC + AP + LS + SY`
### 3.4 CSP 返回串再次解析
OpenCPN 将 CSP 返回串再次通过 Lookup/rule parser 转换成新的 Rule 链。
因此:
`SY(...);TX(...);SY(...);...`
不是提示信息,而是必须按顺序执行的正式渲染程序。
### 3.5 最终 primitive 执行
`DoRenderObject()` 对 Rule 链逐个 switch 执行。
OpenCPN 当前实际执行的主要 primitive 包括:
- `TX` → RenderTX
- `TE` → RenderTE
- `SY` → RenderSY
- `LS` → RenderLS / RenderGLLS
- `LC` → RenderLC
- `MP/MPS` → RenderMPS
- `CA` → RenderCARC
- `AC` → Area color renderer
- `AP` → Area pattern renderer
- `CS` → 再次进入 conditional symbology
结论:
**OpenCPN 的语义单元是“完整有序 Rule Chain”,不是一个最终 symbol code。**
## 4. CM95 当前流程
当前 CM95 有两条不同路径。
### 4.1 非 mariner-dependent CSP
`resolve_csp_hints()` 调用 `csp.py` resolver,得到 instruction string。
之后通过:
- `static_rule_hints()`
- `materialize_symbol_hints()`
- `materialize_text_hints()`
转换成 PBF scalar/JSON hints,再由 style compiler 消费。
### 4.2 mariner-dependent CSP
当前集合:
- DEPARE01 / DEPARE02
- DEPCNT02
- SOUNDG02
- OBSTRN04
- WRECKS02
在 `mariner_mode=runtime` 时,`s52.py` **不会执行完整 resolve_csp_hints()**。
而是只调用:
`materialize_mariner_runtime_facts()`
将中性业务事实写入 PBF,然后把 CSP 名保留给 runtime。
因此完整的 OpenCPN CSP rule chain **没有进入 PBF,也没有形成一个统一 Runtime Rule IR**。
Web 端随后在 JavaScript 中重新手写:
- DEPARE 颜色
- DEPCNT safety contour
- SOUNDG 显示
- OBSTRN/WRECKS symbol
- LIGHT CA screen-space 绘制
这形成了第二套决策逻辑。
## 5. 核心结构性缺陷
### 5.1 同一个 CSP 被实现两次
例如 OBSTRN04:
第一份逻辑:
`src/cm95pbf/csp.py::resolve_obstrn04_instruction()`
第二份逻辑:
`web/kyushu_compare.html::runtimeHazardSymbol()`
OpenCPN 只有一份 CSP 决策树,返回完整 Rule Chain,再由通用 renderer 执行。
当前架构则是:
`OpenCPN CSP → Python 人工翻译一次 → runtime facts → JavaScript 再人工翻译一次`
任何一层少一个分支都会产生视觉缺失。
本次“有虚线圈但没有 10.5”就是这种重复翻译导致的。
## 6. 已确认的源码级不等价
### 6.1 OBSTRN04 POINT:QUAPNT01 丢失
OpenCPN POINT 分支最终会追加:
`CSQUAPNT01(obj)`
无论是 isolated danger 还是普通 obstruction branch,都会执行 positional-quality presentation。
当前 `resolve_obstrn04_instruction()`:
- isolated danger:直接 `return udw`
- 普通 POINT:返回 symbol / SOUNDG03
没有追加 QUAPNT01。
因此即使 Python 固定 profile 下直接使用 resolver,也不是完整 OpenCPN rule chain。
### 6.2 OBSTRN04:OBJNAM TX 丢失
OpenCPN OBSTRN04 在统一 end 阶段:
如果存在 `OBJNAM`,追加:
`TX(OBJNAM,1,2,3,'15118',-1,-1,CHBLK,26)`
当前 Python resolver 没有该 TX。
当前 Web runtime 同样没有实现。
### 6.3 OBSTRN04 LINE:sounding 丢失
OpenCPN:
当 LINE 有 VALSOU 且 `VALSOU <= 20`,在 LS 后还会追加 `SNDFRM02/SOUNDG03` sounding presentation。
当前 Python LINE branch 只返回:
- LC
- 或 LS
没有 sounding。
### 6.4 OBSTRN04 AREA:sounding 与 QUAPNT01 丢失
OpenCPN AREA 有 VALSOU 时:
- 先 LS
- 再追加 sounding
- 最后追加 QUAPNT01
当前 Python AREA 有 VALSOU 时只返回 LS。
### 6.5 OBSTRN04 isolated AREA:QUAPNT01 丢失
OpenCPN isolated area:
`AC + AP + LS + ISODGR51 + QUAPNT01`
当前:
`AC + AP + LS + ISODGR51`
少 QUAPNT01。
### 6.6 WRECKS02 POINT:QUAPNT01 丢失
OpenCPN POINT:
- isolated:`ISODGR51 + QUAPNT01`
- 有 VALSOU:`DANGERxx + TX('Wk') + [WRECKS07] + SOUNDG03 + QUAPNT01`
- 无 VALSOU:WRECKS symbol + QUAPNT01
当前 Python resolver 的这些分支均未完整追加 QUAPNT01。
### 6.7 WRECKS02 AREA:QUAPNT01 丢失
OpenCPN AREA:
line/fill/isolated danger/sounding 后仍追加 QUAPNT01。
当前 Python area path 不完整。
### 6.8 Web runtime 的 SOUNDG 不是 OpenCPN SOUNDG03
本次为危险物补的:
`runtime_depth_text="10.5"`
可以恢复肉眼数字,但它不是 OpenCPN SOUNDG03 的真正实现。
OpenCPN SOUNDG03 会根据:
- SAFETY_DEPTH
- TECSOU
- QUASOU
- STATUS
- QUAPOS
- 深度整数/小数位
- 正负深度
- sounding symbol family
生成多枚:
`SOUNDSxx / SOUNDGxx`
symbol sequence。
当前 Web runtime 是普通 MapLibre text。
因此:
**“数字出现”不等于 1:1 S-52 sounding 已完成。**
### 6.9 WRECKS 的 TX('Wk') / WRECKS07 当前 Web runtime 未执行
Python resolver 已能生成部分完整 instruction。
但 runtime mode 下这些 instruction 不会被发送给 Web;Web 只根据 facts 自行选择主要 hazard symbol 和 depth text。
所以以下 OpenCPN primitives 当前可能不出现:
- `TX('Wk',...)`
- `SY(WRECKS07)`
- QUAPNT01 symbol
- SOUNDG03 glyph chain
### 6.10 LIGHTS05 实际应审 LIGHTS06
OpenCPN 参考源码:
`LIGHTS05() → return LIGHTS06(param)`
因此审计目标必须是 LIGHTS06 的真实流程,而不是函数名 LIGHTS05 后面的旧不可达代码。
本次已核对:
- 非 sector / sector CA 半径逻辑;
- sector angle +180° 转换;
- LITVIS obscured sector;
- all-round CA;
当前核心 CA 逻辑与 OpenCPN LIGHTS06 基本一致。
但 light description 的同位置去重/偏移规则仍应单独建立 branch test,不能仅靠已有 screenshot 判 PASS。
## 7. Rule Hint 压缩本身也存在风险
`presentation.py::static_rule_hints()` 明确是:
> Materialize compact scalar hints; full rule IR remains lookup-side.
它对于同类 primitive 通常只存第一个 scalar:
- first SY
- first AC
- first AP
- first LC
- first LS
虽然 `materialize_symbol_hints()` 在多 SY 时可以生成 `s52_symbol_rule_sequence`,但当前 style compiler 并不是一个通用的“有序 instruction executor”。
所以即使非 runtime CSP,也必须审计:
**原始 rule chain 是否在 scalar 化之后仍然保持所有可见 primitive。**
## 8. Style Compiler 与 OpenCPN renderer 的架构差异
OpenCPN 是通用 rule executor。
当前 style compiler 是“按已知 materialized fields 拼 MapLibre layers”。
当前 static coverage gate 能检查:
- AC
- AP
- LS
- LC
- SY
- LIGHT_SY
- TX/TE
- LIGHT_TX
但它检查的是“某 render kind 有 layer 消费”,不是:
`OpenCPN rule #1 → #2 → #3 ... 是否全部、按顺序、按条件等价执行`
Runtime coverage 更粗:
- RUNTIME_DEPARE
- RUNTIME_DEPCNT
- RUNTIME_SOUNDG
- RUNTIME_OBSTRN
- RUNTIME_WRECKS
- RUNTIME_CA
当前 capability 只能回答“某类 renderer 是否存在/preview”,不能回答:
**OBSTRN04 的 7 个 primitive 是否一个都没漏。**
## 9. 为什么现有审计没有提前抓到 10.5
现有审计能证明:
- Source data 存在;
- PBF facts 存在;
- CSP 名存在;
- runtime capability 有一个 renderer;
- 页面确实画了 hazard symbol。
但它没有建立:
`OpenCPN OBSTRN04 branch → expected ordered primitive sequence`
因此:
`SY(DANGER51)`
和:
`SY(DANGER51) + SOUNDG03(10.5) + QUAPNT01 + OBJNAM TX`
在旧 capability audit 中都可能被归为同一个:
`RUNTIME_OBSTRN`
粒度过粗。
## 10. 应建立的新审计:OpenCPN Source-First Rendering Equivalence
建议新增独立审计层,不依赖最终 PBF screenshot。
### Gate A — OPENCPN_CSP_BRANCH_COVERAGE
从 OpenCPN `s52cnsy.cpp` 固定 commit 提取每个目标 CSP 的 branch matrix。
至少记录:
- procedure
- geometry type
- object class
- attribute conditions
- mariner parameters
- called sub-procedures
- ordered output primitives
当前第一批:
- LIGHTS06
- SOUNDG02 / SOUNDG03
- UDWHAZ03
- OBSTRN04
- WRECKS02
- DEPARE01
- DEPCNT02
- QUAPOS01 / QUAPNT01 / QUALIN01
- SLCONS03
- RESTRN01
- RESARE02
### Gate B — CSP_RULE_SEQUENCE_EQUIVALENCE
对每个 branch,比较:
`OpenCPN expected ordered rule sequence`
与:
`CM95 resolver expected ordered rule sequence`
不是只比较主要 symbol。
例如:
Expected:
`SY(DANGER51), SOUNDG03, QUAPNT01, TX(OBJNAM)`
Actual:
`SY(DANGER51), SOUNDG03`
必须 FAIL。
### Gate C — RUNTIME_PRIMITIVE_COVERAGE
对于 mariner-dependent CSP,检查 expected primitive 是否存在对应 runtime executor:
- SY
- TX
- TE
- LS
- LC
- AC
- AP
- CA
- SOUNDG/MPS
- OP / DisplayBase side effects
- nested QUAPOS
不能再只检查 `RUNTIME_OBSTRN` 一个粗粒度 flag。
### Gate D — REAL_DATA_BRANCH_COVERAGE
扫描实际 Dataset,把真实出现过的 attribute combination 映射到 OpenCPN branch signature。
如果实际数据中存在某个 branch,而该 branch 没有 equivalence fixture:
FAIL。
这能避免只测试“理论上典型的几条”。
### Gate E — RENDER_CHAIN_CONSERVATION
建立:
`Source → normalized S57 view → Lookup → CSP branch → ordered Rule IR → PBF/runtime facts → renderer primitive execution`
逐段 signature。
任何阶段删掉 primitive 都失败。
## 11. 推荐架构修正
审计能发现问题,但要从根本上减少问题,建议取消“Python 一套条件 + JS 再写一套条件”的结构。
推荐:
`OpenCPN-compatible CSP Decision IR / Runtime Rule IR`
流程改为:
`Source facts → 唯一 CSP resolver → ordered Rule IR → 平台 renderer`
例如 OBSTRN04 resolver 输出:
```json
[
{"op":"SY","code":"DANGER51"},
{"op":"SOUNDG03","depth":10.5,"...":"..."},
{"op":"QUAPNT01","quapos":4},
{"op":"TX","field":"OBJNAM","...":"..."}
]
```
Web / NavSea renderer 只负责执行 IR,不再自己判断 WATLEV/VALSOU/CATWRK。
这样 OpenCPN 决策树只翻译一次。
## 12. 当前审计结论
当前实现不能称为“OpenCPN 渲染流程 1:1 等价”。
可以确认:
- CM93 → S52 lookup 主链已经较完整;
- static resource contract 已较强;
- LIGHTS multi-presentation 守恒已有独立审计;
- CA screen-space renderer 已较完整;
但 mariner-dependent CSP 存在明确的 Rule Chain 丢失:
- OBSTRN04:FAIL
- WRECKS02:FAIL
- SOUNDG runtime:FAIL(当前为文字近似,不是 SOUNDG03 glyph executor)
- DEPARE/DEPCNT:仍为 PREVIEW_ONLY,需按 branch 审计
- LIGHTS06:核心 CA 已接近,但完整 branch/label sequence 尚未建立 source-first equivalence gate
因此下一阶段不建议继续按截图逐项补。
应先建立:
1. OpenCPN CSP branch manifest;
2. ordered Rule IR;
3. CSP rule-sequence equivalence audit;
4. runtime primitive coverage audit;
然后再修 renderer。
人工截图应降级为最终视觉验收,而不再承担发现业务分支遗漏的职责。