1163 lines
38 KiB
Python
1163 lines
38 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CM93 -> MySQL Invalid Polygon 线性残片与填充规则诊断
|
||
Version: v8.0
|
||
|
||
背景
|
||
====
|
||
前序 V7 已确认:
|
||
- invalid total = 55(Area 52 + Coverage 3)
|
||
- 41 条 make_valid 后得到 Polygon + LineString
|
||
- 14 条得到纯 Polygon/MultiPolygon
|
||
- 其中 5 条 make_valid 面选择与“polygonize 全部 faces”不同
|
||
- 31 条 polygonize 有 dangles,10 条有 cuts,恰好合计 41
|
||
|
||
V8 目标
|
||
=======
|
||
只做只读、内存诊断,不修复数据库。
|
||
|
||
A. 验证 41 条线性残片的来源
|
||
--------------------------------
|
||
对 make_valid 产生的每一条线性 remainder:
|
||
1. 将 remainder 拆成最小线段;
|
||
2. 对每个 remainder segment 的中点,反查原始 invalid Polygon 的所有 ring segment;
|
||
3. 统计该点被多少条原始 segment 覆盖;
|
||
4. 判断覆盖 segment 相对于 remainder segment 的方向:
|
||
- 正向
|
||
- 反向
|
||
5. 分类:
|
||
- OPPOSITE_DIRECTION_RETRACE
|
||
同一位置至少有两条原始 segment,且存在正反两个方向;
|
||
这是“往返桥接/重走线”的强证据。
|
||
- SAME_DIRECTION_OVERLAP
|
||
- SINGLE_TRAVERSAL
|
||
- MULTI_COVER_NO_OPPOSITE
|
||
- UNMATCHED
|
||
6. 对每个对象给出 record-level 分类:
|
||
- RETRACED_CONNECTOR_CONFIRMED
|
||
- RETRACED_CONNECTOR_MIXED
|
||
- NONRETRACED_LINEAR_REMAINDER
|
||
- NO_LINEAR_REMAINDER
|
||
|
||
B. 验证 polygon face 填充规则
|
||
--------------------------------
|
||
对原始 invalid geometry.boundary:
|
||
1. unary_union + polygonize_full,得到所有 planar faces;
|
||
2. 对每个 face 取 representative point;
|
||
3. 对原始 ring 坐标计算:
|
||
- Odd-even parity
|
||
- Non-zero winding number
|
||
4. 检查该 face 是否被 Shapely make_valid 的 polygonal result 选中;
|
||
5. 每条记录判定:
|
||
- MATCHES_BOTH_ODD_EVEN_AND_NONZERO
|
||
- MATCHES_ODD_EVEN_ONLY
|
||
- MATCHES_NONZERO_ONLY
|
||
- MATCHES_NEITHER
|
||
- NO_FACES
|
||
|
||
这一步尤其解释 V7 中 5 条 POLYGON_ONLY_FACE_SELECTION_DIFFERS:
|
||
不是坐标变了,而是“全部 polygonized faces 中哪些应该填充”存在语义选择。
|
||
|
||
C. 输出结论候选
|
||
--------------------------------
|
||
仅用于诊断,不自动修改数据库:
|
||
|
||
- OGC_AREA_WITH_RETRACED_CONNECTOR
|
||
面部分可有效表达,线性残片基本确认是原 ring 的正反重走桥接线。
|
||
|
||
- OGC_AREA_WITH_NONRETRACED_REMAINDER
|
||
存在线性残片,但无法解释为简单正反重走,需要继续调查。
|
||
|
||
- OGC_POLYGONAL_FILL_RULE_RESOLVED
|
||
无线性残片,且 make_valid face selection 能被 odd-even/nonzero 规则解释。
|
||
|
||
- OGC_POLYGONAL_FILL_RULE_UNRESOLVED
|
||
face selection 无法由两种常见规则解释。
|
||
|
||
重要边界
|
||
========
|
||
当前 CM93JAPAN 是日本沿海局部子集,不是完整 CM93:
|
||
- 跨未导入海域连续性不作为 FAIL;
|
||
- Cell 边界上的共享拓扑保留 boundary context;
|
||
- V8 不将“Cell 边界 self-touch”自动判为源数据损坏。
|
||
|
||
禁止
|
||
====
|
||
- 不 UPDATE / INSERT / DELETE
|
||
- 不写回 make_valid
|
||
- 不 buffer(0)
|
||
- 不修改 geometry_issue
|
||
- 不开始 PBF
|
||
|
||
依赖
|
||
====
|
||
pip install pymysql shapely
|
||
|
||
执行
|
||
====
|
||
python cm93_mysql_fillrule_audit_v8.py ^
|
||
--host 127.0.0.1 ^
|
||
--port 3306 ^
|
||
--user root ^
|
||
--database cm93_chart ^
|
||
--dataset-id 1
|
||
|
||
输出
|
||
====
|
||
- fillrule_audit_summary_v8.txt
|
||
- fillrule_audit_report_v8.json
|
||
- invalid_polygon_fillrule_classification.csv
|
||
- linear_remainder_segment_evidence.csv
|
||
- polygon_face_fillrule_evidence.csv
|
||
- fillrule_by_object_class.csv
|
||
- fillrule_by_boundary_context.csv
|
||
- retraced_connector_summary.csv
|
||
- fillrule_diagnostic.geojson
|
||
并自动打 ZIP。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sys
|
||
import traceback
|
||
import zipfile
|
||
from collections import Counter, defaultdict
|
||
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 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, LineString, mapping
|
||
from shapely.ops import unary_union, polygonize_full
|
||
from shapely.validation import explain_validity
|
||
except ImportError:
|
||
print("缺少 shapely:pip install pymysql 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 r in rows:
|
||
for k in r.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
|
||
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||
w.writeheader()
|
||
for r in rows:
|
||
out = {}
|
||
for k in fieldnames:
|
||
v = r.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
|
||
w.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 FROM chart_area_geometry LIMIT 1")
|
||
cur.fetchone()
|
||
return fn
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("找不到 geometry -> WKB 函数")
|
||
|
||
|
||
def parse_invalid_point(reason: str) -> Optional[Tuple[float, float]]:
|
||
if not reason:
|
||
return None
|
||
m = INVALID_POINT_RE.search(reason)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return float(m.group(1)), float(m.group(2))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def bbox_from_row(row: Dict[str, Any]) -> Optional[Tuple[float, float, float, float]]:
|
||
try:
|
||
vals = (
|
||
float(row["cell_bbox_min_lon"]),
|
||
float(row["cell_bbox_min_lat"]),
|
||
float(row["cell_bbox_max_lon"]),
|
||
float(row["cell_bbox_max_lat"]),
|
||
)
|
||
if all(math.isfinite(v) for v in vals):
|
||
return vals
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def invalid_point_cell_edge(
|
||
p: Optional[Tuple[float, float]],
|
||
bbox: Optional[Tuple[float, float, float, float]],
|
||
tol: float
|
||
) -> Tuple[int, str, Optional[float]]:
|
||
if p is None or bbox is None:
|
||
return 0, "", None
|
||
|
||
x, y = p
|
||
minx, miny, maxx, maxy = bbox
|
||
d = {
|
||
"WEST": abs(x - minx),
|
||
"EAST": abs(x - maxx),
|
||
"SOUTH": abs(y - miny),
|
||
"NORTH": abs(y - maxy),
|
||
}
|
||
edge, dist = min(d.items(), key=lambda kv: kv[1])
|
||
return int(dist <= tol), edge if dist <= tol else "", dist
|
||
|
||
|
||
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 geom_union(items: Sequence[Any]) -> Optional[Any]:
|
||
if not items:
|
||
return None
|
||
try:
|
||
return unary_union(list(items))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
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 p in geom.geoms:
|
||
add_polygon(p)
|
||
return rings
|
||
|
||
|
||
def original_segments(geom) -> List[Dict[str, Any]]:
|
||
segs = []
|
||
for ri, ring in enumerate(original_rings(geom)):
|
||
for si in range(len(ring) - 1):
|
||
a = ring[si]
|
||
b = ring[si + 1]
|
||
if a == b:
|
||
continue
|
||
try:
|
||
line = LineString([a, b])
|
||
except Exception:
|
||
continue
|
||
segs.append({
|
||
"ring_index": ri,
|
||
"segment_index": si,
|
||
"a": a,
|
||
"b": b,
|
||
"line": line,
|
||
})
|
||
return segs
|
||
|
||
|
||
def vector_direction(a: Tuple[float, float],
|
||
b: Tuple[float, float]) -> Tuple[float, float]:
|
||
return b[0] - a[0], b[1] - a[1]
|
||
|
||
|
||
def dot(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
||
return a[0] * b[0] + a[1] * b[1]
|
||
|
||
|
||
def cross(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
||
return a[0] * b[1] - a[1] * b[0]
|
||
|
||
|
||
def classify_remainder_segment(
|
||
ra: Tuple[float, float],
|
||
rb: Tuple[float, float],
|
||
source_segments: List[Dict[str, Any]],
|
||
tol: float,
|
||
) -> Dict[str, Any]:
|
||
rvec = vector_direction(ra, rb)
|
||
rlen = math.hypot(rvec[0], rvec[1])
|
||
if rlen <= 0:
|
||
return {
|
||
"coverage_count": 0,
|
||
"same_direction_count": 0,
|
||
"opposite_direction_count": 0,
|
||
"segment_class": "ZERO_LENGTH_REMAINDER_SEGMENT",
|
||
}
|
||
|
||
mid = Point((ra[0] + rb[0]) / 2.0, (ra[1] + rb[1]) / 2.0)
|
||
same = 0
|
||
opposite = 0
|
||
covers = 0
|
||
source_hits = []
|
||
|
||
for s in source_segments:
|
||
line = s["line"]
|
||
# midpoint 必须落在线段上或非常接近
|
||
try:
|
||
if line.distance(mid) > tol:
|
||
continue
|
||
except Exception:
|
||
continue
|
||
|
||
svec = vector_direction(s["a"], s["b"])
|
||
slen = math.hypot(svec[0], svec[1])
|
||
if slen <= 0:
|
||
continue
|
||
|
||
# 要求方向基本共线,避免“恰好在交点处”的非同线 segment 被误计。
|
||
sin_angle = abs(cross(rvec, svec)) / (rlen * slen)
|
||
if sin_angle > 1e-7:
|
||
continue
|
||
|
||
covers += 1
|
||
dp = dot(rvec, svec)
|
||
if dp >= 0:
|
||
same += 1
|
||
direction = "SAME"
|
||
else:
|
||
opposite += 1
|
||
direction = "OPPOSITE"
|
||
|
||
source_hits.append({
|
||
"ring_index": s["ring_index"],
|
||
"segment_index": s["segment_index"],
|
||
"direction": direction,
|
||
})
|
||
|
||
if covers >= 2 and same > 0 and opposite > 0:
|
||
cls = "OPPOSITE_DIRECTION_RETRACE"
|
||
elif covers >= 2 and opposite == 0:
|
||
cls = "SAME_DIRECTION_OVERLAP"
|
||
elif covers == 1:
|
||
cls = "SINGLE_TRAVERSAL"
|
||
elif covers >= 2:
|
||
cls = "MULTI_COVER_NO_OPPOSITE"
|
||
else:
|
||
cls = "UNMATCHED"
|
||
|
||
return {
|
||
"coverage_count": covers,
|
||
"same_direction_count": same,
|
||
"opposite_direction_count": opposite,
|
||
"segment_class": cls,
|
||
"source_hits": source_hits,
|
||
}
|
||
|
||
|
||
def record_remainder_class(segment_classes: Counter) -> str:
|
||
total = sum(segment_classes.values())
|
||
if total == 0:
|
||
return "NO_LINEAR_REMAINDER"
|
||
|
||
retraced = segment_classes.get("OPPOSITE_DIRECTION_RETRACE", 0)
|
||
unmatched = segment_classes.get("UNMATCHED", 0)
|
||
single = segment_classes.get("SINGLE_TRAVERSAL", 0)
|
||
same = segment_classes.get("SAME_DIRECTION_OVERLAP", 0)
|
||
other = total - retraced - unmatched - single - same
|
||
|
||
if retraced == total:
|
||
return "RETRACED_CONNECTOR_CONFIRMED"
|
||
if retraced > 0 and (single > 0 or same > 0 or unmatched > 0 or other > 0):
|
||
return "RETRACED_CONNECTOR_MIXED"
|
||
return "NONRETRACED_LINEAR_REMAINDER"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fill rules
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def point_on_segment(px, py, ax, ay, bx, by, eps=1e-12) -> bool:
|
||
vx, vy = bx - ax, by - ay
|
||
wx, wy = px - ax, 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):
|
||
# representative point 理论上不会落边界;若发生,按 inside 处理。
|
||
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 is_left(ax, ay, bx, by, px, py) -> float:
|
||
return (bx - ax) * (py - ay) - (px - ax) * (by - ay)
|
||
|
||
|
||
def winding_number(point: Tuple[float, float],
|
||
rings: List[List[Tuple[float, float]]]) -> int:
|
||
x, y = point
|
||
wn = 0
|
||
|
||
for ring in rings:
|
||
for i in range(len(ring) - 1):
|
||
x1, y1 = ring[i]
|
||
x2, y2 = ring[i + 1]
|
||
|
||
if y1 <= y:
|
||
if y2 > y and is_left(x1, y1, x2, y2, x, y) > 0:
|
||
wn += 1
|
||
else:
|
||
if y2 <= y and is_left(x1, y1, x2, y2, x, y) < 0:
|
||
wn -= 1
|
||
return wn
|
||
|
||
|
||
def fill_rule_record_class(face_rows: List[Dict[str, Any]]) -> str:
|
||
if not face_rows:
|
||
return "NO_FACES"
|
||
|
||
odd_ok = all(
|
||
bool(r["selected_by_make_valid"]) == bool(r["odd_even_inside"])
|
||
for r in face_rows
|
||
)
|
||
nonzero_ok = all(
|
||
bool(r["selected_by_make_valid"]) == bool(r["nonzero_inside"])
|
||
for r in face_rows
|
||
)
|
||
|
||
if odd_ok and nonzero_ok:
|
||
return "MATCHES_BOTH_ODD_EVEN_AND_NONZERO"
|
||
if odd_ok:
|
||
return "MATCHES_ODD_EVEN_ONLY"
|
||
if nonzero_ok:
|
||
return "MATCHES_NONZERO_ONLY"
|
||
return "MATCHES_NEITHER"
|
||
|
||
|
||
def final_semantic_class(remainder_class: str, fill_class: str) -> str:
|
||
if remainder_class == "RETRACED_CONNECTOR_CONFIRMED":
|
||
return "OGC_AREA_WITH_RETRACED_CONNECTOR"
|
||
if remainder_class in {
|
||
"RETRACED_CONNECTOR_MIXED",
|
||
"NONRETRACED_LINEAR_REMAINDER",
|
||
}:
|
||
return "OGC_AREA_WITH_NONRETRACED_REMAINDER"
|
||
|
||
if fill_class != "MATCHES_NEITHER":
|
||
return "OGC_POLYGONAL_FILL_RULE_RESOLVED"
|
||
return "OGC_POLYGONAL_FILL_RULE_UNRESOLVED"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_invalid_records(cur, dataset_id: int, wkb_fn: str) -> List[Dict[str, Any]]:
|
||
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,
|
||
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"]))
|
||
if not geom.is_valid:
|
||
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,
|
||
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"]))
|
||
if not geom.is_valid:
|
||
x = dict(r)
|
||
x["_geom"] = geom
|
||
records.append(x)
|
||
except Exception:
|
||
continue
|
||
|
||
return records
|
||
|
||
|
||
def make_geojson(records: List[Dict[str, Any]], 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:
|
||
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"),
|
||
"remainder_class": r.get("_remainder_class"),
|
||
"fill_rule_class": r.get("_fill_rule_class"),
|
||
"final_semantic_class": r.get("_final_semantic_class"),
|
||
}
|
||
|
||
add(r.get("_geom"), {**common, "layer": "original_invalid"})
|
||
add(r.get("_mv_polygon_union"), {**common, "layer": "make_valid_polygonal"})
|
||
add(r.get("_mv_line_union"), {**common, "layer": "linear_remainder"})
|
||
|
||
p = r.get("_invalid_point")
|
||
if p:
|
||
add(Point(p), {**common, "layer": "invalidity_point"})
|
||
|
||
path.write_text(
|
||
json.dumps(
|
||
{"type": "FeatureCollection", "features": features},
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
default=str,
|
||
),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
|
||
def zip_dir(folder: Path) -> Path:
|
||
zpath = folder.with_suffix(".zip")
|
||
with zipfile.ZipFile(zpath, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||
for p in sorted(folder.rglob("*")):
|
||
if p.is_file():
|
||
z.write(p, p.relative_to(folder.parent))
|
||
return zpath
|
||
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(
|
||
description="CM93 invalid Polygon fill rule + retrace audit v8"
|
||
)
|
||
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(
|
||
"--segment-tolerance",
|
||
type=float,
|
||
default=2e-8,
|
||
help="remainder segment midpoint 与原始 ring segment 匹配容差(degree)"
|
||
)
|
||
p.add_argument(
|
||
"--cell-edge-tolerance",
|
||
type=float,
|
||
default=5e-6,
|
||
help="invalid point 是否位于 Cell bbox 边界的容差(degree)"
|
||
)
|
||
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_fillrule_audit_v8_dataset_{args.dataset_id}_{stamp}"
|
||
)
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
report = {
|
||
"version": "v8.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"),
|
||
"scope_note": (
|
||
"CM93JAPAN 是局部数据集。V8 只验证 invalid Polygon 的线性残片"
|
||
"是否为重走桥接线,以及 face selection 对 odd-even/nonzero 填充规则的符合程度。"
|
||
),
|
||
"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)
|
||
report["wkb_fn"] = wkb_fn
|
||
|
||
print("[1/5] 加载 invalid Area + Coverage ...")
|
||
records = load_invalid_records(cur, args.dataset_id, wkb_fn)
|
||
print(f" invalid records={len(records)}")
|
||
|
||
classification_rows = []
|
||
remainder_evidence = []
|
||
face_evidence = []
|
||
|
||
print("[2/5] 线性残片 retrace 诊断 ...")
|
||
for idx, r in enumerate(records, start=1):
|
||
geom = r["_geom"]
|
||
reason = explain_validity(geom)
|
||
invalid_point = parse_invalid_point(reason)
|
||
cell_bbox = bbox_from_row(r)
|
||
on_edge, edge_name, edge_dist = invalid_point_cell_edge(
|
||
invalid_point, cell_bbox, args.cell_edge_tolerance
|
||
)
|
||
|
||
mv = make_valid_geom(geom)
|
||
comps = flatten_components(mv)
|
||
mv_poly_union = geom_union(comps["polygons"])
|
||
mv_line_union = geom_union(comps["lines"])
|
||
|
||
src_segments = original_segments(geom)
|
||
seg_class_counts = Counter()
|
||
segment_count = 0
|
||
|
||
for li, line in enumerate(comps["lines"]):
|
||
coords = [(float(c[0]), float(c[1])) for c in line.coords]
|
||
for si in range(len(coords) - 1):
|
||
ra, rb = coords[si], coords[si+1]
|
||
if ra == rb:
|
||
continue
|
||
ev = classify_remainder_segment(
|
||
ra, rb, src_segments, args.segment_tolerance
|
||
)
|
||
segment_count += 1
|
||
seg_class_counts[ev["segment_class"]] += 1
|
||
|
||
remainder_evidence.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"),
|
||
"line_component_index": li,
|
||
"remainder_segment_index": si,
|
||
"ax": ra[0],
|
||
"ay": ra[1],
|
||
"bx": rb[0],
|
||
"by": rb[1],
|
||
"segment_length_deg": LineString([ra, rb]).length,
|
||
"coverage_count": ev["coverage_count"],
|
||
"same_direction_count": ev["same_direction_count"],
|
||
"opposite_direction_count": ev["opposite_direction_count"],
|
||
"segment_class": ev["segment_class"],
|
||
"source_hits": ev.get("source_hits", []),
|
||
})
|
||
|
||
rem_class = record_remainder_class(seg_class_counts)
|
||
|
||
# Face analysis
|
||
boundary = unary_union(geom.boundary)
|
||
polygons_geom, cuts, dangles, invalid_rings = polygonize_full(boundary)
|
||
face_comps = flatten_components(polygons_geom)["polygons"]
|
||
rings = original_rings(geom)
|
||
|
||
this_faces = []
|
||
for fi, face in enumerate(face_comps):
|
||
rp = face.representative_point()
|
||
pxy = (float(rp.x), float(rp.y))
|
||
odd = even_odd_inside(pxy, rings)
|
||
wn = winding_number(pxy, rings)
|
||
nonzero = wn != 0
|
||
|
||
selected = False
|
||
if mv_poly_union is not None:
|
||
try:
|
||
selected = bool(mv_poly_union.covers(rp))
|
||
except Exception:
|
||
selected = False
|
||
|
||
row = {
|
||
"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_index": fi,
|
||
"face_area_deg2": float(face.area),
|
||
"representative_x": pxy[0],
|
||
"representative_y": pxy[1],
|
||
"selected_by_make_valid": int(selected),
|
||
"odd_even_inside": int(odd),
|
||
"winding_number": wn,
|
||
"nonzero_inside": int(nonzero),
|
||
"odd_even_matches_selection": int(selected == odd),
|
||
"nonzero_matches_selection": int(selected == nonzero),
|
||
}
|
||
face_evidence.append(row)
|
||
this_faces.append(row)
|
||
|
||
fill_class = fill_rule_record_class(this_faces)
|
||
final_class = final_semantic_class(rem_class, fill_class)
|
||
|
||
rid = (
|
||
r.get("feature_id")
|
||
if r["kind"] == "AREA"
|
||
else r.get("coverage_geom_id")
|
||
)
|
||
|
||
c = {
|
||
"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"),
|
||
"validity_reason": reason,
|
||
"invalid_point_on_cell_edge": on_edge,
|
||
"cell_edge": edge_name,
|
||
"cell_edge_distance_deg": edge_dist,
|
||
"make_valid_polygon_count": len(comps["polygons"]),
|
||
"make_valid_line_count": len(comps["lines"]),
|
||
"linear_remainder_segment_count": segment_count,
|
||
"remainder_segment_classes": dict(seg_class_counts),
|
||
"remainder_class": rem_class,
|
||
"polygonize_face_count": len(this_faces),
|
||
"faces_selected_by_make_valid": sum(
|
||
int(x["selected_by_make_valid"]) for x in this_faces
|
||
),
|
||
"faces_odd_even_inside": sum(
|
||
int(x["odd_even_inside"]) for x in this_faces
|
||
),
|
||
"faces_nonzero_inside": sum(
|
||
int(x["nonzero_inside"]) for x in this_faces
|
||
),
|
||
"fill_rule_class": fill_class,
|
||
"final_semantic_class": final_class,
|
||
}
|
||
classification_rows.append(c)
|
||
|
||
r["_invalid_point"] = invalid_point
|
||
r["_mv_polygon_union"] = mv_poly_union
|
||
r["_mv_line_union"] = mv_line_union
|
||
r["_remainder_class"] = rem_class
|
||
r["_fill_rule_class"] = fill_class
|
||
r["_final_semantic_class"] = final_class
|
||
|
||
print(
|
||
f" [{idx:02d}/{len(records)}] {r['kind']} id={rid} "
|
||
f"{r.get('object_class')} -> "
|
||
f"{rem_class} / {fill_class}"
|
||
)
|
||
|
||
print("[3/5] 汇总 ...")
|
||
remainder_counts = Counter(r["remainder_class"] for r in classification_rows)
|
||
fill_counts = Counter(r["fill_rule_class"] for r in classification_rows)
|
||
semantic_counts = Counter(r["final_semantic_class"] for r in classification_rows)
|
||
segment_counts = Counter(r["segment_class"] for r in remainder_evidence)
|
||
|
||
edge_ctx_counts = Counter(
|
||
(
|
||
"CELL_EDGE" if r["invalid_point_on_cell_edge"] else "INTERNAL",
|
||
r["final_semantic_class"],
|
||
)
|
||
for r in classification_rows
|
||
)
|
||
object_counts = Counter(
|
||
(r["object_class"], r["final_semantic_class"])
|
||
for r in classification_rows
|
||
)
|
||
|
||
report["summary"] = {
|
||
"invalid_total": len(classification_rows),
|
||
"remainder_class_counts": dict(remainder_counts),
|
||
"fill_rule_class_counts": dict(fill_counts),
|
||
"final_semantic_class_counts": dict(semantic_counts),
|
||
"remainder_segment_class_counts": dict(segment_counts),
|
||
"linear_remainder_record_count": sum(
|
||
r["remainder_class"] != "NO_LINEAR_REMAINDER"
|
||
for r in classification_rows
|
||
),
|
||
"retrace_confirmed_record_count": sum(
|
||
r["remainder_class"] == "RETRACED_CONNECTOR_CONFIRMED"
|
||
for r in classification_rows
|
||
),
|
||
"fill_rule_resolved_count": sum(
|
||
r["fill_rule_class"] != "MATCHES_NEITHER"
|
||
for r in classification_rows
|
||
),
|
||
"fill_rule_odd_even_compatible_count": sum(
|
||
r["fill_rule_class"] in {
|
||
"MATCHES_BOTH_ODD_EVEN_AND_NONZERO",
|
||
"MATCHES_ODD_EVEN_ONLY",
|
||
}
|
||
for r in classification_rows
|
||
),
|
||
"fill_rule_nonzero_compatible_count": sum(
|
||
r["fill_rule_class"] in {
|
||
"MATCHES_BOTH_ODD_EVEN_AND_NONZERO",
|
||
"MATCHES_NONZERO_ONLY",
|
||
}
|
||
for r in classification_rows
|
||
),
|
||
}
|
||
|
||
print("[4/5] 写文件 ...")
|
||
write_csv(
|
||
outdir / "invalid_polygon_fillrule_classification.csv",
|
||
classification_rows
|
||
)
|
||
write_csv(
|
||
outdir / "linear_remainder_segment_evidence.csv",
|
||
remainder_evidence
|
||
)
|
||
write_csv(
|
||
outdir / "polygon_face_fillrule_evidence.csv",
|
||
face_evidence
|
||
)
|
||
|
||
write_csv(
|
||
outdir / "retraced_connector_summary.csv",
|
||
[
|
||
{"remainder_class": k, "count": v}
|
||
for k, v in remainder_counts.most_common()
|
||
],
|
||
["remainder_class", "count"]
|
||
)
|
||
write_csv(
|
||
outdir / "fillrule_by_object_class.csv",
|
||
[
|
||
{
|
||
"object_class": obj,
|
||
"final_semantic_class": cls,
|
||
"count": n,
|
||
}
|
||
for (obj, cls), n in object_counts.most_common()
|
||
]
|
||
)
|
||
write_csv(
|
||
outdir / "fillrule_by_boundary_context.csv",
|
||
[
|
||
{
|
||
"boundary_context": ctx,
|
||
"final_semantic_class": cls,
|
||
"count": n,
|
||
}
|
||
for (ctx, cls), n in edge_ctx_counts.most_common()
|
||
]
|
||
)
|
||
|
||
make_geojson(
|
||
records,
|
||
outdir / "fillrule_diagnostic.geojson"
|
||
)
|
||
|
||
(outdir / "fillrule_audit_report_v8.json").write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
lines = []
|
||
lines.append("CM93 -> MySQL Invalid Polygon 线性残片与填充规则诊断 v8")
|
||
lines.append("=" * 92)
|
||
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 为局部数据集;跨未导入海域不连续不作为 FAIL。")
|
||
lines.append(" 本轮只诊断 retraced connector 与 polygon fill rule,不写数据库。")
|
||
lines.append("")
|
||
lines.append("REMAINDER RECORD CLASS")
|
||
for k, v in remainder_counts.most_common():
|
||
lines.append(f" {k:42s} {v}")
|
||
lines.append("")
|
||
lines.append("REMAINDER SEGMENT EVIDENCE")
|
||
for k, v in segment_counts.most_common():
|
||
lines.append(f" {k:42s} {v}")
|
||
lines.append("")
|
||
lines.append("FILL RULE")
|
||
for k, v in fill_counts.most_common():
|
||
lines.append(f" {k:42s} {v}")
|
||
lines.append("")
|
||
lines.append("FINAL SEMANTIC CLASS")
|
||
for k, v in semantic_counts.most_common():
|
||
lines.append(f" {k:42s} {v}")
|
||
lines.append("")
|
||
lines.append("KEY COUNTS")
|
||
lines.append(
|
||
f" linear remainder records: "
|
||
f"{report['summary']['linear_remainder_record_count']}"
|
||
)
|
||
lines.append(
|
||
f" retraced connector confirmed: "
|
||
f"{report['summary']['retrace_confirmed_record_count']}"
|
||
)
|
||
lines.append(
|
||
f" fill rule resolved: "
|
||
f"{report['summary']['fill_rule_resolved_count']}"
|
||
)
|
||
lines.append(
|
||
f" odd-even compatible records: "
|
||
f"{report['summary']['fill_rule_odd_even_compatible_count']}"
|
||
)
|
||
lines.append(
|
||
f" non-zero compatible records: "
|
||
f"{report['summary']['fill_rule_nonzero_compatible_count']}"
|
||
)
|
||
lines.append("")
|
||
lines.append("INTERPRETATION")
|
||
lines.append(
|
||
" RETRACED_CONNECTOR_CONFIRMED:make_valid 的线性残片在原始 ring "
|
||
"中由相反方向的重走 segment 解释,强烈支持“桥接/connector”而非随机坏线。"
|
||
)
|
||
lines.append(
|
||
" MATCHES_ODD_EVEN_ONLY / MATCHES_NONZERO_ONLY:"
|
||
"说明 polygonize 的全部 planar faces 不能全部填充,必须应用对应 fill rule。"
|
||
)
|
||
lines.append(
|
||
" MATCHES_NEITHER:不能仅靠常见 odd-even / non-zero 规则解释,需要继续调查。"
|
||
)
|
||
lines.append("")
|
||
lines.append("NO WRITE / NO REPAIR / NO PBF")
|
||
|
||
(outdir / "fillrule_audit_summary_v8.txt").write_text(
|
||
"\n".join(lines), encoding="utf-8"
|
||
)
|
||
|
||
conn.rollback()
|
||
|
||
print("[5/5] 压缩 ...")
|
||
zpath = zip_dir(outdir)
|
||
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()
|