feat: freeze CM95 visual object layer classification baseline
This commit is contained in:
2
tools/style-visual-object/.gitignore
vendored
Normal file
2
tools/style-visual-object/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
785
tools/style-visual-object/README.md
Normal file
785
tools/style-visual-object/README.md
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
# Style Visual Object Tool
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
本工具只做一件事:
|
||||||
|
|
||||||
|
> 从一份 Style JSON 以及它使用的 Sprite / Glyph 中,独立提取和生成 Visual Object。
|
||||||
|
|
||||||
|
工具输出的是匿名、可比较、可审计的 Visual Object,不负责给 Visual Object 赋予 NavSea 标准 Layer 名称,也不负责解释 Provider 的业务字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最重要原则:先生成 Visual Object,再做语义和 Layer 对齐
|
||||||
|
|
||||||
|
本工具必须严格遵守以下顺序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
任意 Provider Style + Sprite / Glyph
|
||||||
|
↓
|
||||||
|
Style Visual Object Tool
|
||||||
|
↓
|
||||||
|
Anonymous Visual Object Catalog
|
||||||
|
↓
|
||||||
|
与 Standard Visual Object Catalog 比较
|
||||||
|
↓
|
||||||
|
MATCH / PARTIAL / UNKNOWN
|
||||||
|
↓
|
||||||
|
最后才允许赋予标准 Layer 名称或 Semantic
|
||||||
|
```
|
||||||
|
|
||||||
|
### 禁止走捷径
|
||||||
|
|
||||||
|
在 Visual Object 生成阶段,以下信息即使存在,也只能作为 provenance / audit 信息保存,**不得用于决定 Visual Object 属于哪个标准 Layer 或 Semantic**:
|
||||||
|
|
||||||
|
- `source-layer` 名称
|
||||||
|
- Style Layer `id`
|
||||||
|
- Provider 名称
|
||||||
|
- `canonical_object_type`
|
||||||
|
- S-52 / CM95 对象类名称
|
||||||
|
- S-52 symbol code 的文字含义
|
||||||
|
- 任何看起来与 NavSea 标准 Layer 同名或相似的字段
|
||||||
|
- 任何 Provider 私有业务字段名称
|
||||||
|
|
||||||
|
例如,某个 Provider 的 Style 中即使存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source-layer = navigation_marks
|
||||||
|
```
|
||||||
|
|
||||||
|
本工具也不得因此把该 Visual Object 定义为 NavSea 的 `navigation_marks`。
|
||||||
|
|
||||||
|
它只能记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
provenance.source_layer = navigation_marks
|
||||||
|
```
|
||||||
|
|
||||||
|
真正的 Layer 名称必须在 Visual Object 生成完成后,通过与标准 Visual Object 的比较结果确定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider-neutral
|
||||||
|
|
||||||
|
本工具必须完全独立于具体 Provider。
|
||||||
|
|
||||||
|
同一套代码应能够处理:
|
||||||
|
|
||||||
|
- NavSea Style
|
||||||
|
- CM95 Style
|
||||||
|
- 未来 Provider B / C / D
|
||||||
|
- 任意符合支持范围的 MapLibre Style
|
||||||
|
|
||||||
|
主逻辑中禁止出现类似:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if provider == "cm95"
|
||||||
|
if source_layer == "navigation_marks"
|
||||||
|
if symbol_code == "BOYCAN63" then ...
|
||||||
|
```
|
||||||
|
|
||||||
|
这类 Provider-specific Semantic 判断。
|
||||||
|
|
||||||
|
Provider-specific 名称可以进入 provenance,但不能进入 Visual Object 分类逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输入
|
||||||
|
|
||||||
|
第一阶段输入仅包括:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Style JSON
|
||||||
|
Sprite JSON + PNG
|
||||||
|
Glyph(Style 使用时)
|
||||||
|
```
|
||||||
|
|
||||||
|
Visual Object 提取本身不要求读取 PBF。
|
||||||
|
|
||||||
|
如果 Style 中使用数据驱动表达式,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["get", "chart_icon_image"]
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["get", "s52_symbol_code"]
|
||||||
|
```
|
||||||
|
|
||||||
|
工具必须保留为动态视觉参数,不得为了得到具体值去读取 PBF、猜测字段含义或套用 Provider 规则。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输出
|
||||||
|
|
||||||
|
建议至少生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
catalog.json
|
||||||
|
catalog.html
|
||||||
|
previews/
|
||||||
|
audit.json
|
||||||
|
```
|
||||||
|
|
||||||
|
其中每个 Visual Object 至少记录:
|
||||||
|
|
||||||
|
- 匿名 Visual Object ID
|
||||||
|
- geometry role
|
||||||
|
- Style Layer 原始顺序 / z-order
|
||||||
|
- 参与组成的 Render Components
|
||||||
|
- icon / line / fill / pattern / circle / text
|
||||||
|
- Sprite 实际视觉资源
|
||||||
|
- 动态视觉参数
|
||||||
|
- 出现条件 / Style filter
|
||||||
|
- 组件数量与组合关系
|
||||||
|
- Preview
|
||||||
|
- provenance
|
||||||
|
- 无法解析或无法确定的部分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Signature
|
||||||
|
|
||||||
|
用于后续跨 Provider 比较的核心是 Visual Signature,而不是 Provider 字段名称。
|
||||||
|
|
||||||
|
Visual Signature 应主要表达:
|
||||||
|
|
||||||
|
```text
|
||||||
|
geometry role
|
||||||
|
+
|
||||||
|
ordered render components
|
||||||
|
+
|
||||||
|
sprite visual
|
||||||
|
+
|
||||||
|
fill
|
||||||
|
+
|
||||||
|
line
|
||||||
|
+
|
||||||
|
pattern
|
||||||
|
+
|
||||||
|
text role
|
||||||
|
+
|
||||||
|
component multiplicity
|
||||||
|
+
|
||||||
|
component composition
|
||||||
|
+
|
||||||
|
style order
|
||||||
|
+
|
||||||
|
visual conditions / variants
|
||||||
|
```
|
||||||
|
|
||||||
|
后续 Visual Matcher 应基于这些视觉结构进行比较。
|
||||||
|
|
||||||
|
Provider 原始字段只作为辅助审计证据,不作为主要匹配依据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## source-layer 的地位
|
||||||
|
|
||||||
|
`source-layer` 在本工具中只具有两个作用:
|
||||||
|
|
||||||
|
1. 帮助确定 Style Layer 的数据来源和可组合范围;
|
||||||
|
2. 作为 provenance / debug / audit 信息输出。
|
||||||
|
|
||||||
|
它**不是 Semantic,也不是最终 Layer 分类结果**。
|
||||||
|
|
||||||
|
不得执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source-layer 名称
|
||||||
|
→ 直接得到 NavSea 标准 Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
正确流程必须是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Style + Sprite
|
||||||
|
→ Visual Object
|
||||||
|
→ Visual Match
|
||||||
|
→ 标准 Layer / Semantic
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Visual Object
|
||||||
|
|
||||||
|
NavSea 当前 Style 也必须使用同一套 Style Visual Object Tool 生成 Visual Object。
|
||||||
|
|
||||||
|
不得在 Tool 内置 NavSea Layer 规则。
|
||||||
|
|
||||||
|
NavSea 之所以成为 Standard,不是因为 Tool 对 NavSea 使用特殊逻辑,而是在 Tool 生成 Visual Object 之后,由独立的标准定义/人工确认把这些 Visual Object 与 NavSea 标准 Layer 关联起来。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NavSea Style
|
||||||
|
→ Anonymous Visual Object Catalog
|
||||||
|
→ 标准确认
|
||||||
|
→ Standard Visual Object Catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
其他 Provider:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Provider Style
|
||||||
|
→ Anonymous Visual Object Catalog
|
||||||
|
→ 与 Standard Visual Object Catalog 比较
|
||||||
|
→ 标准 Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不属于本工具的职责
|
||||||
|
|
||||||
|
本工具不负责:
|
||||||
|
|
||||||
|
- PBF 数据读取和 Feature 实例化
|
||||||
|
- Provider 字段翻译
|
||||||
|
- CM95 专用判断
|
||||||
|
- NavSea 专用判断
|
||||||
|
- Semantic 命名
|
||||||
|
- 标准 Layer 命名
|
||||||
|
- Feature grouping
|
||||||
|
- 危险性判断
|
||||||
|
- 船舶吃水相关 Planning / Danger 判断
|
||||||
|
- 跨 Provider 的最终匹配决策
|
||||||
|
|
||||||
|
这些属于后续独立阶段或独立 Tool。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现约束
|
||||||
|
|
||||||
|
后续实现必须满足:
|
||||||
|
|
||||||
|
- 未知 Style expression 必须显式标记 UNKNOWN / UNSUPPORTED,不得静默忽略;
|
||||||
|
- 必须保留 Style 原始顺序;
|
||||||
|
- 必须保留组件 multiplicity,不能用 set 去重破坏组合;
|
||||||
|
- 必须保留动态表达式,不得伪造成确定值;
|
||||||
|
- Sprite 必须根据 atlas metadata 提取真实视觉资源;
|
||||||
|
- 不得通过坐标接近、名字相似或 Provider 字段名自动拼接/分类 Visual Object;
|
||||||
|
- 不得因为某个 source-layer 与 NavSea 标准 Layer 同名就直接判定 MATCH;
|
||||||
|
- Visual Object 的生成和 Visual Object 的标准匹配必须是两个独立阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一句话验收标准
|
||||||
|
|
||||||
|
> 给工具任意一套 Style + Sprite,即使它完全不知道这是什么 Provider、也没有任何 PBF 数据,仍然能够独立生成匿名 Visual Object Catalog;只有在该 Catalog 生成完成后,才允许进入与 NavSea Standard Visual Object Catalog 的比较和 Layer 命名阶段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NavSea Standard Visual Object Catalog 冻结规则
|
||||||
|
|
||||||
|
NavSea 的 Visual Object 与标准 Layer 名在首次完成生成、确认和命名后,应视为 Standard Visual Object Catalog 的冻结基线。
|
||||||
|
|
||||||
|
正常情况下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NavSea Anonymous Visual Object
|
||||||
|
↓
|
||||||
|
人工/标准确认 Layer 名
|
||||||
|
↓
|
||||||
|
Standard Visual Object Catalog
|
||||||
|
↓
|
||||||
|
冻结
|
||||||
|
```
|
||||||
|
|
||||||
|
冻结后,后续 CM95、Provider B、Provider C 等只能使用该 Standard Visual Object Catalog 进行 Visual Object 匹配和 Layer 命名。
|
||||||
|
|
||||||
|
除非用户明确要求重建、重新生成、修订标准或升级 Standard Catalog 版本,否则禁止:
|
||||||
|
|
||||||
|
- 每次处理新 Provider 时重新生成 NavSea Visual Object;
|
||||||
|
- 自动重新计算或改变 NavSea Visual Object ID;
|
||||||
|
- 自动改变已经确认的标准 Layer 名;
|
||||||
|
- 因 CM95 或其他 Provider 的数据特点反向修改 NavSea Standard Catalog;
|
||||||
|
- 用新的 Provider 匹配结果覆盖现有标准基线。
|
||||||
|
|
||||||
|
如果确实需要修改 Standard Visual Object Catalog,必须作为显式的标准升级处理,并保留旧版本,不得静默覆盖。
|
||||||
|
|
||||||
|
因此正常跨 Provider 工作流固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
已冻结的 NavSea Standard Visual Object Catalog
|
||||||
|
│
|
||||||
|
│ 作为只读标准
|
||||||
|
▼
|
||||||
|
Provider Anonymous Visual Object
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Visual Matcher
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
标准 Layer 命名结果
|
||||||
|
```
|
||||||
|
|
||||||
|
一句话原则:
|
||||||
|
|
||||||
|
> NavSea Standard Visual Object Catalog 是标准输入,不是每次 Provider 解析时重新生成的临时产物。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Object Layer Assignment 的补充硬规则
|
||||||
|
|
||||||
|
### 1. Sprite 存在不等于 Style 会绘制
|
||||||
|
|
||||||
|
Sprite atlas 中存在某个资源,只能说明资源可用,不能说明当前 Style 会实际引用它。
|
||||||
|
|
||||||
|
Anonymous Visual Object Catalog 只能从 Style 的正向可达规则生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Style 明确允许的资源
|
||||||
|
→ 进入 Anonymous Visual Object Catalog
|
||||||
|
|
||||||
|
仅存在于 Sprite、但当前 Style 没有正向可达路径的资源
|
||||||
|
→ 不进入 Catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
尤其不得把 `!match`、`!=` 等排除条件中的值误当成候选 Visual Object。
|
||||||
|
|
||||||
|
### 2. 同一视觉类可能对应多个标准 Layer
|
||||||
|
|
||||||
|
如果 NavSea Standard Visual Object Catalog 中同一实际视觉图形被多个标准 Layer 复用,则 Visual Matcher 不能仅凭图形强行选一个 Layer。
|
||||||
|
|
||||||
|
必须输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AMBIGUOUS
|
||||||
|
candidateStandardLayers:
|
||||||
|
- layer_a
|
||||||
|
- layer_b
|
||||||
|
```
|
||||||
|
|
||||||
|
例如危险物公共符号可以同时被多个标准 Layer 使用;这种歧义是 Standard Catalog 自身的事实,不应由 Matcher 擅自消除。
|
||||||
|
|
||||||
|
### 3. 禁止整个匿名 group 继承单一 Layer
|
||||||
|
|
||||||
|
Style 的一个匿名 group 可能允许多个不同视觉家族,尤其是通用 S-52 Style。
|
||||||
|
|
||||||
|
因此禁止:
|
||||||
|
|
||||||
|
```text
|
||||||
|
group 命中一个视觉锚点
|
||||||
|
→ 整个 group 继承该 Layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Layer Assignment 必须以单个 Visual Object / Visual Resource 为基本单位。
|
||||||
|
|
||||||
|
Group 只用于:
|
||||||
|
|
||||||
|
- 组合范围审计;
|
||||||
|
- Style provenance;
|
||||||
|
- 结果分布检查。
|
||||||
|
|
||||||
|
### 4. Layer Assignment 状态
|
||||||
|
|
||||||
|
正式状态固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MATCH
|
||||||
|
PARTIAL
|
||||||
|
AMBIGUOUS
|
||||||
|
COMPONENT
|
||||||
|
UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- `MATCH`:实际图形基本一致,可以直接作为标准 Layer 锚点;
|
||||||
|
- `PARTIAL`:不是同一完整图形,但视觉家族或组件关系足以支持唯一标准 Layer;
|
||||||
|
- `AMBIGUOUS`:视觉证据只能得到多个标准 Layer 候选,禁止强行选一个;
|
||||||
|
- `COMPONENT`:明确只是完整 Visual Object 的组成部分,不单独分配 Layer;可选记录组件类型;
|
||||||
|
- `UNKNOWN`:当前证据不足,不命名。
|
||||||
|
|
||||||
|
未知和歧义都是合法结果,禁止为了提高覆盖率而静默猜测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 人工确认 UI:CM95 Visual Object Reviewer
|
||||||
|
|
||||||
|
最终 Layer Assignment 必须允许人工确认。项目内提供独立 Web Reviewer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/reviewer/
|
||||||
|
├── review_server.py
|
||||||
|
└── web/
|
||||||
|
├── index.html
|
||||||
|
├── app.js
|
||||||
|
└── style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
默认启动:
|
||||||
|
|
||||||
|
```text
|
||||||
|
python3 tools/style-visual-object/reviewer/review_server.py --host 0.0.0.0 --port 18083
|
||||||
|
```
|
||||||
|
|
||||||
|
### 人工确认单位
|
||||||
|
|
||||||
|
Reviewer 以 **CM95 当前 Style 正向可达的唯一 Visual Resource** 为确认单位。
|
||||||
|
|
||||||
|
当前基线为 150 个资源。一个人工决定会应用到该视觉资源在 Anonymous Catalog 中的所有 occurrence,但不会修改 Anonymous Catalog 本身。
|
||||||
|
|
||||||
|
### 主审查界面必须隐藏 Provider 语义信息
|
||||||
|
|
||||||
|
为了避免人工被字段名或已有 source-layer 名称带偏,主审查界面默认只能看到:
|
||||||
|
|
||||||
|
- 匿名 Visual Object ID;
|
||||||
|
- 实际图形;
|
||||||
|
- component role;
|
||||||
|
- geometry role;
|
||||||
|
- occurrence 数;
|
||||||
|
- NavSea Standard Visual Object 候选;
|
||||||
|
- 标准 Layer;
|
||||||
|
- 当前人工决定。
|
||||||
|
|
||||||
|
以下 CM95 原始信息默认隐藏:
|
||||||
|
|
||||||
|
- resource key;
|
||||||
|
- source-layer;
|
||||||
|
- Style Layer id;
|
||||||
|
- Provider provenance。
|
||||||
|
|
||||||
|
只有用户主动展开“审计信息”后才能读取这些内容。
|
||||||
|
|
||||||
|
### 人工状态
|
||||||
|
|
||||||
|
人工决定固定使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MATCH
|
||||||
|
PARTIAL
|
||||||
|
AMBIGUOUS
|
||||||
|
COMPONENT
|
||||||
|
UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- MATCH / PARTIAL:必须选择且只能选择一个标准 Layer;
|
||||||
|
- AMBIGUOUS:必须选择至少两个候选标准 Layer;
|
||||||
|
- COMPONENT:不选择标准 Layer、不绑定 Standard Visual Object;可选记录顶标、浮标主体、立标主体、灯光、附加标记、面填充图案、线型图案、文字或其他组件;
|
||||||
|
- UNKNOWN:不选择标准 Layer;
|
||||||
|
- Standard Visual Object 可以选择,也可以不选择;
|
||||||
|
- 人工确认优先于机器建议;
|
||||||
|
- 未人工确认的项目在最终人工导出时保持 UNKNOWN,不自动继承机器建议。
|
||||||
|
|
||||||
|
### 人工结果保存
|
||||||
|
|
||||||
|
增量人工决定保存到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/manual-review-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
点击“生成当前人工确认结果”后生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/
|
||||||
|
├── reviewed-anchors-manual-v1.json
|
||||||
|
└── assignment/
|
||||||
|
├── cm95-layer-assignment-v1.json
|
||||||
|
├── cm95-layer-assignment-v1.csv
|
||||||
|
└── cm95-layer-assignment-v1.html
|
||||||
|
```
|
||||||
|
|
||||||
|
人工确认过程不得覆盖:
|
||||||
|
|
||||||
|
- `navsea-standard/standard-catalog.json`;
|
||||||
|
- `cm95-anonymous/anonymous-catalog.json`;
|
||||||
|
- 既有自动/辅助建议文件。
|
||||||
|
|
||||||
|
NavSea Standard Catalog 继续保持 FROZEN,只作为只读标准库使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reviewer 中文名称显示规则
|
||||||
|
|
||||||
|
人工 Reviewer 中,CM95 与 NavSea Standard Visual Object 均应优先显示中文名称,帮助人工理解和确认。
|
||||||
|
|
||||||
|
中文名称来源分两类:
|
||||||
|
|
||||||
|
1. CM95:
|
||||||
|
- 优先读取当前 CM95 仓库中的 S-52 symbol / line / area-pattern metadata;
|
||||||
|
- 保留 S-52 原始英文 `description`;
|
||||||
|
- 生成独立的中文显示译名;
|
||||||
|
- 中文译名属于工程 UI enrichment,不冒充 IHO 官方中文译名。
|
||||||
|
|
||||||
|
2. NavSea Standard:
|
||||||
|
- 依据冻结 Standard Visual Object 的语义资源键和标准 Layer 生成中文显示名;
|
||||||
|
- 标准 Layer 同时显示中文名和原始 Layer ID。
|
||||||
|
|
||||||
|
名称补充表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/output/reference-name-enrichment-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
固定原则:
|
||||||
|
|
||||||
|
> 中文名、英文说明、S-52 code 只用于 UI 显示和人工理解,不得参与 Visual Matcher、自动聚类或 Layer Assignment。
|
||||||
|
|
||||||
|
Reviewer 主界面显示优先级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
中文名称
|
||||||
|
→ 实际图形
|
||||||
|
→ 中文标准 Layer
|
||||||
|
→ 原始 code / 英文说明(辅助)
|
||||||
|
```
|
||||||
|
|
||||||
|
已有机器建议也必须显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
标准对象中文名
|
||||||
|
+
|
||||||
|
标准 Layer 中文名
|
||||||
|
+
|
||||||
|
MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN
|
||||||
|
```
|
||||||
|
|
||||||
|
不得只显示资源 key 或 Layer ID 让用户自行猜测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reviewer 宽屏固定确认布局
|
||||||
|
|
||||||
|
Reviewer 在宽屏下采用固定三栏工作区:
|
||||||
|
|
||||||
|
```text
|
||||||
|
左:CM95 当前 Visual Object
|
||||||
|
中:NavSea Standard 候选(竖排、内部滚动)
|
||||||
|
右:人工判断(固定可见)
|
||||||
|
```
|
||||||
|
|
||||||
|
固定要求:
|
||||||
|
|
||||||
|
- 主页面不得为了点击 MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN 或保存结果而要求纵向滚动;
|
||||||
|
- NavSea Standard 候选采用竖排列表,并在候选区域内部滚动;
|
||||||
|
- 标准 Layer 列表在人工判断面板内部滚动;
|
||||||
|
- `保存并下一个 / 仅保存 / 撤销` 始终保持在右侧人工判断区可见;
|
||||||
|
- 左侧 CM95 队列独立滚动;
|
||||||
|
- 审计信息默认折叠,不抢占主要确认空间;
|
||||||
|
- 窄屏才允许退回上下布局。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Visual Object 缺失时的 Layer-only 人工命名
|
||||||
|
|
||||||
|
外部 Provider(例如 CM95 / S-52)的视觉内容可能多于当前 NavSea Standard Visual Object Catalog。
|
||||||
|
因此:
|
||||||
|
|
||||||
|
> 找不到对应的 NavSea Standard Visual Object,不等于无法确定标准 Layer。
|
||||||
|
|
||||||
|
人工 Reviewer 必须支持两种最终确认路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A. 有对应标准图标
|
||||||
|
Provider Visual Object
|
||||||
|
→ Standard Visual Object
|
||||||
|
→ Standard Layer
|
||||||
|
assignment_mode = VISUAL_OBJECT_MATCH
|
||||||
|
|
||||||
|
B. 没有对应标准图标,但所属 Layer 可以人工确定
|
||||||
|
Provider Visual Object
|
||||||
|
→ Standard Layer
|
||||||
|
assignment_mode = LAYER_ONLY
|
||||||
|
standard_resource_key = null
|
||||||
|
```
|
||||||
|
|
||||||
|
Layer-only 的要求:
|
||||||
|
|
||||||
|
- 必须由人工明确选择标准 Layer;
|
||||||
|
- 不得伪造一个不存在的 Standard Visual Object;
|
||||||
|
- 不得因为没有对应图标就强制设为 UNKNOWN;
|
||||||
|
- 中文名称、S-52 官方英文说明和实际视觉可以作为人工理解证据;
|
||||||
|
- 这些名称仍不进入自动 Visual Matcher;
|
||||||
|
- 当前 Reviewer 中,点击“没有对应标准图标,只确认所属 Layer”会清除 Standard Visual Object 绑定,并保留唯一 Layer。
|
||||||
|
|
||||||
|
状态建议:
|
||||||
|
|
||||||
|
- 有明确对应完整图形时可使用 MATCH;
|
||||||
|
- 无对应标准图标但 Layer 明确时使用 PARTIAL + LAYER_ONLY;
|
||||||
|
- 只能确定多个可能 Layer 时使用 AMBIGUOUS;
|
||||||
|
- 连 Layer 也无法确定时才使用 UNKNOWN。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## COMPONENT:只作为完整 Visual Object 的组成部分
|
||||||
|
|
||||||
|
当当前显示的 S-52 / CM95 图形只是一个主体、顶标、灯光或其他附加组件时,不应强迫用户给它最终 Layer。
|
||||||
|
Reviewer 必须允许:
|
||||||
|
|
||||||
|
```text
|
||||||
|
status = COMPONENT
|
||||||
|
assignment_mode = COMPONENT_ONLY
|
||||||
|
standard_layer = null
|
||||||
|
standard_resource_key = null
|
||||||
|
```
|
||||||
|
|
||||||
|
可选 `component_kind`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
topmark 顶标
|
||||||
|
buoy_body 浮标主体
|
||||||
|
beacon_body 立标主体
|
||||||
|
light 灯光
|
||||||
|
auxiliary_mark 附加标记
|
||||||
|
fill_pattern 面填充图案
|
||||||
|
line_pattern 线型图案
|
||||||
|
text 文字
|
||||||
|
other 其他组件
|
||||||
|
```
|
||||||
|
|
||||||
|
`COMPONENT` 与 `UNKNOWN` 必须区分:
|
||||||
|
|
||||||
|
- `COMPONENT`:已经知道它只是完整对象的一部分;
|
||||||
|
- `UNKNOWN`:当前连它的作用/归属关系也无法可靠判断。
|
||||||
|
|
||||||
|
后续 Composite Visual Object 阶段再把 COMPONENT 与其他组件组合,最终 Layer 应尽量由完整 Visual Object 确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reviewer 最可能候选提示
|
||||||
|
|
||||||
|
Reviewer 的 NavSea Standard 候选可以提供一个 **UI-only 的“最可能”提示**,帮助人工快速判断。
|
||||||
|
|
||||||
|
综合参考只用于界面提示,来源包括:
|
||||||
|
|
||||||
|
- Visual Matcher 的实际图形相似度;
|
||||||
|
- CM95 中文显示名与 NavSea Standard 中文显示名的文字相似度;
|
||||||
|
- 已有 reviewed visual suggestion 是否支持同一标准对象或同一 Layer。
|
||||||
|
|
||||||
|
界面规则:
|
||||||
|
|
||||||
|
- 综合排序第一的候选中文名称标红;
|
||||||
|
- 显示“最可能”标签;
|
||||||
|
- 同时显示“视觉 xx% / 综合 xx%”;
|
||||||
|
- 即使是第一名,综合分较低时也必须保留低分,不能隐藏不确定性;
|
||||||
|
- 红色提示不得自动选择 Standard Visual Object;
|
||||||
|
- 不得自动写入人工决定;
|
||||||
|
- 不参与最终 Layer Assignment。
|
||||||
|
|
||||||
|
一句话:
|
||||||
|
|
||||||
|
> 红色“最可能”只是人工审查提示,不是系统确认结果。
|
||||||
|
|
||||||
|
宽屏布局同时调整为:当前 CM95 对象区域更窄,NavSea Standard 视觉候选区域更宽,右侧人工判断继续固定显示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider Layer Classifier:当前工作的最终目标
|
||||||
|
|
||||||
|
当前多源 PBF 对应工作的目标不是寻找“外部 Provider 图标等于 NavSea 哪个图标”,而是:
|
||||||
|
|
||||||
|
> 把外部 Provider 的对象正确分类到现有 NavSea Standard Layer。
|
||||||
|
|
||||||
|
因此正式流程分成两层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
第一层:Provider-neutral Style Visual Object Tool
|
||||||
|
Style + Sprite
|
||||||
|
→ Anonymous Visual Object
|
||||||
|
|
||||||
|
第二层:Provider Layer Classifier
|
||||||
|
Anonymous Visual Object
|
||||||
|
+ Provider 官方语义资料
|
||||||
|
+ 实际视觉证据
|
||||||
|
+ 可选 Standard Visual Object 参考
|
||||||
|
→ NavSea Standard Layer
|
||||||
|
→ 人工确认
|
||||||
|
```
|
||||||
|
|
||||||
|
关键规则:
|
||||||
|
|
||||||
|
1. `Style Visual Object Tool` 仍然不知道 NavSea Layer、CM95 语义或 Provider 私有分类,继续保持 Provider-neutral。
|
||||||
|
2. `Provider Layer Classifier` 可以使用 Provider 自己的官方语义资料。CM95 当前使用 S-52 metadata 的官方英文 `description`,再提供中文显示译名。
|
||||||
|
3. Provider 的 `source-layer` 仍不得作为分类依据,只保留 provenance/audit。
|
||||||
|
4. Standard Visual Object 一一对应不是最终目标,也不是 Layer 命名的强制前提。
|
||||||
|
5. 没有对应 NavSea 图标,但 Provider 语义足以确定 Layer 时,允许直接 `LAYER_ONLY`。
|
||||||
|
6. 当前图形只是顶标、灯光、主体、pattern 等组件时使用 `COMPONENT`,不单独分配最终 Layer。
|
||||||
|
7. 只有连 Layer 都无法可靠判断时才使用 `UNKNOWN`。
|
||||||
|
|
||||||
|
### CM95 语义 Layer 规则
|
||||||
|
|
||||||
|
当前 CM95 分类规则独立保存:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/cm95_semantic_layer_rules_v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
规则使用 S-52 官方 description 和资源类型进行声明式分类,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tower, black/white, full-chart and simplified
|
||||||
|
→ 对象大类:陆上塔状构筑物
|
||||||
|
→ onshore_structure_point
|
||||||
|
|
||||||
|
beacon tower, red, full-chart
|
||||||
|
→ 对象大类:航标塔体
|
||||||
|
→ navigation_marks
|
||||||
|
|
||||||
|
topmark for buoys ...
|
||||||
|
→ COMPONENT / topmark
|
||||||
|
```
|
||||||
|
|
||||||
|
这些规则属于 Provider Layer Classifier,不得回写进通用 Style Visual Object extraction。
|
||||||
|
|
||||||
|
### Reviewer 中间区域
|
||||||
|
|
||||||
|
Reviewer 中间主要区域现在显示 `NavSea Layer 建议`,综合参考:
|
||||||
|
|
||||||
|
- S-52 官方语义;
|
||||||
|
- 实际视觉相似度;
|
||||||
|
- 已有 reviewed visual suggestion。
|
||||||
|
|
||||||
|
语义证据优先于“长得像”。例如普通 `tower` 即使视觉上很像 beacon,也应优先按 S-52 的 tower 语义归到 `onshore_structure_point`。
|
||||||
|
|
||||||
|
Standard Visual Object 图标被降为折叠的辅助参考区,不再作为主要任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 冻结基线与固定校验命令
|
||||||
|
|
||||||
|
CM95 v1 人工分类完成后,以下内容属于应进入 Git 的冻结基线:
|
||||||
|
|
||||||
|
- 工具实现:`style_vo_tool.py`、`visual_anchor_matcher.py`、`build_reference_names.py`、`apply_reviewed_layer_assignment.py`;
|
||||||
|
- Provider 分类规则:`cm95_semantic_layer_rules_v1.json`;
|
||||||
|
- 人工 Reviewer:`reviewer/`;
|
||||||
|
- 固定校验逻辑:`validate_cm95_classification.py`;
|
||||||
|
- NavSea Frozen Standard Catalog 与 CM95 Anonymous Catalog;
|
||||||
|
- S-52 中文名称 enrichment、视觉辅助结果;
|
||||||
|
- 150 项人工确认;
|
||||||
|
- 最终 occurrence 级 Layer Assignment;
|
||||||
|
- 固定校验报告和最终中文报告。
|
||||||
|
|
||||||
|
固定校验命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/style-visual-object/validate_cm95_classification.py
|
||||||
|
```
|
||||||
|
|
||||||
|
提交前必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RESULT: PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
`WARN` 允许存在,但只能表示“人工结果与后续语义建议不同,需要时复核”;不得自动覆盖人工结果。
|
||||||
|
|
||||||
|
当前权威结果路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/manual-review-v1.json
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/reviewed-anchors-manual-v1.json
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/assignment/cm95-layer-assignment-v1.json
|
||||||
|
tools/style-visual-object/output/cm95-manual-review/validation-report-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
当前最终报告:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/style-visual-object/output/CM95_Visual_Object_Layer命名报告_v2.md
|
||||||
|
```
|
||||||
374
tools/style-visual-object/apply_reviewed_layer_assignment.py
Normal file
374
tools/style-visual-object/apply_reviewed_layer_assignment.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""把人工视觉复核结果应用到 CM95 Anonymous Visual Object Catalog。
|
||||||
|
|
||||||
|
本脚本不参与视觉判断,也不读取 CM95 source-layer 来决定 Layer。
|
||||||
|
它只消费已经完成的 reviewed anchors,并生成独立的 Layer Assignment 派生结果。
|
||||||
|
Anonymous Catalog 本身保持不变。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_layers(anchor: dict[str, Any] | None) -> list[str]:
|
||||||
|
if not anchor:
|
||||||
|
return []
|
||||||
|
if anchor.get("standard_layer"):
|
||||||
|
return [str(anchor["standard_layer"])]
|
||||||
|
return [str(x) for x in anchor.get("standard_layers", []) if x]
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(result: dict[str, Any]) -> str:
|
||||||
|
summary = result["summary"]
|
||||||
|
|
||||||
|
layer_rows: list[str] = []
|
||||||
|
for layer, stats in sorted(result["layer_summary"].items()):
|
||||||
|
layer_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(layer)}</td>"
|
||||||
|
f"<td>{stats['MATCH']}</td>"
|
||||||
|
f"<td>{stats['PARTIAL']}</td>"
|
||||||
|
f"<td>{stats['occurrences']}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
ambiguous_rows: list[str] = []
|
||||||
|
for item in result["ambiguous_summary"]:
|
||||||
|
ambiguous_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(' / '.join(item['candidate_standard_layers']))}</td>"
|
||||||
|
f"<td>{item['resource_count']}</td>"
|
||||||
|
f"<td>{item['occurrence_count']}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
group_rows: list[str] = []
|
||||||
|
for row in result["group_summary"]:
|
||||||
|
dist = ", ".join(
|
||||||
|
f"{html.escape(k)}={v}"
|
||||||
|
for k, v in sorted(row["assigned_layer_counts"].items())
|
||||||
|
) or "—"
|
||||||
|
group_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><code>{html.escape(row['group_id'])}</code></td>"
|
||||||
|
f"<td>{row['named_occurrences']}</td>"
|
||||||
|
f"<td>{row['ambiguous_occurrences']}</td>"
|
||||||
|
f"<td>{row['component_occurrences']}</td>"
|
||||||
|
f"<td>{row['unknown_occurrences']}</td>"
|
||||||
|
f"<td>{dist}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CM95 Visual Object Layer Assignment v1</title>
|
||||||
|
<style>
|
||||||
|
body{{font-family:Arial,"Noto Sans CJK JP",sans-serif;margin:24px;color:#17202a}}
|
||||||
|
.box{{display:inline-block;border:1px solid #d8dee6;border-radius:8px;padding:10px 14px;margin:0 10px 12px 0}}
|
||||||
|
table{{border-collapse:collapse;width:100%;margin:12px 0 28px}}
|
||||||
|
th,td{{border:1px solid #d8dee6;padding:7px;font-size:13px;text-align:left}}
|
||||||
|
th{{background:#eef2f6}}
|
||||||
|
code{{font-size:11px}}
|
||||||
|
.note{{background:#fff7df;border:1px solid #ead99c;padding:12px;border-radius:8px}}
|
||||||
|
</style>
|
||||||
|
<h1>CM95 Anonymous Visual Object → NavSea Standard Layer</h1>
|
||||||
|
<p>本结果只应用经人工视觉复核的共同图标/视觉家族锚点。CM95 source-layer 名称、Provider 字段语义和对象类名称均未参与 Layer 判定。</p>
|
||||||
|
<div class="box">CM95 Visual Objects <b>{summary['total_visual_objects']}</b></div>
|
||||||
|
<div class="box">Sprite-backed <b>{summary['sprite_backed_visual_objects']}</b></div>
|
||||||
|
<div class="box">唯一 Layer 已命名 <b>{summary['uniquely_named_visual_objects']}</b></div>
|
||||||
|
<div class="box">MATCH <b>{summary['match_visual_objects']}</b></div>
|
||||||
|
<div class="box">PARTIAL <b>{summary['partial_visual_objects']}</b></div>
|
||||||
|
<div class="box">AMBIGUOUS <b>{summary['ambiguous_visual_objects']}</b></div>
|
||||||
|
<div class="box">COMPONENT <b>{summary['component_visual_objects']}</b></div>
|
||||||
|
<div class="box">UNKNOWN <b>{summary['unknown_visual_objects']}</b></div>
|
||||||
|
<div class="box">Reviewed anchors <b>{summary['reviewed_anchor_count']}</b></div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<b>状态定义:</b>
|
||||||
|
MATCH = 实际图形基本一致;
|
||||||
|
PARTIAL = 不是同一完整图形,但视觉家族/组件足以支持唯一标准 Layer;
|
||||||
|
AMBIGUOUS = 图形证据只能得到多个标准 Layer 候选,禁止强行选一个;
|
||||||
|
COMPONENT = 只是完整 Visual Object 的组成部分,不单独分配 Layer;
|
||||||
|
UNKNOWN = 当前视觉证据不足。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>唯一命名的标准 Layer</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>标准 Layer</th><th>MATCH</th><th>PARTIAL</th><th>VO occurrence</th></tr></thead>
|
||||||
|
<tbody>{''.join(layer_rows)}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>视觉歧义</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>候选标准 Layer</th><th>资源数</th><th>VO occurrence</th></tr></thead>
|
||||||
|
<tbody>{''.join(ambiguous_rows) if ambiguous_rows else '<tr><td colspan="3">无</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>匿名 Group 分布</h2>
|
||||||
|
<p>Group 仅用于审计组合范围,不能由整个 group 直接继承一个 Layer。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Group</th><th>唯一命名</th><th>AMBIGUOUS</th><th>COMPONENT</th><th>UNKNOWN</th><th>唯一 Layer 分布</th></tr></thead>
|
||||||
|
<tbody>{''.join(group_rows)}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>逐 Visual Object 明细见 <code>cm95-layer-assignment-v1.json</code>;
|
||||||
|
资源级摘要见 <code>cm95-layer-assignment-v1.csv</code>。
|
||||||
|
Anonymous Catalog 没有被修改。</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--anonymous", required=True)
|
||||||
|
ap.add_argument("--reviewed-anchors", required=True)
|
||||||
|
ap.add_argument("--output", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
anon_path = Path(args.anonymous)
|
||||||
|
anchor_path = Path(args.reviewed_anchors)
|
||||||
|
out = Path(args.output)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
anon = load_json(anon_path)
|
||||||
|
review = load_json(anchor_path)
|
||||||
|
anchors = {
|
||||||
|
x["cm95_resource_key"]: x
|
||||||
|
for x in review.get("anchors", [])
|
||||||
|
}
|
||||||
|
group_provenance = {
|
||||||
|
g["group_id"]: g.get("provenance", {})
|
||||||
|
for g in anon.get("groups", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments: list[dict[str, Any]] = []
|
||||||
|
layer_counts: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
group_counts: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
resource_status: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
ambiguous_occurrence_counts: Counter = Counter()
|
||||||
|
|
||||||
|
for obj in anon.get("visual_objects", []):
|
||||||
|
resource_key = obj.get("resource_key")
|
||||||
|
anchor = anchors.get(resource_key)
|
||||||
|
candidate_layers = anchor_layers(anchor)
|
||||||
|
|
||||||
|
if anchor and anchor.get("status") in {"MATCH", "PARTIAL"} and len(candidate_layers) == 1:
|
||||||
|
status = str(anchor["status"])
|
||||||
|
layer = candidate_layers[0]
|
||||||
|
basis = "REVIEWED_VISUAL_ANCHOR"
|
||||||
|
std_resource = anchor.get("standard_resource_key")
|
||||||
|
layer_counts[layer][status] += 1
|
||||||
|
layer_counts[layer]["occurrences"] += 1
|
||||||
|
group_counts[obj["group_id"]][layer] += 1
|
||||||
|
group_counts[obj["group_id"]]["__named__"] += 1
|
||||||
|
resource_status[resource_key][status] += 1
|
||||||
|
elif anchor and anchor.get("status") == "AMBIGUOUS" and len(candidate_layers) >= 2:
|
||||||
|
status = "AMBIGUOUS"
|
||||||
|
layer = None
|
||||||
|
basis = "REVIEWED_VISUAL_AMBIGUITY"
|
||||||
|
std_resource = anchor.get("standard_resource_key")
|
||||||
|
group_counts[obj["group_id"]]["__ambiguous__"] += 1
|
||||||
|
resource_status[resource_key]["AMBIGUOUS"] += 1
|
||||||
|
ambiguous_occurrence_counts[tuple(sorted(candidate_layers))] += 1
|
||||||
|
elif anchor and anchor.get("status") == "COMPONENT":
|
||||||
|
status = "COMPONENT"
|
||||||
|
layer = None
|
||||||
|
candidate_layers = []
|
||||||
|
basis = "REVIEWED_COMPONENT_ONLY"
|
||||||
|
std_resource = None
|
||||||
|
group_counts[obj["group_id"]]["__component__"] += 1
|
||||||
|
resource_status[resource_key]["COMPONENT"] += 1
|
||||||
|
else:
|
||||||
|
status = "UNKNOWN"
|
||||||
|
layer = None
|
||||||
|
candidate_layers = []
|
||||||
|
basis = "INSUFFICIENT_VISUAL_EVIDENCE"
|
||||||
|
std_resource = None
|
||||||
|
group_counts[obj["group_id"]]["__unknown__"] += 1
|
||||||
|
if resource_key:
|
||||||
|
resource_status[resource_key]["UNKNOWN"] += 1
|
||||||
|
|
||||||
|
assignments.append({
|
||||||
|
"visual_object_id": obj["visual_object_id"],
|
||||||
|
"group_id": obj["group_id"],
|
||||||
|
"component_role": obj.get("component_role"),
|
||||||
|
"geometry_role": obj.get("geometry_role"),
|
||||||
|
"cm95_resource_key": resource_key,
|
||||||
|
"assignment_status": status,
|
||||||
|
"assigned_standard_layer": layer,
|
||||||
|
"candidate_standard_layers": candidate_layers,
|
||||||
|
"basis": basis,
|
||||||
|
"reviewed_standard_resource_key": std_resource,
|
||||||
|
"component_kind": anchor.get("component_kind") if anchor and status == "COMPONENT" else None,
|
||||||
|
# provenance 仅用于结果审计;不参与上述判定。
|
||||||
|
"provenance_for_audit_only": obj.get("provenance"),
|
||||||
|
})
|
||||||
|
|
||||||
|
for obj in anon.get("unresolved_visual_objects", []):
|
||||||
|
assignments.append({
|
||||||
|
"visual_object_id": obj["visual_object_id"],
|
||||||
|
"group_id": obj["group_id"],
|
||||||
|
"component_role": obj.get("component_role"),
|
||||||
|
"geometry_role": obj.get("geometry_role"),
|
||||||
|
"cm95_resource_key": None,
|
||||||
|
"assignment_status": "UNKNOWN",
|
||||||
|
"assigned_standard_layer": None,
|
||||||
|
"candidate_standard_layers": [],
|
||||||
|
"basis": "UNRESOLVED_DYNAMIC_VISUAL",
|
||||||
|
"reviewed_standard_resource_key": None,
|
||||||
|
"component_kind": None,
|
||||||
|
"provenance_for_audit_only": obj.get("provenance"),
|
||||||
|
})
|
||||||
|
group_counts[obj["group_id"]]["__unknown__"] += 1
|
||||||
|
|
||||||
|
group_summary: list[dict[str, Any]] = []
|
||||||
|
for gid in sorted(group_provenance):
|
||||||
|
c = group_counts[gid]
|
||||||
|
group_summary.append({
|
||||||
|
"group_id": gid,
|
||||||
|
"named_occurrences": c["__named__"],
|
||||||
|
"ambiguous_occurrences": c["__ambiguous__"],
|
||||||
|
"component_occurrences": c["__component__"],
|
||||||
|
"unknown_occurrences": c["__unknown__"],
|
||||||
|
"assigned_layer_counts": {
|
||||||
|
k: v for k, v in c.items() if not k.startswith("__")
|
||||||
|
},
|
||||||
|
"provenance_for_audit_only": group_provenance[gid],
|
||||||
|
})
|
||||||
|
|
||||||
|
layer_summary: dict[str, Any] = {}
|
||||||
|
for layer, c in layer_counts.items():
|
||||||
|
layer_summary[layer] = {
|
||||||
|
"MATCH": c["MATCH"],
|
||||||
|
"PARTIAL": c["PARTIAL"],
|
||||||
|
"occurrences": c["occurrences"],
|
||||||
|
}
|
||||||
|
|
||||||
|
all_resource_keys = sorted({
|
||||||
|
o.get("resource_key")
|
||||||
|
for o in anon.get("visual_objects", [])
|
||||||
|
if o.get("resource_key")
|
||||||
|
})
|
||||||
|
resource_summary: list[dict[str, Any]] = []
|
||||||
|
for key in all_resource_keys:
|
||||||
|
a = anchors.get(key)
|
||||||
|
layers = anchor_layers(a)
|
||||||
|
status = str(a["status"]) if a else "UNKNOWN"
|
||||||
|
unique_layer = layers[0] if status in {"MATCH", "PARTIAL"} and len(layers) == 1 else None
|
||||||
|
c = resource_status[key]
|
||||||
|
resource_summary.append({
|
||||||
|
"cm95_resource_key": key,
|
||||||
|
"assignment_status": status,
|
||||||
|
"assigned_standard_layer": unique_layer,
|
||||||
|
"candidate_standard_layers": layers,
|
||||||
|
"reviewed_standard_resource_key": a.get("standard_resource_key") if a else None,
|
||||||
|
"component_kind": a.get("component_kind") if a and status == "COMPONENT" else None,
|
||||||
|
"occurrence_count": sum(c.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
ambiguous_resource_counts: Counter = Counter()
|
||||||
|
for key, a in anchors.items():
|
||||||
|
if a.get("status") == "AMBIGUOUS":
|
||||||
|
ambiguous_resource_counts[tuple(sorted(anchor_layers(a)))] += 1
|
||||||
|
|
||||||
|
ambiguous_summary = [
|
||||||
|
{
|
||||||
|
"candidate_standard_layers": list(layer_set),
|
||||||
|
"resource_count": ambiguous_resource_counts[layer_set],
|
||||||
|
"occurrence_count": ambiguous_occurrence_counts[layer_set],
|
||||||
|
}
|
||||||
|
for layer_set in sorted(ambiguous_resource_counts)
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(assignments)
|
||||||
|
match_count = sum(x["assignment_status"] == "MATCH" for x in assignments)
|
||||||
|
partial_count = sum(x["assignment_status"] == "PARTIAL" for x in assignments)
|
||||||
|
ambiguous_count = sum(x["assignment_status"] == "AMBIGUOUS" for x in assignments)
|
||||||
|
component_count = sum(x["assignment_status"] == "COMPONENT" for x in assignments)
|
||||||
|
unknown_count = sum(x["assignment_status"] == "UNKNOWN" for x in assignments)
|
||||||
|
uniquely_named = match_count + partial_count
|
||||||
|
sprite_backed = sum(bool(x.get("cm95_resource_key")) for x in assignments)
|
||||||
|
|
||||||
|
unique_layers: set[str] = set()
|
||||||
|
for a in anchors.values():
|
||||||
|
unique_layers.update(anchor_layers(a))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"schema": "cm95-visual-object-layer-assignment/v1",
|
||||||
|
"source_anonymous_catalog": str(anon_path),
|
||||||
|
"reviewed_anchor_file": str(anchor_path),
|
||||||
|
"policy": {
|
||||||
|
"cm95_source_layer_used_for_assignment": False,
|
||||||
|
"provider_field_semantics_used_for_assignment": False,
|
||||||
|
"standard_layer_is_output_only": True,
|
||||||
|
"unknown_is_allowed": True,
|
||||||
|
"ambiguous_is_allowed": True,
|
||||||
|
"component_is_allowed": True,
|
||||||
|
"whole_group_inheritance_forbidden": True,
|
||||||
|
"anonymous_catalog_modified": False,
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"total_visual_objects": total,
|
||||||
|
"sprite_backed_visual_objects": sprite_backed,
|
||||||
|
"reviewed_anchor_count": len(anchors),
|
||||||
|
"reviewed_standard_layer_candidates": sorted(unique_layers),
|
||||||
|
"uniquely_named_visual_objects": uniquely_named,
|
||||||
|
"match_visual_objects": match_count,
|
||||||
|
"partial_visual_objects": partial_count,
|
||||||
|
"ambiguous_visual_objects": ambiguous_count,
|
||||||
|
"component_visual_objects": component_count,
|
||||||
|
"unknown_visual_objects": unknown_count,
|
||||||
|
"uniquely_named_ratio": (uniquely_named / total) if total else 0.0,
|
||||||
|
"sprite_backed_uniquely_named_ratio": (uniquely_named / sprite_backed) if sprite_backed else 0.0,
|
||||||
|
},
|
||||||
|
"layer_summary": layer_summary,
|
||||||
|
"ambiguous_summary": ambiguous_summary,
|
||||||
|
"resource_summary": resource_summary,
|
||||||
|
"group_summary": group_summary,
|
||||||
|
"assignments": assignments,
|
||||||
|
}
|
||||||
|
|
||||||
|
(out / "cm95-layer-assignment-v1.json").write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(out / "cm95-layer-assignment-v1.html").write_text(
|
||||||
|
build_html(result), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (out / "cm95-layer-assignment-v1.csv").open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
f,
|
||||||
|
lineterminator="\n",
|
||||||
|
fieldnames=[
|
||||||
|
"cm95_resource_key",
|
||||||
|
"assignment_status",
|
||||||
|
"assigned_standard_layer",
|
||||||
|
"candidate_standard_layers",
|
||||||
|
"reviewed_standard_resource_key",
|
||||||
|
"component_kind",
|
||||||
|
"occurrence_count",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
for row in resource_summary:
|
||||||
|
writer.writerow({
|
||||||
|
**row,
|
||||||
|
"candidate_standard_layers": ";".join(row["candidate_standard_layers"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps(result["summary"], ensure_ascii=False, indent=2))
|
||||||
|
print(json.dumps(result["layer_summary"], ensure_ascii=False, indent=2))
|
||||||
|
print(json.dumps(result["ambiguous_summary"], ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
461
tools/style-visual-object/build_reference_names.py
Normal file
461
tools/style-visual-object/build_reference_names.py
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""生成 CM95 / NavSea Visual Object Reviewer 使用的中文名称补充表。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 该文件只用于 UI 显示和人工理解;
|
||||||
|
- 不参与 Visual Object 匹配、聚类、Layer 判定;
|
||||||
|
- CM95 英文说明来自当前 CM95 仓库中的 S-52 metadata;
|
||||||
|
- 中文名称为工程显示译名,不冒充 IHO 官方中文译名。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
OUTPUT = ROOT / "output"
|
||||||
|
CM95_ROOT = Path("/root/sourceserver/cm95")
|
||||||
|
|
||||||
|
LAYER_ZH = {
|
||||||
|
"anchor_caution_hazard_outline": "锚泊注意/危险区轮廓",
|
||||||
|
"anchor_caution_hazard_point": "锚泊注意/危险点",
|
||||||
|
"anchorage_area": "锚地面",
|
||||||
|
"anchorage_outline": "锚地轮廓",
|
||||||
|
"anchorage_point": "锚地点",
|
||||||
|
"baseline_area": "基础区域",
|
||||||
|
"baseline_line": "基础线",
|
||||||
|
"baseline_outline": "基础区域轮廓",
|
||||||
|
"bathymetry_line": "海底地形线",
|
||||||
|
"bridge_structure": "桥梁结构",
|
||||||
|
"clearance_limit_line": "净空限制线",
|
||||||
|
"clearance_limit_point": "净空限制点",
|
||||||
|
"clip_outline_754": "特殊边界线",
|
||||||
|
"depth_contour": "等深线",
|
||||||
|
"depth_zone_739": "水深分区 739",
|
||||||
|
"depth_zone_741": "水深分区 741",
|
||||||
|
"facility_boundary_outline": "设施边界轮廓",
|
||||||
|
"facility_boundary_point": "港口/设施点",
|
||||||
|
"fixed_fishing_gear_area": "定置渔具区域",
|
||||||
|
"hazard_boundary_outline": "危险边界轮廓",
|
||||||
|
"hole_area": "孔洞/挖空区域",
|
||||||
|
"land_area": "陆地区域",
|
||||||
|
"navigation_hazard_outline": "航行危险轮廓",
|
||||||
|
"navigation_hazard_point": "航行危险点",
|
||||||
|
"navigation_marks": "航路标识",
|
||||||
|
"onshore_structure_area": "陆上结构物面",
|
||||||
|
"onshore_structure_line": "陆上结构物线",
|
||||||
|
"onshore_structure_point": "陆上结构物点",
|
||||||
|
"pilot_station_point": "引航站",
|
||||||
|
"place_label_land": "陆地地名",
|
||||||
|
"place_label_sea": "海域地名",
|
||||||
|
"seabed_line": "海底对象线",
|
||||||
|
"seabed_text_point": "海底文字点",
|
||||||
|
"submerged_reef_area": "水下礁区域",
|
||||||
|
"tide": "潮汐",
|
||||||
|
}
|
||||||
|
|
||||||
|
STD_KEY_ZH = {
|
||||||
|
"tidespot-daytime": "潮汐点",
|
||||||
|
"pattern_fill_fishery_or_fish_reef_area": "定置渔业/鱼礁填充",
|
||||||
|
"special_pattern_line_754": "特殊边界线",
|
||||||
|
"bollard_pile_stake": "系缆柱、桩、杭",
|
||||||
|
"cleared_danger_object": "扫海済危险物",
|
||||||
|
"dolphin_structure": "系泊墩",
|
||||||
|
"eddy_whirlpool": "涡流",
|
||||||
|
"fish_reef": "鱼礁",
|
||||||
|
"fish_reef_dangerous": "危险鱼礁",
|
||||||
|
"foul_ground": "碍锚海底",
|
||||||
|
"isolated_danger": "孤立危险物",
|
||||||
|
"obstruction": "障碍物",
|
||||||
|
"rock_exposed_drying_awash_group": "露出岩/干出岩/洗岩",
|
||||||
|
"sand_wave": "沙波",
|
||||||
|
"seaweed": "海草",
|
||||||
|
"subsea_installation_outfall_intake": "海底设施/放水口/取水口",
|
||||||
|
"sunken_rock": "暗岩",
|
||||||
|
"tide_rips_overfalls": "急潮/波纹",
|
||||||
|
"tower_yagura_observation_platform": "塔/架台/测台",
|
||||||
|
"wreck_fully_submerged_dangerous": "危险全沉船",
|
||||||
|
"wreck_hull_exposed": "船体或桅杆露出沉船",
|
||||||
|
"wreck_surveyed": "测定済沉船",
|
||||||
|
"anchorage_designated": "指定锚地",
|
||||||
|
"anchorage_quarantine": "检疫锚地",
|
||||||
|
"anchoring_prohibited": "禁止锚泊区",
|
||||||
|
"restriction_area_group": "限制区域",
|
||||||
|
"light_flare_green": "绿色灯光",
|
||||||
|
"light_flare_red": "红色灯光",
|
||||||
|
"light_flare_yellow_white": "黄色/白色灯光",
|
||||||
|
"harbor_lighthouse": "港湾灯塔",
|
||||||
|
"breakwater_lighthouse": "防波堤灯塔",
|
||||||
|
"coastal_lighthouse": "沿岸灯塔",
|
||||||
|
"light_minor": "小型灯标",
|
||||||
|
"light_beacon": "灯标",
|
||||||
|
"light_beacon_east_cardinal_variantt": "东方位灯标",
|
||||||
|
"light_beacon_isolated_danger_variantt_311359": "孤立危险标识灯标",
|
||||||
|
"light_beacon_north_cardinal_variantt": "北方位灯标",
|
||||||
|
"light_beacon_north_cardinal_variantt_311356": "北方位灯标",
|
||||||
|
"light_beacon_safe_water_variantt_311360": "安全水域标识灯标",
|
||||||
|
"light_beacon_south_cardinal_variantt": "南方位灯标",
|
||||||
|
"light_beacon_west_cardinal_variantt_311358": "西方位灯标",
|
||||||
|
"buoy_can": "罐形浮标",
|
||||||
|
"buoy_east_cardinal": "东方位浮标",
|
||||||
|
"buoy_isolated_danger": "孤立危险浮标",
|
||||||
|
"buoy_lattice": "架形浮标",
|
||||||
|
"buoy_north_cardinal": "北方位浮标",
|
||||||
|
"buoy_pillar": "柱形浮标",
|
||||||
|
"buoy_port_lateral_variantt_312350": "左舷浮标",
|
||||||
|
"buoy_safe_water": "安全水域浮标",
|
||||||
|
"buoy_south_cardinal": "南方位浮标",
|
||||||
|
"buoy_special_mark": "特殊标识浮标",
|
||||||
|
"buoy_starboard_lateral_variantt_312351": "右舷浮标",
|
||||||
|
"buoy_west_cardinal": "西方位浮标",
|
||||||
|
"mooring_buoy": "系泊浮标",
|
||||||
|
"fisherina": "渔港休闲设施",
|
||||||
|
"fishing_port": "渔港",
|
||||||
|
"marina": "游艇码头",
|
||||||
|
"port_general_small_harbor_group": "一般港湾/小港湾",
|
||||||
|
"sea_station_umi_no_eki": "海之驿",
|
||||||
|
"pilot_station": "引航站",
|
||||||
|
"symbol-daytime-401": "露出岩/干出岩/洗岩",
|
||||||
|
"symbol-daytime-404": "暗岩",
|
||||||
|
"symbol-daytime-405": "扫海済危险物",
|
||||||
|
"symbol-daytime-409": "孤立危险物",
|
||||||
|
"symbol-daytime-410": "船体或桅杆露出沉船",
|
||||||
|
"symbol-daytime-412": "危险全沉船",
|
||||||
|
"symbol-daytime-413": "测定済沉船",
|
||||||
|
"symbol-daytime-420": "碍锚海底",
|
||||||
|
"symbol-daytime-421": "沙波",
|
||||||
|
"symbol-daytime-422": "急潮/波纹",
|
||||||
|
"symbol-daytime-424": "涡流",
|
||||||
|
"symbol-daytime-425": "海草",
|
||||||
|
"symbol-daytime-427": "障碍物",
|
||||||
|
"symbol-daytime-428": "鱼礁",
|
||||||
|
"symbol-daytime-429": "危险鱼礁",
|
||||||
|
"symbol-daytime-431": "塔/架台/测台",
|
||||||
|
"symbol-daytime-432": "系缆柱、桩、杭",
|
||||||
|
"symbol-daytime-433": "系泊墩",
|
||||||
|
"symbol-daytime-434": "海底设施/放水口/取水口",
|
||||||
|
"symbol-daytime-640": "烟囱",
|
||||||
|
"symbol-daytime-650": "陆上显著结构物",
|
||||||
|
"symbol-daytime-660": "陆上显著结构物",
|
||||||
|
"symbol-daytime-661": "陆上显著结构物",
|
||||||
|
"symbol-daytime-681": "陆上显著结构物",
|
||||||
|
"symbol-daytime-698": "其他陆上结构物",
|
||||||
|
}
|
||||||
|
|
||||||
|
def color_zh(text: str) -> str:
|
||||||
|
replacements = [
|
||||||
|
("black/yellow/black", "黑/黄/黑"),
|
||||||
|
("yellow/black/yellow", "黄/黑/黄"),
|
||||||
|
("black/red/black", "黑/红/黑"),
|
||||||
|
("green/white", "绿/白"),
|
||||||
|
("red/white", "红/白"),
|
||||||
|
("black/yellow", "黑/黄"),
|
||||||
|
("yellow/black", "黄/黑"),
|
||||||
|
("white/black", "白/黑"),
|
||||||
|
("black/white", "黑/白"),
|
||||||
|
("white/red", "白/红"),
|
||||||
|
("red/green", "红/绿"),
|
||||||
|
("white or yellow", "白色或黄色"),
|
||||||
|
("yellow", "黄色"),
|
||||||
|
("green", "绿色"),
|
||||||
|
("red", "红色"),
|
||||||
|
("white", "白色"),
|
||||||
|
("black", "黑色"),
|
||||||
|
]
|
||||||
|
out = text
|
||||||
|
for en, zh in replacements:
|
||||||
|
out = out.replace(en, zh)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def s52_zh(key: str, desc: str) -> tuple[str, str]:
|
||||||
|
"""返回 (中文显示名, 来源说明)。"""
|
||||||
|
d = desc.strip().lower()
|
||||||
|
if not d:
|
||||||
|
fallback = {
|
||||||
|
"STARPT01": "星形点标志",
|
||||||
|
"TOPMA115": "顶标",
|
||||||
|
"TOPMAR01": "浮标顶标",
|
||||||
|
}.get(key, "未提供说明")
|
||||||
|
return fallback, "工程显示译名"
|
||||||
|
|
||||||
|
exact = {
|
||||||
|
"anchorage area": "锚地",
|
||||||
|
"designated anchor berth for a single vessel": "指定单船锚泊位",
|
||||||
|
"symbol for opening bridge": "开启桥",
|
||||||
|
"berth number symbol": "泊位编号",
|
||||||
|
"built-up area": "建成区",
|
||||||
|
"coastguard station": "海岸警卫站",
|
||||||
|
"chimney": "烟囱",
|
||||||
|
"conspicuous chimney": "显著烟囱",
|
||||||
|
"ho information note": "海图机构信息注记",
|
||||||
|
"flare stack": "火炬塔",
|
||||||
|
"flagstaff, flagpole": "旗杆",
|
||||||
|
"fog signal": "雾号",
|
||||||
|
"fortified structure": "防御工事",
|
||||||
|
"fish haven": "鱼礁/鱼类栖息设施",
|
||||||
|
"fishing harbour": "渔港",
|
||||||
|
"light flare": "灯光标志",
|
||||||
|
"floodlight": "泛光灯",
|
||||||
|
"fish farm": "养殖场",
|
||||||
|
"mooring dolphin": "系泊墩",
|
||||||
|
"mast": "桅杆",
|
||||||
|
"offshore platform": "海上平台",
|
||||||
|
"pilot boarding place": "引航员登轮点",
|
||||||
|
"pile or bollard": "桩/系缆柱",
|
||||||
|
"traffic precautionary area": "通航注意区",
|
||||||
|
"radar reflector": "雷达反射器",
|
||||||
|
"radio station": "无线电台",
|
||||||
|
"radar transponder beacon": "雷达应答标",
|
||||||
|
"yacht harbour, marina": "游艇码头",
|
||||||
|
"sand waves": "沙波",
|
||||||
|
"traffic crossing area": "交通交叉区",
|
||||||
|
"traffic roundabout": "交通环岛",
|
||||||
|
"weed, kelp": "海草/海带",
|
||||||
|
"windmotor": "风车",
|
||||||
|
"submarine cable": "海底电缆",
|
||||||
|
"ferry route": "渡轮航线",
|
||||||
|
"fishing stakes": "定置渔桩",
|
||||||
|
"area with minor restrictions or information notices": "轻微限制/信息提示区域",
|
||||||
|
"beacon in general, black/yellow/back, full-chart": "一般立标(黑/黄/黑,完整显示)",
|
||||||
|
"beacon in general, yellow/back/yellow, full-chart": "一般立标(黄/黑/黄,完整显示)",
|
||||||
|
"beacon in general, black/red/back, full-chart": "一般立标(黑/红/黑,完整显示)",
|
||||||
|
"conical buoy,lateral-green,full-chart": "锥形侧面浮标(绿色,完整显示)",
|
||||||
|
"mooring buoy, can shape, paper-chart": "罐形系泊浮标(海图符号)",
|
||||||
|
"pillar buoy,cardianl-yby,full-chart": "柱形方位浮标(黄/黑/黄,完整显示)",
|
||||||
|
"topmark for buoys, cone point up, paper-chart": "浮标顶标:单锥尖向上",
|
||||||
|
"topmark for buoys, 2 cones point upward, paper-chart": "浮标顶标:双锥尖向上",
|
||||||
|
"topmark for buoys, 2 cones point downward, paper-chart": "浮标顶标:双锥尖向下",
|
||||||
|
"topmark for buoys, 2 cones base to base, paper-chart": "浮标顶标:双锥底相对",
|
||||||
|
"topmark for buoys, 2 cones point to point, paper-chart": "浮标顶标:双锥尖相对",
|
||||||
|
"topmark for buoys, sphere, paper-chart": "浮标顶标:球形",
|
||||||
|
"topmark for buoys, 2 spheres, paper-chart": "浮标顶标:双球",
|
||||||
|
"topmark for buoys, cylinder, paper-chart": "浮标顶标:圆柱形",
|
||||||
|
"beacon top mark, red boarded square board, vertical": "立标顶标:红边方形板(竖向)",
|
||||||
|
}
|
||||||
|
if d in exact:
|
||||||
|
return exact[d], "S-52 英文说明的中文显示译名"
|
||||||
|
|
||||||
|
if d.startswith("anchorage area as a point"):
|
||||||
|
return "锚地点", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("beacon in general"):
|
||||||
|
suffix = d.split(",", 1)[1].strip() if "," in d else ""
|
||||||
|
return "一般立标" + (f"({color_zh(suffix.replace('full-chart','完整显示').replace('paper-chart','海图符号'))})" if suffix else ""), "S-52 英文说明的中文显示译名"
|
||||||
|
if "spar beacon" in d:
|
||||||
|
return color_zh(d.replace("spar beacon", "杆形立标").replace("full-chart", "完整显示")), "S-52 英文说明的中文显示译名"
|
||||||
|
if "stake or pole beacon" in d:
|
||||||
|
return color_zh(d.replace("minor, ", "小型").replace("stake or pole beacon", "桩/杆形立标").replace("full-chart", "完整显示")), "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("beacon tower"):
|
||||||
|
return color_zh(d.replace("beacon tower", "塔形立标").replace("full-chart", "完整显示").replace("brb", "黑/红/黑")), "S-52 英文说明的中文显示译名"
|
||||||
|
if "buoy" in d:
|
||||||
|
out = d
|
||||||
|
out = out.replace("can buoy", "罐形浮标").replace("conical buoy", "锥形浮标")
|
||||||
|
out = out.replace("mooring buoy", "系泊浮标").replace("pillar buoy", "柱形浮标")
|
||||||
|
out = out.replace("spherical buoy", "球形浮标").replace("spar buoy", "杆形浮标")
|
||||||
|
out = out.replace("super-buoy", "特大型浮标").replace("super buoy", "特大型浮标")
|
||||||
|
out = out.replace("default symbol for buoy", "一般浮标")
|
||||||
|
out = out.replace("paper-chart", "海图符号").replace("full-chart", "完整显示")
|
||||||
|
out = out.replace("lateral-r", "侧面标识-红").replace("lateral-g", "侧面标识-绿")
|
||||||
|
out = out.replace("special purpose-y", "特殊用途-黄")
|
||||||
|
out = out.replace("cardinal-byb", "方位标-黑/黄/黑").replace("cardinal-yby", "方位标-黄/黑/黄")
|
||||||
|
out = out.replace("cardinal-by", "方位标-黑/黄").replace("cardinal-yb", "方位标-黄/黑")
|
||||||
|
out = out.replace("isol.danger-brb", "孤立危险标-黑/红/黑").replace("safe water-rw", "安全水域标-红/白")
|
||||||
|
out = color_zh(out)
|
||||||
|
return out, "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("caution area"):
|
||||||
|
return "注意区域", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("cautionary area"):
|
||||||
|
return "谨慎航行区域", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("foul area of seabed"):
|
||||||
|
return "碍锚海底(航行安全但不宜锚泊)", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("fish trap"):
|
||||||
|
return "渔具/鱼栅/金枪鱼网", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("navigable lock gate"):
|
||||||
|
return "可通航闸门", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("non-navigable lock gate"):
|
||||||
|
return "不可通航闸门", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("light flare"):
|
||||||
|
return color_zh(d.replace("light flare", "灯光标志")), "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("light float"):
|
||||||
|
return "灯浮体", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("light vessel"):
|
||||||
|
return color_zh(d.replace("light vessel", "灯船").replace("full-chart", "完整显示")), "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("cursor pick site for a magnetic anomaly"):
|
||||||
|
return "磁异常位置", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("point feature or area of low accuracy"):
|
||||||
|
return "低位置精度对象/区域", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("position of an elevation or control point"):
|
||||||
|
return "高程点/控制点", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("object which is not sufficiently described"):
|
||||||
|
return "未充分描述/无对应符号的对象", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("symbol indicating this object is radar conspicuous"):
|
||||||
|
return "雷达显著物标志", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("recommended route"):
|
||||||
|
return "推荐航路", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("topmark for buoys"):
|
||||||
|
tail = d.split(",", 1)[1].strip() if "," in d else ""
|
||||||
|
trans = tail.replace("2 cones point upward", "双锥尖向上").replace("2 cones point downward", "双锥尖向下")
|
||||||
|
trans = trans.replace("2 cones base to base", "双锥底相对").replace("2 cones point to point", "双锥尖相对")
|
||||||
|
trans = trans.replace("cone point up", "单锥尖向上").replace("2 spheres", "双球").replace("sphere", "球形").replace("cylinder", "圆柱形")
|
||||||
|
trans = trans.replace("paper-chart", "海图符号")
|
||||||
|
return f"浮标顶标:{trans}", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("topmark for beacons"):
|
||||||
|
tail = d.split(",", 1)[1].strip() if "," in d else ""
|
||||||
|
trans = tail.replace("2 cones point upward", "双锥尖向上").replace("2 cones point downward", "双锥尖向下")
|
||||||
|
trans = trans.replace("2 cones base to base", "双锥底相对").replace("2 cones point to point", "双锥尖相对")
|
||||||
|
trans = trans.replace("cone point up", "单锥尖向上").replace("2 spheres", "双球").replace("sphere", "球形").replace("cylinder", "圆柱形")
|
||||||
|
trans = trans.replace("paper-chart", "海图符号")
|
||||||
|
return f"立标顶标:{trans}", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("beacon top mark"):
|
||||||
|
return color_zh(d.replace("beacon top mark", "立标顶标").replace("square board", "方形板").replace("diagonal", "对角").replace("vertical", "竖向")), "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("tower"):
|
||||||
|
return color_zh(d.replace("tower", "塔").replace("full-chart and simplified", "完整/简化显示")), "S-52 英文说明的中文显示译名"
|
||||||
|
if d == "water tower":
|
||||||
|
return "水塔", "S-52 英文说明的中文显示译名"
|
||||||
|
if d == "conspicuous tower":
|
||||||
|
return "显著塔", "S-52 英文说明的中文显示译名"
|
||||||
|
if d == "radio, television tower":
|
||||||
|
return "无线电/电视塔", "S-52 英文说明的中文显示译名"
|
||||||
|
if d == "conspicuous radio, television tower":
|
||||||
|
return "显著无线电/电视塔", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("traffic direction"):
|
||||||
|
return "分道通航单向航道交通方向", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("overfalls, eddies and breakers"):
|
||||||
|
return "急流、涡流和碎波", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("pattern of symbols for a fish haven"):
|
||||||
|
return "鱼礁填充图案", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("pattern of symbols for a marine farm"):
|
||||||
|
return "养殖场填充图案", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("pattern of symbols for a marsh"):
|
||||||
|
return "沼泽填充图案", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("incompletely surveyed area"):
|
||||||
|
return "未完全测量区域", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("pattern of symbols for an area which is not sufficiently described"):
|
||||||
|
return "未充分描述区域填充图案", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("precautionary area or a traffic separation scheme"):
|
||||||
|
return "通航注意区/分道通航交叉或环岛", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("pattern of symbols for wooded areas"):
|
||||||
|
return "林地区域填充图案", "S-52 英文说明的中文显示译名"
|
||||||
|
if d == "essa or pssa boundary":
|
||||||
|
return "特别敏感海域/环境敏感海域边界", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("safety contour of low accuracy"):
|
||||||
|
return "低位置精度安全等深线", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("coastline or shoreline construction of low accuracy"):
|
||||||
|
return "低位置精度海岸线/岸线构筑物", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("oil, gas pipeline"):
|
||||||
|
return "油气管线(海底或陆上)", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("water pipeline"):
|
||||||
|
return "水管/下水管等", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("non-regulated recommended two-way track"):
|
||||||
|
return "非管制推荐双向航迹", "S-52 英文说明的中文显示译名"
|
||||||
|
if d.startswith("non-regulated recommended one-way track"):
|
||||||
|
return "非管制推荐单向航迹", "S-52 英文说明的中文显示译名"
|
||||||
|
|
||||||
|
# 兜底:避免伪造精确术语。仍展示英文原说明。
|
||||||
|
return "待人工确认中文名", "尚未建立可靠中文显示译名"
|
||||||
|
|
||||||
|
|
||||||
|
def build_cm95_names() -> dict[str, Any]:
|
||||||
|
cm = json.loads((OUTPUT / "cm95-anonymous" / "anonymous-catalog.json").read_text(encoding="utf-8"))
|
||||||
|
keys = sorted({o["resource_key"] for o in cm["visual_objects"] if o.get("resource_key")})
|
||||||
|
|
||||||
|
base = json.loads((CM95_ROOT / "build/s52_resources_v2/base/s52_symbol_metadata.json").read_text(encoding="utf-8"))
|
||||||
|
ap_raw = json.loads((CM95_ROOT / "build/s52_resources_v2/ap/s52-sprite-ap-pattern-metadata.json").read_text(encoding="utf-8"))
|
||||||
|
lc_raw = json.loads((CM95_ROOT / "build/s52_resources_v2/final/s52-sprite-full-line-metadata.json").read_text(encoding="utf-8"))
|
||||||
|
ap = {v.get("sprite_key"): v for v in ap_raw.values()}
|
||||||
|
lc = {v.get("sprite_key"): v for v in lc_raw.values()}
|
||||||
|
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for key in keys:
|
||||||
|
rec = base.get(key) or ap.get(key) or lc.get(key)
|
||||||
|
source_type = "symbol" if key in base else ("fill-pattern" if key in ap else ("line-pattern" if key in lc else "unknown"))
|
||||||
|
desc = str((rec or {}).get("description") or "")
|
||||||
|
|
||||||
|
if not desc:
|
||||||
|
# 两个基础图标可从其同名 pattern metadata 得到安全的对象级概念。
|
||||||
|
if key == "FSHHAV02":
|
||||||
|
desc = "fish haven"
|
||||||
|
elif key == "MARSHES1":
|
||||||
|
desc = "marsh"
|
||||||
|
elif key == "CROSSX01":
|
||||||
|
desc = ""
|
||||||
|
|
||||||
|
zh, zh_source = s52_zh(key, desc)
|
||||||
|
if key == "MARSHES1":
|
||||||
|
zh, zh_source = "沼泽", "由同名 S-52 填充图案说明补充"
|
||||||
|
elif key == "FSHHAV02":
|
||||||
|
zh, zh_source = "鱼礁/鱼类栖息设施", "由同名 S-52 填充图案说明补充"
|
||||||
|
elif key == "CROSSX01":
|
||||||
|
zh, zh_source = "交叉标志(待确认)", "S-52 metadata 未找到说明,名称仅作 UI 提示"
|
||||||
|
|
||||||
|
out[key] = {
|
||||||
|
"code": key,
|
||||||
|
"resource_type": source_type,
|
||||||
|
"official_description_en": desc or None,
|
||||||
|
"display_name_zh": zh,
|
||||||
|
"display_name_source": zh_source,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def std_name(key: str, layer: str) -> dict[str, Any]:
|
||||||
|
zh = STD_KEY_ZH.get(key)
|
||||||
|
source = "NavSea 标准资源语义键中文显示译名"
|
||||||
|
if not zh:
|
||||||
|
if key.startswith("leading_light"):
|
||||||
|
zh = "导灯"
|
||||||
|
elif key.startswith("leading_mark"):
|
||||||
|
zh = "导标"
|
||||||
|
elif key.startswith("arc-daytime"):
|
||||||
|
zh = "灯弧"
|
||||||
|
elif key.startswith("symbol-daytime"):
|
||||||
|
zh = LAYER_ZH.get(layer, "标准视觉对象")
|
||||||
|
source = "按标准 Layer 显示,具体对象名待补"
|
||||||
|
else:
|
||||||
|
zh = LAYER_ZH.get(layer, "标准视觉对象")
|
||||||
|
source = "按标准 Layer 显示,具体对象名待补"
|
||||||
|
return {
|
||||||
|
"resource_key": key,
|
||||||
|
"standard_layer": layer,
|
||||||
|
"standard_layer_zh": LAYER_ZH.get(layer, layer),
|
||||||
|
"display_name_zh": zh,
|
||||||
|
"display_name_source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_std_names() -> dict[str, Any]:
|
||||||
|
std = json.loads((OUTPUT / "navsea-standard" / "standard-catalog.json").read_text(encoding="utf-8"))
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for obj in std["visual_objects"]:
|
||||||
|
key = obj.get("resource_key")
|
||||||
|
layer = obj.get("standard_layer_name")
|
||||||
|
if not key or not layer:
|
||||||
|
continue
|
||||||
|
out[key] = std_name(key, layer)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
result = {
|
||||||
|
"schema": "visual-object-reference-names/v1",
|
||||||
|
"policy": {
|
||||||
|
"ui_only": True,
|
||||||
|
"used_for_matching": False,
|
||||||
|
"used_for_layer_assignment": False,
|
||||||
|
"chinese_names_are_display_translations": True,
|
||||||
|
},
|
||||||
|
"cm95": build_cm95_names(),
|
||||||
|
"navsea_standard": build_std_names(),
|
||||||
|
"standard_layer_zh": LAYER_ZH,
|
||||||
|
}
|
||||||
|
path = OUTPUT / "reference-name-enrichment-v1.json"
|
||||||
|
path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(path)
|
||||||
|
print("CM95", len(result["cm95"]))
|
||||||
|
print("NavSea Standard", len(result["navsea_standard"]))
|
||||||
|
print("CM95 待人工确认中文名", sum(x["display_name_zh"] == "待人工确认中文名" for x in result["cm95"].values()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
388
tools/style-visual-object/cm95_semantic_layer_rules_v1.json
Normal file
388
tools/style-visual-object/cm95_semantic_layer_rules_v1.json
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
{
|
||||||
|
"schema": "cm95-provider-layer-semantic-rules/v1",
|
||||||
|
"purpose": "Provider Layer Classifier only. These rules are not part of Style Visual Object extraction.",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "topmark",
|
||||||
|
"pattern": "^(topmark for |beacon top mark)",
|
||||||
|
"category_zh": "航标组件:顶标",
|
||||||
|
"component_kind": "topmark",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.98
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 官方说明明确为顶标。顶标通常不是独立对象,而是浮标/立标的组成部分。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "light_flare",
|
||||||
|
"pattern": "^light flare",
|
||||||
|
"category_zh": "航标组件:灯光",
|
||||||
|
"component_kind": "light",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.96
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 官方说明明确为灯光标志,属于航标显示组件。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "buoy",
|
||||||
|
"pattern": "\\bbuoy\\b",
|
||||||
|
"category_zh": "浮标/系泊浮标",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.98
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 官方说明明确为 buoy,属于航路标识。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beacon",
|
||||||
|
"pattern": "\\bbeacon\\b|radar transponder beacon",
|
||||||
|
"category_zh": "立标/信标",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.98
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 官方说明明确为 beacon/信标,属于航路标识。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "light_vessel_float",
|
||||||
|
"pattern": "^light (float|vessel)",
|
||||||
|
"category_zh": "灯浮体/灯船",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.94
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "灯浮体和灯船属于助航标志。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anchorage_point",
|
||||||
|
"pattern": "anchorage area as a point|anchor berth",
|
||||||
|
"category_zh": "锚地/锚泊位",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"anchorage_point",
|
||||||
|
0.96
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 说明为锚地点或单船锚泊位,优先归入锚地点。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "anchorage_area_symbol",
|
||||||
|
"pattern": "^anchorage area$",
|
||||||
|
"category_zh": "锚地",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"anchorage_point",
|
||||||
|
0.78
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"anchorage_area",
|
||||||
|
0.68
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "当前资源是锚地符号;若实际 PBF 几何为点优先 anchorage_point,若为面则 anchorage_area。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bridge",
|
||||||
|
"pattern": "bridge",
|
||||||
|
"category_zh": "桥梁结构",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"bridge_structure",
|
||||||
|
0.97
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义明确为桥梁。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pilot",
|
||||||
|
"pattern": "pilot boarding place",
|
||||||
|
"category_zh": "引航设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"pilot_station_point",
|
||||||
|
0.96
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义为引航员登轮点,归入引航站类。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "harbor_facility",
|
||||||
|
"pattern": "fishing harbour|yacht harbour|marina|coastguard station|mooring dolphin|pile or bollard|berth number",
|
||||||
|
"category_zh": "港口/岸边设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"facility_boundary_point",
|
||||||
|
0.88
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义属于港口、码头或岸边设施点。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beacon_tower",
|
||||||
|
"pattern": "^beacon tower",
|
||||||
|
"category_zh": "航标塔体",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.97
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "虽然外形像普通塔,但 S-52 官方说明明确是 beacon tower,因此属于航路标识。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land_tower",
|
||||||
|
"pattern": "^(tower,|water tower$|conspicuous tower$|radio, television tower$|conspicuous radio, television tower$)",
|
||||||
|
"category_zh": "陆上塔状构筑物",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"onshore_structure_point",
|
||||||
|
0.97
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 官方说明是普通 tower,而不是 buoy/beacon;应作为固定陆上/岸上结构物理解。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "land_structure",
|
||||||
|
"pattern": "chimney|flare stack|flagstaff|flagpole|fortified structure|^mast$|windmotor|built-up area",
|
||||||
|
"category_zh": "陆上固定构筑物",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"onshore_structure_point",
|
||||||
|
0.93
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义是烟囱、旗杆、桅杆、风车等固定构筑物。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "radio_station",
|
||||||
|
"pattern": "^radio station$",
|
||||||
|
"category_zh": "陆上通信设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"onshore_structure_point",
|
||||||
|
0.82
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "无线电台属于固定通信设施,当前标准中最接近陆上结构物点。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "radar_nav",
|
||||||
|
"pattern": "radar reflector|radar conspicuous|radar transponder beacon",
|
||||||
|
"category_zh": "雷达助航标识",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.86
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "雷达反射器/应答标通常作为助航识别信息。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fog_signal",
|
||||||
|
"pattern": "fog signal",
|
||||||
|
"category_zh": "雾号/助航设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_marks",
|
||||||
|
0.82
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "雾号属于助航设施,当前标准中优先归入航路标识。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fish_gear",
|
||||||
|
"pattern": "fish trap|fish weir|tunny net|fish farm|marine farm|fishing stakes",
|
||||||
|
"category_zh": "定置渔具/养殖设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"fixed_fishing_gear_area",
|
||||||
|
0.94
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义明确为定置渔具或养殖设施。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fish_haven",
|
||||||
|
"pattern": "fish haven",
|
||||||
|
"category_zh": "鱼礁/鱼类栖息设施",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"submerged_reef_area",
|
||||||
|
0.72
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"navigation_hazard_point",
|
||||||
|
0.64
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "鱼礁/鱼类栖息设施属于水下对象;面积对象优先水下礁区域,点状符号可作为航行危险点。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "foul_ground",
|
||||||
|
"pattern": "foul area of seabed",
|
||||||
|
"category_zh": "碍锚海底",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_hazard_point",
|
||||||
|
0.72
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"anchor_caution_hazard_point",
|
||||||
|
0.72
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "碍锚海底对锚泊/航行均有警示意义;当前标准存在两个可能的危险点 Layer,需要人工判断。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seabed_hazard",
|
||||||
|
"pattern": "sand waves|weed, kelp|overfalls, eddies and breakers|magnetic anomaly",
|
||||||
|
"category_zh": "海底/水文注意对象",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_hazard_point",
|
||||||
|
0.68
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"seabed_text_point",
|
||||||
|
0.48
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "属于海底或水文注意对象;当前标准缺少完全对应的专用点 Layer。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "caution",
|
||||||
|
"pattern": "caution area|cautionary area|traffic precautionary area|minor restrictions or information notices",
|
||||||
|
"category_zh": "注意/限制区域",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"anchor_caution_hazard_point",
|
||||||
|
0.66
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"hazard_boundary_outline",
|
||||||
|
0.56
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "语义为注意或限制区域;点状符号可归注意/危险点,边界表达可归危险边界。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "submarine_cable_pipeline",
|
||||||
|
"pattern": "submarine cable|pipeline|sewer",
|
||||||
|
"category_zh": "海底电缆/管线",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"seabed_line",
|
||||||
|
0.94
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义明确为海底电缆或管线,归入海底对象线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "safety_contour",
|
||||||
|
"pattern": "safety contour",
|
||||||
|
"category_zh": "安全等深线",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"depth_contour",
|
||||||
|
0.97
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "S-52 语义明确为安全等深线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "coastline_lowacc",
|
||||||
|
"pattern": "coastline or shoreline construction",
|
||||||
|
"category_zh": "海岸线/岸线构筑物",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"baseline_line",
|
||||||
|
0.82
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "语义为海岸线或岸线构筑物,当前标准中最接近基础线。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marsh_wooded",
|
||||||
|
"pattern": "marsh|wooded areas",
|
||||||
|
"category_zh": "陆地区域/植被区域",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"land_area",
|
||||||
|
0.86
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "沼泽或林地属于陆地区域表达。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "essa_pssa",
|
||||||
|
"pattern": "ESSA or PSSA boundary",
|
||||||
|
"category_zh": "环境敏感区域边界",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"hazard_boundary_outline",
|
||||||
|
0.78
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "属于受保护/敏感区域边界,当前标准中最接近危险/限制边界。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "traffic_route",
|
||||||
|
"pattern": "recommended route|traffic crossing area|traffic direction|traffic roundabout|ferry route|recommended two-way track|recommended one-way track",
|
||||||
|
"category_zh": "航路/交通组织",
|
||||||
|
"layer_candidates": [],
|
||||||
|
"reason_zh": "S-52 语义是航路或交通组织,但当前冻结 Standard Catalog 没有明显一一对应的 route Layer,暂不强推。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "question_unknown",
|
||||||
|
"pattern": "not sufficiently described|no symbol exists",
|
||||||
|
"category_zh": "未充分描述对象",
|
||||||
|
"layer_candidates": [],
|
||||||
|
"reason_zh": "S-52 本身就表示对象描述不足,不能可靠自动归 Layer。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "offshore_platform",
|
||||||
|
"pattern": "offshore platform",
|
||||||
|
"category_zh": "海上固定平台",
|
||||||
|
"layer_candidates": [
|
||||||
|
[
|
||||||
|
"navigation_hazard_point",
|
||||||
|
0.52
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"facility_boundary_point",
|
||||||
|
0.46
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"reason_zh": "属于海上固定设施;当前 Standard Catalog 缺少专用 offshore structure Layer,仅给低置信候选。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fill_pattern_generic",
|
||||||
|
"resource_type": "fill-pattern",
|
||||||
|
"pattern": ".*",
|
||||||
|
"category_zh": "区域填充组件",
|
||||||
|
"component_kind": "fill_pattern",
|
||||||
|
"layer_candidates": [],
|
||||||
|
"reason_zh": "这是区域填充图案,本身可能只是完整对象的显示组件;优先结合 S-52 语义和实际 PBF 几何确认。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "line_pattern_generic",
|
||||||
|
"resource_type": "line-pattern",
|
||||||
|
"pattern": ".*",
|
||||||
|
"category_zh": "线型组件",
|
||||||
|
"component_kind": "line_pattern",
|
||||||
|
"layer_candidates": [],
|
||||||
|
"reason_zh": "这是线型图案,本身可能只是完整线对象的显示组件;优先结合 S-52 语义确认。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# CM95 Visual Object 与标准 Layer 命名报告 v1
|
||||||
|
|
||||||
|
日期:2026-10-05
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
本轮已经按固定三步流程完成一次 CM95 Visual Object 处理:
|
||||||
|
|
||||||
|
1. 使用当前 NavSea Style + Sprite 生成匿名 Visual Object,并在生成完成后挂标准 Layer 名;该 Standard Catalog 已冻结。
|
||||||
|
2. 使用同一套 provider-neutral Tool 独立生成 CM95 Anonymous Visual Object Catalog;生成阶段没有使用 NavSea Layer 名。
|
||||||
|
3. 只使用 Visual Object / Sprite 的实际视觉共同性进行人工复核和 Layer Assignment;CM95 source-layer 名称、Provider 字段语义和对象类名称不参与判定。
|
||||||
|
|
||||||
|
全部 CM95 Visual Object 都已经进入 `MATCH / PARTIAL / AMBIGUOUS / UNKNOWN` 四态之一。`UNKNOWN` 和 `AMBIGUOUS` 是正式结果,不进行强行补全。
|
||||||
|
|
||||||
|
## 2. 冻结的 NavSea Standard Visual Object Catalog
|
||||||
|
|
||||||
|
- Style:`NavSea Delivery Full Semantic ExtentFix`
|
||||||
|
- Style SHA256:`18b32ad1ea2d3798000abe21406d2f2a5225968910aed48d7ca5b1a1cb7f3ce6`
|
||||||
|
- Catalog 状态:`FROZEN`
|
||||||
|
- 匿名 group:`40`
|
||||||
|
- Visual Object:`143`
|
||||||
|
- Sprite-backed Visual Object:`99`
|
||||||
|
- 未完全展开的动态资源:`4`
|
||||||
|
|
||||||
|
后续处理 CM95 或其他 Provider 时,默认只读使用该 Standard Catalog;除非明确要求升级 Standard Catalog,否则不重新生成、不改 Visual Object ID、不改标准 Layer 名。
|
||||||
|
|
||||||
|
## 3. CM95 Anonymous Visual Object Catalog
|
||||||
|
|
||||||
|
- Style:`CM95 S-52 Japan nationwide comparison style v1`
|
||||||
|
- Style SHA256:`6c25ce3e5c1db65060be01ca899acf7f49228fb2277572dc1868c3d34019d4b0`
|
||||||
|
- 匿名 group:`27`
|
||||||
|
- Visual Object:`3699`
|
||||||
|
- Sprite-backed Visual Object:`3595`
|
||||||
|
- 当前 Style 正向可达的唯一视觉资源:`150`
|
||||||
|
|
||||||
|
注意:Sprite atlas 中存在但 Style 没有正向可达路径的资源不会进入 Anonymous Catalog;`!match` / `!=` 等排除列表中的资源也不会被误收为候选对象。
|
||||||
|
|
||||||
|
## 4. Visual Review 资源级结果
|
||||||
|
|
||||||
|
- Reviewed anchor:`88`
|
||||||
|
- MATCH 资源:`9`
|
||||||
|
- PARTIAL 资源:`74`
|
||||||
|
- AMBIGUOUS 资源:`5`
|
||||||
|
- UNKNOWN 资源:`62`
|
||||||
|
|
||||||
|
## 5. Visual Object occurrence 级 Layer Assignment
|
||||||
|
|
||||||
|
- 总 Visual Object:`3699`
|
||||||
|
- 唯一 Layer 已命名:`2224`(`60.12%`)
|
||||||
|
- MATCH:`152`
|
||||||
|
- PARTIAL:`2072`
|
||||||
|
- AMBIGUOUS:`140`
|
||||||
|
- UNKNOWN:`1335`
|
||||||
|
- Sprite-backed 对象的唯一 Layer 命名率:`61.86%`
|
||||||
|
|
||||||
|
### 已唯一命名的标准 Layer
|
||||||
|
|
||||||
|
- `anchorage_point`:MATCH `28`,PARTIAL `56`,合计 occurrence `84`
|
||||||
|
- `clip_outline_754`:MATCH `6`,PARTIAL `0`,合计 occurrence `6`
|
||||||
|
- `facility_boundary_point`:MATCH `56`,PARTIAL `0`,合计 occurrence `56`
|
||||||
|
- `navigation_marks`:MATCH `6`,PARTIAL `1876`,合计 occurrence `1882`
|
||||||
|
- `onshore_structure_point`:MATCH `28`,PARTIAL `140`,合计 occurrence `168`
|
||||||
|
- `pilot_station_point`:MATCH `28`,PARTIAL `0`,合计 occurrence `28`
|
||||||
|
|
||||||
|
### AMBIGUOUS
|
||||||
|
|
||||||
|
- `anchor_caution_hazard_point` / `navigation_hazard_point`:资源 `1`,occurrence `28`
|
||||||
|
- `navigation_marks` / `onshore_structure_point`:资源 `4`,occurrence `112`
|
||||||
|
|
||||||
|
其中危险物的歧义来自 NavSea Standard Catalog 自身:同一实际视觉图形同时被 `navigation_hazard_point` 与 `anchor_caution_hazard_point` 复用,因此视觉匹配不能擅自二选一。
|
||||||
|
|
||||||
|
## 6. 已确认的通用规则
|
||||||
|
|
||||||
|
- Layer 名是 Visual Match 的输出,不是匹配输入。
|
||||||
|
- `source-layer` 只作为 provenance / audit,不参与 CM95 Layer 判定。
|
||||||
|
- Sprite 存在不等于 Style 会绘制。
|
||||||
|
- 不允许整个匿名 group 继承单一 Layer;本轮实际证明多个 CM95 group 同时允许多个不同视觉家族。
|
||||||
|
- Layer Assignment 以单个 Visual Object / Visual Resource 为基本单位。
|
||||||
|
- 同图多 Layer 必须输出 AMBIGUOUS。
|
||||||
|
- 证据不足时保持 UNKNOWN。
|
||||||
|
|
||||||
|
## 7. 主要输出
|
||||||
|
|
||||||
|
- `tools/style-visual-object/output/navsea-standard/standard-catalog.json`
|
||||||
|
- `tools/style-visual-object/output/cm95-anonymous/anonymous-catalog.json`
|
||||||
|
- `tools/style-visual-object/output/cm95-visual-anchor-match/reviewed-anchors-v1.json`
|
||||||
|
- `tools/style-visual-object/output/cm95-layer-assignment/cm95-layer-assignment-v1.json`
|
||||||
|
- `tools/style-visual-object/output/cm95-layer-assignment/cm95-layer-assignment-v1.csv`
|
||||||
|
- `tools/style-visual-object/output/cm95-layer-assignment/cm95-layer-assignment-v1.html`
|
||||||
|
|
||||||
|
## 8. 当前边界
|
||||||
|
|
||||||
|
本轮没有为了追求 100% 覆盖率而使用 CM95 source-layer 名、S-52 名称文字含义或 Provider 私有字段去补答案。剩余 UNKNOWN 需要未来通过更多标准 Visual Object、更多视觉共同性或明确的人工视觉确认逐步收敛。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# CM95 → NavSea Standard Layer 人工分类最终报告 v2
|
||||||
|
|
||||||
|
日期:2026-10-06
|
||||||
|
|
||||||
|
## 1. 最终结论
|
||||||
|
|
||||||
|
CM95 当前 Style 正向可达的 150 个唯一视觉资源已全部完成人工分类,未确认项为 0。
|
||||||
|
最终目标是把 CM95/S-52 对象归入现有 NavSea Standard Layer;Standard Visual Object 一一对应仅作为可选辅助证据。
|
||||||
|
|
||||||
|
## 2. 冻结输入基线
|
||||||
|
|
||||||
|
- CM95 Style SHA256:`6c25ce3e5c1db65060be01ca899acf7f49228fb2277572dc1868c3d34019d4b0`
|
||||||
|
- NavSea Standard Style SHA256:`18b32ad1ea2d3798000abe21406d2f2a5225968910aed48d7ca5b1a1cb7f3ce6`
|
||||||
|
- NavSea Standard Catalog 状态:`FROZEN`
|
||||||
|
- CM95 唯一视觉资源:`150`
|
||||||
|
- CM95 Anonymous Visual Object occurrence:`3699`
|
||||||
|
|
||||||
|
## 3. 人工资源级分类
|
||||||
|
|
||||||
|
- `MATCH`:`14`
|
||||||
|
- `PARTIAL`:`93`
|
||||||
|
- `AMBIGUOUS`:`6`
|
||||||
|
- `COMPONENT`:`35`
|
||||||
|
- `UNKNOWN`:`2`
|
||||||
|
|
||||||
|
Assignment mode:
|
||||||
|
- `VISUAL_OBJECT_MATCH`:`67`
|
||||||
|
- `LAYER_ONLY`:`46`
|
||||||
|
- `COMPONENT_ONLY`:`35`
|
||||||
|
- `UNKNOWN`:`2`
|
||||||
|
|
||||||
|
其中 `LAYER_ONLY` 是正常结果:表示所属 NavSea Layer 已确认,但当前 Standard Catalog 中没有要求必须绑定的一一对应标准图标。
|
||||||
|
|
||||||
|
## 4. 最终 occurrence 级结果
|
||||||
|
|
||||||
|
- 总 Visual Object:`3699`
|
||||||
|
- 唯一确定 NavSea Layer:`2536`
|
||||||
|
- MATCH:`323`
|
||||||
|
- PARTIAL:`2213`
|
||||||
|
- AMBIGUOUS:`168`
|
||||||
|
- COMPONENT:`835`
|
||||||
|
- UNKNOWN:`160`
|
||||||
|
|
||||||
|
### 已实际使用的唯一标准 Layer
|
||||||
|
|
||||||
|
- `anchor_caution_hazard_point`:occurrence `178`(MATCH `117` / PARTIAL `61`)
|
||||||
|
- `anchorage_point`:occurrence `84`(MATCH `84` / PARTIAL `0`)
|
||||||
|
- `baseline_line`:occurrence `6`(MATCH `0` / PARTIAL `6`)
|
||||||
|
- `bridge_structure`:occurrence `28`(MATCH `0` / PARTIAL `28`)
|
||||||
|
- `clip_outline_754`:occurrence `52`(MATCH `0` / PARTIAL `52`)
|
||||||
|
- `depth_contour`:occurrence `6`(MATCH `0` / PARTIAL `6`)
|
||||||
|
- `facility_boundary_outline`:occurrence `28`(MATCH `0` / PARTIAL `28`)
|
||||||
|
- `facility_boundary_point`:occurrence `168`(MATCH `28` / PARTIAL `140`)
|
||||||
|
- `fixed_fishing_gear_area`:occurrence `88`(MATCH `5` / PARTIAL `83`)
|
||||||
|
- `hazard_boundary_outline`:occurrence `6`(MATCH `0` / PARTIAL `6`)
|
||||||
|
- `land_area`:occurrence `10`(MATCH `5` / PARTIAL `5`)
|
||||||
|
- `navigation_hazard_point`:occurrence `61`(MATCH `28` / PARTIAL `33`)
|
||||||
|
- `navigation_marks`:occurrence `1182`(MATCH `0` / PARTIAL `1182`)
|
||||||
|
- `onshore_structure_point`:occurrence `588`(MATCH `28` / PARTIAL `560`)
|
||||||
|
- `pilot_station_point`:occurrence `28`(MATCH `28` / PARTIAL `0`)
|
||||||
|
- `place_label_land`:occurrence `5`(MATCH `0` / PARTIAL `5`)
|
||||||
|
- `seabed_line`:occurrence `18`(MATCH `0` / PARTIAL `18`)
|
||||||
|
|
||||||
|
## 5. 校验结果
|
||||||
|
|
||||||
|
- 总体:`PASS`
|
||||||
|
- 硬校验项:`18` PASS / `0` FAIL
|
||||||
|
- 语义复核 warning:`7` 项
|
||||||
|
|
||||||
|
固定校验命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/style-visual-object/validate_cm95_classification.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 人工结果与 S-52 语义规则的复核 warning
|
||||||
|
|
||||||
|
这些 warning 不会覆盖人工结果,只作为后续需要时复核的线索:
|
||||||
|
|
||||||
|
- `FSHHAV01` 鱼礁/鱼类栖息设施:人工 `anchor_caution_hazard_point`;语义建议 `submerged_reef_area`(0.72)
|
||||||
|
- `FSHHAV02` 鱼礁/鱼类栖息设施:人工 `navigation_hazard_point`;语义建议 `submerged_reef_area`(0.72)
|
||||||
|
- `BRTHNO01` 泊位编号:人工 `navigation_marks`;语义建议 `facility_boundary_point`(0.88)
|
||||||
|
- `MARSHES1` 沼泽:人工 `place_label_land`;语义建议 `land_area`(0.86)
|
||||||
|
- `MORFAC03` 系泊墩:人工 `anchor_caution_hazard_point`;语义建议 `facility_boundary_point`(0.88)
|
||||||
|
- `PILPNT02` 桩/系缆柱:人工 `anchor_caution_hazard_point`;语义建议 `facility_boundary_point`(0.88)
|
||||||
|
- `ap-FSHHAV02` 鱼礁填充图案:人工 `anchor_caution_hazard_point`;语义建议 `submerged_reef_area`(0.72)
|
||||||
|
|
||||||
|
## 6. Git 中的权威结果
|
||||||
|
|
||||||
|
- `output/cm95-manual-review/manual-review-v1.json`:150 项人工决定原始记录。
|
||||||
|
- `output/cm95-manual-review/reviewed-anchors-manual-v1.json`:非 UNKNOWN 的人工导出锚点。
|
||||||
|
- `output/cm95-manual-review/assignment/cm95-layer-assignment-v1.json`:最终 occurrence 级 Layer Assignment。
|
||||||
|
- `output/cm95-manual-review/assignment/cm95-layer-assignment-v1.csv`:资源级摘要。
|
||||||
|
- `output/cm95-manual-review/assignment/cm95-layer-assignment-v1.html`:可视报告。
|
||||||
|
- `output/cm95-manual-review/validation-report-v1.json`:机器校验结果。
|
||||||
|
- `cm95_semantic_layer_rules_v1.json`:CM95/S-52 Provider Layer Classifier 声明式规则。
|
||||||
|
- `validate_cm95_classification.py`:固定一致性校验逻辑。
|
||||||
|
|
||||||
|
## 7. 架构边界
|
||||||
|
|
||||||
|
- 通用 Style Visual Object Tool 继续保持 Provider-neutral:`Style + Sprite → Anonymous Visual Object`。
|
||||||
|
- Provider Layer Classifier 才允许使用 S-52 官方语义资料。
|
||||||
|
- CM95 `source-layer` 不作为 Layer 分类依据。
|
||||||
|
- 人工分类是最终权威;语义/视觉建议只作辅助。
|
||||||
|
- 后续 CM95 Style/Sprite 或 NavSea Standard Layer 基线不变时,不重复人工分类。
|
||||||
7772
tools/style-visual-object/output/cm95-anonymous/_sprite/sprite.json
Normal file
7772
tools/style-visual-object/output/cm95-anonymous/_sprite/sprite.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 499 KiB |
807313
tools/style-visual-object/output/cm95-anonymous/anonymous-catalog.json
Normal file
807313
tools/style-visual-object/output/cm95-anonymous/anonymous-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
cm95_resource_key,assignment_status,assigned_standard_layer,candidate_standard_layers,reviewed_standard_resource_key,component_kind,occurrence_count
|
||||||
|
ACHARE02,PARTIAL,anchorage_point,anchorage_point,anchorage_designated,,28
|
||||||
|
ACHARE51,MATCH,anchorage_point,anchorage_point,anchorage_designated,,28
|
||||||
|
ACHBRT07,PARTIAL,anchorage_point,anchorage_point,anchorage_quarantine,,28
|
||||||
|
BCNGEN01,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN05,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN60,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN61,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN68,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN69,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN70,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN71,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN76,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSPR62,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSTK77,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSTK78,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNTOW60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BCNTOW76,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYCAN63,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYCON01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYCON60,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYCON61,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYCON62,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYGEN03,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYMOR03,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYPIL01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYPIL60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL61,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL62,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL68,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL69,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL70,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL71,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL72,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL73,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPH62,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYSPR60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPR61,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPR62,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSUP01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYSUP62,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BRIDGE01,UNKNOWN,,,,,28
|
||||||
|
BRTHNO01,UNKNOWN,,,,,28
|
||||||
|
BUAARE02,UNKNOWN,,,,,28
|
||||||
|
CGUSTA02,UNKNOWN,,,,,28
|
||||||
|
CHIMNY01,MATCH,onshore_structure_point,onshore_structure_point,symbol-daytime-640,,28
|
||||||
|
CHIMNY11,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-640,,28
|
||||||
|
CHINFO07,UNKNOWN,,,,,28
|
||||||
|
CROSSX01,UNKNOWN,,,,,5
|
||||||
|
CTNARE51,UNKNOWN,,,,,28
|
||||||
|
CTYARE51,UNKNOWN,,,,,28
|
||||||
|
FLASTK01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
FLGSTF01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
FOGSIG01,UNKNOWN,,,,,28
|
||||||
|
FORSTC01,UNKNOWN,,,,,28
|
||||||
|
FOULGND1,AMBIGUOUS,,navigation_hazard_point;anchor_caution_hazard_point,foul_ground,,28
|
||||||
|
FSHFAC02,UNKNOWN,,,,,34
|
||||||
|
FSHHAV01,UNKNOWN,,,,,28
|
||||||
|
FSHHAV02,UNKNOWN,,,,,5
|
||||||
|
GATCON03,UNKNOWN,,,,,28
|
||||||
|
GATCON04,UNKNOWN,,,,,28
|
||||||
|
HRBFAC09,MATCH,facility_boundary_point,facility_boundary_point,fishing_port,,28
|
||||||
|
INFARE51,UNKNOWN,,,,,28
|
||||||
|
LIGHTS11,MATCH,navigation_marks,navigation_marks,light_flare_red,,2
|
||||||
|
LIGHTS12,MATCH,navigation_marks,navigation_marks,light_flare_green,,2
|
||||||
|
LIGHTS13,MATCH,navigation_marks,navigation_marks,light_flare_yellow_white,,2
|
||||||
|
LIGHTS82,UNKNOWN,,,,,2
|
||||||
|
LITDEF11,UNKNOWN,,,,,2
|
||||||
|
LITFLT01,UNKNOWN,,,,,28
|
||||||
|
LITVES60,UNKNOWN,,,,,28
|
||||||
|
LOCMAG51,UNKNOWN,,,,,28
|
||||||
|
LOWACC01,UNKNOWN,,,,,6
|
||||||
|
MARCUL02,UNKNOWN,,,,,33
|
||||||
|
MARSHES1,UNKNOWN,,,,,5
|
||||||
|
MORFAC03,UNKNOWN,,,,,28
|
||||||
|
MSTCON04,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
OFSPLF01,UNKNOWN,,,,,28
|
||||||
|
PILBOP02,MATCH,pilot_station_point,pilot_station_point,pilot_station,,28
|
||||||
|
PILPNT02,UNKNOWN,,,,,28
|
||||||
|
POSGEN04,UNKNOWN,,,,,28
|
||||||
|
PRCARE51,UNKNOWN,,,,,28
|
||||||
|
QUESMRK1,UNKNOWN,,,,,36
|
||||||
|
RACNSP01,UNKNOWN,,,,,28
|
||||||
|
RADRFL03,UNKNOWN,,,,,28
|
||||||
|
RDOSTA02,UNKNOWN,,,,,28
|
||||||
|
RTLDEF51,UNKNOWN,,,,,28
|
||||||
|
RTPBCN02,UNKNOWN,,,,,28
|
||||||
|
SMCFAC02,MATCH,facility_boundary_point,facility_boundary_point,marina,,28
|
||||||
|
SNDWAV02,UNKNOWN,,,,,28
|
||||||
|
STARPT01,UNKNOWN,,,,,28
|
||||||
|
TOPMA115,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR01,UNKNOWN,,,,,28
|
||||||
|
TOPMAR02,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR05,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR06,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR07,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR08,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR10,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR12,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR13,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR22,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR25,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR26,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR27,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR28,PARTIAL,navigation_marks,navigation_marks,buoy_north_cardinal,,28
|
||||||
|
TOPMAR30,UNKNOWN,,,,,28
|
||||||
|
TOPMAR32,UNKNOWN,,,,,28
|
||||||
|
TOPMAR33,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPMAR98,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPSHP51,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPSHPI1,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOPSHPP2,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS02,AMBIGUOUS,,navigation_marks;onshore_structure_point,,,28
|
||||||
|
TOWERS03,AMBIGUOUS,,navigation_marks;onshore_structure_point,,,28
|
||||||
|
TOWERS05,AMBIGUOUS,,navigation_marks;onshore_structure_point,,,28
|
||||||
|
TOWERS15,AMBIGUOUS,,navigation_marks;onshore_structure_point,,,28
|
||||||
|
TOWERS60,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS61,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS63,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS71,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS72,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS78,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS79,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS80,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS83,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS85,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TOWERS95,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TSSCRS51,UNKNOWN,,,,,28
|
||||||
|
TSSLPT51,UNKNOWN,,,,,28
|
||||||
|
TSSRON51,UNKNOWN,,,,,28
|
||||||
|
WATTUR02,UNKNOWN,,,,,28
|
||||||
|
WEDKLP03,UNKNOWN,,,,,28
|
||||||
|
WIMCON01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
ap-FSHHAV02,UNKNOWN,,,,,5
|
||||||
|
ap-MARCUL02,UNKNOWN,,,,,5
|
||||||
|
ap-MARSHES1,UNKNOWN,,,,,5
|
||||||
|
ap-PRTSUR01,UNKNOWN,,,,,5
|
||||||
|
ap-QUESMRK1,UNKNOWN,,,,,5
|
||||||
|
ap-TSSJCT02,UNKNOWN,,,,,5
|
||||||
|
ap-VEGATN03,UNKNOWN,,,,,5
|
||||||
|
lc-CBLSUB06,UNKNOWN,,,,,6
|
||||||
|
lc-ESSARE01,MATCH,clip_outline_754,clip_outline_754,special_pattern_line_754,,6
|
||||||
|
lc-FERYRT01,UNKNOWN,,,,,6
|
||||||
|
lc-FSHFAC02,UNKNOWN,,,,,6
|
||||||
|
lc-LOWACC01,UNKNOWN,,,,,6
|
||||||
|
lc-LOWACC21,UNKNOWN,,,,,6
|
||||||
|
lc-PIPSOL05,UNKNOWN,,,,,6
|
||||||
|
lc-PIPSOL06,UNKNOWN,,,,,6
|
||||||
|
lc-RECTRC09,UNKNOWN,,,,,6
|
||||||
|
lc-RECTRC10,UNKNOWN,,,,,6
|
||||||
|
lc-RECTRC11,UNKNOWN,,,,,6
|
||||||
|
@@ -0,0 +1,55 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>CM95 Visual Object Layer Assignment v1</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Arial,"Noto Sans CJK JP",sans-serif;margin:24px;color:#17202a}
|
||||||
|
.box{display:inline-block;border:1px solid #d8dee6;border-radius:8px;padding:10px 14px;margin:0 10px 12px 0}
|
||||||
|
table{border-collapse:collapse;width:100%;margin:12px 0 28px}
|
||||||
|
th,td{border:1px solid #d8dee6;padding:7px;font-size:13px;text-align:left}
|
||||||
|
th{background:#eef2f6}
|
||||||
|
code{font-size:11px}
|
||||||
|
.note{background:#fff7df;border:1px solid #ead99c;padding:12px;border-radius:8px}
|
||||||
|
</style>
|
||||||
|
<h1>CM95 Anonymous Visual Object → NavSea Standard Layer</h1>
|
||||||
|
<p>本结果只应用经人工视觉复核的共同图标/视觉家族锚点。CM95 source-layer 名称、Provider 字段语义和对象类名称均未参与 Layer 判定。</p>
|
||||||
|
<div class="box">CM95 Visual Objects <b>3699</b></div>
|
||||||
|
<div class="box">Sprite-backed <b>3595</b></div>
|
||||||
|
<div class="box">唯一 Layer 已命名 <b>2224</b></div>
|
||||||
|
<div class="box">MATCH <b>152</b></div>
|
||||||
|
<div class="box">PARTIAL <b>2072</b></div>
|
||||||
|
<div class="box">AMBIGUOUS <b>140</b></div>
|
||||||
|
<div class="box">COMPONENT <b>0</b></div>
|
||||||
|
<div class="box">UNKNOWN <b>1335</b></div>
|
||||||
|
<div class="box">Reviewed anchors <b>88</b></div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<b>状态定义:</b>
|
||||||
|
MATCH = 实际图形基本一致;
|
||||||
|
PARTIAL = 不是同一完整图形,但视觉家族/组件足以支持唯一标准 Layer;
|
||||||
|
AMBIGUOUS = 图形证据只能得到多个标准 Layer 候选,禁止强行选一个;
|
||||||
|
COMPONENT = 只是完整 Visual Object 的组成部分,不单独分配 Layer;
|
||||||
|
UNKNOWN = 当前视觉证据不足。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>唯一命名的标准 Layer</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>标准 Layer</th><th>MATCH</th><th>PARTIAL</th><th>VO occurrence</th></tr></thead>
|
||||||
|
<tbody><tr><td>anchorage_point</td><td>28</td><td>56</td><td>84</td></tr><tr><td>clip_outline_754</td><td>6</td><td>0</td><td>6</td></tr><tr><td>facility_boundary_point</td><td>56</td><td>0</td><td>56</td></tr><tr><td>navigation_marks</td><td>6</td><td>1876</td><td>1882</td></tr><tr><td>onshore_structure_point</td><td>28</td><td>140</td><td>168</td></tr><tr><td>pilot_station_point</td><td>28</td><td>0</td><td>28</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>视觉歧义</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>候选标准 Layer</th><th>资源数</th><th>VO occurrence</th></tr></thead>
|
||||||
|
<tbody><tr><td>anchor_caution_hazard_point / navigation_hazard_point</td><td>1</td><td>28</td></tr><tr><td>navigation_marks / onshore_structure_point</td><td>4</td><td>112</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>匿名 Group 分布</h2>
|
||||||
|
<p>Group 仅用于审计组合范围,不能由整个 group 直接继承一个 Layer。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Group</th><th>唯一命名</th><th>AMBIGUOUS</th><th>COMPONENT</th><th>UNKNOWN</th><th>唯一 Layer 分布</th></tr></thead>
|
||||||
|
<tbody><tr><td><code>GRP-0eb1b887613d3117</code></td><td>0</td><td>0</td><td>0</td><td>3</td><td>—</td></tr><tr><td><code>GRP-118c2f0bf0863e77</code></td><td>317</td><td>20</td><td>0</td><td>213</td><td>anchorage_point=12, clip_outline_754=1, facility_boundary_point=8, navigation_marks=268, onshore_structure_point=24, pilot_station_point=4</td></tr><tr><td><code>GRP-3ca77ff69bbae513</code></td><td>0</td><td>0</td><td>0</td><td>1</td><td>—</td></tr><tr><td><code>GRP-4368c84087270fd4</code></td><td>79</td><td>5</td><td>0</td><td>41</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-5a627b60da2761de</code></td><td>164</td><td>10</td><td>0</td><td>86</td><td>anchorage_point=6, facility_boundary_point=4, navigation_marks=140, onshore_structure_point=12, pilot_station_point=2</td></tr><tr><td><code>GRP-64125b100c4ca018</code></td><td>474</td><td>30</td><td>0</td><td>238</td><td>anchorage_point=18, facility_boundary_point=12, navigation_marks=402, onshore_structure_point=36, pilot_station_point=6</td></tr><tr><td><code>GRP-6425cff78278014f</code></td><td>0</td><td>0</td><td>0</td><td>2</td><td>—</td></tr><tr><td><code>GRP-64eeae4d26a05ac3</code></td><td>1</td><td>0</td><td>0</td><td>20</td><td>clip_outline_754=1</td></tr><tr><td><code>GRP-6c625dd02fb4d766</code></td><td>81</td><td>5</td><td>0</td><td>81</td><td>anchorage_point=3, clip_outline_754=2, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-735ad3da23e1857e</code></td><td>0</td><td>0</td><td>0</td><td>2</td><td>—</td></tr><tr><td><code>GRP-7738e161f8470be0</code></td><td>79</td><td>5</td><td>0</td><td>42</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-7dddca2f59b6bb9f</code></td><td>0</td><td>0</td><td>0</td><td>35</td><td>—</td></tr><tr><td><code>GRP-7fc32eb9501b8414</code></td><td>79</td><td>5</td><td>0</td><td>43</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-805a8de96cbbb56d</code></td><td>158</td><td>10</td><td>0</td><td>78</td><td>anchorage_point=6, facility_boundary_point=4, navigation_marks=134, onshore_structure_point=12, pilot_station_point=2</td></tr><tr><td><code>GRP-8158b1d36777923b</code></td><td>0</td><td>0</td><td>0</td><td>15</td><td>—</td></tr><tr><td><code>GRP-93e05ed1dc014fb5</code></td><td>79</td><td>5</td><td>0</td><td>39</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-9dece9b2b501ed21</code></td><td>79</td><td>5</td><td>0</td><td>40</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-a2907670cc537678</code></td><td>237</td><td>15</td><td>0</td><td>118</td><td>anchorage_point=9, facility_boundary_point=6, navigation_marks=201, onshore_structure_point=18, pilot_station_point=3</td></tr><tr><td><code>GRP-a85fbd02b1a2353a</code></td><td>79</td><td>5</td><td>0</td><td>40</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-a90e6bd8ea5e247d</code></td><td>0</td><td>0</td><td>0</td><td>1</td><td>—</td></tr><tr><td><code>GRP-a975b300e119fe20</code></td><td>79</td><td>5</td><td>0</td><td>41</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-bf8e00c2d3aa9af1</code></td><td>0</td><td>0</td><td>0</td><td>2</td><td>—</td></tr><tr><td><code>GRP-dba4e6019fe110eb</code></td><td>2</td><td>0</td><td>0</td><td>30</td><td>clip_outline_754=2</td></tr><tr><td><code>GRP-ed62480b27dbc023</code></td><td>79</td><td>5</td><td>0</td><td>41</td><td>anchorage_point=3, facility_boundary_point=2, navigation_marks=67, onshore_structure_point=6, pilot_station_point=1</td></tr><tr><td><code>GRP-edea53f2ccdbe24a</code></td><td>158</td><td>10</td><td>0</td><td>79</td><td>anchorage_point=6, facility_boundary_point=4, navigation_marks=134, onshore_structure_point=12, pilot_station_point=2</td></tr><tr><td><code>GRP-fd773b57053bf635</code></td><td>0</td><td>0</td><td>0</td><td>2</td><td>—</td></tr><tr><td><code>GRP-fdccabd4cc28871b</code></td><td>0</td><td>0</td><td>0</td><td>2</td><td>—</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>逐 Visual Object 明细见 <code>cm95-layer-assignment-v1.json</code>;
|
||||||
|
资源级摘要见 <code>cm95-layer-assignment-v1.csv</code>。
|
||||||
|
Anonymous Catalog 没有被修改。</p>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
cm95_resource_key,assignment_status,assigned_standard_layer,candidate_standard_layers,reviewed_standard_resource_key,component_kind,occurrence_count
|
||||||
|
ACHARE02,MATCH,anchorage_point,anchorage_point,anchorage_designated,,28
|
||||||
|
ACHARE51,MATCH,anchorage_point,anchorage_point,anchorage_designated,,28
|
||||||
|
ACHBRT07,MATCH,anchorage_point,anchorage_point,anchorage_quarantine,,28
|
||||||
|
BCNGEN01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BCNGEN05,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN60,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN61,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN68,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN69,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN70,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN71,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNGEN76,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSPR62,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSTK77,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNSTK78,PARTIAL,navigation_marks,navigation_marks,light_beacon,,28
|
||||||
|
BCNTOW60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BCNTOW76,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYCAN63,PARTIAL,navigation_marks,navigation_marks,buoy_can,,28
|
||||||
|
BOYCON01,PARTIAL,navigation_marks,navigation_marks,buoy_lattice,,28
|
||||||
|
BOYCON60,PARTIAL,navigation_marks,navigation_marks,buoy_lattice,,28
|
||||||
|
BOYCON61,PARTIAL,navigation_marks,navigation_marks,buoy_lattice,,28
|
||||||
|
BOYCON62,PARTIAL,navigation_marks,navigation_marks,buoy_lattice,,28
|
||||||
|
BOYGEN03,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYMOR03,PARTIAL,navigation_marks,navigation_marks,mooring_buoy,,28
|
||||||
|
BOYPIL01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYPIL60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL61,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL62,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL68,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL69,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL70,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL71,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL72,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYPIL73,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPH62,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
BOYSPR60,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPR61,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSPR62,PARTIAL,navigation_marks,navigation_marks,buoy_pillar,,28
|
||||||
|
BOYSUP01,PARTIAL,navigation_marks,navigation_marks,mooring_buoy,,28
|
||||||
|
BOYSUP62,PARTIAL,navigation_marks,navigation_marks,mooring_buoy,,28
|
||||||
|
BRIDGE01,PARTIAL,bridge_structure,bridge_structure,,,28
|
||||||
|
BRTHNO01,PARTIAL,navigation_marks,navigation_marks,leading_mark_variantt_308,,28
|
||||||
|
BUAARE02,AMBIGUOUS,,onshore_structure_line;onshore_structure_point,symbol-daytime-661,,28
|
||||||
|
CGUSTA02,PARTIAL,facility_boundary_point,facility_boundary_point,,,28
|
||||||
|
CHIMNY01,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-640,,28
|
||||||
|
CHIMNY11,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-640,,28
|
||||||
|
CHINFO07,PARTIAL,facility_boundary_outline,facility_boundary_outline,,,28
|
||||||
|
CROSSX01,PARTIAL,fixed_fishing_gear_area,fixed_fishing_gear_area,pattern_fill_fishery_or_fish_reef_area,,5
|
||||||
|
CTNARE51,PARTIAL,clip_outline_754,clip_outline_754,,,28
|
||||||
|
CTYARE51,PARTIAL,navigation_hazard_point,navigation_hazard_point,,,28
|
||||||
|
FLASTK01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
FLGSTF01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
FOGSIG01,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
FORSTC01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
FOULGND1,MATCH,navigation_hazard_point,navigation_hazard_point,foul_ground,,28
|
||||||
|
FSHFAC02,PARTIAL,fixed_fishing_gear_area,fixed_fishing_gear_area,pattern_fill_fishery_or_fish_reef_area,,34
|
||||||
|
FSHHAV01,PARTIAL,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-428,,28
|
||||||
|
FSHHAV02,PARTIAL,navigation_hazard_point,navigation_hazard_point,fish_reef,,5
|
||||||
|
GATCON03,PARTIAL,facility_boundary_point,facility_boundary_point,,,28
|
||||||
|
GATCON04,PARTIAL,facility_boundary_point,facility_boundary_point,,,28
|
||||||
|
HRBFAC09,PARTIAL,facility_boundary_point,facility_boundary_point,fishing_port,,28
|
||||||
|
INFARE51,COMPONENT,,,,auxiliary_mark,28
|
||||||
|
LIGHTS11,COMPONENT,,,,light,2
|
||||||
|
LIGHTS12,COMPONENT,,,,light,2
|
||||||
|
LIGHTS13,COMPONENT,,,,light,2
|
||||||
|
LIGHTS82,COMPONENT,,,,light,2
|
||||||
|
LITDEF11,COMPONENT,,,,light,2
|
||||||
|
LITFLT01,COMPONENT,,,,light,28
|
||||||
|
LITVES60,COMPONENT,,,,buoy_body,28
|
||||||
|
LOCMAG51,COMPONENT,,,,other,28
|
||||||
|
LOWACC01,PARTIAL,clip_outline_754,clip_outline_754,,,6
|
||||||
|
MARCUL02,PARTIAL,fixed_fishing_gear_area,fixed_fishing_gear_area,pattern_fill_fishery_or_fish_reef_area,,33
|
||||||
|
MARSHES1,PARTIAL,place_label_land,place_label_land,,,5
|
||||||
|
MORFAC03,MATCH,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-433,,28
|
||||||
|
MSTCON04,COMPONENT,,,,other,28
|
||||||
|
OFSPLF01,AMBIGUOUS,,navigation_hazard_outline;navigation_hazard_point,,,28
|
||||||
|
PILBOP02,MATCH,pilot_station_point,pilot_station_point,pilot_station,,28
|
||||||
|
PILPNT02,MATCH,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-432,,28
|
||||||
|
POSGEN04,UNKNOWN,,,,,28
|
||||||
|
PRCARE51,AMBIGUOUS,,navigation_hazard_outline;navigation_hazard_point,,,28
|
||||||
|
QUESMRK1,COMPONENT,,,,other,36
|
||||||
|
RACNSP01,AMBIGUOUS,,onshore_structure_area;onshore_structure_point,,,28
|
||||||
|
RADRFL03,AMBIGUOUS,,onshore_structure_line;onshore_structure_point,,,28
|
||||||
|
RDOSTA02,COMPONENT,,,,other,28
|
||||||
|
RTLDEF51,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
RTPBCN02,AMBIGUOUS,,onshore_structure_line;onshore_structure_point,,,28
|
||||||
|
SMCFAC02,MATCH,facility_boundary_point,facility_boundary_point,marina,,28
|
||||||
|
SNDWAV02,MATCH,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-421,,28
|
||||||
|
STARPT01,PARTIAL,navigation_marks,navigation_marks,light_minor,,28
|
||||||
|
TOPMA115,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR01,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR02,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR05,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR06,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR07,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR08,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR10,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR12,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR13,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR22,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR25,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR26,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR27,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR28,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR30,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR32,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR33,COMPONENT,,,,topmark,28
|
||||||
|
TOPMAR98,COMPONENT,,,,topmark,28
|
||||||
|
TOPSHP51,COMPONENT,,,,topmark,28
|
||||||
|
TOPSHPI1,COMPONENT,,,,topmark,28
|
||||||
|
TOPSHPP2,COMPONENT,,,,topmark,28
|
||||||
|
TOWERS02,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
TOWERS03,MATCH,onshore_structure_point,onshore_structure_point,symbol-daytime-650,,28
|
||||||
|
TOWERS05,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-650,,28
|
||||||
|
TOWERS15,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-650,,28
|
||||||
|
TOWERS60,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS61,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS63,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS71,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS72,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS78,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS79,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS80,PARTIAL,onshore_structure_point,onshore_structure_point,symbol-daytime-698,,28
|
||||||
|
TOWERS83,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
TOWERS85,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
TOWERS95,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
TSSCRS51,UNKNOWN,,,,,28
|
||||||
|
TSSLPT51,PARTIAL,navigation_marks,navigation_marks,,,28
|
||||||
|
TSSRON51,PARTIAL,facility_boundary_point,facility_boundary_point,,,28
|
||||||
|
WATTUR02,MATCH,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-422,,28
|
||||||
|
WEDKLP03,PARTIAL,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-425,,28
|
||||||
|
WIMCON01,PARTIAL,onshore_structure_point,onshore_structure_point,,,28
|
||||||
|
ap-FSHHAV02,MATCH,anchor_caution_hazard_point,anchor_caution_hazard_point,symbol-daytime-428,,5
|
||||||
|
ap-MARCUL02,MATCH,fixed_fishing_gear_area,fixed_fishing_gear_area,,,5
|
||||||
|
ap-MARSHES1,MATCH,land_area,land_area,,,5
|
||||||
|
ap-PRTSUR01,COMPONENT,,,,fill_pattern,5
|
||||||
|
ap-QUESMRK1,PARTIAL,fixed_fishing_gear_area,fixed_fishing_gear_area,,,5
|
||||||
|
ap-TSSJCT02,PARTIAL,anchor_caution_hazard_point,anchor_caution_hazard_point,,,5
|
||||||
|
ap-VEGATN03,PARTIAL,land_area,land_area,,,5
|
||||||
|
lc-CBLSUB06,PARTIAL,seabed_line,seabed_line,,,6
|
||||||
|
lc-ESSARE01,PARTIAL,hazard_boundary_outline,hazard_boundary_outline,,,6
|
||||||
|
lc-FERYRT01,PARTIAL,navigation_marks,navigation_marks,,,6
|
||||||
|
lc-FSHFAC02,PARTIAL,fixed_fishing_gear_area,fixed_fishing_gear_area,,,6
|
||||||
|
lc-LOWACC01,PARTIAL,depth_contour,depth_contour,,,6
|
||||||
|
lc-LOWACC21,PARTIAL,baseline_line,baseline_line,,,6
|
||||||
|
lc-PIPSOL05,PARTIAL,seabed_line,seabed_line,,,6
|
||||||
|
lc-PIPSOL06,PARTIAL,seabed_line,seabed_line,,,6
|
||||||
|
lc-RECTRC09,PARTIAL,clip_outline_754,clip_outline_754,,,6
|
||||||
|
lc-RECTRC10,PARTIAL,clip_outline_754,clip_outline_754,,,6
|
||||||
|
lc-RECTRC11,PARTIAL,clip_outline_754,clip_outline_754,,,6
|
||||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
{
|
||||||
|
"schema": "cm95-layer-classification-validation/v1",
|
||||||
|
"generated_at": "2026-10-06T00:39:21.897737+00:00",
|
||||||
|
"ok": true,
|
||||||
|
"baseline": {
|
||||||
|
"cm95_style_sha256": "6c25ce3e5c1db65060be01ca899acf7f49228fb2277572dc1868c3d34019d4b0",
|
||||||
|
"standard_style_sha256": "18b32ad1ea2d3798000abe21406d2f2a5225968910aed48d7ca5b1a1cb7f3ce6",
|
||||||
|
"standard_catalog_status": "FROZEN",
|
||||||
|
"unique_cm95_resources": 150,
|
||||||
|
"manual_status_counts": {
|
||||||
|
"AMBIGUOUS": 6,
|
||||||
|
"COMPONENT": 35,
|
||||||
|
"MATCH": 14,
|
||||||
|
"PARTIAL": 93,
|
||||||
|
"UNKNOWN": 2
|
||||||
|
},
|
||||||
|
"final_assignment_status_counts": {
|
||||||
|
"MATCH": 323,
|
||||||
|
"PARTIAL": 2213,
|
||||||
|
"AMBIGUOUS": 168,
|
||||||
|
"COMPONENT": 835,
|
||||||
|
"UNKNOWN": 160
|
||||||
|
},
|
||||||
|
"final_assignment_total": 3699
|
||||||
|
},
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "required_files_exist",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"missing": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard_catalog_frozen",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": "FROZEN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cm95_anonymous_catalog_has_no_standard_layer",
|
||||||
|
"status": "PASS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cm95_unique_visual_resources_150",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_decisions_150",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_covers_all_resources",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"missing": [],
|
||||||
|
"extra": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "style_hash_cm95_matches",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"manual": "6c25ce3e5c1db65060be01ca899acf7f49228fb2277572dc1868c3d34019d4b0",
|
||||||
|
"catalog": "6c25ce3e5c1db65060be01ca899acf7f49228fb2277572dc1868c3d34019d4b0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "style_hash_standard_matches",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"manual": "18b32ad1ea2d3798000abe21406d2f2a5225968910aed48d7ca5b1a1cb7f3ce6",
|
||||||
|
"catalog": "18b32ad1ea2d3798000abe21406d2f2a5225968910aed48d7ca5b1a1cb7f3ce6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_decision_state_constraints",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_status_counts_total_150",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"MATCH": 14,
|
||||||
|
"PARTIAL": 93,
|
||||||
|
"COMPONENT": 35,
|
||||||
|
"AMBIGUOUS": 6,
|
||||||
|
"UNKNOWN": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_anchor_export_matches_non_unknown_decisions",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"expected": 148,
|
||||||
|
"actual": 148,
|
||||||
|
"missing": [],
|
||||||
|
"extra": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manual_anchor_fields_match_decisions",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reference_names_cover_all_cm95_resources",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"missing": [],
|
||||||
|
"extra": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "semantic_rules_are_valid",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignment_total_count",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"expected": 3699,
|
||||||
|
"actual": 3699
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "final_assignment_matches_manual_decisions",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignment_summary_counts_match_rows",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"recomputed": {
|
||||||
|
"MATCH": 323,
|
||||||
|
"PARTIAL": 2213,
|
||||||
|
"AMBIGUOUS": 168,
|
||||||
|
"COMPONENT": 835,
|
||||||
|
"UNKNOWN": 160
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"MATCH": 323,
|
||||||
|
"PARTIAL": 2213,
|
||||||
|
"AMBIGUOUS": 168,
|
||||||
|
"COMPONENT": 835,
|
||||||
|
"UNKNOWN": 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assignment_policy_preserves_architecture",
|
||||||
|
"status": "PASS",
|
||||||
|
"detail": {
|
||||||
|
"cm95_source_layer_used_for_assignment": false,
|
||||||
|
"provider_field_semantics_used_for_assignment": false,
|
||||||
|
"standard_layer_is_output_only": true,
|
||||||
|
"unknown_is_allowed": true,
|
||||||
|
"ambiguous_is_allowed": true,
|
||||||
|
"component_is_allowed": true,
|
||||||
|
"whole_group_inheritance_forbidden": true,
|
||||||
|
"anonymous_catalog_modified": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [
|
||||||
|
{
|
||||||
|
"name": "manual_vs_semantic_classifier_conflicts",
|
||||||
|
"status": "WARN",
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-A9F2024C2B",
|
||||||
|
"resource_key": "FSHHAV01",
|
||||||
|
"display_name_zh": "鱼礁/鱼类栖息设施",
|
||||||
|
"manual_layer": "anchor_caution_hazard_point",
|
||||||
|
"semantic_suggestion": "submerged_reef_area",
|
||||||
|
"semantic_score": 0.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-D1DA37E296",
|
||||||
|
"resource_key": "FSHHAV02",
|
||||||
|
"display_name_zh": "鱼礁/鱼类栖息设施",
|
||||||
|
"manual_layer": "navigation_hazard_point",
|
||||||
|
"semantic_suggestion": "submerged_reef_area",
|
||||||
|
"semantic_score": 0.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-DA05B079BB",
|
||||||
|
"resource_key": "BRTHNO01",
|
||||||
|
"display_name_zh": "泊位编号",
|
||||||
|
"manual_layer": "navigation_marks",
|
||||||
|
"semantic_suggestion": "facility_boundary_point",
|
||||||
|
"semantic_score": 0.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-5157F24143",
|
||||||
|
"resource_key": "MARSHES1",
|
||||||
|
"display_name_zh": "沼泽",
|
||||||
|
"manual_layer": "place_label_land",
|
||||||
|
"semantic_suggestion": "land_area",
|
||||||
|
"semantic_score": 0.86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-0A90325CD0",
|
||||||
|
"resource_key": "MORFAC03",
|
||||||
|
"display_name_zh": "系泊墩",
|
||||||
|
"manual_layer": "anchor_caution_hazard_point",
|
||||||
|
"semantic_suggestion": "facility_boundary_point",
|
||||||
|
"semantic_score": 0.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-0BFA2564A3",
|
||||||
|
"resource_key": "PILPNT02",
|
||||||
|
"display_name_zh": "桩/系缆柱",
|
||||||
|
"manual_layer": "anchor_caution_hazard_point",
|
||||||
|
"semantic_suggestion": "facility_boundary_point",
|
||||||
|
"semantic_score": 0.88
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"anonymous_id": "CM95-VO-A5D22731AF",
|
||||||
|
"resource_key": "ap-FSHHAV02",
|
||||||
|
"display_name_zh": "鱼礁填充图案",
|
||||||
|
"manual_layer": "anchor_caution_hazard_point",
|
||||||
|
"semantic_suggestion": "submerged_reef_area",
|
||||||
|
"semantic_score": 0.72
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
{
|
||||||
|
"schema": "cm95-reviewed-visual-anchors/v1",
|
||||||
|
"basis": "人工复核实际 Sprite 对照图;未使用 CM95 source-layer 名称参与判定。",
|
||||||
|
"rules": {
|
||||||
|
"source_layer_used": false,
|
||||||
|
"provider_field_semantics_used": false,
|
||||||
|
"match_means": "实际图形基本一致,可直接作为标准 Layer 锚点",
|
||||||
|
"partial_means": "不是同一完整图形,但属于明确的同一视觉家族/组件,仅用于 Layer 归类",
|
||||||
|
"unknown_policy": "未列出的 CM95 Visual Object 保持 UNKNOWN"
|
||||||
|
},
|
||||||
|
"anchors": [
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "LIGHTS11",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "light_flare_red",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "LIGHTS12",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "light_flare_green",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "LIGHTS13",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "light_flare_yellow_white",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "ACHARE51",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "anchorage_designated",
|
||||||
|
"standard_layer": "anchorage_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "SMCFAC02",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "marina",
|
||||||
|
"standard_layer": "facility_boundary_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "HRBFAC09",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "fishing_port",
|
||||||
|
"standard_layer": "facility_boundary_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "PILBOP02",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "pilot_station",
|
||||||
|
"standard_layer": "pilot_station_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "CHIMNY01",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "symbol-daytime-640",
|
||||||
|
"standard_layer": "onshore_structure_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "ACHARE02",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "anchorage_designated",
|
||||||
|
"standard_layer": "anchorage_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "ACHBRT07",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "anchorage_quarantine",
|
||||||
|
"standard_layer": "anchorage_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "CHIMNY11",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "symbol-daytime-640",
|
||||||
|
"standard_layer": "onshore_structure_point"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN05",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN69",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN76",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSPR60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSPR61",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL68",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL69",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL72",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL73",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR05",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR08",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL70",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL61",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL71",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN70",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNSPR62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN61",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN68",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN71",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNGEN01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNSTK78",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNSTK77",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "light_beacon",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSPR62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR25",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR28",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR07",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR26",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_north_cardinal",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNTOW60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BCNTOW76",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": "buoy_pillar",
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "visual_family_propagation_reviewed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYCAN63",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYCON01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYCON60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYCON61",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYCON62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYGEN03",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYMOR03",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYPIL01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSPH62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSUP01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "BOYSUP62",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_buoy_body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "FLASTK01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "onshore_structure_point",
|
||||||
|
"review_stage": "anonymous_visual_review_onshore_landmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "FLGSTF01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "onshore_structure_point",
|
||||||
|
"review_stage": "anonymous_visual_review_onshore_landmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "MSTCON04",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "onshore_structure_point",
|
||||||
|
"review_stage": "anonymous_visual_review_onshore_landmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "WIMCON01",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "onshore_structure_point",
|
||||||
|
"review_stage": "anonymous_visual_review_onshore_landmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMA115",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR02",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR06",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR10",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR12",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR13",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR22",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR27",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR33",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPMAR98",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPSHP51",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPSHPI1",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOPSHPP2",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_topmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS60",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS61",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS63",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS71",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS72",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS78",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS79",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS80",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS83",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS85",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS95",
|
||||||
|
"status": "PARTIAL",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layer": "navigation_marks",
|
||||||
|
"review_stage": "anonymous_visual_review_colored_aid_tower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS02",
|
||||||
|
"status": "AMBIGUOUS",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layers": [
|
||||||
|
"navigation_marks",
|
||||||
|
"onshore_structure_point"
|
||||||
|
],
|
||||||
|
"review_stage": "anonymous_visual_review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS03",
|
||||||
|
"status": "AMBIGUOUS",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layers": [
|
||||||
|
"navigation_marks",
|
||||||
|
"onshore_structure_point"
|
||||||
|
],
|
||||||
|
"review_stage": "anonymous_visual_review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS05",
|
||||||
|
"status": "AMBIGUOUS",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layers": [
|
||||||
|
"navigation_marks",
|
||||||
|
"onshore_structure_point"
|
||||||
|
],
|
||||||
|
"review_stage": "anonymous_visual_review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "TOWERS15",
|
||||||
|
"status": "AMBIGUOUS",
|
||||||
|
"standard_resource_key": null,
|
||||||
|
"standard_layers": [
|
||||||
|
"navigation_marks",
|
||||||
|
"onshore_structure_point"
|
||||||
|
],
|
||||||
|
"review_stage": "anonymous_visual_review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "FOULGND1",
|
||||||
|
"status": "AMBIGUOUS",
|
||||||
|
"standard_resource_key": "foul_ground",
|
||||||
|
"standard_layers": [
|
||||||
|
"navigation_hazard_point",
|
||||||
|
"anchor_caution_hazard_point"
|
||||||
|
],
|
||||||
|
"review_stage": "anonymous_visual_review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cm95_resource_key": "lc-ESSARE01",
|
||||||
|
"status": "MATCH",
|
||||||
|
"standard_resource_key": "special_pattern_line_754",
|
||||||
|
"standard_layer": "clip_outline_754",
|
||||||
|
"review_stage": "pattern_visual_review"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 600 KiB |
15048
tools/style-visual-object/output/navsea-anonymous/anonymous-catalog.json
Normal file
15048
tools/style-visual-object/output/navsea-anonymous/anonymous-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
15197
tools/style-visual-object/output/navsea-standard/standard-catalog.json
Normal file
15197
tools/style-visual-object/output/navsea-standard/standard-catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
1772
tools/style-visual-object/output/reference-name-enrichment-v1.json
Normal file
1772
tools/style-visual-object/output/reference-name-enrichment-v1.json
Normal file
File diff suppressed because it is too large
Load Diff
748
tools/style-visual-object/reviewer/review_server.py
Normal file
748
tools/style-visual-object/reviewer/review_server.py
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import difflib
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections import Counter
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
OUTPUT_DIR = TOOL_DIR / "output"
|
||||||
|
CM95_CATALOG_PATH = OUTPUT_DIR / "cm95-anonymous" / "anonymous-catalog.json"
|
||||||
|
STD_CATALOG_PATH = OUTPUT_DIR / "navsea-standard" / "standard-catalog.json"
|
||||||
|
SUGGESTION_PATH = OUTPUT_DIR / "cm95-visual-anchor-match" / "reviewed-anchors-v1.json"
|
||||||
|
VISUAL_CANDIDATE_PATH = OUTPUT_DIR / "cm95-visual-anchor-match" / "visual-anchor-matches.json"
|
||||||
|
REFERENCE_NAME_PATH = OUTPUT_DIR / "reference-name-enrichment-v1.json"
|
||||||
|
SEMANTIC_RULES_PATH = TOOL_DIR / "cm95_semantic_layer_rules_v1.json"
|
||||||
|
MANUAL_DIR = OUTPUT_DIR / "cm95-manual-review"
|
||||||
|
MANUAL_REVIEW_PATH = MANUAL_DIR / "manual-review-v1.json"
|
||||||
|
MANUAL_ANCHOR_PATH = MANUAL_DIR / "reviewed-anchors-manual-v1.json"
|
||||||
|
MANUAL_ASSIGNMENT_DIR = MANUAL_DIR / "assignment"
|
||||||
|
WEB_DIR = Path(__file__).resolve().parent / "web"
|
||||||
|
|
||||||
|
VALID_STATUS = {"MATCH", "PARTIAL", "AMBIGUOUS", "COMPONENT", "UNKNOWN"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_name(text: str | None) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "", str(text).lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _text_similarity(a: str | None, b: str | None) -> float:
|
||||||
|
na = _normalize_name(a)
|
||||||
|
nb = _normalize_name(b)
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
seq = difflib.SequenceMatcher(None, na, nb).ratio()
|
||||||
|
sa, sb = set(na), set(nb)
|
||||||
|
jac = len(sa & sb) / max(1, len(sa | sb))
|
||||||
|
contain = 0.0
|
||||||
|
if na in nb or nb in na:
|
||||||
|
contain = min(len(na), len(nb)) / max(len(na), len(nb))
|
||||||
|
contain = 0.70 + 0.30 * contain
|
||||||
|
return max(seq, jac, contain)
|
||||||
|
|
||||||
|
COMPONENT_KIND_ZH = {
|
||||||
|
"topmark": "顶标",
|
||||||
|
"buoy_body": "浮标主体",
|
||||||
|
"beacon_body": "立标主体",
|
||||||
|
"light": "灯光",
|
||||||
|
"auxiliary_mark": "附加标记",
|
||||||
|
"fill_pattern": "面填充图案",
|
||||||
|
"line_pattern": "线型图案",
|
||||||
|
"text": "文字",
|
||||||
|
"other": "其他组件",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict[str, Any]:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_atomic(path: Path, value: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewState:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.cm = read_json(CM95_CATALOG_PATH)
|
||||||
|
self.std = read_json(STD_CATALOG_PATH)
|
||||||
|
self.suggestions = read_json(SUGGESTION_PATH) if SUGGESTION_PATH.exists() else {"anchors": []}
|
||||||
|
self.visual_candidates = read_json(VISUAL_CANDIDATE_PATH) if VISUAL_CANDIDATE_PATH.exists() else {"top_candidates": []}
|
||||||
|
self.reference_names = read_json(REFERENCE_NAME_PATH) if REFERENCE_NAME_PATH.exists() else {"cm95": {}, "navsea_standard": {}, "standard_layer_zh": {}}
|
||||||
|
self.semantic_rules = read_json(SEMANTIC_RULES_PATH).get("rules", []) if SEMANTIC_RULES_PATH.exists() else []
|
||||||
|
self.cm_names = self.reference_names.get("cm95", {})
|
||||||
|
self.std_names = self.reference_names.get("navsea_standard", {})
|
||||||
|
self.layer_zh = self.reference_names.get("standard_layer_zh", {})
|
||||||
|
|
||||||
|
self.cm_atlas = Image.open(OUTPUT_DIR / "cm95-anonymous" / "_sprite" / "sprite.png").convert("RGBA")
|
||||||
|
self.std_atlas = Image.open(OUTPUT_DIR / "navsea-anonymous" / "_sprite" / "sprite.png").convert("RGBA")
|
||||||
|
|
||||||
|
self.cm_resources = self._build_cm_resources()
|
||||||
|
self.cm_by_id = {x["anonymous_id"]: x for x in self.cm_resources}
|
||||||
|
self.cm_by_key = {x["resource_key"]: x for x in self.cm_resources}
|
||||||
|
|
||||||
|
self.std_resources = self._build_std_resources()
|
||||||
|
self.std_by_key = {x["resource_key"]: x for x in self.std_resources}
|
||||||
|
self.standard_layers = sorted({
|
||||||
|
str(obj.get("standard_layer_name"))
|
||||||
|
for obj in self.std.get("visual_objects", [])
|
||||||
|
if obj.get("standard_layer_name")
|
||||||
|
})
|
||||||
|
|
||||||
|
self.suggestion_by_key = {
|
||||||
|
x["cm95_resource_key"]: x
|
||||||
|
for x in self.suggestions.get("anchors", [])
|
||||||
|
}
|
||||||
|
self.candidate_by_key = {
|
||||||
|
x["cm95_resource_key"]: x
|
||||||
|
for x in self.visual_candidates.get("top_candidates", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
self.review = self._load_or_initialize_review()
|
||||||
|
|
||||||
|
def _anonymous_id(self, resource_key: str, visual_hash: str | None) -> str:
|
||||||
|
seed = f"{resource_key}|{visual_hash or ''}".encode("utf-8")
|
||||||
|
return "CM95-VO-" + hashlib.sha256(seed).hexdigest()[:10].upper()
|
||||||
|
|
||||||
|
def _build_cm_resources(self) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[str, dict[str, Any]] = {}
|
||||||
|
for obj in self.cm.get("visual_objects", []):
|
||||||
|
key = obj.get("resource_key")
|
||||||
|
meta = obj.get("resource_meta")
|
||||||
|
if not key or not meta:
|
||||||
|
continue
|
||||||
|
item = grouped.setdefault(key, {
|
||||||
|
"resource_key": key,
|
||||||
|
"resource_meta": meta,
|
||||||
|
"visual_hash": obj.get("visual_hash"),
|
||||||
|
"component_roles": set(),
|
||||||
|
"geometry_roles": set(),
|
||||||
|
"occurrence_count": 0,
|
||||||
|
"group_ids": set(),
|
||||||
|
"provenance": [],
|
||||||
|
})
|
||||||
|
if obj.get("component_role"):
|
||||||
|
item["component_roles"].add(obj["component_role"])
|
||||||
|
if obj.get("geometry_role"):
|
||||||
|
item["geometry_roles"].add(obj["geometry_role"])
|
||||||
|
item["occurrence_count"] += 1
|
||||||
|
item["group_ids"].add(obj["group_id"])
|
||||||
|
if obj.get("provenance"):
|
||||||
|
item["provenance"].append(obj["provenance"])
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for key in sorted(grouped):
|
||||||
|
item = grouped[key]
|
||||||
|
item["anonymous_id"] = self._anonymous_id(key, item.get("visual_hash"))
|
||||||
|
item["component_roles"] = sorted(item["component_roles"])
|
||||||
|
item["geometry_roles"] = sorted(item["geometry_roles"])
|
||||||
|
item["group_ids"] = sorted(item["group_ids"])
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _build_std_resources(self) -> list[dict[str, Any]]:
|
||||||
|
grouped: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for obj in self.std.get("visual_objects", []):
|
||||||
|
key = obj.get("resource_key")
|
||||||
|
layer = obj.get("standard_layer_name")
|
||||||
|
meta = obj.get("resource_meta")
|
||||||
|
if not key or not layer or not meta:
|
||||||
|
continue
|
||||||
|
gkey = (key, layer)
|
||||||
|
item = grouped.setdefault(gkey, {
|
||||||
|
"resource_key": key,
|
||||||
|
"standard_layer": layer,
|
||||||
|
"resource_meta": meta,
|
||||||
|
"visual_hash": obj.get("visual_hash"),
|
||||||
|
"component_role": obj.get("component_role"),
|
||||||
|
"geometry_role": obj.get("geometry_role"),
|
||||||
|
"visual_object_id": obj.get("visual_object_id"),
|
||||||
|
})
|
||||||
|
return sorted(grouped.values(), key=lambda x: (x["standard_layer"], x["resource_key"]))
|
||||||
|
|
||||||
|
def _semantic_info(self, resource_key: str) -> dict[str, Any]:
|
||||||
|
info = self.cm_names.get(resource_key, {}) or {}
|
||||||
|
desc = str(info.get("official_description_en") or "")
|
||||||
|
resource_type = str(info.get("resource_type") or "")
|
||||||
|
for rule in self.semantic_rules:
|
||||||
|
rule_type = rule.get("resource_type")
|
||||||
|
if rule_type and rule_type != resource_type:
|
||||||
|
continue
|
||||||
|
pattern = str(rule.get("pattern") or "")
|
||||||
|
if pattern and not re.search(pattern, desc, flags=re.IGNORECASE):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"rule_id": rule.get("id"),
|
||||||
|
"category_zh": rule.get("category_zh") or "未分类",
|
||||||
|
"reason_zh": rule.get("reason_zh") or "",
|
||||||
|
"component_kind": rule.get("component_kind"),
|
||||||
|
"layer_candidates": [
|
||||||
|
{"layer": str(x[0]), "semantic_score": float(x[1])}
|
||||||
|
for x in rule.get("layer_candidates", [])
|
||||||
|
if isinstance(x, list) and len(x) >= 2
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"rule_id": None,
|
||||||
|
"category_zh": "尚未建立语义分类",
|
||||||
|
"reason_zh": "当前 S-52 说明没有命中已声明的 Layer 语义规则,需人工确认。",
|
||||||
|
"component_kind": None,
|
||||||
|
"layer_candidates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _layer_suggestions(
|
||||||
|
self,
|
||||||
|
semantic: dict[str, Any],
|
||||||
|
candidates: list[dict[str, Any]],
|
||||||
|
suggestion: dict[str, Any] | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
valid_layers = set(self.standard_layers)
|
||||||
|
semantic_map = {
|
||||||
|
x["layer"]: float(x.get("semantic_score") or 0.0)
|
||||||
|
for x in semantic.get("layer_candidates", [])
|
||||||
|
if x.get("layer") in valid_layers
|
||||||
|
}
|
||||||
|
visual_map: dict[str, tuple[float, str | None]] = {}
|
||||||
|
for c in candidates:
|
||||||
|
layer = c.get("standard_layer")
|
||||||
|
if layer not in valid_layers:
|
||||||
|
continue
|
||||||
|
score = float(c.get("score") or 0.0)
|
||||||
|
prev = visual_map.get(layer)
|
||||||
|
if prev is None or score > prev[0]:
|
||||||
|
visual_map[layer] = (score, c.get("resource_key"))
|
||||||
|
|
||||||
|
prior_layers: set[str] = set()
|
||||||
|
if suggestion:
|
||||||
|
if suggestion.get("standard_layer") in valid_layers:
|
||||||
|
prior_layers.add(str(suggestion.get("standard_layer")))
|
||||||
|
for layer in suggestion.get("standard_layers") or []:
|
||||||
|
if layer in valid_layers:
|
||||||
|
prior_layers.add(str(layer))
|
||||||
|
|
||||||
|
layers = set(semantic_map) | set(visual_map) | prior_layers
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
has_semantic = bool(semantic_map)
|
||||||
|
for layer in layers:
|
||||||
|
sem = semantic_map.get(layer, 0.0)
|
||||||
|
vis, best_resource = visual_map.get(layer, (0.0, None))
|
||||||
|
prior = 1.0 if layer in prior_layers else 0.0
|
||||||
|
if has_semantic:
|
||||||
|
combined = 0.75 * sem + 0.15 * vis + 0.10 * prior
|
||||||
|
else:
|
||||||
|
combined = 0.72 * vis + 0.28 * prior
|
||||||
|
reasons = []
|
||||||
|
if sem > 0:
|
||||||
|
reasons.append(f"S-52 语义 {sem*100:.0f}%")
|
||||||
|
if vis > 0:
|
||||||
|
reasons.append(f"视觉证据 {vis*100:.0f}%")
|
||||||
|
if prior > 0:
|
||||||
|
reasons.append("已有 reviewed suggestion 支持")
|
||||||
|
rows.append({
|
||||||
|
"standard_layer": layer,
|
||||||
|
"standard_layer_zh": self.layer_zh.get(layer, layer),
|
||||||
|
"semantic_score": round(sem, 4),
|
||||||
|
"visual_score": round(vis, 4),
|
||||||
|
"prior_support": bool(prior),
|
||||||
|
"combined_score": round(min(1.0, combined), 4),
|
||||||
|
"best_standard_resource_key": best_resource,
|
||||||
|
"reason_zh": ";".join(reasons) or "辅助证据不足",
|
||||||
|
})
|
||||||
|
rows.sort(key=lambda x: (x["combined_score"], x["semantic_score"], x["visual_score"]), reverse=True)
|
||||||
|
return rows[:8]
|
||||||
|
|
||||||
|
def _load_or_initialize_review(self) -> dict[str, Any]:
|
||||||
|
if MANUAL_REVIEW_PATH.exists():
|
||||||
|
data = read_json(MANUAL_REVIEW_PATH)
|
||||||
|
return data
|
||||||
|
data = {
|
||||||
|
"schema": "cm95-manual-review/v1",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"cm95_style_sha256": self.cm.get("style", {}).get("sha256"),
|
||||||
|
"standard_style_sha256": self.std.get("style", {}).get("sha256"),
|
||||||
|
"standard_catalog_status": self.std.get("catalog_status"),
|
||||||
|
"policy": {
|
||||||
|
"source_layer_hidden_from_main_review": True,
|
||||||
|
"provider_field_semantics_used": False,
|
||||||
|
"standard_catalog_mutated": False,
|
||||||
|
"cm95_anonymous_catalog_mutated": False,
|
||||||
|
},
|
||||||
|
"decisions": {},
|
||||||
|
}
|
||||||
|
write_json_atomic(MANUAL_REVIEW_PATH, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save_decision(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
anonymous_id = str(payload.get("anonymous_id") or "")
|
||||||
|
item = self.cm_by_id.get(anonymous_id)
|
||||||
|
if not item:
|
||||||
|
raise ValueError("未知 anonymous_id")
|
||||||
|
|
||||||
|
status = str(payload.get("status") or "")
|
||||||
|
if status not in VALID_STATUS:
|
||||||
|
raise ValueError("status 必须是 MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN")
|
||||||
|
|
||||||
|
layers = payload.get("standard_layers") or []
|
||||||
|
if not isinstance(layers, list):
|
||||||
|
raise ValueError("standard_layers 必须是数组")
|
||||||
|
layers = sorted({str(x) for x in layers if x})
|
||||||
|
|
||||||
|
std_resource = payload.get("standard_resource_key")
|
||||||
|
if std_resource is not None:
|
||||||
|
std_resource = str(std_resource)
|
||||||
|
if std_resource not in self.std_by_key:
|
||||||
|
raise ValueError("未知 standard_resource_key")
|
||||||
|
|
||||||
|
valid_layers = set(self.standard_layers)
|
||||||
|
if any(x not in valid_layers for x in layers):
|
||||||
|
raise ValueError("standard_layers 包含未知标准 Layer")
|
||||||
|
|
||||||
|
if status in {"MATCH", "PARTIAL"} and len(layers) != 1:
|
||||||
|
raise ValueError(f"{status} 必须且只能选择 1 个标准 Layer")
|
||||||
|
if status == "AMBIGUOUS" and len(layers) < 2:
|
||||||
|
raise ValueError("AMBIGUOUS 至少选择 2 个标准 Layer")
|
||||||
|
component_kind = payload.get("component_kind")
|
||||||
|
if component_kind is not None:
|
||||||
|
component_kind = str(component_kind)
|
||||||
|
if component_kind not in COMPONENT_KIND_ZH:
|
||||||
|
raise ValueError("未知 component_kind")
|
||||||
|
|
||||||
|
if status in {"UNKNOWN", "COMPONENT"}:
|
||||||
|
layers = []
|
||||||
|
std_resource = None
|
||||||
|
if status != "COMPONENT":
|
||||||
|
component_kind = None
|
||||||
|
|
||||||
|
if std_resource:
|
||||||
|
std_layer = self.std_by_key[std_resource]["standard_layer"]
|
||||||
|
if status in {"MATCH", "PARTIAL"} and layers and std_layer != layers[0]:
|
||||||
|
raise ValueError("选择的 Standard Visual Object 与 Layer 不一致")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
decision = {
|
||||||
|
"anonymous_id": anonymous_id,
|
||||||
|
"resource_key_internal": item["resource_key"],
|
||||||
|
"status": status,
|
||||||
|
"standard_layers": layers,
|
||||||
|
"standard_resource_key": std_resource,
|
||||||
|
"component_kind": component_kind,
|
||||||
|
"assignment_mode": (
|
||||||
|
"COMPONENT_ONLY" if status == "COMPONENT"
|
||||||
|
else ("VISUAL_OBJECT_MATCH" if std_resource else ("LAYER_ONLY" if layers else "UNKNOWN"))
|
||||||
|
),
|
||||||
|
"note": str(payload.get("note") or ""),
|
||||||
|
"reviewed_at": now,
|
||||||
|
}
|
||||||
|
self.review["decisions"][anonymous_id] = decision
|
||||||
|
self.review["updated_at"] = now
|
||||||
|
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
def delete_decision(self, anonymous_id: str) -> None:
|
||||||
|
self.review["decisions"].pop(anonymous_id, None)
|
||||||
|
self.review["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
|
||||||
|
|
||||||
|
def abandon_unreviewed(self, dry_run: bool = False) -> dict[str, Any]:
|
||||||
|
decisions = self.review.get("decisions", {})
|
||||||
|
pending = [x for x in self.cm_resources if x["anonymous_id"] not in decisions]
|
||||||
|
if dry_run:
|
||||||
|
return {"affected": len(pending), "applied": False}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for item in pending:
|
||||||
|
anonymous_id = item["anonymous_id"]
|
||||||
|
decisions[anonymous_id] = {
|
||||||
|
"anonymous_id": anonymous_id,
|
||||||
|
"resource_key_internal": item["resource_key"],
|
||||||
|
"status": "UNKNOWN",
|
||||||
|
"standard_layers": [],
|
||||||
|
"standard_resource_key": None,
|
||||||
|
"component_kind": None,
|
||||||
|
"assignment_mode": "UNKNOWN",
|
||||||
|
"note": "批量放弃未确认项",
|
||||||
|
"reviewed_at": now,
|
||||||
|
"bulk_action": "ABANDON_UNREVIEWED",
|
||||||
|
}
|
||||||
|
self.review["updated_at"] = now
|
||||||
|
write_json_atomic(MANUAL_REVIEW_PATH, self.review)
|
||||||
|
return {"affected": len(pending), "applied": True}
|
||||||
|
|
||||||
|
def bootstrap(self) -> dict[str, Any]:
|
||||||
|
decisions = self.review.get("decisions", {})
|
||||||
|
items = []
|
||||||
|
for item in self.cm_resources:
|
||||||
|
anon = item["anonymous_id"]
|
||||||
|
suggestion = self.suggestion_by_key.get(item["resource_key"])
|
||||||
|
candidate = self.candidate_by_key.get(item["resource_key"])
|
||||||
|
candidates = []
|
||||||
|
if candidate:
|
||||||
|
best = candidate.get("best_standard_resource_key")
|
||||||
|
best_layer = candidate.get("best_standard_layer")
|
||||||
|
if best and best_layer:
|
||||||
|
name_info_std = self.std_names.get(best, {}) or {}
|
||||||
|
candidates.append({
|
||||||
|
"resource_key": best,
|
||||||
|
"standard_layer": best_layer,
|
||||||
|
"standard_layer_zh": self.layer_zh.get(best_layer, best_layer),
|
||||||
|
"display_name_zh": name_info_std.get("display_name_zh") or self.layer_zh.get(best_layer, best_layer),
|
||||||
|
"score": candidate.get("score"),
|
||||||
|
"margin": candidate.get("margin"),
|
||||||
|
"source": "visual_candidate",
|
||||||
|
})
|
||||||
|
for alt in candidate.get("alternatives", []):
|
||||||
|
key = alt.get("standard_resource_key")
|
||||||
|
layer = alt.get("standard_layer")
|
||||||
|
if key and layer:
|
||||||
|
name_info_std = self.std_names.get(key, {}) or {}
|
||||||
|
candidates.append({
|
||||||
|
"resource_key": key,
|
||||||
|
"standard_layer": layer,
|
||||||
|
"standard_layer_zh": self.layer_zh.get(layer, layer),
|
||||||
|
"display_name_zh": name_info_std.get("display_name_zh") or self.layer_zh.get(layer, layer),
|
||||||
|
"score": alt.get("score"),
|
||||||
|
"source": "visual_candidate",
|
||||||
|
})
|
||||||
|
|
||||||
|
name_info = self.cm_names.get(item["resource_key"], {})
|
||||||
|
cm_name_zh = name_info.get("display_name_zh") or "未命名视觉对象"
|
||||||
|
|
||||||
|
suggestion_layers = set()
|
||||||
|
suggestion_resource = None
|
||||||
|
if suggestion:
|
||||||
|
suggestion_resource = suggestion.get("standard_resource_key")
|
||||||
|
if suggestion.get("standard_layer"):
|
||||||
|
suggestion_layers.add(str(suggestion.get("standard_layer")))
|
||||||
|
for layer_name in suggestion.get("standard_layers") or []:
|
||||||
|
if layer_name:
|
||||||
|
suggestion_layers.add(str(layer_name))
|
||||||
|
|
||||||
|
# UI-only recommendation: actual visual score + Chinese name similarity +
|
||||||
|
# existing reviewed visual suggestion. This never writes Layer Assignment.
|
||||||
|
for c in candidates:
|
||||||
|
visual_score = float(c.get("score") or 0.0)
|
||||||
|
text_score = _text_similarity(cm_name_zh, c.get("display_name_zh"))
|
||||||
|
bonus = 0.0
|
||||||
|
if suggestion_resource and c.get("resource_key") == suggestion_resource:
|
||||||
|
bonus += 0.16
|
||||||
|
elif c.get("standard_layer") in suggestion_layers:
|
||||||
|
bonus += 0.08
|
||||||
|
c["text_score"] = round(text_score, 4)
|
||||||
|
c["recommendation_score"] = round(min(1.0, 0.62 * visual_score + 0.38 * text_score + bonus), 4)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda x: (float(x.get("recommendation_score") or 0.0), float(x.get("score") or 0.0)), reverse=True)
|
||||||
|
recommended_resource_key = candidates[0]["resource_key"] if candidates else suggestion_resource
|
||||||
|
semantic = self._semantic_info(item["resource_key"])
|
||||||
|
layer_suggestions = self._layer_suggestions(semantic, candidates, suggestion)
|
||||||
|
top_layer = layer_suggestions[0]["standard_layer"] if layer_suggestions else None
|
||||||
|
current_decision = decisions.get(anon)
|
||||||
|
decision_conflict = False
|
||||||
|
if current_decision and top_layer and layer_suggestions and layer_suggestions[0]["combined_score"] >= 0.72:
|
||||||
|
selected = current_decision.get("standard_layers") or []
|
||||||
|
if current_decision.get("status") in {"MATCH", "PARTIAL"} and len(selected) == 1 and selected[0] != top_layer:
|
||||||
|
decision_conflict = True
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"anonymous_id": anon,
|
||||||
|
"display_name_zh": cm_name_zh,
|
||||||
|
"official_description_en": name_info.get("official_description_en"),
|
||||||
|
"name_source": name_info.get("display_name_source"),
|
||||||
|
"semantic": semantic,
|
||||||
|
"layer_suggestions": layer_suggestions,
|
||||||
|
"recommended_layer": top_layer,
|
||||||
|
"decision_conflict": decision_conflict,
|
||||||
|
"component_roles": item["component_roles"],
|
||||||
|
"geometry_roles": item["geometry_roles"],
|
||||||
|
"occurrence_count": item["occurrence_count"],
|
||||||
|
"suggestion": {
|
||||||
|
"status": suggestion.get("status"),
|
||||||
|
"standard_layer": suggestion.get("standard_layer"),
|
||||||
|
"standard_layers": suggestion.get("standard_layers"),
|
||||||
|
"standard_resource_key": suggestion.get("standard_resource_key"),
|
||||||
|
"standard_name_zh": (self.std_names.get(suggestion.get("standard_resource_key") or "", {}) or {}).get("display_name_zh"),
|
||||||
|
"standard_layer_zh": self.layer_zh.get(suggestion.get("standard_layer") or ""),
|
||||||
|
"review_stage": suggestion.get("review_stage"),
|
||||||
|
} if suggestion else None,
|
||||||
|
"candidates": candidates,
|
||||||
|
"recommended_resource_key": recommended_resource_key,
|
||||||
|
"decision": current_decision,
|
||||||
|
})
|
||||||
|
|
||||||
|
std_layers = list(self.standard_layers)
|
||||||
|
std_resources = [
|
||||||
|
{
|
||||||
|
"resource_key": x["resource_key"],
|
||||||
|
"standard_layer": x["standard_layer"],
|
||||||
|
"standard_layer_zh": self.layer_zh.get(x["standard_layer"], x["standard_layer"]),
|
||||||
|
"display_name_zh": (self.std_names.get(x["resource_key"], {}) or {}).get("display_name_zh") or self.layer_zh.get(x["standard_layer"], x["standard_layer"]),
|
||||||
|
"component_role": x.get("component_role"),
|
||||||
|
"geometry_role": x.get("geometry_role"),
|
||||||
|
"visual_object_id": x.get("visual_object_id"),
|
||||||
|
}
|
||||||
|
for x in self.std_resources
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = Counter()
|
||||||
|
for d in decisions.values():
|
||||||
|
summary[d.get("status", "UNKNOWN")] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"standard_layers": std_layers,
|
||||||
|
"standard_layer_zh": {x: self.layer_zh.get(x, x) for x in std_layers},
|
||||||
|
"standard_resources": std_resources,
|
||||||
|
"progress": {
|
||||||
|
"total": len(items),
|
||||||
|
"reviewed": len(decisions),
|
||||||
|
"unreviewed": len(items) - len(decisions),
|
||||||
|
"MATCH": summary["MATCH"],
|
||||||
|
"PARTIAL": summary["PARTIAL"],
|
||||||
|
"AMBIGUOUS": summary["AMBIGUOUS"],
|
||||||
|
"COMPONENT": summary["COMPONENT"],
|
||||||
|
"UNKNOWN": summary["UNKNOWN"],
|
||||||
|
},
|
||||||
|
"component_kinds": [
|
||||||
|
{"id": key, "name_zh": value}
|
||||||
|
for key, value in COMPONENT_KIND_ZH.items()
|
||||||
|
],
|
||||||
|
"policy": self.review.get("policy"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def audit_info(self, anonymous_id: str) -> dict[str, Any]:
|
||||||
|
item = self.cm_by_id.get(anonymous_id)
|
||||||
|
if not item:
|
||||||
|
raise ValueError("未知 anonymous_id")
|
||||||
|
unique_prov = []
|
||||||
|
seen = set()
|
||||||
|
for p in item["provenance"]:
|
||||||
|
key = json.dumps(p, ensure_ascii=False, sort_keys=True)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
unique_prov.append(p)
|
||||||
|
return {
|
||||||
|
"anonymous_id": anonymous_id,
|
||||||
|
"resource_key": item["resource_key"],
|
||||||
|
"visual_hash": item.get("visual_hash"),
|
||||||
|
"group_ids": item["group_ids"],
|
||||||
|
"provenance": unique_prov,
|
||||||
|
}
|
||||||
|
|
||||||
|
def crop_png(self, which: str, identifier: str) -> bytes:
|
||||||
|
if which == "cm95":
|
||||||
|
item = self.cm_by_id.get(identifier)
|
||||||
|
if not item:
|
||||||
|
raise KeyError(identifier)
|
||||||
|
atlas = self.cm_atlas
|
||||||
|
meta = item["resource_meta"]
|
||||||
|
elif which == "std":
|
||||||
|
item = self.std_by_key.get(identifier)
|
||||||
|
if not item:
|
||||||
|
raise KeyError(identifier)
|
||||||
|
atlas = self.std_atlas
|
||||||
|
meta = item["resource_meta"]
|
||||||
|
else:
|
||||||
|
raise KeyError(which)
|
||||||
|
|
||||||
|
crop = atlas.crop((
|
||||||
|
int(meta["x"]), int(meta["y"]),
|
||||||
|
int(meta["x"]) + int(meta["width"]),
|
||||||
|
int(meta["y"]) + int(meta["height"]),
|
||||||
|
)).convert("RGBA")
|
||||||
|
from io import BytesIO
|
||||||
|
buf = BytesIO()
|
||||||
|
crop.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def finalize(self) -> dict[str, Any]:
|
||||||
|
decisions = self.review.get("decisions", {})
|
||||||
|
anchors = []
|
||||||
|
for anon, d in decisions.items():
|
||||||
|
item = self.cm_by_id.get(anon)
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
status = d["status"]
|
||||||
|
if status == "UNKNOWN":
|
||||||
|
continue
|
||||||
|
layers = d.get("standard_layers") or []
|
||||||
|
row: dict[str, Any] = {
|
||||||
|
"cm95_resource_key": item["resource_key"],
|
||||||
|
"status": status,
|
||||||
|
"standard_resource_key": d.get("standard_resource_key"),
|
||||||
|
"component_kind": d.get("component_kind"),
|
||||||
|
"assignment_mode": d.get("assignment_mode") or (
|
||||||
|
"COMPONENT_ONLY" if status == "COMPONENT"
|
||||||
|
else ("VISUAL_OBJECT_MATCH" if d.get("standard_resource_key") else "LAYER_ONLY")
|
||||||
|
),
|
||||||
|
"review_stage": "user_manual_ui_review",
|
||||||
|
"note": d.get("note", ""),
|
||||||
|
}
|
||||||
|
if status in {"MATCH", "PARTIAL"} and len(layers) == 1:
|
||||||
|
row["standard_layer"] = layers[0]
|
||||||
|
elif status == "AMBIGUOUS":
|
||||||
|
row["standard_layers"] = layers
|
||||||
|
anchors.append(row)
|
||||||
|
|
||||||
|
exported = {
|
||||||
|
"schema": "cm95-reviewed-visual-anchors/manual-v1",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"source_manual_review": str(MANUAL_REVIEW_PATH),
|
||||||
|
"rules": {
|
||||||
|
"source_layer_used": False,
|
||||||
|
"provider_field_semantics_used": False,
|
||||||
|
"manual_review_is_authoritative": True,
|
||||||
|
"unreviewed_resources_are_unknown": True,
|
||||||
|
},
|
||||||
|
"anchors": anchors,
|
||||||
|
}
|
||||||
|
write_json_atomic(MANUAL_ANCHOR_PATH, exported)
|
||||||
|
|
||||||
|
script = TOOL_DIR / "apply_reviewed_layer_assignment.py"
|
||||||
|
cmd = [
|
||||||
|
sys.executable, str(script),
|
||||||
|
"--anonymous", str(CM95_CATALOG_PATH),
|
||||||
|
"--reviewed-anchors", str(MANUAL_ANCHOR_PATH),
|
||||||
|
"--output", str(MANUAL_ASSIGNMENT_DIR),
|
||||||
|
]
|
||||||
|
proc = subprocess.run(cmd, text=True, capture_output=True, timeout=120)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(proc.stderr or proc.stdout or "assignment 生成失败")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"manual_review_path": str(MANUAL_REVIEW_PATH),
|
||||||
|
"anchor_path": str(MANUAL_ANCHOR_PATH),
|
||||||
|
"assignment_dir": str(MANUAL_ASSIGNMENT_DIR),
|
||||||
|
"stdout": proc.stdout,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
STATE = ReviewState()
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "StyleVisualObjectReviewer/1.0"
|
||||||
|
|
||||||
|
def _send_json(self, obj: Any, status: int = 200) -> None:
|
||||||
|
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _send_bytes(self, data: bytes, content_type: str, status: int = 200) -> None:
|
||||||
|
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.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _read_json(self) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||||
|
if length <= 0 or length > 2_000_000:
|
||||||
|
raise ValueError("请求体大小不合法")
|
||||||
|
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
if path == "/api/bootstrap":
|
||||||
|
return self._send_json(STATE.bootstrap())
|
||||||
|
|
||||||
|
if path == "/api/audit":
|
||||||
|
q = urllib.parse.parse_qs(parsed.query)
|
||||||
|
anon = (q.get("id") or [""])[0]
|
||||||
|
return self._send_json(STATE.audit_info(anon))
|
||||||
|
|
||||||
|
if path.startswith("/sprite/cm95/") and path.endswith(".png"):
|
||||||
|
anon = urllib.parse.unquote(path[len("/sprite/cm95/"):-4])
|
||||||
|
return self._send_bytes(STATE.crop_png("cm95", anon), "image/png")
|
||||||
|
|
||||||
|
if path.startswith("/sprite/std/") and path.endswith(".png"):
|
||||||
|
key = urllib.parse.unquote(path[len("/sprite/std/"):-4])
|
||||||
|
return self._send_bytes(STATE.crop_png("std", key), "image/png")
|
||||||
|
|
||||||
|
if path == "/" or path == "/index.html":
|
||||||
|
file_path = WEB_DIR / "index.html"
|
||||||
|
else:
|
||||||
|
rel = path.lstrip("/")
|
||||||
|
if ".." in Path(rel).parts:
|
||||||
|
return self._send_json({"error": "invalid path"}, 400)
|
||||||
|
file_path = WEB_DIR / rel
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
return self._send_json({"error": "not found"}, 404)
|
||||||
|
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||||
|
return self._send_bytes(file_path.read_bytes(), content_type)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._send_json({"error": str(exc)}, 500)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
payload = self._read_json()
|
||||||
|
|
||||||
|
if parsed.path == "/api/review":
|
||||||
|
decision = STATE.save_decision(payload)
|
||||||
|
return self._send_json({"ok": True, "decision": decision, "progress": STATE.bootstrap()["progress"]})
|
||||||
|
|
||||||
|
if parsed.path == "/api/unreview":
|
||||||
|
anon = str(payload.get("anonymous_id") or "")
|
||||||
|
STATE.delete_decision(anon)
|
||||||
|
return self._send_json({"ok": True, "progress": STATE.bootstrap()["progress"]})
|
||||||
|
|
||||||
|
if parsed.path == "/api/abandon-unreviewed":
|
||||||
|
dry_run = bool(payload.get("dry_run"))
|
||||||
|
result = STATE.abandon_unreviewed(dry_run=dry_run)
|
||||||
|
return self._send_json({"ok": True, **result, "progress": STATE.bootstrap()["progress"]})
|
||||||
|
|
||||||
|
if parsed.path == "/api/finalize":
|
||||||
|
return self._send_json(STATE.finalize())
|
||||||
|
|
||||||
|
return self._send_json({"error": "not found"}, 404)
|
||||||
|
except ValueError as exc:
|
||||||
|
return self._send_json({"error": str(exc)}, 400)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._send_json({"error": str(exc)}, 500)
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
sys.stdout.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--host", default="0.0.0.0")
|
||||||
|
ap.add_argument("--port", type=int, default=18083)
|
||||||
|
args = ap.parse_args()
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
print(f"CM95 Visual Object Reviewer: http://{args.host}:{args.port}/")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
538
tools/style-visual-object/reviewer/web/app.js
Normal file
538
tools/style-visual-object/reviewer/web/app.js
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
const state = {
|
||||||
|
data: null,
|
||||||
|
filter: "UNREVIEWED",
|
||||||
|
visibleItems: [],
|
||||||
|
currentIndex: 0,
|
||||||
|
selectedStatus: null,
|
||||||
|
selectedLayers: new Set(),
|
||||||
|
selectedStdKey: null,
|
||||||
|
selectedComponentKind: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = function(id){ return document.getElementById(id); };
|
||||||
|
|
||||||
|
function esc(v){
|
||||||
|
return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch){
|
||||||
|
return {"&":"&","<":"<",">":">","\"":""","'":"'"}[ch];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options){
|
||||||
|
const res = await fetch(path, options || {});
|
||||||
|
const data = await res.json();
|
||||||
|
if(!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(){
|
||||||
|
state.data = await api("/api/bootstrap");
|
||||||
|
populateLayerFilter();
|
||||||
|
applyFilter(true);
|
||||||
|
updateProgress(state.data.progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decision(item){ return item.decision || null; }
|
||||||
|
|
||||||
|
function applyFilter(resetIndex){
|
||||||
|
const f = state.filter;
|
||||||
|
state.visibleItems = state.data.items.filter(function(item){
|
||||||
|
const d = decision(item);
|
||||||
|
if(f === "ALL") return true;
|
||||||
|
if(f === "UNREVIEWED") return !d;
|
||||||
|
return d && d.status === f;
|
||||||
|
});
|
||||||
|
if(resetIndex) state.currentIndex = 0;
|
||||||
|
if(state.currentIndex >= state.visibleItems.length) state.currentIndex = Math.max(0, state.visibleItems.length - 1);
|
||||||
|
renderQueue();
|
||||||
|
renderCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueue(){
|
||||||
|
$("queueCount").textContent = state.visibleItems.length + " 项";
|
||||||
|
$("queue").innerHTML = state.visibleItems.map(function(item, idx){
|
||||||
|
const d = decision(item);
|
||||||
|
const st = d ? d.status : "未确认";
|
||||||
|
return '<button class="queue-item ' + (idx===state.currentIndex?'active':'') + '" data-index="' + idx + '">' +
|
||||||
|
'<span class="queue-thumb"><img loading="lazy" src="/sprite/cm95/' + encodeURIComponent(item.anonymous_id) + '.png"></span>' +
|
||||||
|
'<span class="queue-info"><strong>' + esc(item.display_name_zh || item.anonymous_id) + '</strong>' +
|
||||||
|
'<small>' + esc(item.anonymous_id) + '</small>' +
|
||||||
|
'<small>' + esc(item.component_roles.join(", ") || "—") + ' · ' + item.occurrence_count + ' 次</small>' +
|
||||||
|
'<span class="queue-status">' + esc(st) + '</span></span></button>';
|
||||||
|
}).join("");
|
||||||
|
document.querySelectorAll(".queue-item").forEach(function(btn){
|
||||||
|
btn.addEventListener("click", function(){
|
||||||
|
state.currentIndex = Number(btn.dataset.index);
|
||||||
|
renderQueue();
|
||||||
|
renderCurrent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrent(){
|
||||||
|
const item = state.visibleItems[state.currentIndex];
|
||||||
|
const empty = !item;
|
||||||
|
$("emptyState").classList.toggle("hidden", !empty);
|
||||||
|
$("reviewContent").classList.toggle("hidden", empty);
|
||||||
|
if(empty) return;
|
||||||
|
|
||||||
|
$("cmNameZh").textContent = item.display_name_zh || "未命名视觉对象";
|
||||||
|
$("anonId").textContent = item.anonymous_id;
|
||||||
|
$("cmOfficialDesc").textContent = item.official_description_en ? ("S-52: " + item.official_description_en) : (item.name_source || "");
|
||||||
|
$("cmImage").src = "/sprite/cm95/" + encodeURIComponent(item.anonymous_id) + ".png";
|
||||||
|
$("componentRoles").textContent = item.component_roles.join(", ") || "—";
|
||||||
|
$("geometryRoles").textContent = item.geometry_roles.join(", ") || "—";
|
||||||
|
$("occurrenceCount").textContent = item.occurrence_count;
|
||||||
|
|
||||||
|
const sem = item.semantic || {};
|
||||||
|
$("semanticCategory").textContent = sem.category_zh || "尚未建立语义分类";
|
||||||
|
$("semanticReason").textContent = sem.reason_zh || "当前没有可靠的 Provider 语义规则。";
|
||||||
|
const componentHint = $("semanticComponentHint");
|
||||||
|
if(sem.component_kind){
|
||||||
|
const kind = (state.data.component_kinds || []).find(function(x){ return x.id === sem.component_kind; });
|
||||||
|
componentHint.classList.remove("hidden");
|
||||||
|
componentHint.textContent = "建议操作:COMPONENT · " + (kind ? kind.name_zh : sem.component_kind);
|
||||||
|
} else {
|
||||||
|
componentHint.classList.add("hidden");
|
||||||
|
componentHint.textContent = "";
|
||||||
|
}
|
||||||
|
$("decisionConflict").classList.toggle("hidden", !item.decision_conflict);
|
||||||
|
|
||||||
|
const s = item.suggestion;
|
||||||
|
if(s){
|
||||||
|
const layers = s.standard_layer ? [s.standard_layer] : (s.standard_layers || []);
|
||||||
|
$("suggestionBox").classList.remove("hidden");
|
||||||
|
const layerText = layers.map(function(layer){
|
||||||
|
return (state.data.standard_layer_zh[layer] || layer) + "(" + layer + ")";
|
||||||
|
}).join(" / ");
|
||||||
|
const nameText = s.standard_name_zh || "未指定标准对象名";
|
||||||
|
$("suggestionBox").innerHTML = "<b>旧视觉匹配建议(辅助证据)</b><br>" +
|
||||||
|
'<span class="candidate-name">' + esc(nameText) + '</span><br>' +
|
||||||
|
esc(s.status || "—") + " → " + esc(layerText || "—") +
|
||||||
|
(s.review_stage ? '<br><span class="muted">' + esc(s.review_stage) + "</span>" : "");
|
||||||
|
} else {
|
||||||
|
$("suggestionBox").classList.add("hidden");
|
||||||
|
$("suggestionBox").innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = decision(item);
|
||||||
|
state.selectedStatus = d ? d.status : null;
|
||||||
|
state.selectedLayers = new Set(d ? (d.standard_layers || []) : []);
|
||||||
|
state.selectedStdKey = d ? d.standard_resource_key : null;
|
||||||
|
state.selectedComponentKind = d ? (d.component_kind || null) : null;
|
||||||
|
$("note").value = d ? (d.note || "") : "";
|
||||||
|
|
||||||
|
renderDecisionBadge(d);
|
||||||
|
renderStatusButtons();
|
||||||
|
renderModeFields();
|
||||||
|
renderComponentKindSelection();
|
||||||
|
renderLayerSelection();
|
||||||
|
renderLayerSuggestions(item);
|
||||||
|
renderCandidates(item);
|
||||||
|
renderSelectedStd();
|
||||||
|
$("auditDetails").open = false;
|
||||||
|
$("auditText").textContent = "尚未读取。";
|
||||||
|
$("message").textContent = "";
|
||||||
|
$("message").className = "message";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDecisionBadge(d){
|
||||||
|
const badge = $("decisionBadge");
|
||||||
|
const st = d ? d.status : null;
|
||||||
|
badge.className = "badge " + (st ? st.toLowerCase() : "neutral");
|
||||||
|
badge.textContent = st || "未确认";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusButtons(){
|
||||||
|
document.querySelectorAll(".status-btn").forEach(function(btn){
|
||||||
|
btn.classList.toggle("active", btn.dataset.status === state.selectedStatus);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModeFields(){
|
||||||
|
const isComponent = state.selectedStatus === "COMPONENT";
|
||||||
|
$("componentKindField").classList.toggle("hidden", !isComponent);
|
||||||
|
$("standardLayerField").classList.toggle("hidden", isComponent);
|
||||||
|
$("standardObjectField").classList.toggle("hidden", isComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponentKindSelection(){
|
||||||
|
const kinds = state.data.component_kinds || [];
|
||||||
|
$("componentKindSelection").innerHTML = kinds.map(function(k){
|
||||||
|
return '<button class="component-kind-chip ' + (state.selectedComponentKind===k.id?'selected':'') +
|
||||||
|
'" data-kind="' + esc(k.id) + '">' + esc(k.name_zh) + '</button>';
|
||||||
|
}).join("");
|
||||||
|
document.querySelectorAll(".component-kind-chip").forEach(function(btn){
|
||||||
|
btn.addEventListener("click", function(){
|
||||||
|
const kind = btn.dataset.kind;
|
||||||
|
state.selectedComponentKind = state.selectedComponentKind === kind ? null : kind;
|
||||||
|
renderComponentKindSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayerSelection(){
|
||||||
|
$("layerSelection").innerHTML = state.data.standard_layers.map(function(layer){
|
||||||
|
const zh = state.data.standard_layer_zh[layer] || layer;
|
||||||
|
return '<button class="layer-chip ' + (state.selectedLayers.has(layer)?'selected':'') +
|
||||||
|
'" data-layer="' + esc(layer) + '">' + esc(zh) + '<span class="layer-code"> · ' + esc(layer) + '</span></button>';
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
document.querySelectorAll(".layer-chip").forEach(function(btn){
|
||||||
|
btn.addEventListener("click", function(){
|
||||||
|
const layer = btn.dataset.layer;
|
||||||
|
if(state.selectedStatus === "UNKNOWN" || state.selectedStatus === "COMPONENT") return;
|
||||||
|
if(state.selectedStatus === "MATCH" || state.selectedStatus === "PARTIAL"){
|
||||||
|
state.selectedLayers = new Set([layer]);
|
||||||
|
if(state.selectedStdKey){
|
||||||
|
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
|
||||||
|
if(sr && sr.standard_layer !== layer) state.selectedStdKey = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if(state.selectedLayers.has(layer)) state.selectedLayers.delete(layer);
|
||||||
|
else state.selectedLayers.add(layer);
|
||||||
|
}
|
||||||
|
renderLayerSelection();
|
||||||
|
renderSelectedStd();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayerSuggestions(item){
|
||||||
|
const rows = item.layer_suggestions || [];
|
||||||
|
const box = $("layerSuggestionList");
|
||||||
|
if(item.semantic && item.semantic.component_kind){
|
||||||
|
const kind = (state.data.component_kinds || []).find(function(x){ return x.id === item.semantic.component_kind; });
|
||||||
|
const kindName = kind ? kind.name_zh : item.semantic.component_kind;
|
||||||
|
box.innerHTML = '<div class="component-recommendation">' +
|
||||||
|
'<div class="layer-rank-title">建议先作为组件处理</div>' +
|
||||||
|
'<div class="layer-rank-name">COMPONENT · ' + esc(kindName) + '</div>' +
|
||||||
|
'<div class="layer-rank-reason">' + esc(item.semantic.reason_zh || '') + '</div>' +
|
||||||
|
'<button id="applyComponentSuggestion" class="apply-suggestion-btn">采用 COMPONENT 建议</button>' +
|
||||||
|
'</div>' + renderLayerRows(rows);
|
||||||
|
const btn = $("applyComponentSuggestion");
|
||||||
|
if(btn){ btn.addEventListener("click", function(){
|
||||||
|
chooseStatus("COMPONENT");
|
||||||
|
state.selectedComponentKind = item.semantic.component_kind;
|
||||||
|
renderComponentKindSelection();
|
||||||
|
}); }
|
||||||
|
bindLayerSuggestionClicks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!rows.length){
|
||||||
|
box.innerHTML = '<div class="no-layer-suggestion">当前 S-52 语义没有可靠的 NavSea Layer 建议。请人工选择,或保留 UNKNOWN。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = renderLayerRows(rows);
|
||||||
|
bindLayerSuggestionClicks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayerRows(rows){
|
||||||
|
return rows.map(function(r, idx){
|
||||||
|
const top = idx === 0;
|
||||||
|
const score = (Number(r.combined_score || 0) * 100).toFixed(1);
|
||||||
|
const sem = (Number(r.semantic_score || 0) * 100).toFixed(0);
|
||||||
|
const vis = (Number(r.visual_score || 0) * 100).toFixed(0);
|
||||||
|
return '<button class="layer-suggestion-card ' + (top?'top':'') + '" data-layer="' + esc(r.standard_layer) + '">' +
|
||||||
|
'<div class="layer-rank-head"><span class="layer-rank-index">' + (idx+1) + '</span>' +
|
||||||
|
'<span class="layer-rank-name">' + esc(r.standard_layer_zh || r.standard_layer) + '</span>' +
|
||||||
|
(top?'<span class="layer-rank-top">最可能</span>':'') + '</div>' +
|
||||||
|
'<div class="layer-rank-code">' + esc(r.standard_layer) + '</div>' +
|
||||||
|
'<div class="layer-rank-score">综合 ' + score + '% · 语义 ' + sem + '% · 视觉 ' + vis + '%</div>' +
|
||||||
|
'<div class="layer-rank-reason">' + esc(r.reason_zh || '') + '</div>' +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindLayerSuggestionClicks(){
|
||||||
|
document.querySelectorAll(".layer-suggestion-card").forEach(function(card){
|
||||||
|
card.addEventListener("click", function(){
|
||||||
|
const layer = card.dataset.layer;
|
||||||
|
state.selectedLayers = new Set([layer]);
|
||||||
|
if(!state.selectedStatus || state.selectedStatus === "UNKNOWN") state.selectedStatus = "PARTIAL";
|
||||||
|
if(state.selectedStdKey){
|
||||||
|
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
|
||||||
|
if(sr && sr.standard_layer !== layer) state.selectedStdKey = null;
|
||||||
|
}
|
||||||
|
renderStatusButtons();
|
||||||
|
renderModeFields();
|
||||||
|
renderLayerSelection();
|
||||||
|
renderSelectedStd();
|
||||||
|
showMessage("已选择 Layer:" + (state.data.standard_layer_zh[layer] || layer) + "。请确认后保存。", true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCandidates(item){
|
||||||
|
const seen = new Set();
|
||||||
|
const candidates = [];
|
||||||
|
if(item.suggestion && item.suggestion.standard_resource_key){
|
||||||
|
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === item.suggestion.standard_resource_key; });
|
||||||
|
const raw = (item.candidates || []).find(function(x){ return x.resource_key === item.suggestion.standard_resource_key; });
|
||||||
|
if(sr){
|
||||||
|
candidates.push({
|
||||||
|
resource_key:sr.resource_key,standard_layer:sr.standard_layer,standard_layer_zh:sr.standard_layer_zh,display_name_zh:sr.display_name_zh,
|
||||||
|
score:raw ? raw.score : null,text_score:raw ? raw.text_score : null,recommendation_score:raw ? raw.recommendation_score : null,
|
||||||
|
source:"已有建议"
|
||||||
|
});
|
||||||
|
seen.add(sr.resource_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(item.candidates || []).forEach(function(c){
|
||||||
|
if(!seen.has(c.resource_key)){
|
||||||
|
candidates.push({resource_key:c.resource_key,standard_layer:c.standard_layer,standard_layer_zh:c.standard_layer_zh,display_name_zh:c.display_name_zh,score:c.score,text_score:c.text_score,recommendation_score:c.recommendation_score,source:"图标参考"});
|
||||||
|
seen.add(c.resource_key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(state.selectedStdKey && !seen.has(state.selectedStdKey)){
|
||||||
|
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
|
||||||
|
if(sr) candidates.unshift({resource_key:sr.resource_key,standard_layer:sr.standard_layer,standard_layer_zh:sr.standard_layer_zh,display_name_zh:sr.display_name_zh,source:"当前选择"});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!candidates.length){
|
||||||
|
$("candidateGrid").innerHTML = '<div class="muted">没有 Standard Visual Object 图标参考。Layer 仍可单独人工确认。</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("candidateGrid").innerHTML = candidates.map(function(c){
|
||||||
|
const selected = c.resource_key === state.selectedStdKey;
|
||||||
|
const recommended = c.resource_key === item.recommended_resource_key;
|
||||||
|
const score = c.score != null ? (" · 视觉 " + (Number(c.score)*100).toFixed(1) + "%") : "";
|
||||||
|
const recScore = c.recommendation_score != null ? (" · 综合 " + (Number(c.recommendation_score)*100).toFixed(1) + "%") : "";
|
||||||
|
return '<div class="candidate ' + (selected?'selected ':'') + (recommended?'recommended':'') + '" data-key="' + esc(c.resource_key) +
|
||||||
|
'" data-layer="' + esc(c.standard_layer) + '">' +
|
||||||
|
'<div class="candidate-img"><img src="/sprite/std/' + encodeURIComponent(c.resource_key) + '.png"></div>' +
|
||||||
|
'<div class="candidate-name ' + (recommended?'recommended-name':'') + '">' +
|
||||||
|
esc(c.display_name_zh || c.standard_layer_zh || c.resource_key) +
|
||||||
|
(recommended ? '<span class="recommended-tag">最可能</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="candidate-code">' + esc(c.resource_key) + '</div>' +
|
||||||
|
'<small>' + esc(c.standard_layer_zh || c.standard_layer) + ' · ' + esc(c.standard_layer) + '</small>' +
|
||||||
|
'<small>' + esc(c.source) + score + recScore + '</small></div>';
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
document.querySelectorAll("#candidateGrid .candidate").forEach(function(card){
|
||||||
|
card.addEventListener("click", function(){ selectStandard(card.dataset.key, card.dataset.layer); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStandard(key, layer){
|
||||||
|
if(state.selectedStatus === "COMPONENT" || state.selectedStatus === "UNKNOWN") return;
|
||||||
|
state.selectedStdKey = key;
|
||||||
|
if(state.selectedStatus !== "AMBIGUOUS" && state.selectedStatus !== "UNKNOWN"){
|
||||||
|
state.selectedLayers = new Set([layer]);
|
||||||
|
} else if(state.selectedStatus === "AMBIGUOUS"){
|
||||||
|
state.selectedLayers.add(layer);
|
||||||
|
}
|
||||||
|
renderCandidates(state.visibleItems[state.currentIndex]);
|
||||||
|
renderLayerSelection();
|
||||||
|
renderSelectedStd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedStd(){
|
||||||
|
const el = $("selectedStd");
|
||||||
|
if(!state.selectedStdKey){
|
||||||
|
el.textContent = "未选择";
|
||||||
|
el.classList.add("muted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sr = state.data.standard_resources.find(function(x){ return x.resource_key === state.selectedStdKey; });
|
||||||
|
if(!sr) return;
|
||||||
|
el.classList.remove("muted");
|
||||||
|
el.innerHTML = "<b>" + esc(sr.display_name_zh || sr.resource_key) + "</b> · " +
|
||||||
|
esc(sr.standard_layer_zh || sr.standard_layer) +
|
||||||
|
'<div class="candidate-code">' + esc(sr.resource_key) + ' · ' + esc(sr.standard_layer) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLayerOnly(){
|
||||||
|
if(state.selectedLayers.size !== 1){
|
||||||
|
showMessage("请先选择一个标准 Layer。", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.selectedStdKey = null;
|
||||||
|
if(!state.selectedStatus || state.selectedStatus === "MATCH") state.selectedStatus = "PARTIAL";
|
||||||
|
renderStatusButtons();
|
||||||
|
renderCandidates(state.visibleItems[state.currentIndex]);
|
||||||
|
renderSelectedStd();
|
||||||
|
showMessage("已设为:仅确认 Layer,不绑定标准图标。", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseStatus(status){
|
||||||
|
state.selectedStatus = status;
|
||||||
|
if(status === "UNKNOWN" || status === "COMPONENT"){
|
||||||
|
state.selectedLayers.clear();
|
||||||
|
state.selectedStdKey = null;
|
||||||
|
} else {
|
||||||
|
state.selectedComponentKind = null;
|
||||||
|
if((status === "MATCH" || status === "PARTIAL") && state.selectedLayers.size > 1){
|
||||||
|
state.selectedLayers = new Set([Array.from(state.selectedLayers)[0]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderStatusButtons();
|
||||||
|
renderModeFields();
|
||||||
|
renderComponentKindSelection();
|
||||||
|
renderLayerSelection();
|
||||||
|
renderCandidates(state.visibleItems[state.currentIndex]);
|
||||||
|
renderSelectedStd();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDecision(goNext){
|
||||||
|
const item = state.visibleItems[state.currentIndex];
|
||||||
|
if(!item) return;
|
||||||
|
if(!state.selectedStatus){ showMessage("请选择 MATCH / PARTIAL / AMBIGUOUS / COMPONENT / UNKNOWN。", false); return; }
|
||||||
|
const payload = {
|
||||||
|
anonymous_id:item.anonymous_id,
|
||||||
|
status:state.selectedStatus,
|
||||||
|
standard_layers:Array.from(state.selectedLayers),
|
||||||
|
standard_resource_key:state.selectedStdKey,
|
||||||
|
component_kind:state.selectedComponentKind,
|
||||||
|
note:$("note").value || ""
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
const res = await api("/api/review", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
|
||||||
|
item.decision = res.decision;
|
||||||
|
updateProgress(res.progress);
|
||||||
|
showMessage("已保存。", true);
|
||||||
|
if(goNext){
|
||||||
|
if(state.filter === "UNREVIEWED") applyFilter(false);
|
||||||
|
else {
|
||||||
|
if(state.currentIndex < state.visibleItems.length-1) state.currentIndex++;
|
||||||
|
renderQueue(); renderCurrent();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renderDecisionBadge(item.decision); renderQueue();
|
||||||
|
}
|
||||||
|
}catch(e){ showMessage(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDecision(){
|
||||||
|
const item = state.visibleItems[state.currentIndex];
|
||||||
|
if(!item || !item.decision) return;
|
||||||
|
try{
|
||||||
|
const res = await api("/api/unreview", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({anonymous_id:item.anonymous_id})});
|
||||||
|
item.decision = null;
|
||||||
|
updateProgress(res.progress);
|
||||||
|
applyFilter(false);
|
||||||
|
}catch(e){ showMessage(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, ok){
|
||||||
|
$("message").textContent = text;
|
||||||
|
$("message").className = "message " + (ok?"ok":"err");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(p){
|
||||||
|
const pct = p.total ? (p.reviewed/p.total*100) : 0;
|
||||||
|
$("progressText").textContent = p.reviewed + "/" + p.total + " 已确认 · MATCH " + p.MATCH +
|
||||||
|
" · PARTIAL " + p.PARTIAL + " · AMBIGUOUS " + p.AMBIGUOUS + " · COMPONENT " + p.COMPONENT + " · UNKNOWN " + p.UNKNOWN;
|
||||||
|
$("progressBar").style.width = pct + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(delta){
|
||||||
|
if(!state.visibleItems.length) return;
|
||||||
|
state.currentIndex = Math.max(0, Math.min(state.visibleItems.length-1, state.currentIndex+delta));
|
||||||
|
renderQueue(); renderCurrent();
|
||||||
|
const active = document.querySelector(".queue-item.active");
|
||||||
|
if(active) active.scrollIntoView({block:"nearest"});
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLayerFilter(){
|
||||||
|
$("layerFilter").innerHTML = '<option value="">全部标准 Layer</option>' +
|
||||||
|
state.data.standard_layers.map(function(x){ return '<option value="' + esc(x) + '">' + esc(state.data.standard_layer_zh[x] || x) + ' · ' + esc(x) + "</option>"; }).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLibrary(){ renderLibrary(); $("libraryDialog").showModal(); }
|
||||||
|
|
||||||
|
function renderLibrary(){
|
||||||
|
const layer = $("layerFilter").value;
|
||||||
|
const q = $("librarySearch").value.trim().toLowerCase();
|
||||||
|
const rows = state.data.standard_resources.filter(function(x){
|
||||||
|
return (!layer || x.standard_layer === layer) &&
|
||||||
|
(!q || x.resource_key.toLowerCase().includes(q) || x.standard_layer.toLowerCase().includes(q) ||
|
||||||
|
(x.display_name_zh || "").toLowerCase().includes(q) || (x.standard_layer_zh || "").toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
$("libraryGrid").innerHTML = rows.map(function(x){
|
||||||
|
return '<div class="library-item" data-key="' + esc(x.resource_key) + '" data-layer="' + esc(x.standard_layer) + '">' +
|
||||||
|
'<img loading="lazy" src="/sprite/std/' + encodeURIComponent(x.resource_key) + '.png">' +
|
||||||
|
'<div class="candidate-name">' + esc(x.display_name_zh || x.resource_key) + '</div>' +
|
||||||
|
'<div class="candidate-code">' + esc(x.resource_key) + '</div>' +
|
||||||
|
'<small>' + esc(x.standard_layer_zh || x.standard_layer) + ' · ' + esc(x.standard_layer) + '</small></div>';
|
||||||
|
}).join("");
|
||||||
|
document.querySelectorAll(".library-item").forEach(function(el){
|
||||||
|
el.addEventListener("click", function(){
|
||||||
|
selectStandard(el.dataset.key, el.dataset.layer);
|
||||||
|
$("libraryDialog").close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit(){
|
||||||
|
const item = state.visibleItems[state.currentIndex];
|
||||||
|
if(!item) return;
|
||||||
|
try{
|
||||||
|
const data = await api("/api/audit?id=" + encodeURIComponent(item.anonymous_id));
|
||||||
|
$("auditText").textContent = JSON.stringify(data, null, 2);
|
||||||
|
}catch(e){ $("auditText").textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function abandonAllUnreviewed(){
|
||||||
|
const p = state.data && state.data.progress ? state.data.progress : null;
|
||||||
|
const count = p ? p.unreviewed : 0;
|
||||||
|
if(!count){ alert("当前没有未确认项。 "); return; }
|
||||||
|
if(!confirm("确定放弃剩余 " + count + " 个未确认项吗?\n\n它们会全部设为 UNKNOWN;已经人工确认的项目不会改变。")) return;
|
||||||
|
try{
|
||||||
|
const res = await api("/api/abandon-unreviewed", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dry_run:false})});
|
||||||
|
state.data = await api("/api/bootstrap");
|
||||||
|
updateProgress(state.data.progress);
|
||||||
|
applyFilter(true);
|
||||||
|
alert("已放弃 " + res.affected + " 个未确认项,全部标记为 UNKNOWN。 ");
|
||||||
|
}catch(e){ alert("批量放弃失败:" + e.message); }
|
||||||
|
}
|
||||||
|
async function finalize(){
|
||||||
|
if(!confirm("生成当前人工确认结果?未确认项目会保持 UNKNOWN,不会自动继承机器建议。")) return;
|
||||||
|
try{
|
||||||
|
const res = await api("/api/finalize", {method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});
|
||||||
|
alert("已生成。\n\n" + res.assignment_dir);
|
||||||
|
}catch(e){ alert("生成失败:" + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function(e){
|
||||||
|
const f = e.target.closest(".filter");
|
||||||
|
if(f){
|
||||||
|
document.querySelectorAll(".filter").forEach(function(x){ x.classList.remove("active"); });
|
||||||
|
f.classList.add("active"); state.filter = f.dataset.filter; applyFilter(true);
|
||||||
|
}
|
||||||
|
const s = e.target.closest(".status-btn");
|
||||||
|
if(s) chooseStatus(s.dataset.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("prevBtn").addEventListener("click", function(){ move(-1); });
|
||||||
|
$("nextBtn").addEventListener("click", function(){ move(1); });
|
||||||
|
$("saveBtn").addEventListener("click", function(){ saveDecision(true); });
|
||||||
|
$("saveStayBtn").addEventListener("click", function(){ saveDecision(false); });
|
||||||
|
$("clearBtn").addEventListener("click", clearDecision);
|
||||||
|
$("layerOnlyBtn").addEventListener("click", useLayerOnly);
|
||||||
|
$("openLibraryBtn").addEventListener("click", openLibrary);
|
||||||
|
$("closeLibraryBtn").addEventListener("click", function(){ $("libraryDialog").close(); });
|
||||||
|
$("layerFilter").addEventListener("change", renderLibrary);
|
||||||
|
$("librarySearch").addEventListener("input", renderLibrary);
|
||||||
|
$("loadAuditBtn").addEventListener("click", loadAudit);
|
||||||
|
$("abandonAllBtn").addEventListener("click", abandonAllUnreviewed);
|
||||||
|
$("finalizeBtn").addEventListener("click", finalize);
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function(e){
|
||||||
|
const tag = document.activeElement ? document.activeElement.tagName : "";
|
||||||
|
if(["TEXTAREA","INPUT","SELECT"].includes(tag)) return;
|
||||||
|
if(e.key === "m" || e.key === "M") chooseStatus("MATCH");
|
||||||
|
else if(e.key === "p" || e.key === "P") chooseStatus("PARTIAL");
|
||||||
|
else if(e.key === "a" || e.key === "A") chooseStatus("AMBIGUOUS");
|
||||||
|
else if(e.key === "c" || e.key === "C") chooseStatus("COMPONENT");
|
||||||
|
else if(e.key === "u" || e.key === "U") chooseStatus("UNKNOWN");
|
||||||
|
else if(e.key === "ArrowLeft") move(-1);
|
||||||
|
else if(e.key === "ArrowRight") move(1);
|
||||||
|
else if(e.key === "Enter") saveDecision(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
load().catch(function(e){
|
||||||
|
document.body.innerHTML = '<pre style="padding:20px;color:#b42318">' + esc(e.stack || e.message) + "</pre>";
|
||||||
|
});
|
||||||
173
tools/style-visual-object/reviewer/web/index.html
Normal file
173
tools/style-visual-object/reviewer/web/index.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>CM95 → NavSea Layer 人工确认台</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>CM95 → NavSea Layer 人工确认台</h1>
|
||||||
|
<p>左侧显示 CM95/S-52 对象与语义;中间给出 NavSea Standard Layer 建议;右侧由人工确认最终 Layer。Standard Visual Object 只作为可选视觉参考。</p>
|
||||||
|
</div>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div id="progressText">加载中…</div>
|
||||||
|
<div class="progress"><div id="progressBar"></div></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="toolbar">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>筛选</label>
|
||||||
|
<button data-filter="UNREVIEWED" class="filter active">未确认</button>
|
||||||
|
<button data-filter="ALL" class="filter">全部</button>
|
||||||
|
<button data-filter="MATCH" class="filter">MATCH</button>
|
||||||
|
<button data-filter="PARTIAL" class="filter">PARTIAL</button>
|
||||||
|
<button data-filter="AMBIGUOUS" class="filter">AMBIGUOUS</button>
|
||||||
|
<button data-filter="COMPONENT" class="filter">COMPONENT</button>
|
||||||
|
<button data-filter="UNKNOWN" class="filter">UNKNOWN</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button id="prevBtn">← 上一个</button>
|
||||||
|
<button id="nextBtn">下一个 →</button>
|
||||||
|
<button id="abandonAllBtn" class="danger-ghost">全部放弃未确认项</button>
|
||||||
|
<button id="finalizeBtn" class="primary">生成当前人工确认结果</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="queue-panel">
|
||||||
|
<div class="queue-head">
|
||||||
|
<span id="queueTitle">队列</span>
|
||||||
|
<span id="queueCount"></span>
|
||||||
|
</div>
|
||||||
|
<div id="queue" class="queue"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="review-panel">
|
||||||
|
<div id="emptyState" class="empty">没有符合当前筛选条件的对象。</div>
|
||||||
|
|
||||||
|
<div id="reviewContent" class="hidden">
|
||||||
|
<div class="comparison">
|
||||||
|
<section class="cm-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">CM95 匿名 Visual Object</div>
|
||||||
|
<h2 id="cmNameZh"></h2><div id="anonId" class="muted"></div><div id="cmOfficialDesc" class="official-desc"></div>
|
||||||
|
</div>
|
||||||
|
<div id="decisionBadge" class="badge neutral">未确认</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cm-main">
|
||||||
|
<div class="image-stage large">
|
||||||
|
<img id="cmImage" alt="CM95 Visual Object">
|
||||||
|
</div>
|
||||||
|
<div id="semanticBox" class="semantic-box">
|
||||||
|
<div class="semantic-label">S-52 对象大类</div>
|
||||||
|
<div id="semanticCategory" class="semantic-category">—</div>
|
||||||
|
<div id="semanticReason" class="semantic-reason">—</div>
|
||||||
|
<div id="semanticComponentHint" class="semantic-component-hint hidden"></div>
|
||||||
|
<div id="decisionConflict" class="semantic-conflict hidden">当前人工结果与新的语义分类建议不一致,建议复核。</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div><span>组件</span><b id="componentRoles">—</b></div>
|
||||||
|
<div><span>几何</span><b id="geometryRoles">—</b></div>
|
||||||
|
<div><span>出现次数</span><b id="occurrenceCount">—</b></div>
|
||||||
|
</div>
|
||||||
|
<div id="suggestionBox" class="suggestion hidden"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="std-card">
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">Provider Layer Classifier</div>
|
||||||
|
<h2>NavSea Layer 建议</h2>
|
||||||
|
</div>
|
||||||
|
<button id="openLibraryBtn">打开标准库</button>
|
||||||
|
</div>
|
||||||
|
<div id="layerSuggestionList" class="layer-suggestion-list"></div>
|
||||||
|
<details id="visualReferenceDetails" class="visual-reference-details">
|
||||||
|
<summary>Standard Visual Object 图标参考</summary>
|
||||||
|
<div id="candidateGrid" class="candidate-grid"></div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="decision-panel">
|
||||||
|
<div class="decision-head">
|
||||||
|
<h3>人工判断</h3>
|
||||||
|
<div class="shortcut-hint">快捷键:M / P / A / C / U,Enter 保存并下一个</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-buttons">
|
||||||
|
<button data-status="MATCH" class="status-btn match">MATCH</button>
|
||||||
|
<button data-status="PARTIAL" class="status-btn partial">PARTIAL</button>
|
||||||
|
<button data-status="AMBIGUOUS" class="status-btn ambiguous">AMBIGUOUS</button>
|
||||||
|
<button data-status="COMPONENT" class="status-btn component">COMPONENT</button>
|
||||||
|
<button data-status="UNKNOWN" class="status-btn unknown">UNKNOWN</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision-form">
|
||||||
|
<div id="componentKindField" class="field hidden">
|
||||||
|
<label>组件类型(可选)</label>
|
||||||
|
<div id="componentKindSelection" class="component-kind-selection"></div>
|
||||||
|
<div class="help">COMPONENT 表示它只是完整 Visual Object 的组成部分,不单独分配 Layer。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="standardLayerField" class="field">
|
||||||
|
<label>标准 Layer</label>
|
||||||
|
<div id="layerSelection" class="layer-selection"></div>
|
||||||
|
<div class="help">MATCH / PARTIAL 必须选 1 个;AMBIGUOUS 至少选 2 个;UNKNOWN 不选。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="standardObjectField" class="field">
|
||||||
|
<label>Standard Visual Object(可选)</label>
|
||||||
|
<div id="selectedStd" class="selected-std muted">未选择</div>
|
||||||
|
<button id="layerOnlyBtn" class="layer-only-btn">没有对应标准图标,只确认所属 Layer</button>
|
||||||
|
<div class="help">S-52 内容多于 NavSea 时,可以不选标准图标,直接确认所属 Layer。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="note">备注</label>
|
||||||
|
<textarea id="note" rows="3" placeholder="可选:记录为什么确认或为什么保留 UNKNOWN"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision-actions">
|
||||||
|
<button id="saveBtn" class="primary">保存并下一个</button>
|
||||||
|
<button id="saveStayBtn">仅保存</button>
|
||||||
|
<button id="clearBtn" class="danger-ghost">撤销本项人工确认</button>
|
||||||
|
</div>
|
||||||
|
<div id="message" class="message"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details id="auditDetails" class="audit">
|
||||||
|
<summary>审计信息(建议先完成判断,再展开)</summary>
|
||||||
|
<button id="loadAuditBtn">读取原始 provenance</button>
|
||||||
|
<pre id="auditText">尚未读取。</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="libraryDialog">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow">NavSea Frozen Standard Catalog</div>
|
||||||
|
<h2>标准 Visual Object 库</h2>
|
||||||
|
</div>
|
||||||
|
<button id="closeLibraryBtn">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="library-tools">
|
||||||
|
<select id="layerFilter"></select>
|
||||||
|
<input id="librarySearch" type="search" placeholder="搜索标准资源名">
|
||||||
|
</div>
|
||||||
|
<div id="libraryGrid" class="library-grid"></div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1222
tools/style-visual-object/reviewer/web/style.css
Normal file
1222
tools/style-visual-object/reviewer/web/style.css
Normal file
File diff suppressed because it is too large
Load Diff
394
tools/style-visual-object/style_vo_tool.py
Normal file
394
tools/style-visual-object/style_vo_tool.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
VISUAL_RESOURCE_PROPS = (
|
||||||
|
("layout", "icon-image", "icon"),
|
||||||
|
("paint", "fill-pattern", "fill-pattern"),
|
||||||
|
("paint", "line-pattern", "line-pattern"),
|
||||||
|
)
|
||||||
|
|
||||||
|
VECTOR_PROPS = {
|
||||||
|
"fill": ("fill-color", "fill-opacity", "fill-outline-color"),
|
||||||
|
"line": ("line-color", "line-width", "line-dasharray"),
|
||||||
|
"circle": ("circle-color", "circle-radius", "circle-stroke-color", "circle-stroke-width"),
|
||||||
|
"symbol": ("text-color", "text-halo-color", "text-halo-width"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def sid(prefix: str, value: Any) -> str:
|
||||||
|
return prefix + "-" + hashlib.sha256(canonical(value).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path_or_url: str) -> dict[str, Any]:
|
||||||
|
if re.match(r"^https?://", path_or_url):
|
||||||
|
with urllib.request.urlopen(path_or_url, timeout=30) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
return json.loads(Path(path_or_url).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sprite_base(style: dict[str, Any], override: str | None) -> str | None:
|
||||||
|
return override or style.get("sprite")
|
||||||
|
|
||||||
|
|
||||||
|
def load_sprite(base: str | None, output: Path) -> tuple[dict[str, Any], Image.Image | None]:
|
||||||
|
if not base:
|
||||||
|
return {}, None
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
if re.match(r"^https?://", base):
|
||||||
|
with urllib.request.urlopen(base + ".json", timeout=30) as r:
|
||||||
|
meta_bytes = r.read()
|
||||||
|
with urllib.request.urlopen(base + ".png", timeout=60) as r:
|
||||||
|
png_bytes = r.read()
|
||||||
|
(output / "sprite.json").write_bytes(meta_bytes)
|
||||||
|
(output / "sprite.png").write_bytes(png_bytes)
|
||||||
|
else:
|
||||||
|
base_path = Path(base)
|
||||||
|
(output / "sprite.json").write_bytes(base_path.with_suffix(".json").read_bytes())
|
||||||
|
(output / "sprite.png").write_bytes(base_path.with_suffix(".png").read_bytes())
|
||||||
|
meta = json.loads((output / "sprite.json").read_text(encoding="utf-8"))
|
||||||
|
image = Image.open(output / "sprite.png").convert("RGBA")
|
||||||
|
return meta, image
|
||||||
|
|
||||||
|
|
||||||
|
def walk_gets(value: Any, out: set[str]) -> None:
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) >= 2 and value[0] == "get" and isinstance(value[1], str):
|
||||||
|
out.add(value[1])
|
||||||
|
for item in value[1:]:
|
||||||
|
walk_gets(item, out)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
for item in value.values():
|
||||||
|
walk_gets(item, out)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_sprite_literals(value: Any, sprite_keys: set[str], out: set[str]) -> None:
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value in sprite_keys:
|
||||||
|
out.add(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value[1:]:
|
||||||
|
collect_sprite_literals(item, sprite_keys, out)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
for item in value.values():
|
||||||
|
collect_sprite_literals(item, sprite_keys, out)
|
||||||
|
|
||||||
|
|
||||||
|
def finite_positive_domain(expr: Any, field: str) -> set[Any] | None:
|
||||||
|
if not isinstance(expr, list) or not expr:
|
||||||
|
return None
|
||||||
|
head = expr[0]
|
||||||
|
if head == "==" and len(expr) >= 3:
|
||||||
|
left, right = expr[1], expr[2]
|
||||||
|
if isinstance(left, list) and len(left) >= 2 and left[0] == "get" and left[1] == field:
|
||||||
|
if isinstance(right, (str, int, float)):
|
||||||
|
return {right}
|
||||||
|
if isinstance(right, list) and len(right) >= 2 and right[0] == "get" and right[1] == field:
|
||||||
|
if isinstance(left, (str, int, float)):
|
||||||
|
return {left}
|
||||||
|
return None
|
||||||
|
if head == "match" and len(expr) >= 5:
|
||||||
|
input_expr = expr[1]
|
||||||
|
if not (isinstance(input_expr, list) and len(input_expr) >= 2 and input_expr[0] == "get" and input_expr[1] == field):
|
||||||
|
return None
|
||||||
|
arms = expr[2:]
|
||||||
|
fallback = arms[-1]
|
||||||
|
if fallback is not False:
|
||||||
|
return None
|
||||||
|
allowed: set[Any] = set()
|
||||||
|
pairs = arms[:-1]
|
||||||
|
for i in range(0, len(pairs), 2):
|
||||||
|
if i + 1 >= len(pairs):
|
||||||
|
break
|
||||||
|
labels, result = pairs[i], pairs[i + 1]
|
||||||
|
if result is not True:
|
||||||
|
continue
|
||||||
|
if isinstance(labels, list):
|
||||||
|
for x in labels:
|
||||||
|
if isinstance(x, (str, int, float)):
|
||||||
|
allowed.add(x)
|
||||||
|
elif isinstance(labels, (str, int, float)):
|
||||||
|
allowed.add(labels)
|
||||||
|
return allowed or None
|
||||||
|
if head == "all":
|
||||||
|
parts = [finite_positive_domain(x, field) for x in expr[1:]]
|
||||||
|
parts = [x for x in parts if x is not None]
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
out = set(parts[0])
|
||||||
|
for part in parts[1:]:
|
||||||
|
out &= part
|
||||||
|
return out or None
|
||||||
|
if head == "any":
|
||||||
|
parts = [finite_positive_domain(x, field) for x in expr[1:]]
|
||||||
|
if not parts or any(x is None for x in parts):
|
||||||
|
return None
|
||||||
|
out: set[Any] = set()
|
||||||
|
for part in parts:
|
||||||
|
out |= part or set()
|
||||||
|
return out or None
|
||||||
|
# Negation and inequality provide exclusions, not a finite positive domain.
|
||||||
|
if head in {"!", "!="}:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def eval_simple_resource(expr: Any, values: dict[str, Any]) -> Any:
|
||||||
|
if not isinstance(expr, list):
|
||||||
|
return expr
|
||||||
|
if not expr:
|
||||||
|
return expr
|
||||||
|
head = expr[0]
|
||||||
|
if head == "get" and len(expr) >= 2:
|
||||||
|
return values.get(expr[1])
|
||||||
|
if head == "to-string" and len(expr) >= 2:
|
||||||
|
value = eval_simple_resource(expr[1], values)
|
||||||
|
return None if value is None else str(value)
|
||||||
|
if head == "concat":
|
||||||
|
parts = []
|
||||||
|
for item in expr[1:]:
|
||||||
|
value = eval_simple_resource(item, values)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
parts.append(str(value))
|
||||||
|
return "".join(parts)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def concat_prefix(expr: Any) -> str | None:
|
||||||
|
if not isinstance(expr, list) or not expr or expr[0] != "concat":
|
||||||
|
return None
|
||||||
|
parts = expr[1:]
|
||||||
|
prefix = ""
|
||||||
|
saw_dynamic = False
|
||||||
|
for item in parts:
|
||||||
|
if isinstance(item, str) and not saw_dynamic:
|
||||||
|
prefix += item
|
||||||
|
else:
|
||||||
|
saw_dynamic = True
|
||||||
|
return prefix if prefix and saw_dynamic else None
|
||||||
|
|
||||||
|
|
||||||
|
def direct_get_field(expr: Any) -> str | None:
|
||||||
|
if isinstance(expr, list) and len(expr) >= 2 and expr[0] == "get" and isinstance(expr[1], str):
|
||||||
|
return expr[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resource_candidates(expr: Any, sprite_keys: set[str], filter_expr: Any) -> tuple[list[str], list[str]]:
|
||||||
|
literals: set[str] = set()
|
||||||
|
collect_sprite_literals(expr, sprite_keys, literals)
|
||||||
|
gets: set[str] = set()
|
||||||
|
walk_gets(expr, gets)
|
||||||
|
|
||||||
|
field = direct_get_field(expr)
|
||||||
|
if field:
|
||||||
|
domain = finite_positive_domain(filter_expr, field)
|
||||||
|
if domain:
|
||||||
|
for value in domain:
|
||||||
|
if isinstance(value, str) and value in sprite_keys:
|
||||||
|
literals.add(value)
|
||||||
|
|
||||||
|
# For simple generated keys (e.g. concat(prefix, to-string(get(field)))),
|
||||||
|
# only expand when the Style filter itself supplies a finite positive domain.
|
||||||
|
if len(gets) == 1:
|
||||||
|
dynamic_field = next(iter(gets))
|
||||||
|
domain = finite_positive_domain(filter_expr, dynamic_field)
|
||||||
|
if domain:
|
||||||
|
for value in domain:
|
||||||
|
resolved = eval_simple_resource(expr, {dynamic_field: value})
|
||||||
|
if isinstance(resolved, str) and resolved in sprite_keys:
|
||||||
|
literals.add(resolved)
|
||||||
|
|
||||||
|
return sorted(literals), sorted(gets)
|
||||||
|
|
||||||
|
|
||||||
|
def sprite_visual_hash(image: Image.Image, meta: dict[str, Any]) -> str:
|
||||||
|
crop = image.crop((
|
||||||
|
int(meta["x"]), int(meta["y"]),
|
||||||
|
int(meta["x"]) + int(meta["width"]),
|
||||||
|
int(meta["y"]) + int(meta["height"]),
|
||||||
|
))
|
||||||
|
return hashlib.sha256(crop.tobytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def extract(style_path: str, sprite_override: str | None, output: Path, catalog_name: str) -> dict[str, Any]:
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
style = load_json(style_path)
|
||||||
|
style_bytes = canonical(style).encode("utf-8")
|
||||||
|
sprite_meta, sprite_image = load_sprite(resolve_sprite_base(style, sprite_override), output / "_sprite")
|
||||||
|
sprite_keys = set(sprite_meta)
|
||||||
|
|
||||||
|
groups: dict[str, dict[str, Any]] = {}
|
||||||
|
objects: list[dict[str, Any]] = []
|
||||||
|
unresolved: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for index, layer in enumerate(style.get("layers", [])):
|
||||||
|
source_layer = layer.get("source-layer")
|
||||||
|
group_seed = {
|
||||||
|
"source": layer.get("source"),
|
||||||
|
"source_layer": source_layer,
|
||||||
|
}
|
||||||
|
group_id = sid("GRP", group_seed)
|
||||||
|
groups.setdefault(group_id, {
|
||||||
|
"group_id": group_id,
|
||||||
|
"provenance": group_seed,
|
||||||
|
"style_layer_indexes": [],
|
||||||
|
})["style_layer_indexes"].append(index)
|
||||||
|
|
||||||
|
found_resource = False
|
||||||
|
for section, key, role in VISUAL_RESOURCE_PROPS:
|
||||||
|
expr = (layer.get(section) or {}).get(key)
|
||||||
|
if expr is None:
|
||||||
|
continue
|
||||||
|
found_resource = True
|
||||||
|
candidates, dynamic_fields = resource_candidates(expr, sprite_keys, layer.get("filter"))
|
||||||
|
base = {
|
||||||
|
"group_id": group_id,
|
||||||
|
"geometry_role": layer.get("type"),
|
||||||
|
"component_role": role,
|
||||||
|
"style_index": index,
|
||||||
|
"style_layer_id": layer.get("id"),
|
||||||
|
"filter": layer.get("filter"),
|
||||||
|
"resource_expression": expr,
|
||||||
|
"dynamic_fields": dynamic_fields,
|
||||||
|
"provenance": {
|
||||||
|
"source": layer.get("source"),
|
||||||
|
"source_layer": source_layer,
|
||||||
|
"style_layer_id": layer.get("id"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for resource_key in candidates:
|
||||||
|
meta = sprite_meta[resource_key]
|
||||||
|
obj = dict(base)
|
||||||
|
obj["resource_key"] = resource_key
|
||||||
|
obj["resource_meta"] = meta
|
||||||
|
obj["visual_hash"] = sprite_visual_hash(sprite_image, meta) if sprite_image else None
|
||||||
|
signature = {
|
||||||
|
"geometry_role": obj["geometry_role"],
|
||||||
|
"component_role": role,
|
||||||
|
"visual_hash": obj["visual_hash"],
|
||||||
|
"style_index": index,
|
||||||
|
}
|
||||||
|
obj["visual_object_id"] = sid("VO", signature)
|
||||||
|
objects.append(obj)
|
||||||
|
if not candidates:
|
||||||
|
item = dict(base)
|
||||||
|
item["reason"] = "dynamic_or_unresolved_resource"
|
||||||
|
item["visual_object_id"] = sid("VO", {
|
||||||
|
"geometry_role": item["geometry_role"],
|
||||||
|
"component_role": role,
|
||||||
|
"expression": expr,
|
||||||
|
"style_index": index,
|
||||||
|
})
|
||||||
|
unresolved.append(item)
|
||||||
|
|
||||||
|
layer_type = layer.get("type")
|
||||||
|
paint = layer.get("paint") or {}
|
||||||
|
vector_payload = {k: paint[k] for k in VECTOR_PROPS.get(layer_type, ()) if k in paint}
|
||||||
|
text_expr = (layer.get("layout") or {}).get("text-field")
|
||||||
|
if vector_payload or text_expr is not None or not found_resource:
|
||||||
|
payload = {
|
||||||
|
"group_id": group_id,
|
||||||
|
"geometry_role": layer_type,
|
||||||
|
"component_role": "vector-or-text",
|
||||||
|
"style_index": index,
|
||||||
|
"style_layer_id": layer.get("id"),
|
||||||
|
"filter": layer.get("filter"),
|
||||||
|
"visual_properties": vector_payload,
|
||||||
|
"text_expression": text_expr,
|
||||||
|
"provenance": {
|
||||||
|
"source": layer.get("source"),
|
||||||
|
"source_layer": source_layer,
|
||||||
|
"style_layer_id": layer.get("id"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload["visual_object_id"] = sid("VO", {
|
||||||
|
"geometry_role": layer_type,
|
||||||
|
"component_role": "vector-or-text",
|
||||||
|
"visual_properties": vector_payload,
|
||||||
|
"text_expression": text_expr,
|
||||||
|
"style_index": index,
|
||||||
|
})
|
||||||
|
objects.append(payload)
|
||||||
|
|
||||||
|
catalog = {
|
||||||
|
"schema": "style-visual-object/v1",
|
||||||
|
"catalog_name": catalog_name,
|
||||||
|
"principle": "Visual Object extraction is provider-neutral; provenance names are not semantic classification.",
|
||||||
|
"style": {
|
||||||
|
"name": style.get("name"),
|
||||||
|
"version": style.get("version"),
|
||||||
|
"sha256": hashlib.sha256(style_bytes).hexdigest(),
|
||||||
|
"source": style_path,
|
||||||
|
"sprite": style.get("sprite"),
|
||||||
|
"glyphs": style.get("glyphs"),
|
||||||
|
"layer_count": len(style.get("layers", [])),
|
||||||
|
},
|
||||||
|
"sprite": {
|
||||||
|
"resource_count": len(sprite_meta),
|
||||||
|
},
|
||||||
|
"groups": list(groups.values()),
|
||||||
|
"visual_objects": objects,
|
||||||
|
"unresolved_visual_objects": unresolved,
|
||||||
|
"summary": {
|
||||||
|
"group_count": len(groups),
|
||||||
|
"visual_object_count": len(objects),
|
||||||
|
"unresolved_count": len(unresolved),
|
||||||
|
"sprite_backed_count": sum(1 for x in objects if x.get("resource_key")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(output / "anonymous-catalog.json").write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_standard(anonymous_path: Path, output: Path) -> dict[str, Any]:
|
||||||
|
catalog = json.loads(anonymous_path.read_text(encoding="utf-8"))
|
||||||
|
standard = json.loads(json.dumps(catalog))
|
||||||
|
standard["schema"] = "style-visual-object-standard/v1"
|
||||||
|
standard["catalog_status"] = "FROZEN"
|
||||||
|
standard["standard_rule"] = "Layer name is attached only after anonymous Visual Object generation."
|
||||||
|
for obj in standard["visual_objects"]:
|
||||||
|
obj["standard_layer_name"] = obj.get("provenance", {}).get("source_layer")
|
||||||
|
for obj in standard["unresolved_visual_objects"]:
|
||||||
|
obj["standard_layer_name"] = obj.get("provenance", {}).get("source_layer")
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
(output / "standard-catalog.json").write_text(json.dumps(standard, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return standard
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
p = sub.add_parser("extract")
|
||||||
|
p.add_argument("--style", required=True)
|
||||||
|
p.add_argument("--sprite")
|
||||||
|
p.add_argument("--output", required=True)
|
||||||
|
p.add_argument("--name", required=True)
|
||||||
|
f = sub.add_parser("freeze-standard")
|
||||||
|
f.add_argument("--anonymous", required=True)
|
||||||
|
f.add_argument("--output", required=True)
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.cmd == "extract":
|
||||||
|
result = extract(args.style, args.sprite, Path(args.output), args.name)
|
||||||
|
else:
|
||||||
|
result = freeze_standard(Path(args.anonymous), Path(args.output))
|
||||||
|
print(json.dumps(result["summary"], ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
426
tools/style-visual-object/validate_cm95_classification.py
Normal file
426
tools/style-visual-object/validate_cm95_classification.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CM95 → NavSea Layer 分类基线一致性校验。
|
||||||
|
|
||||||
|
只读取已冻结/已完成人工确认的产物,不修改分类结果。
|
||||||
|
失败时退出码为 1;全部硬校验通过时退出码为 0。
|
||||||
|
语义建议与人工结果冲突仅记 warning,因为人工结果是最终权威。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
OUTPUT = ROOT / "output"
|
||||||
|
|
||||||
|
DEFAULT_PATHS = {
|
||||||
|
"cm95_catalog": OUTPUT / "cm95-anonymous" / "anonymous-catalog.json",
|
||||||
|
"standard_catalog": OUTPUT / "navsea-standard" / "standard-catalog.json",
|
||||||
|
"manual_review": OUTPUT / "cm95-manual-review" / "manual-review-v1.json",
|
||||||
|
"manual_anchors": OUTPUT / "cm95-manual-review" / "reviewed-anchors-manual-v1.json",
|
||||||
|
"assignment": OUTPUT / "cm95-manual-review" / "assignment" / "cm95-layer-assignment-v1.json",
|
||||||
|
"reference_names": OUTPUT / "reference-name-enrichment-v1.json",
|
||||||
|
"semantic_rules": ROOT / "cm95_semantic_layer_rules_v1.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_STATUS = {"MATCH", "PARTIAL", "AMBIGUOUS", "COMPONENT", "UNKNOWN"}
|
||||||
|
|
||||||
|
|
||||||
|
class Validation:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.checks: list[dict[str, Any]] = []
|
||||||
|
self.warnings: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def check(self, name: str, condition: bool, detail: Any = None) -> None:
|
||||||
|
row = {"name": name, "status": "PASS" if condition else "FAIL"}
|
||||||
|
if detail is not None:
|
||||||
|
row["detail"] = detail
|
||||||
|
self.checks.append(row)
|
||||||
|
|
||||||
|
def warn(self, name: str, detail: Any) -> None:
|
||||||
|
self.warnings.append({"name": name, "status": "WARN", "detail": detail})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return all(x["status"] == "PASS" for x in self.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict[str, Any]:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def anonymous_resource_id(resource_key: str, visual_hash: str | None) -> str:
|
||||||
|
seed = f"{resource_key}|{visual_hash or ''}".encode("utf-8")
|
||||||
|
return "CM95-VO-" + hashlib.sha256(seed).hexdigest()[:10].upper()
|
||||||
|
|
||||||
|
|
||||||
|
def build_resource_index(cm95: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
|
||||||
|
by_key: dict[str, dict[str, Any]] = {}
|
||||||
|
for obj in cm95.get("visual_objects", []):
|
||||||
|
key = obj.get("resource_key")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
row = by_key.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"resource_key": key,
|
||||||
|
"visual_hash": obj.get("visual_hash"),
|
||||||
|
"occurrences": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row["occurrences"] += 1
|
||||||
|
if not row.get("visual_hash") and obj.get("visual_hash"):
|
||||||
|
row["visual_hash"] = obj.get("visual_hash")
|
||||||
|
by_id = {
|
||||||
|
anonymous_resource_id(key, row.get("visual_hash")): key
|
||||||
|
for key, row in by_key.items()
|
||||||
|
}
|
||||||
|
return by_key, by_id
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument(
|
||||||
|
"--report",
|
||||||
|
default=str(OUTPUT / "cm95-manual-review" / "validation-report-v1.json"),
|
||||||
|
help="校验报告输出路径",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
v = Validation()
|
||||||
|
|
||||||
|
missing = [str(p) for p in DEFAULT_PATHS.values() if not p.exists()]
|
||||||
|
v.check("required_files_exist", not missing, {"missing": missing})
|
||||||
|
if missing:
|
||||||
|
report = {
|
||||||
|
"schema": "cm95-layer-classification-validation/v1",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"ok": False,
|
||||||
|
"checks": v.checks,
|
||||||
|
"warnings": v.warnings,
|
||||||
|
}
|
||||||
|
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
cm95 = load(DEFAULT_PATHS["cm95_catalog"])
|
||||||
|
std = load(DEFAULT_PATHS["standard_catalog"])
|
||||||
|
manual = load(DEFAULT_PATHS["manual_review"])
|
||||||
|
anchors = load(DEFAULT_PATHS["manual_anchors"])
|
||||||
|
assignment = load(DEFAULT_PATHS["assignment"])
|
||||||
|
names = load(DEFAULT_PATHS["reference_names"])
|
||||||
|
semantic_rules = load(DEFAULT_PATHS["semantic_rules"])
|
||||||
|
|
||||||
|
cm_resources, anon_to_key = build_resource_index(cm95)
|
||||||
|
resource_keys = set(cm_resources)
|
||||||
|
decisions = manual.get("decisions", {})
|
||||||
|
decision_ids = set(decisions)
|
||||||
|
|
||||||
|
std_layers = {
|
||||||
|
str(o["standard_layer_name"])
|
||||||
|
for o in std.get("visual_objects", [])
|
||||||
|
if o.get("standard_layer_name")
|
||||||
|
}
|
||||||
|
std_resource_layers: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for o in std.get("visual_objects", []):
|
||||||
|
key = o.get("resource_key")
|
||||||
|
layer = o.get("standard_layer_name")
|
||||||
|
if key and layer:
|
||||||
|
std_resource_layers[str(key)].add(str(layer))
|
||||||
|
|
||||||
|
v.check("standard_catalog_frozen", std.get("catalog_status") == "FROZEN", std.get("catalog_status"))
|
||||||
|
v.check(
|
||||||
|
"cm95_anonymous_catalog_has_no_standard_layer",
|
||||||
|
not any("standard_layer_name" in o for o in cm95.get("visual_objects", [])),
|
||||||
|
)
|
||||||
|
v.check("cm95_unique_visual_resources_150", len(resource_keys) == 150, len(resource_keys))
|
||||||
|
v.check("manual_decisions_150", len(decisions) == 150, len(decisions))
|
||||||
|
v.check("manual_covers_all_resources", decision_ids == set(anon_to_key), {
|
||||||
|
"missing": sorted(set(anon_to_key) - decision_ids),
|
||||||
|
"extra": sorted(decision_ids - set(anon_to_key)),
|
||||||
|
})
|
||||||
|
v.check(
|
||||||
|
"style_hash_cm95_matches",
|
||||||
|
manual.get("cm95_style_sha256") == cm95.get("style", {}).get("sha256"),
|
||||||
|
{
|
||||||
|
"manual": manual.get("cm95_style_sha256"),
|
||||||
|
"catalog": cm95.get("style", {}).get("sha256"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
v.check(
|
||||||
|
"style_hash_standard_matches",
|
||||||
|
manual.get("standard_style_sha256") == std.get("style", {}).get("sha256"),
|
||||||
|
{
|
||||||
|
"manual": manual.get("standard_style_sha256"),
|
||||||
|
"catalog": std.get("style", {}).get("sha256"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
status_counts = Counter()
|
||||||
|
bad_decisions: list[dict[str, Any]] = []
|
||||||
|
for anon_id, d in decisions.items():
|
||||||
|
status = d.get("status")
|
||||||
|
status_counts[status] += 1
|
||||||
|
expected_key = anon_to_key.get(anon_id)
|
||||||
|
layers = [str(x) for x in d.get("standard_layers", [])]
|
||||||
|
std_key = d.get("standard_resource_key")
|
||||||
|
mode = d.get("assignment_mode")
|
||||||
|
component_kind = d.get("component_kind")
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if status not in VALID_STATUS:
|
||||||
|
reasons.append("invalid_status")
|
||||||
|
if d.get("resource_key_internal") != expected_key:
|
||||||
|
reasons.append("resource_key_mismatch")
|
||||||
|
if any(layer not in std_layers for layer in layers):
|
||||||
|
reasons.append("unknown_standard_layer")
|
||||||
|
if std_key and std_key not in std_resource_layers:
|
||||||
|
reasons.append("unknown_standard_resource")
|
||||||
|
|
||||||
|
if status in {"MATCH", "PARTIAL"}:
|
||||||
|
if len(layers) != 1:
|
||||||
|
reasons.append("match_partial_requires_one_layer")
|
||||||
|
if mode not in {"VISUAL_OBJECT_MATCH", "LAYER_ONLY"}:
|
||||||
|
reasons.append("invalid_match_partial_mode")
|
||||||
|
if std_key and layers and layers[0] not in std_resource_layers.get(std_key, set()):
|
||||||
|
reasons.append("standard_resource_layer_mismatch")
|
||||||
|
elif status == "AMBIGUOUS":
|
||||||
|
if len(layers) < 2:
|
||||||
|
reasons.append("ambiguous_requires_two_layers")
|
||||||
|
if component_kind:
|
||||||
|
reasons.append("ambiguous_has_component_kind")
|
||||||
|
elif status == "COMPONENT":
|
||||||
|
if layers or std_key is not None or mode != "COMPONENT_ONLY":
|
||||||
|
reasons.append("component_must_have_no_layer_or_standard_resource")
|
||||||
|
elif status == "UNKNOWN":
|
||||||
|
if layers or std_key is not None or mode != "UNKNOWN":
|
||||||
|
reasons.append("unknown_must_have_no_layer_or_standard_resource")
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
bad_decisions.append({"anonymous_id": anon_id, "reasons": reasons, "decision": d})
|
||||||
|
|
||||||
|
v.check("manual_decision_state_constraints", not bad_decisions, bad_decisions[:20])
|
||||||
|
v.check(
|
||||||
|
"manual_status_counts_total_150",
|
||||||
|
sum(status_counts.values()) == 150,
|
||||||
|
dict(status_counts),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 人工导出 anchors:UNKNOWN 不导出;其余必须一一存在。
|
||||||
|
anchor_by_key = {
|
||||||
|
str(a["cm95_resource_key"]): a
|
||||||
|
for a in anchors.get("anchors", [])
|
||||||
|
if a.get("cm95_resource_key")
|
||||||
|
}
|
||||||
|
expected_anchor_keys = {
|
||||||
|
d["resource_key_internal"]
|
||||||
|
for d in decisions.values()
|
||||||
|
if d.get("status") != "UNKNOWN"
|
||||||
|
}
|
||||||
|
v.check(
|
||||||
|
"manual_anchor_export_matches_non_unknown_decisions",
|
||||||
|
set(anchor_by_key) == expected_anchor_keys,
|
||||||
|
{
|
||||||
|
"expected": len(expected_anchor_keys),
|
||||||
|
"actual": len(anchor_by_key),
|
||||||
|
"missing": sorted(expected_anchor_keys - set(anchor_by_key)),
|
||||||
|
"extra": sorted(set(anchor_by_key) - expected_anchor_keys),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
anchor_mismatches: list[dict[str, Any]] = []
|
||||||
|
decision_by_key = {d["resource_key_internal"]: d for d in decisions.values()}
|
||||||
|
for key, a in anchor_by_key.items():
|
||||||
|
d = decision_by_key[key]
|
||||||
|
if a.get("status") != d.get("status"):
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "status", "decision": d.get("status"), "anchor": a.get("status")})
|
||||||
|
if a.get("standard_resource_key") != d.get("standard_resource_key"):
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "standard_resource_key"})
|
||||||
|
if a.get("assignment_mode") != d.get("assignment_mode"):
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "assignment_mode"})
|
||||||
|
if d.get("status") in {"MATCH", "PARTIAL"}:
|
||||||
|
expected_layer = (d.get("standard_layers") or [None])[0]
|
||||||
|
if a.get("standard_layer") != expected_layer:
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "standard_layer"})
|
||||||
|
elif d.get("status") == "AMBIGUOUS":
|
||||||
|
if sorted(a.get("standard_layers", [])) != sorted(d.get("standard_layers", [])):
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "standard_layers"})
|
||||||
|
elif d.get("status") == "COMPONENT":
|
||||||
|
if a.get("component_kind") != d.get("component_kind"):
|
||||||
|
anchor_mismatches.append({"resource": key, "field": "component_kind"})
|
||||||
|
v.check("manual_anchor_fields_match_decisions", not anchor_mismatches, anchor_mismatches[:20])
|
||||||
|
|
||||||
|
# Reference names 必须覆盖全部当前 CM95 资源。
|
||||||
|
name_keys = set(names.get("cm95", {}))
|
||||||
|
v.check(
|
||||||
|
"reference_names_cover_all_cm95_resources",
|
||||||
|
name_keys == resource_keys,
|
||||||
|
{
|
||||||
|
"missing": sorted(resource_keys - name_keys),
|
||||||
|
"extra": sorted(name_keys - resource_keys),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 声明式语义规则:正则可编译,Layer 必须存在于 Standard Catalog。
|
||||||
|
rule_errors: list[dict[str, Any]] = []
|
||||||
|
for rule in semantic_rules.get("rules", []):
|
||||||
|
try:
|
||||||
|
re.compile(str(rule.get("pattern") or ""), re.IGNORECASE)
|
||||||
|
except re.error as exc:
|
||||||
|
rule_errors.append({"id": rule.get("id"), "error": f"regex: {exc}"})
|
||||||
|
for item in rule.get("layer_candidates", []):
|
||||||
|
if not isinstance(item, list) or len(item) < 2:
|
||||||
|
rule_errors.append({"id": rule.get("id"), "error": "invalid layer_candidates shape"})
|
||||||
|
continue
|
||||||
|
if str(item[0]) not in std_layers:
|
||||||
|
rule_errors.append({"id": rule.get("id"), "error": f"unknown standard layer: {item[0]}"})
|
||||||
|
v.check("semantic_rules_are_valid", not rule_errors, rule_errors)
|
||||||
|
|
||||||
|
# 最终 Assignment 的状态/Layer 必须逐 occurrence 服从人工资源级决定。
|
||||||
|
assignments = assignment.get("assignments", [])
|
||||||
|
expected_total = len(cm95.get("visual_objects", [])) + len(cm95.get("unresolved_visual_objects", []))
|
||||||
|
v.check("assignment_total_count", len(assignments) == expected_total, {
|
||||||
|
"expected": expected_total,
|
||||||
|
"actual": len(assignments),
|
||||||
|
})
|
||||||
|
|
||||||
|
assignment_errors: list[dict[str, Any]] = []
|
||||||
|
assignment_status_counts = Counter()
|
||||||
|
for row in assignments:
|
||||||
|
status = row.get("assignment_status")
|
||||||
|
assignment_status_counts[status] += 1
|
||||||
|
key = row.get("cm95_resource_key")
|
||||||
|
if key:
|
||||||
|
d = decision_by_key.get(key)
|
||||||
|
if not d:
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "resource_without_manual_decision"})
|
||||||
|
continue
|
||||||
|
expected_status = d.get("status")
|
||||||
|
if status != expected_status:
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "status_mismatch", "expected": expected_status, "actual": status})
|
||||||
|
continue
|
||||||
|
if status in {"MATCH", "PARTIAL"}:
|
||||||
|
expected_layer = (d.get("standard_layers") or [None])[0]
|
||||||
|
if row.get("assigned_standard_layer") != expected_layer:
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "layer_mismatch"})
|
||||||
|
elif status == "AMBIGUOUS":
|
||||||
|
if sorted(row.get("candidate_standard_layers", [])) != sorted(d.get("standard_layers", [])):
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "ambiguous_layers_mismatch"})
|
||||||
|
elif status == "COMPONENT":
|
||||||
|
if row.get("assigned_standard_layer") is not None or row.get("component_kind") != d.get("component_kind"):
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "component_assignment_mismatch"})
|
||||||
|
elif status == "UNKNOWN":
|
||||||
|
if row.get("assigned_standard_layer") is not None:
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "unknown_has_layer"})
|
||||||
|
else:
|
||||||
|
# Style-only 未展开动态对象没有对应具体资源,必须保持 UNKNOWN。
|
||||||
|
if status != "UNKNOWN":
|
||||||
|
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "unresolved_dynamic_not_unknown"})
|
||||||
|
|
||||||
|
v.check("final_assignment_matches_manual_decisions", not assignment_errors, assignment_errors[:20])
|
||||||
|
|
||||||
|
summary = assignment.get("summary", {})
|
||||||
|
recomputed = {
|
||||||
|
"MATCH": assignment_status_counts["MATCH"],
|
||||||
|
"PARTIAL": assignment_status_counts["PARTIAL"],
|
||||||
|
"AMBIGUOUS": assignment_status_counts["AMBIGUOUS"],
|
||||||
|
"COMPONENT": assignment_status_counts["COMPONENT"],
|
||||||
|
"UNKNOWN": assignment_status_counts["UNKNOWN"],
|
||||||
|
}
|
||||||
|
summary_expected = {
|
||||||
|
"MATCH": summary.get("match_visual_objects"),
|
||||||
|
"PARTIAL": summary.get("partial_visual_objects"),
|
||||||
|
"AMBIGUOUS": summary.get("ambiguous_visual_objects"),
|
||||||
|
"COMPONENT": summary.get("component_visual_objects"),
|
||||||
|
"UNKNOWN": summary.get("unknown_visual_objects"),
|
||||||
|
}
|
||||||
|
v.check("assignment_summary_counts_match_rows", recomputed == summary_expected, {
|
||||||
|
"recomputed": recomputed,
|
||||||
|
"summary": summary_expected,
|
||||||
|
})
|
||||||
|
v.check(
|
||||||
|
"assignment_policy_preserves_architecture",
|
||||||
|
assignment.get("policy", {}).get("cm95_source_layer_used_for_assignment") is False
|
||||||
|
and assignment.get("policy", {}).get("provider_field_semantics_used_for_assignment") is False
|
||||||
|
and assignment.get("policy", {}).get("anonymous_catalog_modified") is False,
|
||||||
|
assignment.get("policy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 新语义分类器与历史人工结果的冲突只作 warning。
|
||||||
|
# 这里复现最小规则匹配,只比较高置信(>=0.72)首选 Layer。
|
||||||
|
high_conflicts: list[dict[str, Any]] = []
|
||||||
|
cm_name_map = names.get("cm95", {})
|
||||||
|
rules = semantic_rules.get("rules", [])
|
||||||
|
for d in decisions.values():
|
||||||
|
if d.get("status") not in {"MATCH", "PARTIAL"}:
|
||||||
|
continue
|
||||||
|
selected = d.get("standard_layers") or []
|
||||||
|
if len(selected) != 1:
|
||||||
|
continue
|
||||||
|
key = d["resource_key_internal"]
|
||||||
|
info = cm_name_map.get(key, {})
|
||||||
|
desc = str(info.get("official_description_en") or "")
|
||||||
|
rtype = str(info.get("resource_type") or "")
|
||||||
|
top: tuple[str, float] | None = None
|
||||||
|
for rule in rules:
|
||||||
|
if rule.get("resource_type") and rule.get("resource_type") != rtype:
|
||||||
|
continue
|
||||||
|
if not re.search(str(rule.get("pattern") or ""), desc, flags=re.IGNORECASE):
|
||||||
|
continue
|
||||||
|
candidates = rule.get("layer_candidates", [])
|
||||||
|
if candidates:
|
||||||
|
top = (str(candidates[0][0]), float(candidates[0][1]))
|
||||||
|
break
|
||||||
|
if top and top[1] >= 0.72 and top[0] != selected[0]:
|
||||||
|
high_conflicts.append({
|
||||||
|
"anonymous_id": d.get("anonymous_id"),
|
||||||
|
"resource_key": key,
|
||||||
|
"display_name_zh": info.get("display_name_zh"),
|
||||||
|
"manual_layer": selected[0],
|
||||||
|
"semantic_suggestion": top[0],
|
||||||
|
"semantic_score": top[1],
|
||||||
|
})
|
||||||
|
if high_conflicts:
|
||||||
|
v.warn("manual_vs_semantic_classifier_conflicts", high_conflicts)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"schema": "cm95-layer-classification-validation/v1",
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"ok": v.ok,
|
||||||
|
"baseline": {
|
||||||
|
"cm95_style_sha256": cm95.get("style", {}).get("sha256"),
|
||||||
|
"standard_style_sha256": std.get("style", {}).get("sha256"),
|
||||||
|
"standard_catalog_status": std.get("catalog_status"),
|
||||||
|
"unique_cm95_resources": len(resource_keys),
|
||||||
|
"manual_status_counts": dict(sorted(status_counts.items())),
|
||||||
|
"final_assignment_status_counts": recomputed,
|
||||||
|
"final_assignment_total": len(assignments),
|
||||||
|
},
|
||||||
|
"checks": v.checks,
|
||||||
|
"warnings": v.warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
report_path = Path(args.report)
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
print("CM95 → NavSea Layer 分类基线校验")
|
||||||
|
for row in v.checks:
|
||||||
|
print(f"[{row['status']}] {row['name']}")
|
||||||
|
for row in v.warnings:
|
||||||
|
print(f"[WARN] {row['name']}: {json.dumps(row['detail'], ensure_ascii=False)}")
|
||||||
|
print(f"report: {report_path}")
|
||||||
|
print("RESULT:", "PASS" if v.ok else "FAIL")
|
||||||
|
return 0 if v.ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
311
tools/style-visual-object/visual_anchor_matcher.py
Normal file
311
tools/style-visual-object/visual_anchor_matcher.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path) -> dict[str, Any]:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def crop_sprite(atlas: Image.Image, meta: dict[str, Any]) -> Image.Image:
|
||||||
|
crop = atlas.crop((
|
||||||
|
int(meta["x"]), int(meta["y"]),
|
||||||
|
int(meta["x"]) + int(meta["width"]),
|
||||||
|
int(meta["y"]) + int(meta["height"]),
|
||||||
|
)).convert("RGBA")
|
||||||
|
alpha = np.asarray(crop.getchannel("A"))
|
||||||
|
ys, xs = np.where(alpha > 8)
|
||||||
|
if len(xs):
|
||||||
|
crop = crop.crop((int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
|
||||||
|
return crop
|
||||||
|
|
||||||
|
|
||||||
|
def resize_center(crop: Image.Image, size: int = 64, pad: int = 5) -> Image.Image:
|
||||||
|
w, h = crop.size
|
||||||
|
target = size - pad * 2
|
||||||
|
scale = min(target / max(w, 1), target / max(h, 1))
|
||||||
|
nw, nh = max(1, round(w * scale)), max(1, round(h * scale))
|
||||||
|
crop = crop.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||||
|
canvas = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||||
|
canvas.alpha_composite(crop, ((size - nw) // 2, (size - nh) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def dct_matrix(n: int) -> np.ndarray:
|
||||||
|
mat = np.zeros((n, n), dtype=np.float32)
|
||||||
|
factor = math.pi / n
|
||||||
|
for k in range(n):
|
||||||
|
scale = math.sqrt(1 / n) if k == 0 else math.sqrt(2 / n)
|
||||||
|
for i in range(n):
|
||||||
|
mat[k, i] = scale * math.cos((i + 0.5) * k * factor)
|
||||||
|
return mat
|
||||||
|
|
||||||
|
|
||||||
|
DCT32 = dct_matrix(32)
|
||||||
|
|
||||||
|
|
||||||
|
def phash(gray32: np.ndarray) -> np.ndarray:
|
||||||
|
coeff = DCT32 @ gray32 @ DCT32.T
|
||||||
|
low = coeff[:8, :8].copy()
|
||||||
|
vals = low.ravel()[1:]
|
||||||
|
med = float(np.median(vals))
|
||||||
|
return (low.ravel()[1:] > med).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def color_hist(rgb: np.ndarray, alpha: np.ndarray, bins: int = 4) -> np.ndarray:
|
||||||
|
mask = alpha > 0.08
|
||||||
|
if not np.any(mask):
|
||||||
|
return np.zeros(bins ** 3, dtype=np.float32)
|
||||||
|
pix = np.clip(rgb[mask], 0, 0.999999)
|
||||||
|
q = (pix * bins).astype(np.int32)
|
||||||
|
idx = q[:, 0] * bins * bins + q[:, 1] * bins + q[:, 2]
|
||||||
|
hist = np.bincount(idx, minlength=bins ** 3).astype(np.float32)
|
||||||
|
s = hist.sum()
|
||||||
|
return hist / s if s else hist
|
||||||
|
|
||||||
|
|
||||||
|
def descriptor(crop: Image.Image) -> dict[str, Any]:
|
||||||
|
original_w, original_h = crop.size
|
||||||
|
canvas = resize_center(crop, 64, 5)
|
||||||
|
arr = np.asarray(canvas, dtype=np.float32) / 255.0
|
||||||
|
alpha = arr[:, :, 3]
|
||||||
|
rgb = arr[:, :, :3]
|
||||||
|
composited = rgb * alpha[:, :, None] + (1.0 - alpha[:, :, None])
|
||||||
|
gray_img = Image.fromarray(np.uint8(np.clip(
|
||||||
|
(0.299 * composited[:, :, 0] + 0.587 * composited[:, :, 1] + 0.114 * composited[:, :, 2]) * 255,
|
||||||
|
0, 255
|
||||||
|
)))
|
||||||
|
gray32 = np.asarray(gray_img.resize((32, 32), Image.Resampling.LANCZOS), dtype=np.float32) / 255.0
|
||||||
|
edge = np.asarray(gray_img.filter(ImageFilter.FIND_EDGES), dtype=np.float32) / 255.0
|
||||||
|
|
||||||
|
mask = alpha > 0.08
|
||||||
|
xproj = mask.sum(axis=0).astype(np.float32)
|
||||||
|
yproj = mask.sum(axis=1).astype(np.float32)
|
||||||
|
if xproj.sum():
|
||||||
|
xproj /= xproj.sum()
|
||||||
|
if yproj.sum():
|
||||||
|
yproj /= yproj.sum()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alpha": alpha,
|
||||||
|
"gray": gray32,
|
||||||
|
"edge": edge,
|
||||||
|
"phash": phash(gray32),
|
||||||
|
"hist": color_hist(rgb, alpha),
|
||||||
|
"xproj": xproj,
|
||||||
|
"yproj": yproj,
|
||||||
|
"aspect": original_w / max(1, original_h),
|
||||||
|
"occupancy": float(mask.mean()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
||||||
|
denom = float(np.linalg.norm(a) * np.linalg.norm(b))
|
||||||
|
if denom == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.dot(a.ravel(), b.ravel()) / denom)
|
||||||
|
|
||||||
|
|
||||||
|
def similarity(a: dict[str, Any], b: dict[str, Any]) -> float:
|
||||||
|
alpha = cosine(a["alpha"], b["alpha"])
|
||||||
|
edge = cosine(a["edge"], b["edge"])
|
||||||
|
gray = 1.0 - float(np.mean((a["gray"] - b["gray"]) ** 2))
|
||||||
|
hamming = float(np.mean(a["phash"] != b["phash"]))
|
||||||
|
ph = 1.0 - hamming
|
||||||
|
hist = cosine(a["hist"], b["hist"])
|
||||||
|
proj = 0.5 * (cosine(a["xproj"], b["xproj"]) + cosine(a["yproj"], b["yproj"]))
|
||||||
|
ratio = min(a["aspect"], b["aspect"]) / max(a["aspect"], b["aspect"])
|
||||||
|
occ = 1.0 - min(1.0, abs(a["occupancy"] - b["occupancy"]) / max(a["occupancy"], b["occupancy"], 0.02))
|
||||||
|
return (
|
||||||
|
0.24 * alpha +
|
||||||
|
0.17 * edge +
|
||||||
|
0.16 * gray +
|
||||||
|
0.14 * ph +
|
||||||
|
0.12 * hist +
|
||||||
|
0.08 * proj +
|
||||||
|
0.05 * ratio +
|
||||||
|
0.04 * occ
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_records(catalog: dict[str, Any], standard: bool) -> list[dict[str, Any]]:
|
||||||
|
seen: set[tuple[Any, ...]] = set()
|
||||||
|
out = []
|
||||||
|
for obj in catalog.get("visual_objects", []):
|
||||||
|
if not obj.get("resource_key") or not obj.get("resource_meta"):
|
||||||
|
continue
|
||||||
|
layer = obj.get("standard_layer_name") if standard else None
|
||||||
|
key = (
|
||||||
|
obj.get("component_role"),
|
||||||
|
obj.get("resource_key"),
|
||||||
|
layer,
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({
|
||||||
|
"resource_key": obj["resource_key"],
|
||||||
|
"resource_meta": obj["resource_meta"],
|
||||||
|
"component_role": obj.get("component_role"),
|
||||||
|
"standard_layer_name": layer,
|
||||||
|
"visual_object_id": obj.get("visual_object_id"),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_desc(records: list[dict[str, Any]], atlas: Image.Image) -> list[dict[str, Any]]:
|
||||||
|
return [descriptor(crop_sprite(atlas, r["resource_meta"])) for r in records]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--standard", required=True)
|
||||||
|
ap.add_argument("--cm95", required=True)
|
||||||
|
ap.add_argument("--output", required=True)
|
||||||
|
ap.add_argument("--min-score", type=float, default=0.84)
|
||||||
|
ap.add_argument("--min-margin", type=float, default=0.025)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
std_path = Path(args.standard)
|
||||||
|
cm_path = Path(args.cm95)
|
||||||
|
out = Path(args.output)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
std_cat = load_json(std_path)
|
||||||
|
cm_cat = load_json(cm_path)
|
||||||
|
std_dir = std_path.parent.parent / "navsea-anonymous"
|
||||||
|
cm_dir = cm_path.parent
|
||||||
|
std_atlas = Image.open(std_dir / "_sprite" / "sprite.png").convert("RGBA")
|
||||||
|
cm_atlas = Image.open(cm_dir / "_sprite" / "sprite.png").convert("RGBA")
|
||||||
|
|
||||||
|
std = resource_records(std_cat, True)
|
||||||
|
cm = resource_records(cm_cat, False)
|
||||||
|
std_d = build_desc(std, std_atlas)
|
||||||
|
cm_d = build_desc(cm, cm_atlas)
|
||||||
|
|
||||||
|
std_by_role: dict[str, list[int]] = defaultdict(list)
|
||||||
|
cm_by_role: dict[str, list[int]] = defaultdict(list)
|
||||||
|
for i, r in enumerate(std):
|
||||||
|
std_by_role[r["component_role"]].append(i)
|
||||||
|
for i, r in enumerate(cm):
|
||||||
|
cm_by_role[r["component_role"]].append(i)
|
||||||
|
|
||||||
|
cm_rankings: dict[int, list[tuple[float, int]]] = {}
|
||||||
|
std_best: dict[int, tuple[float, int]] = {}
|
||||||
|
|
||||||
|
for role, cm_indices in cm_by_role.items():
|
||||||
|
std_indices = std_by_role.get(role, [])
|
||||||
|
if not std_indices:
|
||||||
|
continue
|
||||||
|
for ci in cm_indices:
|
||||||
|
vals = sorted(
|
||||||
|
((similarity(cm_d[ci], std_d[si]), si) for si in std_indices),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
cm_rankings[ci] = vals[:5]
|
||||||
|
for si in std_indices:
|
||||||
|
vals = max(
|
||||||
|
((similarity(std_d[si], cm_d[ci]), ci) for ci in cm_indices),
|
||||||
|
default=(0.0, -1),
|
||||||
|
)
|
||||||
|
std_best[si] = vals
|
||||||
|
|
||||||
|
anchors = []
|
||||||
|
candidates = []
|
||||||
|
for ci, ranked in cm_rankings.items():
|
||||||
|
if not ranked:
|
||||||
|
continue
|
||||||
|
best_score, si = ranked[0]
|
||||||
|
second_score = ranked[1][0] if len(ranked) > 1 else 0.0
|
||||||
|
mutual = std_best.get(si, (0.0, -1))[1] == ci
|
||||||
|
margin = best_score - second_score
|
||||||
|
row = {
|
||||||
|
"cm95_resource_key": cm[ci]["resource_key"],
|
||||||
|
"component_role": cm[ci]["component_role"],
|
||||||
|
"best_standard_resource_key": std[si]["resource_key"],
|
||||||
|
"best_standard_layer": std[si]["standard_layer_name"],
|
||||||
|
"score": best_score,
|
||||||
|
"margin": margin,
|
||||||
|
"mutual_nearest": mutual,
|
||||||
|
"standard_visual_object_id": std[si]["visual_object_id"],
|
||||||
|
"alternatives": [
|
||||||
|
{
|
||||||
|
"standard_resource_key": std[sj]["resource_key"],
|
||||||
|
"standard_layer": std[sj]["standard_layer_name"],
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
for score, sj in ranked[1:4]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
candidates.append(row)
|
||||||
|
if mutual and best_score >= args.min_score and margin >= args.min_margin:
|
||||||
|
row = dict(row)
|
||||||
|
row["status"] = "MATCH"
|
||||||
|
anchors.append(row)
|
||||||
|
|
||||||
|
# Map accepted visual anchors back to CM95 anonymous Visual Object occurrences.
|
||||||
|
anchor_by_key_role = {
|
||||||
|
(x["cm95_resource_key"], x["component_role"]): x for x in anchors
|
||||||
|
}
|
||||||
|
occurrences = []
|
||||||
|
for obj in cm_cat.get("visual_objects", []):
|
||||||
|
k = (obj.get("resource_key"), obj.get("component_role"))
|
||||||
|
anchor = anchor_by_key_role.get(k)
|
||||||
|
if not anchor:
|
||||||
|
continue
|
||||||
|
occurrences.append({
|
||||||
|
"visual_object_id": obj["visual_object_id"],
|
||||||
|
"group_id": obj["group_id"],
|
||||||
|
"cm95_resource_key": obj["resource_key"],
|
||||||
|
"assigned_standard_layer": anchor["best_standard_layer"],
|
||||||
|
"score": anchor["score"],
|
||||||
|
"basis": "VISUAL_ANCHOR_MUTUAL_NEAREST",
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"standard_visual_resources": len(std),
|
||||||
|
"cm95_visual_resources": len(cm),
|
||||||
|
"candidate_pairs": len(candidates),
|
||||||
|
"accepted_visual_anchors": len(anchors),
|
||||||
|
"cm95_visual_object_occurrences_named": len(occurrences),
|
||||||
|
"standard_layers_reached": sorted(set(x["best_standard_layer"] for x in anchors if x.get("best_standard_layer"))),
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"schema": "style-visual-anchor-match/v1",
|
||||||
|
"rules": {
|
||||||
|
"cm95_source_layer_used": False,
|
||||||
|
"provider_name_used": False,
|
||||||
|
"resource_key_text_semantics_used": False,
|
||||||
|
"standard_layer_is_output_only": True,
|
||||||
|
"min_score": args.min_score,
|
||||||
|
"min_margin": args.min_margin,
|
||||||
|
"requires_mutual_nearest": True,
|
||||||
|
},
|
||||||
|
"summary": summary,
|
||||||
|
"anchors": sorted(anchors, key=lambda x: x["score"], reverse=True),
|
||||||
|
"cm95_named_occurrences": occurrences,
|
||||||
|
"top_candidates": sorted(candidates, key=lambda x: x["score"], reverse=True)[:200],
|
||||||
|
}
|
||||||
|
(out / "visual-anchor-matches.json").write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||||
|
for x in result["anchors"][:80]:
|
||||||
|
print(
|
||||||
|
f'{x["score"]:.4f} margin={x["margin"]:.4f} '
|
||||||
|
f'{x["cm95_resource_key"]} -> {x["best_standard_resource_key"]} '
|
||||||
|
f'[{x["best_standard_layer"]}]'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user