1462 lines
49 KiB
Python
1462 lines
49 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CM93 -> MySQL invalid Area 原始 Edge Chain 重建与 Importer 对账
|
||
Version: v6.0
|
||
|
||
背景
|
||
====
|
||
Dataset ID=1 为日本沿海局部 CM93 数据,并非完整 CM93。
|
||
V3/V4/V5 已确认:
|
||
- 4406 条 raw_geotype=16 / META 是正常 parent-no-geometry,不是真 geometry 损坏;
|
||
- Point / Line / Sounding 均通过;
|
||
- Area 81080 中仅 52 个 Shapely invalid;
|
||
- Coverage 1156 中仅 3 个 invalid;
|
||
- 52 个 invalid Area 中 50 个的 invalid point 还被至少一个“有效 Area”边界共享;
|
||
- chart_feature.raw_feature_blob 已逐 Feature 保存原始(已解码)Feature record;
|
||
- chart_raw_record 中保存每个 Cell 的 HEADER / VECTOR 等整表 blob。
|
||
|
||
本脚本目的
|
||
==========
|
||
只针对 invalid Area:
|
||
1. 从 chart_feature.raw_feature_blob 解码 CM93 Area 的 edge references:
|
||
- object_type
|
||
- geotype
|
||
- obj_desc_bytes
|
||
- n_elements
|
||
- 每个 geometry word
|
||
- edge_index = word & 0x1fff
|
||
- segment_usage = word >> 13
|
||
- usage bit 4 表示该 edge 反向使用
|
||
2. 从 chart_raw_record(record_kind='VECTOR') 解析该 Cell 的全部原始 edge:
|
||
- ushort npoints
|
||
- npoints * (ushort x, ushort y)
|
||
3. 按 OpenCPN CM93 BuildGeom 的规则重建 ring:
|
||
- 根据 segment_usage 选择正向/反向;
|
||
- 每段拼接时跳过前一段共享的最后点;
|
||
- 当前 edge 终点回到 ring 起点时结束一个 ring。
|
||
4. 检查:
|
||
- Feature edge index 是否越界;
|
||
- 相邻 edge 是否连续;
|
||
- ring 是否闭合;
|
||
- 同一 edge 是否重复使用;
|
||
- ring 是否有非连续重复顶点;
|
||
- raw ring 在原始 0..65535 坐标空间是否自交/无效;
|
||
- raw invalidity 与数据库 WGS84 Polygon invalidity 是否拓扑一致。
|
||
5. 使用 HEADER 中 easting/northing transform + chart_coverage.wgsox/wgsoy,
|
||
将 raw CM93 点转换到 WGS84,重建参考 Polygon。
|
||
6. 将“参考重建 Polygon”与数据库 chart_area_geometry.geom 对账:
|
||
- geometry type
|
||
- validity
|
||
- validity reason
|
||
- ring 数
|
||
- Hausdorff distance
|
||
- symmetric difference area(仅辅助,不作为面积物理量)
|
||
7. 对 52 条给出表现分类:
|
||
A. RAW_EDGE_CHAIN_ITSELF_INVALID
|
||
原始 edge chain 按 OpenCPN 规则组出来已经 invalid;
|
||
更接近 CM93 拓扑表达 / 数据本身 / OGC Polygon 表达差异。
|
||
B. RAW_CHAIN_VALID_DB_INVALID
|
||
原始 edge chain 重建有效,但 DB Polygon 无效;
|
||
强烈指向 importer geometry assembly / conversion bug。
|
||
C. EDGE_CHAIN_DISCONTINUITY
|
||
Feature 引用的相邻 edge 不连续或 ring 不闭合;
|
||
需查 edge ordering / source data。
|
||
D. RECONSTRUCTION_MISMATCH
|
||
参考重建与 DB geometry 明显不同;
|
||
指向 importer transform / ring assembly / coverage offset 差异。
|
||
E. RECONSTRUCTION_MATCHES_DB
|
||
参考重建和 DB topology/shape 基本一致。
|
||
8. 输出最值得进一步人工看的孤立 Feature(V5 中无共享邻居的 2 条)详细 edge chain。
|
||
9. 全程只读;不 make_valid、不 buffer(0)、不修改 DB、不做 PBF。
|
||
|
||
参考
|
||
====
|
||
算法按 OpenCPN 当前 cm93.cpp 的公开实现复核:
|
||
- read_vector_record_table()
|
||
- read_feature_record_table()
|
||
- cm93chart::BuildGeom()
|
||
|
||
注意:
|
||
本脚本是“对账/取证工具”,不是修复器。
|
||
即使 raw chain 本身 invalid,也不能直接断言 CM93 原始数据“坏了”。
|
||
CM93 的共享拓扑表达转换为 OGC Polygon 后,可能出现 self-touch 等 validity 差异。
|
||
|
||
依赖
|
||
====
|
||
pip install pymysql shapely
|
||
|
||
执行
|
||
====
|
||
python cm93_mysql_raw_edge_audit_v6.py ^
|
||
--host 127.0.0.1 ^
|
||
--port 3306 ^
|
||
--user root ^
|
||
--database cm93_chart ^
|
||
--dataset-id 1
|
||
|
||
输出
|
||
====
|
||
自动生成目录和 ZIP:
|
||
- raw_edge_audit_summary_v6.txt
|
||
- raw_edge_audit_report_v6.json
|
||
- invalid_area_raw_edge_classification.csv
|
||
- invalid_area_edge_references.csv
|
||
- invalid_area_ring_summary.csv
|
||
- invalid_area_edge_junction_issues.csv
|
||
- raw_vs_db_geometry_compare.csv
|
||
- isolated_invalid_area_edge_detail.csv
|
||
- affected_cell_vector_table_audit.csv
|
||
- invalid_area_raw_reconstruction.geojson
|
||
|
||
安全
|
||
====
|
||
只执行 SELECT / read-only transaction。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import json
|
||
import math
|
||
import os
|
||
import struct
|
||
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:
|
||
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)
|
||
|
||
from shapely.geometry import (
|
||
Point, LineString, LinearRing, Polygon, MultiPolygon, mapping
|
||
)
|
||
from shapely.validation import explain_validity
|
||
except ImportError:
|
||
print("缺少 shapely:pip install pymysql shapely")
|
||
sys.exit(2)
|
||
|
||
|
||
CM93_SEMIMAJOR_AXIS_METERS = 6378388.0
|
||
DEGREE = math.pi / 180.0
|
||
|
||
|
||
def qi(name: str) -> str:
|
||
return "`" + name.replace("`", "``") + "`"
|
||
|
||
|
||
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)
|
||
elif isinstance(v, (bytes, bytearray, memoryview)):
|
||
v = bytes(v).hex()
|
||
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 函数")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CM93 raw structures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def u16le(buf: bytes, offset: int) -> int:
|
||
if offset + 2 > len(buf):
|
||
raise ValueError(f"ushort 越界 offset={offset}, len={len(buf)}")
|
||
return struct.unpack_from("<H", buf, offset)[0]
|
||
|
||
|
||
def parse_area_feature_blob(raw: bytes) -> Dict[str, Any]:
|
||
"""
|
||
OpenCPN read_feature_record_table() 对 Area 的前部格式:
|
||
uint8 object_type
|
||
uint8 geom_prim
|
||
uint16 obj_desc_bytes
|
||
uint16 n_elements
|
||
uint16 edge_ref[n_elements]
|
||
... related/attrs follow according to geotype flags
|
||
|
||
edge_ref:
|
||
edge_index = edge_ref & 0x1fff
|
||
segment_usage = edge_ref >> 13
|
||
reversed when segment_usage & 4
|
||
"""
|
||
if raw is None or len(raw) < 6:
|
||
raise ValueError("raw_feature_blob 太短")
|
||
|
||
object_type = raw[0]
|
||
geotype = raw[1]
|
||
obj_desc_bytes = u16le(raw, 2)
|
||
primitive = geotype & 0x0F
|
||
if primitive != 4:
|
||
raise ValueError(
|
||
f"不是 Area primitive: geotype={geotype}, low_nibble={primitive}"
|
||
)
|
||
|
||
n_elements = u16le(raw, 4)
|
||
need = 6 + n_elements * 2
|
||
if len(raw) < need:
|
||
raise ValueError(
|
||
f"raw_feature_blob edge refs 不完整: "
|
||
f"n_elements={n_elements}, need={need}, len={len(raw)}"
|
||
)
|
||
|
||
refs = []
|
||
pos = 6
|
||
for i in range(n_elements):
|
||
word = u16le(raw, pos)
|
||
pos += 2
|
||
usage = word >> 13
|
||
edge_index = word & 0x1FFF
|
||
refs.append({
|
||
"sequence": i,
|
||
"raw_word": word,
|
||
"raw_word_hex": f"0x{word:04x}",
|
||
"edge_index": edge_index,
|
||
"segment_usage": usage,
|
||
"reverse": int(bool(usage & 4)),
|
||
"usage_bit0": int(bool(usage & 1)),
|
||
"usage_bit1": int(bool(usage & 2)),
|
||
"usage_bit2_reverse": int(bool(usage & 4)),
|
||
})
|
||
|
||
return {
|
||
"object_type": object_type,
|
||
"geotype": geotype,
|
||
"obj_desc_bytes": obj_desc_bytes,
|
||
"primitive": primitive,
|
||
"has_children_related": int(bool(geotype & 0x10)),
|
||
"has_related_count_word": int(bool(geotype & 0x20)),
|
||
"has_bit_0x40": int(bool(geotype & 0x40)),
|
||
"has_attributes": int(bool(geotype & 0x80)),
|
||
"n_elements": n_elements,
|
||
"geometry_refs_end_offset": pos,
|
||
"raw_length": len(raw),
|
||
"refs": refs,
|
||
}
|
||
|
||
|
||
def parse_vector_table(raw: bytes) -> List[List[Tuple[int, int]]]:
|
||
"""
|
||
OpenCPN read_vector_record_table():
|
||
for each edge:
|
||
uint16 npoints
|
||
repeat npoints:
|
||
uint16 x
|
||
uint16 y
|
||
"""
|
||
if raw is None:
|
||
raise ValueError("VECTOR raw_blob is NULL")
|
||
|
||
edges: List[List[Tuple[int, int]]] = []
|
||
pos = 0
|
||
while pos < len(raw):
|
||
if pos + 2 > len(raw):
|
||
raise ValueError(
|
||
f"VECTOR 尾部不足 2 bytes: pos={pos}, len={len(raw)}"
|
||
)
|
||
npoints = u16le(raw, pos)
|
||
pos += 2
|
||
|
||
need = npoints * 4
|
||
if pos + need > len(raw):
|
||
raise ValueError(
|
||
f"VECTOR edge#{len(edges)} 数据越界: "
|
||
f"npoints={npoints}, pos={pos}, len={len(raw)}"
|
||
)
|
||
|
||
pts = []
|
||
for _ in range(npoints):
|
||
x = u16le(raw, pos)
|
||
y = u16le(raw, pos + 2)
|
||
pos += 4
|
||
pts.append((x, y))
|
||
|
||
edges.append(pts)
|
||
|
||
if pos != len(raw):
|
||
raise ValueError(f"VECTOR 未精确消费: pos={pos}, len={len(raw)}")
|
||
return edges
|
||
|
||
|
||
def parse_header_metadata(raw_metadata_json: Any) -> Dict[str, Any]:
|
||
if raw_metadata_json is None:
|
||
raise ValueError("HEADER raw_metadata_json is NULL")
|
||
if isinstance(raw_metadata_json, (bytes, bytearray, memoryview)):
|
||
s = bytes(raw_metadata_json).decode("utf-8", errors="replace")
|
||
obj = json.loads(s)
|
||
elif isinstance(raw_metadata_json, str):
|
||
obj = json.loads(raw_metadata_json)
|
||
elif isinstance(raw_metadata_json, dict):
|
||
obj = raw_metadata_json
|
||
else:
|
||
obj = json.loads(str(raw_metadata_json))
|
||
|
||
h = obj.get("header", obj)
|
||
required = [
|
||
"easting_min", "easting_max",
|
||
"northing_min", "northing_max",
|
||
"usn_vector_records"
|
||
]
|
||
missing = [k for k in required if k not in h]
|
||
if missing:
|
||
raise ValueError(f"HEADER metadata 缺字段: {missing}")
|
||
return h
|
||
|
||
|
||
def transform_cm93_point_to_wgs84(
|
||
p: Tuple[int, int],
|
||
header: Dict[str, Any],
|
||
wgsox: float = 0.0,
|
||
wgsoy: float = 0.0,
|
||
) -> Tuple[float, float]:
|
||
"""
|
||
对齐 OpenCPN:
|
||
transform_x_rate = delta_x / 65535
|
||
transform_y_rate = delta_y / 65535
|
||
valx = x * rate + origin
|
||
valy = y * rate + origin
|
||
valx -= wgsox
|
||
valy -= wgsoy
|
||
lat = inverse mercator
|
||
lon = valx / (DEGREE * CM93_semimajor_axis_meters)
|
||
|
||
返回 (lon, lat)
|
||
"""
|
||
x, y = p
|
||
|
||
e_min = float(header["easting_min"])
|
||
e_max = float(header["easting_max"])
|
||
n_min = float(header["northing_min"])
|
||
n_max = float(header["northing_max"])
|
||
|
||
dx = e_max - e_min
|
||
if dx < 0:
|
||
dx += CM93_SEMIMAJOR_AXIS_METERS * 2.0 * math.pi
|
||
|
||
x_rate = dx / 65535.0
|
||
y_rate = (n_max - n_min) / 65535.0
|
||
|
||
valx = x * x_rate + e_min
|
||
valy = y * y_rate + n_min
|
||
|
||
valx -= float(wgsox or 0.0)
|
||
valy -= float(wgsoy or 0.0)
|
||
|
||
lat = (
|
||
2.0 * math.atan(math.exp(valy / CM93_SEMIMAJOR_AXIS_METERS))
|
||
- math.pi / 2.0
|
||
) / DEGREE
|
||
lon = valx / (DEGREE * CM93_SEMIMAJOR_AXIS_METERS)
|
||
return lon, lat
|
||
|
||
|
||
def orient_edge(points: List[Tuple[int, int]], reverse: bool) -> List[Tuple[int, int]]:
|
||
return list(reversed(points)) if reverse else list(points)
|
||
|
||
|
||
def build_rings_like_opencpn(
|
||
refs: List[Dict[str, Any]],
|
||
edges: List[List[Tuple[int, int]]],
|
||
) -> Tuple[List[List[Tuple[int, int]]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||
"""
|
||
按 OpenCPN BuildGeom Area 的 ring 判定方式重建。
|
||
|
||
OpenCPN 对每个 segment 把全部点写入,然后 ip-- 跳掉该段最后点;
|
||
当 cur_end_point == start_point 时,把 closure point 留下并结束 ring。
|
||
|
||
这里用更直观的列表实现,但保留相同语义。
|
||
"""
|
||
rings: List[List[Tuple[int, int]]] = []
|
||
edge_rows: List[Dict[str, Any]] = []
|
||
junction_issues: List[Dict[str, Any]] = []
|
||
|
||
current: List[Tuple[int, int]] = []
|
||
ring_start: Optional[Tuple[int, int]] = None
|
||
previous_end: Optional[Tuple[int, int]] = None
|
||
ring_index = 0
|
||
|
||
for ref in refs:
|
||
ei = int(ref["edge_index"])
|
||
if ei < 0 or ei >= len(edges):
|
||
edge_rows.append({
|
||
**ref,
|
||
"edge_exists": 0,
|
||
"edge_point_count": None,
|
||
"ring_index": ring_index,
|
||
})
|
||
junction_issues.append({
|
||
"ring_index": ring_index,
|
||
"sequence": ref["sequence"],
|
||
"issue_category": "EDGE_INDEX_OUT_OF_RANGE",
|
||
"detail": f"edge_index={ei}, vector_edge_count={len(edges)}",
|
||
})
|
||
continue
|
||
|
||
raw_pts = edges[ei]
|
||
pts = orient_edge(raw_pts, bool(ref["reverse"]))
|
||
if not pts:
|
||
edge_rows.append({
|
||
**ref,
|
||
"edge_exists": 1,
|
||
"edge_point_count": 0,
|
||
"ring_index": ring_index,
|
||
})
|
||
junction_issues.append({
|
||
"ring_index": ring_index,
|
||
"sequence": ref["sequence"],
|
||
"issue_category": "EMPTY_EDGE",
|
||
"detail": f"edge_index={ei}",
|
||
})
|
||
continue
|
||
|
||
start = pts[0]
|
||
end = pts[-1]
|
||
|
||
if ring_start is None:
|
||
ring_start = start
|
||
previous_end = None
|
||
|
||
continuous_from_previous = (
|
||
previous_end is None or previous_end == start
|
||
)
|
||
|
||
if previous_end is not None and previous_end != start:
|
||
junction_issues.append({
|
||
"ring_index": ring_index,
|
||
"sequence": ref["sequence"],
|
||
"issue_category": "EDGE_JUNCTION_DISCONTINUITY",
|
||
"detail": (
|
||
f"previous_end={previous_end}, current_start={start}, "
|
||
f"edge_index={ei}"
|
||
),
|
||
"previous_end_x": previous_end[0],
|
||
"previous_end_y": previous_end[1],
|
||
"current_start_x": start[0],
|
||
"current_start_y": start[1],
|
||
})
|
||
|
||
# OpenCPN 在 segment 之间跳过共享 end point。
|
||
# 若连续,当前 ring 已包含 previous_end;所以追加本 edge 除首点外的点。
|
||
# 第一段则全部加入。
|
||
if not current:
|
||
current.extend(pts)
|
||
else:
|
||
if current[-1] == pts[0]:
|
||
current.extend(pts[1:])
|
||
else:
|
||
# 为保持 source 顺序证据,不自动补连;直接追加完整 edge。
|
||
current.extend(pts)
|
||
|
||
closed_here = (end == ring_start)
|
||
|
||
edge_rows.append({
|
||
**ref,
|
||
"edge_exists": 1,
|
||
"edge_point_count": len(raw_pts),
|
||
"oriented_start_x": start[0],
|
||
"oriented_start_y": start[1],
|
||
"oriented_end_x": end[0],
|
||
"oriented_end_y": end[1],
|
||
"continuous_from_previous": int(continuous_from_previous),
|
||
"ring_index": ring_index,
|
||
"closes_ring": int(closed_here),
|
||
})
|
||
|
||
previous_end = end
|
||
|
||
if closed_here:
|
||
if current and current[0] != current[-1]:
|
||
current.append(current[0])
|
||
rings.append(current)
|
||
current = []
|
||
ring_start = None
|
||
previous_end = None
|
||
ring_index += 1
|
||
|
||
if current:
|
||
junction_issues.append({
|
||
"ring_index": ring_index,
|
||
"sequence": None,
|
||
"issue_category": "RING_NOT_CLOSED_AT_END_OF_REFS",
|
||
"detail": (
|
||
f"unclosed ring vertices={len(current)}, "
|
||
f"start={ring_start}, end={current[-1] if current else None}"
|
||
),
|
||
})
|
||
rings.append(current)
|
||
|
||
return rings, edge_rows, junction_issues
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Geometry construction and comparison
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def repeated_nonconsecutive_vertices(
|
||
ring: List[Tuple[int, int]]
|
||
) -> List[Tuple[int, int]]:
|
||
if not ring:
|
||
return []
|
||
core = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring
|
||
positions = defaultdict(list)
|
||
for i, p in enumerate(core):
|
||
positions[p].append(i)
|
||
|
||
out = []
|
||
n = len(core)
|
||
for p, pos in positions.items():
|
||
if len(pos) < 2:
|
||
continue
|
||
nonconsecutive = False
|
||
for i in range(len(pos)):
|
||
for j in range(i + 1, len(pos)):
|
||
d = abs(pos[j] - pos[i])
|
||
if d > 1 and d < n - 1:
|
||
nonconsecutive = True
|
||
break
|
||
if nonconsecutive:
|
||
break
|
||
if nonconsecutive:
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def signed_area(coords: List[Tuple[float, float]]) -> float:
|
||
if len(coords) < 3:
|
||
return 0.0
|
||
s = 0.0
|
||
for i in range(len(coords) - 1):
|
||
x1, y1 = coords[i]
|
||
x2, y2 = coords[i + 1]
|
||
s += x1 * y2 - x2 * y1
|
||
return s / 2.0
|
||
|
||
|
||
def ring_to_polygon_candidate(
|
||
rings: List[List[Tuple[float, float]]]
|
||
):
|
||
"""
|
||
为“对账”构造 Polygon/MultiPolygon。
|
||
不尝试 make_valid。
|
||
|
||
CM93/OpenCPN BuildGeom 只记录 contour 顺序,不在这里做复杂 shell/hole 修复。
|
||
优先策略:
|
||
- 1 ring -> Polygon(shell)
|
||
- 多 ring -> 先把第一个 ring 作为 shell,其余作为 holes;
|
||
若失败,再以每个 ring 独立 Polygon 组成 MultiPolygon,仅用于比较。
|
||
|
||
返回 (geom, construction_mode)
|
||
"""
|
||
clean = []
|
||
for ring in rings:
|
||
if len(ring) >= 4:
|
||
rr = list(ring)
|
||
if rr[0] != rr[-1]:
|
||
rr.append(rr[0])
|
||
clean.append(rr)
|
||
|
||
if not clean:
|
||
return None, "NO_USABLE_RING"
|
||
|
||
if len(clean) == 1:
|
||
try:
|
||
return Polygon(clean[0]), "SINGLE_RING_POLYGON"
|
||
except Exception:
|
||
return None, "SINGLE_RING_CONSTRUCTION_FAILED"
|
||
|
||
try:
|
||
return Polygon(clean[0], clean[1:]), "FIRST_SHELL_REST_HOLES"
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
polys = [Polygon(r) for r in clean]
|
||
return MultiPolygon(polys), "MULTIPOLYGON_PER_RING_FALLBACK"
|
||
except Exception:
|
||
return None, "MULTIRING_CONSTRUCTION_FAILED"
|
||
|
||
|
||
def safe_validity(g) -> Tuple[Optional[bool], str]:
|
||
if g is None:
|
||
return None, "NULL_GEOMETRY"
|
||
try:
|
||
valid = bool(g.is_valid)
|
||
return valid, "Valid Geometry" if valid else explain_validity(g)
|
||
except Exception as e:
|
||
return None, f"{type(e).__name__}: {e}"
|
||
|
||
|
||
def safe_hausdorff(a, b) -> Optional[float]:
|
||
try:
|
||
return float(a.hausdorff_distance(b))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def safe_symdiff_area(a, b) -> Optional[float]:
|
||
try:
|
||
return float(a.symmetric_difference(b).area)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def topology_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" in s:
|
||
return "NESTED_RING"
|
||
if "valid geometry" in s:
|
||
return "VALID"
|
||
return "OTHER"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DB loaders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_invalid_areas(cur, dataset_id: int, wkb_fn: str) -> List[Dict[str, Any]]:
|
||
cur.execute(f"""
|
||
SELECT
|
||
f.id AS feature_id,
|
||
f.dataset_id,
|
||
f.cell_id,
|
||
f.coverage_id,
|
||
f.source_record_index,
|
||
f.object_class,
|
||
f.geometry_type,
|
||
f.raw_geotype,
|
||
f.normalized_srid,
|
||
f.raw_feature_blob,
|
||
f.geometry_issue,
|
||
f.geometry_repaired,
|
||
c.cell_name,
|
||
{wkb_fn}(g.geom) AS geom_wkb,
|
||
cv.wgsox AS coverage_wgsox,
|
||
cv.wgsoy AS coverage_wgsoy,
|
||
cv.source_record_index AS coverage_source_record_index
|
||
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
|
||
LEFT JOIN chart_coverage cv ON cv.id=f.coverage_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.geometry_type='AREA'
|
||
ORDER BY f.id
|
||
""", (dataset_id,))
|
||
|
||
out = []
|
||
for r in cur:
|
||
try:
|
||
db_geom = load_wkb(to_bytes(r["geom_wkb"]))
|
||
if not db_geom.is_valid:
|
||
x = dict(r)
|
||
x["_db_geom"] = db_geom
|
||
x["_db_reason"] = explain_validity(db_geom)
|
||
out.append(x)
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
def load_cell_raw_tables(
|
||
cur, cell_id: int
|
||
) -> Dict[str, Dict[str, Any]]:
|
||
rs = fetch_all(cur, """
|
||
SELECT id, record_kind, source_record_index, raw_blob, raw_metadata_json
|
||
FROM chart_raw_record
|
||
WHERE cell_id=%s
|
||
ORDER BY id
|
||
""", (cell_id,))
|
||
d = {}
|
||
for r in rs:
|
||
kind = str(r["record_kind"]).upper()
|
||
# 当前 import 设计中每种 table 通常一个 chunk。
|
||
# 如未来出现 chunk_count>1,先保留全部并在调用端报错。
|
||
if kind in d:
|
||
# 用 list 暂存重复。
|
||
existing = d[kind]
|
||
if not isinstance(existing, dict) or "_chunks" not in existing:
|
||
d[kind] = {"_chunks": [existing, r]}
|
||
else:
|
||
existing["_chunks"].append(r)
|
||
else:
|
||
d[kind] = r
|
||
return d
|
||
|
||
|
||
def normalize_chunk_record(rec: Dict[str, Any], kind: str) -> Dict[str, Any]:
|
||
if "_chunks" not in rec:
|
||
return rec
|
||
|
||
chunks = rec["_chunks"]
|
||
parsed = []
|
||
for c in chunks:
|
||
meta = c.get("raw_metadata_json")
|
||
try:
|
||
if isinstance(meta, str):
|
||
mo = json.loads(meta)
|
||
elif isinstance(meta, dict):
|
||
mo = meta
|
||
else:
|
||
mo = json.loads(bytes(meta).decode("utf-8"))
|
||
except Exception:
|
||
mo = {}
|
||
parsed.append((int(mo.get("chunk_index", 0)), c))
|
||
parsed.sort(key=lambda t: t[0])
|
||
|
||
raw = b"".join(to_bytes(c.get("raw_blob")) or b"" for _, c in parsed)
|
||
first = dict(parsed[0][1])
|
||
first["raw_blob"] = raw
|
||
first["_joined_chunks"] = len(parsed)
|
||
return first
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GeoJSON
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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:
|
||
fid = r["feature_id"]
|
||
add(r.get("_db_geom"), {
|
||
"layer": "db_invalid_area",
|
||
"feature_id": fid,
|
||
"object_class": r.get("object_class"),
|
||
"cell_name": r.get("cell_name"),
|
||
"db_reason": r.get("_db_reason"),
|
||
"final_class": r.get("_final_class"),
|
||
})
|
||
add(r.get("_raw_wgs_geom"), {
|
||
"layer": "raw_reconstructed_area",
|
||
"feature_id": fid,
|
||
"object_class": r.get("object_class"),
|
||
"cell_name": r.get("cell_name"),
|
||
"raw_reason": r.get("_raw_wgs_reason"),
|
||
"construction_mode": r.get("_construction_mode"),
|
||
})
|
||
|
||
path.write_text(
|
||
json.dumps(
|
||
{"type": "FeatureCollection", "features": features},
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
default=str,
|
||
),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
|
||
def classify_record(
|
||
*,
|
||
junction_issues: List[Dict[str, Any]],
|
||
raw_valid: Optional[bool],
|
||
db_valid: bool,
|
||
hausdorff: Optional[float],
|
||
symdiff: Optional[float],
|
||
compare_tolerance: float,
|
||
) -> Tuple[str, List[str]]:
|
||
flags = []
|
||
|
||
if junction_issues:
|
||
flags.extend(sorted(set(x["issue_category"] for x in junction_issues)))
|
||
if any(
|
||
x["issue_category"] in {
|
||
"EDGE_INDEX_OUT_OF_RANGE",
|
||
"EDGE_JUNCTION_DISCONTINUITY",
|
||
"RING_NOT_CLOSED_AT_END_OF_REFS",
|
||
"EMPTY_EDGE",
|
||
}
|
||
for x in junction_issues
|
||
):
|
||
return "EDGE_CHAIN_DISCONTINUITY", flags
|
||
|
||
matches = (
|
||
hausdorff is not None
|
||
and hausdorff <= compare_tolerance
|
||
)
|
||
|
||
if raw_valid is True and db_valid is False:
|
||
if matches:
|
||
# 极少见:几何几乎一致但 validity 不同,可能构造策略/holes 差异。
|
||
return "RAW_CHAIN_VALID_DB_INVALID_NEAR_MATCH", flags
|
||
return "RAW_CHAIN_VALID_DB_INVALID", flags
|
||
|
||
if raw_valid is False:
|
||
if matches:
|
||
return "RAW_EDGE_CHAIN_ITSELF_INVALID_AND_MATCHES_DB", flags
|
||
return "RAW_EDGE_CHAIN_ITSELF_INVALID_RECONSTRUCTION_DIFFERS", flags
|
||
|
||
if matches:
|
||
return "RECONSTRUCTION_MATCHES_DB", flags
|
||
|
||
return "RECONSTRUCTION_MISMATCH", flags
|
||
|
||
|
||
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 Area raw edge chain audit v6"
|
||
)
|
||
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(
|
||
"--compare-tolerance",
|
||
type=float,
|
||
default=2e-6,
|
||
help="参考重建与 DB geometry Hausdorff distance 容差(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_raw_edge_audit_v6_dataset_{args.dataset_id}_{stamp}"
|
||
)
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
report = {
|
||
"version": "v6.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 是局部 CM93 数据集;本轮只重建已导入 invalid Area "
|
||
"自身的 raw edge chain,不要求跨未导入海域连续。"
|
||
),
|
||
"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/6] 找出 52 个 invalid Area ...")
|
||
invalids = load_invalid_areas(cur, args.dataset_id, wkb_fn)
|
||
print(f" invalid Area = {len(invalids)}")
|
||
|
||
affected_cells = sorted(set(int(r["cell_id"]) for r in invalids))
|
||
print(f" affected Cells = {len(affected_cells)}")
|
||
|
||
print("[2/6] 解析受影响 Cell 的 HEADER / VECTOR ...")
|
||
cell_cache = {}
|
||
cell_audit = []
|
||
|
||
for cell_id in affected_cells:
|
||
rawtables = load_cell_raw_tables(cur, cell_id)
|
||
if "HEADER" not in rawtables or "VECTOR" not in rawtables:
|
||
cell_cache[cell_id] = {
|
||
"error": "HEADER_OR_VECTOR_MISSING",
|
||
"rawtables": rawtables,
|
||
}
|
||
cell_audit.append({
|
||
"cell_id": cell_id,
|
||
"status": "FAIL",
|
||
"issue": "HEADER_OR_VECTOR_MISSING",
|
||
})
|
||
continue
|
||
|
||
hrec = normalize_chunk_record(rawtables["HEADER"], "HEADER")
|
||
vrec = normalize_chunk_record(rawtables["VECTOR"], "VECTOR")
|
||
|
||
try:
|
||
header = parse_header_metadata(hrec.get("raw_metadata_json"))
|
||
vector_blob = to_bytes(vrec.get("raw_blob"))
|
||
edges = parse_vector_table(vector_blob)
|
||
expected_edges = int(header["usn_vector_records"])
|
||
exact = len(edges) == expected_edges
|
||
|
||
cell_cache[cell_id] = {
|
||
"header": header,
|
||
"edges": edges,
|
||
"rawtables": rawtables,
|
||
}
|
||
cell_audit.append({
|
||
"cell_id": cell_id,
|
||
"status": "PASS" if exact else "CHECK",
|
||
"parsed_vector_edges": len(edges),
|
||
"header_usn_vector_records": expected_edges,
|
||
"vector_count_match": int(exact),
|
||
"vector_blob_bytes": len(vector_blob or b""),
|
||
})
|
||
except Exception as e:
|
||
cell_cache[cell_id] = {
|
||
"error": f"{type(e).__name__}: {e}",
|
||
"rawtables": rawtables,
|
||
}
|
||
cell_audit.append({
|
||
"cell_id": cell_id,
|
||
"status": "FAIL",
|
||
"issue": f"{type(e).__name__}: {e}",
|
||
})
|
||
|
||
print("[3/6] 解码 invalid Feature raw_feature_blob 并重建 edge rings ...")
|
||
classification_rows = []
|
||
edge_ref_rows = []
|
||
ring_rows = []
|
||
junction_rows = []
|
||
compare_rows = []
|
||
isolated_detail = []
|
||
|
||
# V5 已确认的两条无共享邻接对象 feature,用于特别详细导出。
|
||
# 不硬编码成“结论”,只是当前已知调查重点;若 ID 不存在也不影响主流程。
|
||
isolated_ids = {24057, 227278}
|
||
|
||
for idx, r in enumerate(invalids, start=1):
|
||
fid = int(r["feature_id"])
|
||
cell_id = int(r["cell_id"])
|
||
db_geom = r["_db_geom"]
|
||
db_valid, db_reason = safe_validity(db_geom)
|
||
|
||
cache = cell_cache.get(cell_id, {})
|
||
base = {
|
||
"feature_id": fid,
|
||
"object_class": r.get("object_class"),
|
||
"cell_id": cell_id,
|
||
"cell_name": r.get("cell_name"),
|
||
"source_record_index": r.get("source_record_index"),
|
||
"coverage_id": r.get("coverage_id"),
|
||
"raw_geotype_db": r.get("raw_geotype"),
|
||
"db_valid": int(bool(db_valid)) if db_valid is not None else None,
|
||
"db_validity_reason": db_reason,
|
||
}
|
||
|
||
if "error" in cache:
|
||
classification_rows.append({
|
||
**base,
|
||
"final_class": "CELL_RAW_TABLE_UNAVAILABLE",
|
||
"detail": cache["error"],
|
||
})
|
||
continue
|
||
|
||
raw_feature = to_bytes(r.get("raw_feature_blob"))
|
||
if not raw_feature:
|
||
classification_rows.append({
|
||
**base,
|
||
"final_class": "RAW_FEATURE_BLOB_MISSING",
|
||
})
|
||
continue
|
||
|
||
try:
|
||
parsed = parse_area_feature_blob(raw_feature)
|
||
except Exception as e:
|
||
classification_rows.append({
|
||
**base,
|
||
"final_class": "RAW_FEATURE_PARSE_ERROR",
|
||
"detail": f"{type(e).__name__}: {e}",
|
||
"raw_feature_hex": raw_feature.hex(),
|
||
})
|
||
continue
|
||
|
||
refs = parsed["refs"]
|
||
edges = cache["edges"]
|
||
header = cache["header"]
|
||
|
||
rings_raw, erows, jissues = build_rings_like_opencpn(
|
||
refs, edges
|
||
)
|
||
|
||
# Edge refs export
|
||
for er in erows:
|
||
rr = {
|
||
**base,
|
||
"feature_geotype_parsed": parsed["geotype"],
|
||
"feature_object_type": parsed["object_type"],
|
||
"feature_n_elements": parsed["n_elements"],
|
||
**er,
|
||
}
|
||
edge_ref_rows.append(rr)
|
||
if fid in isolated_ids:
|
||
isolated_detail.append(rr)
|
||
|
||
for ji in jissues:
|
||
junction_rows.append({**base, **ji})
|
||
|
||
# raw coordinate ring stats
|
||
repeated_total = 0
|
||
for ri, ring in enumerate(rings_raw):
|
||
reps = repeated_nonconsecutive_vertices(ring)
|
||
repeated_total += len(reps)
|
||
|
||
raw_line = None
|
||
try:
|
||
raw_line = LinearRing(ring) if len(ring) >= 4 else None
|
||
except Exception:
|
||
raw_line = None
|
||
|
||
ring_rows.append({
|
||
**base,
|
||
"ring_index": ri,
|
||
"raw_vertex_count": len(ring),
|
||
"raw_closed": int(bool(ring) and ring[0] == ring[-1]),
|
||
"repeated_nonconsecutive_vertex_count": len(reps),
|
||
"repeated_vertices": reps[:50],
|
||
"raw_ring_is_simple": (
|
||
int(bool(raw_line.is_simple))
|
||
if raw_line is not None else None
|
||
),
|
||
"signed_area_raw_units": (
|
||
signed_area([(float(x), float(y)) for x, y in ring])
|
||
if ring else None
|
||
),
|
||
})
|
||
|
||
# WGS84 transform
|
||
wgsox = float(r.get("coverage_wgsox") or 0.0)
|
||
wgsoy = float(r.get("coverage_wgsoy") or 0.0)
|
||
|
||
rings_wgs = []
|
||
for ring in rings_raw:
|
||
w = [
|
||
transform_cm93_point_to_wgs84(
|
||
p, header, wgsox, wgsoy
|
||
)
|
||
for p in ring
|
||
]
|
||
rings_wgs.append(w)
|
||
|
||
raw_wgs_geom, construction_mode = ring_to_polygon_candidate(
|
||
rings_wgs
|
||
)
|
||
raw_valid, raw_reason = safe_validity(raw_wgs_geom)
|
||
|
||
haus = safe_hausdorff(raw_wgs_geom, db_geom) if raw_wgs_geom else None
|
||
symdiff = safe_symdiff_area(raw_wgs_geom, db_geom) if raw_wgs_geom else None
|
||
|
||
final_class, flags = classify_record(
|
||
junction_issues=jissues,
|
||
raw_valid=raw_valid,
|
||
db_valid=bool(db_valid),
|
||
hausdorff=haus,
|
||
symdiff=symdiff,
|
||
compare_tolerance=args.compare_tolerance,
|
||
)
|
||
|
||
raw_reason_class = topology_reason_class(raw_reason)
|
||
db_reason_class = topology_reason_class(db_reason)
|
||
|
||
row = {
|
||
**base,
|
||
"parsed_object_type": parsed["object_type"],
|
||
"parsed_geotype": parsed["geotype"],
|
||
"parsed_obj_desc_bytes": parsed["obj_desc_bytes"],
|
||
"parsed_n_elements": parsed["n_elements"],
|
||
"parsed_has_attributes": parsed["has_attributes"],
|
||
"vector_edge_count_cell": len(edges),
|
||
"ring_count_reconstructed": len(rings_raw),
|
||
"edge_junction_issue_count": len(jissues),
|
||
"edge_junction_issue_flags": flags,
|
||
"repeated_nonconsecutive_vertex_total": repeated_total,
|
||
"coverage_wgsox": wgsox,
|
||
"coverage_wgsoy": wgsoy,
|
||
"construction_mode": construction_mode,
|
||
"raw_reconstructed_valid": (
|
||
int(bool(raw_valid)) if raw_valid is not None else None
|
||
),
|
||
"raw_validity_reason": raw_reason,
|
||
"raw_validity_reason_class": raw_reason_class,
|
||
"db_validity_reason_class": db_reason_class,
|
||
"raw_db_same_validity_reason_class": int(
|
||
raw_reason_class == db_reason_class
|
||
),
|
||
"hausdorff_distance_deg": haus,
|
||
"symmetric_difference_area_deg2": symdiff,
|
||
"within_compare_tolerance": (
|
||
int(haus is not None and haus <= args.compare_tolerance)
|
||
),
|
||
"final_class": final_class,
|
||
}
|
||
classification_rows.append(row)
|
||
|
||
compare_rows.append({
|
||
**base,
|
||
"construction_mode": construction_mode,
|
||
"raw_geom_type": (
|
||
raw_wgs_geom.geom_type if raw_wgs_geom is not None else None
|
||
),
|
||
"db_geom_type": db_geom.geom_type,
|
||
"raw_valid": (
|
||
int(bool(raw_valid)) if raw_valid is not None else None
|
||
),
|
||
"db_valid": int(bool(db_valid)),
|
||
"raw_reason": raw_reason,
|
||
"db_reason": db_reason,
|
||
"hausdorff_distance_deg": haus,
|
||
"symmetric_difference_area_deg2": symdiff,
|
||
"within_compare_tolerance": (
|
||
int(haus is not None and haus <= args.compare_tolerance)
|
||
),
|
||
})
|
||
|
||
# 挂到 GeoJSON 使用。
|
||
r["_raw_wgs_geom"] = raw_wgs_geom
|
||
r["_raw_wgs_reason"] = raw_reason
|
||
r["_construction_mode"] = construction_mode
|
||
r["_final_class"] = final_class
|
||
|
||
print(
|
||
f" [{idx:02d}/{len(invalids)}] "
|
||
f"feature={fid} {r.get('object_class')} "
|
||
f"edges={len(refs)} rings={len(rings_raw)} "
|
||
f"class={final_class}"
|
||
)
|
||
|
||
print("[4/6] 汇总 raw vs DB 证据 ...")
|
||
class_counts = Counter(
|
||
r.get("final_class", "UNKNOWN") for r in classification_rows
|
||
)
|
||
raw_reason_counts = Counter(
|
||
r.get("raw_validity_reason_class", "UNKNOWN")
|
||
for r in classification_rows
|
||
if r.get("raw_validity_reason_class")
|
||
)
|
||
db_reason_counts = Counter(
|
||
r.get("db_validity_reason_class", "UNKNOWN")
|
||
for r in classification_rows
|
||
if r.get("db_validity_reason_class")
|
||
)
|
||
|
||
vector_bad_cells = sum(
|
||
1 for r in cell_audit
|
||
if r.get("vector_count_match") == 0
|
||
or r.get("status") == "FAIL"
|
||
)
|
||
|
||
report["summary"] = {
|
||
"invalid_area_count": len(invalids),
|
||
"affected_cell_count": len(affected_cells),
|
||
"cell_vector_audit_bad_count": vector_bad_cells,
|
||
"classification_counts": dict(class_counts),
|
||
"raw_validity_reason_counts": dict(raw_reason_counts),
|
||
"db_validity_reason_counts": dict(db_reason_counts),
|
||
"edge_junction_issue_count": len(junction_rows),
|
||
"features_with_edge_junction_issue": len(set(
|
||
int(r["feature_id"]) for r in junction_rows
|
||
if r.get("feature_id") is not None
|
||
)),
|
||
"reference_reconstruction_within_tolerance": sum(
|
||
int(r.get("within_compare_tolerance") or 0)
|
||
for r in classification_rows
|
||
),
|
||
"same_validity_reason_class_count": sum(
|
||
int(r.get("raw_db_same_validity_reason_class") or 0)
|
||
for r in classification_rows
|
||
),
|
||
}
|
||
|
||
print("[5/6] 写 CSV / JSON / GeoJSON ...")
|
||
write_csv(
|
||
outdir / "invalid_area_raw_edge_classification.csv",
|
||
classification_rows
|
||
)
|
||
write_csv(
|
||
outdir / "invalid_area_edge_references.csv",
|
||
edge_ref_rows
|
||
)
|
||
write_csv(
|
||
outdir / "invalid_area_ring_summary.csv",
|
||
ring_rows
|
||
)
|
||
write_csv(
|
||
outdir / "invalid_area_edge_junction_issues.csv",
|
||
junction_rows
|
||
)
|
||
write_csv(
|
||
outdir / "raw_vs_db_geometry_compare.csv",
|
||
compare_rows
|
||
)
|
||
write_csv(
|
||
outdir / "isolated_invalid_area_edge_detail.csv",
|
||
isolated_detail
|
||
)
|
||
write_csv(
|
||
outdir / "affected_cell_vector_table_audit.csv",
|
||
cell_audit
|
||
)
|
||
|
||
make_geojson(
|
||
[
|
||
r for r in invalids
|
||
if "_raw_wgs_geom" in r
|
||
],
|
||
outdir / "invalid_area_raw_reconstruction.geojson"
|
||
)
|
||
|
||
(outdir / "raw_edge_audit_report_v6.json").write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
lines = []
|
||
lines.append("CM93 -> MySQL invalid Area 原始 Edge Chain 重建审计 v6")
|
||
lines.append("=" * 88)
|
||
lines.append(f"Generated: {report['generated_at']}")
|
||
lines.append(f"Dataset ID: {args.dataset_id}")
|
||
lines.append(f"MySQL: {report['mysql_version']}")
|
||
lines.append("")
|
||
lines.append("SCOPE")
|
||
lines.append(
|
||
" 当前 CM93JAPAN 是局部数据集;不要求与未导入海域跨边界连续。"
|
||
)
|
||
lines.append(
|
||
" 本轮直接从 DB 保存的 raw_feature_blob + VECTOR raw_blob "
|
||
"重建 invalid Area 的 CM93 edge chain。"
|
||
)
|
||
lines.append("")
|
||
lines.append("BASELINE")
|
||
lines.append(f" invalid Area: {len(invalids)}")
|
||
lines.append(f" affected Cells: {len(affected_cells)}")
|
||
lines.append(f" bad VECTOR-table audits: {vector_bad_cells}")
|
||
lines.append("")
|
||
lines.append("FINAL CLASSIFICATION")
|
||
for k, v in class_counts.most_common():
|
||
lines.append(f" {k:56s} {v}")
|
||
lines.append("")
|
||
lines.append("RAW RECONSTRUCTED VALIDITY")
|
||
for k, v in raw_reason_counts.most_common():
|
||
lines.append(f" {k:40s} {v}")
|
||
lines.append("")
|
||
lines.append("DB VALIDITY")
|
||
for k, v in db_reason_counts.most_common():
|
||
lines.append(f" {k:40s} {v}")
|
||
lines.append("")
|
||
lines.append("EDGE CHAIN")
|
||
lines.append(
|
||
f" junction issue rows: {len(junction_rows)}"
|
||
)
|
||
lines.append(
|
||
f" features with junction issue:"
|
||
f" {report['summary']['features_with_edge_junction_issue']}"
|
||
)
|
||
lines.append("")
|
||
lines.append("RAW ↔ DB")
|
||
lines.append(
|
||
f" within Hausdorff tolerance: "
|
||
f"{report['summary']['reference_reconstruction_within_tolerance']}"
|
||
f" / {len(classification_rows)}"
|
||
)
|
||
lines.append(
|
||
f" same validity reason class: "
|
||
f"{report['summary']['same_validity_reason_class_count']}"
|
||
f" / {len(classification_rows)}"
|
||
)
|
||
lines.append("")
|
||
lines.append("INTERPRETATION")
|
||
lines.append(
|
||
" RAW_CHAIN_VALID_DB_INVALID:优先判为 importer assembly/conversion 候选。"
|
||
)
|
||
lines.append(
|
||
" RAW_EDGE_CHAIN_ITSELF_INVALID_AND_MATCHES_DB:"
|
||
"说明 DB 基本忠实反映了 CM93 edge chain 组装结果;"
|
||
"后续重点研究 CM93 shared topology / OGC Polygon validity 语义,"
|
||
"而不是先修改坐标。"
|
||
)
|
||
lines.append(
|
||
" EDGE_CHAIN_DISCONTINUITY:检查 edge index / usage / ordering;"
|
||
"不得自动闭环或补边。"
|
||
)
|
||
lines.append(
|
||
" RECONSTRUCTION_MISMATCH:检查 coverage WGS offset、ring shell/hole "
|
||
"解释以及 importer 的坐标转换。"
|
||
)
|
||
lines.append("")
|
||
lines.append("NO WRITE / NO REPAIR / NO PBF")
|
||
|
||
(outdir / "raw_edge_audit_summary_v6.txt").write_text(
|
||
"\n".join(lines), encoding="utf-8"
|
||
)
|
||
|
||
conn.rollback()
|
||
|
||
print("[6/6] 压缩输出 ...")
|
||
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()
|