547 lines
16 KiB
Python
547 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
r"""
|
||
CM95 / CM93 助航名称候选审计 v1
|
||
|
||
目的
|
||
====
|
||
为 CM95→NavSea `navigation_marks.chart_label_text` 找到可审计的 CM95 自有名称来源,
|
||
不使用 NEWPEC/reference PBF 名称反灌。
|
||
|
||
默认检查第一测试 AOI:
|
||
z10/914/385
|
||
lon 141.328125 .. 141.6796875
|
||
lat 40.44694705960049 .. 40.71395582628604
|
||
默认重点比例尺:
|
||
1:200000
|
||
|
||
候选来源:
|
||
1. 助航组件自身 OBJNAM / NOBJNM;
|
||
2. 同 cell、同 native_scale 的 `_texto._texta.text`;
|
||
3. 同 native_scale、邻近 cell 的 `_texto._texta.text`,仅作为低置信参考。
|
||
|
||
关联原则:
|
||
- 助航组件仍然用 `(cell_id, _cm93_relation20_raw)` 合成;
|
||
- 本脚本只审计名称,不改变 relation20 合成规则;
|
||
- 不自动把最近文字当名称;
|
||
- 输出距离和来源,由下一步确定安全规则。
|
||
|
||
输出:
|
||
cm95_nav_name_candidates_v1.json
|
||
cm95_nav_name_candidates_v1.csv
|
||
cm95_nav_name_candidates_v1.txt
|
||
|
||
执行:
|
||
python .\tools\cm95_nav_name_candidate_audit_v1.py `
|
||
--host 127.0.0.1 `
|
||
--port 3306 `
|
||
--user root `
|
||
--database cm93_chart `
|
||
--dataset-id 1 `
|
||
--native-scale 200000
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import json
|
||
import math
|
||
from collections import defaultdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pymysql
|
||
from pymysql.cursors import DictCursor
|
||
|
||
|
||
NAV_CLASSES = {
|
||
"LIGHTS",
|
||
"BCNLAT",
|
||
"BCNSPP",
|
||
"BOYSPP",
|
||
"RTPBCN",
|
||
"TOWERS",
|
||
"_extgn",
|
||
"SISTAT",
|
||
"SISTAW",
|
||
}
|
||
|
||
DIRECT_NAME_KEYS = (
|
||
"OBJNAM",
|
||
"NOBJNM",
|
||
)
|
||
|
||
DEFAULT_BBOX = (
|
||
141.328125,
|
||
40.44694705960049,
|
||
141.6796875,
|
||
40.71395582628604,
|
||
)
|
||
|
||
|
||
def parse_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("--native-scale", type=int, default=200000)
|
||
p.add_argument("--min-lon", type=float, default=DEFAULT_BBOX[0])
|
||
p.add_argument("--min-lat", type=float, default=DEFAULT_BBOX[1])
|
||
p.add_argument("--max-lon", type=float, default=DEFAULT_BBOX[2])
|
||
p.add_argument("--max-lat", type=float, default=DEFAULT_BBOX[3])
|
||
p.add_argument(
|
||
"--radius-m",
|
||
type=float,
|
||
default=800.0,
|
||
help="每个助航对象保存的文字候选搜索半径",
|
||
)
|
||
p.add_argument(
|
||
"--candidate-limit",
|
||
type=int,
|
||
default=12,
|
||
)
|
||
p.add_argument("--output-dir", default=".")
|
||
return p.parse_args()
|
||
|
||
|
||
def json_dict(value: Any) -> dict[str, Any]:
|
||
if value is None:
|
||
return {}
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||
value = bytes(value).decode("utf-8", "replace")
|
||
try:
|
||
obj = json.loads(value)
|
||
except Exception:
|
||
return {}
|
||
return obj if isinstance(obj, dict) else {}
|
||
|
||
|
||
def relation20(attrs: dict[str, Any]):
|
||
value = attrs.get("_cm93_relation20_raw")
|
||
if value is None:
|
||
return None
|
||
try:
|
||
return int(value)
|
||
except Exception:
|
||
return str(value)
|
||
|
||
|
||
def texta(attrs: dict[str, Any]):
|
||
value = attrs.get("_texta")
|
||
if not isinstance(value, dict):
|
||
return None, None
|
||
text = value.get("text")
|
||
prefix = value.get("prefix")
|
||
if text is None:
|
||
return None, prefix
|
||
text = str(text).strip()
|
||
return (text or None), prefix
|
||
|
||
|
||
def direct_names(attrs: dict[str, Any]):
|
||
out = []
|
||
for key in DIRECT_NAME_KEYS:
|
||
value = attrs.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
out.append({
|
||
"field": key,
|
||
"text": value.strip(),
|
||
})
|
||
return out
|
||
|
||
|
||
def haversine_m(lon1, lat1, lon2, lat2):
|
||
r = 6371008.8
|
||
p1 = math.radians(lat1)
|
||
p2 = math.radians(lat2)
|
||
dp = p2 - p1
|
||
dl = math.radians(lon2 - lon1)
|
||
a = (
|
||
math.sin(dp / 2) ** 2
|
||
+ math.cos(p1) * math.cos(p2)
|
||
* math.sin(dl / 2) ** 2
|
||
)
|
||
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
|
||
|
||
|
||
def write_csv(path: Path, rows):
|
||
rows = list(rows)
|
||
fields = [
|
||
"cell_id",
|
||
"source_relative_path",
|
||
"native_scale",
|
||
"relation20",
|
||
"lon",
|
||
"lat",
|
||
"component_classes",
|
||
"component_source_record_indices",
|
||
"direct_names",
|
||
"nearest_same_cell_text",
|
||
"nearest_same_cell_distance_m",
|
||
"nearest_any_cell_text",
|
||
"nearest_any_cell_distance_m",
|
||
"same_cell_candidate_count",
|
||
"all_candidate_count",
|
||
"candidate_summary",
|
||
]
|
||
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||
w = csv.DictWriter(f, fieldnames=fields)
|
||
w.writeheader()
|
||
for row in rows:
|
||
out = dict(row)
|
||
for key in (
|
||
"component_classes",
|
||
"component_source_record_indices",
|
||
"direct_names",
|
||
"candidate_summary",
|
||
):
|
||
out[key] = json.dumps(
|
||
out.get(key),
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
)
|
||
w.writerow(out)
|
||
|
||
|
||
def main():
|
||
a = parse_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=DictCursor,
|
||
autocommit=False,
|
||
connect_timeout=20,
|
||
read_timeout=900,
|
||
)
|
||
|
||
nav_rows = []
|
||
text_rows = []
|
||
|
||
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")
|
||
|
||
placeholders = ",".join(["%s"] * len(NAV_CLASSES))
|
||
cur.execute(
|
||
f"""
|
||
SELECT
|
||
f.id AS feature_id,
|
||
f.cell_id,
|
||
c.source_relative_path,
|
||
f.source_record_index,
|
||
f.object_class,
|
||
f.native_scale,
|
||
f.bbox_min_lon AS lon,
|
||
f.bbox_min_lat AS lat,
|
||
f.attributes_json
|
||
FROM chart_feature f
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.native_scale=%s
|
||
AND f.geometry_type='POINT'
|
||
AND f.object_class IN ({placeholders})
|
||
AND f.bbox_min_lon BETWEEN %s AND %s
|
||
AND f.bbox_min_lat BETWEEN %s AND %s
|
||
ORDER BY
|
||
f.cell_id,
|
||
f.source_record_index
|
||
""",
|
||
(
|
||
a.dataset_id,
|
||
a.native_scale,
|
||
*sorted(NAV_CLASSES),
|
||
a.min_lon,
|
||
a.max_lon,
|
||
a.min_lat,
|
||
a.max_lat,
|
||
),
|
||
)
|
||
nav_rows = list(cur.fetchall())
|
||
|
||
# 扩大少量边界,避免 tile 边缘的文字候选被切掉。
|
||
lat_pad = a.radius_m / 111320.0
|
||
mid_lat = (a.min_lat + a.max_lat) / 2.0
|
||
lon_pad = (
|
||
a.radius_m
|
||
/ max(1.0, 111320.0 * math.cos(math.radians(mid_lat)))
|
||
)
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
f.id AS feature_id,
|
||
f.cell_id,
|
||
c.source_relative_path,
|
||
f.source_record_index,
|
||
f.native_scale,
|
||
f.bbox_min_lon AS lon,
|
||
f.bbox_min_lat AS lat,
|
||
f.attributes_json
|
||
FROM chart_feature f
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.native_scale=%s
|
||
AND f.geometry_type='POINT'
|
||
AND f.object_class='_texto'
|
||
AND f.bbox_min_lon BETWEEN %s AND %s
|
||
AND f.bbox_min_lat BETWEEN %s AND %s
|
||
ORDER BY
|
||
f.cell_id,
|
||
f.source_record_index
|
||
""",
|
||
(
|
||
a.dataset_id,
|
||
a.native_scale,
|
||
a.min_lon - lon_pad,
|
||
a.max_lon + lon_pad,
|
||
a.min_lat - lat_pad,
|
||
a.max_lat + lat_pad,
|
||
),
|
||
)
|
||
text_rows = list(cur.fetchall())
|
||
|
||
conn.rollback()
|
||
finally:
|
||
conn.close()
|
||
|
||
for row in nav_rows:
|
||
row["attributes"] = json_dict(row.pop("attributes_json"))
|
||
row["relation20"] = relation20(row["attributes"])
|
||
row["lon"] = float(row["lon"])
|
||
row["lat"] = float(row["lat"])
|
||
|
||
parsed_text = []
|
||
for row in text_rows:
|
||
attrs = json_dict(row.pop("attributes_json"))
|
||
text, prefix = texta(attrs)
|
||
if not text:
|
||
continue
|
||
parsed_text.append({
|
||
"feature_id": int(row["feature_id"]),
|
||
"cell_id": int(row["cell_id"]),
|
||
"source_relative_path": str(row["source_relative_path"]),
|
||
"source_record_index": int(row["source_record_index"]),
|
||
"native_scale": int(row["native_scale"]),
|
||
"lon": float(row["lon"]),
|
||
"lat": float(row["lat"]),
|
||
"text": text,
|
||
"prefix": prefix,
|
||
})
|
||
|
||
groups = defaultdict(list)
|
||
standalone = []
|
||
|
||
for row in nav_rows:
|
||
rel = row["relation20"]
|
||
if rel is None:
|
||
standalone.append([row])
|
||
else:
|
||
groups[(int(row["cell_id"]), rel)].append(row)
|
||
|
||
nav_groups = list(groups.values()) + standalone
|
||
|
||
results = []
|
||
|
||
for members in nav_groups:
|
||
# relation20 组成员已由上一步证明同址;
|
||
# standalone 则只有一条。
|
||
lon = sum(m["lon"] for m in members) / len(members)
|
||
lat = sum(m["lat"] for m in members) / len(members)
|
||
cell_id = int(members[0]["cell_id"])
|
||
source_path = str(members[0]["source_relative_path"])
|
||
rel = members[0]["relation20"]
|
||
|
||
strong_names = []
|
||
for m in members:
|
||
for n in direct_names(m["attributes"]):
|
||
strong_names.append({
|
||
"object_class": m["object_class"],
|
||
"source_record_index": m["source_record_index"],
|
||
**n,
|
||
})
|
||
|
||
candidates = []
|
||
for t in parsed_text:
|
||
distance = haversine_m(
|
||
lon,
|
||
lat,
|
||
t["lon"],
|
||
t["lat"],
|
||
)
|
||
if distance > a.radius_m:
|
||
continue
|
||
candidates.append({
|
||
**t,
|
||
"same_cell": t["cell_id"] == cell_id,
|
||
"distance_m": round(distance, 2),
|
||
})
|
||
|
||
candidates.sort(
|
||
key=lambda c: (
|
||
not c["same_cell"],
|
||
c["distance_m"],
|
||
c["text"],
|
||
)
|
||
)
|
||
|
||
same_cell = [
|
||
c for c in candidates if c["same_cell"]
|
||
]
|
||
|
||
nearest_same = same_cell[0] if same_cell else None
|
||
nearest_any = (
|
||
min(candidates, key=lambda c: c["distance_m"])
|
||
if candidates
|
||
else None
|
||
)
|
||
|
||
results.append({
|
||
"cell_id": cell_id,
|
||
"source_relative_path": source_path,
|
||
"native_scale": a.native_scale,
|
||
"relation20": rel,
|
||
"lon": lon,
|
||
"lat": lat,
|
||
"component_classes": [
|
||
m["object_class"] for m in members
|
||
],
|
||
"component_source_record_indices": [
|
||
int(m["source_record_index"]) for m in members
|
||
],
|
||
"direct_names": strong_names,
|
||
"nearest_same_cell_text": (
|
||
nearest_same["text"] if nearest_same else None
|
||
),
|
||
"nearest_same_cell_distance_m": (
|
||
nearest_same["distance_m"] if nearest_same else None
|
||
),
|
||
"nearest_any_cell_text": (
|
||
nearest_any["text"] if nearest_any else None
|
||
),
|
||
"nearest_any_cell_distance_m": (
|
||
nearest_any["distance_m"] if nearest_any else None
|
||
),
|
||
"same_cell_candidate_count": len(same_cell),
|
||
"all_candidate_count": len(candidates),
|
||
"candidate_summary": candidates[: a.candidate_limit],
|
||
})
|
||
|
||
results.sort(
|
||
key=lambda r: (
|
||
r["cell_id"],
|
||
-1 if r["relation20"] is None else r["relation20"],
|
||
r["lon"],
|
||
r["lat"],
|
||
)
|
||
)
|
||
|
||
direct_named = sum(
|
||
1 for r in results if r["direct_names"]
|
||
)
|
||
same_100 = sum(
|
||
1
|
||
for r in results
|
||
if r["nearest_same_cell_distance_m"] is not None
|
||
and r["nearest_same_cell_distance_m"] <= 100.0
|
||
)
|
||
same_250 = sum(
|
||
1
|
||
for r in results
|
||
if r["nearest_same_cell_distance_m"] is not None
|
||
and r["nearest_same_cell_distance_m"] <= 250.0
|
||
)
|
||
|
||
report = {
|
||
"schema": "navsea.cm95.nav-name-candidate-audit/v1",
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"dataset_id": a.dataset_id,
|
||
"native_scale": a.native_scale,
|
||
"aoi_bbox": [
|
||
a.min_lon,
|
||
a.min_lat,
|
||
a.max_lon,
|
||
a.max_lat,
|
||
],
|
||
"search_radius_m": a.radius_m,
|
||
"counts": {
|
||
"nav_component_rows": len(nav_rows),
|
||
"nav_groups": len(results),
|
||
"texto_rows_with_text": len(parsed_text),
|
||
"groups_with_direct_OBJNAM_or_NOBJNM": direct_named,
|
||
"groups_with_same_cell_text_within_100m": same_100,
|
||
"groups_with_same_cell_text_within_250m": same_250,
|
||
},
|
||
"groups": results,
|
||
"decision": (
|
||
"DATA_READY_FOR_NAME_ASSOCIATION_REVIEW"
|
||
if results
|
||
else "NO_NAV_GROUPS"
|
||
),
|
||
}
|
||
|
||
json_path = outdir / "cm95_nav_name_candidates_v1.json"
|
||
csv_path = outdir / "cm95_nav_name_candidates_v1.csv"
|
||
txt_path = outdir / "cm95_nav_name_candidates_v1.txt"
|
||
|
||
json_path.write_text(
|
||
json.dumps(
|
||
report,
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
default=str,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
write_csv(csv_path, results)
|
||
|
||
lines = [
|
||
"CM95 / CM93 助航名称候选审计 v1",
|
||
"=" * 76,
|
||
f"Native scale : {a.native_scale}",
|
||
f"Nav component rows : {len(nav_rows)}",
|
||
f"Nav groups : {len(results)}",
|
||
f"_texto rows with text : {len(parsed_text)}",
|
||
f"Groups with direct OBJNAM/NOBJNM : {direct_named}",
|
||
f"Same-cell text <=100m : {same_100}",
|
||
f"Same-cell text <=250m : {same_250}",
|
||
"",
|
||
"注意:最近文字只是候选,不代表自动确定为助航名称。",
|
||
"下一步由映射审计决定哪些关系可安全写入 chart_label_text。",
|
||
]
|
||
txt_path.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
print("\n".join(lines))
|
||
print("")
|
||
print(json_path)
|
||
print(csv_path)
|
||
print(txt_path)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|