feat: CoordConverter 坐标转换套件 v1.0.0
TXT / Excel(CSV) / SHP 三种格式任意互转
支持 WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator 坐标系转换
由 Mapo 🗺️ 自动生成
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# cli/__init__.py
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# converter/__init__.py
|
||||||
|
from .workflow import ConversionWorkflow
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# core/__init__.py
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": "互联网地图投影"},
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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,终点
|
||||||
|
@@ -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 终点
|
||||||
@@ -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.
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pyproj
|
||||||
|
geopandas
|
||||||
|
pandas
|
||||||
|
openpyxl
|
||||||
|
shapely
|
||||||
@@ -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)
|
||||||
@@ -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:4326;auto 表示自动识别(仅 SHP 支持)"
|
||||||
|
to_crs:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "EPSG:4490"
|
||||||
|
desc: "目标坐标系 EPSG 编码,默认 EPSG:4490(CGCS2000)"
|
||||||
|
output_format:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "auto"
|
||||||
|
desc: "输出格式 auto/txt/csv/xlsx/shp;auto 根据 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
|
||||||
Reference in New Issue
Block a user