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
+53
View File
@@ -0,0 +1,53 @@
# 🗺️ CoordConverter — 坐标转换套件 (Mercator Suite)
<p align="center">
<b>地理信息行业专用 · TXT / Excel / SHP 互转 · 多坐标系转换</b>
<br>
<code>Mercator Suite</code><code>Python 3</code><code>pyproj</code><code>geopandas</code>
</p>
---
## 📌 一句话
TXT、Excel(CSV)、SHP 三种格式的坐标数据**任意互转**,同时支持 **WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator** 坐标系转换。
## 🚀 快速使用(作为 Mercator 套件)
```bash
agc run /tmp/coord-output \
--suite-id <suite_id> \
--input input_file=/data/外业数据.txt \
--input output_file=/tmp/coord-output/成果表.xlsx \
--input from_crs=EPSG:4326 \
--input to_crs=EPSG:4490
```
## 📦 依赖
已预装 `gis-base:latest`,额外需要:
- `pandas``openpyxl`Excel 读写)
## 🏗️ 项目结构
```
coord-converter-suite/
├── workflow.yaml ← Mercator 套件定义
├── run_suite.py ← 套件入口(读取 PARAM_* 环境变量)
├── requirements.txt
├── README.md
├── core/
│ ├── coord_transform.py ← 坐标系转换引擎(pyproj)
│ └── data_model.py ← 统一数据模型
├── file_io/
│ ├── txt_handler.py ← TXT/CSV 读写(自动检测分隔符)
│ ├── excel_handler.py ← Excel/CSV 读写
│ └── shp_handler.py ← SHP 读写(geopandas
├── converter/
│ └── workflow.py ← 转换编排器
├── cli/
│ └── cli_runner.py ← 独立 CLI(可本地调试)
└── examples/
├── sample_points.txt
└── sample_points.csv
```
+1
View File
@@ -0,0 +1 @@
# cli/__init__.py
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
CoordConverter CLI — standalone command-line interface.
"""
import argparse
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from converter.workflow import ConversionWorkflow
from core.coord_transform import CoordTransformer
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="coord-converter", description="坐标转换工具")
sub = p.add_subparsers(dest="command")
# convert
cp = sub.add_parser("convert", help="转换单个文件")
cp.add_argument("input", help="输入文件路径")
cp.add_argument("-o", "--output", required=True, help="输出文件路径")
cp.add_argument("--from-crs", default="auto", help="源坐标系 EPSG (默认 auto)")
cp.add_argument("--to-crs", default="EPSG:4490", help="目标坐标系 EPSG (默认 CGCS2000)")
cp.add_argument("--output-format", default="auto", help="强制输出格式")
# list-crs
lp = sub.add_parser("list-crs", help="列出支持的坐标系")
return p
def main():
parser = build_parser()
args = parser.parse_args()
if args.command == "convert":
wf = ConversionWorkflow()
result = wf.run(
input_path=args.input,
output_path=args.output,
from_crs=args.from_crs if args.from_crs != "auto" else None,
to_crs=args.to_crs,
output_format=args.output_format,
)
print(f"✅ 转换完成: {result['output']}")
print(f" 记录数: {result['feature_count']}")
print(f" 坐标系: {result['crs']}")
elif args.command == "list-crs":
for crs in CoordTransformer.list_supported():
print(f" {crs['name']:16s} {crs['epsg']:12s} {crs['desc']}")
else:
parser.print_help()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
# converter/__init__.py
from .workflow import ConversionWorkflow
Binary file not shown.
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
"""
Conversion workflow orchestrator.
Detects input format, reads, transforms CRS, writes output.
"""
import os
from pathlib import Path
from core.data_model import CoordData
from core.coord_transform import CoordTransformer
from file_io.txt_handler import TxtHandler
from file_io.excel_handler import ExcelHandler
from file_io.shp_handler import ShpHandler
# Registry: {extension_lowercase: handler_class}
HANDLERS = {}
for h in (TxtHandler, ExcelHandler, ShpHandler):
for ext in h.EXTENSIONS:
HANDLERS[ext] = h
def get_handler(path: str):
ext = Path(path).suffix.lower()
h = HANDLERS.get(ext)
if not h:
raise ValueError(f"不支持的文件格式: {ext}(支持: {', '.join(sorted(HANDLERS))}")
return h
def resolve_format(path: str, fmt_hint: str) -> str:
"""Determine output format. fmt_hint='auto' → infer from path ext."""
if fmt_hint and fmt_hint.lower() != "auto":
return fmt_hint.lower()
ext = Path(path).suffix.lower()
ext_map = {".txt": "txt", ".csv": "csv", ".xlsx": "xlsx", ".xls": "xlsx", ".shp": "shp"}
f = ext_map.get(ext)
if not f:
raise ValueError(f"无法从扩展名推断输出格式: {ext}")
return f
def format_to_ext(fmt: str) -> str:
mapping = {"txt": ".txt", "csv": ".csv", "xlsx": ".xlsx", "shp": ".shp"}
return mapping.get(fmt, f".{fmt}")
class ConversionWorkflow:
"""Orchestrate read → transform → write."""
def run(self, input_path: str, output_path: str,
from_crs: str | None = None, to_crs: str | None = None,
output_format: str = "auto") -> dict:
# 1. Read
in_handler = get_handler(input_path)
data = in_handler.read(input_path)
# 2. Determine source CRS
src_crs = from_crs or data.crs
tgt_crs = to_crs
# 3. Transform if needed
if src_crs and tgt_crs and src_crs != tgt_crs:
transformer = CoordTransformer(src_crs, tgt_crs)
xs = [p.x for p in data.points]
ys = [p.y for p in data.points]
new_xs, new_ys = transformer.transform_batch(xs, ys)
for i, p in enumerate(data.points):
p.x = round(new_xs[i], 6)
p.y = round(new_ys[i], 6)
data.crs = tgt_crs
crs_note = transformer.description
else:
crs_note = src_crs or "未指定"
# 4. Write
out_fmt = resolve_format(output_path, output_format)
out_ext = format_to_ext(out_fmt)
# Ensure output path has the right extension
final_path = str(Path(output_path).with_suffix(out_ext))
out_handler = get_handler(final_path)
out_handler.write(data, final_path)
return {
"success": True,
"input": input_path,
"output": final_path,
"input_format": Path(input_path).suffix.lower(),
"output_format": out_fmt,
"crs": crs_note,
"feature_count": data.count,
}
+1
View File
@@ -0,0 +1 @@
# core/__init__.py
Binary file not shown.
Binary file not shown.
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
"""
Coordinate transformation engine — wraps pyproj for all CRS conversions.
"""
import pyproj
from typing import Tuple
# Well-known EPSG codes
WELL_KNOWN = {
"WGS84": "EPSG:4326",
"CGCS2000": "EPSG:4490",
"Xian80": "EPSG:4610",
"Beijing54": "EPSG:4214",
"WebMercator": "EPSG:3857",
"PseudoMercator": "EPSG:3857",
}
def resolve_epsg(name: str) -> str:
"""Resolve a well-known name or return as-is if already EPSG:xxxx."""
name = name.strip()
if name.upper() in WELL_KNOWN:
return WELL_KNOWN[name.upper()]
if name.upper().startswith("EPSG:"):
return name.upper()
return f"EPSG:{name}"
class CoordTransformer:
"""Convert coordinates between any two CRS using pyproj."""
def __init__(self, from_crs: str, to_crs: str):
self.from_crs = resolve_epsg(from_crs)
self.to_crs = resolve_epsg(to_crs)
if self.from_crs == self.to_crs:
self._is_identity = True
self._transformer = None
else:
self._is_identity = False
self._transformer = pyproj.Transformer.from_crs(
self.from_crs, self.to_crs, always_xy=True
)
def transform(self, x: float, y: float) -> Tuple[float, float]:
if self._is_identity:
return x, y
return self._transformer.transform(x, y)
def transform_batch(self, xs: list[float], ys: list[float]) -> Tuple[list[float], list[float]]:
if self._is_identity:
return xs, ys
results = self._transformer.transform(xs, ys)
return results[0], results[1]
@property
def description(self) -> str:
if self._is_identity:
return f"{self.from_crs} (无转换)"
return f"{self.from_crs}{self.to_crs}"
@staticmethod
def list_supported() -> list[dict]:
return [
{"name": "WGS84", "epsg": "EPSG:4326", "desc": "GPS / Google Earth"},
{"name": "CGCS2000", "epsg": "EPSG:4490", "desc": "2000国家大地坐标系"},
{"name": "Xian80", "epsg": "EPSG:4610", "desc": "西安80坐标系"},
{"name": "Beijing54", "epsg": "EPSG:4214", "desc": "北京54坐标系"},
{"name": "Web Mercator","epsg": "EPSG:3857", "desc": "互联网地图投影"},
]
+57
View File
@@ -0,0 +1,57 @@
"""
Data model — unified internal representation for all coordinate data.
"""
from dataclasses import dataclass, field
from typing import Any
@dataclass
class CoordPoint:
x: float
y: float
z: float | None = None
attrs: dict[str, Any] = field(default_factory=dict)
@dataclass
class CoordData:
"""Unified container for all coordinate data regardless of source format."""
points: list[CoordPoint] = field(default_factory=list)
crs: str | None = None # e.g. "EPSG:4326"
columns: list[str] = field(default_factory=list) # all column names
geometry_type: str = "Point" # Point / LineString / Polygon
extra_meta: dict[str, Any] = field(default_factory=dict)
@property
def count(self) -> int:
return len(self.points)
def to_records(self) -> list[dict[str, Any]]:
"""Convert to list of flat dicts for DataFrame/Excel export."""
records = []
for pt in self.points:
row = dict(pt.attrs)
# Always put X, Y (and Z) at front
row["X"] = pt.x
row["Y"] = pt.y
if pt.z is not None:
row["Z"] = pt.z
records.append(row)
return records
@classmethod
def from_records(cls, records: list[dict], crs: str | None = None,
x_col: str = "X", y_col: str = "Y", z_col: str | None = "Z",
geometry_type: str = "Point") -> "CoordData":
points = []
for rec in records:
pt = CoordPoint(
x=float(rec[x_col]),
y=float(rec[y_col]),
z=float(rec[z_col]) if z_col and z_col in rec else None,
attrs={k: v for k, v in rec.items()
if k not in (x_col, y_col, z_col)},
)
points.append(pt)
cols = list(records[0].keys()) if records else []
return cls(points=points, crs=crs, columns=cols, geometry_type=geometry_type)
+6
View File
@@ -0,0 +1,6 @@
点号,经度,纬度,高程,描述
1,102.705584,25.045231,1892.5,起点
2,102.706215,25.045689,1890.1,拐点A
3,102.707892,25.044367,1891.8,拐点B
4,102.706983,25.043852,1893.2,拐点C
5,102.705012,25.044578,1892.0,终点
1 点号 经度 纬度 高程 描述
2 1 102.705584 25.045231 1892.5 起点
3 2 102.706215 25.045689 1890.1 拐点A
4 3 102.707892 25.044367 1891.8 拐点B
5 4 102.706983 25.043852 1893.2 拐点C
6 5 102.705012 25.044578 1892.0 终点
+7
View File
@@ -0,0 +1,7 @@
# 示例坐标数据 — WGS84 经纬度
点号 X Y H 描述
1 102.705584 25.045231 1892.5 起点
2 102.706215 25.045689 1890.1 拐点A
3 102.707892 25.044367 1891.8 拐点B
4 102.706983 25.043852 1893.2 拐点C
5 102.705012 25.044578 1892.0 终点
+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
+5
View File
@@ -0,0 +1,5 @@
pyproj
geopandas
pandas
openpyxl
shapely
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
CoordConverter — Mercator Suite Entry Point
Reads PARAM_* env vars set by agc, runs conversion, outputs SUITE_OUTPUT JSON.
"""
import os
import sys
import json
import traceback
# Ensure suite dir is on path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from converter.workflow import ConversionWorkflow
def get_param(name: str, default: str | None = None) -> str:
val = os.environ.get(f"PARAM_{name}")
if val:
return val.strip()
if default is not None:
return default
raise ValueError(f"缺少必填参数: {name}")
def parse_epsg(raw: str) -> str | None:
"""Normalize EPSG string. '4326''EPSG:4326'. 'auto' / '' → None."""
raw = raw.strip()
if not raw or raw.lower() in ("auto", "none", ""):
return None
if raw.upper().startswith("EPSG:"):
return raw.upper()
return f"EPSG:{raw}"
def main():
input_file = get_param("INPUT_FILE")
output_file = get_param("OUTPUT_FILE")
from_crs_raw = get_param("FROM_CRS", "auto")
to_crs_raw = get_param("TO_CRS", "EPSG:4490")
output_format = get_param("OUTPUT_FORMAT", "auto")
# Validate input
if not os.path.isfile(input_file):
raise FileNotFoundError(f"输入文件不存在: {input_file}")
from_crs = parse_epsg(from_crs_raw)
to_crs = parse_epsg(to_crs_raw)
# Ensure output dir exists
out_dir = os.path.dirname(output_file)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
wf = ConversionWorkflow()
result = wf.run(
input_path=input_file,
output_path=output_file,
from_crs=from_crs,
to_crs=to_crs,
output_format=output_format,
)
# Mercator standard output
print("=== SUITE_OUTPUT ===")
print(json.dumps(result, ensure_ascii=False, indent=2))
print("=== END_SUITE_OUTPUT ===")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(json.dumps({
"success": False,
"error": str(e),
"traceback": traceback.format_exc(),
}, ensure_ascii=False))
sys.exit(1)
+39
View File
@@ -0,0 +1,39 @@
name: coord-converter
version: 1.0.0
platform: linux
params:
input_file:
type: string
required: true
desc: "输入文件路径,支持 .txt / .csv / .xlsx / .shp"
output_file:
type: string
required: true
desc: "输出文件路径,支持 .txt / .csv / .xlsx / .shp"
from_crs:
type: string
required: false
default: "auto"
desc: "源坐标系 EPSG 编码,如 EPSG:4326auto 表示自动识别(仅 SHP 支持)"
to_crs:
type: string
required: false
default: "EPSG:4490"
desc: "目标坐标系 EPSG 编码,默认 EPSG:4490CGCS2000"
output_format:
type: string
required: false
default: "auto"
desc: "输出格式 auto/txt/csv/xlsx/shpauto 根据 output_file 扩展名推断"
steps:
- name: convert
runtime: python3
base_image: gis-base:latest
script: run_suite.py
inputs:
- PARAM_INPUT_FILE
- PARAM_OUTPUT_FILE
- PARAM_FROM_CRS
- PARAM_TO_CRS
- PARAM_OUTPUT_FORMAT