#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CM93 -> MySQL OGC Normalization 可重复性 / Canonical WKB 验证 Version: v10.0 背景 ==== V9 已确认: - 55/55 invalid Area/Coverage 使用 odd-even face selection 可生成 valid Polygon/MultiPolygon; - 55/55 与 make_valid polygonal 部分一致; - 55/55 不引入原始 linework 之外的新边界; - 55/55 被丢弃的 linework 与已确认 retraced connector 完全一致; - 55/55 normalization 幂等; - 82181 条原本有效的 Area/Coverage 全部 PASS-THROUGH,WKB hash 0 变化; - 27 条 candidate bbox 缩小,其中 27/27 都由被去掉的零面积 retraced connector 解释。 V10 目的 ======= 在真正设计 importer/schema 修改前,再验证一件实现层关键要求: “相同的 CM93 面语义,在输入 ring 起点、ring 方向、MultiPolygon component 顺序、 polygonize face 顺序不同的情况下,最终是否能得到稳定的 canonical geometry / WKB hash。” 原因: 如果 normalized geometry 语义完全相同,但 WKB 因 ring 起点或 component 顺序变化而不同, 以后重新导入、增量同步、缓存或 hash 对账可能出现伪变化。 V10 只读、只在内存运行。 验证内容 ======== 对 55 个 invalid Area/Coverage: 1. 基线: - original invalid geometry - odd-even normalization candidate - GEOS/Shapely normalize() canonicalization - canonical WKB SHA-256 2. 输入等价变体: A. 原始输入 B. 每个 ring 全部反向 C. 每个 ring 改变起始 vertex D. ring 反向 + 改变起点 E. MultiPolygon component 反序(如适用) 以上变体不改变原始线网/odd-even 面语义。 3. 对每个变体重新执行 odd-even normalization; 然后 canonicalize; 必须满足: - candidate valid - candidate polygonal-only - 与基线 candidate topology equals - canonical WKB SHA-256 与基线一致 4. polygonize selected-face 顺序: - 原顺序 union - reverse 顺序 union - 按面积排序 union - 按 centroid 排序 union canonical hash 必须一致。 5. 重复执行: 同一输入连续 normalization 3 次,canonical hash 必须一致。 6. Canonicalization 自身不得改变 topology: - symdiff area = 0 - Hausdorff distance = 0(数值容差内) 7. 输出 55 条 golden vectors: - kind - feature_id / coverage_id - object_class - cell - source_record_index - normalized type - normalized part count - canonical WKB SHA-256 - candidate bbox - candidate area - discarded connector length - feature bbox 是否未来需要更新 这些 golden vectors 可供后续 importer 实现回归测试。 8. 有效 geometry policy: 仍然只做 PASS-THROUGH,不 canonicalize 后写回; 统计 valid records,确认 policy 不会改它们。 重要: - CM93JAPAN 是日本沿海局部数据,不是完整 CM93; - 跨未导入海域边界不连续不作为错误; - 本脚本不修改数据库; - 不开始 PBF。 依赖 ==== pip install pymysql shapely 当前验证环境已知 Shapely 2.0.7。 执行 ==== python cm93_mysql_normalization_canonical_v10.py ^ --host 127.0.0.1 ^ --port 3306 ^ --user root ^ --database cm93_chart ^ --dataset-id 1 输出 ==== - normalization_canonical_summary_v10.txt - normalization_canonical_report_v10.json - normalization_golden_vectors_v10.csv - normalization_variant_results.csv - normalization_face_order_results.csv - normalization_canonical_failures.csv - normalization_bbox_metadata_impact.csv - normalization_canonical_candidates.geojson - normalization_golden_vectors_v10.json 并自动生成 ZIP。 禁止 ==== - 不 UPDATE / INSERT / DELETE - 不改 importer - 不写 normalized geometry - 不修改 bbox - 不 PBF """ from __future__ import annotations import argparse import csv import getpass import hashlib import json import math import os import re import sys import traceback import zipfile from collections import Counter from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple try: import pymysql from pymysql.cursors import DictCursor except ImportError: print("缺少 pymysql:pip install pymysql shapely") sys.exit(2) try: import shapely try: from shapely import from_wkb as _from_wkb def load_wkb(data: bytes): return _from_wkb(data) except ImportError: from shapely import wkb as _wkb def load_wkb(data: bytes): return _wkb.loads(data) try: from shapely import to_wkb as _to_wkb def dump_wkb(g) -> bytes: return bytes(_to_wkb(g, hex=False, byte_order=1)) except ImportError: from shapely import wkb as _wkb2 def dump_wkb(g) -> bytes: return _wkb2.dumps(g, hex=False, big_endian=False) try: from shapely import normalize as _normalize def canonicalize(g): return _normalize(g) except ImportError: raise ImportError("V10 需要 Shapely 2.x 的 shapely.normalize()") try: from shapely import make_valid as _make_valid def make_valid_geom(g): return _make_valid(g) except ImportError: from shapely.validation import make_valid as _make_valid def make_valid_geom(g): return _make_valid(g) from shapely.geometry import ( Point, Polygon, MultiPolygon, mapping ) from shapely.ops import unary_union, polygonize_full from shapely.validation import explain_validity except ImportError as e: print(f"Shapely 初始化失败:{e}") print("请执行:pip install -U shapely") sys.exit(2) INVALID_POINT_RE = re.compile( r"\[\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+" r"([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*\]" ) def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any: cur.execute(sql, params) row = cur.fetchone() return next(iter(row.values())) if row else None def begin_read_only(cur, warnings: List[str]): try: cur.execute("SET SESSION TRANSACTION READ ONLY") except Exception as e: warnings.append(f"SET SESSION TRANSACTION READ ONLY failed: {e}") try: cur.execute("START TRANSACTION READ ONLY") except Exception as e: warnings.append(f"START TRANSACTION READ ONLY failed: {e}") cur.execute("START TRANSACTION") def to_bytes(v: Any) -> Optional[bytes]: if v is None: return None if isinstance(v, bytes): return v if isinstance(v, bytearray): return bytes(v) if isinstance(v, memoryview): return v.tobytes() try: return bytes(v) except Exception: return None def write_csv( path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None, ): rows = list(rows) if fieldnames is None: seen = set() fieldnames = [] for row in rows: for k in row.keys(): if k not in seen: seen.add(k) fieldnames.append(k) path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8-sig", newline="") as f: if not fieldnames: return writer = csv.DictWriter( f, fieldnames=fieldnames, extrasaction="ignore" ) writer.writeheader() for row in rows: out = {} for k in fieldnames: v = row.get(k) if isinstance(v, (dict, list, tuple)): v = json.dumps(v, ensure_ascii=False, default=str) out[k] = "" if v is None else v writer.writerow(out) def probe_wkb_fn(cur) -> str: for fn in ("ST_AsBinary", "ST_AsWKB", "AsBinary", "AsWKB"): try: cur.execute( f"SELECT {fn}(geom) AS x " f"FROM chart_area_geometry LIMIT 1" ) cur.fetchone() return fn except Exception: pass raise RuntimeError("找不到 geometry -> WKB 函数") def geometry_hash(g) -> Optional[str]: if g is None: return None try: return hashlib.sha256(dump_wkb(g)).hexdigest() except Exception: return None def safe_symdiff_area(a, b) -> Optional[float]: if a is None or b is None: return None try: return float(a.symmetric_difference(b).area) except Exception: return None def safe_hausdorff(a, b) -> Optional[float]: if a is None or b is None: return None try: return float(a.hausdorff_distance(b)) except Exception: return None def safe_length(g) -> float: if g is None: return 0.0 try: return float(g.length) except Exception: return 0.0 def flatten_components(g) -> Dict[str, List[Any]]: result = { "polygons": [], "lines": [], "points": [], "others": [], } def rec(x): if x is None: return t = x.geom_type if t == "Polygon": result["polygons"].append(x) elif t == "MultiPolygon": for y in x.geoms: rec(y) elif t in ("LineString", "LinearRing"): result["lines"].append(x) elif t == "MultiLineString": for y in x.geoms: rec(y) elif t == "Point": result["points"].append(x) elif t == "MultiPoint": for y in x.geoms: rec(y) elif hasattr(x, "geoms"): for y in x.geoms: rec(y) else: result["others"].append(x) rec(g) return result def polygon_part_count(g) -> int: if g is None: return 0 if g.geom_type == "Polygon": return 1 if g.geom_type == "MultiPolygon": return len(g.geoms) return len(flatten_components(g)["polygons"]) # --------------------------------------------------------------------------- # Odd-even normalization # --------------------------------------------------------------------------- def original_rings(geom) -> List[List[Tuple[float, float]]]: rings: List[List[Tuple[float, float]]] = [] def add_polygon(poly): rings.append([ (float(c[0]), float(c[1])) for c in poly.exterior.coords ]) for ring in poly.interiors: rings.append([ (float(c[0]), float(c[1])) for c in ring.coords ]) if geom.geom_type == "Polygon": add_polygon(geom) elif geom.geom_type == "MultiPolygon": for poly in geom.geoms: add_polygon(poly) return rings def point_on_segment( px: float, py: float, ax: float, ay: float, bx: float, by: float, eps: float = 1e-12, ) -> bool: vx = bx - ax vy = by - ay wx = px - ax wy = py - ay c = abs(vx * wy - vy * wx) if c > eps * max(1.0, abs(vx), abs(vy)): return False d = wx * vx + wy * vy if d < -eps: return False vv = vx * vx + vy * vy if d - vv > eps: return False return True def even_odd_inside( point: Tuple[float, float], rings: List[List[Tuple[float, float]]], ) -> bool: x, y = point crossings = 0 for ring in rings: for i in range(len(ring) - 1): x1, y1 = ring[i] x2, y2 = ring[i + 1] if point_on_segment(x, y, x1, y1, x2, y2): return True if (y1 > y) != (y2 > y): xinters = x1 + (y - y1) * (x2 - x1) / (y2 - y1) if xinters > x: crossings += 1 return crossings % 2 == 1 def select_faces_odd_even(geom) -> Tuple[List[Any], Dict[str, Any]]: rings = original_rings(geom) noded = unary_union(geom.boundary) polygons_geom, cuts, dangles, invalid_rings = polygonize_full(noded) faces = flatten_components(polygons_geom)["polygons"] selected = [] for face in faces: rp = face.representative_point() p = (float(rp.x), float(rp.y)) if even_odd_inside(p, rings): selected.append(face) return selected, { "face_count": len(faces), "selected_face_count": len(selected), "cuts_length_deg": safe_length(cuts), "dangles_length_deg": safe_length(dangles), "invalid_rings_length_deg": safe_length(invalid_rings), } def candidate_from_selected_faces( selected: List[Any], mode: str = "original", ): if not selected: return None if mode == "original": items = list(selected) elif mode == "reverse": items = list(reversed(selected)) elif mode == "area": items = sorted(selected, key=lambda g: (float(g.area), g.bounds)) elif mode == "centroid": items = sorted( selected, key=lambda g: ( float(g.centroid.x), float(g.centroid.y), float(g.area), ) ) else: raise ValueError(mode) return unary_union(items) def normalize_odd_even(geom): if geom.is_valid: return geom selected, _ = select_faces_odd_even(geom) if not selected: return None return unary_union(selected) # --------------------------------------------------------------------------- # Equivalent input variants # --------------------------------------------------------------------------- def rotate_closed_ring( coords: List[Tuple[float, float]], shift: int ) -> List[Tuple[float, float]]: if not coords: return coords core = list(coords) if len(core) > 1 and core[0] == core[-1]: core = core[:-1] if not core: return coords shift = shift % len(core) rotated = core[shift:] + core[:shift] rotated.append(rotated[0]) return rotated def reverse_closed_ring( coords: List[Tuple[float, float]] ) -> List[Tuple[float, float]]: core = list(coords) if len(core) > 1 and core[0] == core[-1]: core = core[:-1] rev = list(reversed(core)) if rev: rev.append(rev[0]) return rev def polygon_variant(poly, *, reverse: bool, rotate: bool): ext = [ (float(c[0]), float(c[1])) for c in poly.exterior.coords ] holes = [ [ (float(c[0]), float(c[1])) for c in ring.coords ] for ring in poly.interiors ] def tx(coords, index): out = coords if reverse: out = reverse_closed_ring(out) if rotate: core_len = max(1, len(out) - 1) shift = max(1, core_len // 3 + index) out = rotate_closed_ring(out, shift) return out ext2 = tx(ext, 0) holes2 = [tx(h, i + 1) for i, h in enumerate(holes)] return Polygon(ext2, holes2) def make_input_variant(geom, mode: str): if mode == "original": return geom reverse = mode in { "reverse_rings", "reverse_and_rotate", "reverse_components_and_rings", } rotate = mode in { "rotate_rings", "reverse_and_rotate", } if geom.geom_type == "Polygon": return polygon_variant( geom, reverse=reverse, rotate=rotate, ) if geom.geom_type == "MultiPolygon": polys = [ polygon_variant( p, reverse=reverse, rotate=rotate, ) for p in geom.geoms ] if mode in { "reverse_components", "reverse_components_and_rings", }: polys = list(reversed(polys)) return MultiPolygon(polys) return geom # --------------------------------------------------------------------------- # DB loader # --------------------------------------------------------------------------- def load_polygon_records(cur, dataset_id: int, wkb_fn: str): records = [] cur.execute(f""" SELECT 'AREA' AS kind, f.id AS feature_id, NULL AS coverage_geom_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.coverage_id, f.raw_geotype, f.bbox_min_lon, f.bbox_min_lat, f.bbox_max_lon, f.bbox_max_lat, c.bbox_min_lon AS cell_bbox_min_lon, c.bbox_min_lat AS cell_bbox_min_lat, c.bbox_max_lon AS cell_bbox_max_lon, c.bbox_max_lat AS cell_bbox_max_lat, {wkb_fn}(g.geom) AS geom_wkb FROM chart_feature f JOIN chart_area_geometry g ON g.feature_id=f.id JOIN chart_cell c ON c.id=f.cell_id WHERE f.dataset_id=%s AND f.geometry_type='AREA' ORDER BY f.id """, (dataset_id,)) for r in cur: try: geom = load_wkb(to_bytes(r["geom_wkb"])) x = dict(r) x["_geom"] = geom records.append(x) except Exception: continue cur.execute(f""" SELECT 'COVERAGE' AS kind, NULL AS feature_id, v.id AS coverage_geom_id, 'COVERAGE' AS object_class, v.cell_id, c.cell_name, v.source_record_index, NULL AS coverage_id, NULL AS raw_geotype, NULL AS bbox_min_lon, NULL AS bbox_min_lat, NULL AS bbox_max_lon, NULL AS bbox_max_lat, c.bbox_min_lon AS cell_bbox_min_lon, c.bbox_min_lat AS cell_bbox_min_lat, c.bbox_max_lon AS cell_bbox_max_lon, c.bbox_max_lat AS cell_bbox_max_lat, {wkb_fn}(v.geom) AS geom_wkb FROM chart_coverage v JOIN chart_cell c ON c.id=v.cell_id WHERE v.dataset_id=%s ORDER BY v.id """, (dataset_id,)) for r in cur: try: geom = load_wkb(to_bytes(r["geom_wkb"])) x = dict(r) x["_geom"] = geom records.append(x) except Exception: continue return records # --------------------------------------------------------------------------- # GeoJSON # --------------------------------------------------------------------------- def make_geojson(records, path: Path): features = [] def add(g, props): if g is None: return try: features.append({ "type": "Feature", "geometry": mapping(g), "properties": props, }) except Exception: pass for r in records: if r["_geom"].is_valid: continue rid = ( r.get("feature_id") if r["kind"] == "AREA" else r.get("coverage_geom_id") ) common = { "kind": r["kind"], "record_id": rid, "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "canonical_hash": r.get("_canonical_hash"), } add( r["_geom"], { **common, "layer": "original_invalid", } ) add( r.get("_canonical_candidate"), { **common, "layer": "canonical_candidate", } ) path.write_text( json.dumps( { "type": "FeatureCollection", "features": features, }, ensure_ascii=False, separators=(",", ":"), default=str, ), encoding="utf-8" ) def parse_args(): p = argparse.ArgumentParser( description="CM93 normalization canonical validation v10" ) p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1")) p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306"))) p.add_argument("--user", default=os.getenv("MYSQL_USER", "root")) p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD")) p.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart")) p.add_argument("--dataset-id", type=int, default=1) p.add_argument( "--topology-area-tolerance", type=float, default=1e-12, ) p.add_argument( "--hausdorff-tolerance", type=float, default=1e-12, ) p.add_argument( "--bbox-tolerance", type=float, default=5e-6, ) p.add_argument("--output", default=None) return p.parse_args() def main(): args = parse_args() if not args.password: args.password = getpass.getpass("MySQL password: ") stamp = datetime.now().strftime("%Y%m%d_%H%M%S") outdir = Path( args.output or f"cm93_normalization_canonical_v10_dataset_{args.dataset_id}_{stamp}" ) outdir.mkdir(parents=True, exist_ok=True) report = { "version": "v10.0", "generated_at": datetime.now().isoformat(timespec="seconds"), "database": args.database, "dataset_id": args.dataset_id, "read_only": True, "pbf": "OUT_OF_SCOPE", "shapely_version": getattr(shapely, "__version__", "unknown"), "warnings": [], } conn = None try: conn = pymysql.connect( host=args.host, port=args.port, user=args.user, password=args.password, database=args.database, charset="utf8mb4", cursorclass=DictCursor, autocommit=False, connect_timeout=20, read_timeout=600, write_timeout=30, ) with conn.cursor() as cur: begin_read_only(cur, report["warnings"]) report["mysql_version"] = scalar(cur, "SELECT VERSION()") wkb_fn = probe_wkb_fn(cur) print("[1/6] 加载 Area + Coverage ...") records = load_polygon_records( cur, args.dataset_id, wkb_fn ) valid = [r for r in records if r["_geom"].is_valid] invalid = [r for r in records if not r["_geom"].is_valid] print( f" total={len(records):,} " f"valid={len(valid):,} invalid={len(invalid):,}" ) print("[2/6] Valid PASS-THROUGH policy guard ...") valid_policy_changed = 0 valid_hash_changed = 0 for i, r in enumerate(valid, start=1): # policy: valid geometry 不进入 canonical rewrite out = r["_geom"] if out is not r["_geom"]: valid_policy_changed += 1 if geometry_hash(out) != geometry_hash(r["_geom"]): valid_hash_changed += 1 if i % 20000 == 0: print(f" valid checked={i:,}") print("[3/6] 55 invalid canonical / input-variant 验证 ...") golden_rows = [] variant_rows = [] face_order_rows = [] failure_rows = [] bbox_rows = [] input_modes = [ "original", "reverse_rings", "rotate_rings", "reverse_and_rotate", "reverse_components", "reverse_components_and_rings", ] for i, r in enumerate(invalid, start=1): geom = r["_geom"] rid = ( r.get("feature_id") if r["kind"] == "AREA" else r.get("coverage_geom_id") ) selected, sel_info = select_faces_odd_even(geom) baseline = candidate_from_selected_faces( selected, "original" ) if baseline is None: fail = { "kind": r["kind"], "record_id": rid, "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "failure": "BASELINE_CANDIDATE_NONE", } failure_rows.append(fail) continue baseline_canonical = canonicalize(baseline) baseline_hash = geometry_hash(baseline_canonical) baseline_valid = bool(baseline_canonical.is_valid) baseline_polygon_only = ( baseline_canonical.geom_type in {"Polygon", "MultiPolygon"} ) # make_valid line remainder length only as metadata mv = make_valid_geom(geom) mvcomps = flatten_components(mv) remainder = geom_union(mvcomps["lines"]) remainder_length = safe_length(remainder) # canonicalization itself topology invariant canonical_sym = safe_symdiff_area( baseline, baseline_canonical ) canonical_haus = safe_hausdorff( baseline, baseline_canonical ) canonical_topology_ok = ( canonical_sym is not None and canonical_sym <= args.topology_area_tolerance and canonical_haus is not None and canonical_haus <= args.hausdorff_tolerance ) # face order invariance face_hashes = {} for mode in [ "original", "reverse", "area", "centroid", ]: cand = candidate_from_selected_faces( selected, mode ) canon = canonicalize(cand) h = geometry_hash(canon) face_hashes[mode] = h face_order_rows.append({ "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "face_order_mode": mode, "canonical_wkb_sha256": h, "matches_baseline_hash": int(h == baseline_hash), "topology_symdiff_area_deg2": safe_symdiff_area( baseline_canonical, canon ), }) face_order_stable = ( len(set(face_hashes.values())) == 1 ) # repeated same input repeat_hashes = [] for _ in range(3): c = normalize_odd_even(geom) cc = canonicalize(c) repeat_hashes.append(geometry_hash(cc)) repeat_stable = ( len(set(repeat_hashes)) == 1 and repeat_hashes[0] == baseline_hash ) # input-equivalent variants variant_fail = 0 for mode in input_modes: try: vg = make_input_variant(geom, mode) vc = normalize_odd_even(vg) if vc is None: variant_fail += 1 variant_rows.append({ "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "variant_mode": mode, "status": "FAIL_NO_CANDIDATE", }) continue vcanon = canonicalize(vc) vh = geometry_hash(vcanon) sym = safe_symdiff_area( baseline_canonical, vcanon ) haus = safe_hausdorff( baseline_canonical, vcanon ) topology_equal = ( bool( baseline_canonical.equals( vcanon ) ) and sym is not None and sym <= args.topology_area_tolerance ) hash_equal = (vh == baseline_hash) if not ( vcanon.is_valid and topology_equal and hash_equal ): variant_fail += 1 variant_rows.append({ "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "variant_mode": mode, "variant_input_valid": int(bool(vg.is_valid)), "candidate_valid": int(bool(vcanon.is_valid)), "candidate_type": vcanon.geom_type, "topology_equal_baseline": int(topology_equal), "symdiff_area_deg2": sym, "hausdorff_distance_deg": haus, "canonical_wkb_sha256": vh, "matches_baseline_hash": int(hash_equal), "status": "PASS" if ( vcanon.is_valid and topology_equal and hash_equal ) else "FAIL", }) except Exception as e: variant_fail += 1 variant_rows.append({ "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "variant_mode": mode, "status": "EXCEPTION", "detail": f"{type(e).__name__}: {e}", }) # bbox metadata impact old_bounds = tuple(float(v) for v in geom.bounds) new_bounds = tuple( float(v) for v in baseline_canonical.bounds ) bbox_changed = any( abs(a - b) > args.bbox_tolerance for a, b in zip(old_bounds, new_bounds) ) feature_bbox = None feature_bbox_matches_candidate = None if r["kind"] == "AREA": vals = [ r.get("bbox_min_lon"), r.get("bbox_min_lat"), r.get("bbox_max_lon"), r.get("bbox_max_lat"), ] if all(v is not None for v in vals): feature_bbox = tuple( float(v) for v in vals ) feature_bbox_matches_candidate = all( abs(a - b) <= args.bbox_tolerance for a, b in zip( feature_bbox, new_bounds ) ) bbox_rows.append({ "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_name": r.get("cell_name"), "original_geometry_bounds": old_bounds, "candidate_bounds": new_bounds, "bbox_changed_over_tolerance": int(bbox_changed), "current_feature_bbox": feature_bbox, "current_feature_bbox_matches_candidate": ( int(feature_bbox_matches_candidate) if feature_bbox_matches_candidate is not None else None ), "future_feature_bbox_update_needed": ( int(not feature_bbox_matches_candidate) if feature_bbox_matches_candidate is not None else None ), "discarded_connector_length_deg": remainder_length, }) record_pass = all([ baseline_valid, baseline_polygon_only, canonical_topology_ok, face_order_stable, repeat_stable, variant_fail == 0, ]) golden = { "kind": r["kind"], "feature_id": r.get("feature_id"), "coverage_geom_id": r.get("coverage_geom_id"), "object_class": r.get("object_class"), "cell_id": r.get("cell_id"), "cell_name": r.get("cell_name"), "source_record_index": r.get("source_record_index"), "coverage_id": r.get("coverage_id"), "raw_geotype": r.get("raw_geotype"), "normalization_rule": "ODD_EVEN_FACE_SELECTION", "canonicalization_rule": "GEOS_NORMALIZE", "candidate_type": baseline_canonical.geom_type, "candidate_polygon_part_count": polygon_part_count( baseline_canonical ), "candidate_valid": int(baseline_valid), "polygonize_face_count": sel_info["face_count"], "selected_face_count": sel_info["selected_face_count"], "candidate_area_deg2": float(baseline_canonical.area), "candidate_bbox": tuple( float(v) for v in baseline_canonical.bounds ), "discarded_connector_length_deg": remainder_length, "canonical_wkb_sha256": baseline_hash, "canonicalization_symdiff_area_deg2": canonical_sym, "canonicalization_hausdorff_deg": canonical_haus, "canonical_topology_unchanged": int(canonical_topology_ok), "face_order_hash_stable": int(face_order_stable), "repeat_hash_stable": int(repeat_stable), "input_variant_failure_count": variant_fail, "record_status": "PASS" if record_pass else "FAIL", } golden_rows.append(golden) if not record_pass: failure_rows.append(golden) r["_canonical_candidate"] = baseline_canonical r["_canonical_hash"] = baseline_hash print( f" [{i:02d}/{len(invalid)}] " f"{r['kind']} id={rid} " f"{r.get('object_class')} -> " f"{'PASS' if record_pass else 'FAIL'}" ) print("[4/6] 汇总 canonical stability ...") record_status = Counter( r["record_status"] for r in golden_rows ) variant_status = Counter( r.get("status", "UNKNOWN") for r in variant_rows ) bbox_update_area_count = sum( int( r.get( "future_feature_bbox_update_needed" ) or 0 ) for r in bbox_rows ) report["summary"] = { "total_polygon_records": len(records), "valid_polygon_records": len(valid), "invalid_polygon_records": len(invalid), "valid_policy_changed_count": valid_policy_changed, "valid_hash_changed_count": valid_hash_changed, "golden_vector_count": len(golden_rows), "golden_record_status_counts": dict(record_status), "input_variant_case_count": len(variant_rows), "input_variant_status_counts": dict(variant_status), "face_order_case_count": len(face_order_rows), "face_order_hash_mismatch_count": sum( not bool( r["matches_baseline_hash"] ) for r in face_order_rows ), "canonical_topology_failure_count": sum( not bool( r["canonical_topology_unchanged"] ) for r in golden_rows ), "repeat_hash_failure_count": sum( not bool( r["repeat_hash_stable"] ) for r in golden_rows ), "input_variant_failure_record_count": sum( int( r["input_variant_failure_count"] ) > 0 for r in golden_rows ), "future_area_feature_bbox_update_needed_count": bbox_update_area_count, } overall_pass = ( len(invalid) == 55 and len(golden_rows) == 55 and record_status.get("PASS", 0) == 55 and record_status.get("FAIL", 0) == 0 and valid_policy_changed == 0 and valid_hash_changed == 0 and report["summary"][ "face_order_hash_mismatch_count" ] == 0 and report["summary"][ "canonical_topology_failure_count" ] == 0 and report["summary"][ "repeat_hash_failure_count" ] == 0 and report["summary"][ "input_variant_failure_record_count" ] == 0 ) report["summary"]["overall_pass"] = overall_pass print("[5/6] 写 golden vectors / 报告 ...") write_csv( outdir / "normalization_golden_vectors_v10.csv", golden_rows ) ( outdir / "normalization_golden_vectors_v10.json" ).write_text( json.dumps( golden_rows, ensure_ascii=False, indent=2, default=str, ), encoding="utf-8" ) write_csv( outdir / "normalization_variant_results.csv", variant_rows ) write_csv( outdir / "normalization_face_order_results.csv", face_order_rows ) write_csv( outdir / "normalization_canonical_failures.csv", failure_rows ) write_csv( outdir / "normalization_bbox_metadata_impact.csv", bbox_rows ) make_geojson( records, outdir / "normalization_canonical_candidates.geojson" ) ( outdir / "normalization_canonical_report_v10.json" ).write_text( json.dumps( report, ensure_ascii=False, indent=2, default=str, ), encoding="utf-8" ) lines = [] lines.append( "CM93 -> MySQL OGC Normalization Canonical Validation v10" ) lines.append("=" * 96) lines.append( f"Generated: {report['generated_at']}" ) lines.append( f"Dataset ID: {args.dataset_id}" ) lines.append( f"MySQL: {report['mysql_version']}" ) lines.append( f"Shapely: {report['shapely_version']}" ) lines.append("") lines.append("SCOPE") lines.append( " CM93JAPAN 是局部 CM93 数据集;" "跨未导入海域不连续不作为错误。" ) lines.append( " V10 只验证 odd-even candidate 的 canonical WKB " "可重复性,不修改数据库。" ) lines.append("") lines.append("BASELINE") lines.append( f" polygon records: {len(records)}" ) lines.append( f" valid: {len(valid)}" ) lines.append( f" invalid: {len(invalid)}" ) lines.append("") lines.append("VALID PASS-THROUGH") lines.append( f" policy changed: {valid_policy_changed}" ) lines.append( f" WKB hash changed: {valid_hash_changed}" ) lines.append("") lines.append("GOLDEN VECTOR STATUS") for k, v in record_status.most_common(): lines.append( f" {k:24s} {v}" ) lines.append("") lines.append("INPUT VARIANTS") lines.append( f" cases: {len(variant_rows)}" ) for k, v in variant_status.most_common(): lines.append( f" {k:24s} {v}" ) lines.append("") lines.append("STABILITY") lines.append( f" face-order hash mismatch: " f"{report['summary']['face_order_hash_mismatch_count']}" ) lines.append( f" canonical topology failures: " f"{report['summary']['canonical_topology_failure_count']}" ) lines.append( f" repeat hash failures: " f"{report['summary']['repeat_hash_failure_count']}" ) lines.append( f" input-variant failure records: " f"{report['summary']['input_variant_failure_record_count']}" ) lines.append("") lines.append("METADATA IMPACT") lines.append( f" future Area feature bbox updates needed: " f"{bbox_update_area_count}" ) lines.append( " bbox 更新仅是未来若真正保存 normalized geometry " "时的元数据同步要求;V10 不修改任何 bbox。" ) lines.append("") lines.append("OVERALL") lines.append( f" CANONICAL VALIDATION PASS: " f"{'YES' if overall_pass else 'NO'}" ) lines.append("") lines.append("NEXT USE") lines.append( " normalization_golden_vectors_v10.csv/json " "可作为未来 importer normalization 实现的 55 条 golden regression vectors。" ) lines.append( " 后续实现应固定:ODD_EVEN_FACE_SELECTION + GEOS_NORMALIZE," "并要求输出 canonical_wkb_sha256 与 golden vector 一致。" ) lines.append("") lines.append( "NO DATABASE WRITE / NO IMPORTER CHANGE / NO PBF" ) ( outdir / "normalization_canonical_summary_v10.txt" ).write_text( "\n".join(lines), encoding="utf-8" ) conn.rollback() print("[6/6] 压缩 ...") zpath = outdir.with_suffix(".zip") with zipfile.ZipFile( zpath, "w", compression=zipfile.ZIP_DEFLATED ) as z: for p in sorted(outdir.rglob("*")): if p.is_file(): z.write( p, p.relative_to(outdir.parent) ) print("") print("完成。") print(f"Output: {outdir.resolve()}") print(f"ZIP: {zpath.resolve()}") print("把 ZIP 整包上传给 ChatGPT。") except KeyboardInterrupt: if conn: try: conn.rollback() except Exception: pass print("\n用户中断。") sys.exit(130) except Exception: if conn: try: conn.rollback() except Exception: pass tb = traceback.format_exc() print(tb) try: ( outdir / "fatal_error.txt" ).write_text( tb, encoding="utf-8" ) except Exception: pass sys.exit(1) finally: if conn: try: conn.close() except Exception: pass if __name__ == "__main__": main()