1180 lines
39 KiB
Python
1180 lines
39 KiB
Python
#!/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()
|