Files
pbf/coastline/navgrid_v2/build_coast_wall_z12_bin.py

871 lines
34 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 datetime as dt
import json
import math
import struct
from pathlib import Path
from typing import Iterable
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
ZOOM = 12
GRID_W = 40
GRID_H = 40
CELL_COUNT = GRID_W * GRID_H
BLOCK_SIZE = 32
SPARSE_THRESHOLD = 96
MAGIC = b"CWZ12BIN"
VERSION = 1
LAYER_20 = "fish_port_20m"
LAYER_50 = "hazard_50m"
LAYER_200 = "coast_200m"
CELL_SIZES = {
LAYER_20: 20.0,
LAYER_50: 50.0,
LAYER_200: 200.0,
}
TABLES = {
LAYER_20: "navsea_fish_port_grid_cell",
LAYER_50: "navsea_hazard_grid_cell",
LAYER_200: "navsea_coast_grid_cell",
}
def table_name_for_layer(layer_name: str) -> str:
if layer_name in TABLES:
return TABLES[layer_name]
if layer_name.startswith("coast_") and layer_name.endswith("m"):
return "navsea_coast_grid_cell"
raise KeyError(layer_name)
def coast_layer_name_for_size(cell_size_m: float) -> str:
if cell_size_m <= 0:
raise ValueError("coast cell size must be positive")
if float(cell_size_m).is_integer():
return f"coast_{int(cell_size_m)}m"
return f"coast_{cell_size_m:g}m"
def density_key_for_size(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return str(int(cell_size_m))
return f"{cell_size_m:g}"
def unit_tag_for_size(cell_size_m: float) -> str:
if float(cell_size_m).is_integer():
return str(int(cell_size_m))
return str(cell_size_m).replace(".", "_")
def wall_unit_tag(zoom: int, grid_w: int, grid_h: int) -> str:
if grid_w == grid_h:
return f"z{zoom}_g{grid_w}"
return f"z{zoom}_g{grid_w}x{grid_h}"
def build_out_path(path: Path, coast_unit: str, wall_unit: str, *, include_fish_port: bool) -> Path:
source_unit = f"{coast_unit}_50_20" if include_fish_port else f"{coast_unit}_50"
stem = path.stem
if wall_unit != "z12_g40" and stem.endswith("_z12"):
stem = stem.removesuffix("_z12")
suffix = f"_{source_unit}" if wall_unit == "z12_g40" else f"_{wall_unit}_{source_unit}"
return path.with_name(f"{stem}{suffix}{path.suffix}")
RADIUS = 6378137.0
WORLD_MIN = -math.pi * RADIUS
WORLD_MAX = math.pi * RADIUS
WORLD_SIZE = WORLD_MAX - WORLD_MIN
TILE_COUNT = 1 << ZOOM
TILE_SIZE_M = WORLD_SIZE / TILE_COUNT
Z12_CELL_SIZE_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_X_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_Y_M = TILE_SIZE_M / GRID_H
GLOBAL_CELL_COUNT_X = TILE_COUNT * GRID_W
GLOBAL_CELL_COUNT_Y = TILE_COUNT * GRID_H
BLOCK_AXIS_BITS = math.ceil(math.log2(math.ceil(TILE_COUNT / BLOCK_SIZE)))
def configure_wall_grid(*, zoom: int, grid_w: int, grid_h: int, sparse_threshold: int) -> None:
global ZOOM
global GRID_W
global GRID_H
global CELL_COUNT
global SPARSE_THRESHOLD
global TILE_COUNT
global TILE_SIZE_M
global Z12_CELL_SIZE_M
global GLOBAL_CELL_COUNT_X
global GLOBAL_CELL_COUNT_Y
global Z12_CELL_SIZE_X_M
global Z12_CELL_SIZE_Y_M
global BLOCK_AXIS_BITS
if not (0 <= zoom <= 16):
raise SystemExit("--zoom must be between 0 and 16")
if not (1 <= grid_w <= 255 and 1 <= grid_h <= 255):
raise SystemExit("--grid-w/--grid-h must be between 1 and 255")
if grid_w * grid_h > 65535:
raise SystemExit("--grid-w * --grid-h must be <= 65535 because cell ids are uint16")
if sparse_threshold < 0 or sparse_threshold > grid_w * grid_h:
raise SystemExit("--sparse-threshold must be between 0 and grid_w * grid_h")
ZOOM = zoom
GRID_W = grid_w
GRID_H = grid_h
CELL_COUNT = GRID_W * GRID_H
SPARSE_THRESHOLD = sparse_threshold
TILE_COUNT = 1 << ZOOM
TILE_SIZE_M = WORLD_SIZE / TILE_COUNT
Z12_CELL_SIZE_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_X_M = TILE_SIZE_M / GRID_W
Z12_CELL_SIZE_Y_M = TILE_SIZE_M / GRID_H
GLOBAL_CELL_COUNT_X = TILE_COUNT * GRID_W
GLOBAL_CELL_COUNT_Y = TILE_COUNT * GRID_H
BLOCK_AXIS_BITS = math.ceil(math.log2(math.ceil(TILE_COUNT / BLOCK_SIZE)))
HEADER_STRUCT = struct.Struct("<8sHBBBBBIII")
BLOCK_ENTRY_STRUCT = struct.Struct("<IIHH")
TILE_ENTRY_STRUCT = struct.Struct("<IIHBB")
PRESET_BBOXES = {
# 九州本岛和周边主要岛屿的调试范围,足够覆盖福冈/佐贺/长崎/熊本/鹿儿岛一带。
"kyushu": (128.0, 30.0, 132.5, 34.9),
}
def log(message: str) -> None:
print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] {message}")
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, 85.0511287798066), -85.0511287798066)
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 parse_bbox(value: str) -> tuple[float, float, float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 4:
raise argparse.ArgumentTypeError("bbox must be min_lon,min_lat,max_lon,max_lat")
min_lon, min_lat, max_lon, max_lat = parts
if min_lon >= max_lon or min_lat >= max_lat:
raise argparse.ArgumentTypeError("bbox min values must be smaller than max values")
return min_lon, min_lat, max_lon, max_lat
def parse_lonlat(value: str) -> tuple[float, float]:
parts = [float(part.strip()) for part in value.split(",") if part.strip()]
if len(parts) != 2:
raise argparse.ArgumentTypeError("query point must be lon,lat")
return parts[0], parts[1]
def mercator_bbox_from_lonlat(bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
min_lon, min_lat, max_lon, max_lat = bbox
return mercator_x(min_lon), mercator_y(min_lat), mercator_x(max_lon), mercator_y(max_lat)
def rowcol_range_for_bbox(
bbox_mercator: tuple[float, float, float, float],
cell_size_m: float,
) -> tuple[int, int, int, int]:
minx, miny, maxx, maxy = bbox_mercator
col_min = math.floor(minx / cell_size_m)
col_max = math.floor((maxx - 1e-9) / cell_size_m)
row_min = math.floor(miny / cell_size_m)
row_max = math.floor((maxy - 1e-9) / cell_size_m)
return row_min, row_max, col_min, col_max
def overlap_range(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def overlaps_finer_cells(
row_idx: int,
col_idx: int,
coarse_size_m: float,
fine_size_m: float,
fine_keys: set[tuple[int, int]],
) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = overlap_range(min_y, max_y, fine_size_m)
col_start, col_end = overlap_range(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def pack_tile_id(tile_x: int, tile_y: int) -> int:
if not (0 <= tile_x < TILE_COUNT and 0 <= tile_y < TILE_COUNT):
raise ValueError(f"tile out of z{ZOOM} range: {tile_x}, {tile_y}")
return (tile_x << ZOOM) | tile_y
def unpack_tile_id(tile_id: int) -> tuple[int, int]:
mask = (1 << ZOOM) - 1
return tile_id >> ZOOM, tile_id & mask
def pack_cell_id(row: int, col: int) -> int:
if not (0 <= row < GRID_H and 0 <= col < GRID_W):
raise ValueError(f"cell out of tile range: {row}, {col}")
return row * GRID_W + col
def pack_block_id(tile_x: int, tile_y: int) -> int:
block_x = tile_x >> 5
block_y = tile_y >> 5
return (block_x << BLOCK_AXIS_BITS) | block_y
def global_cell_x_range(minx: float, maxx: float) -> tuple[int, int]:
start = math.floor((minx - WORLD_MIN) / Z12_CELL_SIZE_X_M)
end = math.floor(((maxx - 1e-9) - WORLD_MIN) / Z12_CELL_SIZE_X_M)
return max(0, start), min(GLOBAL_CELL_COUNT_X - 1, end)
def global_cell_y_range(miny: float, maxy: float) -> tuple[int, int]:
# WebMercator tile rows start at the north edge, while project grid rows use increasing mercator Y.
start = math.floor((WORLD_MAX - maxy) / Z12_CELL_SIZE_Y_M)
end = math.floor(((WORLD_MAX - miny) - 1e-9) / Z12_CELL_SIZE_Y_M)
return max(0, start), min(GLOBAL_CELL_COUNT_Y - 1, end)
def add_mercator_rect_to_blocked(
blocked: dict[int, set[int]],
minx: float,
miny: float,
maxx: float,
maxy: float,
) -> int:
gx_start, gx_end = global_cell_x_range(minx, maxx)
gy_start, gy_end = global_cell_y_range(miny, maxy)
added = 0
for gy in range(gy_start, gy_end + 1):
tile_y = gy // GRID_H
row = gy % GRID_H
for gx in range(gx_start, gx_end + 1):
tile_x = gx // GRID_W
col = gx % GRID_W
tile_id = pack_tile_id(tile_x, tile_y)
cell_id = pack_cell_id(row, col)
cells = blocked.setdefault(tile_id, set())
before = len(cells)
cells.add(cell_id)
if len(cells) != before:
added += 1
return added
def iter_layer_rows(
conn,
*,
layer_name: str,
bbox_mercator: tuple[float, float, float, float] | None,
):
where = ["layer_name=%s"]
params: list[object] = [layer_name]
if bbox_mercator is not None:
row_min, row_max, col_min, col_max = rowcol_range_for_bbox(bbox_mercator, CELL_SIZES[layer_name])
where.append("row_idx BETWEEN %s AND %s")
where.append("col_idx BETWEEN %s AND %s")
params.extend([row_min, row_max, col_min, col_max])
table_name = table_name_for_layer(layer_name)
sql = f"""
SELECT cell_id, row_idx, col_idx
FROM {table_name}
WHERE {' AND '.join(where)}
ORDER BY row_idx, col_idx, source_name
"""
with conn.cursor(pymysql.cursors.SSCursor) as cur:
cur.execute(sql, tuple(params))
for cell_id, row_idx, col_idx in cur:
yield str(cell_id), int(row_idx), int(col_idx)
def source_cell_mercator_bbox(row_idx: int, col_idx: int, cell_size_m: float) -> tuple[float, float, float, float]:
minx = col_idx * cell_size_m
miny = row_idx * cell_size_m
return minx, miny, minx + cell_size_m, miny + cell_size_m
def build_blocked_map(
conn,
*,
coast_layer_name: str,
coast_cell_size_m: float,
include_fish_port: bool,
bbox_mercator: tuple[float, float, float, float] | None,
progress_every: int,
) -> tuple[dict[int, set[int]], dict]:
blocked: dict[int, set[int]] = {}
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
seen_20: set[str] = set()
seen_50: set[str] = set()
seen_200: set[str] = set()
coast_key = density_key_for_size(coast_cell_size_m)
summary = {
"sourceRows": {LAYER_20: 0, LAYER_50: 0, coast_layer_name: 0},
"sourceDistinctCells": {LAYER_20: 0, LAYER_50: 0, coast_layer_name: 0},
"keptByDensity": {"20": 0, "50": 0, coast_key: 0},
"skippedByDensity": {f"50_over_20": 0, f"{coast_key}_over_20": 0, f"{coast_key}_over_50": 0},
"blockedCellAdds": {"20": 0, "50": 0, coast_key: 0},
"activeLayers": [LAYER_50, coast_layer_name],
"disabledLayers": [],
}
if include_fish_port:
summary["activeLayers"].insert(0, LAYER_20)
log("读取 fish_port_20m;density 规则:20m 永远保留")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_20, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_20] += 1
if cell_id in seen_20:
continue
seen_20.add(cell_id)
keys_20.add((row_idx, col_idx))
summary["sourceDistinctCells"][LAYER_20] += 1
summary["keptByDensity"]["20"] += 1
summary["blockedCellAdds"]["20"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_20]),
)
if progress_every and summary["sourceRows"][LAYER_20] % progress_every == 0:
log(f"fish_port_20m rows={summary['sourceRows'][LAYER_20]} kept={summary['keptByDensity']['20']}")
else:
summary["disabledLayers"].append(
{
"layer": LAYER_20,
"reason": f"coast_cell_size_m={coast_cell_size_m:g} < fish_port_20m precision",
}
)
log(f"跳过 fish_port_20m;原因:coast_cell_size_m={coast_cell_size_m:g} 已细于 20m")
hazard_rule = "与 20m 重叠则跳过" if include_fish_port else "不再与 20m 做重叠裁剪"
log(f"读取 hazard_50m;density 规则:{hazard_rule}")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=LAYER_50, bbox_mercator=bbox_mercator):
summary["sourceRows"][LAYER_50] += 1
if cell_id in seen_50:
continue
seen_50.add(cell_id)
summary["sourceDistinctCells"][LAYER_50] += 1
if include_fish_port and overlaps_finer_cells(row_idx, col_idx, CELL_SIZES[LAYER_50], CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"]["50_over_20"] += 1
continue
keys_50.add((row_idx, col_idx))
summary["keptByDensity"]["50"] += 1
summary["blockedCellAdds"]["50"] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, CELL_SIZES[LAYER_50]),
)
if progress_every and summary["sourceRows"][LAYER_50] % progress_every == 0:
log(f"hazard_50m rows={summary['sourceRows'][LAYER_50]} kept={summary['keptByDensity']['50']}")
coast_rule = "与 20m/保留后的 50m 重叠则跳过" if include_fish_port else "与保留后的 50m 重叠则跳过"
log(f"读取 {coast_layer_name};density 规则:{coast_rule}")
for cell_id, row_idx, col_idx in iter_layer_rows(conn, layer_name=coast_layer_name, bbox_mercator=bbox_mercator):
summary["sourceRows"][coast_layer_name] += 1
if cell_id in seen_200:
continue
seen_200.add(cell_id)
summary["sourceDistinctCells"][coast_layer_name] += 1
if include_fish_port and overlaps_finer_cells(row_idx, col_idx, coast_cell_size_m, CELL_SIZES[LAYER_20], keys_20):
summary["skippedByDensity"][f"{coast_key}_over_20"] += 1
continue
if overlaps_finer_cells(row_idx, col_idx, coast_cell_size_m, CELL_SIZES[LAYER_50], keys_50):
summary["skippedByDensity"][f"{coast_key}_over_50"] += 1
continue
summary["keptByDensity"][coast_key] += 1
summary["blockedCellAdds"][coast_key] += add_mercator_rect_to_blocked(
blocked,
*source_cell_mercator_bbox(row_idx, col_idx, coast_cell_size_m),
)
if progress_every and summary["sourceRows"][coast_layer_name] % progress_every == 0:
log(f"{coast_layer_name} rows={summary['sourceRows'][coast_layer_name]} kept={summary['keptByDensity'][coast_key]}")
return blocked, summary
def encode_tile_cells(cells: set[int]) -> tuple[int, int, bytes]:
sorted_cells = sorted(cells)
if not sorted_cells:
raise ValueError("empty tile should not be encoded")
if sorted_cells[0] < 0 or sorted_cells[-1] >= CELL_COUNT:
raise ValueError(f"cellId out of range: {sorted_cells[0]}..{sorted_cells[-1]}")
if len(sorted_cells) <= SPARSE_THRESHOLD:
payload = struct.pack(f"<{len(sorted_cells)}H", *sorted_cells)
return 1, len(sorted_cells), payload
word_count = math.ceil(CELL_COUNT / 32)
words = [0] * word_count
for cell_id in sorted_cells:
word = cell_id >> 5
bit = cell_id & 31
words[word] |= 1 << bit
payload = struct.pack(f"<{word_count}I", *words)
return 2, len(sorted_cells), payload
def write_bin(path: Path, blocked: dict[int, set[int]]) -> dict:
path.parent.mkdir(parents=True, exist_ok=True)
grouped: dict[int, list[tuple[int, int, int, bytes]]] = {}
sparse_count = 0
bitset_count = 0
total_blocked_cells = 0
for tile_id in sorted(blocked):
cells = blocked[tile_id]
if not cells:
continue
tile_x, tile_y = unpack_tile_id(tile_id)
encoding, count, payload = encode_tile_cells(cells)
if encoding == 1:
sparse_count += 1
else:
bitset_count += 1
total_blocked_cells += count
block_id = pack_block_id(tile_x, tile_y)
grouped.setdefault(block_id, []).append((tile_id, encoding, count, payload))
block_ids = sorted(grouped)
block_count = len(block_ids)
block_dir_offset = HEADER_STRUCT.size
tile_dir_offset = block_dir_offset + block_count * BLOCK_ENTRY_STRUCT.size
tile_entry_count = sum(len(grouped[block_id]) for block_id in block_ids)
data_offset = tile_dir_offset + tile_entry_count * TILE_ENTRY_STRUCT.size
block_entries: list[tuple[int, int, int, int]] = []
tile_entries_by_block: dict[int, list[tuple[int, int, int, int]]] = {}
data_parts: list[bytes] = []
current_tile_dir_offset = tile_dir_offset
current_data_offset = 0
for block_id in block_ids:
tiles = sorted(grouped[block_id], key=lambda item: item[0])
block_entries.append((block_id, current_tile_dir_offset, len(tiles), 0))
current_tile_dir_offset += len(tiles) * TILE_ENTRY_STRUCT.size
tile_entries: list[tuple[int, int, int, int]] = []
for tile_id, encoding, count, payload in tiles:
tile_entries.append((tile_id, current_data_offset, count, encoding))
data_parts.append(payload)
current_data_offset += len(payload)
tile_entries_by_block[block_id] = tile_entries
with path.open("wb") as fh:
fh.write(
HEADER_STRUCT.pack(
MAGIC,
VERSION,
ZOOM,
GRID_W,
GRID_H,
BLOCK_SIZE,
0,
block_count,
block_dir_offset,
data_offset,
)
)
for entry in block_entries:
fh.write(BLOCK_ENTRY_STRUCT.pack(*entry))
for block_id in block_ids:
for tile_id, offset, count, encoding in tile_entries_by_block[block_id]:
fh.write(TILE_ENTRY_STRUCT.pack(tile_id, offset, count, encoding, 0))
for payload in data_parts:
fh.write(payload)
file_size = path.stat().st_size
return {
"file": str(path),
"fileSizeBytes": file_size,
"headerSizeBytes": HEADER_STRUCT.size,
"blockCount": block_count,
"totalBlockedTiles": sparse_count + bitset_count,
"totalBlockedCells": total_blocked_cells,
"sparseTileCount": sparse_count,
"bitsetTileCount": bitset_count,
"blockDirOffset": block_dir_offset,
"dataOffset": data_offset,
"dataBytes": current_data_offset,
}
class CoastWallReader:
def __init__(self, path: Path):
self.path = path
self.data = path.read_bytes()
header = HEADER_STRUCT.unpack_from(self.data, 0)
magic, version, zoom, grid_w, grid_h, block_size, _flags, block_count, block_dir_offset, data_offset = header
if magic != MAGIC:
raise ValueError(f"bad magic: {magic!r}")
if version != VERSION or zoom != ZOOM or grid_w != GRID_W or grid_h != GRID_H or block_size != BLOCK_SIZE:
raise ValueError(
"unsupported coast wall header: "
f"file version={version} zoom={zoom} grid={grid_w}x{grid_h} block={block_size}; "
f"reader expects version={VERSION} zoom={ZOOM} grid={GRID_W}x{GRID_H} block={BLOCK_SIZE}"
)
self.block_count = block_count
self.block_dir_offset = block_dir_offset
self.data_offset = data_offset
self.blocks: dict[int, list[tuple[int, int, int, int]]] = {}
for idx in range(block_count):
offset = block_dir_offset + idx * BLOCK_ENTRY_STRUCT.size
block_id, tile_dir_offset, tile_count, _reserved = BLOCK_ENTRY_STRUCT.unpack_from(self.data, offset)
entries: list[tuple[int, int, int, int]] = []
for tile_idx in range(tile_count):
tile_offset = tile_dir_offset + tile_idx * TILE_ENTRY_STRUCT.size
tile_id, payload_offset, count, encoding, _tile_reserved = TILE_ENTRY_STRUCT.unpack_from(self.data, tile_offset)
entries.append((tile_id, payload_offset, count, encoding))
self.blocks[block_id] = entries
def tile_cells(self, tile_id: int) -> set[int]:
tile_x, tile_y = unpack_tile_id(tile_id)
block_id = pack_block_id(tile_x, tile_y)
for entry_tile_id, payload_offset, count, encoding in self.blocks.get(block_id, []):
if entry_tile_id != tile_id:
continue
start = self.data_offset + payload_offset
if encoding == 1:
values = struct.unpack_from(f"<{count}H", self.data, start)
return set(values)
if encoding == 2:
word_count = math.ceil(CELL_COUNT / 32)
words = struct.unpack_from(f"<{word_count}I", self.data, start)
cells: set[int] = set()
for word_idx, word in enumerate(words):
if not word:
continue
for bit in range(32):
if word & (1 << bit):
cell_id = word_idx * 32 + bit
if cell_id < CELL_COUNT:
cells.add(cell_id)
return cells
raise ValueError(f"unknown encoding: {encoding}")
return set()
def is_blocked_lonlat(self, lon: float, lat: float) -> bool:
tile_x, tile_y, _row, _col, cell_id = z12_cell_for_lonlat(lon, lat)
tile_id = pack_tile_id(tile_x, tile_y)
return cell_id in self.tile_cells(tile_id)
def validate_bin(path: Path, blocked: dict[int, set[int]], sample_tiles: int = 200) -> None:
reader = CoastWallReader(path)
for idx, tile_id in enumerate(sorted(blocked)):
if idx >= sample_tiles:
break
expected = blocked[tile_id]
actual = reader.tile_cells(tile_id)
if actual != expected:
raise RuntimeError(f"reader roundtrip mismatch tile={tile_id}: expected={len(expected)} actual={len(actual)}")
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def z12_cell_bounds(tile_x: int, tile_y: int, row: int, col: int) -> tuple[float, float, float, float]:
minx = WORLD_MIN + (tile_x * GRID_W + col) * Z12_CELL_SIZE_X_M
maxx = minx + Z12_CELL_SIZE_X_M
maxy = WORLD_MAX - (tile_y * GRID_H + row) * Z12_CELL_SIZE_Y_M
miny = maxy - Z12_CELL_SIZE_Y_M
return lon_from_mercator(minx), lat_from_mercator(miny), lon_from_mercator(maxx), lat_from_mercator(maxy)
def z12_cell_for_lonlat(lon: float, lat: float) -> tuple[int, int, int, int, int]:
x = mercator_x(lon)
y = mercator_y(lat)
gx = math.floor((x - WORLD_MIN) / Z12_CELL_SIZE_X_M)
gy = math.floor((WORLD_MAX - y) / Z12_CELL_SIZE_Y_M)
if not (0 <= gx < GLOBAL_CELL_COUNT_X and 0 <= gy < GLOBAL_CELL_COUNT_Y):
raise ValueError(f"lon/lat outside z{ZOOM} grid: {lon}, {lat}")
tile_x = gx // GRID_W
tile_y = gy // GRID_H
col = gx % GRID_W
row = gy % GRID_H
cell_id = pack_cell_id(row, col)
return tile_x, tile_y, row, col, cell_id
def write_debug_geojson(path: Path, blocked: dict[int, set[int]], limit: int) -> 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 tile_id in sorted(blocked):
tile_x, tile_y = unpack_tile_id(tile_id)
for cell_id in sorted(blocked[tile_id]):
row = cell_id // GRID_W
col = cell_id % GRID_W
feature = {
"type": "Feature",
"properties": {
"tile_id": tile_id,
"tile_x": tile_x,
"tile_y": tile_y,
"cell_id": cell_id,
"row": row,
"col": col,
},
"geometry": rect_geometry(*z12_cell_bounds(tile_x, tile_y, row, col)),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if limit and count >= limit:
fh.write("\n]}\n")
return count
fh.write("\n]}\n")
return count
def run_self_test() -> None:
sample_tile_x = min(TILE_COUNT - 1, max(0, TILE_COUNT * 3 // 4))
sample_tile_y = min(TILE_COUNT - 1, max(0, TILE_COUNT // 5))
assert unpack_tile_id(pack_tile_id(sample_tile_x, sample_tile_y)) == (sample_tile_x, sample_tile_y)
assert pack_cell_id(GRID_H - 1, GRID_W - 1) == CELL_COUNT - 1
assert pack_block_id(32, 64) == ((1 << BLOCK_AXIS_BITS) | 2)
sparse_cells = {0, min(3, CELL_COUNT - 1), CELL_COUNT - 1}
encoding, count, payload = encode_tile_cells(sparse_cells)
assert encoding == 1 and count == len(sparse_cells)
assert set(struct.unpack(f"<{len(sparse_cells)}H", payload)) == sparse_cells
if SPARSE_THRESHOLD < CELL_COUNT:
dense_count = min(CELL_COUNT, max(SPARSE_THRESHOLD + 1, 120))
dense_cells = set(range(dense_count))
encoding, count, payload = encode_tile_cells(dense_cells)
assert encoding == 2 and count == dense_count and len(payload) == math.ceil(CELL_COUNT / 32) * 4
log("self-test passed")
def main() -> None:
parser = argparse.ArgumentParser(description="Build coast wall bin from MySQL density-overview grid layers")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out", default="out/navmask/coast_wall_z12.bin")
parser.add_argument("--meta-json", default=None, help="默认写到 <out>.meta.json")
parser.add_argument("--debug-geojson", default=None)
parser.add_argument("--debug-limit", type=int, default=5000)
parser.add_argument("--preset", choices=["all", *sorted(PRESET_BBOXES)], default="kyushu")
parser.add_argument("--bbox", type=parse_bbox, default=None, help="min_lon,min_lat,max_lon,max_lat;优先于 --preset")
parser.add_argument("--query", type=parse_lonlat, default=None, help="生成后用 reader 查询一个 lon,lat 点")
parser.add_argument("--progress-every", type=int, default=200000)
parser.add_argument("--zoom", type=int, default=12, help="阻断墙输出 tile zoom,默认 12")
parser.add_argument(
"--grid-size",
type=int,
default=40,
help="每个输出 tile 内部切成 N x N 个墙 cell,默认 40;值越大墙 cell 越小",
)
parser.add_argument("--grid-w", type=int, default=None, help="覆盖输出 tile 内部横向 cell 数")
parser.add_argument("--grid-h", type=int, default=None, help="覆盖输出 tile 内部纵向 cell 数")
parser.add_argument(
"--sparse-threshold",
type=int,
default=96,
help="单 tile blocked cell 数量小于等于该值时使用 sparse 编码,默认 96",
)
parser.add_argument(
"--coast-cell-size-m",
type=float,
default=200.0,
help="参与 bin 生成的海岸线格网大小,默认 200;可切 100、50、20、10 等已入库层",
)
parser.add_argument(
"--coast-layer-name",
default=None,
help="覆盖海岸线层名;不传时按 --coast-cell-size-m 自动生成,例如 coast_200m / coast_50m / coast_10m",
)
parser.add_argument("--self-test", action="store_true")
args = parser.parse_args()
grid_w = args.grid_w if args.grid_w is not None else args.grid_size
grid_h = args.grid_h if args.grid_h is not None else args.grid_size
configure_wall_grid(
zoom=args.zoom,
grid_w=grid_w,
grid_h=grid_h,
sparse_threshold=args.sparse_threshold,
)
if args.self_test:
run_self_test()
if args.coast_cell_size_m <= 0:
raise SystemExit("--coast-cell-size-m must be positive")
coast_layer_name = args.coast_layer_name or coast_layer_name_for_size(args.coast_cell_size_m)
coast_cell_size_m = float(args.coast_cell_size_m)
if coast_layer_name in CELL_SIZES and CELL_SIZES[coast_layer_name] != coast_cell_size_m:
raise SystemExit(
f"{coast_layer_name} already maps to {CELL_SIZES[coast_layer_name]}m, "
f"but --coast-cell-size-m={coast_cell_size_m}"
)
CELL_SIZES.setdefault(coast_layer_name, coast_cell_size_m)
coast_unit_tag = unit_tag_for_size(coast_cell_size_m)
wall_unit = wall_unit_tag(ZOOM, GRID_W, GRID_H)
include_fish_port = coast_cell_size_m >= CELL_SIZES[LAYER_20]
bbox_lonlat = args.bbox
if bbox_lonlat is None and args.preset != "all":
bbox_lonlat = PRESET_BBOXES[args.preset]
bbox_mercator = mercator_bbox_from_lonlat(bbox_lonlat) if bbox_lonlat is not None else None
out_path = Path(args.out)
out_path = build_out_path(out_path, coast_unit_tag, wall_unit, include_fish_port=include_fish_port)
meta_path = Path(args.meta_json) if args.meta_json else out_path.with_suffix(out_path.suffix + ".meta.json")
log(
f"开始生成 coast wall bin zoom={ZOOM} grid={GRID_W}x{GRID_H} "
f"wall_cell_m={Z12_CELL_SIZE_X_M:.3f}x{Z12_CELL_SIZE_Y_M:.3f} "
f"preset={args.preset} bbox={bbox_lonlat} "
f"coast_layer={coast_layer_name} include_fish_port={include_fish_port} out={out_path.name}"
)
conn = mysql_connect(args.db_name)
try:
blocked, density_summary = build_blocked_map(
conn,
coast_layer_name=coast_layer_name,
coast_cell_size_m=coast_cell_size_m,
include_fish_port=include_fish_port,
bbox_mercator=bbox_mercator,
progress_every=args.progress_every,
)
finally:
conn.close()
if not blocked:
raise SystemExit("no blocked cells produced")
write_summary = write_bin(out_path, blocked)
validate_bin(out_path, blocked)
debug_count = 0
if args.debug_geojson:
debug_count = write_debug_geojson(Path(args.debug_geojson), blocked, args.debug_limit)
query_result = None
if args.query is not None:
lon, lat = args.query
reader = CoastWallReader(out_path)
tile_x, tile_y, row, col, cell_id = z12_cell_for_lonlat(lon, lat)
query_result = {
"lon": lon,
"lat": lat,
"tile_x": tile_x,
"tile_y": tile_y,
"row": row,
"col": col,
"cell_id": cell_id,
"blocked": reader.is_blocked_lonlat(lon, lat),
}
meta = {
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"db_name": args.db_name,
"preset": args.preset,
"bbox_lonlat": bbox_lonlat,
"coast_layer_name": coast_layer_name,
"coast_cell_size_m": coast_cell_size_m,
"include_fish_port_20m": include_fish_port,
"zoom": ZOOM,
"grid_w": GRID_W,
"grid_h": GRID_H,
"wall_cell_size_m": Z12_CELL_SIZE_M,
"wall_cell_size_x_m": Z12_CELL_SIZE_X_M,
"wall_cell_size_y_m": Z12_CELL_SIZE_Y_M,
"z12_cell_size_m": Z12_CELL_SIZE_M,
"sparse_threshold": SPARSE_THRESHOLD,
"density_rule": (
[
"keep fish_port_20m first",
"keep hazard_50m only when it does not overlap kept fish_port_20m",
f"keep {coast_layer_name} only when it does not overlap kept fish_port_20m or kept hazard_50m",
]
if include_fish_port
else [
"skip fish_port_20m because coast_cell_size_m is finer than 20m",
"keep hazard_50m",
f"keep {coast_layer_name} only when it does not overlap kept hazard_50m",
]
),
"density_summary": density_summary,
"binary_summary": write_summary,
"debug_geojson": args.debug_geojson,
"debug_feature_count": debug_count,
"query_result": query_result,
}
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"完成 bin={out_path} meta={meta_path}")
log(
"统计 "
f"tiles={write_summary['totalBlockedTiles']} cells={write_summary['totalBlockedCells']} "
f"sparse={write_summary['sparseTileCount']} bitset={write_summary['bitsetTileCount']} "
f"blocks={write_summary['blockCount']} size={write_summary['fileSizeBytes']}"
)
if query_result is not None:
log(f"query lon={query_result['lon']} lat={query_result['lat']} blocked={query_result['blocked']}")
if __name__ == "__main__":
main()