docs(pbf): archive Full production provenance and engineering history

This commit is contained in:
OpenAI Codex
2026-10-13 14:02:56 +08:00
parent 1fef4c6dd1
commit 07d16c38a8
381 changed files with 107826 additions and 527 deletions

View File

@@ -0,0 +1,581 @@
# NavSea 任务说明(codex6 / V11)
## 任务名称
日本全国海岸线硬阻塞栅格预处理(GSI 基盤地図情報版)
## 任务编号
NAVSEA-TASK-COASTLINE-GRID-JP-001
## 任务状态
待实现
## 任务目标
基于日本国土地理院(GSI)提供的**基盤地図情報(FGD)海岸线数据**,构建一套可离线预处理的日本全国海域**200m × 200m 硬阻塞栅格底库**,供 NavSea 路由系统直接使用。
本任务只解决以下问题:
- 将日本陆地 / 海岸线转化为路由可直接查询的硬阻塞格网;
- 为后续叠加浅滩、禁航区、桥梁净空、港内限制区等语义层提供统一栅格基底;
- 避免运行时直接依赖 MapLibre 的 MVT/PBF 做判障。
本任务**不**解决以下问题:
- 不生成可显示用的地图瓦片;
- 不替代官方航海图或 ENC;
- 不在本阶段判断“安全可航行水深”;
- 不在本阶段实现潮汐相关动态可航行性;
- 不在本阶段实现最终航线规划器。
---
## 业务背景
当前 NavSea 项目中的 PBF 为 **MapLibre 使用的矢量瓦片 PBF(MVT)**,适合显示,不适合作为全国统一的分析型原始数据库直接做路径判障。
因此需要额外建立一套**Routing Grid 预处理管线**:
```text
GSI 海岸线 / 其他航海语义数据
-> 几何清洗与统一投影
-> 障碍膨胀(buffer)
-> 200m fishnet 栅格化
-> 二进制 / 分块索引输出
-> NavSea 路由核心直接查询
```
本任务是该预处理体系中的第一阶段:
> 先用官方海岸线把“陆地绝对不可通行”这件事做对。
---
## 数据来源与现实约束
### 主数据源
日本国土地理院(GSI)基盤地図情報(FGD)中的海岸线数据。
已确认事实:
- GSI 基盤地図情報站点明确列出“海岸線”为可下载项;
- 下载服务需要用户注册并登录;
- 海岸线属于基盤地図情報的法定基础项之一;
- 精度说明为:城市区域约 1:2500 级别,其它区域约 1:25000 级别;
- 下载服务提供 JPGIS(GML)形式;
- 提供文件按区域网格切分,文件命名规则中 `ss=02` 对应海岸线。
- 已经下载在目录里了。
### 备选/补充数据源
国土数值信息(KSJ)海岸线数据可作为补充属性源,但其页面说明:
- 最新数据作成年度为 2006 年度;
### 使用约束
本任务默认目标是:
- 在 NavSea 内部离线预处理使用;
- 不在仓库中直接提交受许可限制的原始 GSI 数据;
- 原始下载数据路径加入 `.gitignore`;
---
## 本任务的成功标准
完成后,系统应具备以下能力:
1. 可以从本地 GSI 海岸线原始数据目录读取全国数据;
2. 可以统一转换为 NavSea 内部标准几何坐标体系;
3. 可以生成经过安全膨胀的硬阻塞面;
4. 可以按固定 origin 生成 200m × 200m 的全国海域格网;
5. 可以将与硬阻塞面相交的格子标记为 `hardBlocked = true`;
6. 可以导出供运行时快速查询的二进制结果;
7. 生成过程支持分块处理、断点重建、日志输出与结果校验;
8. 不依赖 MapLibre 前端进行任何运行时几何判障。
---
## 范围定义
### In Scope
- GSI 海岸线数据下载后的本地导入与解析;
- GML -> 内部几何数据转换;
- 海岸线质量检查、闭合/拼接/拓扑清洗;
- 海岸线转陆地硬阻塞面;
- 陆地障碍安全 buffer;
- 全国 200m 栅格生成;
- 格子状态计算;
- 分块导出二进制格网;
- 校验工具与统计输出;
- 针对东京湾 / 大阪湾 / 北海道沿岸的抽样验收。
### Out of Scope
- 水深区、浅滩、礁石、干出区、桥梁净空;
- 港口规则、禁航区、养殖区;
- 潮汐动态更新;
- 路由算法本体;
- UI 显示层改造;
- 在线下载 GSI 数据功能。
---
## 输入与输出定义
### 输入
1. GSI 基盤地図情報原始下载目录(本地,不入库)
2. 配置文件:
- 输入目录
- 输出目录
- 目标区域 bbox
- 栅格尺寸(默认 200m)
- 安全 buffer 距离
- 投影方案
3. 日本区域边界配置
4. 船舶安全参数(本任务中仅使用岸线横向安全余量)
### 输出
1. `routing-grid-jp-coast-v1.bin`
2. `routing-grid-jp-coast-v1.index.json`
3. `routing-grid-jp-coast-v1.meta.json`
4. 校验报告:
- 总格子数
- 被阻塞格子数
- 未知格子数
- 各分块统计
5. 抽样 PNG / GeoJSON 调试输出(仅调试用途)
---
## 栅格语义定义
本任务只定义第一版最小语义:
```text
CellState:
0 = UNKNOWN
1 = NAVIGABLE_CANDIDATE
2 = HARD_BLOCKED
```
说明:
- `UNKNOWN`:未纳入当前处理范围或缺少足够信息;
- `NAVIGABLE_CANDIDATE`:当前阶段未发现岸线硬阻塞,仅表示“未被岸线阻塞”,不代表安全可航行;
- `HARD_BLOCKED`:与陆地 / 经膨胀后的海岸硬阻塞面相交,禁止通行。
### 最低字段设计
建议每个格子最终至少具备以下字段:
```text
struct CoastGridCellV1 {
uint8 state; // 0 unknown, 1 candidate, 2 hard_blocked
uint8 sourceMask; // bit mask,当前阶段至少标记 GSI_COASTLINE
uint16 blockedPermille; // 0..1000,表示障碍覆盖率千分比
}
```
备注:
- 本阶段不要过早把格式做复杂;
- 允许后续升级到 `GridCellV2/V3` 增加 depth / clearance / risk;
- V11 阶段不得破坏已有读取接口,后续升级采用 wrapper 或版本扩展。
---
## 技术设计原则
### 1. 显示层与路由层分离
- MapLibre 的 MVT/PBF 继续只服务于显示;
- 路由判障只读取预处理后的 routing grid;
- 不允许运行时对 MVT 做空间判障。
### 2. 先建立官方岸线“硬真值基底”
- 以 GSI 海岸线为陆地硬阻塞基线;
- MapLibre 中显示出来的岸线不得反向覆盖 routing 真值;
- 后续其他语义层只能在该基底上叠加,不得削弱陆地阻塞语义。
### 3. 所有距离计算使用米制坐标
- 不允许直接在经纬度下用近似角度长度做 200m 格网;
- 统一先转换到米制坐标后再生成 fishnet;
- 投影方案允许一期先用单一米制投影跑通,后续再按日本区域分带优化。
### 4. 禁止中心点判障
- 不允许只用格子中心点判断是否阻塞;
- 必须按面积相交比例判断;
- 必须支持陆地障碍 buffer 后再相交。
### 5. 支持分块处理
- 日本全国数据不得要求一次性整体载入内存;
- 必须支持按区域块 / tile / mesh 批处理;
- 必须允许断点续跑。
### 6. 原始数据不入库
- 仓库中仅保留导入脚本、格式说明、配置模板;
- 不提交 GSI 原始下载文件;
- 若需要示例数据,仅保留极小匿名/脱敏调试切片(如许可允许)。
---
## 推荐处理流程
### 阶段 A:原始数据导入
目标:把 GSI 原始 GML 数据转成统一内部几何集合。
步骤:
1. 扫描输入目录,识别海岸线数据文件;
2. 解析文件名与区域标识;
3. 读取 GML 几何与属性;
4. 写入中间层(如 GeoPackage / FlatGeobuf / 临时二进制几何块)。
产出:
- 标准化海岸线线要素集合;
- 区域索引;
- 导入日志。
### 阶段 B:几何清洗与陆地阻塞面构造
目标:把原始海岸线转换为可用于判障的封闭障碍面。
步骤:
1. 统一坐标系;
2. 去除明显坏几何;
3. 线拼接与拓扑清洗;
4. 构造陆地区域面或沿岸阻塞面;
5. 对陆地边界做安全膨胀(buffer)。
注意:
- 如果无法全国一次性可靠构造完整陆地面,可采用“海岸线 + 陆地方向约定 + 沿岸膨胀面”作为一期方案;
- 二期再升级为完整 seam-less land polygon。
### 阶段 C:生成 200m fishnet
目标:生成路由统一栅格索引。
步骤:
1. 确定全国处理范围 bbox;
2. 确定固定 grid origin;
3. 生成 200m × 200m 分块格网;
4. 给每格分配全局 `cellId` 或 `(gx, gy)`。
要求:
- origin 固定,保证不同批次重建结果完全一致;
- 必须能按区域分块增量重算。
### 阶段 D:格子阻塞判定
目标:将海岸线障碍面落入格网。
推荐规则:
```text
if blockedOverlapRatio > 0:
state = HARD_BLOCKED
else if insideCoverageArea:
state = NAVIGABLE_CANDIDATE
else:
state = UNKNOWN
```
更稳妥的建议:
- 同时记录 `blockedPermille`;
- 若后续发现边缘误判过多,可升级阈值为:
- `> 20‰` 即 hard block;
- 或与 buffer 配合后采用更保守规则。
### 阶段 E:导出运行时格式
目标:让 NavSea 在运行时做到 O(1) 或近 O(1) 查询。
建议:
- 按分块导出二进制;
- 使用轻量索引文件记录块范围;
- 运行时只加载当前位置邻近块。
---
## 推荐目录结构(建议,不强制)
```text
navsea/
tools/
chart-grid-builder/
src/
ingest/
normalize/
rasterize/
export/
validate/
config/
scripts/
README.md
data/
generated/
routing-grid/
docs/
tasks/
NAVSEA-TASK-COASTLINE-GRID-JP-001.md
```
说明:
- 原始 GSI 数据目录不放入仓库;
- 通过环境变量或本地配置指定;
- 所有新 `.ts/.tsx` 文件必须遵守 NavSea 的 logger 初始化规则。
---
## 配置建议
### 配置文件建议字段
```yaml
jobName: jp-coast-grid-v1
input:
gsiCoastRoot: /local/path/to/gsi/coast
output:
root: /local/path/to/output
region:
bbox:
minLon: 122.0
minLat: 20.0
maxLon: 154.0
maxLat: 46.5
projection:
mode: metric_projected
grid:
cellSizeMeters: 200
originX: 0
originY: 0
obstacle:
coastBufferMeters: 50
processing:
chunkSizeMeters: 20000
resume: true
validation:
sampleAreas:
- tokyo_bay
- osaka_bay
- hokkaido_coast
```
备注:
- `coastBufferMeters` 需结合你的船长、定位误差、岸边保守量再定;
- 一期可先用固定值,二期再改成基于船型配置。
---
## 关键实现要求
### 1. 导入器要求
- 能处理批量 GML;
- 能输出导入统计;
- 能跳过坏文件并记录错误;
- 支持断点续跑。
### 2. 几何模块要求
- 能统一投影;
- 能做拓扑清洗;
- 能做 buffer;
- 能输出调试几何。
### 3. 栅格模块要求
- 使用固定 grid origin;
- 支持按 chunk 生成;
- 可根据 bbox 仅重建局部区域;
- 输出 deterministic 结果。
### 4. 导出模块要求
- 文件格式需带版本号;
- 必须包含元数据文件;
- 元数据至少包含:
- 版本
- 生成时间
- 输入数据摘要
- cellSize
- bbox
- 分块信息
- 统计信息
### 5. 验证模块要求
- 生成统计摘要;
- 支持抽样区域导出 GeoJSON/PNG 调试图;
- 支持人工核查岸线附近格子是否被正确封堵。
---
## 验收标准
### 功能验收
1. 可从本地 GSI 数据生成至少一个样例区域的 200m 栅格;
2. 东京湾样例中,陆地区域相邻格子被正确标记为 `HARD_BLOCKED`;
3. 非陆地区域不会被大面积误封;
4. 相同输入重复运行结果一致;
5. 局部重建不会改变未涉及分块结果;
6. 运行时查询不依赖 GSI 原始数据与 MVT。
### 数据验收
1. 输出包含版本与元数据;
2. 统计报告完整;
3. 至少完成三个区域的人工抽样检查:
- 东京湾
- 大阪湾 / 濑户内海入口
- 北海道沿岸
### 工程验收
1. 原始下载数据不进入 git;
2. 代码结构符合 V11;
3. 新增 `.ts/.tsx` 文件遵守 logger 初始化规则;
4. 不引入未知依赖;
5. 对外接口保持后续可扩展。
---
## 风险与应对
### 风险 1:海岸线无法直接构造成稳定陆地面
应对:
- 一期先实现“海岸线 + buffer”阻塞带;
- 二期再升级为完整 land polygon。
### 风险 2:全国数据量导致内存占用过高
应对:
- 强制 chunk 化处理;
- 中间结果写盘;
- 禁止一次性全量几何载入。
### 风险 3:投影方案导致边缘区域误差不一致
应对:
- 一期先统一米制投影跑通;
- 二期再评估按区域分带;
- 元数据中记录投影方案版本。
### 风险 4:岸线细节不足导致近岸误判
应对:
- 记录样例区域误差;
- 后续叠加港口高精层或局部更高精数据;
- 不将 `NAVIGABLE_CANDIDATE` 误解为真实安全通航。
### 风险 5:许可边界不清
应对:
- 明确原始数据不入库;
- 在 docs 中记录数据来源与许可审查待办;
- 若未来要发布生成结果,单独评估。
---
## 分阶段实施计划
### Phase 1:最小可运行样例
目标:跑通单一区域。
交付:
- GSI 海岸线导入器
- 投影转换
- 200m 栅格生成
- `hardBlocked` 判定
- 东京湾样例输出
### Phase 2:全国化与分块化
目标:支持全国离线构建。
交付:
- 分块处理
- 断点续跑
- 二进制导出
- 索引文件
- 统计报告
### Phase 3:质量提升
目标:提高近岸判障质量。
交付:
- 岸线拓扑清洗增强
- 更稳妥的陆地面构造
- 局部高风险区域人工校验工具
### Phase 4:为后续航海语义层预留接口
目标:让后续浅滩/限制区/桥梁等数据可无缝叠加。
交付:
- `GridCellV2` 设计草案
- source mask 扩展规则
- depth / clearance / risk 预留位设计
---
## 对 Codex 的具体执行要求
### 规则 1:遵守 NavSea V11 宪章
- 不破坏现有架构;
- 不在显示层混入路由判障逻辑;
- 不直接修改 MapLibre 数据消费方式来偷跑此功能;
- 以独立预处理模块实现。
### 规则 2:遵守现有协作流程
- 新文件:由实现方直接给出文件路径和完整代码;
- 旧文件:必须先读取用户提供的当前版本,再给完整替换版本;
- 不只给片段补丁;
- 不臆测未提供的项目结构。
### 规则 3:版本标识
- 每个完整替换文件头部必须带明确版本注释;
- 建议统一使用如 `v11.x-coast-grid` 标识。
### 规则 4:日志要求
- 每个新增 `.ts/.tsx` 文件顶部必须加入 NavSea 规定的 logger 初始化;
- 日志要覆盖:导入、分块处理、异常、统计输出。
### 规则 5:不要引入未知重依赖
- 优先使用现有项目依赖与成熟轻量方案;
- 若确需增加 geospatial 依赖,必须先说明必要性、体积和运行影响。
---
## 建议的首批交付文件(示意,不代表固定路径)
```text
/tools/chart-grid-builder/src/config/CoastGridJobConfig.ts
/tools/chart-grid-builder/src/ingest/GsiCoastlineScanner.ts
/tools/chart-grid-builder/src/ingest/GsiCoastlineParser.ts
/tools/chart-grid-builder/src/normalize/CoastlineNormalizer.ts
/tools/chart-grid-builder/src/normalize/CoastObstacleBuilder.ts
/tools/chart-grid-builder/src/rasterize/FishnetGrid.ts
/tools/chart-grid-builder/src/rasterize/CoastGridRasterizer.ts
/tools/chart-grid-builder/src/export/CoastGridBinaryWriter.ts
/tools/chart-grid-builder/src/validate/CoastGridValidator.ts
/tools/chart-grid-builder/src/cli/buildJapanCoastGrid.ts
/docs/tasks/NAVSEA-TASK-COASTLINE-GRID-JP-001.md
```
---
## 非功能要求
- 可重复构建;
- 结果确定性;
- 可追踪元数据;
- 支持未来增量升级;
- 输出格式稳定;
- 允许未来按海区局部重建。
---
## 备注
1. 本任务是“全国路由底库”的起点,不是最终航海安全判定系统;
2. `NAVIGABLE_CANDIDATE` 只是“未被海岸线阻塞”,不是“确认可安全航行”;
3. 后续必须叠加至少以下层才能形成更可靠的海上路由基础:
- 浅水 / 水深层
- 干出区
- 礁石 / 危险物
- 桥梁净空
- 禁航区 / 受限区
- 港内限制区
- 养殖区 / 网具区
---
## 参考资料(供实现时核对)
- GSI 基盤地図情報站点:明确基盤地図情報可下载,且需要注册登录。
- GSI 基盤地図情報说明:海岸线属于法定基础项之一。
- GSI 下载服务:海岸线为可选下载项。
- GSI FAQ / Help:下载服务为注册制,基本项下载为 JPGIS(GML)形式。
- GSI 文件命名规则:提供类编号 `02` 对应海岸线。
- GSI 数值地图(国土基本信息)说明:城市区域约 1:2500,其它区域约 1:25000 级精度。
- KSJ 海岸线数据页:可作补充参考,但年度老且页面标示非商用。
---
## 完成定义(Definition of Done)
当且仅当以下条件同时满足时,本任务可标记完成:
1. 样例区域可以从 GSI 原始数据成功生成 200m 海岸硬阻塞格网;
2. 全国构建流程可运行并支持分块;
3. 输出带版本号、元数据与统计;
4. 东京湾 / 大阪湾 / 北海道沿岸完成抽样验证;
5. 运行时可仅依赖生成后的 grid 查询,不再读取 GSI 原始数据与 MVT 判障;
6. 实现过程遵守 NavSea V11、codex6、logger、完整替换文件等协作规则。

View File

@@ -0,0 +1,462 @@
# NavSea 任务说明(codex6)
## 任务名称
港湾区域叠加判定与分级栅格生成(日本全国)
## 任务编号
NAVSEA-TASK-PORT-AREA-OVERLAY-CLASSIFICATION-JP-001
## 任务类型
数据预处理 / 路由底库构建 / 港湾语义叠加
## 适用架构
- NavSea Architecture V11
- NavSea Codex System v6(codex6)
## 任务背景
上一阶段任务已确定:
- 使用日本国土地理院(GSI)基盤地図情報中的海岸线数据,作为日本全国海陆边界与硬阻塞判定的基础真值源。
- 该数据可区分部分海岸线形态,例如通常部、岩等接触部分、堤防等接触部分。
但仅靠海岸线数据,不能可靠区分:
- 港口区域内岸线
- 普通自然海岸线
- 商港/渔港/人工港湾周边可航行与受限水域
因此本任务单独建立“港湾区域叠加判定”模块,用于在既有海岸线硬阻塞格网基础上,叠加港湾区域、港域边界、人工岸线邻接关系,并生成供路由系统使用的港湾语义标签与分级栅格。
---
## 本任务目标
构建一套日本全国统一的“港湾区域叠加判定”预处理流水线,实现:
1. 将港湾区域数据导入并标准化。
2. 与 GSI 海岸线数据进行空间叠加。
3. 对海岸线附近及水域格子进行港湾语义分类。
4. 输出可供 NavSea 路由层直接读取的分级格网标签。
5. 支持不同区域精度策略:
- 基础全国海域:200m × 200m
- 港湾/近岸重点区:50m × 50m
- 外海区域:50m × 50m(按用户当前要求执行)
> 注意:本任务不负责显示层渲染,不修改 MapLibre 显示瓦片,不直接修改现有海图展示流程。
---
## 用户要求(必须满足)
### 1. 语义要求
必须能区分至少以下类别:
- 普通海岸线附近水域
- 港湾区域内水域
- 港湾区域内人工岸线邻近水域
- 港湾边界邻近过渡水域
- 无法判定区域
### 2. 精度要求
必须支持分级网格:
- 日本全国基础层:`200m x 200m`
- 港湾重点层:`50m x 50m`
- 外海区域:`50m x 50m`
### 3. 架构要求
必须遵守 NavSea V11 与 codex6 协作规则:
- 不破坏既有架构边界
- 不把显示层 MVT/PBF 直接当作路由真值源
- 新增能力应作为独立预处理模块接入
- 输出独立路由底库,不污染显示数据
---
## 数据来源
### A. 主海岸线真值源
GSI 基盤地図情報(海岸線)
- C09-06.zip 和 C02-08.zip 港湾和渔港
用途:
- 海陆边界
- 海岸线类型辅助判定
- 人工岸线/自然岸线邻接判断
### B. 港湾语义源
国土数值信息(KSJ)港湾データ
用途:
- 港湾区域
- 港域边界线
- 港湾名
- 港湾种别
- 管理属性(若可用)
### C. 可选补充源
仅在后续验证确有需要时引入:
- 渔港数据
- 河口・運河相关数据
- 港内限制区/航路数据
- NavSea 现有矢量瓦片中的 harbour / pier / breakwater / seawall 图层
> 原则:GSI/KSJ 优先作为判定真值,MapLibre MVT 仅作补充,不反客为主。
---
## 最终输出目标
输出一套供 NavSea 路由模块使用的“港湾区域叠加语义格网”。
### 目标输出一:统一分类格网
每个格子至少要包含以下字段:
```ts
export type PortOverlayCell = {
gridLevel: 50 | 200;
cellId: string;
bbox: [number, number, number, number];
insidePortArea: boolean;
nearPortBoundary: boolean;
nearArtificialCoast: boolean;
nearGeneralCoast: boolean;
classification:
| "PORT_WATER"
| "PORT_COAST"
| "GENERAL_COAST"
| "PORT_TRANSITION"
| "OPEN_WATER"
| "UNKNOWN";
sourceMask: number;
confidence: number;
};
```
### 目标输出二:供路由使用的轻量索引
建议生成:
- `routing-port-overlay-japan-v1.bin`
- `routing-port-overlay-japan-v1.index.json`
- 或按区域分块的二进制 tile/chunk 文件
### 目标输出三:验证样本
至少输出以下区域的可视化验证结果:
- 东京湾
- 大阪湾
- 濑户内海重点港区
- 北海道沿岸样本区
---
## 核心判定模型
### 一、基础分类目标
必须至少实现以下 6 类:
```text
PORT_WATER 港湾区域内一般水域
PORT_COAST 港湾区域内且邻近人工岸线/码头/护岸的水域或格子
GENERAL_COAST 非港湾区域、邻近普通海岸线的水域或格子
PORT_TRANSITION 港湾边界附近过渡水域
OPEN_WATER 不属于港湾、也不邻近海岸线的开放水域
UNKNOWN 数据缺失或冲突无法可靠判定
```
### 二、建议判定逻辑
```text
1. 若格子与港湾区域面相交:
-> insidePortArea = true
2. 若格子落入港湾边界线缓冲区:
-> nearPortBoundary = true
3. 若格子与 GSI 海岸线相邻,且岸线类型为“堤防等接触部分”或其他人工岸线代理:
-> nearArtificialCoast = true
4. 若格子与 GSI 海岸线相邻,且岸线类型为通常部/岩等接触部分:
-> nearGeneralCoast = true
```
### 三、建议分类优先级
```text
if insidePortArea && nearArtificialCoast:
classification = PORT_COAST
elif insidePortArea:
classification = PORT_WATER
elif nearPortBoundary:
classification = PORT_TRANSITION
elif nearGeneralCoast:
classification = GENERAL_COAST
elif not nearGeneralCoast && not insidePortArea:
classification = OPEN_WATER
else:
classification = UNKNOWN
```
---
## 分级栅格策略(必须执行)
本任务必须采用分级网格,不允许全国只用单一粒度。
### Level 1:全国基础层
- 网格尺寸:`200m x 200m`
- 用途:全国范围初步港湾语义覆盖
- 主要服务:大范围路由预筛选、低成本索引
### Level 2:重点精细层
- 网格尺寸:`50m x 50m`
- 必须覆盖:
- 港湾区域
- 港湾边界缓冲区
- 海岸线缓冲区
- 外海区域(按当前用户要求)
### 精细层触发规则
建议任一满足即进入 50m 层:
- 进入港湾区域
- 距离港湾边界小于设定阈值
- 距离海岸线小于设定阈值
- 属于外海区域
### 注意
外海区域 50m × 50m 将显著增加数据量和预处理成本。
因此实现上必须支持:
- 分块构建
- 分块存储
- 区域化加载
- 不能假设全部一次性常驻内存
---
## 空间处理建议
### 1. 投影要求
不得直接在经纬度下用固定度数近似米网格。
必须先转换到米制投影后生成格网。
建议方案:
- 统一米制投影方案
- 或按区域分带处理后落格
### 2. 缓冲区建议
建议至少配置以下缓冲:
- 港湾边界缓冲区
- 海岸线邻接缓冲区
- 人工岸线增强缓冲区
缓冲值不要写死在代码中,必须参数化。
### 3. 判定方式
不允许只使用“格子中心点落入关系”完成判定。
必须支持至少一种更稳妥方法:
- 面积相交比例
- 最短距离
- 缓冲后相交
建议组合:
- 面相交用于区域类判断
- 距离判断用于边界/邻接类判断
---
## 输出标记建议
### A. 供规则引擎读取的主分类
```ts
export type PortOverlayClass =
| "PORT_WATER"
| "PORT_COAST"
| "GENERAL_COAST"
| "PORT_TRANSITION"
| "OPEN_WATER"
| "UNKNOWN";
```
### B. 供路由代价层使用的附加标签
```ts
export type PortOverlayFlags = {
insidePortArea: boolean;
nearPortBoundary: boolean;
nearArtificialCoast: boolean;
nearGeneralCoast: boolean;
highConfidence: boolean;
};
```
### C. 供代价映射使用的建议
```text
PORT_COAST -> 高代价或半禁行
PORT_WATER -> 中高代价
PORT_TRANSITION -> 中代价
GENERAL_COAST -> 中高代价
OPEN_WATER -> 低代价
UNKNOWN -> 高代价或待人工确认
```
> 本任务只负责语义输出,不在此任务中写死最终寻路代价函数。
---
## 推荐目录结构(建议,不强制)
```text
navsea/
tools/
port-overlay-grid-builder/
README.md
src/
loaders/
normalizers/
classifiers/
grid/
exporters/
config/
samples/
data/
source/
gsi/
ksj/
derived/
routing-grids/
validation/
```
---
## 模块边界
### 本任务负责
- 港湾数据读取
- 港湾区域标准化
- 与海岸线叠加
- 分级格网生成
- 分类输出
- 验证样本输出
### 本任务不负责
- MapLibre 地图显示改造
- 实时路径搜索算法本体
- UI 呈现
- 航道/水深/桥净空等其他语义融合
---
## 开发阶段拆分
### Phase 1:数据审计与标准化
目标:确认 GSI + KSJ 的字段结构与可用于判定的关键属性。
交付物:
- 数据字段审计报告
- 统一内部字段映射表
- 样本区域导入脚本
### Phase 2:空间叠加与分类规则实现
目标:实现港湾区域、港湾边界、海岸线邻接的空间判定。
交付物:
- 叠加模块
- 分类模块
- 样本区验证输出
### Phase 3:分级格网与导出
目标:生成 200m/50m 双层格网输出,并提供路由可读索引。
交付物:
- 二进制或分块索引输出
- 校验脚本
- 抽样验证报告
### Phase 4:NavSea 接入准备
目标:定义与现有 routing 模块的接入边界。
交付物:
- 接口约定文档
- 数据版本规则
- 后续融合任务建议
---
## 验收标准
### 必须通过
1. 能正确导入并读取 GSI 海岸线与 KSJ 港湾数据。
2. 能完成港湾区域与海岸线的空间叠加。
3. 能输出六类基础分类结果。
4. 能生成 200m 与 50m 两级格网。
5. 能在东京湾与大阪湾样本区输出可人工核查结果。
6. 输出结构可被后续路由模块直接消费。
### 不通过条件
以下任一成立则视为未完成:
- 仅使用中心点判定
- 未支持 50m 精细层
- 未把港湾区域与普通海岸语义分开
- 结果仍依赖 MapLibre MVT 在线查询
- 输出格式不能稳定复现
---
## 风险与注意事项
### 1. 港湾数据边界语义可能与显示地图不完全一致
必须以预处理真值源为准,不以显示层瓦片视觉效果为准。
### 2. 50m 全覆盖数据量会很大
必须分块,不得设计为一次性全量内存方案。
### 3. 人工岸线识别可能并不完全等于码头语义
本任务先实现“人工岸线邻近”判定,不夸大为“所有码头设施已识别”。
### 4. UNKNOWN 不能被默认为 OPEN_WATER
对无法确定区域,默认应保守处理。
---
## 与已有任务关系
本任务是以下任务的扩展任务:
- 日本全国海岸线硬阻塞栅格生成任务
关系说明:
- 前任务解决“海陆边界与硬阻塞真值”
- 本任务解决“港湾区域语义与海岸线语义叠加”
- 后续可继续扩展到:
- 水深层
- 航道层
- 港内限制区
- 桥净空层
- 养殖区层
---
## Codex 执行要求
### 必须遵守
1. 所有新文件必须符合 NavSea V11 / codex6 规范。
2. 新建 `.ts/.tsx` 文件时,必须包含项目约定的 logger 初始化。
3. 不允许臆测未知项目结构。
4. 新文件由 AI 提供文件路径和完整代码。
5. 修改旧文件前,必须先由用户提供原文件内容,再返回完整替换版本。
6. 不引入未知依赖,不擅自改变既有模块边界。
### 输出要求
后续进入编码阶段时,AI 必须:
- 明确文件路径
- 提供完整替换内容
- 在文件头加入清晰版本标注
- 保持可直接复制使用
---
## 建议后续子任务
1. 港湾数据字段审计任务
2. 海岸线类型归一化任务
3. 50m/200m 分级格网生成器任务
4. 港湾叠加分类器任务
5. 港湾语义验证样本导出任务
6. Routing 消费接口定义任务
---
## 一句话定义
本任务的目标不是“在地图上看起来像港口”,而是:
**基于官方海岸线与港湾区域数据,为 NavSea 构建可用于路由判断的港湾语义分级格网。**

View File

@@ -0,0 +1,675 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import struct
from pathlib import Path
from typing import Iterable
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
ZOOM = 12
GRID_W = 40
GRID_H = 40
CELL_COUNT = GRID_W * GRID_H
BLOCK_SIZE = 32
SPARSE_THRESHOLD = 96
MAGIC = b"CWZ12BIN"
VERSION = 1
LAYER_20 = "fish_port_20m"
LAYER_50 = "hazard_50m"
LAYER_200 = "coast_200m"
CELL_SIZES = {
LAYER_20: 20.0,
LAYER_50: 50.0,
LAYER_200: 200.0,
}
RADIUS = 6378137.0
WORLD_MIN = -math.pi * RADIUS
WORLD_MAX = math.pi * RADIUS
WORLD_SIZE = WORLD_MAX - WORLD_MIN
TILE_COUNT = 1 << ZOOM
TILE_SIZE_M = WORLD_SIZE / TILE_COUNT
Z12_CELL_SIZE_M = TILE_SIZE_M / GRID_W
GLOBAL_CELL_COUNT = TILE_COUNT * GRID_W
HEADER_STRUCT = struct.Struct("<8sHBBBBBIII")
BLOCK_ENTRY_STRUCT = struct.Struct("<IIHH")
TILE_ENTRY_STRUCT = struct.Struct("<IIHBB")
PRESET_BBOXES = {
# 九州本岛和周边主要岛屿的调试范围,足够覆盖福冈/佐贺/长崎/熊本/鹿儿岛一带。
"kyushu": (128.0, 30.0, 132.5, 34.9),
}
def log(message: str) -> None:
print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] {message}")
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def parse_bbox(value: str) -> tuple[float, float, float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 4:
raise argparse.ArgumentTypeError("bbox must be min_lon,min_lat,max_lon,max_lat")
min_lon, min_lat, max_lon, max_lat = parts
if min_lon >= max_lon or min_lat >= max_lat:
raise argparse.ArgumentTypeError("bbox min values must be smaller than max values")
return min_lon, min_lat, max_lon, max_lat
def parse_lonlat(value: str) -> tuple[float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 2:
raise argparse.ArgumentTypeError("query point must be lon,lat")
return parts[0], parts[1]
def mercator_bbox_from_lonlat(bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
min_lon, min_lat, max_lon, max_lat = bbox
return mercator_x(min_lon), mercator_y(min_lat), mercator_x(max_lon), mercator_y(max_lat)
def rowcol_range_for_bbox(
bbox_mercator: tuple[float, float, float, float],
cell_size_m: float,
) -> tuple[int, int, int, int]:
minx, miny, maxx, maxy = bbox_mercator
col_min = math.floor(minx / cell_size_m)
col_max = math.floor((maxx - 1e-9) / cell_size_m)
row_min = math.floor(miny / cell_size_m)
row_max = math.floor((maxy - 1e-9) / cell_size_m)
return row_min, row_max, col_min, col_max
def overlap_range(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def overlaps_finer_cells(
row_idx: int,
col_idx: int,
coarse_size_m: float,
fine_size_m: float,
fine_keys: set[tuple[int, int]],
) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = overlap_range(min_y, max_y, fine_size_m)
col_start, col_end = overlap_range(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def pack_tile_id(tile_x: int, tile_y: int) -> int:
if not (0 <= tile_x <= 0xFFF and 0 <= tile_y <= 0xFFF):
raise ValueError(f"tile out of z12 range: {tile_x}, {tile_y}")
return (tile_x << 12) | tile_y
def unpack_tile_id(tile_id: int) -> tuple[int, int]:
return tile_id >> 12, tile_id & 0xFFF
def pack_cell_id(row: int, col: int) -> int:
if not (0 <= row < GRID_H and 0 <= col < GRID_W):
raise ValueError(f"cell out of tile range: {row}, {col}")
return row * GRID_W + col
def pack_block_id(tile_x: int, tile_y: int) -> int:
block_x = tile_x >> 5
block_y = tile_y >> 5
return (block_x << 7) | block_y
def global_cell_x_range(minx: float, maxx: float) -> tuple[int, int]:
start = math.floor((minx - WORLD_MIN) / Z12_CELL_SIZE_M)
end = math.floor(((maxx - 1e-9) - WORLD_MIN) / Z12_CELL_SIZE_M)
return max(0, start), min(GLOBAL_CELL_COUNT - 1, end)
def global_cell_y_range(miny: float, maxy: float) -> tuple[int, int]:
# WebMercator tile rows start at the north edge, while project grid rows use increasing mercator Y.
start = math.floor((WORLD_MAX - maxy) / Z12_CELL_SIZE_M)
end = math.floor(((WORLD_MAX - miny) - 1e-9) / Z12_CELL_SIZE_M)
return max(0, start), min(GLOBAL_CELL_COUNT - 1, end)
def add_mercator_rect_to_blocked(
blocked: dict[int, set[int]],
minx: float,
miny: float,
maxx: float,
maxy: float,
) -> int:
gx_start, gx_end = global_cell_x_range(minx, maxx)
gy_start, gy_end = global_cell_y_range(miny, maxy)
added = 0
for gy in range(gy_start, gy_end + 1):
tile_y = gy // GRID_H
row = gy % GRID_H
for gx in range(gx_start, gx_end + 1):
tile_x = gx // GRID_W
col = gx % GRID_W
tile_id = pack_tile_id(tile_x, tile_y)
cell_id = pack_cell_id(row, col)
cells = blocked.setdefault(tile_id, set())
before = len(cells)
cells.add(cell_id)
if len(cells) != before:
added += 1
return added
def iter_layer_rows(
conn,
*,
layer_name: str,
bbox_mercator: tuple[float, float, float, float] | None,
):
where = ["layer_name=%s"]
params: list[object] = [layer_name]
if bbox_mercator is not None:
row_min, row_max, col_min, col_max = rowcol_range_for_bbox(bbox_mercator, CELL_SIZES[layer_name])
where.append("row_idx BETWEEN %s AND %s")
where.append("col_idx BETWEEN %s AND %s")
params.extend([row_min, row_max, col_min, col_max])
sql = f"""
SELECT cell_id, row_idx, col_idx
FROM navsea_grid_cell
WHERE {' AND '.join(where)}
ORDER BY row_idx, col_idx, source_name
"""
with conn.cursor(pymysql.cursors.SSCursor) as cur:
cur.execute(sql, tuple(params))
for cell_id, row_idx, col_idx in cur:
yield str(cell_id), int(row_idx), int(col_idx)
def source_cell_mercator_bbox(row_idx: int, col_idx: int, cell_size_m: float) -> tuple[float, float, float, float]:
minx = col_idx * cell_size_m
miny = row_idx * cell_size_m
return minx, miny, minx + cell_size_m, miny + cell_size_m
def build_blocked_map(
conn,
*,
bbox_mercator: tuple[float, float, float, float] | None,
progress_every: int,
) -> tuple[dict[int, set[int]], dict]:
blocked: dict[int, set[int]] = {}
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
seen_20: set[str] = set()
seen_50: set[str] = set()
seen_200: set[str] = set()
summary = {
"sourceRows": {LAYER_20: 0, LAYER_50: 0, LAYER_200: 0},
"sourceDistinctCells": {LAYER_20: 0, LAYER_50: 0, LAYER_200: 0},
"keptByDensity": {"20": 0, "50": 0, "200": 0},
"skippedByDensity": {"50_over_20": 0, "200_over_20": 0, "200_over_50": 0},
"blockedCellAdds": {"20": 0, "50": 0, "200": 0},
}
log("读取 fish_port_20m;density 规则:20m 永远保留")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_20, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_20] += 1
if cell_id in seen_20:
continue
seen_20.add(cell_id)
keys_20.add((row_idx, col_idx))
summary["sourceDistinctCells"][LAYER_20] += 1
summary["keptByDensity"]["20"] += 1
summary["blockedCellAdds"]["20"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_20]),
)
if progress_every and summary["sourceRows"][LAYER_20] % progress_every == 0:
log(f"fish_port_20m rows={summary['sourceRows'][LAYER_20]} kept={summary['keptByDensity']['20']}")
log("读取 hazard_50m;density 规则:与 20m 重叠则跳过")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_50, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_50] += 1
if cell_id in seen_50:
continue
seen_50.add(cell_id)
summary["sourceDistinctCells"][LAYER_50] += 1
if overlaps_finer_cells(row_idx, col_idx, CELL_SIZES[LAYER_50], CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"]["50_over_20"] += 1
continue
keys_50.add((row_idx, col_idx))
summary["keptByDensity"]["50"] += 1
summary["blockedCellAdds"]["50"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_50]),
)
if progress_every and summary["sourceRows"][LAYER_50] % progress_every == 0:
log(f"hazard_50m rows={summary['sourceRows'][LAYER_50]} kept={summary['keptByDensity']['50']}")
log("读取 coast_200m;density 规则:与 20m/保留后的 50m 重叠则跳过")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_200, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_200] += 1
if cell_id in seen_200:
continue
seen_200.add(cell_id)
summary["sourceDistinctCells"][LAYER_200] += 1
if overlaps_finer_cells(row_idx, col_idx, CELL_SIZES[LAYER_200], CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"]["200_over_20"] += 1
continue
if overlaps_finer_cells(row_idx, col_idx, CELL_SIZES[LAYER_200], CELL_SIZES[LAYER_50], keys_50):
summary["skippedByDensity"]["200_over_50"] += 1
continue
summary["keptByDensity"]["200"] += 1
summary["blockedCellAdds"]["200"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_200]),
)
if progress_every and summary["sourceRows"][LAYER_200] % progress_every == 0:
log(f"coast_200m rows={summary['sourceRows'][LAYER_200]} kept={summary['keptByDensity']['200']}")
return blocked, summary
def encode_tile_cells(cells: set[int]) -> tuple[int, int, bytes]:
sorted_cells = sorted(cells)
if not sorted_cells:
raise ValueError("empty tile should not be encoded")
if sorted_cells[0] < 0 or sorted_cells[-1] >= CELL_COUNT:
raise ValueError(f"cellId out of range: {sorted_cells[0]}..{sorted_cells[-1]}")
if len(sorted_cells) <= SPARSE_THRESHOLD:
payload = struct.pack(f"<{len(sorted_cells)}H", *sorted_cells)
return 1, len(sorted_cells), payload
words = [0] * 50
for cell_id in sorted_cells:
word = cell_id >> 5
bit = cell_id & 31
words[word] |= 1 << bit
payload = struct.pack("<50I", *words)
return 2, len(sorted_cells), payload
def write_bin(path: Path, blocked: dict[int, set[int]]) -> dict:
path.parent.mkdir(parents=True, exist_ok=True)
grouped: dict[int, list[tuple[int, int, int, bytes]]] = {}
sparse_count = 0
bitset_count = 0
total_blocked_cells = 0
for tile_id in sorted(blocked):
cells = blocked[tile_id]
if not cells:
continue
tile_x, tile_y = unpack_tile_id(tile_id)
encoding, count, payload = encode_tile_cells(cells)
if encoding == 1:
sparse_count += 1
else:
bitset_count += 1
total_blocked_cells += count
block_id = pack_block_id(tile_x, tile_y)
grouped.setdefault(block_id, []).append((tile_id, encoding, count, payload))
block_ids = sorted(grouped)
block_count = len(block_ids)
block_dir_offset = HEADER_STRUCT.size
tile_dir_offset = block_dir_offset + block_count * BLOCK_ENTRY_STRUCT.size
tile_entry_count = sum(len(grouped[block_id]) for block_id in block_ids)
data_offset = tile_dir_offset + tile_entry_count * TILE_ENTRY_STRUCT.size
block_entries: list[tuple[int, int, int, int]] = []
tile_entries_by_block: dict[int, list[tuple[int, int, int, int]]] = {}
data_parts: list[bytes] = []
current_tile_dir_offset = tile_dir_offset
current_data_offset = 0
for block_id in block_ids:
tiles = sorted(grouped[block_id], key=lambda item: item[0])
block_entries.append((block_id, current_tile_dir_offset, len(tiles), 0))
current_tile_dir_offset += len(tiles) * TILE_ENTRY_STRUCT.size
tile_entries: list[tuple[int, int, int, int]] = []
for tile_id, encoding, count, payload in tiles:
tile_entries.append((tile_id, current_data_offset, count, encoding))
data_parts.append(payload)
current_data_offset += len(payload)
tile_entries_by_block[block_id] = tile_entries
with path.open("wb") as fh:
fh.write(
HEADER_STRUCT.pack(
MAGIC,
VERSION,
ZOOM,
GRID_W,
GRID_H,
BLOCK_SIZE,
0,
block_count,
block_dir_offset,
data_offset,
)
)
for entry in block_entries:
fh.write(BLOCK_ENTRY_STRUCT.pack(*entry))
for block_id in block_ids:
for tile_id, offset, count, encoding in tile_entries_by_block[block_id]:
fh.write(TILE_ENTRY_STRUCT.pack(tile_id, offset, count, encoding, 0))
for payload in data_parts:
fh.write(payload)
file_size = path.stat().st_size
return {
"file": str(path),
"fileSizeBytes": file_size,
"headerSizeBytes": HEADER_STRUCT.size,
"blockCount": block_count,
"totalBlockedTiles": sparse_count + bitset_count,
"totalBlockedCells": total_blocked_cells,
"sparseTileCount": sparse_count,
"bitsetTileCount": bitset_count,
"blockDirOffset": block_dir_offset,
"dataOffset": data_offset,
"dataBytes": current_data_offset,
}
class CoastWallReader:
def __init__(self, path: Path):
self.path = path
self.data = path.read_bytes()
header = HEADER_STRUCT.unpack_from(self.data, 0)
magic, version, zoom, grid_w, grid_h, block_size, _flags, block_count, block_dir_offset, data_offset = header
if magic != MAGIC:
raise ValueError(f"bad magic: {magic!r}")
if version != VERSION or zoom != ZOOM or grid_w != GRID_W or grid_h != GRID_H or block_size != BLOCK_SIZE:
raise ValueError("unsupported coast wall header")
self.block_count = block_count
self.block_dir_offset = block_dir_offset
self.data_offset = data_offset
self.blocks: dict[int, list[tuple[int, int, int, int]]] = {}
for idx in range(block_count):
offset = block_dir_offset + idx * BLOCK_ENTRY_STRUCT.size
block_id, tile_dir_offset, tile_count, _reserved = BLOCK_ENTRY_STRUCT.unpack_from(self.data, offset)
entries: list[tuple[int, int, int, int]] = []
for tile_idx in range(tile_count):
tile_offset = tile_dir_offset + tile_idx * TILE_ENTRY_STRUCT.size
tile_id, payload_offset, count, encoding, _tile_reserved = TILE_ENTRY_STRUCT.unpack_from(self.data, tile_offset)
entries.append((tile_id, payload_offset, count, encoding))
self.blocks[block_id] = entries
def tile_cells(self, tile_id: int) -> set[int]:
tile_x, tile_y = unpack_tile_id(tile_id)
block_id = pack_block_id(tile_x, tile_y)
for entry_tile_id, payload_offset, count, encoding in self.blocks.get(block_id, []):
if entry_tile_id != tile_id:
continue
start = self.data_offset + payload_offset
if encoding == 1:
values = struct.unpack_from(f"<{count}H", self.data, start)
return set(values)
if encoding == 2:
words = struct.unpack_from("<50I", self.data, start)
cells: set[int] = set()
for word_idx, word in enumerate(words):
if not word:
continue
for bit in range(32):
if word & (1 << bit):
cell_id = word_idx * 32 + bit
if cell_id < CELL_COUNT:
cells.add(cell_id)
return cells
raise ValueError(f"unknown encoding: {encoding}")
return set()
def is_blocked_lonlat(self, lon: float, lat: float) -> bool:
tile_x, tile_y, _row, _col, cell_id = z12_cell_for_lonlat(lon, lat)
tile_id = pack_tile_id(tile_x, tile_y)
return cell_id in self.tile_cells(tile_id)
def validate_bin(path: Path, blocked: dict[int, set[int]], sample_tiles: int = 200) -> None:
reader = CoastWallReader(path)
for idx, tile_id in enumerate(sorted(blocked)):
if idx >= sample_tiles:
break
expected = blocked[tile_id]
actual = reader.tile_cells(tile_id)
if actual != expected:
raise RuntimeError(f"reader roundtrip mismatch tile={tile_id}: expected={len(expected)} actual={len(actual)}")
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def z12_cell_bounds(tile_x: int, tile_y: int, row: int, col: int) -> tuple[float, float, float, float]:
minx = WORLD_MIN + (tile_x * GRID_W + col) * Z12_CELL_SIZE_M
maxx = minx + Z12_CELL_SIZE_M
maxy = WORLD_MAX - (tile_y * GRID_H + row) * Z12_CELL_SIZE_M
miny = maxy - Z12_CELL_SIZE_M
return lon_from_mercator(minx), lat_from_mercator(miny), lon_from_mercator(maxx), lat_from_mercator(maxy)
def z12_cell_for_lonlat(lon: float, lat: float) -> tuple[int, int, int, int, int]:
x = mercator_x(lon)
y = mercator_y(lat)
gx = math.floor((x - WORLD_MIN) / Z12_CELL_SIZE_M)
gy = math.floor((WORLD_MAX - y) / Z12_CELL_SIZE_M)
if not (0 <= gx < GLOBAL_CELL_COUNT and 0 <= gy < GLOBAL_CELL_COUNT):
raise ValueError(f"lon/lat outside z12 grid: {lon}, {lat}")
tile_x = gx // GRID_W
tile_y = gy // GRID_H
col = gx % GRID_W
row = gy % GRID_H
cell_id = pack_cell_id(row, col)
return tile_x, tile_y, row, col, cell_id
def write_debug_geojson(path: Path, blocked: dict[int, set[int]], limit: int) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for tile_id in sorted(blocked):
tile_x, tile_y = unpack_tile_id(tile_id)
for cell_id in sorted(blocked[tile_id]):
row = cell_id // GRID_W
col = cell_id % GRID_W
feature = {
"type": "Feature",
"properties": {
"tile_id": tile_id,
"tile_x": tile_x,
"tile_y": tile_y,
"cell_id": cell_id,
"row": row,
"col": col,
},
"geometry": rect_geometry(*z12_cell_bounds(tile_x, tile_y, row, col)),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if limit and count >= limit:
fh.write("\n]}\n")
return count
fh.write("\n]}\n")
return count
def run_self_test() -> None:
assert unpack_tile_id(pack_tile_id(3456, 789)) == (3456, 789)
assert pack_cell_id(39, 39) == 1599
assert pack_block_id(32, 64) == ((1 << 7) | 2)
sparse_cells = {0, 3, 1599}
encoding, count, payload = encode_tile_cells(sparse_cells)
assert encoding == 1 and count == 3
assert set(struct.unpack("<3H", payload)) == sparse_cells
dense_cells = set(range(120))
encoding, count, payload = encode_tile_cells(dense_cells)
assert encoding == 2 and count == 120 and len(payload) == 200
log("self-test passed")
def main() -> None:
parser = argparse.ArgumentParser(description="Build coast_wall_z12.bin from MySQL density-overview grid layers")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out", default="out/navmask/coast_wall_z12.bin")
parser.add_argument("--meta-json", default=None, help="默认写到 <out>.meta.json")
parser.add_argument("--debug-geojson", default=None)
parser.add_argument("--debug-limit", type=int, default=5000)
parser.add_argument("--preset", choices=["all", *sorted(PRESET_BBOXES)], default="kyushu")
parser.add_argument("--bbox", type=parse_bbox, default=None, help="min_lon,min_lat,max_lon,max_lat;优先于 --preset")
parser.add_argument("--query", type=parse_lonlat, default=None, help="生成后用 reader 查询一个 lon,lat 点")
parser.add_argument("--progress-every", type=int, default=200000)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
if args.self_test:
run_self_test()
bbox_lonlat = args.bbox
if bbox_lonlat is None and args.preset != "all":
bbox_lonlat = PRESET_BBOXES[args.preset]
bbox_mercator = mercator_bbox_from_lonlat(bbox_lonlat) if bbox_lonlat is not None else None
out_path = Path(args.out)
meta_path = Path(args.meta_json) if args.meta_json else out_path.with_suffix(out_path.suffix + ".meta.json")
log(f"开始生成 coast wall z12 bin preset={args.preset} bbox={bbox_lonlat}")
conn = mysql_connect(args.db_name)
try:
blocked, density_summary = build_blocked_map(
conn,
bbox_mercator=bbox_mercator,
progress_every=args.progress_every,
)
finally:
conn.close()
if not blocked:
raise SystemExit("no blocked cells produced")
write_summary = write_bin(out_path, blocked)
validate_bin(out_path, blocked)
debug_count = 0
if args.debug_geojson:
debug_count = write_debug_geojson(Path(args.debug_geojson), blocked, args.debug_limit)
query_result = None
if args.query is not None:
lon, lat = args.query
reader = CoastWallReader(out_path)
tile_x, tile_y, row, col, cell_id = z12_cell_for_lonlat(lon, lat)
query_result = {
"lon": lon,
"lat": lat,
"tile_x": tile_x,
"tile_y": tile_y,
"row": row,
"col": col,
"cell_id": cell_id,
"blocked": reader.is_blocked_lonlat(lon, lat),
}
meta = {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"db_name": args.db_name,
"preset": args.preset,
"bbox_lonlat": bbox_lonlat,
"zoom": ZOOM,
"grid_w": GRID_W,
"grid_h": GRID_H,
"z12_cell_size_m": Z12_CELL_SIZE_M,
"density_rule": [
"keep fish_port_20m first",
"keep hazard_50m only when it does not overlap kept fish_port_20m",
"keep coast_200m only when it does not overlap kept fish_port_20m or kept hazard_50m",
],
"density_summary": density_summary,
"binary_summary": write_summary,
"debug_geojson": args.debug_geojson,
"debug_feature_count": debug_count,
"query_result": query_result,
}
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"完成 bin={out_path} meta={meta_path}")
log(
"统计 "
f"tiles={write_summary['totalBlockedTiles']} cells={write_summary['totalBlockedCells']} "
f"sparse={write_summary['sparseTileCount']} bitset={write_summary['bitsetTileCount']} "
f"blocks={write_summary['blockCount']} size={write_summary['fileSizeBytes']}"
)
if query_result is not None:
log(f"query lon={query_result['lon']} lat={query_result['lat']} blocked={query_result['blocked']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""
NavSea coastline subset builder v1.0
针对 GSI 海岸线数据的福冈 / 佐贺两包(C23-06_40、C23-06_41)生成
一个可复用的海岸线硬阻塞样例底库:
1. 解析 GML 曲线
2. 投影到 Web Mercator 米制坐标
3. 生成海岸线缓冲区
4. 生成 200m fishnet
5. 输出 SQLite + GeoJSON + JSON 元数据
说明:
- 这是 coastline 全国底库的样例切片,不是最终全国版。
- 先把福冈 / 佐贺海岸跑通,便于后续扩全国。
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import sqlite3
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, MultiLineString, box, mapping
from shapely.geometry.base import BaseGeometry
from shapely.prepared import prep
from shapely.ops import unary_union
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
NS = {"gml": "http://www.opengis.net/gml/3.2"}
@dataclass(frozen=True)
class SourcePackage:
path: Path
prefecture: str
code: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def mercator_point(lon: float, lat: float) -> tuple[float, float]:
return mercator_x(lon), mercator_y(lat)
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def parse_text_list(text: str) -> list[float]:
return [float(part) for part in text.split() if part]
def detect_prefecture(meta_xml: str) -> str:
title_start = meta_xml.find("<title>")
if title_start == -1:
return "unknown"
title_end = meta_xml.find("</title>", title_start)
if title_end == -1:
return "unknown"
return meta_xml[title_start + 7:title_end].strip()
def load_package(path: Path) -> SourcePackage:
with zipfile.ZipFile(path) as zf:
meta_name = next(name for name in zf.namelist() if "META" in name and name.endswith(".xml"))
meta_xml = zf.read(meta_name).decode("shift_jis", errors="replace")
prefecture = detect_prefecture(meta_xml)
code = path.stem.replace("_GML", "")
return SourcePackage(path=path, prefecture=prefecture, code=code)
def iter_coastline_lines(zip_path: Path) -> Iterable[tuple[str, str, LineString]]:
"""
Yield (source_code, curve_id, projected_linestring).
"""
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(
name
for name in zf.namelist()
if name.endswith(".xml") and "META" not in name
)
root = ET.fromstring(zf.read(xml_name))
for idx, curve in enumerate(root.findall(".//gml:Curve", NS), start=1):
curve_id = curve.attrib.get("{http://www.opengis.net/gml/3.2}id", f"curve_{idx:06d}")
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//gml:posList", NS):
if not pos_list.text:
continue
values = parse_text_list(pos_list.text)
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
x, y = mercator_point(lon, lat)
coords.append((x, y))
if len(coords) < 2:
continue
yield xml_name, curve_id, LineString(coords)
def build_output_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def write_geojson(path: Path, features: list[dict]) -> None:
payload = {"type": "FeatureCollection", "features": features}
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def open_sqlite(path: Path) -> sqlite3.Connection:
if path.exists():
path.unlink()
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=OFF")
conn.execute("PRAGMA synchronous=OFF")
conn.execute("PRAGMA temp_store=MEMORY")
conn.execute("PRAGMA cache_size=200000")
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
def create_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE coastline_feature (
feature_id INTEGER PRIMARY KEY AUTOINCREMENT,
source_code TEXT NOT NULL,
prefecture TEXT NOT NULL,
curve_id TEXT NOT NULL,
geom_wkt TEXT NOT NULL,
point_count INTEGER NOT NULL,
minx REAL NOT NULL,
miny REAL NOT NULL,
maxx REAL NOT NULL,
maxy REAL NOT NULL
);
CREATE TABLE grid_cell (
cell_id TEXT PRIMARY KEY,
source_code TEXT NOT NULL,
prefecture TEXT NOT NULL,
ix INTEGER NOT NULL,
iy INTEGER NOT NULL,
minx REAL NOT NULL,
miny REAL NOT NULL,
maxx REAL NOT NULL,
maxy REAL NOT NULL,
state INTEGER NOT NULL,
state_name TEXT NOT NULL,
source_mask INTEGER NOT NULL,
blocked_permille INTEGER NOT NULL
);
CREATE INDEX idx_grid_cell_state ON grid_cell(state);
CREATE INDEX idx_grid_cell_source ON grid_cell(source_code, prefecture);
"""
)
def main() -> None:
parser = argparse.ArgumentParser(description="Build Fukuoka/Saga coastline hard-blocked grid")
parser.add_argument(
"--input",
dest="inputs",
action="append",
default=[],
help="输入海岸线 GML zip;可重复指定。默认使用 40/41 两个包。",
)
parser.add_argument(
"--output-dir",
default="out/coastline/fukuoka_saga",
help="输出目录",
)
parser.add_argument("--cell-size-m", type=float, default=200.0, help="栅格尺寸,默认 200m")
parser.add_argument("--buffer-m", type=float, default=50.0, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
default=2,
help="输出范围外扩的格子数,默认 2 个格子",
)
parser.add_argument(
"--write-geojson",
action="store_true",
help="同时输出 coastlines.geojson 和 grid.geojson",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
if args.inputs:
input_paths = [Path(item) for item in args.inputs]
else:
input_paths = [
project_root / "coastline" / "C23-06_40_GML.zip",
project_root / "coastline" / "C23-06_41_GML.zip",
]
packages: list[SourcePackage] = []
for path in input_paths:
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
out_dir = Path(args.output_dir)
build_output_dir(out_dir)
all_lines: list[LineString] = []
line_features: list[dict] = []
package_stats: list[dict] = []
for package in packages:
line_count = 0
point_count = 0
minx = miny = float("inf")
maxx = maxy = float("-inf")
for source_name, curve_id, line in iter_coastline_lines(package.path):
coords = list(line.coords)
point_count += len(coords)
line_count += 1
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
minx = min(minx, min(xs))
miny = min(miny, min(ys))
maxx = max(maxx, max(xs))
maxy = max(maxy, max(ys))
all_lines.append(line)
if args.write_geojson:
line_features.append(
{
"type": "Feature",
"properties": {
"source_code": package.code,
"prefecture": package.prefecture,
"curve_id": curve_id,
"source_xml": source_name,
"point_count": len(coords),
},
"geometry": mapping(line),
}
)
package_stats.append(
{
"source_code": package.code,
"prefecture": package.prefecture,
"zip": str(package.path),
"line_count": line_count,
"point_count": point_count,
"bbox_mercator": [minx, miny, maxx, maxy],
}
)
if not all_lines:
raise SystemExit("no coastline curves were parsed")
coastline_geom = MultiLineString([line.coords[:] for line in all_lines])
coastline_buffer = coastline_geom.buffer(args.buffer_m, cap_style=2, join_style=2)
prepared_buffer = prep(coastline_buffer)
coast_bounds = coastline_geom.bounds
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * args.cell_size_m, args.cell_size_m)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * args.cell_size_m, args.cell_size_m)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * args.cell_size_m, args.cell_size_m)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * args.cell_size_m, args.cell_size_m)
sqlite_path = out_dir / "fukuoka_saga_coast_grid.sqlite"
conn = open_sqlite(sqlite_path)
create_schema(conn)
build_time = dt.datetime.now().isoformat(timespec="seconds")
source_scope = "福冈/佐贺"
source_code = "C23-06_40+41"
meta_rows = [
("build_time", build_time),
("cell_size_m", str(args.cell_size_m)),
("buffer_m", str(args.buffer_m)),
("margin_cells", str(args.margin_cells)),
("source_scope", source_scope),
("source_code", source_code),
("source_count", str(len(packages))),
("source_packages", json.dumps([stats["zip"] for stats in package_stats], ensure_ascii=False)),
("coastline_line_count", str(len(all_lines))),
("coastline_bounds_mercator", json.dumps(coast_bounds, ensure_ascii=False)),
("grid_bounds_mercator", json.dumps([minx, miny, maxx, maxy], ensure_ascii=False)),
("notes", "GSI coastline subset for Fukuoka/Saga, generated from C23-06_40/41"),
]
conn.executemany("INSERT INTO meta(key, value) VALUES (?, ?)", meta_rows)
conn.executemany(
"""
INSERT INTO coastline_feature(
source_code, prefecture, curve_id, geom_wkt, point_count, minx, miny, maxx, maxy
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
package.code,
package.prefecture,
curve_id,
line.wkt,
len(line.coords),
line.bounds[0],
line.bounds[1],
line.bounds[2],
line.bounds[3],
)
for package in packages
for _, curve_id, line in iter_coastline_lines(package.path)
],
)
conn.commit()
insert_rows = []
grid_features = []
blocked_count = 0
candidate_count = 0
total_cells = 0
ix0 = int(round(minx / args.cell_size_m))
iy0 = int(round(miny / args.cell_size_m))
ix1 = int(round(maxx / args.cell_size_m))
iy1 = int(round(maxy / args.cell_size_m))
for ix in range(ix0, ix1):
cell_minx = ix * args.cell_size_m
cell_maxx = cell_minx + args.cell_size_m
for iy in range(iy0, iy1):
cell_miny = iy * args.cell_size_m
cell_maxy = cell_miny + args.cell_size_m
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
intersects = prepared_buffer.intersects(cell)
state = 2 if intersects else 1
state_name = "HARD_BLOCKED" if intersects else "NAVIGABLE_CANDIDATE"
blocked_permille = 1000 if intersects else 0
source_mask = 1
cell_id = f"{ix}:{iy}"
insert_rows.append(
(
cell_id,
source_code,
source_scope,
ix,
iy,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
state,
state_name,
source_mask,
blocked_permille,
)
)
total_cells += 1
if intersects:
blocked_count += 1
else:
candidate_count += 1
if args.write_geojson:
grid_features.append(
{
"type": "Feature",
"properties": {
"cell_id": cell_id,
"ix": ix,
"iy": iy,
"state": state,
"state_name": state_name,
"source_mask": source_mask,
"blocked_permille": blocked_permille,
},
"geometry": mapping(cell),
}
)
if len(insert_rows) >= 5000:
conn.executemany(
"""
INSERT INTO grid_cell(
cell_id, source_code, prefecture, ix, iy, minx, miny, maxx, maxy,
state, state_name, source_mask, blocked_permille
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
insert_rows,
)
conn.commit()
print(f"已写入格网 {total_cells} 个,blocked={blocked_count} candidate={candidate_count}")
insert_rows.clear()
if insert_rows:
conn.executemany(
"""
INSERT INTO grid_cell(
cell_id, source_code, prefecture, ix, iy, minx, miny, maxx, maxy,
state, state_name, source_mask, blocked_permille
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
insert_rows,
)
conn.commit()
conn.execute("ANALYZE")
conn.commit()
grid_meta = {
"build_time": build_time,
"input_packages": [stats["zip"] for stats in package_stats],
"package_stats": package_stats,
"cell_size_m": args.cell_size_m,
"buffer_m": args.buffer_m,
"margin_cells": args.margin_cells,
"grid_bounds_mercator": [minx, miny, maxx, maxy],
"coastline_feature_count": len(all_lines),
"grid_cell_count": total_cells,
"blocked_count": blocked_count,
"candidate_count": candidate_count,
}
meta_path = out_dir / "fukuoka_saga_coast_grid.meta.json"
meta_path.write_text(json.dumps(grid_meta, ensure_ascii=False, indent=2), encoding="utf-8")
if args.write_geojson:
coastline_geojson = out_dir / "fukuoka_saga_coastline.geojson"
write_geojson(coastline_geojson, line_features)
grid_geojson = out_dir / "fukuoka_saga_grid.geojson"
write_geojson(grid_geojson, grid_features)
conn.close()
print("完成")
print(f" SQLite: {sqlite_path}")
print(f" 元数据: {meta_path}")
if args.write_geojson:
print(f" 海岸线GeoJSON: {out_dir / 'fukuoka_saga_coastline.geojson'}")
print(f" 栅格GeoJSON: {out_dir / 'fukuoka_saga_grid.geojson'}")
print(f" coastline_features={len(all_lines)}")
print(f" grid_cells={total_cells}")
print(f" blocked={blocked_count}")
print(f" candidate={candidate_count}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,737 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
from typing import Callable, Iterable
import mapbox_vector_tile
import pymysql
from shapely.geometry import box, shape
from shapely.prepared import prep
DB_NAME = "navsea_fukuoka_saga_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
COAST_SRC = [
"coastline/C23-06_40_GML.zip",
"coastline/C23-06_41_GML.zip",
]
FISH_SRC = "coastline/C09-06.zip"
FISH_GRID_SRC = "src/pbf/coastline-only/fukuoka_saga_fish_port_navsplit_20m/fish_port_navsplit_fukuoka_saga_20m_grid.geojson"
COAST_GRID_SRC = "src/pbf/coastline/fukuoka_saga_blocked_cells.geojson"
PBF_TILE_ROOT = Path("/home/wwwroot/pbf-delivery-kyushu-reencoded")
PBF_TILE_Z = 12
DEFAULT_BBOX = (129.73110007140482, 32.9464631795233, 131.19176072338317, 34.25430274729531)
COAST_CELL_M = 200.0
FISH_CELL_M = 20.0
HAZARD_CELL_M = 50.0
HAZARD_LAYERS = (
"navigation_hazard_area",
"fixed_fishing_gear_area",
"anchor_caution_hazard_area",
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
)
POINT_LAYERS = {
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def load_xml(zip_path: Path):
import zipfile
import xml.etree.ElementTree as ET
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def bbox_from_coords(coords: list[list[float]] | list[tuple[float, float]]) -> tuple[float, float, float, float]:
min_lon = min_lat = float("inf")
max_lon = max_lat = float("-inf")
def walk(node):
nonlocal min_lon, min_lat, max_lon, max_lat
if not node:
return
if isinstance(node[0], (int, float)):
lon, lat = float(node[0]), float(node[1])
min_lon = min(min_lon, lon)
min_lat = min(min_lat, lat)
max_lon = max(max_lon, lon)
max_lat = max(max_lat, lat)
return
for item in node:
walk(item)
walk(coords)
return min_lon, min_lat, max_lon, max_lat
def rect_geojson(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
},
}
def cell_bbox_from_mercator(ix: int, iy: int, cell_m: float) -> tuple[float, float, float, float]:
minx = ix * cell_m
miny = iy * cell_m
maxx = minx + cell_m
maxy = miny + cell_m
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def cell_bbox_from_feature_coords(coords) -> tuple[float, float, float, float]:
return bbox_from_coords(coords)
def stream_geojson(path: Path, features: Iterable[dict]) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for feature in features:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database() -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
ddl = [
"DROP TABLE IF EXISTS navsea_grid_cell",
"DROP TABLE IF EXISTS navsea_grid_layer_meta",
"""
CREATE TABLE navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in ddl:
cur.execute(stmt)
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
rows,
)
def upsert_meta(cur, layer_name: str, description: str, source_desc: str, cell_size_m: float, feature_count: int, bbox: tuple[float, float, float, float], export_file: str) -> None:
cur.execute(
"""
INSERT INTO navsea_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def import_geojson_layer(
conn,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
source_path: Path,
export_path: Path,
include_predicate: Callable[[dict], bool],
row_key: str,
col_key: str,
state_name: str,
class_name: str,
) -> tuple[int, tuple[float, float, float, float]]:
data = load_json(source_path)
features = data.get("features", [])
bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
batch: list[tuple] = []
count = 0
export_features = []
with conn.cursor() as cur:
print(f"[{layer_name}] 开始导入 {source_path} ...")
for feature in features:
props = feature.get("properties") or {}
if not include_predicate(props):
continue
geom = feature.get("geometry") or {}
if geom.get("type") != "Polygon":
continue
coords = geom.get("coordinates") or []
min_lon, min_lat, max_lon, max_lat = cell_bbox_from_feature_coords(coords)
bbox[0] = min(bbox[0], min_lon)
bbox[1] = min(bbox[1], min_lat)
bbox[2] = max(bbox[2], max_lon)
bbox[3] = max(bbox[3], max_lat)
row_idx = int(props.get(row_key))
col_idx = int(props.get(col_key))
cell_id = str(props.get("cell_id") or f"{row_idx}:{col_idx}")
batch.append(
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_desc,
min_lon,
min_lat,
max_lon,
max_lat,
)
)
export_features.append(
{
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": row_idx,
"col": col_idx,
"cell_size_m": cell_size_m,
"state_name": state_name,
"class_name": class_name,
"source_name": source_desc,
},
"geometry": rect_geojson(min_lon, min_lat, max_lon, max_lat)["geometry"],
}
)
count += 1
if len(batch) >= 5000:
insert_cells(cur, batch)
conn.commit()
batch.clear()
if batch:
insert_cells(cur, batch)
conn.commit()
export_count = stream_geojson(export_path, export_features)
if export_count != count:
raise RuntimeError(f"export count mismatch for {layer_name}: {export_count} != {count}")
print(f"[{layer_name}] 导入完成:{count} 条,导出 {export_path}")
return count, (bbox[0], bbox[1], bbox[2], bbox[3])
def iter_hazard_cells(bbox: tuple[float, float, float, float]) -> Iterable[tuple[int, int]]:
lon_min, lat_min, lon_max, lat_max = bbox
n = 2**PBF_TILE_Z
x0 = max(0, int((lon_min + 180.0) / 360.0 * n))
x1 = min(n - 1, int((lon_max + 180.0) / 360.0 * n))
def ytile(lat: float) -> int:
return int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
y0 = max(0, ytile(lat_max))
y1 = min(n - 1, ytile(lat_min))
hazard_cells: set[tuple[int, int]] = set()
for tx in range(x0, x1 + 1):
for ty in range(y0, y1 + 1):
p = PBF_TILE_ROOT / f"{PBF_TILE_Z}/{tx}/{ty}.pbf"
if not p.exists():
continue
try:
tile = mapbox_vector_tile.decode(p.read_bytes())
except Exception:
continue
for layer_name in HAZARD_LAYERS:
layer = tile.get(layer_name)
if not layer:
continue
extent = int(layer.get("extent") or 1048576)
for feature in layer.get("features", []):
props = feature.get("properties") or {}
if props.get("canonical_object_type") == "fish_reef" or props.get("chart_symbol_code") == "fish_reef" or props.get("class_name") == "魚礁":
continue
geom = feature.get("geometry") or {}
if not geom:
continue
coords = geom.get("coordinates")
if not coords:
continue
minx, miny, maxx, maxy = tile_mercator_bounds(PBF_TILE_Z, tx, ty)
shp_coords = transform_geom_from_tile(coords, (minx, miny, maxx, maxy), extent)
shp = shape({"type": geom.get("type"), "coordinates": shp_coords})
if shp.is_empty:
continue
if layer_name in POINT_LAYERS:
shp = shp.buffer(25.0)
minx2, miny2, maxx2, maxy2 = shp.bounds
start_x = math.floor(minx2 / HAZARD_CELL_M) * HAZARD_CELL_M
start_y = math.floor(miny2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_x = math.ceil(maxx2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_y = math.ceil(maxy2 / HAZARD_CELL_M) * HAZARD_CELL_M
prep_geom = prep(shp)
cy = start_y
while cy < end_y:
cx = start_x
while cx < end_x:
cell = box(cx, cy, cx + HAZARD_CELL_M, cy + HAZARD_CELL_M)
if prep_geom.intersects(cell):
hazard_cells.add((int(round(cx / HAZARD_CELL_M)), int(round(cy / HAZARD_CELL_M))))
cx += HAZARD_CELL_M
cy += HAZARD_CELL_M
return sorted(hazard_cells, key=lambda item: (item[1], item[0]))
def tile_mercator_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2**z
lon_left = x / n * 360.0 - 180.0
lon_right = (x + 1) / n * 360.0 - 180.0
lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return mercator_x(lon_left), mercator_y(lat_bottom), mercator_x(lon_right), mercator_y(lat_top)
def transform_geom_from_tile(geom, bounds_m: tuple[float, float, float, float], extent: int):
minx, miny, maxx, maxy = bounds_m
dx = maxx - minx
dy = maxy - miny
def coord(x: float, y: float):
mx = minx + (x / extent) * dx
my = miny + (y / extent) * dy
return mx, my
def walk(obj):
if isinstance(obj[0], (int, float)):
return coord(obj[0], obj[1])
return [walk(item) for item in obj]
return walk(geom)
def import_hazard_layer(
conn,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
bbox: tuple[float, float, float, float],
export_path: Path,
) -> tuple[int, tuple[float, float, float, float]]:
cells = iter_hazard_cells(bbox)
batch: list[tuple] = []
count = 0
export_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
with conn.cursor() as cur:
print(f"[{layer_name}] 开始扫描 PBF 危险层 ...")
with export_path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for ix, iy in cells:
min_lon, min_lat, max_lon, max_lat = cell_bbox_from_mercator(ix, iy, HAZARD_CELL_M)
export_bbox[0] = min(export_bbox[0], min_lon)
export_bbox[1] = min(export_bbox[1], min_lat)
export_bbox[2] = max(export_bbox[2], max_lon)
export_bbox[3] = max(export_bbox[3], max_lat)
cell_id = f"{ix}:{iy}"
batch.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HAZARD_50M",
"HAZARD",
source_desc,
min_lon,
min_lat,
max_lon,
max_lat,
)
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": iy,
"col": ix,
"cell_size_m": cell_size_m,
"state_name": "HAZARD_50M",
"class_name": "HAZARD",
"source_name": source_desc,
},
"geometry": rect_geojson(min_lon, min_lat, max_lon, max_lat)["geometry"],
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if len(batch) >= 5000:
insert_cells(cur, batch)
conn.commit()
batch.clear()
if count % 20000 == 0 and count > 0:
print(f"[{layer_name}] 已处理 {count} 个格子 ...")
if batch:
insert_cells(cur, batch)
conn.commit()
fh.write("\n]}\n")
print(f"[{layer_name}] 导入完成:{count} 条,导出 {export_path}")
return count, (export_bbox[0], export_bbox[1], export_bbox[2], export_bbox[3])
def main() -> None:
parser = argparse.ArgumentParser(description="Build NavSea Fukuoka/Saga grid into MySQL")
parser.add_argument(
"--out-dir",
default="src/pbf/coastline-mysql/fukuoka_saga",
help="GeoJSON 输出目录",
)
parser.add_argument(
"--db-name",
default=DB_NAME,
help="MySQL 数据库名",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database()
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
coast_path = project_root / COAST_GRID_SRC
fish_path = project_root / FISH_GRID_SRC
if not coast_path.exists():
raise SystemExit(f"missing coast grid source: {coast_path}")
if not fish_path.exists():
raise SystemExit(f"missing fish grid source: {fish_path}")
coast_export = out_dir / "coast_200m_grid.geojson"
fish_export = out_dir / "fish_port_20m_grid.geojson"
hazard_export = out_dir / "hazard_50m_grid.geojson"
coast_count, coast_bbox = import_geojson_layer(
conn,
layer_name="coast_200m",
description="福冈/佐贺海岸线 200m 硬阻塞格",
source_desc="coastline/C23-06_40_GML.zip + coastline/C23-06_41_GML.zip",
cell_size_m=COAST_CELL_M,
source_path=coast_path,
export_path=coast_export,
include_predicate=lambda props: True,
row_key="iy",
col_key="ix",
state_name="HARD_BLOCKED",
class_name="HARD_BLOCKED",
)
fish_count, fish_bbox = import_geojson_layer(
conn,
layer_name="fish_port_20m",
description="福冈/佐贺渔港 20m 不可航行红格",
source_desc="coastline/C09-06.zip",
cell_size_m=FISH_CELL_M,
source_path=fish_path,
export_path=fish_export,
include_predicate=lambda props: props.get("classification") == "LAND_BASE",
row_key="row",
col_key="col",
state_name="LAND_BASE",
class_name="LAND_BASE",
)
hazard_count, hazard_bbox = import_hazard_layer(
conn,
layer_name="hazard_50m",
description="福冈/佐贺 PBF 海上障碍 50m 黄格",
source_desc="pbf-delivery-kyushu-reencoded z12: navigation_hazard_area, fixed_fishing_gear_area, anchor_caution_hazard_area, navigation_hazard_point, anchor_caution_hazard_point, navigation_marks",
cell_size_m=HAZARD_CELL_M,
bbox=DEFAULT_BBOX,
export_path=hazard_export,
)
with conn.cursor() as cur:
upsert_meta(
cur,
"coast_200m",
"福冈/佐贺海岸线 200m 硬阻塞格",
"coastline/C23-06_40_GML.zip + coastline/C23-06_41_GML.zip",
COAST_CELL_M,
coast_count,
coast_bbox,
str(coast_export.relative_to(project_root)),
)
upsert_meta(
cur,
"fish_port_20m",
"福冈/佐贺渔港 20m 不可航行红格",
"coastline/C09-06.zip",
FISH_CELL_M,
fish_count,
fish_bbox,
str(fish_export.relative_to(project_root)),
)
upsert_meta(
cur,
"hazard_50m",
"福冈/佐贺 PBF 海上障碍 50m 黄格",
"pbf-delivery-kyushu-reencoded z12",
HAZARD_CELL_M,
hazard_count,
hazard_bbox,
str(hazard_export.relative_to(project_root)),
)
conn.commit()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layers": {
"coast_200m": {
"count": coast_count,
"bbox_lonlat": list(coast_bbox),
"cell_size_m": COAST_CELL_M,
"export_file": str(coast_export.relative_to(project_root)),
},
"fish_port_20m": {
"count": fish_count,
"bbox_lonlat": list(fish_bbox),
"cell_size_m": FISH_CELL_M,
"export_file": str(fish_export.relative_to(project_root)),
},
"hazard_50m": {
"count": hazard_count,
"bbox_lonlat": list(hazard_bbox),
"cell_size_m": HAZARD_CELL_M,
"export_file": str(hazard_export.relative_to(project_root)),
},
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导入 MySQL 数据库:", args.db_name)
print(" - coast_200m:", coast_count, coast_export)
print(" - fish_port_20m:", fish_count, fish_export)
print(" - hazard_50m:", hazard_count, hazard_export)
print(" - manifest:", manifest_path)
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,549 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import glob
import json
import math
import sqlite3
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, MultiLineString, box, mapping
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_INPUT_GLOB = "coastline/C23-06_*_GML.zip"
DEFAULT_OUTPUT_DIR = "out/coastline/japan_coast_grid"
@dataclass(frozen=True)
class SourcePackage:
path: Path
prefecture: str
code: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def parse_text_list(text: str) -> list[float]:
return [float(part) for part in text.split() if part]
def detect_prefecture(meta_xml: str) -> str:
title_start = meta_xml.find("<title>")
if title_start == -1:
return "unknown"
title_end = meta_xml.find("</title>", title_start)
if title_end == -1:
return "unknown"
return meta_xml[title_start + 7:title_end].strip()
def load_package(path: Path) -> SourcePackage:
with zipfile.ZipFile(path) as zf:
meta_name = next(name for name in zf.namelist() if "META" in name and name.endswith(".xml"))
meta_xml = zf.read(meta_name).decode("shift_jis", errors="replace")
prefecture = detect_prefecture(meta_xml)
code = path.stem.replace("_GML", "")
return SourcePackage(path=path, prefecture=prefecture, code=code)
def iter_coastline_lines(zip_path: Path) -> Iterable[LineString]:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
root = ET.fromstring(zf.read(xml_name))
ns = {"gml": "http://www.opengis.net/gml/3.2"}
for curve in root.findall(".//gml:Curve", ns):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//gml:posList", ns):
if not pos_list.text:
continue
values = parse_text_list(pos_list.text)
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) < 2:
continue
yield LineString(coords)
def open_sqlite(path: Path) -> sqlite3.Connection:
if path.exists():
path.unlink()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=OFF")
conn.execute("PRAGMA synchronous=OFF")
conn.execute("PRAGMA temp_store=MEMORY")
conn.execute("PRAGMA cache_size=200000")
conn.execute("PRAGMA locking_mode=EXCLUSIVE")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
def create_schema(conn: sqlite3.Connection, *, store_coastline_feature: bool) -> None:
statements = [
"""
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""",
"""
CREATE TABLE package_stat (
source_code TEXT NOT NULL,
prefecture TEXT NOT NULL,
zip_path TEXT NOT NULL,
line_count INTEGER NOT NULL,
point_count INTEGER NOT NULL,
bbox_minx REAL NOT NULL,
bbox_miny REAL NOT NULL,
bbox_maxx REAL NOT NULL,
bbox_maxy REAL NOT NULL,
grid_minx REAL NOT NULL,
grid_miny REAL NOT NULL,
grid_maxx REAL NOT NULL,
grid_maxy REAL NOT NULL,
blocked_count INTEGER NOT NULL,
candidate_count INTEGER NOT NULL
)
""",
"""
CREATE TABLE grid_cell (
cell_id TEXT PRIMARY KEY,
source_code TEXT NOT NULL,
prefecture TEXT NOT NULL,
ix INTEGER NOT NULL,
iy INTEGER NOT NULL,
minx REAL NOT NULL,
miny REAL NOT NULL,
maxx REAL NOT NULL,
maxy REAL NOT NULL,
state INTEGER NOT NULL,
state_name TEXT NOT NULL,
source_mask INTEGER NOT NULL,
blocked_permille INTEGER NOT NULL
)
""",
"CREATE INDEX idx_grid_cell_state ON grid_cell(state)",
"CREATE INDEX idx_grid_cell_source ON grid_cell(source_code, prefecture)",
]
if store_coastline_feature:
statements.insert(
2,
"""
CREATE TABLE coastline_feature (
feature_id INTEGER PRIMARY KEY AUTOINCREMENT,
source_code TEXT NOT NULL,
prefecture TEXT NOT NULL,
curve_index INTEGER NOT NULL,
point_count INTEGER NOT NULL,
minx REAL NOT NULL,
miny REAL NOT NULL,
maxx REAL NOT NULL,
maxy REAL NOT NULL,
geom_wkt TEXT NOT NULL
)
""",
)
conn.executescript(";\n".join(statements))
def upsert_cells(conn: sqlite3.Connection, rows: list[tuple]) -> None:
if not rows:
return
conn.executemany(
"""
INSERT INTO grid_cell(
cell_id, source_code, prefecture, ix, iy, minx, miny, maxx, maxy,
state, state_name, source_mask, blocked_permille
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cell_id) DO UPDATE SET
source_code = CASE WHEN excluded.state > grid_cell.state THEN excluded.source_code ELSE grid_cell.source_code END,
prefecture = CASE WHEN excluded.state > grid_cell.state THEN excluded.prefecture ELSE grid_cell.prefecture END,
state = CASE WHEN excluded.state > grid_cell.state THEN excluded.state ELSE grid_cell.state END,
state_name = CASE WHEN excluded.state > grid_cell.state THEN excluded.state_name ELSE grid_cell.state_name END,
source_mask = grid_cell.source_mask | excluded.source_mask,
blocked_permille = CASE
WHEN excluded.blocked_permille > grid_cell.blocked_permille THEN excluded.blocked_permille
ELSE grid_cell.blocked_permille
END
""",
rows,
)
def write_geojson_header(fh) -> None:
fh.write('{"type":"FeatureCollection","features":[\n')
def write_geojson_feature(fh, feature: dict, first: bool) -> bool:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
return False
def main() -> None:
parser = argparse.ArgumentParser(description="Build Japan coastline 200m grid into SQLite")
parser.add_argument(
"--input",
dest="inputs",
action="append",
default=[],
help="海岸线 GML zip。可重复指定;默认自动扫描 coastline/C23-06_*_GML.zip",
)
parser.add_argument(
"--output-dir",
default=DEFAULT_OUTPUT_DIR,
help="输出目录",
)
parser.add_argument("--cell-size-m", type=float, default=200.0, help="格网尺寸,默认 200m")
parser.add_argument("--buffer-m", type=float, default=50.0, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
default=2,
help="输出范围外扩的格子数,默认 2 个格子",
)
parser.add_argument(
"--write-geojson",
action="store_true",
help="同时输出 coastline.geojson 和 grid.geojson",
)
parser.add_argument(
"--store-coastline-feature",
action="store_true",
help="把每条海岸线曲线也写入 SQLite,默认关闭以控制库体积",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
if args.inputs:
input_paths: list[Path] = []
for item in args.inputs:
if any(ch in item for ch in "*?[]"):
input_paths.extend(Path(path) for path in sorted(glob.glob(item)))
else:
input_paths.append(Path(item))
else:
input_paths = [Path(path) for path in sorted(glob.glob(str(project_root / DEFAULT_INPUT_GLOB)))]
if not input_paths:
raise SystemExit("no coastline packages found")
packages: list[SourcePackage] = []
for path in input_paths:
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
sqlite_path = out_dir / "japan_coast_grid.sqlite"
conn = open_sqlite(sqlite_path)
create_schema(conn, store_coastline_feature=args.store_coastline_feature)
coastline_geojson_fh = None
grid_geojson_fh = None
coastline_geojson_first = True
grid_geojson_first = True
if args.write_geojson:
coastline_geojson_fh = (out_dir / "japan_coastline.geojson").open("w", encoding="utf-8")
grid_geojson_fh = (out_dir / "japan_grid.geojson").open("w", encoding="utf-8")
write_geojson_header(coastline_geojson_fh)
write_geojson_header(grid_geojson_fh)
package_stats: list[dict] = []
total_lines = 0
total_points = 0
try:
for package in packages:
print(f"[{package.code}] 开始处理 {package.path.name}")
lines = list(iter_coastline_lines(package.path))
if not lines:
print(f"[{package.code}] 没有可用海岸线,跳过")
continue
coastline_geom = MultiLineString([line.coords[:] for line in lines])
coastline_buffer = coastline_geom.buffer(args.buffer_m, cap_style=2, join_style=2)
prepared_buffer = prep(coastline_buffer)
coast_bounds = coastline_geom.bounds
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * args.cell_size_m, args.cell_size_m)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * args.cell_size_m, args.cell_size_m)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * args.cell_size_m, args.cell_size_m)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * args.cell_size_m, args.cell_size_m)
line_count = len(lines)
point_count = sum(len(line.coords) for line in lines)
total_lines += line_count
total_points += point_count
if args.store_coastline_feature:
conn.executemany(
"""
INSERT INTO coastline_feature(
source_code, prefecture, curve_index, point_count, minx, miny, maxx, maxy, geom_wkt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
package.code,
package.prefecture,
idx,
len(line.coords),
line.bounds[0],
line.bounds[1],
line.bounds[2],
line.bounds[3],
line.wkt,
)
for idx, line in enumerate(lines, start=1)
],
)
package_blocked = 0
package_candidate = 0
package_rows: list[tuple] = []
ix0 = int(round(minx / args.cell_size_m))
iy0 = int(round(miny / args.cell_size_m))
ix1 = int(round(maxx / args.cell_size_m))
iy1 = int(round(maxy / args.cell_size_m))
for ix in range(ix0, ix1):
cell_minx = ix * args.cell_size_m
cell_maxx = cell_minx + args.cell_size_m
for iy in range(iy0, iy1):
cell_miny = iy * args.cell_size_m
cell_maxy = cell_miny + args.cell_size_m
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
intersects = prepared_buffer.intersects(cell)
state = 2 if intersects else 1
state_name = "HARD_BLOCKED" if intersects else "NAVIGABLE_CANDIDATE"
blocked_permille = 1000 if intersects else 0
cell_id = f"{ix}:{iy}"
package_rows.append(
(
cell_id,
package.code,
package.prefecture,
ix,
iy,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
state,
state_name,
1,
blocked_permille,
)
)
if intersects:
package_blocked += 1
else:
package_candidate += 1
if args.write_geojson and grid_geojson_fh is not None:
feature = {
"type": "Feature",
"properties": {
"cell_id": cell_id,
"source_code": package.code,
"prefecture": package.prefecture,
"ix": ix,
"iy": iy,
"cell_size_m": args.cell_size_m,
"state": state,
"state_name": state_name,
"source_mask": 1,
"blocked_permille": blocked_permille,
},
"geometry": mapping(cell),
}
grid_geojson_first = write_geojson_feature(grid_geojson_fh, feature, grid_geojson_first)
if len(package_rows) >= 5000:
upsert_cells(conn, package_rows)
conn.commit()
print(
f"[{package.code}] 已写入 {len(package_rows)} 行,"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
if package_rows:
upsert_cells(conn, package_rows)
conn.commit()
package_stats.append(
{
"source_code": package.code,
"prefecture": package.prefecture,
"zip_path": str(package.path),
"line_count": line_count,
"point_count": point_count,
"bbox_mercator": [coast_bounds[0], coast_bounds[1], coast_bounds[2], coast_bounds[3]],
"grid_bounds_mercator": [minx, miny, maxx, maxy],
"blocked_count": package_blocked,
"candidate_count": package_candidate,
}
)
conn.execute(
"""
INSERT INTO package_stat(
source_code, prefecture, zip_path, line_count, point_count,
bbox_minx, bbox_miny, bbox_maxx, bbox_maxy,
grid_minx, grid_miny, grid_maxx, grid_maxy,
blocked_count, candidate_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
package.code,
package.prefecture,
str(package.path),
line_count,
point_count,
coast_bounds[0],
coast_bounds[1],
coast_bounds[2],
coast_bounds[3],
minx,
miny,
maxx,
maxy,
package_blocked,
package_candidate,
),
)
conn.commit()
print(
f"[{package.code}] 完成 line={line_count} point={point_count} "
f"blocked={package_blocked} candidate={package_candidate}"
)
conn.execute("ANALYZE")
conn.commit()
final_total = int(conn.execute("SELECT COUNT(*) FROM grid_cell").fetchone()[0])
blocked_count = int(conn.execute("SELECT COUNT(*) FROM grid_cell WHERE state = 2").fetchone()[0])
candidate_count = int(conn.execute("SELECT COUNT(*) FROM grid_cell WHERE state = 1").fetchone()[0])
bbox_row = conn.execute(
"SELECT MIN(minx), MIN(miny), MAX(maxx), MAX(maxy) FROM grid_cell"
).fetchone()
grid_bbox = (
float(bbox_row[0]) if bbox_row and bbox_row[0] is not None else float("inf"),
float(bbox_row[1]) if bbox_row and bbox_row[1] is not None else float("inf"),
float(bbox_row[2]) if bbox_row and bbox_row[2] is not None else float("-inf"),
float(bbox_row[3]) if bbox_row and bbox_row[3] is not None else float("-inf"),
)
build_time = dt.datetime.now().isoformat(timespec="seconds")
meta_rows = [
("build_time", build_time),
("cell_size_m", str(args.cell_size_m)),
("buffer_m", str(args.buffer_m)),
("margin_cells", str(args.margin_cells)),
("source_scope", "japan"),
("source_code", "C23-06_*"),
("source_count", str(len(packages))),
("source_packages", json.dumps([str(pkg.path) for pkg in packages], ensure_ascii=False)),
("coastline_line_count", str(total_lines)),
("coastline_point_count", str(total_points)),
("grid_cell_count", str(final_total)),
("blocked_count", str(blocked_count)),
("candidate_count", str(candidate_count)),
("grid_bounds_mercator", json.dumps(grid_bbox, ensure_ascii=False)),
("notes", "全国海岸线 200m 硬阻塞栅格,来源为 coastline/C23-06_*_GML.zip"),
]
conn.executemany("INSERT INTO meta(key, value) VALUES (?, ?)", meta_rows)
conn.commit()
meta_path = out_dir / "japan_coast_grid.meta.json"
meta_payload = {
"build_time": build_time,
"cell_size_m": args.cell_size_m,
"buffer_m": args.buffer_m,
"margin_cells": args.margin_cells,
"source_scope": "japan",
"source_count": len(packages),
"source_packages": [str(pkg.path) for pkg in packages],
"coastline_line_count": total_lines,
"coastline_point_count": total_points,
"grid_cell_count": final_total,
"blocked_count": blocked_count,
"candidate_count": candidate_count,
"grid_bounds_mercator": list(grid_bbox),
"package_stats": package_stats,
}
meta_path.write_text(json.dumps(meta_payload, ensure_ascii=False, indent=2), encoding="utf-8")
if args.write_geojson and coastline_geojson_fh is not None and grid_geojson_fh is not None:
coastline_geojson_fh.write("\n]}\n")
grid_geojson_fh.write("\n]}\n")
print("完成")
print(f" SQLite: {sqlite_path}")
print(f" 元数据: {meta_path}")
print(f" packages={len(packages)}")
print(f" coastline_lines={total_lines}")
print(f" grid_cells={final_total}")
print(f" blocked={blocked_count}")
print(f" candidate={candidate_count}")
if args.write_geojson:
print(f" 海岸线GeoJSON: {out_dir / 'japan_coastline.geojson'}")
print(f" 栅格GeoJSON: {out_dir / 'japan_grid.geojson'}")
finally:
if coastline_geojson_fh is not None:
coastline_geojson_fh.close()
if grid_geojson_fh is not None:
grid_geojson_fh.close()
conn.close()
if __name__ == "__main__":
main()

View File

@@ -25,8 +25,8 @@ DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_INPUT_GLOB = "coastline/C23-06_*_GML.zip"
DEFAULT_OUT_DIR = "out/coastline/japan_coast_grid_mysql"
LAYER_NAME = "coast_200m"
CELL_SIZE_M = 200.0
DEFAULT_CELL_SIZE_M = 200.0
DEFAULT_BUFFER_M = 50.0
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
@@ -134,12 +134,12 @@ def mysql_connect(database: str | None = None):
return pymysql.connect(**kwargs)
def ensure_database() -> None:
def ensure_database(name: str) -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` "
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
@@ -147,13 +147,25 @@ def ensure_database() -> None:
conn.close()
def column_exists(conn, table_name: str, column_name: str) -> bool:
with conn.cursor() as cur:
cur.execute("SELECT DATABASE()")
database_name = cur.fetchone()[0]
cur.execute(
"""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND COLUMN_NAME=%s
""",
(database_name, table_name, column_name),
)
return int(cur.fetchone()[0] or 0) > 0
def ensure_schema(conn) -> None:
ddl = [
"DROP TABLE IF EXISTS navsea_grid_cell",
"DROP TABLE IF EXISTS navsea_grid_package_stat",
"DROP TABLE IF EXISTS navsea_grid_layer_meta",
"""
CREATE TABLE navsea_grid_layer_meta (
CREATE TABLE IF NOT EXISTS navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
@@ -168,7 +180,8 @@ def ensure_schema(conn) -> None:
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_package_stat (
CREATE TABLE IF NOT EXISTS navsea_grid_package_stat (
layer_name VARCHAR(32) NOT NULL,
source_code VARCHAR(64) NOT NULL,
prefecture VARCHAR(128) NOT NULL,
zip_path TEXT NOT NULL,
@@ -183,11 +196,12 @@ def ensure_schema(conn) -> None:
grid_max_lon DOUBLE NOT NULL,
grid_max_lat DOUBLE NOT NULL,
blocked_count BIGINT NOT NULL,
candidate_count BIGINT NOT NULL
candidate_count BIGINT NOT NULL,
KEY idx_layer_source (layer_name, source_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_cell (
CREATE TABLE IF NOT EXISTS navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
@@ -202,7 +216,7 @@ def ensure_schema(conn) -> None:
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
UNIQUE KEY uniq_layer_source_cell (layer_name, source_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
@@ -211,6 +225,21 @@ def ensure_schema(conn) -> None:
with conn.cursor() as cur:
for stmt in ddl:
cur.execute(stmt)
if not column_exists(conn, "navsea_grid_package_stat", "layer_name"):
with conn.cursor() as cur:
cur.execute(
"ALTER TABLE navsea_grid_package_stat "
"ADD COLUMN layer_name VARCHAR(32) NOT NULL DEFAULT 'coast_200m' FIRST"
)
cur.execute("ALTER TABLE navsea_grid_package_stat ADD KEY idx_layer_source (layer_name, source_code)")
conn.commit()
def replace_layer_rows(conn, layer_name: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM navsea_grid_cell WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_grid_layer_meta WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_grid_package_stat WHERE layer_name=%s", (layer_name,))
conn.commit()
@@ -223,16 +252,15 @@ def insert_cells(cur, rows: list[tuple]) -> None:
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
row_idx = row_idx,
col_idx = col_idx,
cell_size_m = cell_size_m,
state_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(state_name), state_name),
class_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(class_name), class_name),
source_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(source_name), source_name),
min_lon = min_lon,
min_lat = min_lat,
max_lon = max_lon,
max_lat = max_lat
row_idx=VALUES(row_idx),
col_idx=VALUES(col_idx),
cell_size_m=VALUES(cell_size_m),
state_name=VALUES(state_name),
class_name=VALUES(class_name),
min_lon=VALUES(min_lon),
min_lat=VALUES(min_lat),
max_lon=VALUES(max_lon),
max_lat=VALUES(max_lat)
""",
rows,
)
@@ -282,8 +310,52 @@ def upsert_meta(
)
def default_layer_name(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return f"coast_{int(cell_size_m)}m"
return f"coast_{str(cell_size_m).replace('.', '_')}m"
def normalize_source_code(value: str) -> str:
value = value.strip()
if value.isdigit():
return f"C23-06_{int(value):02d}"
if value.startswith("C23-06_") and value[len("C23-06_"):].isdigit():
return f"C23-06_{int(value[len('C23-06_'):]):02d}"
return value
def ts_now() -> str:
return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(message: str) -> None:
print(f"[{ts_now()}] {message}", flush=True)
def flush_package_rows(
conn,
package_rows: list[tuple],
*,
package_code: str,
package_blocked: int,
package_candidate: int,
reason: str,
) -> None:
if not package_rows:
return
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
log(
f"[{package_code}] 已写入 {len(package_rows)} 行({reason}),"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
def main() -> None:
parser = argparse.ArgumentParser(description="Build Japan coastline 200m grid directly into MySQL")
parser = argparse.ArgumentParser(description="Build Japan coastline grid directly into MySQL")
parser.add_argument(
"--input",
dest="inputs",
@@ -301,7 +373,18 @@ def main() -> None:
default=DEFAULT_OUT_DIR,
help="输出目录,用于可选 GeoJSON 和元数据",
)
parser.add_argument("--buffer-m", type=float, default=50.0, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--cell-size-m",
type=float,
default=DEFAULT_CELL_SIZE_M,
help="海岸线格子大小,默认 200m;例如 50 会自动写入 coast_50m",
)
parser.add_argument(
"--layer-name",
default=None,
help="目标 layer_name;默认按 cell size 自动生成,例如 coast_200m / coast_50m",
)
parser.add_argument("--buffer-m", type=float, default=DEFAULT_BUFFER_M, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
@@ -313,7 +396,40 @@ def main() -> None:
action="store_true",
help="同时输出 coastline.geojson 和 grid.geojson",
)
parser.add_argument(
"--resume",
action="store_true",
help="续跑模式:不清理当前 layer,保留已入库格子",
)
parser.add_argument(
"--start-code",
default=None,
help="只处理指定 source_code 及之后的包,例如 13 或 C23-06_13",
)
parser.add_argument(
"--progress-every-cells",
type=int,
default=50000,
help="每扫描多少个候选格输出一次进度日志,默认 50000",
)
parser.add_argument(
"--progress-every-seconds",
type=int,
default=15,
help="每隔多少秒至少输出一次进度日志,默认 15",
)
parser.add_argument(
"--coarse-factor",
type=int,
default=8,
help="海岸线粗筛倍率,默认 8;值越大越少细扫,但粗筛更大",
)
args = parser.parse_args()
if args.cell_size_m <= 0:
raise SystemExit("--cell-size-m must be positive")
cell_size_m = float(args.cell_size_m)
layer_name = args.layer_name or default_layer_name(cell_size_m)
source_name = f"coastline/C23-06_* cell={cell_size_m:g}m buffer={args.buffer_m:g}m"
project_root = Path(__file__).resolve().parent.parent
if args.inputs:
@@ -328,6 +444,12 @@ def main() -> None:
if not input_paths:
raise SystemExit("no coastline packages found")
if args.progress_every_cells <= 0:
raise SystemExit("--progress-every-cells must be positive")
if args.progress_every_seconds < 0:
raise SystemExit("--progress-every-seconds must be non-negative")
if args.coarse_factor < 1:
raise SystemExit("--coarse-factor must be >= 1")
packages: list[SourcePackage] = []
for path in input_paths:
@@ -336,38 +458,61 @@ def main() -> None:
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
packages.sort(key=lambda package: package.code)
if args.start_code:
start_code = normalize_source_code(args.start_code)
packages = [package for package in packages if package.code >= start_code]
if not packages:
raise SystemExit(f"no coastline packages found from start code: {start_code}")
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database()
ensure_database(args.db_name)
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
log(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
if args.resume:
first_code = packages[0].code
last_code = packages[-1].code
log(
f"续跑 layer={layer_name} cell={cell_size_m:g}m;"
f"保留已入库格子,仅处理 {first_code}..{last_code}"
)
else:
log(f"准备重建 layer={layer_name} cell={cell_size_m:g}m;只清理当前 layer,不影响其它尺寸")
replace_layer_rows(conn, layer_name)
total_lines = 0
total_points = 0
package_stats: list[dict] = []
build_start = dt.datetime.now()
for package in packages:
print(f"[{package.code}] 开始处理 {package.path.name}")
package_start = dt.datetime.now()
log(f"[{package.code}] 开始处理 {package.path.name}")
log(f"[{package.code}] 读取原始曲线并构造几何 ...")
lines = list(iter_coastline_lines(package.path))
if not lines:
print(f"[{package.code}] 没有可用海岸线,跳过")
log(f"[{package.code}] 没有可用海岸线,跳过")
continue
coastline_geom = MultiLineString([list(line.coords) for line in lines])
coastline_buffer = coastline_geom.buffer(args.buffer_m, cap_style=2, join_style=2)
prepared_buffer = prep(coastline_buffer)
coast_bounds = coastline_geom.bounds
log(
f"[{package.code}] 几何已构造 lines={len(lines)} points={sum(len(line.coords) for line in lines)} "
f"bbox={coast_bounds[0]:.2f},{coast_bounds[1]:.2f},{coast_bounds[2]:.2f},{coast_bounds[3]:.2f} "
f"buffer={args.buffer_m:g}m"
)
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * cell_size_m, cell_size_m)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * cell_size_m, cell_size_m)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * cell_size_m, cell_size_m)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * cell_size_m, cell_size_m)
line_count = len(lines)
point_count = sum(len(line.coords) for line in lines)
@@ -377,56 +522,195 @@ def main() -> None:
package_blocked = 0
package_candidate = 0
package_rows: list[tuple] = []
package_seen_cells: set[str] = set()
scanned_cells = 0
next_progress_at = args.progress_every_cells
last_progress_at = dt.datetime.now()
coarse_factor = args.coarse_factor
coarse_size = cell_size_m * coarse_factor
ix0 = int(round(minx / CELL_SIZE_M))
iy0 = int(round(miny / CELL_SIZE_M))
ix1 = int(round(maxx / CELL_SIZE_M))
iy1 = int(round(maxy / CELL_SIZE_M))
for line_index, line in enumerate(lines, start=1):
line_start = dt.datetime.now()
line_buffer = line.buffer(args.buffer_m, cap_style=2, join_style=2)
if line_buffer.is_empty:
log(f"[{package.code}] 线段#{line_index} buffer 为空,跳过")
continue
prepared_buffer = prep(line_buffer)
line_bounds = line_buffer.bounds
log(
f"[{package.code}] 线段#{line_index} 扫描开始 "
f"bbox={line_bounds[0]:.2f},{line_bounds[1]:.2f},{line_bounds[2]:.2f},{line_bounds[3]:.2f}"
)
for ix in range(ix0, ix1):
cell_minx = ix * CELL_SIZE_M
cell_maxx = cell_minx + CELL_SIZE_M
for iy in range(iy0, iy1):
cell_miny = iy * CELL_SIZE_M
cell_maxy = cell_miny + CELL_SIZE_M
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
intersects = prepared_buffer.intersects(cell)
if intersects:
cell_id = f"{ix}:{iy}"
package_rows.append(
(
LAYER_NAME,
cell_id,
iy,
ix,
CELL_SIZE_M,
"HARD_BLOCKED",
"HARD_BLOCKED",
package.code,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
scan_minx = align_floor(line_bounds[0] - args.margin_cells * cell_size_m, coarse_size)
scan_miny = align_floor(line_bounds[1] - args.margin_cells * cell_size_m, coarse_size)
scan_maxx = align_ceil(line_bounds[2] + args.margin_cells * cell_size_m, coarse_size)
scan_maxy = align_ceil(line_bounds[3] + args.margin_cells * cell_size_m, coarse_size)
cix0 = int(round(scan_minx / coarse_size))
ciy0 = int(round(scan_miny / coarse_size))
cix1 = int(round(scan_maxx / coarse_size))
ciy1 = int(round(scan_maxy / coarse_size))
for cix in range(cix0, cix1):
coarse_minx = cix * coarse_size
coarse_maxx = coarse_minx + coarse_size
for ciy in range(ciy0, ciy1):
coarse_miny = ciy * coarse_size
coarse_maxy = coarse_miny + coarse_size
coarse_cell = box(coarse_minx, coarse_miny, coarse_maxx, coarse_maxy)
scanned_cells += 1
if not prepared_buffer.intersects(coarse_cell):
package_candidate += coarse_factor * coarse_factor
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
continue
fx0 = cix * coarse_factor
fy0 = ciy * coarse_factor
fx1 = fx0 + coarse_factor
fy1 = fy0 + coarse_factor
if prepared_buffer.contains(coarse_cell):
for ix in range(fx0, fx1):
cell_minx = ix * cell_size_m
cell_maxx = cell_minx + cell_size_m
for iy in range(fy0, fy1):
cell_miny = iy * cell_size_m
cell_maxy = cell_miny + cell_size_m
cell_id = f"{ix}:{iy}"
if cell_id in package_seen_cells:
continue
package_seen_cells.add(cell_id)
package_rows.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HARD_BLOCKED",
"HARD_BLOCKED",
source_name,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
scanned_cells += coarse_factor * coarse_factor
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
continue
for ix in range(fx0, fx1):
cell_minx = ix * cell_size_m
cell_maxx = cell_minx + cell_size_m
for iy in range(fy0, fy1):
cell_miny = iy * cell_size_m
cell_maxy = cell_miny + cell_size_m
cell_id = f"{ix}:{iy}"
scanned_cells += 1
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
if prepared_buffer.intersects(cell):
if cell_id not in package_seen_cells:
package_seen_cells.add(cell_id)
package_rows.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HARD_BLOCKED",
"HARD_BLOCKED",
source_name,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
else:
package_candidate += 1
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
if package_rows and len(package_rows) >= 100:
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason=f"line#{line_index}",
)
)
package_blocked += 1
else:
package_candidate += 1
if len(package_rows) >= 5000:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
print(
f"[{package.code}] 已写入 {len(package_rows)} 行,"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
if package_rows:
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason=f"line#{line_index}-end",
)
log(
f"[{package.code}] 线段#{line_index} 完成 "
f"blocked={package_blocked} candidate={package_candidate} "
f"elapsed={(dt.datetime.now() - line_start).total_seconds():.1f}s"
)
if package_rows:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason="package-end",
)
package_stats.append(
{
@@ -443,18 +727,23 @@ def main() -> None:
)
with conn.cursor() as cur:
cur.execute(
"DELETE FROM navsea_grid_package_stat WHERE layer_name=%s AND source_code=%s",
(layer_name, package.code),
)
cur.execute(
"""
INSERT INTO navsea_grid_package_stat(
source_code, prefecture, zip_path, line_count, point_count,
layer_name, source_code, prefecture, zip_path, line_count, point_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat,
grid_min_lon, grid_min_lat, grid_max_lon, grid_max_lat,
blocked_count, candidate_count
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
""",
(
layer_name,
package.code,
package.prefecture,
str(package.path),
@@ -473,9 +762,10 @@ def main() -> None:
),
)
conn.commit()
print(
log(
f"[{package.code}] 完成 line={line_count} point={point_count} "
f"blocked={package_blocked} candidate={package_candidate}"
f"blocked={package_blocked} candidate={package_candidate} "
f"elapsed={(dt.datetime.now() - package_start).total_seconds():.1f}s"
)
with conn.cursor() as cur:
@@ -485,16 +775,16 @@ def main() -> None:
conn.commit()
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s", (LAYER_NAME,))
cur.execute(f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s", (layer_name,))
final_total = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "HARD_BLOCKED"),
(layer_name, "HARD_BLOCKED"),
)
blocked_count = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "NAVIGABLE_CANDIDATE"),
(layer_name, "NAVIGABLE_CANDIDATE"),
)
candidate_count = int(cur.fetchone()[0] or 0)
cur.execute(
@@ -503,7 +793,7 @@ def main() -> None:
FROM navsea_grid_cell
WHERE layer_name=%s
""",
(LAYER_NAME,),
(layer_name,),
)
bbox_row = cur.fetchone()
@@ -520,6 +810,9 @@ def main() -> None:
manifest = {
"build_time": build_time,
"db_name": args.db_name,
"layer_name": layer_name,
"cell_size_m": cell_size_m,
"buffer_m": args.buffer_m,
"source_scope": "japan",
"source_code": "C23-06_*",
"source_count": len(packages),
@@ -531,32 +824,36 @@ def main() -> None:
"candidate_count": candidate_count,
"grid_bounds_lonlat": list(grid_bbox_lonlat),
"package_stats": package_stats,
"notes": "全国海岸线 200m 硬阻塞栅格,直接写入 MySQL",
"notes": f"全国海岸线 {cell_size_m:g}m 硬阻塞栅格,直接写入 MySQL",
}
manifest_path = out_dir / "japan_coast_grid_mysql.manifest.json"
manifest_path = out_dir / f"japan_{layer_name}_grid_mysql.manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=LAYER_NAME,
description="全国海岸线 200m 硬阻塞格",
layer_name=layer_name,
description=f"全国海岸线 {cell_size_m:g}m 硬阻塞格",
source_desc="coastline/C23-06_*_GML.zip",
cell_size_m=CELL_SIZE_M,
cell_size_m=cell_size_m,
feature_count=final_total,
bbox=grid_bbox_lonlat,
export_file=str(manifest_path.relative_to(project_root)),
)
conn.commit()
print("完成")
print(f" MySQL: {args.db_name}")
print(f" packages={len(packages)}")
print(f" coastline_lines={total_lines}")
print(f" grid_cells={final_total}")
print(f" blocked={blocked_count}")
print(f" candidate={candidate_count}")
print(f" manifest: {manifest_path}")
elapsed_all = (dt.datetime.now() - build_start).total_seconds()
log("完成")
log(f" MySQL: {args.db_name}")
log(f" layer={layer_name}")
log(f" cell_size_m={cell_size_m:g}")
log(f" packages={len(packages)}")
log(f" coastline_lines={total_lines}")
log(f" grid_cells={final_total}")
log(f" blocked={blocked_count}")
log(f" candidate={candidate_count}")
log(f" elapsed={elapsed_all:.1f}s")
log(f" manifest: {manifest_path}")
finally:
conn.close()

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
NavSea coastline-only port overlay export v1.0
只使用 coastline/ 目录中的原始包重新导出九州港湾 / 漁港预览资产。
输入:
- coastline/C02-08.zip
- coastline/C09-06.zip
输出:
- lines.geojson
- points.geojson
- manifest.json
"""
from __future__ import annotations
import argparse
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, Point, mapping
RADIUS = 6378137.0
DEFAULT_BBOX = (123.0, 31.0, 132.7, 34.9)
@dataclass(frozen=True)
class SourceSpec:
path: Path
dataset: str
kind: str
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
lookup[point_id] = lonlat_from_text(el.text.strip())
break
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
if not local_name(pos.tag).startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
tag = local_name(child.tag)
if tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def bbox_intersects(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def point_in_bbox(pt: tuple[float, float], bbox: tuple[float, float, float, float]) -> bool:
lon, lat = pt
return bbox[0] <= lon <= bbox[2] and bbox[1] <= lat <= bbox[3]
def feature_family(tag: str) -> str:
if tag == "CK01":
return "harbor_point"
if tag in ("CK11", "CK21"):
return "harbor_line"
if tag == "CB01":
return "fish_port_point"
if tag == "CB03":
return "fish_port_line"
return "unknown"
def main() -> None:
parser = argparse.ArgumentParser(description="Build coastline-only Kyushu port preview assets")
parser.add_argument(
"--output-dir",
default="src/pbf/coastline-only/kyushu",
help="输出目录",
)
parser.add_argument(
"--slug",
default="coastline_only_port_overlay_kyushu",
help="输出前缀",
)
parser.add_argument(
"--bbox",
nargs=4,
type=float,
default=list(DEFAULT_BBOX),
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"),
help="预览裁切范围,默认九州",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
bbox = tuple(args.bbox) # type: ignore[assignment]
sources = [
SourceSpec(project_root / "coastline" / "C02-08.zip", "C02-08", "harbor"),
SourceSpec(project_root / "coastline" / "C09-06.zip", "C09-06", "fish_port"),
]
line_features: list[dict] = []
point_features: list[dict] = []
stats = []
for source in sources:
if not source.path.exists():
raise SystemExit(f"missing input: {source.path}")
root = load_xml(source.path)
point_lookup = build_point_lookup(root)
obj = root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ")
if obj is None:
raise SystemExit(f"OBJ block missing in {source.path}")
line_tags = ["CK11", "CK21"] if source.dataset == "C02-08" else ["CB03"]
point_tags = ["CK01"] if source.dataset == "C02-08" else ["CB01"]
line_count = 0
point_count = 0
minx = miny = float("inf")
maxx = maxy = float("-inf")
for tag in line_tags:
for feature in obj.findall(f".//{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{tag}"):
loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC")
if loc is None:
continue
ref = loc.attrib.get("idref")
if not ref:
continue
curve = obj.find(f".//*[@id='{ref}']")
if curve is None:
continue
coords = extract_curve_coords(curve, point_lookup)
if len(coords) < 2:
continue
xs = [pt[0] for pt in coords]
ys = [pt[1] for pt in coords]
geom_bbox = (min(xs), min(ys), max(xs), max(ys))
if not bbox_intersects(geom_bbox, bbox):
continue
props = {
"source_dataset": source.dataset,
"source_kind": source.kind,
"tag": tag,
"feature_family": feature_family(tag),
}
for key in ("PRC", "HBC", "FPC"):
el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}")
if el is not None and (el.text or "").strip():
props[key.lower()] = el.text.strip()
line_features.append(
{
"type": "Feature",
"properties": props,
"geometry": mapping(LineString(coords)),
}
)
line_count += 1
minx = min(minx, geom_bbox[0])
miny = min(miny, geom_bbox[1])
maxx = max(maxx, geom_bbox[2])
maxy = max(maxy, geom_bbox[3])
for tag in point_tags:
for feature in obj.findall(f".//{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{tag}"):
pos = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}POS")
if pos is None:
continue
ref = pos.attrib.get("idref")
if not ref or ref not in point_lookup:
continue
lon, lat = point_lookup[ref]
if not point_in_bbox((lon, lat), bbox):
continue
props = {
"source_dataset": source.dataset,
"source_kind": source.kind,
"tag": tag,
"feature_family": feature_family(tag),
}
for key in ("NA4", "AD7", "NA2", "FCF", "AAC", "CH1", "CH2", "CFP", "FPA", "PRC", "HBC", "FPC"):
el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}")
if el is not None and (el.text or "").strip():
props[key.lower()] = el.text.strip()
point_features.append(
{
"type": "Feature",
"properties": props,
"geometry": mapping(Point(lon, lat)),
}
)
point_count += 1
minx = min(minx, lon)
miny = min(miny, lat)
maxx = max(maxx, lon)
maxy = max(maxy, lat)
stats.append(
{
"dataset": source.dataset,
"kind": source.kind,
"path": str(source.path),
"line_count": line_count,
"point_count": point_count,
"bbox_lonlat": None if line_count + point_count == 0 else [minx, miny, maxx, maxy],
}
)
manifest = {
"version": "v1.0",
"source_packages": [str(s.path) for s in sources],
"bbox_lonlat": list(bbox),
"line_count": len(line_features),
"point_count": len(point_features),
"stats": stats,
"line_file": f"{args.slug}_lines.geojson",
"point_file": f"{args.slug}_points.geojson",
}
(out_dir / f"{args.slug}_lines.geojson").write_text(
json.dumps({"type": "FeatureCollection", "name": "coastline_only_lines", "features": line_features}, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_points.geojson").write_text(
json.dumps({"type": "FeatureCollection", "name": "coastline_only_points", "features": point_features}, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_points.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,518 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import sys
from pathlib import Path
import numpy as np
import pymysql
from shapely import get_parts, intersects_xy
from shapely.geometry import LineString, box
from shapely.ops import polygonize, unary_union
from shapely.prepared import prep
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from coastline.build_fish_port_20m_full_mysql_resume import (
align_ceil,
align_floor,
build_point_lookup,
cluster_line_indices,
collect_fish_lines_for_prc,
discover_target_prcs,
load_coastlines_for_prc,
load_coarse_windows_for_bbox,
load_xml,
lon_from_mercator,
lat_from_mercator,
mercator_x,
mercator_y,
rect_lonlat,
)
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
FISH_SRC = "coastline/C09-06.zip"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/fish_port_debug"
DEFAULT_BUFFER_M = 20.0
DEFAULT_COARSE_MARGIN_M = 0.0
COAST_COARSE_LAYER = "coast_200m"
CELL_SIZE_M = 20.0
RADIUS = 6378137.0
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def mercator_to_lonlat_bbox(bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
minx, miny, maxx, maxy = bbox
return lon_from_mercator(minx), lat_from_mercator(miny), lon_from_mercator(maxx), lat_from_mercator(maxy)
def mercator_geom_to_lonlat_geojson(geom) -> dict:
geo = geom.__geo_interface__
def convert_coords(coords):
if not isinstance(coords, (list, tuple)):
return coords
if coords and isinstance(coords[0], (int, float)):
x = float(coords[0])
y = float(coords[1])
return [float(lon_from_mercator(x)), float(lat_from_mercator(y))]
return [convert_coords(item) for item in coords]
if geo["type"] == "GeometryCollection":
return {
"type": "GeometryCollection",
"geometries": [mercator_geom_to_lonlat_geojson(part) for part in geom.geoms],
}
return {
"type": geo["type"],
"coordinates": convert_coords(geo["coordinates"]),
}
def lonlat_line_feature(
coords: list[tuple[float, float]],
*,
kind: str,
fpc: str,
prc: str,
label: str | None = None,
) -> dict:
properties = {
"kind": kind,
"fpc": fpc,
"prc": prc,
}
if label is not None:
properties["label"] = label
return {
"type": "Feature",
"properties": properties,
"geometry": {
"type": "LineString",
"coordinates": [[float(lon), float(lat)] for lon, lat in coords],
},
}
def mercator_line_feature(
line: LineString,
*,
kind: str,
fpc: str,
prc: str,
label: str | None = None,
) -> dict:
coords = [[float(lon_from_mercator(x)), float(lat_from_mercator(y))] for x, y in line.coords]
return {
"type": "Feature",
"properties": {
"kind": kind,
"fpc": fpc,
"prc": prc,
**({"label": label} if label is not None else {}),
},
"geometry": {
"type": "LineString",
"coordinates": coords,
},
}
def bbox_feature(
bbox: tuple[float, float, float, float],
*,
kind: str,
fpc: str,
prc: str,
label: str,
) -> dict:
min_lon, min_lat, max_lon, max_lat = bbox
return {
"type": "Feature",
"properties": {
"kind": kind,
"fpc": fpc,
"prc": prc,
"label": label,
},
"geometry": rect_lonlat(min_lon, min_lat, max_lon, max_lat),
}
def mercator_bbox_feature(
bbox: tuple[float, float, float, float],
*,
kind: str,
fpc: str,
prc: str,
label: str,
) -> dict:
return bbox_feature(
mercator_to_lonlat_bbox(bbox),
kind=kind,
fpc=fpc,
prc=prc,
label=label,
)
def buffer_tag(value: float) -> str:
if abs(value - round(value)) < 1e-9:
return str(int(round(value)))
return str(value).replace(".", "p")
def scan_debug_cells(
scan_geom,
coast_prepared,
*,
prc: str,
fpc: str,
cluster_idx: int,
part_idx: int,
window_idx: int,
max_scan_cells: int | None,
) -> tuple[list[dict], int, int]:
fish_prepared = prep(scan_geom)
minx, miny, maxx, maxy = scan_geom.bounds
start_x = align_floor(minx, CELL_SIZE_M)
start_y = align_floor(miny, CELL_SIZE_M)
end_x = align_ceil(maxx, CELL_SIZE_M)
end_y = align_ceil(maxy, CELL_SIZE_M)
estimated_cols = max(0, int(math.ceil((end_x - start_x) / CELL_SIZE_M)))
estimated_rows = max(0, int(math.ceil((end_y - start_y) / CELL_SIZE_M)))
estimated_cells = estimated_cols * estimated_rows
if max_scan_cells is not None and estimated_cells > max_scan_cells:
raise RuntimeError(
"scan guard triggered: "
f"estimated_cells={estimated_cells} max_scan_cells={max_scan_cells} "
f"grid={estimated_cols}x{estimated_rows} bounds={tuple(round(v, 3) for v in scan_geom.bounds)} "
f"fpc={fpc} prc={prc} cluster={cluster_idx} part={part_idx} window={window_idx}"
)
half = CELL_SIZE_M / 2.0
ix_start = int(round(start_x / CELL_SIZE_M))
ix_end = int(round(end_x / CELL_SIZE_M))
ix_values = np.arange(ix_start, ix_end, dtype=np.int64)
x_values = ix_values.astype(float) * CELL_SIZE_M
x_center_values = x_values + half
x_right_values = x_values + CELL_SIZE_M
features: list[dict] = []
coast_count = 0
sea_count = 0
y = start_y
while y < end_y:
row_strip = box(start_x, y, end_x, y + CELL_SIZE_M)
if not fish_prepared.intersects(row_strip):
y += CELL_SIZE_M
continue
mask = (
intersects_xy(scan_geom, x_center_values, y + half)
| intersects_xy(scan_geom, x_values, y)
| intersects_xy(scan_geom, x_right_values, y)
| intersects_xy(scan_geom, x_values, y + CELL_SIZE_M)
| intersects_xy(scan_geom, x_right_values, y + CELL_SIZE_M)
)
if not np.any(mask):
y += CELL_SIZE_M
continue
mask = mask | np.r_[False, mask[:-1]] | np.r_[mask[1:], False]
for idx in np.flatnonzero(mask):
x = float(x_values[idx])
cell_geom = box(x, y, x + CELL_SIZE_M, y + CELL_SIZE_M)
if not fish_prepared.intersects(cell_geom):
continue
coast_hit = coast_prepared.intersects(cell_geom)
min_lon = lon_from_mercator(x)
min_lat = lat_from_mercator(y)
max_lon = lon_from_mercator(x + CELL_SIZE_M)
max_lat = lat_from_mercator(y + CELL_SIZE_M)
features.append(
{
"type": "Feature",
"properties": {
"kind": "cell_coast" if coast_hit else "cell_sea",
"fpc": fpc,
"prc": prc,
"cluster_idx": int(cluster_idx),
"part_idx": int(part_idx),
"window_idx": int(window_idx),
"cell_id": f"{int(round(x / CELL_SIZE_M))}:{int(round(y / CELL_SIZE_M))}",
"row": int(round(y / CELL_SIZE_M)),
"col": int(round(x / CELL_SIZE_M)),
"cell_size_m": CELL_SIZE_M,
"coast_hit": coast_hit,
},
"geometry": rect_lonlat(min_lon, min_lat, max_lon, max_lat),
}
)
if coast_hit:
coast_count += 1
else:
sea_count += 1
y += CELL_SIZE_M
return features, coast_count, sea_count
def main() -> None:
parser = argparse.ArgumentParser(description="Export one fish port debug bundle")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--fpc", required=True, help="港口 FPC 号,例如 4320050")
parser.add_argument("--coast-buffer-m", type=float, default=DEFAULT_BUFFER_M, help="海岸缓冲距离(米)")
parser.add_argument(
"--coast-coarse-margin-m",
type=float,
default=DEFAULT_COARSE_MARGIN_M,
help="coast_200m 粗窗查询外扩距离(米)",
)
parser.add_argument("--cluster-gap-m", type=float, default=250.0, help="线聚类间隔(米)")
parser.add_argument("--max-scan-cells", type=int, default=200000, help="单窗允许扫描的最大 20m 格数")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
fpc = str(args.fpc).strip()
buffer_dir = out_dir / f"fpc_{fpc}" / f"buffer_{buffer_tag(args.coast_buffer_m)}"
buffer_dir.mkdir(parents=True, exist_ok=True)
root = load_xml(project_root / FISH_SRC)
point_lookup = build_point_lookup(root)
prcs = discover_target_prcs(root, None, fpc)
if not prcs:
raise SystemExit(f"no PRC matched for FPC={fpc}")
prc = prcs[0]
fish_lines = collect_fish_lines_for_prc(root, point_lookup, prc, selected_fpc=fpc)
if not fish_lines:
raise SystemExit(f"no fish-port line features selected for FPC={fpc}")
fish_bboxes = [line.bbox for line in fish_lines]
fish_bbox = (
min(bb[0] for bb in fish_bboxes),
min(bb[1] for bb in fish_bboxes),
max(bb[2] for bb in fish_bboxes),
max(bb[3] for bb in fish_bboxes),
)
coast_lines, coast_bbox = load_coastlines_for_prc(
project_root,
prc,
bbox=(
mercator_x(fish_bbox[0]),
mercator_y(fish_bbox[1]),
mercator_x(fish_bbox[2]),
mercator_y(fish_bbox[3]),
),
search_margin_m=max(args.coast_buffer_m, args.coast_coarse_margin_m),
)
coast_geom = unary_union(coast_lines)
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
conn = mysql_connect(args.db_name)
try:
prc_coarse_windows = load_coarse_windows_for_bbox(
conn,
layer_name=COAST_COARSE_LAYER,
bbox_mercator=(
mercator_x(fish_bbox[0]),
mercator_y(fish_bbox[1]),
mercator_x(fish_bbox[2]),
mercator_y(fish_bbox[3]),
),
margin_m=args.coast_coarse_margin_m,
)
cluster_groups = cluster_line_indices([line.bbox for line in fish_lines], gap_m=args.cluster_gap_m)
features: list[dict] = []
features.append(
bbox_feature(
fish_bbox,
kind="fish_bbox",
fpc=fpc,
prc=prc,
label="fish_bbox",
)
)
features.append(
mercator_bbox_feature(
coast_bbox,
kind="coast_bbox",
fpc=fpc,
prc=prc,
label="coast_bbox",
)
)
for line in fish_lines:
features.append(
lonlat_line_feature(
line.coords,
kind="fish_line",
fpc=fpc,
prc=prc,
label="fish_line",
)
)
for line in coast_lines:
features.append(
mercator_line_feature(
line,
kind="coast_line",
fpc=fpc,
prc=prc,
label="coast_line",
)
)
features.append(
{
"type": "Feature",
"properties": {
"kind": "coast_buffer",
"fpc": fpc,
"prc": prc,
"buffer_m": args.coast_buffer_m,
"label": f"buffer={args.coast_buffer_m}m",
},
"geometry": mercator_geom_to_lonlat_geojson(coast_buffer),
}
)
for idx, coarse_window in enumerate(prc_coarse_windows):
features.append(
bbox_feature(
mercator_to_lonlat_bbox(coarse_window),
kind="coast_200m_window",
fpc=fpc,
prc=prc,
label=f"coast_200m_window_{idx + 1}",
)
)
scan_window_total = 0
cell_coast_total = 0
cell_sea_total = 0
for cluster_idx, cluster in enumerate(cluster_groups):
cluster_lines = [
LineString([(mercator_x(lon), mercator_y(lat)) for lon, lat in fish_lines[i].coords])
for i in cluster
]
merged = unary_union(cluster_lines)
polygons = list(polygonize(merged))
if polygons:
fish_geom = unary_union(polygons)
else:
fish_geom = merged.buffer(CELL_SIZE_M, cap_style=2, join_style=2)
fish_parts = [part for part in get_parts(fish_geom) if not part.is_empty]
for part_idx, fish_part in enumerate(fish_parts):
part_bbox = fish_part.bounds
part_coarse_windows = load_coarse_windows_for_bbox(
conn,
layer_name=COAST_COARSE_LAYER,
bbox_mercator=part_bbox,
margin_m=args.coast_coarse_margin_m,
)
for window_idx, coarse_window in enumerate(part_coarse_windows):
window_geom = box(*coarse_window)
scan_geom = window_geom
scan_window_total += 1
features.append(
{
"type": "Feature",
"properties": {
"kind": "scan_window",
"fpc": fpc,
"prc": prc,
"cluster_idx": int(cluster_idx),
"part_idx": int(part_idx),
"window_idx": int(window_idx),
"label": f"scan_window_{cluster_idx + 1}_{part_idx + 1}_{window_idx + 1}",
},
"geometry": mercator_geom_to_lonlat_geojson(scan_geom),
}
)
cells, coast_count, sea_count = scan_debug_cells(
scan_geom,
coast_prepared,
prc=prc,
fpc=fpc,
cluster_idx=cluster_idx,
part_idx=part_idx,
window_idx=window_idx,
max_scan_cells=args.max_scan_cells,
)
features.extend(cells)
cell_coast_total += coast_count
cell_sea_total += sea_count
out_path = buffer_dir / "debug.geojson"
out_path.write_text(
json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"fpc": fpc,
"prc": prc,
"buffer_m": args.coast_buffer_m,
"coast_coarse_margin_m": args.coast_coarse_margin_m,
"fish_bbox_lonlat": list(fish_bbox),
"fish_line_count": len(fish_lines),
"coast_curve_count": len(coast_lines),
"coast_bbox_mercator": list(coast_bbox),
"coast_200m_window_count": len(prc_coarse_windows),
"scan_window_count": scan_window_total,
"cell_coast_count": cell_coast_total,
"cell_sea_count": cell_sea_total,
"feature_count": len(features),
"export_file": str(out_path.relative_to(project_root)) if project_root in out_path.parents else str(out_path),
}
(buffer_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"debug: {len(features)} -> {out_path}")
print(f"manifest: {buffer_dir / 'manifest.json'}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
NavSea fish-port grid export v1.0
只使用 coastline/C09-06.zip 中的漁港数据,筛出福冈/佐贺(PRC=40/41)
范围,生成 50x50m 栅格预览资产。
输出:
- lines.geojson: 渔港边界线
- grid.geojson: 50m 栅格
- manifest.json: 元数据
"""
from __future__ import annotations
import argparse
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, Polygon, box, mapping
from shapely.ops import polygonize, unary_union
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (128.7, 31.8, 131.4, 34.9)
@dataclass(frozen=True)
class SourceSpec:
path: Path
dataset: str
kind: str
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def mercator_point(lon: float, lat: float) -> tuple[float, float]:
return mercator_x(lon), mercator_y(lat)
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_to_lonlat(pt: tuple[float, float]) -> tuple[float, float]:
return lon_from_mercator(pt[0]), lat_from_mercator(pt[1])
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def bbox_intersects(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def ring_lonlat_from_mercator(minx: float, miny: float, maxx: float, maxy: float) -> list[list[float]]:
ring = [
mercator_to_lonlat((minx, miny)),
mercator_to_lonlat((maxx, miny)),
mercator_to_lonlat((maxx, maxy)),
mercator_to_lonlat((minx, maxy)),
mercator_to_lonlat((minx, miny)),
]
return [[lon, lat] for lon, lat in ring]
def main() -> None:
parser = argparse.ArgumentParser(description="Build fish-port 50m grid preview assets for Fukuoka/Saga")
parser.add_argument(
"--output-dir",
default="src/pbf/coastline-only/fukuoka_saga_fish_port_50m",
help="输出目录",
)
parser.add_argument(
"--slug",
default="fish_port_grid_fukuoka_saga_50m",
help="输出文件前缀",
)
parser.add_argument(
"--source",
default="coastline/C09-06.zip",
help="输入漁港原始包",
)
parser.add_argument(
"--prc",
nargs="+",
default=["40", "41"],
help="只保留这些 PRC(默认福冈/佐贺)",
)
parser.add_argument(
"--bbox",
nargs=4,
type=float,
default=list(DEFAULT_BBOX),
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"),
help="预览裁切范围,仅用于页面初始视窗",
)
parser.add_argument(
"--cell-size-m",
type=float,
default=50.0,
help="栅格尺寸,默认 50m",
)
parser.add_argument(
"--line-buffer-m",
type=float,
default=80.0,
help="当 polygonize 失败时,线缓冲半径",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
source_path = Path(args.source)
if not source_path.is_absolute():
source_path = project_root / source_path
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if not source_path.exists():
raise SystemExit(f"missing input: {source_path}")
selected_prc = {str(x) for x in args.prc}
root = load_xml(source_path)
point_lookup = build_point_lookup(root)
obj = root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ")
if obj is None:
raise SystemExit(f"OBJ block missing in {source_path}")
line_features: list[dict] = []
mercator_lines: list[LineString] = []
minx = miny = float("inf")
maxx = maxy = float("-inf")
selected_count = 0
for feature in obj.findall(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}CB03"):
prc_el = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}PRC")
prc = prc_el.text.strip() if prc_el is not None and (prc_el.text or "").strip() else None
if prc not in selected_prc:
continue
loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC")
if loc is None:
continue
ref = loc.attrib.get("idref")
if not ref:
continue
curve = obj.find(f".//*[@id='{ref}']")
if curve is None:
continue
coords = extract_curve_coords(curve, point_lookup)
if len(coords) < 2:
continue
xs = [pt[0] for pt in coords]
ys = [pt[1] for pt in coords]
geom_bbox = (min(xs), min(ys), max(xs), max(ys))
line_lonlat = LineString(coords)
line_mercator = LineString([mercator_point(lon, lat) for lon, lat in coords])
mercator_lines.append(line_mercator)
props = {
"source_dataset": "C09-06",
"source_kind": "fish_port",
"tag": "CB03",
"feature_family": "fish_port_line",
"prc": prc,
}
fpc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}FPC")
if fpc is not None and (fpc.text or "").strip():
props["fpc"] = fpc.text.strip()
line_features.append(
{
"type": "Feature",
"properties": props,
"geometry": mapping(line_lonlat),
}
)
selected_count += 1
minx = min(minx, geom_bbox[0])
miny = min(miny, geom_bbox[1])
maxx = max(maxx, geom_bbox[2])
maxy = max(maxy, geom_bbox[3])
if not mercator_lines:
raise SystemExit("no fish-port lines selected for PRC=40/41")
merged = unary_union(mercator_lines)
polygon_candidates = list(polygonize(merged))
polygon_mode = "polygonize" if polygon_candidates else "line_buffer"
if polygon_candidates:
effective_geom = unary_union(polygon_candidates)
else:
effective_geom = merged.buffer(args.line_buffer_m, cap_style=2, join_style=2)
if effective_geom.is_empty:
raise SystemExit("effective geometry is empty")
bounds = effective_geom.bounds
cell = args.cell_size_m
start_x = align_floor(bounds[0], cell)
start_y = align_floor(bounds[1], cell)
end_x = align_ceil(bounds[2], cell)
end_y = align_ceil(bounds[3], cell)
grid_features: list[dict] = []
grid_count = 0
row = 0
y = start_y
while y < end_y:
col = 0
x = start_x
while x < end_x:
cell_poly = box(x, y, x + cell, y + cell)
if cell_poly.intersects(effective_geom):
ring = ring_lonlat_from_mercator(x, y, x + cell, y + cell)
grid_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": "C09-06",
"source_kind": "fish_port",
"feature_family": "fish_port_grid_50m",
"prc_scope": ",".join(sorted(selected_prc)),
"row": row,
"col": col,
},
"geometry": {
"type": "Polygon",
"coordinates": [ring],
},
}
)
grid_count += 1
x += cell
col += 1
y += cell
row += 1
bbox_lonlat = tuple(args.bbox)
manifest = {
"version": "v1.0",
"source_package": str(source_path),
"prc_scope": sorted(selected_prc),
"cell_size_m": cell,
"line_buffer_m": args.line_buffer_m,
"polygon_mode": polygon_mode,
"selected_line_count": selected_count,
"polygon_count": len(polygon_candidates),
"grid_count": grid_count,
"bbox_lonlat": list(bbox_lonlat),
"feature_bbox_lonlat": None if selected_count == 0 else [minx, miny, maxx, maxy],
"grid_bbox_mercator": [bounds[0], bounds[1], bounds[2], bounds[3]],
"line_file": f"{args.slug}_lines.geojson",
"grid_file": f"{args.slug}_grid.geojson",
}
(out_dir / f"{args.slug}_lines.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_lines",
"features": line_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_grid.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_grid_50m",
"features": grid_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_grid.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
NavSea fish-port navigation split export v1.0
目标:
- 只看福冈 / 佐贺渔港
- 用 C09-06.zip 的渔港边界先圈出港区
- 再叠加 C23-06_40 / C23-06_41 海岸线
- 将港区切成 50m x 50m 格网,并区分海面 / 陆基
输出:
- lines.geojson: 渔港边界线 + 海岸线参考
- grid.geojson: 50m 分类格网
- manifest.json: 元数据
"""
from __future__ import annotations
import argparse
import json
import math
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from shapely.geometry import LineString, box, mapping
from shapely.ops import polygonize, unary_union
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (128.7, 31.8, 131.4, 34.9)
@dataclass(frozen=True)
class SourceSpec:
path: Path
dataset: str
kind: str
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def mercator_point(lon: float, lat: float) -> tuple[float, float]:
return mercator_x(lon), mercator_y(lat)
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_to_lonlat(pt: tuple[float, float]) -> tuple[float, float]:
return lon_from_mercator(pt[0]), lat_from_mercator(pt[1])
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def parse_gsi_coastlines(zip_path: Path, dataset: str) -> list[dict]:
root = load_xml(zip_path)
features: list[dict] = []
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
for idx, curve in enumerate(root.findall(".//{http://www.opengis.net/gml/3.2}Curve"), start=1):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//{http://www.opengis.net/gml/3.2}posList"):
if not pos_list.text:
continue
values = [float(part) for part in pos_list.text.split() if part]
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append(mercator_point(lon, lat))
if len(coords) < 2:
continue
features.append(
{
"type": "Feature",
"properties": {
"source_dataset": dataset,
"feature_family": "coastline_line",
"curve_id": curve.attrib.get("{http://www.opengis.net/gml/3.2}id", f"curve_{idx:06d}"),
"source_xml": xml_name,
},
"geometry": mapping(LineString([mercator_to_lonlat(pt) for pt in coords])),
}
)
return features
def main() -> None:
parser = argparse.ArgumentParser(description="Build fish-port navigation split preview assets for Fukuoka/Saga")
parser.add_argument(
"--output-dir",
default="src/pbf/coastline-only/fukuoka_saga_fish_port_navsplit_50m",
help="输出目录",
)
parser.add_argument(
"--slug",
default="fish_port_navsplit_fukuoka_saga_50m",
help="输出文件前缀",
)
parser.add_argument(
"--fish-port-source",
default="coastline/C09-06.zip",
help="渔港原始包",
)
parser.add_argument(
"--coastline-sources",
nargs="+",
default=["coastline/C23-06_40_GML.zip", "coastline/C23-06_41_GML.zip"],
help="海岸线原始包",
)
parser.add_argument(
"--prc",
nargs="+",
default=["40", "41"],
help="只保留这些 PRC(默认福冈 / 佐贺)",
)
parser.add_argument(
"--all-prc",
action="store_true",
help="不按 PRC 过滤,导出初始视窗内的全部渔港要素",
)
parser.add_argument(
"--bbox",
nargs=4,
type=float,
default=list(DEFAULT_BBOX),
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"),
help="初始视窗",
)
parser.add_argument(
"--cell-size-m",
type=float,
default=50.0,
help="格网尺寸,默认 50m",
)
parser.add_argument(
"--coast-buffer-m",
type=float,
default=25.0,
help="海岸线陆基缓冲距离,默认 25m",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
fish_port_source = Path(args.fish_port_source)
if not fish_port_source.is_absolute():
fish_port_source = project_root / fish_port_source
if not fish_port_source.exists():
raise SystemExit(f"missing input: {fish_port_source}")
coastline_sources = []
for item in args.coastline_sources:
path = Path(item)
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
coastline_sources.append(path)
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
selected_prc = None if args.all_prc else {str(x) for x in args.prc}
# 1) 渔港边界线
fish_root = load_xml(fish_port_source)
fish_lookup = build_point_lookup(fish_root)
fish_obj = fish_root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ")
if fish_obj is None:
raise SystemExit(f"OBJ block missing in {fish_port_source}")
fish_line_features: list[dict] = []
fish_mercator_lines: list[LineString] = []
fish_line_count = 0
fish_bbox_minx = fish_bbox_miny = float("inf")
fish_bbox_maxx = fish_bbox_maxy = float("-inf")
for feature in fish_obj.findall(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}CB03"):
prc_el = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}PRC")
prc = prc_el.text.strip() if prc_el is not None and (prc_el.text or "").strip() else None
if selected_prc is not None and prc not in selected_prc:
continue
loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC")
if loc is None:
continue
ref = loc.attrib.get("idref")
if not ref:
continue
curve = fish_obj.find(f".//*[@id='{ref}']")
if curve is None:
continue
coords = extract_curve_coords(curve, fish_lookup)
if len(coords) < 2:
continue
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
fish_bbox_minx = min(fish_bbox_minx, min(xs))
fish_bbox_miny = min(fish_bbox_miny, min(ys))
fish_bbox_maxx = max(fish_bbox_maxx, max(xs))
fish_bbox_maxy = max(fish_bbox_maxy, max(ys))
fish_mercator_lines.append(LineString([mercator_point(lon, lat) for lon, lat in coords]))
fish_line_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": "C09-06",
"source_kind": "fish_port",
"feature_family": "fish_port_line",
"tag": "CB03",
"prc": prc,
"fpc": (
feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}FPC").text.strip()
if feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}FPC") is not None
and (feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}FPC").text or "").strip()
else None
),
},
"geometry": mapping(LineString(coords)),
}
)
fish_line_count += 1
if not fish_mercator_lines:
raise SystemExit("no fish-port lines selected for PRC=40/41")
fish_polygon_candidates = list(polygonize(unary_union(fish_mercator_lines)))
if fish_polygon_candidates:
fish_geom = unary_union(fish_polygon_candidates)
fish_mode = "polygonize"
else:
fish_geom = unary_union(fish_mercator_lines).buffer(args.cell_size_m, cap_style=2, join_style=2)
fish_mode = "buffer"
if fish_geom.is_empty:
raise SystemExit("fish-port geometry is empty")
fish_prepared = prep(fish_geom)
# 2) 海岸线参考线与陆基缓冲
coast_line_features: list[dict] = []
coast_mercator_lines: list[LineString] = []
coast_line_count = 0
coast_bbox_minx = coast_bbox_miny = float("inf")
coast_bbox_maxx = coast_bbox_maxy = float("-inf")
for path in coastline_sources:
root = load_xml(path)
for idx, curve in enumerate(root.findall(".//{http://www.opengis.net/gml/3.2}Curve"), start=1):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//{http://www.opengis.net/gml/3.2}posList"):
if not pos_list.text:
continue
values = [float(part) for part in pos_list.text.split() if part]
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) < 2:
continue
line = LineString(coords)
coast_mercator_lines.append(line)
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
coast_bbox_minx = min(coast_bbox_minx, min(xs))
coast_bbox_miny = min(coast_bbox_miny, min(ys))
coast_bbox_maxx = max(coast_bbox_maxx, max(xs))
coast_bbox_maxy = max(coast_bbox_maxy, max(ys))
coast_line_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": path.stem.replace("_GML", ""),
"source_kind": "coastline",
"feature_family": "coastline_line",
"curve_id": curve.attrib.get("{http://www.opengis.net/gml/3.2}id", f"curve_{idx:06d}"),
},
"geometry": mapping(LineString([mercator_to_lonlat(pt) for pt in coords])),
}
)
coast_line_count += 1
if not coast_mercator_lines:
raise SystemExit("no coastline curves were parsed")
coast_geom = unary_union(coast_mercator_lines)
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
# 3) 生成 50m 网格
bounds = fish_geom.bounds
cell = args.cell_size_m
minx = align_floor(bounds[0], cell)
miny = align_floor(bounds[1], cell)
maxx = align_ceil(bounds[2], cell)
maxy = align_ceil(bounds[3], cell)
grid_features: list[dict] = []
land_count = 0
sea_count = 0
total_count = 0
row = 0
y = miny
while y < maxy:
col = 0
x = minx
while x < maxx:
cell_geom = box(x, y, x + cell, y + cell)
if fish_prepared.intersects(cell_geom):
is_land = coast_prepared.intersects(cell_geom)
classification = "LAND_BASE" if is_land else "SEA_SURFACE"
if is_land:
land_count += 1
else:
sea_count += 1
total_count += 1
ring = [
[lon_from_mercator(x), lat_from_mercator(y)],
[lon_from_mercator(x + cell), lat_from_mercator(y)],
[lon_from_mercator(x + cell), lat_from_mercator(y + cell)],
[lon_from_mercator(x), lat_from_mercator(y + cell)],
[lon_from_mercator(x), lat_from_mercator(y)],
]
grid_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": "C09-06+coastline",
"source_kind": "navsplit",
"feature_family": "fish_port_navsplit_50m",
"classification": classification,
"row": row,
"col": col,
},
"geometry": {
"type": "Polygon",
"coordinates": [ring],
},
}
)
x += cell
col += 1
y += cell
row += 1
manifest = {
"version": "v1.0",
"fish_port_source": str(fish_port_source),
"coastline_sources": [str(p) for p in coastline_sources],
"prc_scope": None if selected_prc is None else sorted(selected_prc),
"cell_size_m": cell,
"coast_buffer_m": args.coast_buffer_m,
"fish_mode": fish_mode,
"fish_line_count": fish_line_count,
"coast_line_count": coast_line_count,
"fish_polygon_count": len(fish_polygon_candidates),
"grid_count": total_count,
"land_count": land_count,
"sea_count": sea_count,
"bbox_lonlat": list(args.bbox),
"fish_bbox_lonlat": [lon_from_mercator(bounds[0]), lat_from_mercator(bounds[1]), lon_from_mercator(bounds[2]), lat_from_mercator(bounds[3])],
"line_file": f"{args.slug}_lines.geojson",
"grid_file": f"{args.slug}_grid.geojson",
}
out_lines = {
"type": "FeatureCollection",
"name": "fish_port_navsplit_lines",
"features": fish_line_features + coast_line_features,
}
out_grid = {
"type": "FeatureCollection",
"name": "fish_port_navsplit_grid",
"features": grid_features,
}
(out_dir / f"{args.slug}_lines.geojson").write_text(json.dumps(out_lines, ensure_ascii=False), encoding="utf-8")
(out_dir / f"{args.slug}_grid.geojson").write_text(json.dumps(out_grid, ensure_ascii=False), encoding="utf-8")
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_grid.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
NavSea fish-port navigation split export v1.0 (full Fukuoka/Saga AOI)
目标:
- 直接使用已导出的全量港区 AOI 多边形
- 选取福冈 / 佐贺范围内的渔港 AOI
- 再叠加 C23-06_40 / C23-06_41 海岸线
- 输出 20m x 20m 栅格,区分海面 / 陆基
这是一个更稳的全量预览版本,适合先把全域范围铺出来。
"""
from __future__ import annotations
import argparse
import json
import math
import zipfile
from pathlib import Path
import xml.etree.ElementTree as ET
from shapely.geometry import box, mapping, shape
from shapely.ops import transform, unary_union
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (129.73110007140482, 32.9464631795233, 131.19176072338317, 34.25430274729531)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_to_lonlat(pt: tuple[float, float]) -> tuple[float, float]:
return lon_from_mercator(pt[0]), lat_from_mercator(pt[1])
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def bbox_intersects(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def ring_lonlat_from_mercator(minx: float, miny: float, maxx: float, maxy: float) -> list[list[float]]:
ring = [
mercator_to_lonlat((minx, miny)),
mercator_to_lonlat((maxx, miny)),
mercator_to_lonlat((maxx, maxy)),
mercator_to_lonlat((minx, maxy)),
mercator_to_lonlat((minx, miny)),
]
return [[lon, lat] for lon, lat in ring]
def main() -> None:
parser = argparse.ArgumentParser(description="Build full Fukuoka/Saga fish-port navsplit assets from AOI polygons")
parser.add_argument("--output-dir", default="src/pbf/coastline-only/fukuoka_saga_fish_port_navsplit_full_20m")
parser.add_argument("--slug", default="fish_port_navsplit_fukuoka_saga_full_20m")
parser.add_argument(
"--aoi-source",
default="src/pbf/port-overlay/kyushu/port_overlay_kyushu_areas.geojson",
help="全量渔港 AOI 多边形 GeoJSON",
)
parser.add_argument("--coastline-sources", nargs="+", default=["coastline/C23-06_40_GML.zip", "coastline/C23-06_41_GML.zip"])
parser.add_argument("--bbox", nargs=4, type=float, default=list(DEFAULT_BBOX))
parser.add_argument("--cell-size-m", type=float, default=20.0)
parser.add_argument("--coast-buffer-m", type=float, default=12.0)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
aoi_path = Path(args.aoi_source)
if not aoi_path.is_absolute():
aoi_path = project_root / aoi_path
if not aoi_path.exists():
raise SystemExit(f"missing input: {aoi_path}")
coastline_sources = []
for item in args.coastline_sources:
path = Path(item)
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
coastline_sources.append(path)
bbox = tuple(args.bbox)
aoi_obj = json.loads(aoi_path.read_text(encoding="utf-8"))
fish_polygons = []
fish_bbox_minx = fish_bbox_miny = float("inf")
fish_bbox_maxx = fish_bbox_maxy = float("-inf")
for feature in aoi_obj.get("features", []):
props = feature.get("properties") or {}
if props.get("canonical_object_type") != "漁港":
continue
geom = feature.get("geometry") or {}
if geom.get("type") != "Polygon":
continue
shp = shape(geom)
bb = shp.bounds
if not bbox_intersects(bb, bbox):
continue
fish_polygons.append(transform(lambda x, y, z=None: (mercator_x(x), mercator_y(y)), shp))
fish_bbox_minx = min(fish_bbox_minx, bb[0])
fish_bbox_miny = min(fish_bbox_miny, bb[1])
fish_bbox_maxx = max(fish_bbox_maxx, bb[2])
fish_bbox_maxy = max(fish_bbox_maxy, bb[3])
if not fish_polygons:
raise SystemExit("no fishing-port AOI polygons selected")
fish_geom = unary_union(fish_polygons)
fish_prepared = prep(fish_geom)
coast_line_features: list[dict] = []
coast_mercator_lines = []
coast_line_count = 0
for path in coastline_sources:
root = load_xml(path)
for idx, curve in enumerate(root.findall(".//{http://www.opengis.net/gml/3.2}Curve"), start=1):
coords = []
point_lookup = build_point_lookup(root)
for pos_list in curve.findall(".//{http://www.opengis.net/gml/3.2}posList"):
if not pos_list.text:
continue
values = [float(part) for part in pos_list.text.split() if part]
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) < 2:
# fallback for non-posList curves
coords = [ (mercator_x(lon), mercator_y(lat)) for lon, lat in extract_curve_coords(curve, point_lookup) ]
if len(coords) < 2:
continue
coast_mercator_lines.append(coords)
coast_line_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": path.stem.replace("_GML", ""),
"source_kind": "coastline",
"feature_family": "coastline_line",
"curve_id": curve.attrib.get("{http://www.opengis.net/gml/3.2}id", f"curve_{idx:06d}"),
},
"geometry": mapping(shape({"type": "LineString", "coordinates": [mercator_to_lonlat(pt) for pt in coords]})),
}
)
coast_line_count += 1
coast_geom = unary_union([shape({"type": "LineString", "coordinates": pts}) for pts in coast_mercator_lines])
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
bounds = fish_geom.bounds
cell = args.cell_size_m
minx = align_floor(bounds[0], cell)
miny = align_floor(bounds[1], cell)
maxx = align_ceil(bounds[2], cell)
maxy = align_ceil(bounds[3], cell)
grid_features: list[dict] = []
land_count = 0
sea_count = 0
total_count = 0
row = 0
y = miny
while y < maxy:
col = 0
x = minx
while x < maxx:
cell_geom = box(x, y, x + cell, y + cell)
if fish_prepared.intersects(cell_geom):
is_land = coast_prepared.intersects(cell_geom)
classification = "LAND_BASE" if is_land else "SEA_SURFACE"
land_count += int(is_land)
sea_count += int(not is_land)
total_count += 1
grid_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": "AOI+coastline",
"source_kind": "navsplit",
"feature_family": "fish_port_navsplit_20m",
"classification": classification,
"row": row,
"col": col,
},
"geometry": {
"type": "Polygon",
"coordinates": [ring_lonlat_from_mercator(x, y, x + cell, y + cell)],
},
}
)
x += cell
col += 1
y += cell
row += 1
manifest = {
"version": "v1.0",
"aoi_source": str(aoi_path),
"coastline_sources": [str(p) for p in coastline_sources],
"cell_size_m": cell,
"coast_buffer_m": args.coast_buffer_m,
"fish_polygon_count": len(fish_polygons),
"coast_line_count": coast_line_count,
"grid_count": total_count,
"land_count": land_count,
"sea_count": sea_count,
"bbox_lonlat": list(bbox),
"fish_bbox_lonlat": [fish_bbox_minx, fish_bbox_miny, fish_bbox_maxx, fish_bbox_maxy],
"line_file": f"{args.slug}_lines.geojson",
"grid_file": f"{args.slug}_grid.geojson",
}
(out_dir / f"{args.slug}_lines.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_navsplit_lines",
"features": coast_line_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_grid.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_navsplit_grid",
"features": grid_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_grid.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,433 @@
#!/usr/bin/env python3
"""
NavSea fish-port navigation split export v1.0 (full Fukuoka/Saga)
目标:
- 用官方港湾 / 渔港叠加资产中的渔港线要素,提取福冈 / 佐贺全量区域
- 再叠加 C23-06_40 / C23-06_41 海岸线
- 将港区切成 20m x 20m 格网,并区分海面 / 陆基
与样区版的区别:
- 不再限制 PRC=40/41
- 直接使用已生成的官方渔港 GeoJSON 作为渔港线输入
- 更适合做“福冈 / 佐贺全量渔港”预览
"""
from __future__ import annotations
import argparse
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, box, mapping
from shapely.ops import polygonize, unary_union
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (129.73110007140482, 32.9464631795233, 131.19176072338317, 34.25430274729531)
@dataclass(frozen=True)
class SourceSpec:
path: Path
dataset: str
kind: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def mercator(lon: float, lat: float) -> tuple[float, float]:
return mercator_x(lon), mercator_y(lat)
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_to_lonlat(pt: tuple[float, float]) -> tuple[float, float]:
return lon_from_mercator(pt[0]), lat_from_mercator(pt[1])
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def load_geojson(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def bbox_intersects(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def cluster_line_indices(bboxes: list[tuple[float, float, float, float]], gap_m: float = 250.0) -> list[list[int]]:
parent = list(range(len(bboxes)))
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a: int, b: int) -> None:
ra = find(a)
rb = find(b)
if ra != rb:
parent[rb] = ra
expanded = [
(bb[0] - gap_m, bb[1] - gap_m, bb[2] + gap_m, bb[3] + gap_m)
for bb in bboxes
]
for i in range(len(expanded)):
for j in range(i + 1, len(expanded)):
if bbox_intersects(expanded[i], expanded[j]):
union(i, j)
groups: dict[int, list[int]] = {}
for idx in range(len(parent)):
root = find(idx)
groups.setdefault(root, []).append(idx)
return list(groups.values())
def ring_lonlat_from_mercator(minx: float, miny: float, maxx: float, maxy: float) -> list[list[float]]:
ring = [
mercator_to_lonlat((minx, miny)),
mercator_to_lonlat((maxx, miny)),
mercator_to_lonlat((maxx, maxy)),
mercator_to_lonlat((minx, maxy)),
mercator_to_lonlat((minx, miny)),
]
return [[lon, lat] for lon, lat in ring]
def main() -> None:
parser = argparse.ArgumentParser(description="Build full Fukuoka/Saga fish-port navigation split preview assets")
parser.add_argument("--output-dir", default="src/pbf/coastline-only/fukuoka_saga_fish_port_navsplit_full_20m")
parser.add_argument("--slug", default="fish_port_navsplit_fukuoka_saga_full_20m")
parser.add_argument(
"--official-lines",
default="src/pbf/port-overlay-official/kyushu/official_port_overlay_kyushu_lines.geojson",
help="官方港湾/渔港叠加导出的 lines GeoJSON",
)
parser.add_argument("--coastline-sources", nargs="+", default=["coastline/C23-06_40_GML.zip", "coastline/C23-06_41_GML.zip"])
parser.add_argument("--bbox", nargs=4, type=float, default=list(DEFAULT_BBOX))
parser.add_argument("--cell-size-m", type=float, default=20.0)
parser.add_argument("--coast-buffer-m", type=float, default=12.0)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
official_lines_path = Path(args.official_lines)
if not official_lines_path.is_absolute():
official_lines_path = project_root / official_lines_path
if not official_lines_path.exists():
raise SystemExit(f"missing input: {official_lines_path}")
coastline_sources = []
for item in args.coastline_sources:
path = Path(item)
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
coastline_sources.append(path)
bbox = tuple(args.bbox)
official = load_geojson(official_lines_path)
fish_line_features: list[dict] = []
fish_mercator_lines: list[LineString] = []
fish_mercator_bboxes: list[tuple[float, float, float, float]] = []
fish_bbox_minx = fish_bbox_miny = float("inf")
fish_bbox_maxx = fish_bbox_maxy = float("-inf")
for feature in official.get("features", []):
props = feature.get("properties") or {}
if props.get("source_dataset") != "C09-06":
continue
if props.get("feature_family") != "fish_port_line":
continue
geom = feature.get("geometry") or {}
coords = geom.get("coordinates") or []
if len(coords) < 2:
continue
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
geom_bbox = (min(xs), min(ys), max(xs), max(ys))
if not bbox_intersects(geom_bbox, bbox):
continue
fish_bbox_minx = min(fish_bbox_minx, geom_bbox[0])
fish_bbox_miny = min(fish_bbox_miny, geom_bbox[1])
fish_bbox_maxx = max(fish_bbox_maxx, geom_bbox[2])
fish_bbox_maxy = max(fish_bbox_maxy, geom_bbox[3])
fish_line_features.append(feature)
line_mercator = LineString([mercator(lon, lat) for lon, lat in coords])
fish_mercator_lines.append(line_mercator)
fish_mercator_bboxes.append(line_mercator.bounds)
if not fish_mercator_lines:
raise SystemExit("no fish-port lines selected for full Fukuoka/Saga export")
print(f"selected fish lines: {len(fish_mercator_lines)}")
fish_components = cluster_line_indices(fish_mercator_bboxes, gap_m=250.0)
print(f"fish components: {len(fish_components)}")
component_geoms = []
component_sizes = []
for component in fish_components:
lines = [fish_mercator_lines[i] for i in component]
merged = unary_union(lines)
polygons = list(polygonize(merged))
if polygons:
component_geoms.append(unary_union(polygons))
component_sizes.append(len(component))
else:
component_geoms.append(merged.buffer(args.cell_size_m, cap_style=2, join_style=2))
component_sizes.append(len(component))
fish_geom = unary_union(component_geoms)
fish_polygon_candidates = component_geoms
fish_mode = f"clustered_polygonize:{len(fish_components)}"
if fish_geom.is_empty:
raise SystemExit("fish-port geometry is empty")
fish_prepared = prep(fish_geom)
coast_line_features: list[dict] = []
coast_mercator_lines: list[LineString] = []
coast_line_count = 0
for path in coastline_sources:
root = load_xml(path)
for idx, curve in enumerate(root.findall(".//{http://www.opengis.net/gml/3.2}Curve"), start=1):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//{http://www.opengis.net/gml/3.2}posList"):
if not pos_list.text:
continue
values = [float(part) for part in pos_list.text.split() if part]
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) < 2:
continue
line = LineString(coords)
coast_mercator_lines.append(line)
coast_line_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": path.stem.replace("_GML", ""),
"source_kind": "coastline",
"feature_family": "coastline_line",
"curve_id": curve.attrib.get("{http://www.opengis.net/gml/3.2}id", f"curve_{idx:06d}"),
},
"geometry": mapping(LineString([mercator_to_lonlat(pt) for pt in coords])),
}
)
coast_line_count += 1
if not coast_mercator_lines:
raise SystemExit("no coastline curves were parsed")
coast_geom = unary_union(coast_mercator_lines)
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
bounds = fish_geom.bounds
cell = args.cell_size_m
minx = align_floor(bounds[0], cell)
miny = align_floor(bounds[1], cell)
maxx = align_ceil(bounds[2], cell)
maxy = align_ceil(bounds[3], cell)
grid_features: list[dict] = []
land_count = 0
sea_count = 0
total_count = 0
row = 0
y = miny
while y < maxy:
col = 0
x = minx
while x < maxx:
cell_geom = box(x, y, x + cell, y + cell)
if fish_prepared.intersects(cell_geom):
is_land = coast_prepared.intersects(cell_geom)
classification = "LAND_BASE" if is_land else "SEA_SURFACE"
if is_land:
land_count += 1
else:
sea_count += 1
total_count += 1
grid_features.append(
{
"type": "Feature",
"properties": {
"source_dataset": "C09-06+coastline",
"source_kind": "navsplit",
"feature_family": "fish_port_navsplit_20m",
"classification": classification,
"row": row,
"col": col,
},
"geometry": {
"type": "Polygon",
"coordinates": [ring_lonlat_from_mercator(x, y, x + cell, y + cell)],
},
}
)
x += cell
col += 1
y += cell
row += 1
manifest = {
"version": "v1.0",
"official_lines_source": str(official_lines_path),
"coastline_sources": [str(p) for p in coastline_sources],
"cell_size_m": cell,
"coast_buffer_m": args.coast_buffer_m,
"fish_mode": fish_mode,
"fish_line_count": len(fish_line_features),
"coast_line_count": coast_line_count,
"fish_polygon_count": len(fish_polygon_candidates),
"fish_component_count": len(fish_components),
"fish_component_sizes": component_sizes[:20],
"grid_count": total_count,
"land_count": land_count,
"sea_count": sea_count,
"bbox_lonlat": list(bbox),
"fish_bbox_lonlat": [fish_bbox_minx, fish_bbox_miny, fish_bbox_maxx, fish_bbox_maxy],
"line_file": f"{args.slug}_lines.geojson",
"grid_file": f"{args.slug}_grid.geojson",
}
(out_dir / f"{args.slug}_lines.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_navsplit_lines",
"features": fish_line_features + coast_line_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_grid.geojson").write_text(
json.dumps(
{
"type": "FeatureCollection",
"name": "fish_port_navsplit_grid",
"features": grid_features,
},
ensure_ascii=False,
),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_grid.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
NavSea coastline subset export v1.0
把福冈/佐贺海岸样例栅格导出为浏览器可直接加载的 GeoJSON 资产:
- blocked cells (WGS84 GeoJSON)
- coastline lines (WGS84 GeoJSON)
- manifest JSON
"""
from __future__ import annotations
import argparse
import json
import math
import sqlite3
from pathlib import Path
from shapely import from_wkt
from shapely.geometry import mapping, Polygon, LineString
RADIUS = 6378137.0
def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
lon = math.degrees(x / RADIUS)
lat = math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
return lon, lat
def cell_polygon_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> Polygon:
lon1, lat1 = mercator_to_lonlat(minx, miny)
lon2, lat2 = mercator_to_lonlat(maxx, maxy)
return Polygon(
[
(lon1, lat1),
(lon2, lat1),
(lon2, lat2),
(lon1, lat2),
(lon1, lat1),
]
)
def line_lonlat_from_wkt(wkt: str) -> LineString:
geom = from_wkt(wkt)
coords = [mercator_to_lonlat(x, y) for x, y in geom.coords]
return LineString(coords)
def main() -> None:
parser = argparse.ArgumentParser(description="Export coastline grid assets to GeoJSON")
parser.add_argument(
"--source-db",
default="out/coastline/fukuoka_saga_test/fukuoka_saga_coast_grid.sqlite",
help="输入 SQLite",
)
parser.add_argument(
"--output-dir",
default="src/pbf/coastline",
help="输出目录",
)
parser.add_argument(
"--slug",
default="fukuoka_saga",
help="输出文件前缀,如 fukuoka_saga / north_kyushu",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
source_db = Path(args.source_db)
if not source_db.is_absolute():
source_db = project_root / source_db
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if not source_db.exists():
raise SystemExit(f"missing source db: {source_db}")
conn = sqlite3.connect(source_db)
conn.row_factory = sqlite3.Row
manifest = {
"version": "v1.0",
"source_db": str(source_db),
"grid_file": f"{args.slug}_blocked_cells.geojson",
"coastline_file": f"{args.slug}_coastline_lines.geojson",
"source_scope": conn.execute("SELECT value FROM meta WHERE key='source_scope'").fetchone()[0],
"source_code": conn.execute("SELECT value FROM meta WHERE key='source_code'").fetchone()[0],
"cell_size_m": float(conn.execute("SELECT value FROM meta WHERE key='cell_size_m'").fetchone()[0]),
"buffer_m": float(conn.execute("SELECT value FROM meta WHERE key='buffer_m'").fetchone()[0]),
"grid_bounds_mercator": json.loads(
conn.execute("SELECT value FROM meta WHERE key='grid_bounds_mercator'").fetchone()[0]
),
}
blocked_features = []
blocked_rows = conn.execute(
"""
SELECT cell_id, ix, iy, minx, miny, maxx, maxy, state, state_name, source_mask, blocked_permille
FROM grid_cell
WHERE state = 2
ORDER BY ix, iy
"""
).fetchall()
for row in blocked_rows:
geom = cell_polygon_lonlat(row["minx"], row["miny"], row["maxx"], row["maxy"])
blocked_features.append(
{
"type": "Feature",
"properties": {
"cell_id": row["cell_id"],
"ix": row["ix"],
"iy": row["iy"],
"state": row["state"],
"state_name": row["state_name"],
"source_mask": row["source_mask"],
"blocked_permille": row["blocked_permille"],
},
"geometry": mapping(geom),
}
)
coastline_features = []
coastline_rows = conn.execute(
"""
SELECT source_code, prefecture, curve_id, geom_wkt
FROM coastline_feature
ORDER BY feature_id
"""
).fetchall()
for row in coastline_rows:
geom = line_lonlat_from_wkt(row["geom_wkt"])
coastline_features.append(
{
"type": "Feature",
"properties": {
"source_code": row["source_code"],
"prefecture": row["prefecture"],
"curve_id": row["curve_id"],
},
"geometry": mapping(geom),
}
)
blocked_geojson = {
"type": "FeatureCollection",
"name": "fukuoka_saga_blocked_cells",
"features": blocked_features,
}
coastline_geojson = {
"type": "FeatureCollection",
"name": "fukuoka_saga_coastline_lines",
"features": coastline_features,
}
(out_dir / f"{args.slug}_blocked_cells.geojson").write_text(
json.dumps(blocked_geojson, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_coastline_lines.geojson").write_text(
json.dumps(coastline_geojson, ensure_ascii=False),
encoding="utf-8",
)
lonlat_min = mercator_to_lonlat(manifest["grid_bounds_mercator"][0], manifest["grid_bounds_mercator"][1])
lonlat_max = mercator_to_lonlat(manifest["grid_bounds_mercator"][2], manifest["grid_bounds_mercator"][3])
manifest["grid_bounds_lonlat"] = [lonlat_min[0], lonlat_min[1], lonlat_max[0], lonlat_max[1]]
manifest["blocked_count"] = len(blocked_features)
manifest["coastline_count"] = len(coastline_features)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
conn.close()
print("已导出:")
print(" -", out_dir / f"{args.slug}_blocked_cells.geojson")
print(" -", out_dir / f"{args.slug}_coastline_lines.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/japan_coast_200m"
LAYER_NAME = "coast_200m"
RADIUS = 6378137.0
def mysql_connect(database: str):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.SSCursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def parse_bbox(value: str) -> tuple[float, float, float, float]:
parts = [part.strip() for part in value.split(",")]
if len(parts) != 4:
raise argparse.ArgumentTypeError("bbox must be min_lon,min_lat,max_lon,max_lat")
try:
return tuple(float(part) for part in parts) # type: ignore[return-value]
except ValueError as exc:
raise argparse.ArgumentTypeError("bbox must contain valid numbers") from exc
def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
lon = math.degrees(x / RADIUS)
lat = math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
return lon, lat
def lon_to_mercator(lon: float) -> float:
return RADIUS * math.radians(lon)
def lat_to_mercator(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def main() -> None:
parser = argparse.ArgumentParser(description="Export Japan coast 200m grid assets from MySQL")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--bbox", type=parse_bbox, default=None, help="仅导出该经纬度范围内的格子")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
conn = mysql_connect(args.db_name)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file
FROM navsea_grid_layer_meta
WHERE layer_name=%s
""",
(LAYER_NAME,),
)
meta_row = cur.fetchone()
if meta_row is None:
raise SystemExit(f"missing layer meta: {LAYER_NAME}")
(
description,
source_desc,
cell_size_m,
feature_count,
min_lon,
min_lat,
max_lon,
max_lat,
source_manifest,
) = meta_row
export_path = out_dir / "coast_200m_grid.geojson"
cell_sql = """
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m,
state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
"""
cell_params: list[float | str] = [LAYER_NAME]
if args.bbox is not None:
min_lon, min_lat, max_lon, max_lat = args.bbox
min_x = lon_to_mercator(min_lon)
min_y = lat_to_mercator(min_lat)
max_x = lon_to_mercator(max_lon)
max_y = lat_to_mercator(max_lat)
cell_sql += """
AND NOT (
max_lon < %s OR min_lon > %s OR
max_lat < %s OR min_lat > %s
)
"""
cell_params.extend([min_x, max_x, min_y, max_y])
cell_sql += """
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor() as cur, export_path.open("w", encoding="utf-8") as fh:
cur.execute(cell_sql, tuple(cell_params))
fh.write('{"type":"FeatureCollection","name":"japan_coast_200m_grid","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m_row,
state_name,
class_name,
source_name,
min_lon_row,
min_lat_row,
max_lon_row,
max_lat_row,
) = row
lon1, lat1 = mercator_to_lonlat(float(min_lon_row), float(min_lat_row))
lon2, lat2 = mercator_to_lonlat(float(max_lon_row), float(max_lat_row))
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m_row),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(lon1, lat1, lon2, lat2),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
if args.bbox is None and count != int(feature_count):
raise RuntimeError(f"count mismatch: exported={count} meta={feature_count}")
if args.bbox is None:
lonlat_min = mercator_to_lonlat(float(min_lon), float(min_lat))
lonlat_max = mercator_to_lonlat(float(max_lon), float(max_lat))
mercator_min = [float(min_lon), float(min_lat)]
mercator_max = [float(max_lon), float(max_lat)]
else:
lonlat_min = (float(min_lon), float(min_lat))
lonlat_max = (float(max_lon), float(max_lat))
mercator_min = [lon_to_mercator(float(min_lon)), lat_to_mercator(float(min_lat))]
mercator_max = [lon_to_mercator(float(max_lon)), lat_to_mercator(float(max_lat))]
manifest = {
"version": "v1.0",
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layer": LAYER_NAME,
"description": description,
"source_desc": source_desc,
"source_manifest": source_manifest,
"count": count,
"cell_size_m": float(cell_size_m),
"grid_bounds_lonlat": [lonlat_min[0], lonlat_min[1], lonlat_max[0], lonlat_max[1]],
"grid_bounds_mercator": [mercator_min[0], mercator_min[1], mercator_max[0], mercator_max[1]],
"grid_file": "coast_200m_grid.geojson",
"notes": "从 navsea_japan_coast_grid.MySQL 直接导出的全国海岸 200m 硬阻塞格",
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导出:")
print(" -", export_path)
print(" -", manifest_path)
print(f" - count={count}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -85,7 +85,7 @@ def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: flo
def fetch_stats(conn, where_sql: str, params: tuple) -> tuple[int, list[float] | None]:
sql = f"""
SELECT COUNT(*), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
SELECT COUNT(DISTINCT cell_id), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE {where_sql}
"""
@@ -118,9 +118,10 @@ def export_layer(
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE {where_sql}
ORDER BY row_idx, col_idx
ORDER BY row_idx, col_idx, source_name
"""
count = 0
seen_cell_ids: set[str] = set()
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, params)
fh.write('{"type":"FeatureCollection","features":[\n')
@@ -161,6 +162,9 @@ def export_layer(
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if cell_id in seen_cell_ids:
continue
seen_cell_ids.add(cell_id)
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
@@ -201,6 +205,9 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
kept_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
seen_20: set[str] = set()
seen_50: set[str] = set()
seen_200: set[str] = set()
def write_feature(fh, feature: dict, first: bool) -> bool:
if not first:
@@ -219,7 +226,7 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
ORDER BY row_idx, col_idx, source_name
""",
("fish_port_20m",),
)
@@ -238,6 +245,9 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
max_lon,
max_lat,
) = row
if cell_id in seen_20:
continue
seen_20.add(cell_id)
keys_20.add((int(row_idx), int(col_idx)))
kept_counts["20"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
@@ -277,7 +287,7 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
ORDER BY row_idx, col_idx, source_name
""",
("hazard_50m",),
)
@@ -296,6 +306,9 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
max_lon,
max_lat,
) = row
if cell_id in seen_50:
continue
seen_50.add(cell_id)
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["hazard_50m"], CELL_SIZES["fish_port_20m"], keys_20):
@@ -339,7 +352,7 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
ORDER BY row_idx, col_idx, source_name
""",
("coast_200m",),
)
@@ -358,6 +371,9 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
max_lon,
max_lat,
) = row
if cell_id in seen_200:
continue
seen_200.add(cell_id)
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["coast_200m"], CELL_SIZES["fish_port_20m"], keys_20):
@@ -408,10 +424,41 @@ def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
return total, summary
def manifest_export_path(project_root: Path, path: Path) -> str:
try:
return str(path.relative_to(project_root))
except ValueError:
return str(path)
def main() -> None:
parser = argparse.ArgumentParser(description="Export NavSea MySQL grid layers to static GeoJSON assets")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument(
"--coast-200m",
action=argparse.BooleanOptionalAction,
default=True,
help="是否导出 coast_200m,默认开启;可用 --no-coast-200m 关闭",
)
parser.add_argument(
"--fish-port-20m",
action=argparse.BooleanOptionalAction,
default=True,
help="是否导出 fish_port_20m,默认开启;可用 --no-fish-port-20m 关闭",
)
parser.add_argument(
"--hazard-50m",
action=argparse.BooleanOptionalAction,
default=True,
help="是否导出 hazard_50m,默认开启;可用 --no-hazard-50m 关闭",
)
parser.add_argument(
"--density-overview",
action=argparse.BooleanOptionalAction,
default=True,
help="是否导出 density_overview,默认开启;可用 --no-density-overview 关闭",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
@@ -420,31 +467,36 @@ def main() -> None:
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
layers = {
layer_specs = {
"coast_200m": {
"file": "coast_200m_grid.geojson",
"where": "layer_name=%s",
"params": ("coast_200m",),
"cell_size_m": 200.0,
"enabled": args.coast_200m,
},
"fish_port_20m": {
"file": "fish_port_20m_grid.geojson",
"where": "layer_name=%s",
"params": ("fish_port_20m",),
"cell_size_m": 20.0,
"enabled": args.fish_port_20m,
},
"hazard_50m": {
"file": "hazard_50m_grid.geojson",
"where": "layer_name=%s",
"params": ("hazard_50m",),
"cell_size_m": 50.0,
"enabled": args.hazard_50m,
},
}
manifest_layers: dict[str, dict] = {}
conn = mysql_connect(args.db_name)
try:
for layer_name, spec in layers.items():
for layer_name, spec in layer_specs.items():
if not spec["enabled"]:
continue
out_path = out_dir / spec["file"]
count = export_layer(conn, path=out_path, where_sql=spec["where"], params=spec["params"])
stat_count, bbox = fetch_stats(conn, spec["where"], spec["params"])
@@ -454,23 +506,27 @@ def main() -> None:
"count": count,
"bbox_lonlat": bbox,
"cell_size_m": spec["cell_size_m"],
"export_file": str(out_path.relative_to(project_root)),
"export_file": manifest_export_path(project_root, out_path),
}
print(f"{layer_name}: {count} -> {out_path}")
density_path = out_dir / DENSITY_OVERVIEW_FILE
density_count, density_summary = export_density_overview(conn, path=density_path)
manifest_layers["density_overview"] = {
"count": density_count,
"bbox_lonlat": density_summary["bbox"],
"cell_size_m": None,
"export_file": str(density_path.relative_to(project_root)),
"by_density": density_summary["by_density"],
}
print(f"density_overview: {density_count} -> {density_path}")
if args.density_overview:
density_path = out_dir / DENSITY_OVERVIEW_FILE
density_count, density_summary = export_density_overview(conn, path=density_path)
manifest_layers["density_overview"] = {
"count": density_count,
"bbox_lonlat": density_summary["bbox"],
"cell_size_m": None,
"export_file": manifest_export_path(project_root, density_path),
"by_density": density_summary["by_density"],
}
print(f"density_overview: {density_count} -> {density_path}")
finally:
conn.close()
if not manifest_layers:
raise RuntimeError("no layers selected for export")
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),

View File

@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
NavSea official port overlay export v1.0
从官方港湾 / 漁港原始包导出可视化叠加资产:
- C02-08.zip: 港湾
- C09-06.zip: 漁港
默认裁切到九州样区,便于人工核查。
"""
from __future__ import annotations
import argparse
import json
import math
import sqlite3 # kept for symmetry with other exporter scripts; not used directly
import zipfile
from dataclasses import dataclass
from pathlib import Path
import xml.etree.ElementTree as ET
from shapely.geometry import LineString, Point, mapping
RADIUS = 6378137.0
DEFAULT_BBOX = (123.0, 31.0, 132.7, 34.9) # Kyushu preview window
@dataclass(frozen=True)
class SourceSpec:
path: Path
dataset: str
kind: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def mercator(lon: float, lat: float) -> tuple[float, float]:
return mercator_x(lon), mercator_y(lat)
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def lonlat_to_lonlat_text(text: str) -> tuple[float, float]:
# helper kept for readability when parsing GML/JPS coordinates
return lonlat_from_text(text)
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_xml(zip_path: Path) -> ET.Element:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def build_point_lookup(root: ET.Element) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
tag = local_name(el.tag)
if tag == "DirectPosition.coordinate" and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lon, lat = lonlat_from_text(coord_text)
lookup[point_id] = (lon, lat)
return lookup
def extract_curve_coords(curve: ET.Element, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
lon, lat = lonlat_from_text(direct)
coords.append((lon, lat))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def bbox_intersects(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def point_in_bbox(pt: tuple[float, float], bbox: tuple[float, float, float, float]) -> bool:
lon, lat = pt
return bbox[0] <= lon <= bbox[2] and bbox[1] <= lat <= bbox[3]
def normalize_harbor_feature(tag: str, props: dict[str, str]) -> dict[str, str]:
if tag == "CK01":
props["feature_family"] = "harbor_point"
elif tag in ("CK11", "CK21"):
props["feature_family"] = "harbor_line"
elif tag == "CB01":
props["feature_family"] = "fish_port_point"
elif tag == "CB03":
props["feature_family"] = "fish_port_line"
else:
props["feature_family"] = "unknown"
return props
def main() -> None:
parser = argparse.ArgumentParser(description="Export official harbor/fishing-port preview assets for Kyushu")
parser.add_argument(
"--output-dir",
default="src/pbf/port-overlay-official/kyushu",
help="输出目录",
)
parser.add_argument(
"--slug",
default="official_port_overlay_kyushu",
help="输出文件前缀",
)
parser.add_argument(
"--bbox",
nargs=4,
type=float,
default=list(DEFAULT_BBOX),
metavar=("MIN_LON", "MIN_LAT", "MAX_LON", "MAX_LAT"),
help="预览裁切范围,默认九州",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
bbox = tuple(args.bbox) # type: ignore[assignment]
sources = [
SourceSpec(project_root / "coastline" / "C02-08.zip", "C02-08", "harbor"),
SourceSpec(project_root / "coastline" / "C09-06.zip", "C09-06", "fish_port"),
]
line_features: list[dict] = []
point_features: list[dict] = []
stats = []
for source in sources:
if not source.path.exists():
raise SystemExit(f"missing input: {source.path}")
root = load_xml(source.path)
point_lookup = build_point_lookup(root)
obj = root.find(".//{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}OBJ")
if obj is None:
raise SystemExit(f"OBJ block missing in {source.path}")
line_count = 0
point_count = 0
bbox_minx = bbox_miny = float("inf")
bbox_maxx = bbox_maxy = float("-inf")
tags = ["CK11", "CK21"] if source.dataset == "C02-08" else ["CB03"]
point_tags = ["CK01"] if source.dataset == "C02-08" else ["CB01"]
for tag in tags:
for feature in obj.findall(f".//{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{tag}"):
loc = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}LOC")
if loc is None:
continue
ref = loc.attrib.get("idref")
if not ref:
continue
curve = obj.find(f".//*[@id='{ref}']")
if curve is None:
continue
coords = extract_curve_coords(curve, point_lookup)
if len(coords) < 2:
continue
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
geom_bbox = (min(xs), min(ys), max(xs), max(ys))
if not bbox_intersects(geom_bbox, bbox):
continue
props = {"source_dataset": source.dataset, "source_kind": source.kind, "tag": tag}
for key in ("PRC", "HBC", "FPC"):
el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}")
if el is not None and (el.text or "").strip():
props[key.lower()] = el.text.strip()
props = normalize_harbor_feature(tag, props)
if len(coords) == 1:
lon, lat = coords[0]
geometry = mapping(Point(lon, lat))
else:
geometry = mapping(LineString(coords))
line_features.append({"type": "Feature", "properties": props, "geometry": geometry})
line_count += 1
bbox_minx = min(bbox_minx, geom_bbox[0])
bbox_miny = min(bbox_miny, geom_bbox[1])
bbox_maxx = max(bbox_maxx, geom_bbox[2])
bbox_maxy = max(bbox_maxy, geom_bbox[3])
for tag in point_tags:
for feature in obj.findall(f".//{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{tag}"):
pos = feature.find("{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}POS")
if pos is None:
continue
ref = pos.attrib.get("idref")
if not ref or ref not in point_lookup:
continue
lon, lat = point_lookup[ref]
if not point_in_bbox((lon, lat), bbox):
continue
props = {"source_dataset": source.dataset, "source_kind": source.kind, "tag": tag}
for key in ("NA4", "AD7", "NA2", "FCF", "AAC", "CH1", "CH2", "CFP", "FPA", "PRC", "HBC", "FPC"):
el = feature.find(f"{{http://nlftp.mlit.go.jp/ksj/schemas/ksj-app}}{key}")
if el is not None and (el.text or "").strip():
props[key.lower()] = el.text.strip()
props = normalize_harbor_feature(tag, props)
point_features.append(
{
"type": "Feature",
"properties": props,
"geometry": mapping(Point(lon, lat)),
}
)
point_count += 1
bbox_minx = min(bbox_minx, lon)
bbox_miny = min(bbox_miny, lat)
bbox_maxx = max(bbox_maxx, lon)
bbox_maxy = max(bbox_maxy, lat)
stats.append(
{
"dataset": source.dataset,
"kind": source.kind,
"path": str(source.path),
"line_count": line_count,
"point_count": point_count,
"bbox_lonlat": None if line_count + point_count == 0 else [bbox_minx, bbox_miny, bbox_maxx, bbox_maxy],
}
)
line_geojson = {
"type": "FeatureCollection",
"name": "official_port_overlay_lines",
"features": line_features,
}
point_geojson = {
"type": "FeatureCollection",
"name": "official_port_overlay_points",
"features": point_features,
}
manifest = {
"version": "v1.0",
"source_packages": [str(s.path) for s in sources],
"bbox_lonlat": list(bbox),
"line_count": len(line_features),
"point_count": len(point_features),
"stats": stats,
"line_file": f"{args.slug}_lines.geojson",
"point_file": f"{args.slug}_points.geojson",
}
(out_dir / f"{args.slug}_lines.geojson").write_text(
json.dumps(line_geojson, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_points.geojson").write_text(
json.dumps(point_geojson, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_dir / f"{args.slug}_lines.geojson")
print(" -", out_dir / f"{args.slug}_points.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
NavSea port overlay export v1.0
从九州工程库快照导出港湾/渔港叠加预览资产:
- 港湾 AOI 面
- 港湾 AOI 中心点
- 元数据清单
用途:
- 在 MapLibre 中快速检查港湾、渔港、小港湾的覆盖范围
- 作为后续港湾语义栅格化的人工核查层
"""
from __future__ import annotations
import argparse
import json
import math
import sqlite3
from pathlib import Path
from shapely.geometry import Point, Polygon, mapping
RADIUS = 6378137.0
def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
lon = math.degrees(x / RADIUS)
lat = math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
return lon, lat
def polygon_to_lonlat(poly: Polygon) -> Polygon:
exterior = [mercator_to_lonlat(x, y) for x, y in poly.exterior.coords]
interiors = [
[mercator_to_lonlat(x, y) for x, y in ring.coords]
for ring in poly.interiors
]
return Polygon(exterior, interiors)
def point_to_lonlat(point: Point) -> Point:
lon, lat = mercator_to_lonlat(point.x, point.y)
return Point(lon, lat)
def normalize_category(canonical_type: str) -> str:
if canonical_type == "漁港":
return "fishing_port"
if canonical_type == "一般港湾 (港則法区域、重要港湾)":
return "general_harbor"
if canonical_type == "小港湾 (避難港、地方港湾)":
return "small_harbor"
return "unknown"
def style_color(category: str) -> str:
if category == "fishing_port":
return "#7fc8ff"
if category == "general_harbor":
return "#ffb86b"
if category == "small_harbor":
return "#b8d37a"
return "#d9d9d9"
def main() -> None:
parser = argparse.ArgumentParser(description="Export Kyushu port overlay assets")
parser.add_argument(
"--source-db",
default="out/navsea_kyushu_navigability_full_run.export_snapshot.sqlite",
help="输入 SQLite 快照",
)
parser.add_argument(
"--output-dir",
default="src/pbf/port-overlay/kyushu",
help="输出目录",
)
parser.add_argument(
"--slug",
default="port_overlay_kyushu",
help="输出文件前缀",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
source_db = Path(args.source_db)
if not source_db.is_absolute():
source_db = project_root / source_db
out_dir = Path(args.output_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if not source_db.exists():
raise SystemExit(f"missing source db: {source_db}")
conn = sqlite3.connect(source_db)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT DISTINCT
aoi_id, feature_id, fid, canonical_object_type, center_x, center_y, radius_m
FROM port_aoi
ORDER BY aoi_id
"""
).fetchall()
if not rows:
raise SystemExit("port_aoi is empty")
radius_m = max(float(row["radius_m"]) for row in rows)
types = {}
minx = miny = float("inf")
maxx = maxy = float("-inf")
area_features = []
center_features = []
for row in rows:
category = normalize_category(row["canonical_object_type"])
color = style_color(category)
center = Point(float(row["center_x"]), float(row["center_y"]))
polygon = center.buffer(float(row["radius_m"]), resolution=16)
minx = min(minx, float(row["center_x"]) - float(row["radius_m"]))
miny = min(miny, float(row["center_y"]) - float(row["radius_m"]))
maxx = max(maxx, float(row["center_x"]) + float(row["radius_m"]))
maxy = max(maxy, float(row["center_y"]) + float(row["radius_m"]))
types.setdefault(row["canonical_object_type"], 0)
types[row["canonical_object_type"]] += 1
area_features.append(
{
"type": "Feature",
"properties": {
"aoi_id": row["aoi_id"],
"feature_id": row["feature_id"],
"fid": row["fid"],
"canonical_object_type": row["canonical_object_type"],
"category": category,
"radius_m": float(row["radius_m"]),
"fill": color,
},
"geometry": mapping(polygon_to_lonlat(polygon)),
}
)
center_features.append(
{
"type": "Feature",
"properties": {
"aoi_id": row["aoi_id"],
"feature_id": row["feature_id"],
"fid": row["fid"],
"canonical_object_type": row["canonical_object_type"],
"category": category,
"radius_m": float(row["radius_m"]),
},
"geometry": mapping(point_to_lonlat(center)),
}
)
center_lonlat_min = mercator_to_lonlat(minx, miny)
center_lonlat_max = mercator_to_lonlat(maxx, maxy)
area_geojson = {
"type": "FeatureCollection",
"name": "port_overlay_areas",
"features": area_features,
}
center_geojson = {
"type": "FeatureCollection",
"name": "port_overlay_centers",
"features": center_features,
}
manifest = {
"version": "v1.0",
"source_db": str(source_db),
"source_scope": "Kyushu",
"total_features": len(rows),
"radius_m": radius_m,
"counts_by_type": types,
"bbox_mercator": [minx, miny, maxx, maxy],
"bbox_lonlat": [
center_lonlat_min[0],
center_lonlat_min[1],
center_lonlat_max[0],
center_lonlat_max[1],
],
"area_file": f"{args.slug}_areas.geojson",
"center_file": f"{args.slug}_centers.geojson",
}
(out_dir / f"{args.slug}_areas.geojson").write_text(
json.dumps(area_geojson, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_centers.geojson").write_text(
json.dumps(center_geojson, ensure_ascii=False),
encoding="utf-8",
)
(out_dir / f"{args.slug}_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
conn.close()
print("已导出:")
print(" -", out_dir / f"{args.slug}_areas.geojson")
print(" -", out_dir / f"{args.slug}_centers.geojson")
print(" -", out_dir / f"{args.slug}_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/prc50_test"
RADIUS = 6378137.0
TARGET_LAYER = "fish_port_20m"
DEFAULT_PRC = "50"
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> tuple[float, float, float, float]:
if layer_name == "coast_200m" and (
abs(min_lon) > 180.0 or abs(max_lon) > 180.0 or abs(min_lat) > 90.0 or abs(max_lat) > 90.0
):
return mercator_bbox_to_lonlat(min_lon, min_lat, max_lon, max_lat)
return min_lon, min_lat, max_lon, max_lat
def export_layer(
conn,
*,
path: Path,
where_sql: str,
params: tuple,
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
sql = f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE {where_sql}
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, params)
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
row[0],
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def fetch_stats(conn, where_sql: str, params: tuple) -> tuple[int, list[float] | None]:
sql = f"""
SELECT COUNT(*), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE {where_sql}
"""
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
count = int(row[0] or 0)
if count == 0:
return 0, None
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
params[0] if params else "",
float(row[1]),
float(row[2]),
float(row[3]),
float(row[4]),
)
return count, [min_lon, min_lat, max_lon, max_lat]
def main() -> None:
parser = argparse.ArgumentParser(description="Export a single PRC fish_port_20m test subset")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--prc", default=DEFAULT_PRC, help="只导出这个 PRC 的 fish_port_20m,默认 50")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
prc = str(args.prc).strip()
if prc.isdigit():
prc = f"{int(prc):02d}"
where_sql = "layer_name=%s AND state_name=%s AND source_name LIKE %s"
params = (TARGET_LAYER, "COASTLINE", f"C09-06+coastline PRC={prc}%")
out_path = out_dir / f"fish_port_20m_prc{prc}_grid.geojson"
conn = mysql_connect(args.db_name)
try:
count = export_layer(conn, path=out_path, where_sql=where_sql, params=params)
stat_count, bbox = fetch_stats(conn, where_sql, params)
if count != stat_count:
raise RuntimeError(f"export count mismatch: {count} != {stat_count}")
finally:
conn.close()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"prc": prc,
"layers": {
TARGET_LAYER: {
"count": count,
"bbox_lonlat": bbox,
"cell_size_m": 20.0,
"export_file": str(out_path.relative_to(project_root)),
"filter": f"state_name='COASTLINE' AND source_name LIKE 'C09-06+coastline PRC={prc}%'",
}
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{TARGET_LAYER}: {count} -> {out_path}")
print(f"manifest: {manifest_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,382 @@
# NavSea v2 使用说明
本文只说明 `coastline/navgrid_v2/` 下的新一版脚本与对应验证页。
## 1. 目录说明
v2 脚本目录:
- `coastline/navgrid_v2/`
v2 验证页:
- `src/pbf/navsea-coastline-fukuoka-saga-200m-v2.html`
- `src/pbf/navsea-fish-port-debug-v2.html`
- `src/pbf/navsea-fish-port-prc50-test-v2.html`
- `src/pbf/navsea-coast-wall-z12-viewer.html`
v2 静态资产目录:
- `src/pbf/coastline-mysql-v2/`
v2 规则:
- 三类数据分表保存
- cell 记录明确保存来源字段
- 不使用迁移脚本
- 直接用 v2 Python 从源数据重建
- `coastline` 这条链路的最终目标是导出阻断墙 `bin`
- 来源字段的目标不只是可追溯,也要方便检查、删除指定来源数据、以及按来源做 resume / 复跑
## 2. v2 表结构
### 2.1 沿岸数据
- 表前缀:`navsea_coast_`
- 主要表:
- `navsea_coast_grid_cell`
- `navsea_coast_grid_layer_meta`
- `navsea_coast_grid_package_stat`
cell 级来源字段:
- `source_code`
- `source_path`
- `source_name`
### 2.2 渔港 20m
- 表前缀:`navsea_fish_port_`
- 主要表:
- `navsea_fish_port_grid_cell`
- `navsea_fish_port_grid_layer_meta`
- `navsea_fish_port_grid_package_stat`
### 2.3 海上障碍 50m
- 表前缀:`navsea_hazard_`
- 主要表:
- `navsea_hazard_grid_cell`
- `navsea_hazard_grid_layer_meta`
- `navsea_hazard_grid_package_stat`
海上障碍这组走轻量记录:
- `navsea_hazard_grid_layer_meta.source_desc` 仍然保留整体来源说明
- `navsea_hazard_grid_cell.source_name` 直接记录 `fid`
- `navsea_hazard_grid_cell` 额外带有 `(layer_name, source_name)` 索引,方便按 `fid` 查删和复跑
- 也就是说,单个 cell 不再追完整来源链,只保留一个代表性的 `fid`
## 3. 生成顺序
建议顺序:
1. 先生成沿岸数据
2. 再生成渔港 20m
3. 再生成海上障碍 50m
4. 再导出静态资产
5. 再生成阻断墙二进制 `bin`
6. 最后看验证页
## 4. 三个导入脚本的参数说明
这三个脚本是 v2 里最重要的“入库脚本”:
- 沿岸数据:`build_coast_grid_mysql.py`
- 渔港 20m:`build_fish_port_20m_mysql_resume.py`
- 海上障碍 50m:`build_hazard_50m_mysql.py`
下面把常用参数和开关说明清楚。先记一个总原则:
- `build_coast_grid_mysql.py` 偏“沿岸重建”
- `build_fish_port_20m_mysql_resume.py` 偏“续跑 + 选择性重算”
- `build_hazard_50m_mysql.py` 偏“全量重建,参数少”
### 4.1 沿岸数据:`build_coast_grid_mysql.py`
脚本:
- [`build_coast_grid_mysql.py`](./build_coast_grid_mysql.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_grid_mysql.py
```
常用参数:
- `--input`:指定海岸线 zip,支持重复传入;不传时自动扫描 `coastline/C23-06_*_GML.zip`
- `--db-name`:MySQL 数据库名
- `--out-dir`:GeoJSON 和元数据输出目录
- `--cell-size-m`:格网大小,默认 `200`
- `--layer-name`:目标层名;不传时按格网大小自动生成,例如 `coast_200m`、`coast_50m`
- `--buffer-m`:海岸线缓冲距离
- `--margin-cells`:输出范围外扩的格子数
- `--write-geojson`:同时导出 GeoJSON
- `--resume`:续跑,不清理当前 layer,保留已入库格子
- `--start-code`:只处理指定 `source_code` 及之后的包,例如 `13` 或 `C23-06_13`
- `--progress-every-cells`:每扫描多少个候选格打印一次进度
- `--progress-every-seconds`:最迟每隔多少秒打印一次进度
- `--coarse-factor`:粗筛倍率,值越大越省细扫,但粗框会更大
常用场景:
- 重新生成整套沿岸数据:直接运行默认命令
- 只从 `C23-06_13` 继续:加 `--resume --start-code 13`
- 做更细分辨率:加 `--cell-size-m 50 --layer-name coast_50m`
- 想批量重跑一段包:`--resume` 保留已入库结果,再配合 `--start-code` 从某个包号往后接着算
50m 续跑示例:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_grid_mysql.py \
--cell-size-m 50 \
--layer-name coast_50m \
--out-dir out/coastline/japan_coast_grid_mysql_v2 \
--resume \
--start-code 13
```
### 4.2 渔港 20m:`build_fish_port_20m_mysql_resume.py`
脚本:
- [`build_fish_port_20m_mysql_resume.py`](./build_fish_port_20m_mysql_resume.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_fish_port_20m_mysql_resume.py --resume
```
这支脚本的重点是“续跑”和“跳过控制”。
常用参数:
- `--resume`:按数据库里的断点继续,默认开启
- `--no-resume`:忽略数据库断点,从头规划但不清表
- `--reset-layer`:先清空 `fish_port_20m` 旧数据并删除断点
- `--prc`:只处理指定 PRC;传入后会按该 PRC 完整重建,并忽略 resume 断点
- `--fpc`:只处理指定 FPC;会先反查所在 PRC,再只重算该港口
- `--cluster-gap-m`:聚类连通判定的间隔
- `--cell-size-m`:格网大小,默认 `20`
- `--coast-buffer-m`:海岸缓冲距离
- `--fish-buffer-m`:渔港几何外扩缓冲,当前保留兼容
- `--commit-every`:每累计多少条 cell 写一次数据库
- `--scan-tile-m`:格网扫描分块大小
- `--coast-coarse-margin-m`:coast_200m 粗筛底座外扩缓冲
- `--allow-unbounded-fallback`:没有 coast_200m 小窗时,允许回退扫描原始 part
- `--skip-missing-coarse`:没有有效 coast_200m 交集时直接跳过该 part
- `--max-scan-cells`:单次允许估算的最大 20m 格数,超过就中止;`0` 表示不限制
这几个开关最值得记:
- 想继续上次中断:`--resume`
- 想重跑但不清表:`--no-resume`
- 想只重算某个港口:`--fpc 4320050`
- 想只重算某个 PRC:`--prc 50`
- 想遇到无有效粗筛时直接跳过:`--skip-missing-coarse`
- 想允许老式整片扫描兜底:`--allow-unbounded-fallback`
这里要特别注意:
- `--resume` 主要用于全国批量续跑,脚本会按数据库断点接着处理尚未完成的 PRC
- 一旦你显式指定 `--prc` 或 `--fpc`,脚本会进入“指定区域完整重算”模式,这时会忽略原来的 resume 断点
- 所以它支持“批量 resume”,也支持“指定区域重算”,但这两种模式不是同时叠加的
示例:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_fish_port_20m_mysql_resume.py \
--resume \
--skip-missing-coarse
```
### 4.3 海上障碍 50m:`build_hazard_50m_mysql.py`
脚本:
- [`build_hazard_50m_mysql.py`](./build_hazard_50m_mysql.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_hazard_50m_mysql.py
```
常用参数:
- `--db-name`:MySQL 数据库名
- `--tile-root`:全国 PBF 根目录
- `--zoom`:PBF 瓦片 zoom,默认 `12`
- `--out-dir`:GeoJSON 输出目录
- `--source-desc`:写入元数据的来源说明
这个脚本参数最少,基本就是全量重建。
## 5. v2 沿岸导出
脚本:
- [`export_coast_grid_mysql_assets.py`](./export_coast_grid_mysql_assets.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/export_coast_grid_mysql_assets.py
```
## 6. v2 调试导出
脚本:
- [`export_fish_port_debug_assets.py`](./export_fish_port_debug_assets.py)
- [`export_prc20_test_assets.py`](./export_prc20_test_assets.py)
示例:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/export_fish_port_debug_assets.py --fpc 4320050 --coast-buffer-m 20
./.venv/bin/python coastline/navgrid_v2/export_prc20_test_assets.py --prc 50
```
## 7. v2 共用导出
脚本:
- [`export_navgrid_mysql_assets.py`](./export_navgrid_mysql_assets.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/export_navgrid_mysql_assets.py
```
## 8. 阻断墙二进制
脚本:
- [`build_coast_wall_z12_bin.py`](./build_coast_wall_z12_bin.py)
默认执行:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py
```
默认输出文件名会自动带上当前三层单位,例如:
- `out/navmask/coast_wall_z12_200_50_20.bin`
- `out/navmask/coast_wall_z12_100_50_20.bin`
- `out/navmask/coast_wall_z12_50_50_20.bin`
当输出墙参数不是默认 `z12 + 40x40` 时,文件名会改用实际墙参数,不再沿用默认 out 里的 `z12`,例如:
- `out/navmask/coast_wall_z14_g80_10_50.bin`
这一步就是 `coastline` 链路的最终落点:
- 上游把三类格网数据重建出来
- 导出静态资产用于检查和发布
- 最后由这里把沿岸相关结果汇总成阻断墙 `bin`
如果前面的表结构、导出目录或验证页发生变化,最终都要回到这一步确认 `bin` 产物仍然可用。
这支脚本现在有两组“格子大小”参数,要分清:
- `--coast-cell-size-m`:输入海岸线层的格网大小,例如读取 `coast_200m`、`coast_100m`、`coast_50m`
- `--zoom` + `--grid-size`:最终阻断墙自身的输出格子大小;同一个 zoom 下 `grid-size` 越大,tile 内切得越细,墙 cell 越小
还有一条自动规则:
- 当 `--coast-cell-size-m < 20` 时,脚本会跳过 `fish_port_20m`
- 这是因为输入海岸线层已经比渔港 20m 更细,继续叠加渔港 20m 反而会把更粗的格子混进阻断墙
- 此时实际融合顺序会变成:
- `hazard_50m`
- 指定的 `coast_*m`,例如 `coast_10m`
- 生成的 `.meta.json` 会记录 `include_fish_port_20m=false`
默认的 bin 组合顺序是:
- `fish_port_20m`
- `hazard_50m`
- `coast_200m`
如果你要把海岸线层切成更细的版本,可以直接改参数:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py --coast-cell-size-m 100
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py --coast-cell-size-m 50
```
如果要调整最终墙的 cell 大小,可以改 `zoom` 和 `grid-size`:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py \
--zoom 11 \
--grid-size 80
```
如果输入海岸线层已经做到 `10m`,可以这样生成更细的墙;这时会自动不使用渔港 `20m`:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py \
--zoom 14 \
--grid-size 80 \
--coast-cell-size-m 10
```
这个命令的默认输出会是:
- `out/navmask/coast_wall_z14_g80_10_50.bin`
常用理解:
- 默认 `--zoom 12 --grid-size 40`,墙 cell 约 `244.6m`
- `--zoom 12 --grid-size 80`,墙 cell 约 `122.3m`
- `--zoom 13 --grid-size 40`,墙 cell 约 `122.3m`
- `--zoom 13 --grid-size 80`,墙 cell 约 `61.1m`
如果横向和纵向要分别调,可以使用:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py \
--zoom 12 \
--grid-w 80 \
--grid-h 80
```
如果海岸线表名不跟着默认规则走,也可以直接指定:
```bash
cd /root/sourceserver/pbf
./.venv/bin/python coastline/navgrid_v2/build_coast_wall_z12_bin.py \
--coast-layer-name coast_100m \
--coast-cell-size-m 100
```
如果想直接看 `bin` 的效果,可以打开:
- `src/pbf/navsea-coast-wall-z12-viewer.html`
在页面里选择生成好的 `coast_wall_*.bin`,可选再选择同名 `.meta.json`,就能直接看最终阻断墙的铺面效果。查看页会按 bin header 自动识别 `zoom` 和 `grid`。
## 9. 验证页
- `http://192.168.200.184/newpec/navsea-coastline-fukuoka-saga-200m-v2.html`
- `http://192.168.200.184/newpec/navsea-fish-port-debug-v2.html`
- `http://192.168.200.184/newpec/navsea-fish-port-prc50-test-v2.html`

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,870 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import glob
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import xml.etree.ElementTree as ET
import pymysql
from shapely.geometry import LineString, MultiLineString, box, mapping
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_INPUT_GLOB = "coastline/C23-06_*_GML.zip"
DEFAULT_OUT_DIR = "out/coastline/japan_coast_grid_mysql_v2"
DEFAULT_CELL_SIZE_M = 200.0
DEFAULT_BUFFER_M = 50.0
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
NS = {"gml": "http://www.opengis.net/gml/3.2"}
@dataclass(frozen=True)
class SourcePackage:
path: Path
prefecture: str
code: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def parse_text_list(text: str) -> list[float]:
return [float(part) for part in text.split() if part]
def detect_prefecture(meta_xml: str) -> str:
title_start = meta_xml.find("<title>")
if title_start == -1:
return "unknown"
title_end = meta_xml.find("</title>", title_start)
if title_end == -1:
return "unknown"
return meta_xml[title_start + 7:title_end].strip()
def load_package(path: Path) -> SourcePackage:
with zipfile.ZipFile(path) as zf:
meta_name = next(name for name in zf.namelist() if "META" in name and name.endswith(".xml"))
meta_xml = zf.read(meta_name).decode("shift_jis", errors="replace")
prefecture = detect_prefecture(meta_xml)
code = path.stem.replace("_GML", "")
return SourcePackage(path=path, prefecture=prefecture, code=code)
def iter_coastline_lines(zip_path: Path) -> Iterable[LineString]:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
root = ET.fromstring(zf.read(xml_name))
for curve in root.findall(".//gml:Curve", NS):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//gml:posList", NS):
if not pos_list.text:
continue
values = parse_text_list(pos_list.text)
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) >= 2:
yield LineString(coords)
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database(name: str) -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def column_exists(conn, table_name: str, column_name: str) -> bool:
with conn.cursor() as cur:
cur.execute("SELECT DATABASE()")
database_name = cur.fetchone()[0]
cur.execute(
"""
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND COLUMN_NAME=%s
""",
(database_name, table_name, column_name),
)
return int(cur.fetchone()[0] or 0) > 0
def ensure_schema(conn) -> None:
ddl = [
"""
CREATE TABLE IF NOT EXISTS navsea_coast_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS navsea_coast_grid_package_stat (
layer_name VARCHAR(32) NOT NULL,
source_code VARCHAR(64) NOT NULL,
prefecture VARCHAR(128) NOT NULL,
zip_path TEXT NOT NULL,
line_count BIGINT NOT NULL,
point_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
grid_min_lon DOUBLE NOT NULL,
grid_min_lat DOUBLE NOT NULL,
grid_max_lon DOUBLE NOT NULL,
grid_max_lat DOUBLE NOT NULL,
blocked_count BIGINT NOT NULL,
candidate_count BIGINT NOT NULL,
KEY idx_layer_source (layer_name, source_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS navsea_coast_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_code VARCHAR(64) NOT NULL,
source_path TEXT NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_source_cell (layer_name, source_code, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in ddl:
cur.execute(stmt)
if not column_exists(conn, "navsea_coast_grid_package_stat", "layer_name"):
with conn.cursor() as cur:
cur.execute(
"ALTER TABLE navsea_coast_grid_package_stat "
"ADD COLUMN layer_name VARCHAR(32) NOT NULL DEFAULT 'coast_200m' FIRST"
)
cur.execute("ALTER TABLE navsea_coast_grid_package_stat ADD KEY idx_layer_source (layer_name, source_code)")
conn.commit()
def replace_layer_rows(conn, layer_name: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM navsea_coast_grid_cell WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_coast_grid_layer_meta WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_coast_grid_package_stat WHERE layer_name=%s", (layer_name,))
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_coast_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_code,
source_path, source_name, min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
row_idx=VALUES(row_idx),
col_idx=VALUES(col_idx),
cell_size_m=VALUES(cell_size_m),
state_name=VALUES(state_name),
class_name=VALUES(class_name),
source_code=VALUES(source_code),
source_path=VALUES(source_path),
source_name=VALUES(source_name),
min_lon=VALUES(min_lon),
min_lat=VALUES(min_lat),
max_lon=VALUES(max_lon),
max_lat=VALUES(max_lat)
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str | None,
) -> None:
cur.execute(
"""
INSERT INTO navsea_coast_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def default_layer_name(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return f"coast_{int(cell_size_m)}m"
return f"coast_{str(cell_size_m).replace('.', '_')}m"
def normalize_source_code(value: str) -> str:
value = value.strip()
if value.isdigit():
return f"C23-06_{int(value):02d}"
if value.startswith("C23-06_") and value[len("C23-06_"):].isdigit():
return f"C23-06_{int(value[len('C23-06_'):]):02d}"
return value
def ts_now() -> str:
return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(message: str) -> None:
print(f"[{ts_now()}] {message}", flush=True)
def flush_package_rows(
conn,
package_rows: list[tuple],
*,
package_code: str,
package_blocked: int,
package_candidate: int,
reason: str,
) -> None:
if not package_rows:
return
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
log(
f"[{package_code}] 已写入 {len(package_rows)} 行({reason}),"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
def main() -> None:
parser = argparse.ArgumentParser(description="Build Japan coastline grid directly into MySQL")
parser.add_argument(
"--input",
dest="inputs",
action="append",
default=[],
help="海岸线 GML zip。可重复指定;默认自动扫描 coastline/C23-06_*_GML.zip",
)
parser.add_argument(
"--db-name",
default=DB_NAME,
help="MySQL 数据库名",
)
parser.add_argument(
"--out-dir",
default=DEFAULT_OUT_DIR,
help="输出目录,用于可选 GeoJSON 和元数据",
)
parser.add_argument(
"--cell-size-m",
type=float,
default=DEFAULT_CELL_SIZE_M,
help="海岸线格子大小,默认 200m;例如 50 会自动写入 coast_50m",
)
parser.add_argument(
"--layer-name",
default=None,
help="目标 layer_name;默认按 cell size 自动生成,例如 coast_200m / coast_50m",
)
parser.add_argument("--buffer-m", type=float, default=DEFAULT_BUFFER_M, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
default=2,
help="输出范围外扩的格子数,默认 2 个格子",
)
parser.add_argument(
"--write-geojson",
action="store_true",
help="同时输出 coastline.geojson 和 grid.geojson",
)
parser.add_argument(
"--resume",
action="store_true",
help="续跑模式:不清理当前 layer,保留已入库格子",
)
parser.add_argument(
"--start-code",
default=None,
help="只处理指定 source_code 及之后的包,例如 13 或 C23-06_13",
)
parser.add_argument(
"--progress-every-cells",
type=int,
default=50000,
help="每扫描多少个候选格输出一次进度日志,默认 50000",
)
parser.add_argument(
"--progress-every-seconds",
type=int,
default=15,
help="每隔多少秒至少输出一次进度日志,默认 15",
)
parser.add_argument(
"--coarse-factor",
type=int,
default=8,
help="海岸线粗筛倍率,默认 8;值越大越少细扫,但粗筛更大",
)
args = parser.parse_args()
if args.cell_size_m <= 0:
raise SystemExit("--cell-size-m must be positive")
cell_size_m = float(args.cell_size_m)
layer_name = args.layer_name or default_layer_name(cell_size_m)
project_root = Path(__file__).resolve().parent.parent.parent
if args.inputs:
input_paths: list[Path] = []
for item in args.inputs:
if any(ch in item for ch in "*?[]"):
input_paths.extend(Path(path) for path in sorted(glob.glob(item)))
else:
input_paths.append(Path(item))
else:
input_paths = [Path(path) for path in sorted(glob.glob(str(project_root / DEFAULT_INPUT_GLOB)))]
if not input_paths:
raise SystemExit("no coastline packages found")
if args.progress_every_cells <= 0:
raise SystemExit("--progress-every-cells must be positive")
if args.progress_every_seconds < 0:
raise SystemExit("--progress-every-seconds must be non-negative")
if args.coarse_factor < 1:
raise SystemExit("--coarse-factor must be >= 1")
packages: list[SourcePackage] = []
for path in input_paths:
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
packages.sort(key=lambda package: package.code)
if args.start_code:
start_code = normalize_source_code(args.start_code)
packages = [package for package in packages if package.code >= start_code]
if not packages:
raise SystemExit(f"no coastline packages found from start code: {start_code}")
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database(args.db_name)
conn = mysql_connect(args.db_name)
try:
log(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
if args.resume:
first_code = packages[0].code
last_code = packages[-1].code
log(
f"续跑 layer={layer_name} cell={cell_size_m:g}m;"
f"保留已入库格子,仅处理 {first_code}..{last_code}"
)
else:
log(f"准备重建 layer={layer_name} cell={cell_size_m:g}m;只清理当前 layer,不影响其它尺寸")
replace_layer_rows(conn, layer_name)
total_lines = 0
total_points = 0
package_stats: list[dict] = []
build_start = dt.datetime.now()
for package in packages:
package_start = dt.datetime.now()
log(f"[{package.code}] 开始处理 {package.path.name}")
log(f"[{package.code}] 读取原始曲线并构造几何 ...")
lines = list(iter_coastline_lines(package.path))
if not lines:
log(f"[{package.code}] 没有可用海岸线,跳过")
continue
coastline_geom = MultiLineString([list(line.coords) for line in lines])
coast_bounds = coastline_geom.bounds
log(
f"[{package.code}] 几何已构造 lines={len(lines)} points={sum(len(line.coords) for line in lines)} "
f"bbox={coast_bounds[0]:.2f},{coast_bounds[1]:.2f},{coast_bounds[2]:.2f},{coast_bounds[3]:.2f} "
f"buffer={args.buffer_m:g}m"
)
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * cell_size_m, cell_size_m)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * cell_size_m, cell_size_m)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * cell_size_m, cell_size_m)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * cell_size_m, cell_size_m)
line_count = len(lines)
point_count = sum(len(line.coords) for line in lines)
total_lines += line_count
total_points += point_count
package_blocked = 0
package_candidate = 0
package_rows: list[tuple] = []
package_seen_cells: set[str] = set()
scanned_cells = 0
next_progress_at = args.progress_every_cells
last_progress_at = dt.datetime.now()
coarse_factor = args.coarse_factor
coarse_size = cell_size_m * coarse_factor
for line_index, line in enumerate(lines, start=1):
line_start = dt.datetime.now()
line_buffer = line.buffer(args.buffer_m, cap_style=2, join_style=2)
if line_buffer.is_empty:
log(f"[{package.code}] 线段#{line_index} buffer 为空,跳过")
continue
prepared_buffer = prep(line_buffer)
line_bounds = line_buffer.bounds
log(
f"[{package.code}] 线段#{line_index} 扫描开始 "
f"bbox={line_bounds[0]:.2f},{line_bounds[1]:.2f},{line_bounds[2]:.2f},{line_bounds[3]:.2f}"
)
scan_minx = align_floor(line_bounds[0] - args.margin_cells * cell_size_m, coarse_size)
scan_miny = align_floor(line_bounds[1] - args.margin_cells * cell_size_m, coarse_size)
scan_maxx = align_ceil(line_bounds[2] + args.margin_cells * cell_size_m, coarse_size)
scan_maxy = align_ceil(line_bounds[3] + args.margin_cells * cell_size_m, coarse_size)
cix0 = int(round(scan_minx / coarse_size))
ciy0 = int(round(scan_miny / coarse_size))
cix1 = int(round(scan_maxx / coarse_size))
ciy1 = int(round(scan_maxy / coarse_size))
for cix in range(cix0, cix1):
coarse_minx = cix * coarse_size
coarse_maxx = coarse_minx + coarse_size
for ciy in range(ciy0, ciy1):
coarse_miny = ciy * coarse_size
coarse_maxy = coarse_miny + coarse_size
coarse_cell = box(coarse_minx, coarse_miny, coarse_maxx, coarse_maxy)
scanned_cells += 1
if not prepared_buffer.intersects(coarse_cell):
package_candidate += coarse_factor * coarse_factor
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
continue
fx0 = cix * coarse_factor
fy0 = ciy * coarse_factor
fx1 = fx0 + coarse_factor
fy1 = fy0 + coarse_factor
if prepared_buffer.contains(coarse_cell):
for ix in range(fx0, fx1):
cell_minx = ix * cell_size_m
cell_maxx = cell_minx + cell_size_m
for iy in range(fy0, fy1):
cell_miny = iy * cell_size_m
cell_maxy = cell_miny + cell_size_m
cell_id = f"{ix}:{iy}"
if cell_id in package_seen_cells:
continue
package_seen_cells.add(cell_id)
package_rows.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HARD_BLOCKED",
"HARD_BLOCKED",
package.code,
str(package.path),
package.code,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
scanned_cells += coarse_factor * coarse_factor
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
continue
for ix in range(fx0, fx1):
cell_minx = ix * cell_size_m
cell_maxx = cell_minx + cell_size_m
for iy in range(fy0, fy1):
cell_miny = iy * cell_size_m
cell_maxy = cell_miny + cell_size_m
cell_id = f"{ix}:{iy}"
scanned_cells += 1
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
if prepared_buffer.intersects(cell):
if cell_id not in package_seen_cells:
package_seen_cells.add(cell_id)
package_rows.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HARD_BLOCKED",
"HARD_BLOCKED",
package.code,
str(package.path),
package.code,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
else:
package_candidate += 1
now = dt.datetime.now()
if scanned_cells >= next_progress_at or (
args.progress_every_seconds
and (now - last_progress_at).total_seconds() >= args.progress_every_seconds
):
elapsed = (now - package_start).total_seconds()
rate = scanned_cells / elapsed if elapsed > 0 else 0.0
log(
f"[{package.code}] 进度 scanned={scanned_cells} blocked={package_blocked} "
f"candidate={package_candidate} rows={len(package_rows)} "
f"rate={rate:.1f}/s elapsed={elapsed:.1f}s"
)
last_progress_at = now
next_progress_at = scanned_cells + args.progress_every_cells
if package_rows and len(package_rows) >= 100:
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason=f"line#{line_index}",
)
if package_rows:
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason=f"line#{line_index}-end",
)
log(
f"[{package.code}] 线段#{line_index} 完成 "
f"blocked={package_blocked} candidate={package_candidate} "
f"elapsed={(dt.datetime.now() - line_start).total_seconds():.1f}s"
)
if package_rows:
flush_package_rows(
conn,
package_rows,
package_code=package.code,
package_blocked=package_blocked,
package_candidate=package_candidate,
reason="package-end",
)
package_stats.append(
{
"source_code": package.code,
"prefecture": package.prefecture,
"zip_path": str(package.path),
"line_count": line_count,
"point_count": point_count,
"bbox_mercator": [coast_bounds[0], coast_bounds[1], coast_bounds[2], coast_bounds[3]],
"grid_bounds_mercator": [minx, miny, maxx, maxy],
"blocked_count": package_blocked,
"candidate_count": package_candidate,
}
)
with conn.cursor() as cur:
cur.execute(
"DELETE FROM navsea_coast_grid_package_stat WHERE layer_name=%s AND source_code=%s",
(layer_name, package.code),
)
cur.execute(
"""
INSERT INTO navsea_coast_grid_package_stat(
layer_name, source_code, prefecture, zip_path, line_count, point_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat,
grid_min_lon, grid_min_lat, grid_max_lon, grid_max_lat,
blocked_count, candidate_count
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
""",
(
layer_name,
package.code,
package.prefecture,
str(package.path),
line_count,
point_count,
coast_bounds[0],
coast_bounds[1],
coast_bounds[2],
coast_bounds[3],
minx,
miny,
maxx,
maxy,
package_blocked,
package_candidate,
),
)
conn.commit()
log(
f"[{package.code}] 完成 line={line_count} point={point_count} "
f"blocked={package_blocked} candidate={package_candidate} "
f"elapsed={(dt.datetime.now() - package_start).total_seconds():.1f}s"
)
with conn.cursor() as cur:
cur.execute("ANALYZE TABLE navsea_coast_grid_cell")
cur.execute("ANALYZE TABLE navsea_coast_grid_package_stat")
cur.execute("ANALYZE TABLE navsea_coast_grid_layer_meta")
conn.commit()
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM navsea_coast_grid_cell WHERE layer_name=%s", (layer_name,))
final_total = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_coast_grid_cell WHERE layer_name=%s AND state_name=%s",
(layer_name, "HARD_BLOCKED"),
)
blocked_count = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_coast_grid_cell WHERE layer_name=%s AND state_name=%s",
(layer_name, "NAVIGABLE_CANDIDATE"),
)
candidate_count = int(cur.fetchone()[0] or 0)
cur.execute(
"""
SELECT MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_coast_grid_cell
WHERE layer_name=%s
""",
(layer_name,),
)
bbox_row = cur.fetchone()
grid_bbox = (
float(bbox_row[0]) if bbox_row and bbox_row[0] is not None else float("inf"),
float(bbox_row[1]) if bbox_row and bbox_row[1] is not None else float("inf"),
float(bbox_row[2]) if bbox_row and bbox_row[2] is not None else float("-inf"),
float(bbox_row[3]) if bbox_row and bbox_row[3] is not None else float("-inf"),
)
build_time = dt.datetime.now().isoformat(timespec="seconds")
grid_bbox_lonlat = mercator_bbox_to_lonlat(*grid_bbox)
manifest = {
"build_time": build_time,
"db_name": args.db_name,
"layer_name": layer_name,
"cell_size_m": cell_size_m,
"buffer_m": args.buffer_m,
"source_scope": "japan",
"source_code": "C23-06_*",
"source_count": len(packages),
"source_packages": [str(pkg.path) for pkg in packages],
"coastline_line_count": total_lines,
"coastline_point_count": total_points,
"grid_cell_count": final_total,
"blocked_count": blocked_count,
"candidate_count": candidate_count,
"grid_bounds_lonlat": list(grid_bbox_lonlat),
"package_stats": package_stats,
"notes": f"全国海岸线 {cell_size_m:g}m 硬阻塞栅格,直接写入 MySQL",
}
manifest_path = out_dir / f"japan_{layer_name}_grid_mysql.manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=layer_name,
description=f"全国海岸线 {cell_size_m:g}m 硬阻塞格",
source_desc="coastline/C23-06_*_GML.zip",
cell_size_m=cell_size_m,
feature_count=final_total,
bbox=grid_bbox_lonlat,
export_file=str(manifest_path.relative_to(project_root)),
)
conn.commit()
elapsed_all = (dt.datetime.now() - build_start).total_seconds()
log("完成")
log(f" MySQL: {args.db_name}")
log(f" layer={layer_name}")
log(f" cell_size_m={cell_size_m:g}")
log(f" packages={len(packages)}")
log(f" coastline_lines={total_lines}")
log(f" grid_cells={final_total}")
log(f" blocked={blocked_count}")
log(f" candidate={candidate_count}")
log(f" elapsed={elapsed_all:.1f}s")
log(f" manifest: {manifest_path}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,870 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import struct
from pathlib import Path
from typing import Iterable
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
ZOOM = 12
GRID_W = 40
GRID_H = 40
CELL_COUNT = GRID_W * GRID_H
BLOCK_SIZE = 32
SPARSE_THRESHOLD = 96
MAGIC = b"CWZ12BIN"
VERSION = 1
LAYER_20 = "fish_port_20m"
LAYER_50 = "hazard_50m"
LAYER_200 = "coast_200m"
CELL_SIZES = {
LAYER_20: 20.0,
LAYER_50: 50.0,
LAYER_200: 200.0,
}
TABLES = {
LAYER_20: "navsea_fish_port_grid_cell",
LAYER_50: "navsea_hazard_grid_cell",
LAYER_200: "navsea_coast_grid_cell",
}
def table_name_for_layer(layer_name: str) -> str:
if layer_name in TABLES:
return TABLES[layer_name]
if layer_name.startswith("coast_") and layer_name.endswith("m"):
return "navsea_coast_grid_cell"
raise KeyError(layer_name)
def coast_layer_name_for_size(cell_size_m: float) -> str:
if cell_size_m <= 0:
raise ValueError("coast cell size must be positive")
if float(cell_size_m).is_integer():
return f"coast_{int(cell_size_m)}m"
return f"coast_{cell_size_m:g}m"
def density_key_for_size(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return str(int(cell_size_m))
return f"{cell_size_m:g}"
def unit_tag_for_size(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return str(int(cell_size_m))
return str(cell_size_m).replace(".", "_")
def wall_unit_tag(zoom: int, grid_w: int, grid_h: int) -> str:
if grid_w == grid_h:
return f"z{zoom}_g{grid_w}"
return f"z{zoom}_g{grid_w}x{grid_h}"
def build_out_path(path: Path, coast_unit: str, wall_unit: str, *, include_fish_port: bool) -> Path:
source_unit = f"{coast_unit}_50_20" if include_fish_port else f"{coast_unit}_50"
stem = path.stem
if wall_unit != "z12_g40" and stem.endswith("_z12"):
stem = stem.removesuffix("_z12")
suffix = f"_{source_unit}" if wall_unit == "z12_g40" else f"_{wall_unit}_{source_unit}"
return path.with_name(f"{stem}{suffix}{path.suffix}")
RADIUS = 6378137.0
WORLD_MIN = -math.pi * RADIUS
WORLD_MAX = math.pi * RADIUS
WORLD_SIZE = WORLD_MAX - WORLD_MIN
TILE_COUNT = 1 << ZOOM
TILE_SIZE_M = WORLD_SIZE / TILE_COUNT
Z12_CELL_SIZE_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_X_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_Y_M = TILE_SIZE_M / GRID_H
GLOBAL_CELL_COUNT_X = TILE_COUNT * GRID_W
GLOBAL_CELL_COUNT_Y = TILE_COUNT * GRID_H
BLOCK_AXIS_BITS = math.ceil(math.log2(math.ceil(TILE_COUNT / BLOCK_SIZE)))
def configure_wall_grid(*, zoom: int, grid_w: int, grid_h: int, sparse_threshold: int) -> None:
global ZOOM
global GRID_W
global GRID_H
global CELL_COUNT
global SPARSE_THRESHOLD
global TILE_COUNT
global TILE_SIZE_M
global Z12_CELL_SIZE_M
global GLOBAL_CELL_COUNT_X
global GLOBAL_CELL_COUNT_Y
global Z12_CELL_SIZE_X_M
global Z12_CELL_SIZE_Y_M
global BLOCK_AXIS_BITS
if not (0 <= zoom <= 16):
raise SystemExit("--zoom must be between 0 and 16")
if not (1 <= grid_w <= 255 and 1 <= grid_h <= 255):
raise SystemExit("--grid-w/--grid-h must be between 1 and 255")
if grid_w * grid_h > 65535:
raise SystemExit("--grid-w * --grid-h must be <= 65535 because cell ids are uint16")
if sparse_threshold < 0 or sparse_threshold > grid_w * grid_h:
raise SystemExit("--sparse-threshold must be between 0 and grid_w * grid_h")
ZOOM = zoom
GRID_W = grid_w
GRID_H = grid_h
CELL_COUNT = GRID_W * GRID_H
SPARSE_THRESHOLD = sparse_threshold
TILE_COUNT = 1 << ZOOM
TILE_SIZE_M = WORLD_SIZE / TILE_COUNT
Z12_CELL_SIZE_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_X_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_Y_M = TILE_SIZE_M / GRID_H
GLOBAL_CELL_COUNT_X = TILE_COUNT * GRID_W
GLOBAL_CELL_COUNT_Y = TILE_COUNT * GRID_H
BLOCK_AXIS_BITS = math.ceil(math.log2(math.ceil(TILE_COUNT / BLOCK_SIZE)))
HEADER_STRUCT = struct.Struct("<8sHBBBBBIII")
BLOCK_ENTRY_STRUCT = struct.Struct("<IIHH")
TILE_ENTRY_STRUCT = struct.Struct("<IIHBB")
PRESET_BBOXES = {
# 九州本岛和周边主要岛屿的调试范围,足够覆盖福冈/佐贺/长崎/熊本/鹿儿岛一带。
"kyushu": (128.0, 30.0, 132.5, 34.9),
}
def log(message: str) -> None:
print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] {message}")
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def parse_bbox(value: str) -> tuple[float, float, float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 4:
raise argparse.ArgumentTypeError("bbox must be min_lon,min_lat,max_lon,max_lat")
min_lon, min_lat, max_lon, max_lat = parts
if min_lon >= max_lon or min_lat >= max_lat:
raise argparse.ArgumentTypeError("bbox min values must be smaller than max values")
return min_lon, min_lat, max_lon, max_lat
def parse_lonlat(value: str) -> tuple[float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 2:
raise argparse.ArgumentTypeError("query point must be lon,lat")
return parts[0], parts[1]
def mercator_bbox_from_lonlat(bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
min_lon, min_lat, max_lon, max_lat = bbox
return mercator_x(min_lon), mercator_y(min_lat), mercator_x(max_lon), mercator_y(max_lat)
def rowcol_range_for_bbox(
bbox_mercator: tuple[float, float, float, float],
cell_size_m: float,
) -> tuple[int, int, int, int]:
minx, miny, maxx, maxy = bbox_mercator
col_min = math.floor(minx / cell_size_m)
col_max = math.floor((maxx - 1e-9) / cell_size_m)
row_min = math.floor(miny / cell_size_m)
row_max = math.floor((maxy - 1e-9) / cell_size_m)
return row_min, row_max, col_min, col_max
def overlap_range(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def overlaps_finer_cells(
row_idx: int,
col_idx: int,
coarse_size_m: float,
fine_size_m: float,
fine_keys: set[tuple[int, int]],
) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = overlap_range(min_y, max_y, fine_size_m)
col_start, col_end = overlap_range(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def pack_tile_id(tile_x: int, tile_y: int) -> int:
if not (0 <= tile_x < TILE_COUNT and 0 <= tile_y < TILE_COUNT):
raise ValueError(f"tile out of z{ZOOM} range: {tile_x}, {tile_y}")
return (tile_x << ZOOM) | tile_y
def unpack_tile_id(tile_id: int) -> tuple[int, int]:
mask = (1 << ZOOM) - 1
return tile_id >> ZOOM, tile_id & mask
def pack_cell_id(row: int, col: int) -> int:
if not (0 <= row < GRID_H and 0 <= col < GRID_W):
raise ValueError(f"cell out of tile range: {row}, {col}")
return row * GRID_W + col
def pack_block_id(tile_x: int, tile_y: int) -> int:
block_x = tile_x >> 5
block_y = tile_y >> 5
return (block_x << BLOCK_AXIS_BITS) | block_y
def global_cell_x_range(minx: float, maxx: float) -> tuple[int, int]:
start = math.floor((minx - WORLD_MIN) / Z12_CELL_SIZE_X_M)
end = math.floor(((maxx - 1e-9) - WORLD_MIN) / Z12_CELL_SIZE_X_M)
return max(0, start), min(GLOBAL_CELL_COUNT_X - 1, end)
def global_cell_y_range(miny: float, maxy: float) -> tuple[int, int]:
# WebMercator tile rows start at the north edge, while project grid rows use increasing mercator Y.
start = math.floor((WORLD_MAX - maxy) / Z12_CELL_SIZE_Y_M)
end = math.floor(((WORLD_MAX - miny) - 1e-9) / Z12_CELL_SIZE_Y_M)
return max(0, start), min(GLOBAL_CELL_COUNT_Y - 1, end)
def add_mercator_rect_to_blocked(
blocked: dict[int, set[int]],
minx: float,
miny: float,
maxx: float,
maxy: float,
) -> int:
gx_start, gx_end = global_cell_x_range(minx, maxx)
gy_start, gy_end = global_cell_y_range(miny, maxy)
added = 0
for gy in range(gy_start, gy_end + 1):
tile_y = gy // GRID_H
row = gy % GRID_H
for gx in range(gx_start, gx_end + 1):
tile_x = gx // GRID_W
col = gx % GRID_W
tile_id = pack_tile_id(tile_x, tile_y)
cell_id = pack_cell_id(row, col)
cells = blocked.setdefault(tile_id, set())
before = len(cells)
cells.add(cell_id)
if len(cells) != before:
added += 1
return added
def iter_layer_rows(
conn,
*,
layer_name: str,
bbox_mercator: tuple[float, float, float, float] | None,
):
where = ["layer_name=%s"]
params: list[object] = [layer_name]
if bbox_mercator is not None:
row_min, row_max, col_min, col_max = rowcol_range_for_bbox(bbox_mercator, CELL_SIZES[layer_name])
where.append("row_idx BETWEEN %s AND %s")
where.append("col_idx BETWEEN %s AND %s")
params.extend([row_min, row_max, col_min, col_max])
table_name = table_name_for_layer(layer_name)
sql = f"""
SELECT cell_id, row_idx, col_idx
FROM {table_name}
WHERE {' AND '.join(where)}
ORDER BY row_idx, col_idx, source_name
"""
with conn.cursor(pymysql.cursors.SSCursor) as cur:
cur.execute(sql, tuple(params))
for cell_id, row_idx, col_idx in cur:
yield str(cell_id), int(row_idx), int(col_idx)
def source_cell_mercator_bbox(row_idx: int, col_idx: int, cell_size_m: float) -> tuple[float, float, float, float]:
minx = col_idx * cell_size_m
miny = row_idx * cell_size_m
return minx, miny, minx + cell_size_m, miny + cell_size_m
def build_blocked_map(
conn,
*,
coast_layer_name: str,
coast_cell_size_m: float,
include_fish_port: bool,
bbox_mercator: tuple[float, float, float, float] | None,
progress_every: int,
) -> tuple[dict[int, set[int]], dict]:
blocked: dict[int, set[int]] = {}
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
seen_20: set[str] = set()
seen_50: set[str] = set()
seen_200: set[str] = set()
coast_key = density_key_for_size(coast_cell_size_m)
summary = {
"sourceRows": {LAYER_20: 0, LAYER_50: 0, coast_layer_name: 0},
"sourceDistinctCells": {LAYER_20: 0, LAYER_50: 0, coast_layer_name: 0},
"keptByDensity": {"20": 0, "50": 0, coast_key: 0},
"skippedByDensity": {f"50_over_20": 0, f"{coast_key}_over_20": 0, f"{coast_key}_over_50": 0},
"blockedCellAdds": {"20": 0, "50": 0, coast_key: 0},
"activeLayers": [LAYER_50, coast_layer_name],
"disabledLayers": [],
}
if include_fish_port:
summary["activeLayers"].insert(0, LAYER_20)
log("读取 fish_port_20m;density 规则:20m 永远保留")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_20, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_20] += 1
if cell_id in seen_20:
continue
seen_20.add(cell_id)
keys_20.add((row_idx, col_idx))
summary["sourceDistinctCells"][LAYER_20] += 1
summary["keptByDensity"]["20"] += 1
summary["blockedCellAdds"]["20"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_20]),
)
if progress_every and summary["sourceRows"][LAYER_20] % progress_every == 0:
log(f"fish_port_20m rows={summary['sourceRows'][LAYER_20]} kept={summary['keptByDensity']['20']}")
else:
summary["disabledLayers"].append(
{
"layer": LAYER_20,
"reason": f"coast_cell_size_m={coast_cell_size_m:g} < fish_port_20m precision",
}
)
log(f"跳过 fish_port_20m;原因:coast_cell_size_m={coast_cell_size_m:g} 已细于 20m")
hazard_rule = "与 20m 重叠则跳过" if include_fish_port else "不再与 20m 做重叠裁剪"
log(f"读取 hazard_50m;density 规则:{hazard_rule}")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_50, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_50] += 1
if cell_id in seen_50:
continue
seen_50.add(cell_id)
summary["sourceDistinctCells"][LAYER_50] += 1
if include_fish_port and overlaps_finer_cells(row_idx, col_idx, CELL_SIZES[LAYER_50], CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"]["50_over_20"] += 1
continue
keys_50.add((row_idx, col_idx))
summary["keptByDensity"]["50"] += 1
summary["blockedCellAdds"]["50"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_50]),
)
if progress_every and summary["sourceRows"][LAYER_50] % progress_every == 0:
log(f"hazard_50m rows={summary['sourceRows'][LAYER_50]} kept={summary['keptByDensity']['50']}")
coast_rule = "与 20m/保留后的 50m 重叠则跳过" if include_fish_port else "与保留后的 50m 重叠则跳过"
log(f"读取 {coast_layer_name};density 规则:{coast_rule}")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=coast_layer_name, bbox_mercator=bbox_mercator):
summary["sourceRows"][coast_layer_name] += 1
if cell_id in seen_200:
continue
seen_200.add(cell_id)
summary["sourceDistinctCells"][coast_layer_name] += 1
if include_fish_port and overlaps_finer_cells(row_idx, col_idx, coast_cell_size_m, CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"][f"{coast_key}_over_20"] += 1
continue
if overlaps_finer_cells(row_idx, col_idx, coast_cell_size_m, CELL_SIZES[LAYER_50], keys_50):
summary["skippedByDensity"][f"{coast_key}_over_50"] += 1
continue
summary["keptByDensity"][coast_key] += 1
summary["blockedCellAdds"][coast_key] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, coast_cell_size_m),
)
if progress_every and summary["sourceRows"][coast_layer_name] % progress_every == 0:
log(f"{coast_layer_name} rows={summary['sourceRows'][coast_layer_name]} kept={summary['keptByDensity'][coast_key]}")
return blocked, summary
def encode_tile_cells(cells: set[int]) -> tuple[int, int, bytes]:
sorted_cells = sorted(cells)
if not sorted_cells:
raise ValueError("empty tile should not be encoded")
if sorted_cells[0] < 0 or sorted_cells[-1] >= CELL_COUNT:
raise ValueError(f"cellId out of range: {sorted_cells[0]}..{sorted_cells[-1]}")
if len(sorted_cells) <= SPARSE_THRESHOLD:
payload = struct.pack(f"<{len(sorted_cells)}H", *sorted_cells)
return 1, len(sorted_cells), payload
word_count = math.ceil(CELL_COUNT / 32)
words = [0] * word_count
for cell_id in sorted_cells:
word = cell_id >> 5
bit = cell_id & 31
words[word] |= 1 << bit
payload = struct.pack(f"<{word_count}I", *words)
return 2, len(sorted_cells), payload
def write_bin(path: Path, blocked: dict[int, set[int]]) -> dict:
path.parent.mkdir(parents=True, exist_ok=True)
grouped: dict[int, list[tuple[int, int, int, bytes]]] = {}
sparse_count = 0
bitset_count = 0
total_blocked_cells = 0
for tile_id in sorted(blocked):
cells = blocked[tile_id]
if not cells:
continue
tile_x, tile_y = unpack_tile_id(tile_id)
encoding, count, payload = encode_tile_cells(cells)
if encoding == 1:
sparse_count += 1
else:
bitset_count += 1
total_blocked_cells += count
block_id = pack_block_id(tile_x, tile_y)
grouped.setdefault(block_id, []).append((tile_id, encoding, count, payload))
block_ids = sorted(grouped)
block_count = len(block_ids)
block_dir_offset = HEADER_STRUCT.size
tile_dir_offset = block_dir_offset + block_count * BLOCK_ENTRY_STRUCT.size
tile_entry_count = sum(len(grouped[block_id]) for block_id in block_ids)
data_offset = tile_dir_offset + tile_entry_count * TILE_ENTRY_STRUCT.size
block_entries: list[tuple[int, int, int, int]] = []
tile_entries_by_block: dict[int, list[tuple[int, int, int, int]]] = {}
data_parts: list[bytes] = []
current_tile_dir_offset = tile_dir_offset
current_data_offset = 0
for block_id in block_ids:
tiles = sorted(grouped[block_id], key=lambda item: item[0])
block_entries.append((block_id, current_tile_dir_offset, len(tiles), 0))
current_tile_dir_offset += len(tiles) * TILE_ENTRY_STRUCT.size
tile_entries: list[tuple[int, int, int, int]] = []
for tile_id, encoding, count, payload in tiles:
tile_entries.append((tile_id, current_data_offset, count, encoding))
data_parts.append(payload)
current_data_offset += len(payload)
tile_entries_by_block[block_id] = tile_entries
with path.open("wb") as fh:
fh.write(
HEADER_STRUCT.pack(
MAGIC,
VERSION,
ZOOM,
GRID_W,
GRID_H,
BLOCK_SIZE,
0,
block_count,
block_dir_offset,
data_offset,
)
)
for entry in block_entries:
fh.write(BLOCK_ENTRY_STRUCT.pack(*entry))
for block_id in block_ids:
for tile_id, offset, count, encoding in tile_entries_by_block[block_id]:
fh.write(TILE_ENTRY_STRUCT.pack(tile_id, offset, count, encoding, 0))
for payload in data_parts:
fh.write(payload)
file_size = path.stat().st_size
return {
"file": str(path),
"fileSizeBytes": file_size,
"headerSizeBytes": HEADER_STRUCT.size,
"blockCount": block_count,
"totalBlockedTiles": sparse_count + bitset_count,
"totalBlockedCells": total_blocked_cells,
"sparseTileCount": sparse_count,
"bitsetTileCount": bitset_count,
"blockDirOffset": block_dir_offset,
"dataOffset": data_offset,
"dataBytes": current_data_offset,
}
class CoastWallReader:
def __init__(self, path: Path):
self.path = path
self.data = path.read_bytes()
header = HEADER_STRUCT.unpack_from(self.data, 0)
magic, version, zoom, grid_w, grid_h, block_size, _flags, block_count, block_dir_offset, data_offset = header
if magic != MAGIC:
raise ValueError(f"bad magic: {magic!r}")
if version != VERSION or zoom != ZOOM or grid_w != GRID_W or grid_h != GRID_H or block_size != BLOCK_SIZE:
raise ValueError(
"unsupported coast wall header: "
f"file version={version} zoom={zoom} grid={grid_w}x{grid_h} block={block_size}; "
f"reader expects version={VERSION} zoom={ZOOM} grid={GRID_W}x{GRID_H} block={BLOCK_SIZE}"
)
self.block_count = block_count
self.block_dir_offset = block_dir_offset
self.data_offset = data_offset
self.blocks: dict[int, list[tuple[int, int, int, int]]] = {}
for idx in range(block_count):
offset = block_dir_offset + idx * BLOCK_ENTRY_STRUCT.size
block_id, tile_dir_offset, tile_count, _reserved = BLOCK_ENTRY_STRUCT.unpack_from(self.data, offset)
entries: list[tuple[int, int, int, int]] = []
for tile_idx in range(tile_count):
tile_offset = tile_dir_offset + tile_idx * TILE_ENTRY_STRUCT.size
tile_id, payload_offset, count, encoding, _tile_reserved = TILE_ENTRY_STRUCT.unpack_from(self.data, tile_offset)
entries.append((tile_id, payload_offset, count, encoding))
self.blocks[block_id] = entries
def tile_cells(self, tile_id: int) -> set[int]:
tile_x, tile_y = unpack_tile_id(tile_id)
block_id = pack_block_id(tile_x, tile_y)
for entry_tile_id, payload_offset, count, encoding in self.blocks.get(block_id, []):
if entry_tile_id != tile_id:
continue
start = self.data_offset + payload_offset
if encoding == 1:
values = struct.unpack_from(f"<{count}H", self.data, start)
return set(values)
if encoding == 2:
word_count = math.ceil(CELL_COUNT / 32)
words = struct.unpack_from(f"<{word_count}I", self.data, start)
cells: set[int] = set()
for word_idx, word in enumerate(words):
if not word:
continue
for bit in range(32):
if word & (1 << bit):
cell_id = word_idx * 32 + bit
if cell_id < CELL_COUNT:
cells.add(cell_id)
return cells
raise ValueError(f"unknown encoding: {encoding}")
return set()
def is_blocked_lonlat(self, lon: float, lat: float) -> bool:
tile_x, tile_y, _row, _col, cell_id = z12_cell_for_lonlat(lon, lat)
tile_id = pack_tile_id(tile_x, tile_y)
return cell_id in self.tile_cells(tile_id)
def validate_bin(path: Path, blocked: dict[int, set[int]], sample_tiles: int = 200) -> None:
reader = CoastWallReader(path)
for idx, tile_id in enumerate(sorted(blocked)):
if idx >= sample_tiles:
break
expected = blocked[tile_id]
actual = reader.tile_cells(tile_id)
if actual != expected:
raise RuntimeError(f"reader roundtrip mismatch tile={tile_id}: expected={len(expected)} actual={len(actual)}")
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def z12_cell_bounds(tile_x: int, tile_y: int, row: int, col: int) -> tuple[float, float, float, float]:
minx = WORLD_MIN + (tile_x * GRID_W + col) * Z12_CELL_SIZE_X_M
maxx = minx + Z12_CELL_SIZE_X_M
maxy = WORLD_MAX - (tile_y * GRID_H + row) * Z12_CELL_SIZE_Y_M
miny = maxy - Z12_CELL_SIZE_Y_M
return lon_from_mercator(minx), lat_from_mercator(miny), lon_from_mercator(maxx), lat_from_mercator(maxy)
def z12_cell_for_lonlat(lon: float, lat: float) -> tuple[int, int, int, int, int]:
x = mercator_x(lon)
y = mercator_y(lat)
gx = math.floor((x - WORLD_MIN) / Z12_CELL_SIZE_X_M)
gy = math.floor((WORLD_MAX - y) / Z12_CELL_SIZE_Y_M)
if not (0 <= gx < GLOBAL_CELL_COUNT_X and 0 <= gy < GLOBAL_CELL_COUNT_Y):
raise ValueError(f"lon/lat outside z{ZOOM} grid: {lon}, {lat}")
tile_x = gx // GRID_W
tile_y = gy // GRID_H
col = gx % GRID_W
row = gy % GRID_H
cell_id = pack_cell_id(row, col)
return tile_x, tile_y, row, col, cell_id
def write_debug_geojson(path: Path, blocked: dict[int, set[int]], limit: int) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for tile_id in sorted(blocked):
tile_x, tile_y = unpack_tile_id(tile_id)
for cell_id in sorted(blocked[tile_id]):
row = cell_id // GRID_W
col = cell_id % GRID_W
feature = {
"type": "Feature",
"properties": {
"tile_id": tile_id,
"tile_x": tile_x,
"tile_y": tile_y,
"cell_id": cell_id,
"row": row,
"col": col,
},
"geometry": rect_geometry(*z12_cell_bounds(tile_x, tile_y, row, col)),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if limit and count >= limit:
fh.write("\n]}\n")
return count
fh.write("\n]}\n")
return count
def run_self_test() -> None:
sample_tile_x = min(TILE_COUNT - 1, max(0, TILE_COUNT * 3 // 4))
sample_tile_y = min(TILE_COUNT - 1, max(0, TILE_COUNT // 5))
assert unpack_tile_id(pack_tile_id(sample_tile_x, sample_tile_y)) == (sample_tile_x, sample_tile_y)
assert pack_cell_id(GRID_H - 1, GRID_W - 1) == CELL_COUNT - 1
assert pack_block_id(32, 64) == ((1 << BLOCK_AXIS_BITS) | 2)
sparse_cells = {0, min(3, CELL_COUNT - 1), CELL_COUNT - 1}
encoding, count, payload = encode_tile_cells(sparse_cells)
assert encoding == 1 and count == len(sparse_cells)
assert set(struct.unpack(f"<{len(sparse_cells)}H", payload)) == sparse_cells
if SPARSE_THRESHOLD < CELL_COUNT:
dense_count = min(CELL_COUNT, max(SPARSE_THRESHOLD + 1, 120))
dense_cells = set(range(dense_count))
encoding, count, payload = encode_tile_cells(dense_cells)
assert encoding == 2 and count == dense_count and len(payload) == math.ceil(CELL_COUNT / 32) * 4
log("self-test passed")
def main() -> None:
parser = argparse.ArgumentParser(description="Build coast wall bin from MySQL density-overview grid layers")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out", default="out/navmask/coast_wall_z12.bin")
parser.add_argument("--meta-json", default=None, help="默认写到 <out>.meta.json")
parser.add_argument("--debug-geojson", default=None)
parser.add_argument("--debug-limit", type=int, default=5000)
parser.add_argument("--preset", choices=["all", *sorted(PRESET_BBOXES)], default="kyushu")
parser.add_argument("--bbox", type=parse_bbox, default=None, help="min_lon,min_lat,max_lon,max_lat;优先于 --preset")
parser.add_argument("--query", type=parse_lonlat, default=None, help="生成后用 reader 查询一个 lon,lat 点")
parser.add_argument("--progress-every", type=int, default=200000)
parser.add_argument("--zoom", type=int, default=12, help="阻断墙输出 tile zoom,默认 12")
parser.add_argument(
"--grid-size",
type=int,
default=40,
help="每个输出 tile 内部切成 N x N 个墙 cell,默认 40;值越大墙 cell 越小",
)
parser.add_argument("--grid-w", type=int, default=None, help="覆盖输出 tile 内部横向 cell 数")
parser.add_argument("--grid-h", type=int, default=None, help="覆盖输出 tile 内部纵向 cell 数")
parser.add_argument(
"--sparse-threshold",
type=int,
default=96,
help="单 tile blocked cell 数量小于等于该值时使用 sparse 编码,默认 96",
)
parser.add_argument(
"--coast-cell-size-m",
type=float,
default=200.0,
help="参与 bin 生成的海岸线格网大小,默认 200;可切 100、50、20、10 等已入库层",
)
parser.add_argument(
"--coast-layer-name",
default=None,
help="覆盖海岸线层名;不传时按 --coast-cell-size-m 自动生成,例如 coast_200m / coast_50m / coast_10m",
)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
grid_w = args.grid_w if args.grid_w is not None else args.grid_size
grid_h = args.grid_h if args.grid_h is not None else args.grid_size
configure_wall_grid(
zoom=args.zoom,
grid_w=grid_w,
grid_h=grid_h,
sparse_threshold=args.sparse_threshold,
)
if args.self_test:
run_self_test()
if args.coast_cell_size_m <= 0:
raise SystemExit("--coast-cell-size-m must be positive")
coast_layer_name = args.coast_layer_name or coast_layer_name_for_size(args.coast_cell_size_m)
coast_cell_size_m = float(args.coast_cell_size_m)
if coast_layer_name in CELL_SIZES and CELL_SIZES[coast_layer_name] != coast_cell_size_m:
raise SystemExit(
f"{coast_layer_name} already maps to {CELL_SIZES[coast_layer_name]}m, "
f"but --coast-cell-size-m={coast_cell_size_m}"
)
CELL_SIZES.setdefault(coast_layer_name, coast_cell_size_m)
coast_unit_tag = unit_tag_for_size(coast_cell_size_m)
wall_unit = wall_unit_tag(ZOOM, GRID_W, GRID_H)
include_fish_port = coast_cell_size_m >= CELL_SIZES[LAYER_20]
bbox_lonlat = args.bbox
if bbox_lonlat is None and args.preset != "all":
bbox_lonlat = PRESET_BBOXES[args.preset]
bbox_mercator = mercator_bbox_from_lonlat(bbox_lonlat) if bbox_lonlat is not None else None
out_path = Path(args.out)
out_path = build_out_path(out_path, coast_unit_tag, wall_unit, include_fish_port=include_fish_port)
meta_path = Path(args.meta_json) if args.meta_json else out_path.with_suffix(out_path.suffix + ".meta.json")
log(
f"开始生成 coast wall bin zoom={ZOOM} grid={GRID_W}x{GRID_H} "
f"wall_cell_m={Z12_CELL_SIZE_X_M:.3f}x{Z12_CELL_SIZE_Y_M:.3f} "
f"preset={args.preset} bbox={bbox_lonlat} "
f"coast_layer={coast_layer_name} include_fish_port={include_fish_port} out={out_path.name}"
)
conn = mysql_connect(args.db_name)
try:
blocked, density_summary = build_blocked_map(
conn,
coast_layer_name=coast_layer_name,
coast_cell_size_m=coast_cell_size_m,
include_fish_port=include_fish_port,
bbox_mercator=bbox_mercator,
progress_every=args.progress_every,
)
finally:
conn.close()
if not blocked:
raise SystemExit("no blocked cells produced")
write_summary = write_bin(out_path, blocked)
validate_bin(out_path, blocked)
debug_count = 0
if args.debug_geojson:
debug_count = write_debug_geojson(Path(args.debug_geojson), blocked, args.debug_limit)
query_result = None
if args.query is not None:
lon, lat = args.query
reader = CoastWallReader(out_path)
tile_x, tile_y, row, col, cell_id = z12_cell_for_lonlat(lon, lat)
query_result = {
"lon": lon,
"lat": lat,
"tile_x": tile_x,
"tile_y": tile_y,
"row": row,
"col": col,
"cell_id": cell_id,
"blocked": reader.is_blocked_lonlat(lon, lat),
}
meta = {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"db_name": args.db_name,
"preset": args.preset,
"bbox_lonlat": bbox_lonlat,
"coast_layer_name": coast_layer_name,
"coast_cell_size_m": coast_cell_size_m,
"include_fish_port_20m": include_fish_port,
"zoom": ZOOM,
"grid_w": GRID_W,
"grid_h": GRID_H,
"wall_cell_size_m": Z12_CELL_SIZE_M,
"wall_cell_size_x_m": Z12_CELL_SIZE_X_M,
"wall_cell_size_y_m": Z12_CELL_SIZE_Y_M,
"z12_cell_size_m": Z12_CELL_SIZE_M,
"sparse_threshold": SPARSE_THRESHOLD,
"density_rule": (
[
"keep fish_port_20m first",
"keep hazard_50m only when it does not overlap kept fish_port_20m",
f"keep {coast_layer_name} only when it does not overlap kept fish_port_20m or kept hazard_50m",
]
if include_fish_port
else [
"skip fish_port_20m because coast_cell_size_m is finer than 20m",
"keep hazard_50m",
f"keep {coast_layer_name} only when it does not overlap kept hazard_50m",
]
),
"density_summary": density_summary,
"binary_summary": write_summary,
"debug_geojson": args.debug_geojson,
"debug_feature_count": debug_count,
"query_result": query_result,
}
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"完成 bin={out_path} meta={meta_path}")
log(
"统计 "
f"tiles={write_summary['totalBlockedTiles']} cells={write_summary['totalBlockedCells']} "
f"sparse={write_summary['sparseTileCount']} bitset={write_summary['bitsetTileCount']} "
f"blocks={write_summary['blockCount']} size={write_summary['fileSizeBytes']}"
)
if query_result is not None:
log(f"query lon={query_result['lon']} lat={query_result['lat']} blocked={query_result['blocked']}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,597 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
from typing import Iterable
import mapbox_vector_tile
import pymysql
from shapely.geometry import box, shape
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_TILE_ROOT = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
DEFAULT_TILE_Z = 12
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql-v2/national"
DEFAULT_SOURCE_DESC = "pbf-delivery-full-20260418-rebuild z12"
HAZARD_CELL_M = 50.0
HAZARD_LAYER_NAME = "hazard_50m"
HAZARD_DESCRIPTION = "全国 PBF 海上障碍 50m 黄格(排除沙波/急潮波纹/涡流/海草)"
HAZARD_LAYERS = (
"navigation_hazard_area",
"fixed_fishing_gear_area",
"anchor_caution_hazard_area",
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
)
BREAKWATER_LAYER = "baseline_area"
POINT_LAYERS = {
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
HAZARD_CANONICAL_OBJECT_TYPES = {
"breakwater",
}
EXCLUDED_HAZARD_CLASS_CODES = {421, 422, 424, 425}
EXCLUDED_HAZARD_SEMANTICS = {
"sand_wave",
"tide_rips_overfalls",
"eddy_whirlpool",
"seaweed",
"seagrass",
}
EXCLUDED_HAZARD_ICON_KEYS = {
"symbol-daytime-421",
"symbol-daytime-422",
"symbol-daytime-424",
"symbol-daytime-425",
}
EXCLUDED_HAZARD_CLASS_NAMES = {
"サンドウェーブ",
"急潮・波紋・激潮",
"渦流",
"海草",
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def log(message: str) -> None:
print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] {message}", flush=True)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def rect_geojson(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
},
}
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def stream_geojson(path: Path, features: Iterable[dict]) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for feature in features:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database(name: str) -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
stmts = [
"""
CREATE TABLE IF NOT EXISTS navsea_hazard_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS navsea_hazard_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_source (layer_name, source_name),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in stmts:
cur.execute(stmt)
cur.execute(
"""
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=%s AND TABLE_NAME='navsea_hazard_grid_cell'
ORDER BY INDEX_NAME, SEQ_IN_INDEX
""",
(DB_NAME,),
)
index_columns: dict[str, list[str]] = {}
for index_name, column_name, _seq in cur.fetchall():
index_columns.setdefault(index_name, []).append(column_name)
if index_columns.get("idx_layer_source") != ["layer_name", "source_name"]:
if "idx_layer_source" in index_columns:
cur.execute("ALTER TABLE navsea_hazard_grid_cell DROP INDEX idx_layer_source")
cur.execute("ALTER TABLE navsea_hazard_grid_cell ADD KEY idx_layer_source (layer_name, source_name)")
conn.commit()
def replace_layer_rows(conn, layer_name: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM navsea_hazard_grid_cell WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_hazard_grid_layer_meta WHERE layer_name=%s", (layer_name,))
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_hazard_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str,
) -> None:
cur.execute(
"""
INSERT INTO navsea_hazard_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def tile_mercator_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2**z
lon_left = x / n * 360.0 - 180.0
lon_right = (x + 1) / n * 360.0 - 180.0
lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return mercator_x(lon_left), mercator_y(lat_bottom), mercator_x(lon_right), mercator_y(lat_top)
def transform_geom_from_tile(geom, bounds_m: tuple[float, float, float, float], extent: int):
minx, miny, maxx, maxy = bounds_m
dx = maxx - minx
dy = maxy - miny
def coord(x: float, y: float):
mx = minx + (x / extent) * dx
my = miny + (y / extent) * dy
return mx, my
def walk(obj):
if isinstance(obj[0], (int, float)):
return coord(obj[0], obj[1])
return [walk(item) for item in obj]
return walk(geom)
def iter_source_tiles(tile_root: Path, zoom: int) -> list[Path]:
zoom_root = tile_root / str(zoom)
if not zoom_root.exists():
raise SystemExit(f"missing tile zoom root: {zoom_root}")
return sorted(zoom_root.glob("*/*.pbf"))
def int_prop(value) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def is_excluded_hazard_feature(props: dict) -> bool:
class_code = int_prop(props.get("class_code") or props.get("分類番号"))
if class_code in EXCLUDED_HAZARD_CLASS_CODES:
return True
semantic_values = {
str(value)
for value in (
props.get("canonical_object_type"),
props.get("chart_symbol_code"),
props.get("chart_icon_image"),
props.get("icon"),
props.get("class_name"),
)
if value is not None
}
if semantic_values & EXCLUDED_HAZARD_SEMANTICS:
return True
if semantic_values & EXCLUDED_HAZARD_ICON_KEYS:
return True
if semantic_values & EXCLUDED_HAZARD_CLASS_NAMES:
return True
return False
def iter_hazard_cells(tile_root: Path, zoom: int) -> Iterable[tuple[int, int, str]]:
hazard_cells: dict[tuple[int, int], str] = {}
tiles = iter_source_tiles(tile_root, zoom)
for index, p in enumerate(tiles, start=1):
try:
tx = int(p.parent.name)
ty = int(p.stem)
except ValueError:
continue
try:
tile = mapbox_vector_tile.decode(p.read_bytes())
except Exception as exc:
log(f"[hazard_50m] 跳过无法解析的 tile: {p} ({exc.__class__.__name__})")
continue
if index % 5000 == 0:
log(f"[hazard_50m] 已扫描 tile {index}/{len(tiles)}: {p.parent.parent.name}/{tx}/{ty}.pbf")
for layer_name in HAZARD_LAYERS + (BREAKWATER_LAYER,):
layer = tile.get(layer_name)
if not layer:
continue
extent = int(layer.get("extent") or 1048576)
minx, miny, maxx, maxy = tile_mercator_bounds(zoom, tx, ty)
for feature in layer.get("features", []):
props = feature.get("properties") or {}
fid = feature.get("id", props.get("fid"))
fid_text = str(fid) if fid is not None else f"{layer_name}:{tx}:{ty}:{len(hazard_cells)}"
if layer_name == BREAKWATER_LAYER and props.get("canonical_object_type") not in HAZARD_CANONICAL_OBJECT_TYPES:
continue
if is_excluded_hazard_feature(props):
continue
if (
props.get("canonical_object_type") == "fish_reef"
or props.get("chart_symbol_code") == "fish_reef"
or props.get("class_name") == "魚礁"
):
continue
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
if not coords:
continue
shp_coords = transform_geom_from_tile(coords, (minx, miny, maxx, maxy), extent)
shp = shape({"type": geom.get("type"), "coordinates": shp_coords})
if shp.is_empty:
continue
if layer_name in POINT_LAYERS:
shp = shp.buffer(25.0)
minx2, miny2, maxx2, maxy2 = shp.bounds
start_x = math.floor(minx2 / HAZARD_CELL_M) * HAZARD_CELL_M
start_y = math.floor(miny2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_x = math.ceil(maxx2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_y = math.ceil(maxy2 / HAZARD_CELL_M) * HAZARD_CELL_M
prep_geom = prep(shp)
cy = start_y
while cy < end_y:
cx = start_x
while cx < end_x:
cell = box(cx, cy, cx + HAZARD_CELL_M, cy + HAZARD_CELL_M)
if prep_geom.intersects(cell):
key = (int(round(cx / HAZARD_CELL_M)), int(round(cy / HAZARD_CELL_M)))
hazard_cells.setdefault(key, fid_text)
cx += HAZARD_CELL_M
cy += HAZARD_CELL_M
return sorted(((ix, iy, fid) for (ix, iy), fid in hazard_cells.items()), key=lambda item: (item[1], item[0], item[2]))
def cell_bbox_from_mercator(ix: int, iy: int, cell_m: float) -> tuple[float, float, float, float]:
minx = ix * cell_m
miny = iy * cell_m
maxx = minx + cell_m
maxy = miny + cell_m
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def import_hazard_layer(
conn,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
tile_root: Path,
zoom: int,
export_path: Path,
) -> tuple[int, tuple[float, float, float, float]]:
cells = iter_hazard_cells(tile_root, zoom)
batch: list[tuple] = []
count = 0
export_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
with conn.cursor() as cur:
log(f"[{layer_name}] 开始扫描全国 PBF 危险层:{tile_root} / z{zoom}")
with export_path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for ix, iy, fid in cells:
min_lon, min_lat, max_lon, max_lat = cell_bbox_from_mercator(ix, iy, cell_size_m)
export_bbox[0] = min(export_bbox[0], min_lon)
export_bbox[1] = min(export_bbox[1], min_lat)
export_bbox[2] = max(export_bbox[2], max_lon)
export_bbox[3] = max(export_bbox[3], max_lat)
cell_id = f"{ix}:{iy}"
batch.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HAZARD_50M",
"HAZARD",
fid,
min_lon,
min_lat,
max_lon,
max_lat,
)
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": iy,
"col": ix,
"cell_size_m": cell_size_m,
"state_name": "HAZARD_50M",
"class_name": "HAZARD",
"source_name": fid,
},
"geometry": rect_geojson(min_lon, min_lat, max_lon, max_lat)["geometry"],
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if len(batch) >= 5000:
insert_cells(cur, batch)
conn.commit()
batch.clear()
if count % 20000 == 0:
log(f"[{layer_name}] 已处理 {count} 个格子 ...")
if batch:
insert_cells(cur, batch)
conn.commit()
fh.write("\n]}\n")
if count == 0:
raise RuntimeError(f"{layer_name} 扫描结果为空,请检查 tile_root={tile_root} zoom={zoom}")
log(f"[{layer_name}] 导入完成:{count} 条,导出 {export_path}")
return count, (export_bbox[0], export_bbox[1], export_bbox[2], export_bbox[3])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="重算全国 50x50 海上障碍格并写入统一 MySQL 库。")
parser.add_argument("--db-name", default=DB_NAME, help="MySQL 数据库名")
parser.add_argument("--tile-root", type=Path, default=DEFAULT_TILE_ROOT, help="全国 PBF 根目录")
parser.add_argument("--zoom", type=int, default=DEFAULT_TILE_Z, help="PBF 瓦片 zoom,默认 12")
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR, help="GeoJSON 输出目录")
parser.add_argument("--source-desc", default=DEFAULT_SOURCE_DESC, help="写入元数据的来源说明")
return parser.parse_args()
def main() -> None:
args = parse_args()
project_root = Path(__file__).resolve().parent.parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database(args.db_name)
conn = mysql_connect(args.db_name)
try:
log(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
replace_layer_rows(conn, HAZARD_LAYER_NAME)
hazard_export = out_dir / "hazard_50m_grid.geojson"
hazard_count, hazard_bbox = import_hazard_layer(
conn,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
tile_root=args.tile_root,
zoom=args.zoom,
export_path=hazard_export,
)
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
feature_count=hazard_count,
bbox=hazard_bbox,
export_file=str(hazard_export.relative_to(project_root)),
)
conn.commit()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layer": {
"name": HAZARD_LAYER_NAME,
"count": hazard_count,
"bbox_lonlat": list(hazard_bbox),
"cell_size_m": HAZARD_CELL_M,
"export_file": str(hazard_export.relative_to(project_root)),
"source_desc": args.source_desc,
"tile_root": str(args.tile_root),
"zoom": args.zoom,
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"已导入 MySQL 数据库: {args.db_name}")
log(f" - hazard_50m: {hazard_count} {hazard_export}")
log(f" - manifest: {manifest_path}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql-v2/coast_200m"
LAYER_NAME = "coast_200m"
RADIUS = 6378137.0
def mysql_connect(database: str):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.SSCursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def parse_bbox(value: str) -> tuple[float, float, float, float]:
parts = [part.strip() for part in value.split(",")]
if len(parts) != 4:
raise argparse.ArgumentTypeError("bbox must be min_lon,min_lat,max_lon,max_lat")
try:
return tuple(float(part) for part in parts) # type: ignore[return-value]
except ValueError as exc:
raise argparse.ArgumentTypeError("bbox must contain valid numbers") from exc
def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
lon = math.degrees(x / RADIUS)
lat = math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
return lon, lat
def lon_to_mercator(lon: float) -> float:
return RADIUS * math.radians(lon)
def lat_to_mercator(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def main() -> None:
parser = argparse.ArgumentParser(description="Export Japan coast 200m grid assets from MySQL")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--bbox", type=parse_bbox, default=None, help="仅导出该经纬度范围内的格子")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
conn = mysql_connect(args.db_name)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file
FROM navsea_coast_grid_layer_meta
WHERE layer_name=%s
""",
(LAYER_NAME,),
)
meta_row = cur.fetchone()
if meta_row is None:
raise SystemExit(f"missing layer meta: {LAYER_NAME}")
(
description,
source_desc,
cell_size_m,
feature_count,
min_lon,
min_lat,
max_lon,
max_lat,
source_manifest,
) = meta_row
export_path = out_dir / "coast_200m_grid.geojson"
cell_sql = """
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m,
state_name, class_name, source_code, source_path, source_name,
min_lon, min_lat, max_lon, max_lat
FROM navsea_coast_grid_cell
WHERE layer_name=%s
"""
cell_params: list[float | str] = [LAYER_NAME]
if args.bbox is not None:
min_lon, min_lat, max_lon, max_lat = args.bbox
min_x = lon_to_mercator(min_lon)
min_y = lat_to_mercator(min_lat)
max_x = lon_to_mercator(max_lon)
max_y = lat_to_mercator(max_lat)
cell_sql += """
AND NOT (
max_lon < %s OR min_lon > %s OR
max_lat < %s OR min_lat > %s
)
"""
cell_params.extend([min_x, max_x, min_y, max_y])
cell_sql += """
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor() as cur, export_path.open("w", encoding="utf-8") as fh:
cur.execute(cell_sql, tuple(cell_params))
fh.write('{"type":"FeatureCollection","name":"japan_coast_200m_grid","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m_row,
state_name,
class_name,
source_code,
source_path,
source_name,
min_lon_row,
min_lat_row,
max_lon_row,
max_lat_row,
) = row
lon1, lat1 = mercator_to_lonlat(float(min_lon_row), float(min_lat_row))
lon2, lat2 = mercator_to_lonlat(float(max_lon_row), float(max_lat_row))
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m_row),
"state_name": state_name,
"class_name": class_name,
"source_code": source_code,
"source_path": source_path,
"source_name": source_name,
},
"geometry": rect_geometry(lon1, lat1, lon2, lat2),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
if args.bbox is None and count != int(feature_count):
raise RuntimeError(f"count mismatch: exported={count} meta={feature_count}")
if args.bbox is None:
lonlat_min = mercator_to_lonlat(float(min_lon), float(min_lat))
lonlat_max = mercator_to_lonlat(float(max_lon), float(max_lat))
mercator_min = [float(min_lon), float(min_lat)]
mercator_max = [float(max_lon), float(max_lat)]
else:
lonlat_min = (float(min_lon), float(min_lat))
lonlat_max = (float(max_lon), float(max_lat))
mercator_min = [lon_to_mercator(float(min_lon)), lat_to_mercator(float(min_lat))]
mercator_max = [lon_to_mercator(float(max_lon)), lat_to_mercator(float(max_lat))]
manifest = {
"version": "v1.0",
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layer": LAYER_NAME,
"description": description,
"source_desc": source_desc,
"source_manifest": source_manifest,
"count": count,
"cell_size_m": float(cell_size_m),
"grid_bounds_lonlat": [lonlat_min[0], lonlat_min[1], lonlat_max[0], lonlat_max[1]],
"grid_bounds_mercator": [mercator_min[0], mercator_min[1], mercator_max[0], mercator_max[1]],
"grid_file": "coast_200m_grid.geojson",
"notes": "从 navsea_japan_coast_grid.MySQL 直接导出的全国海岸 200m 硬阻塞格",
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导出:")
print(" -", export_path)
print(" -", manifest_path)
print(f" - count={count}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,518 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
import sys
from pathlib import Path
import numpy as np
import pymysql
from shapely import get_parts, intersects_xy
from shapely.geometry import LineString, box
from shapely.ops import polygonize, unary_union
from shapely.prepared import prep
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from coastline.navgrid_v2.build_fish_port_20m_mysql_resume import (
align_ceil,
align_floor,
build_point_lookup,
cluster_line_indices,
collect_fish_lines_for_prc,
discover_target_prcs,
load_coastlines_for_prc,
load_coarse_windows_for_bbox,
load_xml,
lon_from_mercator,
lat_from_mercator,
mercator_x,
mercator_y,
rect_lonlat,
)
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
FISH_SRC = "coastline/C09-06.zip"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql-v2/fish_port_debug"
DEFAULT_BUFFER_M = 20.0
DEFAULT_COARSE_MARGIN_M = 0.0
COAST_COARSE_LAYER = "coast_200m"
CELL_SIZE_M = 20.0
RADIUS = 6378137.0
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def mercator_to_lonlat_bbox(bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
minx, miny, maxx, maxy = bbox
return lon_from_mercator(minx), lat_from_mercator(miny), lon_from_mercator(maxx), lat_from_mercator(maxy)
def mercator_geom_to_lonlat_geojson(geom) -> dict:
geo = geom.__geo_interface__
def convert_coords(coords):
if not isinstance(coords, (list, tuple)):
return coords
if coords and isinstance(coords[0], (int, float)):
x = float(coords[0])
y = float(coords[1])
return [float(lon_from_mercator(x)), float(lat_from_mercator(y))]
return [convert_coords(item) for item in coords]
if geo["type"] == "GeometryCollection":
return {
"type": "GeometryCollection",
"geometries": [mercator_geom_to_lonlat_geojson(part) for part in geom.geoms],
}
return {
"type": geo["type"],
"coordinates": convert_coords(geo["coordinates"]),
}
def lonlat_line_feature(
coords: list[tuple[float, float]],
*,
kind: str,
fpc: str,
prc: str,
label: str | None = None,
) -> dict:
properties = {
"kind": kind,
"fpc": fpc,
"prc": prc,
}
if label is not None:
properties["label"] = label
return {
"type": "Feature",
"properties": properties,
"geometry": {
"type": "LineString",
"coordinates": [[float(lon), float(lat)] for lon, lat in coords],
},
}
def mercator_line_feature(
line: LineString,
*,
kind: str,
fpc: str,
prc: str,
label: str | None = None,
) -> dict:
coords = [[float(lon_from_mercator(x)), float(lat_from_mercator(y))] for x, y in line.coords]
return {
"type": "Feature",
"properties": {
"kind": kind,
"fpc": fpc,
"prc": prc,
**({"label": label} if label is not None else {}),
},
"geometry": {
"type": "LineString",
"coordinates": coords,
},
}
def bbox_feature(
bbox: tuple[float, float, float, float],
*,
kind: str,
fpc: str,
prc: str,
label: str,
) -> dict:
min_lon, min_lat, max_lon, max_lat = bbox
return {
"type": "Feature",
"properties": {
"kind": kind,
"fpc": fpc,
"prc": prc,
"label": label,
},
"geometry": rect_lonlat(min_lon, min_lat, max_lon, max_lat),
}
def mercator_bbox_feature(
bbox: tuple[float, float, float, float],
*,
kind: str,
fpc: str,
prc: str,
label: str,
) -> dict:
return bbox_feature(
mercator_to_lonlat_bbox(bbox),
kind=kind,
fpc=fpc,
prc=prc,
label=label,
)
def buffer_tag(value: float) -> str:
if abs(value - round(value)) < 1e-9:
return str(int(round(value)))
return str(value).replace(".", "p")
def scan_debug_cells(
scan_geom,
coast_prepared,
*,
prc: str,
fpc: str,
cluster_idx: int,
part_idx: int,
window_idx: int,
max_scan_cells: int | None,
) -> tuple[list[dict], int, int]:
fish_prepared = prep(scan_geom)
minx, miny, maxx, maxy = scan_geom.bounds
start_x = align_floor(minx, CELL_SIZE_M)
start_y = align_floor(miny, CELL_SIZE_M)
end_x = align_ceil(maxx, CELL_SIZE_M)
end_y = align_ceil(maxy, CELL_SIZE_M)
estimated_cols = max(0, int(math.ceil((end_x - start_x) / CELL_SIZE_M)))
estimated_rows = max(0, int(math.ceil((end_y - start_y) / CELL_SIZE_M)))
estimated_cells = estimated_cols * estimated_rows
if max_scan_cells is not None and estimated_cells > max_scan_cells:
raise RuntimeError(
"scan guard triggered: "
f"estimated_cells={estimated_cells} max_scan_cells={max_scan_cells} "
f"grid={estimated_cols}x{estimated_rows} bounds={tuple(round(v, 3) for v in scan_geom.bounds)} "
f"fpc={fpc} prc={prc} cluster={cluster_idx} part={part_idx} window={window_idx}"
)
half = CELL_SIZE_M / 2.0
ix_start = int(round(start_x / CELL_SIZE_M))
ix_end = int(round(end_x / CELL_SIZE_M))
ix_values = np.arange(ix_start, ix_end, dtype=np.int64)
x_values = ix_values.astype(float) * CELL_SIZE_M
x_center_values = x_values + half
x_right_values = x_values + CELL_SIZE_M
features: list[dict] = []
coast_count = 0
sea_count = 0
y = start_y
while y < end_y:
row_strip = box(start_x, y, end_x, y + CELL_SIZE_M)
if not fish_prepared.intersects(row_strip):
y += CELL_SIZE_M
continue
mask = (
intersects_xy(scan_geom, x_center_values, y + half)
| intersects_xy(scan_geom, x_values, y)
| intersects_xy(scan_geom, x_right_values, y)
| intersects_xy(scan_geom, x_values, y + CELL_SIZE_M)
| intersects_xy(scan_geom, x_right_values, y + CELL_SIZE_M)
)
if not np.any(mask):
y += CELL_SIZE_M
continue
mask = mask | np.r_[False, mask[:-1]] | np.r_[mask[1:], False]
for idx in np.flatnonzero(mask):
x = float(x_values[idx])
cell_geom = box(x, y, x + CELL_SIZE_M, y + CELL_SIZE_M)
if not fish_prepared.intersects(cell_geom):
continue
coast_hit = coast_prepared.intersects(cell_geom)
min_lon = lon_from_mercator(x)
min_lat = lat_from_mercator(y)
max_lon = lon_from_mercator(x + CELL_SIZE_M)
max_lat = lat_from_mercator(y + CELL_SIZE_M)
features.append(
{
"type": "Feature",
"properties": {
"kind": "cell_coast" if coast_hit else "cell_sea",
"fpc": fpc,
"prc": prc,
"cluster_idx": int(cluster_idx),
"part_idx": int(part_idx),
"window_idx": int(window_idx),
"cell_id": f"{int(round(x / CELL_SIZE_M))}:{int(round(y / CELL_SIZE_M))}",
"row": int(round(y / CELL_SIZE_M)),
"col": int(round(x / CELL_SIZE_M)),
"cell_size_m": CELL_SIZE_M,
"coast_hit": coast_hit,
},
"geometry": rect_lonlat(min_lon, min_lat, max_lon, max_lat),
}
)
if coast_hit:
coast_count += 1
else:
sea_count += 1
y += CELL_SIZE_M
return features, coast_count, sea_count
def main() -> None:
parser = argparse.ArgumentParser(description="Export one fish port debug bundle")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--fpc", required=True, help="港口 FPC 号,例如 4320050")
parser.add_argument("--coast-buffer-m", type=float, default=DEFAULT_BUFFER_M, help="海岸缓冲距离(米)")
parser.add_argument(
"--coast-coarse-margin-m",
type=float,
default=DEFAULT_COARSE_MARGIN_M,
help="coast_200m 粗窗查询外扩距离(米)",
)
parser.add_argument("--cluster-gap-m", type=float, default=250.0, help="线聚类间隔(米)")
parser.add_argument("--max-scan-cells", type=int, default=200000, help="单窗允许扫描的最大 20m 格数")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
fpc = str(args.fpc).strip()
buffer_dir = out_dir / f"fpc_{fpc}" / f"buffer_{buffer_tag(args.coast_buffer_m)}"
buffer_dir.mkdir(parents=True, exist_ok=True)
root = load_xml(project_root / FISH_SRC)
point_lookup = build_point_lookup(root)
prcs = discover_target_prcs(root, None, fpc)
if not prcs:
raise SystemExit(f"no PRC matched for FPC={fpc}")
prc = prcs[0]
fish_lines = collect_fish_lines_for_prc(root, point_lookup, prc, selected_fpc=fpc)
if not fish_lines:
raise SystemExit(f"no fish-port line features selected for FPC={fpc}")
fish_bboxes = [line.bbox for line in fish_lines]
fish_bbox = (
min(bb[0] for bb in fish_bboxes),
min(bb[1] for bb in fish_bboxes),
max(bb[2] for bb in fish_bboxes),
max(bb[3] for bb in fish_bboxes),
)
coast_lines, coast_bbox = load_coastlines_for_prc(
project_root,
prc,
bbox=(
mercator_x(fish_bbox[0]),
mercator_y(fish_bbox[1]),
mercator_x(fish_bbox[2]),
mercator_y(fish_bbox[3]),
),
search_margin_m=max(args.coast_buffer_m, args.coast_coarse_margin_m),
)
coast_geom = unary_union(coast_lines)
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
conn = mysql_connect(args.db_name)
try:
prc_coarse_windows = load_coarse_windows_for_bbox(
conn,
layer_name=COAST_COARSE_LAYER,
bbox_mercator=(
mercator_x(fish_bbox[0]),
mercator_y(fish_bbox[1]),
mercator_x(fish_bbox[2]),
mercator_y(fish_bbox[3]),
),
margin_m=args.coast_coarse_margin_m,
)
cluster_groups = cluster_line_indices([line.bbox for line in fish_lines], gap_m=args.cluster_gap_m)
features: list[dict] = []
features.append(
bbox_feature(
fish_bbox,
kind="fish_bbox",
fpc=fpc,
prc=prc,
label="fish_bbox",
)
)
features.append(
mercator_bbox_feature(
coast_bbox,
kind="coast_bbox",
fpc=fpc,
prc=prc,
label="coast_bbox",
)
)
for line in fish_lines:
features.append(
lonlat_line_feature(
line.coords,
kind="fish_line",
fpc=fpc,
prc=prc,
label="fish_line",
)
)
for line in coast_lines:
features.append(
mercator_line_feature(
line,
kind="coast_line",
fpc=fpc,
prc=prc,
label="coast_line",
)
)
features.append(
{
"type": "Feature",
"properties": {
"kind": "coast_buffer",
"fpc": fpc,
"prc": prc,
"buffer_m": args.coast_buffer_m,
"label": f"buffer={args.coast_buffer_m}m",
},
"geometry": mercator_geom_to_lonlat_geojson(coast_buffer),
}
)
for idx, coarse_window in enumerate(prc_coarse_windows):
features.append(
bbox_feature(
mercator_to_lonlat_bbox(coarse_window),
kind="coast_200m_window",
fpc=fpc,
prc=prc,
label=f"coast_200m_window_{idx + 1}",
)
)
scan_window_total = 0
cell_coast_total = 0
cell_sea_total = 0
for cluster_idx, cluster in enumerate(cluster_groups):
cluster_lines = [
LineString([(mercator_x(lon), mercator_y(lat)) for lon, lat in fish_lines[i].coords])
for i in cluster
]
merged = unary_union(cluster_lines)
polygons = list(polygonize(merged))
if polygons:
fish_geom = unary_union(polygons)
else:
fish_geom = merged.buffer(CELL_SIZE_M, cap_style=2, join_style=2)
fish_parts = [part for part in get_parts(fish_geom) if not part.is_empty]
for part_idx, fish_part in enumerate(fish_parts):
part_bbox = fish_part.bounds
part_coarse_windows = load_coarse_windows_for_bbox(
conn,
layer_name=COAST_COARSE_LAYER,
bbox_mercator=part_bbox,
margin_m=args.coast_coarse_margin_m,
)
for window_idx, coarse_window in enumerate(part_coarse_windows):
window_geom = box(*coarse_window)
scan_geom = window_geom
scan_window_total += 1
features.append(
{
"type": "Feature",
"properties": {
"kind": "scan_window",
"fpc": fpc,
"prc": prc,
"cluster_idx": int(cluster_idx),
"part_idx": int(part_idx),
"window_idx": int(window_idx),
"label": f"scan_window_{cluster_idx + 1}_{part_idx + 1}_{window_idx + 1}",
},
"geometry": mercator_geom_to_lonlat_geojson(scan_geom),
}
)
cells, coast_count, sea_count = scan_debug_cells(
scan_geom,
coast_prepared,
prc=prc,
fpc=fpc,
cluster_idx=cluster_idx,
part_idx=part_idx,
window_idx=window_idx,
max_scan_cells=args.max_scan_cells,
)
features.extend(cells)
cell_coast_total += coast_count
cell_sea_total += sea_count
out_path = buffer_dir / "debug.geojson"
out_path.write_text(
json.dumps({"type": "FeatureCollection", "features": features}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"fpc": fpc,
"prc": prc,
"buffer_m": args.coast_buffer_m,
"coast_coarse_margin_m": args.coast_coarse_margin_m,
"fish_bbox_lonlat": list(fish_bbox),
"fish_line_count": len(fish_lines),
"coast_curve_count": len(coast_lines),
"coast_bbox_mercator": list(coast_bbox),
"coast_200m_window_count": len(prc_coarse_windows),
"scan_window_count": scan_window_total,
"cell_coast_count": cell_coast_total,
"cell_sea_count": cell_sea_total,
"feature_count": len(features),
"export_file": str(out_path.relative_to(project_root)) if project_root in out_path.parents else str(out_path),
}
(buffer_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"debug: {len(features)} -> {out_path}")
print(f"manifest: {buffer_dir / 'manifest.json'}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,400 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql-v2/national"
TABLES = {
"coast_200m": "navsea_coast_grid_cell",
"fish_port_20m": "navsea_fish_port_grid_cell",
"hazard_50m": "navsea_hazard_grid_cell",
}
CELL_SIZES = {
"coast_200m": 200.0,
"fish_port_20m": 20.0,
"hazard_50m": 50.0,
}
CELL_PRIORITY = ("fish_port_20m", "hazard_50m", "coast_200m")
DENSITY_OVERVIEW_FILE = "density_overview_grid.geojson"
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> tuple[float, float, float, float]:
if layer_name == "coast_200m" and (
abs(min_lon) > 180.0 or abs(max_lon) > 180.0 or abs(min_lat) > 90.0 or abs(max_lat) > 90.0
):
return mercator_bbox_to_lonlat(min_lon, min_lat, max_lon, max_lat)
return min_lon, min_lat, max_lon, max_lat
def export_layer(
conn,
*,
table_name: str,
layer_name: str,
path: Path,
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
sql = f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM {table_name}
WHERE layer_name=%s
ORDER BY row_idx, col_idx, source_name
"""
count = 0
seen_cell_ids: set[str] = set()
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, (layer_name,))
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for row in cur:
(
row_layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
if cell_id in seen_cell_ids:
continue
seen_cell_ids.add(cell_id)
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
row_layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
feature = {
"type": "Feature",
"properties": {
"layer_name": row_layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def overlap_range(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def overlaps_finer_cells(
row_idx: int,
col_idx: int,
coarse_size_m: float,
fine_size_m: float,
fine_keys: set[tuple[int, int]],
) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = overlap_range(min_y, max_y, fine_size_m)
col_start, col_end = overlap_range(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
path.parent.mkdir(parents=True, exist_ok=True)
counts = {"20": 0, "50": 0, "200": 0}
bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
seen = {name: set[str]() for name in CELL_PRIORITY}
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
def write_feature(feature: dict) -> None:
nonlocal first
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
def read_rows(layer_name: str):
table_name = TABLES[layer_name]
cur.execute(
f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM {table_name}
WHERE layer_name=%s
ORDER BY row_idx, col_idx, source_name
""",
(layer_name,),
)
for row in cur:
yield row
for row in read_rows("fish_port_20m"):
layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name, min_lon, min_lat, max_lon, max_lat = row
if cell_id in seen["fish_port_20m"]:
continue
seen["fish_port_20m"].add(cell_id)
keys_20.add((int(row_idx), int(col_idx)))
counts["20"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(layer_name, float(min_lon), float(min_lat), float(max_lon), float(max_lat))
bbox[0] = min(bbox[0], min_lon)
bbox[1] = min(bbox[1], min_lat)
bbox[2] = max(bbox[2], max_lon)
bbox[3] = max(bbox[3], max_lat)
write_feature(
{
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density": 20,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
)
for row in read_rows("hazard_50m"):
layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name, min_lon, min_lat, max_lon, max_lat = row
if cell_id in seen["hazard_50m"]:
continue
if overlaps_finer_cells(int(row_idx), int(col_idx), CELL_SIZES["hazard_50m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
seen["hazard_50m"].add(cell_id)
keys_50.add((int(row_idx), int(col_idx)))
counts["50"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(layer_name, float(min_lon), float(min_lat), float(max_lon), float(max_lat))
bbox[0] = min(bbox[0], min_lon)
bbox[1] = min(bbox[1], min_lat)
bbox[2] = max(bbox[2], max_lon)
bbox[3] = max(bbox[3], max_lat)
write_feature(
{
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density": 50,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
)
for row in read_rows("coast_200m"):
layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name, min_lon, min_lat, max_lon, max_lat = row
if cell_id in seen["coast_200m"]:
continue
if overlaps_finer_cells(int(row_idx), int(col_idx), CELL_SIZES["coast_200m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
if overlaps_finer_cells(int(row_idx), int(col_idx), CELL_SIZES["coast_200m"], CELL_SIZES["hazard_50m"], keys_50):
continue
seen["coast_200m"].add(cell_id)
counts["200"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(layer_name, float(min_lon), float(min_lat), float(max_lon), float(max_lat))
bbox[0] = min(bbox[0], min_lon)
bbox[1] = min(bbox[1], min_lat)
bbox[2] = max(bbox[2], max_lon)
bbox[3] = max(bbox[3], max_lat)
write_feature(
{
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density": 200,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
)
fh.write("\n]}\n")
total = counts["20"] + counts["50"] + counts["200"]
return total, {
"kept_counts": counts,
"bbox_lonlat": None if total == 0 else bbox,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Export v2 navgrid layers from split MySQL tables")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--coast-200m", dest="coast_200m", action="store_true", default=True)
parser.add_argument("--no-coast-200m", dest="coast_200m", action="store_false")
parser.add_argument("--fish-port-20m", dest="fish_port_20m", action="store_true", default=True)
parser.add_argument("--no-fish-port-20m", dest="fish_port_20m", action="store_false")
parser.add_argument("--hazard-50m", dest="hazard_50m", action="store_true", default=True)
parser.add_argument("--no-hazard-50m", dest="hazard_50m", action="store_false")
parser.add_argument("--density-overview", dest="density_overview", action="store_true", default=True)
parser.add_argument("--no-density-overview", dest="density_overview", action="store_false")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
conn = mysql_connect(args.db_name)
try:
manifest_layers: dict[str, dict] = {}
if args.coast_200m:
coast_path = out_dir / "coast_200m_grid.geojson"
coast_count = export_layer(conn, table_name=TABLES["coast_200m"], layer_name="coast_200m", path=coast_path)
manifest_layers["coast_200m"] = {
"count": coast_count,
"cell_size_m": CELL_SIZES["coast_200m"],
"export_file": str(coast_path.relative_to(project_root)),
"source_table": TABLES["coast_200m"],
}
if args.fish_port_20m:
fish_path = out_dir / "fish_port_20m_grid.geojson"
fish_count = export_layer(conn, table_name=TABLES["fish_port_20m"], layer_name="fish_port_20m", path=fish_path)
manifest_layers["fish_port_20m"] = {
"count": fish_count,
"cell_size_m": CELL_SIZES["fish_port_20m"],
"export_file": str(fish_path.relative_to(project_root)),
"source_table": TABLES["fish_port_20m"],
}
if args.hazard_50m:
hazard_path = out_dir / "hazard_50m_grid.geojson"
hazard_count = export_layer(conn, table_name=TABLES["hazard_50m"], layer_name="hazard_50m", path=hazard_path)
manifest_layers["hazard_50m"] = {
"count": hazard_count,
"cell_size_m": CELL_SIZES["hazard_50m"],
"export_file": str(hazard_path.relative_to(project_root)),
"source_table": TABLES["hazard_50m"],
}
if args.density_overview:
density_path = out_dir / DENSITY_OVERVIEW_FILE
density_count, density_summary = export_density_overview(conn, path=density_path)
manifest_layers["density_overview"] = {
"count": density_count,
"export_file": str(density_path.relative_to(project_root)),
"source_tables": list(TABLES.values()),
**density_summary,
}
finally:
conn.close()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layers": manifest_layers,
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
for layer_name, info in manifest_layers.items():
print(f"{layer_name}: {info['count']}")
print(f"manifest: {manifest_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,218 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql-v2/prc50_test"
RADIUS = 6378137.0
TARGET_LAYER = "fish_port_20m"
DEFAULT_PRC = "50"
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> tuple[float, float, float, float]:
if layer_name == "coast_200m" and (
abs(min_lon) > 180.0 or abs(max_lon) > 180.0 or abs(min_lat) > 90.0 or abs(max_lat) > 90.0
):
return mercator_bbox_to_lonlat(min_lon, min_lat, max_lon, max_lat)
return min_lon, min_lat, max_lon, max_lat
def export_layer(
conn,
*,
path: Path,
where_sql: str,
params: tuple,
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
sql = f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_fish_port_grid_cell
WHERE {where_sql}
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, params)
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
row[0],
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def fetch_stats(conn, where_sql: str, params: tuple) -> tuple[int, list[float] | None]:
sql = f"""
SELECT COUNT(*), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_fish_port_grid_cell
WHERE {where_sql}
"""
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
count = int(row[0] or 0)
if count == 0:
return 0, None
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
params[0] if params else "",
float(row[1]),
float(row[2]),
float(row[3]),
float(row[4]),
)
return count, [min_lon, min_lat, max_lon, max_lat]
def main() -> None:
parser = argparse.ArgumentParser(description="Export a single PRC fish_port_20m test subset")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
parser.add_argument("--prc", default=DEFAULT_PRC, help="只导出这个 PRC 的 fish_port_20m,默认 50")
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
prc = str(args.prc).strip()
if prc.isdigit():
prc = f"{int(prc):02d}"
where_sql = "layer_name=%s AND state_name=%s AND source_name LIKE %s"
params = (TARGET_LAYER, "COASTLINE", f"C09-06+coastline PRC={prc}%")
out_path = out_dir / f"fish_port_20m_prc{prc}_grid.geojson"
conn = mysql_connect(args.db_name)
try:
count = export_layer(conn, path=out_path, where_sql=where_sql, params=params)
stat_count, bbox = fetch_stats(conn, where_sql, params)
if count != stat_count:
raise RuntimeError(f"export count mismatch: {count} != {stat_count}")
finally:
conn.close()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"prc": prc,
"layers": {
TARGET_LAYER: {
"count": count,
"bbox_lonlat": bbox,
"cell_size_m": 20.0,
"export_file": str(out_path.relative_to(project_root)),
"filter": f"state_name='COASTLINE' AND source_name LIKE 'C09-06+coastline PRC={prc}%'",
}
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{TARGET_LAYER}: {count} -> {out_path}")
print(f"manifest: {manifest_path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,500 @@
# NavSea V11 / Codex Task:后端生成 coast_wall_z12.bin
版本:v1.0
日期:2026-05-02
适用架构:NavSea V11
任务类型:Backend / Data Pipeline / Binary Asset Generator
---
## 0. V11 / Codex 执行约束
本任务必须遵守 NavSea V11 架构规则:
```text
不破坏现有结构
不引入未知依赖
新文件按指定路径创建
旧文件必须由用户提供原文件后再修改
新增 TypeScript 文件必须带 loggerFactory 初始化
输出完整可替换文件
保持模块边界清晰
```
新增 `.ts/.tsx` 文件必须包含:
```ts
import { loggerFactory } from "@/lib/logger/loggerFactory";
const logger = loggerFactory.create("FileName");
```
如项目实际 logger 路径不同,必须先由用户确认现有文件结构,不得臆测。
---
## 1. 任务目标
实现一个后端/工具链模块,用于从以下数据源生成:
```text
coast_wall_z12.bin
```
输入:
```text
海岸线矢量数据
PBF 提取的海上障碍物数据
```
输出:
```text
全国 coastline + obstacle blocked wall 二进制文件
```
该文件供前端或路径规划模块快速查询:
```ts
isBlocked(lat, lon) -> boolean
```
---
## 2. 非目标
本任务不做:
```text
完整 land/water mask
20m detail overlay
水深判断
吃水安全判断
路径规划算法本身
前端地图渲染
SQLite 航迹存储
```
本任务只负责生成 z12 / 200m blocked wall binary asset。
---
## 3. 生成文件
输出文件:
```text
coast_wall_z12.bin
```
可选输出:
```text
coast_wall_z12.meta.json
coast_wall_z12.debug.geojson
```
---
## 4. 输入数据接口
### 4.1 海岸线输入
支持至少一种格式:
```text
GeoJSON LineString / MultiLineString
```
后续可扩展:
```text
ENC coastline
PBF coastline
自有 vector coastline
```
### 4.2 障碍物输入
输入为从 PBF 中提取后的标准化 JSON/GeoJSON。
建议标准结构:
```ts
type ObstacleFeature = {
id: string;
kind: string;
geometry: Point | LineString | Polygon | MultiPolygon;
properties?: Record<string, unknown>;
};
```
---
## 5. 处理参数
默认参数:
```ts
const ZOOM = 12;
const GRID_W = 40;
const GRID_H = 40;
const CELL_COUNT = 1600;
const BLOCK_SIZE = 32;
const SPARSE_THRESHOLD = 96;
const COAST_DILATION_CELLS = 1;
```
障碍物 buffer 建议:
```ts
rock / wreck / obstruction: 200m
unknown danger: 400m
reef / shoal polygon: 200m
breakwater line: 200m
restricted area: 0m or by config
aquaculture / fish farm: 0m or by config
```
---
## 6. 核心数据结构
生成阶段使用:
```ts
type TileId = number;
type CellId = number;
type BlockedMap = Map<TileId, Set<CellId>>;
```
最终写入前转换为:
```ts
type EncodedTile = {
tileId: number;
encoding: 1 | 2;
count: number;
data: Uint16Array | Uint32Array;
};
```
---
## 7. tileId / cellId 编码
```ts
function packTileId(tileX: number, tileY: number): number {
return (tileX << 12) | tileY;
}
function unpackTileId(tileId: number): { tileX: number; tileY: number } {
return {
tileX: tileId >>> 12,
tileY: tileId & 0xfff,
};
}
function packCellId(row: number, col: number): number {
return row * 40 + col;
}
```
---
## 8. blockId 编码
```ts
function packBlockId(tileX: number, tileY: number): number {
const blockX = tileX >> 5;
const blockY = tileY >> 5;
return (blockX << 7) | blockY;
}
```
---
## 9. 海岸线处理流程
```text
for each coastline feature:
for each line segment:
convert lon/lat to WebMercator or tile-local coordinate
find crossed z12 tiles
convert to 40×40 cell coordinate
supercover rasterize segment
add blocked cell
after rasterization:
apply dilation 1 cell
```
要求:
```text
不能只采样线段端点
必须处理跨 tile 线段
必须处理 dilation 跨 tile 边界
```
---
## 10. 障碍物处理流程
```text
for each obstacle feature:
classify kind
determine geometry type
determine safety buffer
rasterize to z12 / 40×40 cells
add blocked cells
```
处理规则:
```text
Point -> circle buffer rasterize
Line -> line buffer rasterize
Polygon -> polygon body + optional buffer rasterize
```
所有障碍物结果与海岸线结果写入同一个 `BlockedMap`。
---
## 11. 编码规则
每个 tile:
```text
count = blocked cell count
```
规则:
```text
count = 0 -> skip
count <= 96 -> sparse uint16[]
count > 96 -> bitset uint32[50]
```
sparse:
```text
cellId sorted ascending
uint16[count]
```
bitset:
```text
uint32[50]
```
bitset 写入:
```ts
const word = cellId >>> 5;
const bit = cellId & 31;
words[word] |= 1 << bit;
```
---
## 12. 二进制文件格式
### 12.1 Header
```ts
type Header = {
magic: "CWZ12BIN"; // char[8]
version: 1; // uint16
zoom: 12; // uint8
gridW: 40; // uint8
gridH: 40; // uint8
blockSize: 32; // uint8
flags: number; // uint8
blockCount: number; // uint32
blockDirOffset: number; // uint32
dataOffset: number; // uint32
};
```
### 12.2 BlockEntry
```ts
type BlockEntry = {
blockId: number; // uint32
tileDirOffset: number; // uint32
tileCount: number; // uint16
reserved: number; // uint16
};
```
### 12.3 TileEntry
```ts
type TileEntry = {
tileId: number; // uint32
offset: number; // uint32, offset inside DataBlob
count: number; // uint16
encoding: 1 | 2; // uint8
reserved: number; // uint8
};
```
### 12.4 DataBlob
```text
encoding=1:
uint16[count]
encoding=2:
uint32[50]
```
---
## 13. 输出校验
生成器必须输出统计:
```text
totalBlockedTiles
totalBlockedCells
sparseTileCount
bitsetTileCount
blockCount
fileSizeBytes
coastlineFeatureCount
obstacleFeatureCount
```
必须检查:
```text
cellId must be 0..1599
tileX/tileY must be 0..4095
sparse list sorted
sparse list no duplicates
bitset length = 50 uint32
block entries sorted by blockId
tile entries sorted by tileId within block
```
---
## 14. Debug 输出
建议支持:
```text
--debug-geojson
```
输出抽样 blocked cells,便于在地图上验证。
Debug GeoJSON 每个 blocked cell 可输出为 polygon 或 point。
---
## 15. 测试要求
至少包含以下测试:
```text
1. tileId pack/unpack
2. blockId pack/unpack
3. cellId pack
4. sparse encode/decode
5. bitset encode/decode
6. sparse/bitset threshold
7. coastline segment rasterize
8. dilation across tile boundary
9. point obstacle buffer rasterize
10. binary writer / reader roundtrip
```
---
## 16. 性能要求
目标:
```text
运行时查询 O(1) 或 O(log n)
生成阶段允许离线耗时
最终文件建议控制在 20–150 MB
```
目录加载策略:
```text
仅 Header + BlockDirectory 常驻
TileDirectoryBlock 按需加载
Tile data 按需加载
LRU cache
```
---
## 17. 建议文件路径
如项目尚无对应目录,新建:
```text
tools/navmask/
generateCoastWallZ12.ts
coastWallBinaryWriter.ts
coastWallRasterizer.ts
coastWallTypes.ts
coastWallObstacleRules.ts
```
如已有数据工具目录,应沿用现有结构,不得新建平行架构。
---
## 18. 交付物
本任务完成后应交付:
```text
1. coast_wall_z12.bin 生成器
2. 二进制 writer
3. 基础 reader 用于测试
4. 单元测试
5. 生成统计日志
6. 可选 debug GeoJSON
7. README 或 usage doc
```
---
## 19. 命令行示例
```bash
pnpm tsx tools/navmask/generateCoastWallZ12.ts \
--coastline data/coastline.geojson \
--obstacles data/obstacles.geojson \
--out public/navdata/coast_wall_z12.bin \
--debug-geojson tmp/coast_wall_debug.geojson
```
---
## 20. 完成标准
任务完成必须满足:
```text
能够从 coastline + obstacle 输入生成 coast_wall_z12.bin
文件符合二进制格式
reader 能正确查询 blocked cell
跨 tile dilation 正确
障碍物已融合进 blocked wall
测试通过
无破坏 V11 结构
无未知依赖
```

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from PIL import Image, ImageDraw
from shapely.geometry import box, shape
from shapely.ops import transform, unary_union
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (129.73110007140482, 32.9464631795233, 131.19176072338317, 34.25430274729531)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def bbox_intersects(a, b) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def iter_polygons(geom):
gt = geom.geom_type
if gt == "Polygon":
yield geom
elif gt == "MultiPolygon":
for g in geom.geoms:
yield g
def draw_cell(draw: ImageDraw.ImageDraw, minx: float, maxy: float, px_size: float, x: float, y: float, color):
x0 = (x - minx) / px_size
y0 = (maxy - (y + px_size)) / px_size
x1 = x0 + 1
y1 = y0 + 1
draw.rectangle((x0, y0, x1, y1), fill=color)
def main() -> None:
parser = argparse.ArgumentParser(description="Render full Fukuoka/Saga fish-port 20m red/green cell raster")
parser.add_argument("--aoi-source", default="src/pbf/port-overlay/kyushu/port_overlay_kyushu_areas.geojson")
parser.add_argument("--output-png", default="src/pbf/coastline-only/fukuoka_saga_fish_port_navsplit_full_20m_aoi/fish_port_navsplit_fukuoka_saga_full_20m_aoi.png")
parser.add_argument("--bbox", nargs=4, type=float, default=list(DEFAULT_BBOX))
parser.add_argument("--cell-size-m", type=float, default=20.0)
parser.add_argument("--coast-buffer-m", type=float, default=12.0)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
aoi_path = Path(args.aoi_source)
if not aoi_path.is_absolute():
aoi_path = project_root / aoi_path
if not aoi_path.exists():
raise SystemExit(f"missing input: {aoi_path}")
data = json.loads(aoi_path.read_text(encoding="utf-8"))
bbox = tuple(args.bbox)
px_size = args.cell_size_m
selected = []
for feature in data.get("features", []):
props = feature.get("properties") or {}
if props.get("canonical_object_type") != "漁港":
continue
geom = shape(feature["geometry"])
if not bbox_intersects(geom.bounds, bbox):
continue
selected.append(transform(lambda x, y, z=None: (mercator_x(x), mercator_y(y)), geom))
if not selected:
raise SystemExit("no selected fish-port AOI polygons")
merged = unary_union(selected)
prepared = prep(merged)
minlon, minlat, maxlon, maxlat = bbox
minx, miny = mercator_x(minlon), mercator_y(minlat)
maxx, maxy = mercator_x(maxlon), mercator_y(maxlat)
width = max(1, math.ceil((maxx - minx) / px_size))
height = max(1, math.ceil((maxy - miny) / px_size))
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image, "RGBA")
cell_count = 0
land_count = 0
sea_count = 0
# 只对渔港 AOI 内的格子着色,外部保持透明。
for poly in iter_polygons(merged):
pb = poly.bounds
start_x = math.floor(pb[0] / px_size) * px_size
start_y = math.floor(pb[1] / px_size) * px_size
end_x = math.ceil(pb[2] / px_size) * px_size
end_y = math.ceil(pb[3] / px_size) * px_size
y = start_y
while y < end_y:
x = start_x
while x < end_x:
cell = box(x, y, x + px_size, y + px_size)
if prepared.intersects(cell):
# 海岸线 buffer 之外视为海面可进入,之内视为陆基不可进入。
# 这里暂用 AOI 内的海岸缓冲近似,渲染为红/绿小格。
draw_cell(draw, minx, maxy, px_size, x, y, (90, 210, 110, 110))
sea_count += 1
cell_count += 1
x += px_size
y += px_size
# 让港内不可进入区域更明显:再叠一层海岸缓冲红色边缘。
# 这里直接沿 AOI 外廓作一个保守缓冲带,后续可再细化到真正 coastline 交集。
outline = merged.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
outline_p = prep(outline)
for poly in iter_polygons(outline):
pb = poly.bounds
start_x = math.floor(pb[0] / px_size) * px_size
start_y = math.floor(pb[1] / px_size) * px_size
end_x = math.ceil(pb[2] / px_size) * px_size
end_y = math.ceil(pb[3] / px_size) * px_size
y = start_y
while y < end_y:
x = start_x
while x < end_x:
cell = box(x, y, x + px_size, y + px_size)
if outline_p.intersects(cell):
draw_cell(draw, minx, maxy, px_size, x, y, (240, 100, 100, 140))
land_count += 1
x += px_size
y += px_size
out_path = Path(args.output_png)
if not out_path.is_absolute():
out_path = project_root / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
image.save(out_path)
manifest = {
"aoi_source": str(aoi_path),
"bbox_lonlat": list(bbox),
"pixel_size_m": px_size,
"selected_polygon_count": len(selected),
"image_width": width,
"image_height": height,
"cell_count": cell_count,
"sea_count": sea_count,
"land_count": land_count,
"png_file": out_path.name,
}
(out_path.parent / "fish_port_navsplit_fukuoka_saga_full_20m_aoi_raster_manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("已导出:")
print(" -", out_path)
print(" -", out_path.parent / "fish_port_navsplit_fukuoka_saga_full_20m_aoi_raster_manifest.json")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,440 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from PIL import Image, ImageDraw
import mapbox_vector_tile
from shapely.geometry import box, shape
from shapely.ops import transform, unary_union
from shapely.prepared import prep
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
DEFAULT_BBOX = (129.73110007140482, 32.9464631795233, 131.19176072338317, 34.25430274729531)
COAST_CELL_M = 200.0
FISH_CELL_M = 20.0
HAZARD_CELL_M = 50.0
COAST_SRC = [
"coastline/C23-06_40_GML.zip",
"coastline/C23-06_41_GML.zip",
]
FISH_AOI_SRC = "src/pbf/port-overlay/kyushu/port_overlay_kyushu_areas.geojson"
PBF_TILE_ROOT = Path("/home/wwwroot/pbf-delivery-kyushu-reencoded")
PBF_TILE_Z = 12
HAZARD_LAYERS = {
"navigation_hazard_area",
"fixed_fishing_gear_area",
"anchor_caution_hazard_area",
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
LAND_LAYER = "land_area"
POINT_LAYERS = {
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def bbox_intersects(a, b) -> bool:
return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3])
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_xml(zip_path: Path):
import zipfile
import xml.etree.ElementTree as ET
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
return ET.fromstring(zf.read(xml_name))
def lonlat_from_text(text: str) -> tuple[float, float]:
parts = [p for p in text.replace(",", " ").split() if p]
if len(parts) < 2:
raise ValueError(f"invalid coordinate text: {text!r}")
lat = float(parts[0])
lon = float(parts[1])
return lon, lat
def build_point_lookup(root) -> dict[str, tuple[float, float]]:
lookup: dict[str, tuple[float, float]] = {}
for point in root.iter():
if local_name(point.tag) != "GM_Point":
continue
point_id = point.attrib.get("id")
if not point_id:
continue
coord_text = None
for el in point.iter():
if local_name(el.tag).endswith("DirectPosition.coordinate") and (el.text or "").strip():
coord_text = el.text.strip()
break
if coord_text is None:
continue
lookup[point_id] = lonlat_from_text(coord_text)
return lookup
def extract_curve_coords(curve, point_lookup: dict[str, tuple[float, float]]) -> list[tuple[float, float]]:
coords: list[tuple[float, float]] = []
for pos in curve.iter():
tag = local_name(pos.tag)
if not tag.startswith("GM_Position"):
continue
direct = None
ref = None
for child in list(pos):
child_tag = local_name(child.tag)
if child_tag.endswith("DirectPosition.coordinate") and (child.text or "").strip():
direct = child.text.strip()
break
if child_tag.endswith("PointRef.point"):
ref = child.attrib.get("idref")
if direct is not None:
coords.append(lonlat_from_text(direct))
elif ref and ref in point_lookup:
coords.append(point_lookup[ref])
return coords
def tile_lonlat_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2**z
lon_left = x / n * 360.0 - 180.0
lon_right = (x + 1) / n * 360.0 - 180.0
lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return lon_left, lat_bottom, lon_right, lat_top
def tile_mercator_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
lon_min, lat_min, lon_max, lat_max = tile_lonlat_bounds(z, x, y)
return mercator_x(lon_min), mercator_y(lat_min), mercator_x(lon_max), mercator_y(lat_max)
def transform_geom_from_tile(geom, bounds_m: tuple[float, float, float, float], extent: int):
minx, miny, maxx, maxy = bounds_m
dx = maxx - minx
dy = maxy - miny
def coord(x: float, y: float):
mx = minx + (x / extent) * dx
my = miny + (y / extent) * dy
return mx, my
def walk(obj):
if isinstance(obj[0], (int, float)):
return coord(obj[0], obj[1])
return [walk(item) for item in obj]
return walk(geom)
def iter_polygons(geom):
gt = geom.geom_type
if gt == "Polygon":
yield geom
elif gt == "MultiPolygon":
for g in geom.geoms:
yield g
def px_from_mercator(x: float, minx: float, px_m: float) -> float:
return (x - minx) / px_m
def py_from_mercator(y: float, maxy: float, px_m: float) -> float:
return (maxy - y) / px_m
def cell_to_px_rect(minx: float, maxy: float, px_m: float, x: float, y: float, cell_m: float) -> tuple[float, float, float, float]:
x0 = px_from_mercator(x, minx, px_m)
y0 = py_from_mercator(y + cell_m, maxy, px_m)
x1 = px_from_mercator(x + cell_m, minx, px_m)
y1 = py_from_mercator(y, maxy, px_m)
return x0, y0, x1, y1
def main() -> None:
parser = argparse.ArgumentParser(description="Render Fukuoka/Saga three-layer navigation preview")
parser.add_argument("--output-png", default="src/pbf/coastline-only/fukuoka_saga_three_layer/fukuoka_saga_three_layer.png")
parser.add_argument("--bbox", nargs=4, type=float, default=list(DEFAULT_BBOX))
parser.add_argument("--pixel-size-m", type=float, default=20.0)
parser.add_argument("--coast-buffer-m", type=float, default=12.0)
parser.add_argument("--point-buffer-m", type=float, default=25.0)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_path = Path(args.output_png)
if not out_path.is_absolute():
out_path = project_root / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
bbox = tuple(args.bbox)
px_m = args.pixel_size_m
# 1) 渔港 AOI
aoi_path = project_root / FISH_AOI_SRC
data = json.loads(aoi_path.read_text(encoding="utf-8"))
fish_polygons = []
fish_bounds_list = []
for feature in data.get("features", []):
props = feature.get("properties") or {}
if props.get("canonical_object_type") != "漁港":
continue
geom = shape(feature["geometry"])
if not bbox_intersects(geom.bounds, bbox):
continue
mg = transform(lambda x, y, z=None: (mercator_x(x), mercator_y(y)), geom)
fish_polygons.append(mg)
fish_bounds_list.append(mg.bounds)
if not fish_polygons:
raise SystemExit("no fish-port AOI polygons selected")
fish_geom = unary_union(fish_polygons)
fish_prepared = prep(fish_geom)
# 1b) 海岸线底座,用于限制渔港 20m 小框只出现在重叠区域
coast_lines = []
for src in COAST_SRC:
root = load_xml(project_root / src)
point_lookup = build_point_lookup(root)
for curve in root.findall(".//{http://www.opengis.net/gml/3.2}Curve"):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//{http://www.opengis.net/gml/3.2}posList"):
if not pos_list.text:
continue
values = [float(part) for part in pos_list.text.split() if part]
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) < 2:
coords = [(mercator_x(lon), mercator_y(lat)) for lon, lat in extract_curve_coords(curve, point_lookup)]
if len(coords) < 2:
continue
coast_lines.append(shape({"type": "LineString", "coordinates": coords}))
if not coast_lines:
raise SystemExit("no coastline curves were parsed")
coast_geom = unary_union(coast_lines)
coast_buffer = coast_geom.buffer(args.coast_buffer_m, cap_style=2, join_style=2)
coast_prepared = prep(coast_buffer)
# 2) 黄色层:PBF 危险对象
z = PBF_TILE_Z
n = 2**z
lon_min, lat_min, lon_max, lat_max = bbox
x0 = max(0, int((lon_min + 180.0) / 360.0 * n))
x1 = min(n - 1, int((lon_max + 180.0) / 360.0 * n))
def ytile(lat: float) -> int:
return int((1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n)
y0 = max(0, ytile(lat_max))
y1 = min(n - 1, ytile(lat_min))
hazard_cells_50m: set[tuple[int, int]] = set()
fish_reef_points: list[tuple[float, float]] = []
hazard_source_counts: dict[str, int] = {k: 0 for k in HAZARD_LAYERS}
hazard_features = 0
tile_count = 0
for tx in range(x0, x1 + 1):
for ty in range(y0, y1 + 1):
p = PBF_TILE_ROOT / f"{z}/{tx}/{ty}.pbf"
if not p.exists():
continue
try:
tile = mapbox_vector_tile.decode(p.read_bytes())
except Exception:
continue
tile_count += 1
bounds_m = tile_mercator_bounds(z, tx, ty)
for layer_name in HAZARD_LAYERS:
layer = tile.get(layer_name)
if not layer:
continue
extent = int(layer.get("extent") or 1048576)
for feature in layer.get("features", []):
geom = feature.get("geometry") or {}
if not geom:
continue
props = feature.get("properties") or {}
is_fish_reef = (
props.get("canonical_object_type") == "fish_reef"
or props.get("chart_symbol_code") == "fish_reef"
or props.get("class_name") == "魚礁"
)
gtype = geom.get("type")
coords = transform_geom_from_tile(geom.get("coordinates"), bounds_m, extent)
shp = shape({"type": gtype, "coordinates": coords})
if shp.is_empty:
continue
if is_fish_reef:
fish_reef_points.append((shp.centroid.x, shp.centroid.y))
hazard_features += 1
hazard_source_counts[layer_name] += 1
continue
if layer_name in POINT_LAYERS:
shp = shp.buffer(args.point_buffer_m)
hazard_features += 1
hazard_source_counts[layer_name] += 1
minx, miny, maxx, maxy = shp.bounds
start_x = math.floor(minx / HAZARD_CELL_M) * HAZARD_CELL_M
start_y = math.floor(miny / HAZARD_CELL_M) * HAZARD_CELL_M
end_x = math.ceil(maxx / HAZARD_CELL_M) * HAZARD_CELL_M
end_y = math.ceil(maxy / HAZARD_CELL_M) * HAZARD_CELL_M
prep_geom = prep(shp)
cy = start_y
while cy < end_y:
cx = start_x
while cx < end_x:
cell = box(cx, cy, cx + HAZARD_CELL_M, cy + HAZARD_CELL_M)
if prep_geom.intersects(cell):
hazard_cells_50m.add((int(round(cx / HAZARD_CELL_M)), int(round(cy / HAZARD_CELL_M))))
cx += HAZARD_CELL_M
cy += HAZARD_CELL_M
# 2b) 黄色层单独导出:50m 障碍格 + 鱼礁黄点
hazard_minx = mercator_x(bbox[0])
hazard_miny = mercator_y(bbox[1])
hazard_maxx = mercator_x(bbox[2])
hazard_maxy = mercator_y(bbox[3])
hazard_width = max(1, math.ceil((hazard_maxx - hazard_minx) / px_m))
hazard_height = max(1, math.ceil((hazard_maxy - hazard_miny) / px_m))
hazard_image = Image.new("RGBA", (hazard_width, hazard_height), (0, 0, 0, 0))
hazard_draw = ImageDraw.Draw(hazard_image, "RGBA")
fish_reef_count = 0
for ix, iy in hazard_cells_50m:
x = ix * HAZARD_CELL_M
y = iy * HAZARD_CELL_M
x0, y0, x1, y1 = cell_to_px_rect(hazard_minx, hazard_maxy, px_m, x, y, HAZARD_CELL_M)
hazard_draw.rectangle((x0, y0, x1, y1), fill=(242, 194, 48, 150), outline=(235, 180, 20, 160))
for x, y in fish_reef_points:
px = px_from_mercator(x, hazard_minx, px_m)
py = py_from_mercator(y, hazard_maxy, px_m)
r = 4.0
hazard_draw.ellipse((px - r, py - r, px + r, py + r), fill=(242, 194, 48, 235), outline=(141, 103, 0, 230), width=1)
fish_reef_count += 1
hazard_overlay_path = out_path.parent / "fukuoka_saga_hazard_50m_overlay.png"
hazard_image.save(hazard_overlay_path)
hazard_manifest = {
"bbox_lonlat": list(bbox),
"pixel_size_m": px_m,
"hazard_cell_m": HAZARD_CELL_M,
"hazard_cells_50m": len(hazard_cells_50m),
"fish_reef_points": fish_reef_count,
"hazard_features": hazard_features,
"hazard_source_counts": hazard_source_counts,
"pbf_tiles_scanned": tile_count,
"image_width": hazard_width,
"image_height": hazard_height,
"png_file": hazard_overlay_path.name,
}
hazard_manifest_path = out_path.parent / "fukuoka_saga_hazard_50m_overlay_manifest.json"
hazard_manifest_path.write_text(json.dumps(hazard_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
# 4) 20m 渔港不可航行格:只对渔港 AOI 内着色
minx_all, miny_all = fish_geom.bounds[0], fish_geom.bounds[1]
maxx_all, maxy_all = fish_geom.bounds[2], fish_geom.bounds[3]
width = max(1, math.ceil((maxx_all - minx_all) / px_m))
height = max(1, math.ceil((maxy_all - miny_all) / px_m))
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image, "RGBA")
red_cells = 0
coast_cells = 0
yellow_cells = 0
for poly in iter_polygons(fish_geom):
pb = poly.bounds
start_x = math.floor(pb[0] / FISH_CELL_M) * FISH_CELL_M
start_y = math.floor(pb[1] / FISH_CELL_M) * FISH_CELL_M
end_x = math.ceil(pb[2] / FISH_CELL_M) * FISH_CELL_M
end_y = math.ceil(pb[3] / FISH_CELL_M) * FISH_CELL_M
prep_poly = prep(poly)
y = start_y
while y < end_y:
x = start_x
while x < end_x:
cell = box(x, y, x + FISH_CELL_M, y + FISH_CELL_M)
if prep_poly.intersects(cell) and coast_prepared.intersects(cell):
x0, y0, x1, y1 = cell_to_px_rect(minx_all, maxy_all, px_m, x, y, FISH_CELL_M)
draw.rectangle((x0, y0, x1, y1), fill=(222, 92, 92, 170))
red_cells += 1
x += FISH_CELL_M
y += FISH_CELL_M
# 5) 黄色 50m 格
for ix, iy in hazard_cells_50m:
x = ix * HAZARD_CELL_M
y = iy * HAZARD_CELL_M
x0, y0, x1, y1 = cell_to_px_rect(minx_all, maxy_all, px_m, x, y, HAZARD_CELL_M)
draw.rectangle((x0, y0, x1, y1), fill=(242, 194, 48, 155))
yellow_cells += 1
image.save(out_path)
manifest = {
"bbox_lonlat": list(bbox),
"pixel_size_m": px_m,
"coast_cell_m": COAST_CELL_M,
"fish_cell_m": FISH_CELL_M,
"hazard_cell_m": HAZARD_CELL_M,
"fish_polygon_count": len(fish_polygons),
"fish_green_cells": 0,
"fish_red_cells": red_cells,
"hazard_cells_50m": yellow_cells,
"hazard_features": hazard_features,
"hazard_source_counts": hazard_source_counts,
"pbf_tiles_scanned": tile_count,
"image_width": width,
"image_height": height,
"png_file": out_path.name,
}
manifest_path = out_path.parent / "fukuoka_saga_three_layer_manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导出:")
print(" -", out_path)
print(" -", manifest_path)
print(" -", hazard_overlay_path)
print(" -", hazard_manifest_path)
if __name__ == "__main__":
main()