Files
CM95/cm93_mysql_ogc_validity_audit_v7.py
2026-09-02 09:50:12 +08:00

1180 lines
39 KiB
Python
Raw Permalink 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 -*-
"""
CM93 -> MySQL Invalid Polygon OGC Validity 表达诊断
Version: v7.0
背景
====
前序审计已经确认:
- Dataset ID=1 是日本沿海局部 CM93 子集,不是完整 CM93;
- raw_geotype=16 / META 的 4406 条不是 geometry corruption;
- Point / Line / Sounding 已通过;
- Area 共有 52 个 Shapely invalid;
- Coverage 共有 3 个 Shapely invalid;
- V6 从 raw_feature_blob + VECTOR 原始 edge chain 按 OpenCPN 规则重建:
* 52/52 raw chain 仍然 invalid;
* 52/52 与 DB Polygon 几乎完全重合;
* 0 edge index 越界;
* 0 edge junction 断裂;
* 0 Feature 重复引用同一 edge;
* importer edge 顺序/方向/坐标转换造成 geometry 损坏的假设已基本排除。
V7 目的
=======
只做“OGC 有效面表达诊断”,不做任何数据库修复。
对 52 invalid Area + 3 invalid Coverage:
1. 调用 Shapely make_valid(),但只在内存中。
2. 分解 make_valid 输出:
- Polygon / MultiPolygon
- LineString / MultiLineString
- Point / MultiPoint
3. 对原始 invalid geometry.boundary 做:
unary_union(boundary) -> polygonize_full()
4. 统计:
- 可 polygonize 的 face 数;
- cuts / dangles / invalid rings;
- make_valid polygon parts 数;
- make_valid linear remainder 数和总长度;
- make_valid point remainder 数;
- 原始 invalid polygon area;
- make_valid polygonal area;
- polygonize faces union area;
- make_valid polygonal 与 polygonize union 的 symmetric difference;
- 原始 boundary 与 make_valid polygon boundary 的 Hausdorff distance;
- boundary symmetric-difference length;
5. 精确标记 invalid point 是否在 Cell bbox 边界。
6. 给出“诊断分类”,注意这不是自动修复建议:
- POLYGON_ONLY_CONSISTENT
make_valid 只产生 polygonal geometry,且与 polygonize faces 基本一致。
- POLYGON_ONLY_FACE_SELECTION_DIFFERS
make_valid 只产生面,但与 polygonize 全部 faces 的结果明显不同,
说明不能简单假设“所有 face 都应该填充”。
- POLYGON_PLUS_LINEAR_REMAINDER
make_valid 同时产生面 + 线,说明原始 linework 中有不能直接归入有效面内部的线段。
- POLYGON_PLUS_POINT_REMAINDER
- POLYGON_PLUS_MIXED_REMAINDER
- NO_POLYGONAL_RESULT
- MAKE_VALID_FAILED
7. 单独按:
- Area / Coverage
- Cell 边界 / Cell 内部
- Object Class
- validity reason
统计。
8. 生成 GeoJSON:
- original invalid
- invalidity point
- make_valid polygonal result
- make_valid linear remainder
- polygonize faces
9. 输出是否存在“可以统一无损转成 Polygon/MultiPolygon”的证据。
注意:即使所有记录都能变成有效面,也不能在本任务写回 DB。
非常重要
========
当前 Dataset 只覆盖日本沿海一部分,因此:
- 数据集外海域不连续不算错误;
- Cell 边界上的 self-touch / shared topology 要保留边界上下文;
- V7 不把 Cell 边界异常自动判成“坏数据”;
- V7 只回答“OGC Polygon/MultiPolygon 能否干净表达”。
禁止
====
- 不 UPDATE / INSERT / DELETE
- 不 ST_MakeValid 写回
- 不 buffer(0)
- 不替换 chart_area_geometry
- 不替换 chart_coverage
- 不修改 geometry_issue
- 不重新导入
- 不开始 PBF
依赖
====
pip install pymysql shapely
执行
====
python cm93_mysql_ogc_validity_audit_v7.py ^
--host 127.0.0.1 ^
--port 3306 ^
--user root ^
--database cm93_chart ^
--dataset-id 1
输出
====
- ogc_validity_summary_v7.txt
- ogc_validity_report_v7.json
- invalid_polygon_ogc_diagnostic.csv
- make_valid_component_detail.csv
- polygonize_face_detail.csv
- ogc_diagnostic_by_class.csv
- ogc_diagnostic_by_object_class.csv
- ogc_diagnostic_by_cell.csv
- ogc_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, 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 fetch_all(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
cur.execute(sql, params)
return list(cur.fetchall())
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, tuple, list)):
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
ds = {
"WEST": abs(x - minx),
"EAST": abs(x - maxx),
"SOUTH": abs(y - miny),
"NORTH": abs(y - maxy),
}
edge, dist = min(ds.items(), key=lambda kv: kv[1])
return int(dist <= tol), edge if dist <= tol else "", dist
def validity_reason_class(reason: str) -> str:
s = (reason or "").lower()
if "ring self-intersection" in s:
return "RING_SELF_INTERSECTION"
if "self-intersection" in s:
return "SELF_INTERSECTION"
if "interior is disconnected" in s:
return "INTERIOR_DISCONNECTED"
if "hole lies outside shell" in s:
return "HOLE_OUTSIDE_SHELL"
if "nested shells" in s:
return "NESTED_SHELLS"
if "nested holes" in s:
return "NESTED_HOLES"
if "too few points" in s:
return "TOO_FEW_POINTS"
return "OTHER_INVALID"
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 safe_area(g) -> Optional[float]:
if g is None:
return None
try:
return float(g.area)
except Exception:
return None
def safe_length(g) -> Optional[float]:
if g is None:
return None
try:
return float(g.length)
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_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_symdiff_length(a, b) -> Optional[float]:
if a is None or b is None:
return None
try:
return float(a.symmetric_difference(b).length)
except Exception:
return None
def count_collection_parts(g) -> int:
if g is None:
return 0
try:
if hasattr(g, "geoms"):
return len(g.geoms)
return 1
except Exception:
return 0
def diagnostic_class(
*,
polygon_count: int,
line_count: int,
point_count: int,
other_count: int,
polygonize_agrees: Optional[bool],
) -> str:
if polygon_count == 0:
return "NO_POLYGONAL_RESULT"
if line_count == 0 and point_count == 0 and other_count == 0:
if polygonize_agrees is True:
return "POLYGON_ONLY_CONSISTENT"
if polygonize_agrees is False:
return "POLYGON_ONLY_FACE_SELECTION_DIFFERS"
return "POLYGON_ONLY_UNRESOLVED_COMPARISON"
if line_count > 0 and point_count == 0 and other_count == 0:
return "POLYGON_PLUS_LINEAR_REMAINDER"
if line_count == 0 and point_count > 0 and other_count == 0:
return "POLYGON_PLUS_POINT_REMAINDER"
return "POLYGON_PLUS_MIXED_REMAINDER"
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,
f.geometry_issue,
f.geometry_repaired,
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,
v.geometry_issue,
v.geometry_repaired,
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"),
"diagnostic_class": r.get("_diagnostic_class"),
"validity_reason": r.get("_validity_reason"),
"cell_edge": r.get("_invalid_point_on_cell_edge"),
}
add(r.get("_geom"), {
**common,
"layer": "original_invalid",
})
p = r.get("_invalid_point")
if p is not None:
add(Point(p), {
**common,
"layer": "invalidity_point",
})
add(r.get("_mv_polygon_union"), {
**common,
"layer": "make_valid_polygonal",
})
add(r.get("_mv_line_union"), {
**common,
"layer": "make_valid_linear_remainder",
})
add(r.get("_polygonize_union"), {
**common,
"layer": "polygonize_faces_union",
})
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 OGC validity audit v7"
)
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(
"--cell-edge-tolerance",
type=float,
default=5e-6,
help="invalid point 距 Cell bbox 边界判定容差(degree)"
)
p.add_argument(
"--face-symdiff-abs-tolerance",
type=float,
default=1e-12,
help="make_valid polygon 与 polygonize union 面积差绝对容差(degree^2)"
)
p.add_argument(
"--face-symdiff-relative-tolerance",
type=float,
default=1e-6,
help="make_valid polygon 与 polygonize union 面积差相对容差"
)
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_ogc_validity_audit_v7_dataset_{args.dataset_id}_{stamp}"
)
outdir.mkdir(parents=True, exist_ok=True)
report = {
"version": "v7.0",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"database": args.database,
"dataset_id": args.dataset_id,
"read_only": True,
"pbf": "OUT_OF_SCOPE",
"scope_note": (
"CM93JAPAN 是日本沿海局部数据集。"
"V7 只诊断 invalid Polygon 如何映射到 OGC valid polygonal geometry,"
"不做任何数据库写回。"
),
"warnings": [],
"shapely_version": getattr(shapely, "__version__", "unknown"),
}
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" total={len(records)} "
f"Area={sum(r['kind']=='AREA' for r in records)} "
f"Coverage={sum(r['kind']=='COVERAGE' for r in records)}"
)
print("[2/5] make_valid + polygonize_full 诊断 ...")
diag_rows = []
component_rows = []
face_rows = []
for i, r in enumerate(records, start=1):
geom = r["_geom"]
reason = explain_validity(geom)
reason_class = validity_reason_class(reason)
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
)
base = {
"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"),
"validity_reason": reason,
"validity_reason_class": reason_class,
"invalid_x": invalid_point[0] if invalid_point else None,
"invalid_y": invalid_point[1] if invalid_point else None,
"invalid_point_on_cell_edge": on_edge,
"cell_edge": edge_name,
"cell_edge_distance_deg": edge_dist,
}
try:
mv = make_valid_geom(geom)
except Exception as e:
diag_rows.append({
**base,
"make_valid_type": None,
"diagnostic_class": "MAKE_VALID_FAILED",
"detail": f"{type(e).__name__}: {e}",
})
continue
comps = flatten_components(mv)
mv_poly_union = geom_union(comps["polygons"])
mv_line_union = geom_union(comps["lines"])
mv_point_union = geom_union(comps["points"])
for idx, p in enumerate(comps["polygons"]):
component_rows.append({
**base,
"component_dimension": "POLYGON",
"component_index": idx,
"geom_type": p.geom_type,
"area_deg2": safe_area(p),
"length_deg": safe_length(p.boundary),
})
for idx, l in enumerate(comps["lines"]):
component_rows.append({
**base,
"component_dimension": "LINE",
"component_index": idx,
"geom_type": l.geom_type,
"area_deg2": 0.0,
"length_deg": safe_length(l),
})
for idx, p in enumerate(comps["points"]):
component_rows.append({
**base,
"component_dimension": "POINT",
"component_index": idx,
"geom_type": p.geom_type,
"area_deg2": 0.0,
"length_deg": 0.0,
})
for idx, o in enumerate(comps["others"]):
component_rows.append({
**base,
"component_dimension": "OTHER",
"component_index": idx,
"geom_type": getattr(o, "geom_type", type(o).__name__),
"area_deg2": safe_area(o),
"length_deg": safe_length(o),
})
# polygonize_full on noded linework
polygonize_error = ""
polygonize_union = None
polygons_geom = cuts = dangles = invalid_rings = None
poly_faces: List[Any] = []
try:
noded_boundary = unary_union(geom.boundary)
polygons_geom, cuts, dangles, invalid_rings = polygonize_full(
noded_boundary
)
pcomps = flatten_components(polygons_geom)
poly_faces = pcomps["polygons"]
polygonize_union = geom_union(poly_faces)
except Exception as e:
polygonize_error = f"{type(e).__name__}: {e}"
for idx, face in enumerate(poly_faces):
face_rows.append({
**base,
"face_index": idx,
"area_deg2": safe_area(face),
"boundary_length_deg": safe_length(face.boundary),
})
orig_area = safe_area(geom)
mv_poly_area = safe_area(mv_poly_union)
polyz_area = safe_area(polygonize_union)
mv_vs_polyz_symarea = safe_symdiff_area(
mv_poly_union, polygonize_union
)
if (
mv_vs_polyz_symarea is not None
and mv_poly_area is not None
and polyz_area is not None
):
scale = max(abs(mv_poly_area), abs(polyz_area), 1e-30)
allowed = max(
args.face_symdiff_abs_tolerance,
args.face_symdiff_relative_tolerance * scale
)
polygonize_agrees = mv_vs_polyz_symarea <= allowed
else:
allowed = None
polygonize_agrees = None
cls = diagnostic_class(
polygon_count=len(comps["polygons"]),
line_count=len(comps["lines"]),
point_count=len(comps["points"]),
other_count=len(comps["others"]),
polygonize_agrees=polygonize_agrees,
)
original_boundary = None
mv_boundary = None
try:
original_boundary = unary_union(geom.boundary)
except Exception:
pass
try:
if mv_poly_union is not None:
mv_boundary = unary_union(mv_poly_union.boundary)
except Exception:
pass
boundary_haus = safe_hausdorff(
original_boundary, mv_boundary
)
boundary_sym_len = safe_symdiff_length(
original_boundary, mv_boundary
)
line_remainder_len = sum(
safe_length(x) or 0.0 for x in comps["lines"]
)
point_remainder_count = len(comps["points"])
diag = {
**base,
"original_geom_type": geom.geom_type,
"original_area_deg2": orig_area,
"make_valid_type": mv.geom_type,
"make_valid_is_valid": int(bool(mv.is_valid)),
"make_valid_polygon_count": len(comps["polygons"]),
"make_valid_line_count": len(comps["lines"]),
"make_valid_point_count": len(comps["points"]),
"make_valid_other_count": len(comps["others"]),
"make_valid_polygon_area_deg2": mv_poly_area,
"make_valid_linear_remainder_length_deg": line_remainder_len,
"make_valid_point_remainder_count": point_remainder_count,
"polygonize_face_count": len(poly_faces),
"polygonize_union_area_deg2": polyz_area,
"polygonize_cuts_length_deg": safe_length(cuts),
"polygonize_dangles_length_deg": safe_length(dangles),
"polygonize_invalid_rings_length_deg": safe_length(invalid_rings),
"polygonize_error": polygonize_error,
"make_valid_vs_polygonize_symdiff_area_deg2":
mv_vs_polyz_symarea,
"face_agreement_allowed_area_deg2": allowed,
"make_valid_polygonize_agree": (
int(polygonize_agrees)
if polygonize_agrees is not None else None
),
"original_vs_make_valid_boundary_hausdorff_deg":
boundary_haus,
"original_vs_make_valid_boundary_symdiff_length_deg":
boundary_sym_len,
"diagnostic_class": cls,
}
diag_rows.append(diag)
r["_validity_reason"] = reason
r["_invalid_point"] = invalid_point
r["_invalid_point_on_cell_edge"] = on_edge
r["_mv_polygon_union"] = mv_poly_union
r["_mv_line_union"] = mv_line_union
r["_mv_point_union"] = mv_point_union
r["_polygonize_union"] = polygonize_union
r["_diagnostic_class"] = cls
rid = (
r.get("feature_id")
if r["kind"] == "AREA"
else r.get("coverage_geom_id")
)
print(
f" [{i:02d}/{len(records)}] "
f"{r['kind']} id={rid} {r.get('object_class')} "
f"{reason_class} -> {cls} "
f"poly={len(comps['polygons'])} "
f"line={len(comps['lines'])}"
)
print("[3/5] 汇总分类 ...")
class_counts = Counter(
r.get("diagnostic_class", "UNKNOWN") for r in diag_rows
)
kind_class_counts = Counter(
(r.get("kind"), r.get("diagnostic_class"))
for r in diag_rows
)
edge_class_counts = Counter(
(
"CELL_EDGE" if r.get("invalid_point_on_cell_edge") else "INTERNAL",
r.get("diagnostic_class"),
)
for r in diag_rows
)
object_counts = Counter(
(r.get("object_class"), r.get("diagnostic_class"))
for r in diag_rows
)
cell_counts = Counter(
(r.get("cell_name"), r.get("diagnostic_class"))
for r in diag_rows
)
report["summary"] = {
"invalid_total": len(diag_rows),
"invalid_area_count": sum(
r.get("kind") == "AREA" for r in diag_rows
),
"invalid_coverage_count": sum(
r.get("kind") == "COVERAGE" for r in diag_rows
),
"diagnostic_class_counts": dict(class_counts),
"cell_edge_count": sum(
int(r.get("invalid_point_on_cell_edge") or 0)
for r in diag_rows
),
"internal_count": sum(
1 for r in diag_rows
if not r.get("invalid_point_on_cell_edge")
),
"make_valid_with_linear_remainder": sum(
int(r.get("make_valid_line_count") or 0) > 0
for r in diag_rows
),
"make_valid_polygon_only": sum(
int(r.get("make_valid_polygon_count") or 0) > 0
and int(r.get("make_valid_line_count") or 0) == 0
and int(r.get("make_valid_point_count") or 0) == 0
and int(r.get("make_valid_other_count") or 0) == 0
for r in diag_rows
),
"make_valid_vs_polygonize_agree": sum(
int(r.get("make_valid_polygonize_agree") or 0)
for r in diag_rows
),
"records_with_polygonize_dangles": sum(
(r.get("polygonize_dangles_length_deg") or 0.0) > 0
for r in diag_rows
),
"records_with_polygonize_cuts": sum(
(r.get("polygonize_cuts_length_deg") or 0.0) > 0
for r in diag_rows
),
}
by_class_rows = []
for (kind, cls), n in kind_class_counts.most_common():
by_class_rows.append({
"group": "KIND",
"key": kind,
"diagnostic_class": cls,
"count": n,
})
for (ctx, cls), n in edge_class_counts.most_common():
by_class_rows.append({
"group": "BOUNDARY_CONTEXT",
"key": ctx,
"diagnostic_class": cls,
"count": n,
})
obj_rows = [
{
"object_class": obj,
"diagnostic_class": cls,
"count": n,
}
for (obj, cls), n in object_counts.most_common()
]
cell_rows = [
{
"cell_name": cell,
"diagnostic_class": cls,
"count": n,
}
for (cell, cls), n in cell_counts.most_common()
]
print("[4/5] 写输出文件 ...")
write_csv(
outdir / "invalid_polygon_ogc_diagnostic.csv",
diag_rows
)
write_csv(
outdir / "make_valid_component_detail.csv",
component_rows
)
write_csv(
outdir / "polygonize_face_detail.csv",
face_rows
)
write_csv(
outdir / "ogc_diagnostic_by_class.csv",
by_class_rows
)
write_csv(
outdir / "ogc_diagnostic_by_object_class.csv",
obj_rows
)
write_csv(
outdir / "ogc_diagnostic_by_cell.csv",
cell_rows
)
make_geojson(
[r for r in records if "_diagnostic_class" in r],
outdir / "ogc_diagnostic.geojson"
)
(outdir / "ogc_validity_report_v7.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str),
encoding="utf-8"
)
lines = []
lines.append("CM93 -> MySQL Invalid Polygon OGC Validity 表达诊断 v7")
lines.append("=" * 90)
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 是局部数据集;Cell/Dataset 外边界不连续不作为错误。"
)
lines.append(
" 本轮仅在内存中运行 make_valid / polygonize_full 作为诊断,"
"绝不写回数据库。"
)
lines.append("")
lines.append("BASELINE")
lines.append(
f" invalid total: {report['summary']['invalid_total']}"
)
lines.append(
f" Area: {report['summary']['invalid_area_count']}"
)
lines.append(
f" Coverage: {report['summary']['invalid_coverage_count']}"
)
lines.append(
f" invalid point Cell edge: {report['summary']['cell_edge_count']}"
)
lines.append(
f" invalid point internal: {report['summary']['internal_count']}"
)
lines.append("")
lines.append("DIAGNOSTIC CLASS")
for k, v in class_counts.most_common():
lines.append(f" {k:46s} {v}")
lines.append("")
lines.append("MAKE_VALID COMPOSITION")
lines.append(
f" polygon-only records: "
f"{report['summary']['make_valid_polygon_only']}"
)
lines.append(
f" records with linear remainder: "
f"{report['summary']['make_valid_with_linear_remainder']}"
)
lines.append("")
lines.append("POLYGONIZE")
lines.append(
f" make_valid polygon agrees with polygonize faces: "
f"{report['summary']['make_valid_vs_polygonize_agree']}"
)
lines.append(
f" records with polygonize dangles: "
f"{report['summary']['records_with_polygonize_dangles']}"
)
lines.append(
f" records with polygonize cuts: "
f"{report['summary']['records_with_polygonize_cuts']}"
)
lines.append("")
lines.append("HOW TO READ")
lines.append(
" POLYGON_ONLY_CONSISTENT:存在比较干净的 OGC Polygon/MultiPolygon 表达候选,"
"但本轮仍不允许写回。"
)
lines.append(
" POLYGON_PLUS_LINEAR_REMAINDER:make_valid 为了得到有效面,"
"还必须留下线性几何;这说明不能粗暴把结果只取 Polygon 后当成无损修复。"
)
lines.append(
" POLYGON_ONLY_FACE_SELECTION_DIFFERS:make_valid 与把所有 noded faces "
"全部 polygonize 的结果不同,说明 fill/face 选择存在语义问题。"
)
lines.append(
" 当前目标只是确定表达性质,不决定 importer 是否要规范化。"
)
lines.append("")
lines.append("NO WRITE / NO REPAIR / NO PBF")
(outdir / "ogc_validity_summary_v7.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()