Files
CM95/cm95_select_pbf_test_aoi_v1.py

416 lines
12 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
# -*- coding: utf-8 -*-
r"""
CM95 / CM93 MySQL → PBF 测试 AOI 自动候选选择器 v1
目标:
从已导入 MySQL 的 CM93 Dataset 中,自动寻找适合做
CM95→NavSea PBF 第一轮对照测试的 z10 XYZ tile。
优先选择同时包含:
- 陆地/岸线
- 水深区
- 等深线
- 灯标/航标
- 危险物
- 较细比例尺数据
脚本只读数据库,不修改任何内容。
执行:
python .\cm95_select_pbf_test_aoi_v1.py `
--host 127.0.0.1 `
--port 3306 `
--user root `
--database cm93_chart `
--dataset-id 1
输出:
- cm95_pbf_aoi_candidates_v1.csv
- cm95_pbf_aoi_candidates_v1.json
- cm95_pbf_aoi_candidates_v1.txt
"""
from __future__ import annotations
import argparse
import csv
import getpass
import json
import math
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
import pymysql
from pymysql.cursors import SSDictCursor
Z = 10
LAND = {"LNDARE", "COALNE", "SLCONS"}
DEPTH = {"DEPARE"}
CONTOUR = {"DEPCNT"}
LIGHT_NAV_EXACT = {
"LIGHTS", "LITFLT", "LITVES", "LNDMRK",
"FOGSIG", "RTPBCN", "TOPMAR", "DAYMAR",
}
LIGHT_NAV_PREFIX = ("BOY", "BCN")
HAZARD = {
"OBSTRN", "UWTROC", "WRECKS", "SBDARE",
"CTNARE", "MORFAC",
}
# native_scale 数值越小,通常越详细。
DETAIL_SCALE_LIMIT = 50000
def args():
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=3306)
p.add_argument("--user", default="root")
p.add_argument("--password", default=None)
p.add_argument("--database", default="cm93_chart")
p.add_argument("--dataset-id", type=int, default=1)
p.add_argument("--min-lon", type=float, default=120.0)
p.add_argument("--max-lon", type=float, default=160.0)
p.add_argument("--min-lat", type=float, default=40.0)
p.add_argument("--max-lat", type=float, default=50.0)
p.add_argument("--top", type=int, default=40)
p.add_argument("--output-dir", default=".")
return p.parse_args()
def lonlat_to_tile(lon, lat, z):
lat = max(min(lat, 85.05112878), -85.05112878)
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
lat_rad = math.radians(lat)
y = int(
(1.0 - math.asinh(math.tan(lat_rad)) / math.pi)
/ 2.0 * n
)
x = max(0, min(n - 1, x))
y = max(0, min(n - 1, y))
return x, y
def tile_bounds(z, x, y):
n = 2 ** z
min_lon = x / n * 360.0 - 180.0
max_lon = (x + 1) / n * 360.0 - 180.0
def lat(ty):
return math.degrees(
math.atan(math.sinh(math.pi * (1 - 2 * ty / n)))
)
max_lat = lat(y)
min_lat = lat(y + 1)
return min_lon, min_lat, max_lon, max_lat
def is_navmark(cls):
return cls in LIGHT_NAV_EXACT or cls.startswith(LIGHT_NAV_PREFIX)
def classify(cls):
flags = set()
if cls in LAND:
flags.add("land")
if cls in DEPTH:
flags.add("depth")
if cls in CONTOUR:
flags.add("contour")
if is_navmark(cls):
flags.add("nav")
if cls in HAZARD:
flags.add("hazard")
return flags
def main():
a = args()
if a.password is None:
a.password = getpass.getpass("MySQL password: ")
outdir = Path(a.output_dir).resolve()
outdir.mkdir(parents=True, exist_ok=True)
conn = pymysql.connect(
host=a.host,
port=a.port,
user=a.user,
password=a.password,
database=a.database,
charset="utf8mb4",
cursorclass=SSDictCursor,
autocommit=False,
connect_timeout=20,
read_timeout=900,
)
tiles = {}
try:
with conn.cursor() as cur:
try:
cur.execute("SET SESSION TRANSACTION READ ONLY")
except Exception:
pass
try:
cur.execute("START TRANSACTION READ ONLY")
except Exception:
cur.execute("START TRANSACTION")
cur.execute(
"""
SELECT
object_class,
geometry_type,
native_scale,
bbox_min_lon,
bbox_min_lat,
bbox_max_lon,
bbox_max_lat
FROM chart_feature
WHERE dataset_id=%s
AND bbox_min_lon IS NOT NULL
AND bbox_min_lat IS NOT NULL
AND bbox_max_lon IS NOT NULL
AND bbox_max_lat IS NOT NULL
AND bbox_max_lon >= %s
AND bbox_min_lon <= %s
AND bbox_max_lat >= %s
AND bbox_min_lat <= %s
""",
(
a.dataset_id,
a.min_lon,
a.max_lon,
a.min_lat,
a.max_lat,
),
)
scanned = 0
for row in cur:
scanned += 1
lon = (
float(row["bbox_min_lon"])
+ float(row["bbox_max_lon"])
) / 2.0
lat = (
float(row["bbox_min_lat"])
+ float(row["bbox_max_lat"])
) / 2.0
if not (
a.min_lon <= lon <= a.max_lon
and a.min_lat <= lat <= a.max_lat
):
continue
x, y = lonlat_to_tile(lon, lat, Z)
key = (x, y)
if key not in tiles:
tiles[key] = {
"feature_count": 0,
"detail_feature_count": 0,
"classes": Counter(),
"geometry": Counter(),
"flags": Counter(),
"native_scales": Counter(),
}
t = tiles[key]
cls = str(row["object_class"] or "")
geom = str(row["geometry_type"] or "")
scale = int(row["native_scale"] or 999999999)
t["feature_count"] += 1
t["classes"][cls] += 1
t["geometry"][geom] += 1
t["native_scales"][scale] += 1
if scale <= DETAIL_SCALE_LIMIT:
t["detail_feature_count"] += 1
for flag in classify(cls):
t["flags"][flag] += 1
conn.rollback()
finally:
conn.close()
rows = []
for (x, y), t in tiles.items():
flags = t["flags"]
required_present = sum(
1
for name in ("land", "depth", "contour", "nav", "hazard")
if flags[name] > 0
)
hard_gate_present = (
int(flags["nav"] > 0)
+ int(flags["hazard"] > 0)
+ int(flags["depth"] > 0)
)
# 先奖励类别闭环,再奖励细尺度和总要素。
score = (
required_present * 1_000_000
+ hard_gate_present * 250_000
+ min(t["detail_feature_count"], 10000) * 100
+ min(t["feature_count"], 100000)
)
min_lon, min_lat, max_lon, max_lat = tile_bounds(Z, x, y)
rows.append({
"score": score,
"z": Z,
"x": x,
"y": y,
"tile_path": f"{Z}/{x}/{y}.pbf",
"center_lon": (min_lon + max_lon) / 2,
"center_lat": (min_lat + max_lat) / 2,
"min_lon": min_lon,
"min_lat": min_lat,
"max_lon": max_lon,
"max_lat": max_lat,
"feature_count": t["feature_count"],
"detail_feature_count_le_50000": t["detail_feature_count"],
"required_groups_present": required_present,
"land_count": flags["land"],
"depth_count": flags["depth"],
"contour_count": flags["contour"],
"navmark_light_count": flags["nav"],
"hazard_count": flags["hazard"],
"point_count": t["geometry"]["POINT"],
"line_count": t["geometry"]["LINE"],
"area_count": t["geometry"]["AREA"],
"top_classes": [
{"class": k, "count": v}
for k, v in t["classes"].most_common(15)
],
"native_scales": [
{"scale": k, "count": v}
for k, v in sorted(t["native_scales"].items())
],
})
rows.sort(
key=lambda r: (
r["score"],
r["detail_feature_count_le_50000"],
r["feature_count"],
),
reverse=True,
)
top = rows[: a.top]
csv_path = outdir / "cm95_pbf_aoi_candidates_v1.csv"
json_path = outdir / "cm95_pbf_aoi_candidates_v1.json"
txt_path = outdir / "cm95_pbf_aoi_candidates_v1.txt"
csv_fields = [
"rank", "score", "z", "x", "y", "tile_path",
"center_lon", "center_lat",
"min_lon", "min_lat", "max_lon", "max_lat",
"feature_count", "detail_feature_count_le_50000",
"required_groups_present",
"land_count", "depth_count", "contour_count",
"navmark_light_count", "hazard_count",
"point_count", "line_count", "area_count",
"top_classes", "native_scales",
]
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=csv_fields)
w.writeheader()
for i, row in enumerate(top, 1):
r = dict(row)
r["rank"] = i
r["top_classes"] = json.dumps(
r["top_classes"], ensure_ascii=False
)
r["native_scales"] = json.dumps(
r["native_scales"], ensure_ascii=False
)
w.writerow(r)
json_path.write_text(
json.dumps(
{
"version": "v1",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"dataset_id": a.dataset_id,
"search_bbox": [
a.min_lon, a.min_lat, a.max_lon, a.max_lat
],
"z": Z,
"candidate_count": len(rows),
"top": top,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
lines = [
"CM95 → NavSea PBF 第一测试 AOI 候选 v1",
"=" * 86,
f"Dataset ID: {a.dataset_id}",
f"扫描范围: lon {a.min_lon}..{a.max_lon}, lat {a.min_lat}..{a.max_lat}",
"",
]
for i, r in enumerate(top[:15], 1):
lines += [
(
f"#{i:02d} z{r['z']}/{r['x']}/{r['y']} "
f"center=({r['center_lon']:.5f},{r['center_lat']:.5f}) "
f"features={r['feature_count']} "
f"detail={r['detail_feature_count_le_50000']} "
f"groups={r['required_groups_present']}/5"
),
(
f" land={r['land_count']} depth={r['depth_count']} "
f"contour={r['contour_count']} "
f"nav={r['navmark_light_count']} "
f"hazard={r['hazard_count']}"
),
]
lines += [
"",
"下一步:把 JSON 或 CSV 上传给 ChatGPT。",
"ChatGPT 会选一个最适合的 tile,并告诉你需要提供的现有 NavSea PBF z10/z11/z12 路径。",
]
txt_path.write_text("\n".join(lines), encoding="utf-8")
print("\n".join(lines))
print("")
print("输出:")
print(csv_path)
print(json_path)
print(txt_path)
if __name__ == "__main__":
main()