Files
pbf/coastline/render_fukuoka_saga_three_layer_png.py

441 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()