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,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()