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