#!/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("") if title_start == -1: return "unknown" title_end = meta_xml.find("", 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()