441 lines
16 KiB
Python
441 lines
16 KiB
Python
#!/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()
|