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