Files
pbf/navsea_geometry_native_audit.py
2026-04-18 09:43:42 +08:00

369 lines
13 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 os
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from mapbox_vector_tile.Mapbox import vector_tile_pb2
DEFAULT_SOURCE_ROOT = Path(
"/home/wwwroot/newpec/exported_auto/"
"tile.mapple-on.jp__newpec-mvt-20260106__z___x___y_.pbf/tiles"
)
@dataclass
class LayerGeometryStats:
point_count: int = 0
min_x: int = 0
max_x: int = 0
min_y: int = 0
max_y: int = 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="审计并按需修复 NavSea PBF 的几何 / extent / native 风险。")
parser.add_argument("--target-root", type=Path, required=True)
parser.add_argument("--label", required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--source-root", type=Path, default=DEFAULT_SOURCE_ROOT)
parser.add_argument("--apply-fix", action="store_true", help="若检测到 extent 与源瓦片不一致,则按源瓦片 extent 原地修复。")
parser.add_argument("--sample-limit", type=int, default=30)
parser.add_argument("--zoom", action="append", type=int, help="只审指定 zoom可重复传入多次。")
return parser.parse_args()
def zigzag_decode(value: int) -> int:
return (value >> 1) ^ (-(value & 1))
def decode_feature_geometry(geometry: list[int]) -> LayerGeometryStats:
stats = LayerGeometryStats()
cursor_x = 0
cursor_y = 0
initialized = False
idx = 0
while idx < len(geometry):
command = geometry[idx]
idx += 1
command_id = command & 0x7
command_count = command >> 3
if command_id in (1, 2): # MoveTo, LineTo
for _ in range(command_count):
if idx + 1 >= len(geometry):
raise ValueError("geometry command truncated")
cursor_x += zigzag_decode(geometry[idx])
cursor_y += zigzag_decode(geometry[idx + 1])
idx += 2
if not initialized:
stats.min_x = stats.max_x = cursor_x
stats.min_y = stats.max_y = cursor_y
initialized = True
else:
stats.min_x = min(stats.min_x, cursor_x)
stats.max_x = max(stats.max_x, cursor_x)
stats.min_y = min(stats.min_y, cursor_y)
stats.max_y = max(stats.max_y, cursor_y)
stats.point_count += 1
elif command_id == 7: # ClosePath
continue
else:
raise ValueError(f"unsupported geometry command id={command_id}")
return stats
def merge_layer_stats(existing: LayerGeometryStats | None, incoming: LayerGeometryStats) -> LayerGeometryStats:
if existing is None or existing.point_count == 0:
return incoming
if incoming.point_count == 0:
return existing
return LayerGeometryStats(
point_count=existing.point_count + incoming.point_count,
min_x=min(existing.min_x, incoming.min_x),
max_x=max(existing.max_x, incoming.max_x),
min_y=min(existing.min_y, incoming.min_y),
max_y=max(existing.max_y, incoming.max_y),
)
def analyze_tile(tile_path: Path) -> dict[str, Any]:
tile = vector_tile_pb2.tile()
tile.ParseFromString(tile_path.read_bytes())
layer_summaries: list[dict[str, Any]] = []
extent_values: set[int] = set()
tile_outside_extent = False
tile_max_overflow = 0
for layer in tile.layers:
extent = int(layer.extent or 4096)
extent_values.add(extent)
aggregate: LayerGeometryStats | None = None
for feature in layer.features:
geom_stats = decode_feature_geometry(list(feature.geometry))
aggregate = merge_layer_stats(aggregate, geom_stats)
if aggregate is None:
aggregate = LayerGeometryStats()
outside_extent = False
overflow = 0
if aggregate.point_count:
overflow = max(
0,
-aggregate.min_x,
-aggregate.min_y,
aggregate.max_x - extent,
aggregate.max_y - extent,
)
outside_extent = overflow > 0
tile_outside_extent = tile_outside_extent or outside_extent
tile_max_overflow = max(tile_max_overflow, overflow)
layer_summaries.append(
{
"name": layer.name,
"extent": extent,
"point_count": aggregate.point_count,
"min_x": aggregate.min_x,
"max_x": aggregate.max_x,
"min_y": aggregate.min_y,
"max_y": aggregate.max_y,
"outside_extent": outside_extent,
"overflow": overflow,
}
)
return {
"extent_values": sorted(extent_values),
"outside_extent": tile_outside_extent,
"max_overflow": tile_max_overflow,
"layers": layer_summaries,
}
def desired_extent_from_source(source_path: Path) -> int | None:
if not source_path.exists():
return None
tile = vector_tile_pb2.tile()
tile.ParseFromString(source_path.read_bytes())
extents = sorted({int(layer.extent or 4096) for layer in tile.layers})
if not extents:
return None
if len(extents) == 1:
return extents[0]
return max(extents)
def repair_tile_extents(tile_path: Path, desired_extent: int) -> bool:
tile = vector_tile_pb2.tile()
raw = tile_path.read_bytes()
tile.ParseFromString(raw)
changed = False
for layer in tile.layers:
current = int(layer.extent or 4096)
if current != desired_extent:
layer.extent = desired_extent
changed = True
if changed:
tile_path.write_bytes(tile.SerializeToString())
return changed
def hardlink_copy_tree(src_root: Path, dst_root: Path) -> None:
if dst_root.exists():
raise SystemExit(f"backup root already exists: {dst_root}")
dst_root.mkdir(parents=True, exist_ok=True)
os.system(f"cp -al {src_root}/. {dst_root}/")
def write_report(
output_dir: Path,
label: str,
target_root: Path,
source_root: Path,
summary: dict[str, Any],
) -> tuple[Path, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
report_json = output_dir / "geometry_native_audit.json"
report_md = output_dir / "geometry_native_audit.md"
report_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = [
f"# {label} 几何 / Extent / Native 风险审计",
"",
"## 范围",
"",
f"- target root: `{target_root}`",
f"- source root: `{source_root}`",
f"- tiles: `{summary['tile_count']}`",
f"- repaired tiles: `{summary['repaired_tiles']}`",
"",
"## 总体结论",
"",
f"- 坐标超当前 extent 的瓦片数:`{summary['outside_extent_tiles']}`",
f"- 与源瓦片 extent 不一致的瓦片数:`{summary['source_extent_mismatch_tiles']}`",
f"- 可直接按源 extent 修复的瓦片数:`{summary['fixable_from_source_tiles']}`",
"",
"## Zoom 汇总",
"",
]
for zoom in sorted(summary["zoom_summary"], key=int):
item = summary["zoom_summary"][zoom]
lines.extend(
[
f"### z{zoom}",
"",
f"- tiles: `{item['tile_count']}`",
f"- outside extent: `{item['outside_extent_tiles']}`",
f"- source mismatch: `{item['source_extent_mismatch_tiles']}`",
f"- extent counts: `{item['extent_counts']}`",
"",
]
)
lines.extend(["## 代表问题样本", ""])
for item in summary["sample_tiles"]:
lines.extend(
[
f"### `{item['tile']}`",
"",
f"- extent values: `{item['extent_values']}`",
f"- source extent: `{item['source_extent']}`",
f"- outside extent: `{item['outside_extent']}`",
f"- max overflow: `{item['max_overflow']}`",
f"- fixable from source: `{item['fixable_from_source']}`",
"",
]
)
report_md.write_text("\n".join(lines) + "\n", encoding="utf-8")
return report_md, report_json
def main() -> None:
args = parse_args()
target_root = args.target_root
source_root = args.source_root
output_dir = args.output_dir
if not target_root.exists():
raise SystemExit(f"target root not found: {target_root}")
if not source_root.exists():
raise SystemExit(f"source root not found: {source_root}")
zoom_summary: dict[str, dict[str, Any]] = defaultdict(
lambda: {
"tile_count": 0,
"outside_extent_tiles": 0,
"source_extent_mismatch_tiles": 0,
"extent_counts": Counter(),
}
)
sample_tiles: list[dict[str, Any]] = []
repaired_tiles = 0
outside_extent_tiles = 0
source_extent_mismatch_tiles = 0
fixable_from_source_tiles = 0
tile_paths = sorted(target_root.glob("*/*/*.pbf"))
if args.zoom:
zoom_filter = {str(item) for item in args.zoom}
tile_paths = [path for path in tile_paths if path.parent.parent.name in zoom_filter]
for tile_path in tile_paths:
rel = tile_path.relative_to(target_root)
zoom = rel.parts[0]
analysis = analyze_tile(tile_path)
source_extent = desired_extent_from_source(source_root / rel)
current_extent = max(analysis["extent_values"]) if analysis["extent_values"] else None
mismatch = source_extent is not None and current_extent is not None and source_extent != current_extent
fixable = bool(
mismatch
and source_extent is not None
and analysis["outside_extent"]
and analysis["max_overflow"] <= max(0, source_extent - (current_extent or 0))
)
zoom_item = zoom_summary[zoom]
zoom_item["tile_count"] += 1
for extent in analysis["extent_values"]:
zoom_item["extent_counts"][extent] += 1
if analysis["outside_extent"]:
outside_extent_tiles += 1
zoom_item["outside_extent_tiles"] += 1
if mismatch:
source_extent_mismatch_tiles += 1
zoom_item["source_extent_mismatch_tiles"] += 1
if fixable:
fixable_from_source_tiles += 1
if args.apply_fix and fixable and source_extent is not None:
if repair_tile_extents(tile_path, source_extent):
repaired_tiles += 1
analysis = analyze_tile(tile_path)
if len(sample_tiles) < args.sample_limit and (analysis["outside_extent"] or mismatch):
sample_tiles.append(
{
"tile": str(rel),
"extent_values": analysis["extent_values"],
"source_extent": source_extent,
"outside_extent": analysis["outside_extent"],
"max_overflow": analysis["max_overflow"],
"fixable_from_source": fixable,
}
)
normalized_zoom_summary = {
zoom: {
"tile_count": item["tile_count"],
"outside_extent_tiles": item["outside_extent_tiles"],
"source_extent_mismatch_tiles": item["source_extent_mismatch_tiles"],
"extent_counts": dict(sorted(item["extent_counts"].items())),
}
for zoom, item in sorted(zoom_summary.items(), key=lambda kv: int(kv[0]))
}
summary = {
"label": args.label,
"target_root": str(target_root),
"source_root": str(source_root),
"tile_count": len(tile_paths),
"outside_extent_tiles": outside_extent_tiles,
"source_extent_mismatch_tiles": source_extent_mismatch_tiles,
"fixable_from_source_tiles": fixable_from_source_tiles,
"repaired_tiles": repaired_tiles,
"zoom_summary": normalized_zoom_summary,
"sample_tiles": sample_tiles,
}
report_md, report_json = write_report(output_dir, args.label, target_root, source_root, summary)
print(
json.dumps(
{
"report_md": str(report_md),
"report_json": str(report_json),
"outside_extent_tiles": outside_extent_tiles,
"source_extent_mismatch_tiles": source_extent_mismatch_tiles,
"fixable_from_source_tiles": fixable_from_source_tiles,
"repaired_tiles": repaired_tiles,
},
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()