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

1462 lines
49 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 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()