feat: CoordConverter 坐标转换套件 v1.0.0

TXT / Excel(CSV) / SHP 三种格式任意互转
支持 WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator 坐标系转换

由 Mapo 🗺️ 自动生成
This commit is contained in:
2026-07-29 12:49:08 +08:00
commit e9f87fc693
26 changed files with 725 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# file_io/__init__.py
from .txt_handler import TxtHandler
from .excel_handler import ExcelHandler
from .shp_handler import ShpHandler
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+72
View File
@@ -0,0 +1,72 @@
"""
Excel/CSV file reader/writer using pandas + openpyxl.
"""
import os
from pathlib import Path
import pandas as pd
from core.data_model import CoordData, CoordPoint
class ExcelHandler:
EXTENSIONS = {".xlsx", ".xls", ".csv"}
@classmethod
def read(cls, path: str, sheet: str | None = None) -> CoordData:
ext = Path(path).suffix.lower()
if ext == ".csv":
df = pd.read_csv(path, encoding="utf-8-sig")
else:
df = pd.read_excel(path, sheet_name=sheet or 0, dtype_backend="numpy_nullable")
if df.empty:
raise ValueError(f"文件无数据: {path}")
records = df.to_dict(orient="records")
# Convert nan to None
for r in records:
for k, v in r.items():
if pd.isna(v):
r[k] = None
cols = list(df.columns)
x_col, y_col, z_col = cls._find_coord_cols(cols)
return CoordData.from_records(records, x_col=x_col, y_col=y_col, z_col=z_col)
@classmethod
def write(cls, data: CoordData, path: str, sheet: str = "坐标数据"):
records = data.to_records()
if not records:
raise ValueError("无数据可写入")
df = pd.DataFrame(records)
ext = Path(path).suffix.lower()
if ext == ".csv":
df.to_csv(path, index=False, encoding="utf-8-sig")
else:
with pd.ExcelWriter(path, engine="openpyxl") as writer:
df.to_excel(writer, sheet_name=sheet, index=False)
@staticmethod
def _find_coord_cols(cols: list[str]) -> tuple[str, str, str | None]:
"""Same logic as TxtHandler for column discovery."""
x_patterns = ["x", "lon", "经度", "lng", "easting"]
y_patterns = ["y", "lat", "纬度", "northing"]
z_patterns = ["z", "h", "高程", "高度", "alt"]
x_col = y_col = z_col = None
lower_cols = {c: c.lower().replace(" ", "").replace("_", "").replace("-","")
for c in cols}
for c, lc in lower_cols.items():
if any(p in lc for p in x_patterns):
x_col = c
elif any(p in lc for p in y_patterns):
y_col = c
elif any(p in lc for p in z_patterns):
z_col = c
if not x_col and len(cols) >= 2:
x_col, y_col = cols[0], cols[1]
return x_col, y_col, z_col
+53
View File
@@ -0,0 +1,53 @@
"""
SHP (Shapefile) reader/writer using geopandas.
"""
import os
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely.geometry import Point
from core.data_model import CoordData, CoordPoint
class ShpHandler:
EXTENSIONS = {".shp"}
@classmethod
def read(cls, path: str) -> CoordData:
gdf = gpd.read_file(path, encoding="utf-8")
if gdf.empty:
raise ValueError(f"Shapefile 无数据: {path}")
crs = gdf.crs.to_string() if gdf.crs else None
# Extract centroids for non-Point geometries
geom_col = gdf.geometry.name
points = []
for _, row in gdf.iterrows():
geom = row[geom_col]
if geom is None or geom.is_empty:
continue
centroid = geom.centroid if geom.geom_type != "Point" else geom
pt = CoordPoint(
x=centroid.x,
y=centroid.y,
z=centroid.z if hasattr(centroid, "z") and centroid.z else None,
attrs={k: v for k, v in row.items() if k != geom_col and not pd.isna(v)},
)
points.append(pt)
cols = [c for c in gdf.columns if c != geom_col]
return CoordData(points=points, crs=crs, columns=cols,
geometry_type=gdf.geom_type.iloc[0] if len(gdf) > 0 else "Point")
@classmethod
def write(cls, data: CoordData, path: str, crs: str | None = None):
records = data.to_records()
if not records:
raise ValueError("无数据可写入")
# Build geometry column
geometry = [Point(r.pop("X"), r.pop("Y")) for r in records]
gdf = gpd.GeoDataFrame(records, geometry=geometry, crs=crs or data.crs)
gdf.to_file(path, encoding="utf-8")
+131
View File
@@ -0,0 +1,131 @@
"""
TXT file reader/writer.
Auto-detects delimiters (space, tab, comma) and column names.
Handles optional header line and comment lines (#, //).
"""
import csv
import io
from pathlib import Path
from core.data_model import CoordData, CoordPoint
class TxtHandler:
EXTENSIONS = {".txt", ".csv"}
@classmethod
def read(cls, path: str) -> CoordData:
path = Path(path)
raw = path.read_text(encoding="utf-8-sig")
# Strip comment lines
lines = [l for l in raw.splitlines()
if l.strip() and not l.strip().startswith(("#", "//"))]
if not lines:
raise ValueError(f"文件为空或全为注释: {path}")
# Detect delimiter
delim = cls._detect_delimiter(lines)
reader = csv.reader(io.StringIO("\n".join(lines)), delimiter=delim)
all_rows = list(reader)
if not all_rows:
raise ValueError(f"无法解析文件: {path}")
# Check if first row looks like a header (contains non-numeric first field
# or known column names)
first_row = all_rows[0]
has_header = cls._looks_like_header(first_row)
if has_header:
col_names = first_row
data_rows = all_rows[1:]
else:
# No header — auto-generate column names
num_cols = len(first_row)
col_names = [f"COL{i+1}" for i in range(num_cols)]
data_rows = all_rows
# Detect X, Y, Z columns
x_col, y_col, z_col = cls._find_coord_cols(col_names)
records = []
for row in data_rows:
record = {}
for i, name in enumerate(col_names):
val = row[i].strip() if i < len(row) else None
record[name] = val
records.append(record)
return CoordData.from_records(records, x_col=x_col, y_col=y_col, z_col=z_col)
@classmethod
def write(cls, data: CoordData, path: str):
records = data.to_records()
if not records:
raise ValueError("无数据可写入")
delim = cls._detect_delimiter_from_ext(path)
fieldnames = list(records[0].keys())
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=delim)
writer.writeheader()
writer.writerows(records)
@staticmethod
def _looks_like_header(row: list[str]) -> bool:
"""Heuristic: header if first few entries are non-numeric strings."""
if not row:
return False
# If any column name matches known coord patterns, it's a header
lower = {c.lower().strip() for c in row[:5]}
coord_keywords = {"x", "y", "lon", "lat", "经度", "纬度", "点号", "id"}
if lower & coord_keywords:
return True
# If the first entry is not a valid number, it's probably a header
try:
float(row[0].strip())
return False
except (ValueError, AttributeError):
return True
@staticmethod
def _detect_delimiter(lines: list[str]) -> str:
"""Auto-detect: tab > comma > space."""
sample = "\n".join(lines[:20])
tab_count = sample.count("\t")
comma_count = sample.count(",")
# Check if commas are between spaces (likely CSV) vs within text
if comma_count > 3 or comma_count > tab_count:
return ","
if tab_count > 0:
return "\t"
return " " # default: space-separated
@staticmethod
def _detect_delimiter_from_ext(path: str) -> str:
return "," if path.lower().endswith(".csv") else "\t"
@staticmethod
def _find_coord_cols(cols: list[str]) -> tuple[str, str, str | None]:
"""Find X, Y, Z columns."""
x_patterns = ["x", "lon", "经度", "lng", "easting"]
y_patterns = ["y", "lat", "纬度", "northing"]
z_patterns = ["z", "h", "高程", "高度", "alt", "elevation"]
x_col = y_col = z_col = None
for c in cols:
lc = c.lower().replace(" ", "").replace("_", "").replace("-", "")
if any(p == lc or lc.startswith(p) or lc.endswith(p) for p in x_patterns):
x_col = c
elif any(p == lc or lc.startswith(p) or lc.endswith(p) for p in y_patterns):
y_col = c
elif any(p == lc or lc.startswith(p) or lc.endswith(p) for p in z_patterns):
z_col = c
# Fallback: first two numeric-ish columns if no match
if not x_col and len(cols) >= 2:
x_col, y_col = cols[0], cols[1]
return x_col, y_col, z_col