#!/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()