Files
CM95/cm95_nav_name_candidate_audit_v1.py

547 lines
16 KiB
Python
Raw 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 助航名称候选审计 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()