#!/usr/bin/env python3 # -*- coding: utf-8 -*- r""" CM95 / CM93 助航名称多尺度与显式关系审计 v2 目标 ==== 确认 CM95 自身是否存在可安全写入 NavSea `navigation_marks.chart_label_text` 的名称来源。 重点: 1. 同时检查 1:20,000 / 1:50,000 / 1:200,000; 2. 优先检查助航对象和 `_texto` 中的显式关系/索引字段; 3. 再检查同 Cell 最近文字; 4. 不自动把最近文字当名称; 5. 不读取或反灌 NEWPEC/reference PBF 名称; 6. 只读 MySQL,不修改 importer/schema。 默认 AOI: z10/914/385 lon 141.328125 .. 141.6796875 lat 40.44694705960049 .. 40.71395582628604 输出: - cm95_nav_name_multiscale_audit_v2.json - cm95_nav_name_multiscale_groups_v2.csv - cm95_nav_text_relation_fields_v2.csv - cm95_nav_name_multiscale_audit_v2.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 from typing import Any import pymysql from pymysql.cursors import DictCursor NAV_CLASSES = { "LIGHTS", "BCNLAT", "BCNSPP", "BOYSPP", "RTPBCN", "TOWERS", "_extgn", "SISTAT", "SISTAW", } DEFAULT_SCALES = (20000, 50000, 200000) DIRECT_NAME_KEYS = ("OBJNAM", "NOBJNM") RELATION_KEY_HINTS = ( "relation", "related", "index", "record", "assoc", "link", "ref", "cm93", ) 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( "--scales", default="20000,50000,200000", help="逗号分隔,例如 20000,50000,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) 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 relation_like_fields(attrs: dict[str, Any]): out = {} for key, value in attrs.items(): lk = str(key).lower() if any(h in lk for h in RELATION_KEY_HINTS): out[key] = value 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 flatten_relation_values(obj: Any): """用于审计,不解释语义。把标量/list/dict转成可比较字符串集合。""" values = set() def walk(v): if v is None: return if isinstance(v, dict): for kk, vv in v.items(): values.add(f"{kk}={json.dumps(vv, ensure_ascii=False, sort_keys=True, default=str)}") walk(vv) elif isinstance(v, list): for item in v: walk(item) else: values.add(str(v)) walk(obj) return values def write_csv(path: Path, rows, fields): with path.open("w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=fields) w.writeheader() for row in rows: out = {} for field in fields: value = row.get(field) if isinstance(value, (dict, list, tuple)): value = json.dumps( value, ensure_ascii=False, separators=(",", ":"), default=str, ) out[field] = value w.writerow(out) def main(): a = parse_args() scales = tuple( sorted( { int(x.strip()) for x in a.scales.split(",") if x.strip() } ) ) if not scales: raise SystemExit("scales 不能为空") 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") nav_ph = ",".join(["%s"] * len(NAV_CLASSES)) scale_ph = ",".join(["%s"] * len(scales)) 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 IN ({scale_ph}) AND f.geometry_type='POINT' AND f.object_class IN ({nav_ph}) AND f.bbox_min_lon BETWEEN %s AND %s AND f.bbox_min_lat BETWEEN %s AND %s ORDER BY f.native_scale, f.cell_id, f.source_record_index """, ( a.dataset_id, *scales, *sorted(NAV_CLASSES), a.min_lon, a.max_lon, a.min_lat, a.max_lat, ), ) nav_rows = list(cur.fetchall()) 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( 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 IN ({scale_ph}) 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.native_scale, f.cell_id, f.source_record_index """, ( a.dataset_id, *scales, 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() parsed_nav = [] for row in nav_rows: attrs = json_dict(row.pop("attributes_json")) parsed_nav.append( { **row, "feature_id": int(row["feature_id"]), "cell_id": int(row["cell_id"]), "source_record_index": int(row["source_record_index"]), "native_scale": int(row["native_scale"]), "lon": float(row["lon"]), "lat": float(row["lat"]), "attributes": attrs, "relation20": relation20(attrs), "direct_names": direct_names(attrs), "relation_fields": relation_like_fields(attrs), } ) parsed_text = [] relation_field_counter = Counter() for row in text_rows: attrs = json_dict(row.pop("attributes_json")) text, prefix = texta(attrs) rel_fields = relation_like_fields(attrs) for key in rel_fields: relation_field_counter[ (int(row["native_scale"]), str(key)) ] += 1 parsed_text.append( { **row, "feature_id": int(row["feature_id"]), "cell_id": int(row["cell_id"]), "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, "attributes": attrs, "relation_fields": rel_fields, "relation_values": sorted( flatten_relation_values(rel_fields) ), } ) # relation20 仅在同 cell + 同 scale 内组成助航主组。 grouped = defaultdict(list) standalone = [] for row in parsed_nav: rel = row["relation20"] if rel is None: standalone.append([row]) else: grouped[ ( row["native_scale"], row["cell_id"], rel, ) ].append(row) nav_groups = list(grouped.values()) + standalone results = [] per_scale = {} for members in nav_groups: scale = members[0]["native_scale"] cell_id = members[0]["cell_id"] rel = members[0]["relation20"] lon = sum(m["lon"] for m in members) / len(members) lat = sum(m["lat"] for m in members) / len(members) direct = [] nav_relation_values = set() for m in members: for n in m["direct_names"]: direct.append( { "object_class": m["object_class"], "source_record_index": m["source_record_index"], **n, } ) nav_relation_values |= flatten_relation_values( m["relation_fields"] ) candidates = [] # 同比例尺;显式关系优先,同 cell 次之,距离只作证据。 for t in parsed_text: if t["native_scale"] != scale or not t["text"]: continue distance = haversine_m( lon, lat, t["lon"], t["lat"], ) explicit_overlap = sorted( nav_relation_values.intersection( set(t["relation_values"]) ) ) if distance <= a.radius_m or explicit_overlap: candidates.append( { "feature_id": t["feature_id"], "cell_id": t["cell_id"], "source_relative_path": t["source_relative_path"], "source_record_index": t["source_record_index"], "native_scale": t["native_scale"], "lon": t["lon"], "lat": t["lat"], "text": t["text"], "prefix": t["prefix"], "same_cell": t["cell_id"] == cell_id, "distance_m": round(distance, 2), "explicit_relation_overlap": explicit_overlap, "text_relation_fields": t["relation_fields"], } ) candidates.sort( key=lambda c: ( not bool(c["explicit_relation_overlap"]), not c["same_cell"], c["distance_m"], c["text"], ) ) explicit = [ c for c in candidates if c["explicit_relation_overlap"] ] same = [c for c in candidates if c["same_cell"]] nearest_same = ( min(same, key=lambda c: c["distance_m"]) if same else None ) result = { "native_scale": scale, "cell_id": cell_id, "source_relative_path": members[0]["source_relative_path"], "relation20": rel, "lon": lon, "lat": lat, "component_classes": [ m["object_class"] for m in members ], "component_source_record_indices": [ m["source_record_index"] for m in members ], "direct_names": direct, "nav_relation_fields": { str(m["source_record_index"]): m["relation_fields"] for m in members if m["relation_fields"] }, "explicit_text_candidates": explicit[ : a.candidate_limit ], "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 ), "same_cell_candidate_count": len(same), "all_candidate_count": len(candidates), "candidate_summary": candidates[ : a.candidate_limit ], } results.append(result) results.sort( key=lambda r: ( r["native_scale"], r["cell_id"], -1 if r["relation20"] is None else r["relation20"], r["lon"], r["lat"], ) ) for scale in scales: subset = [r for r in results if r["native_scale"] == scale] per_scale[str(scale)] = { "nav_groups": len(subset), "groups_with_direct_names": sum( 1 for r in subset if r["direct_names"] ), "groups_with_explicit_text_candidates": sum( 1 for r in subset if r["explicit_text_candidates"] ), "groups_with_same_cell_text_within_50m": sum( 1 for r in subset if r["nearest_same_cell_distance_m"] is not None and r["nearest_same_cell_distance_m"] <= 50 ), "groups_with_same_cell_text_within_100m": sum( 1 for r in subset if r["nearest_same_cell_distance_m"] is not None and r["nearest_same_cell_distance_m"] <= 100 ), "groups_with_same_cell_text_within_250m": sum( 1 for r in subset if r["nearest_same_cell_distance_m"] is not None and r["nearest_same_cell_distance_m"] <= 250 ), } relation_rows = [ { "native_scale": scale, "field_name": field, "occurrence_count": count, } for (scale, field), count in sorted( relation_field_counter.items() ) ] report = { "schema": "navsea.cm95.nav-name-multiscale-audit/v2", "generated_at": datetime.now().isoformat(timespec="seconds"), "dataset_id": a.dataset_id, "scales": list(scales), "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(parsed_nav), "nav_groups": len(results), "texto_rows": len(parsed_text), "texto_rows_with_text": sum( 1 for t in parsed_text if t["text"] ), }, "per_scale": per_scale, "text_relation_field_counts": relation_rows, "groups": results, "decision": "READY_FOR_SAFE_NAME_RULE_REVIEW", } json_path = ( outdir / "cm95_nav_name_multiscale_audit_v2.json" ) groups_csv = ( outdir / "cm95_nav_name_multiscale_groups_v2.csv" ) rel_csv = ( outdir / "cm95_nav_text_relation_fields_v2.csv" ) txt_path = ( outdir / "cm95_nav_name_multiscale_audit_v2.txt" ) json_path.write_text( json.dumps( report, ensure_ascii=False, indent=2, default=str, ), encoding="utf-8", ) write_csv( groups_csv, results, [ "native_scale", "cell_id", "source_relative_path", "relation20", "lon", "lat", "component_classes", "component_source_record_indices", "direct_names", "nav_relation_fields", "explicit_text_candidates", "nearest_same_cell_text", "nearest_same_cell_distance_m", "same_cell_candidate_count", "all_candidate_count", "candidate_summary", ], ) write_csv( rel_csv, relation_rows, [ "native_scale", "field_name", "occurrence_count", ], ) lines = [ "CM95 / CM93 助航名称多尺度与显式关系审计 v2", "=" * 82, f"Dataset ID : {a.dataset_id}", f"Scales : {','.join(map(str, scales))}", f"Nav component rows : {len(parsed_nav)}", f"Nav groups : {len(results)}", f"_texto rows : {len(parsed_text)}", "", ] for scale in scales: s = per_scale[str(scale)] lines += [ f"1:{scale}", f" groups : {s['nav_groups']}", f" direct names : {s['groups_with_direct_names']}", f" explicit text relation: {s['groups_with_explicit_text_candidates']}", f" same-cell <= 50m : {s['groups_with_same_cell_text_within_50m']}", f" same-cell <= 100m : {s['groups_with_same_cell_text_within_100m']}", f" same-cell <= 250m : {s['groups_with_same_cell_text_within_250m']}", "", ] lines += [ "注意:", "- 显式关系候选优先于距离候选。", "- 最近文字仍不会被脚本自动写入 chart_label_text。", "- 若 20k/50k/200k 都无安全来源,则该数据源的助航名称保持为空,不从 NEWPEC 反灌。", ] txt_path.write_text("\n".join(lines), encoding="utf-8") print("\n".join(lines)) print("") print(json_path) print(groups_csv) print(rel_csv) print(txt_path) if __name__ == "__main__": main()