commit e9f87fc6939edd7e90ede69b11c602697e8cddd7 Author: mayihua Date: Wed Jul 29 12:49:08 2026 +0800 feat: CoordConverter 坐标转换套件 v1.0.0 TXT / Excel(CSV) / SHP 三种格式任意互转 支持 WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator 坐标系转换 由 Mapo 🗺️ 自动生成 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c23c21c --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# 🗺️ CoordConverter — 坐标转换套件 (Mercator Suite) + +

+ 地理信息行业专用 · TXT / Excel / SHP 互转 · 多坐标系转换 +
+ Mercator SuitePython 3pyprojgeopandas +

+ +--- + +## 📌 一句话 + +TXT、Excel(CSV)、SHP 三种格式的坐标数据**任意互转**,同时支持 **WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator** 坐标系转换。 + +## 🚀 快速使用(作为 Mercator 套件) + +```bash +agc run /tmp/coord-output \ + --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 +``` diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..51ec977 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1 @@ +# cli/__init__.py diff --git a/cli/cli_runner.py b/cli/cli_runner.py new file mode 100644 index 0000000..30c67f1 --- /dev/null +++ b/cli/cli_runner.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() diff --git a/converter/__init__.py b/converter/__init__.py new file mode 100644 index 0000000..c3c5333 --- /dev/null +++ b/converter/__init__.py @@ -0,0 +1,2 @@ +# converter/__init__.py +from .workflow import ConversionWorkflow diff --git a/converter/__pycache__/__init__.cpython-312.pyc b/converter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..70c522c Binary files /dev/null and b/converter/__pycache__/__init__.cpython-312.pyc differ diff --git a/converter/__pycache__/workflow.cpython-312.pyc b/converter/__pycache__/workflow.cpython-312.pyc new file mode 100644 index 0000000..ae17aef Binary files /dev/null and b/converter/__pycache__/workflow.cpython-312.pyc differ diff --git a/converter/workflow.py b/converter/workflow.py new file mode 100644 index 0000000..e52a19e --- /dev/null +++ b/converter/workflow.py @@ -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, + } diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..91db526 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# core/__init__.py diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..5934fa7 Binary files /dev/null and b/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/core/__pycache__/coord_transform.cpython-312.pyc b/core/__pycache__/coord_transform.cpython-312.pyc new file mode 100644 index 0000000..5702f08 Binary files /dev/null and b/core/__pycache__/coord_transform.cpython-312.pyc differ diff --git a/core/__pycache__/data_model.cpython-312.pyc b/core/__pycache__/data_model.cpython-312.pyc new file mode 100644 index 0000000..26be20e Binary files /dev/null and b/core/__pycache__/data_model.cpython-312.pyc differ diff --git a/core/coord_transform.py b/core/coord_transform.py new file mode 100644 index 0000000..5a7ccad --- /dev/null +++ b/core/coord_transform.py @@ -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": "互联网地图投影"}, + ] diff --git a/core/data_model.py b/core/data_model.py new file mode 100644 index 0000000..08be83e --- /dev/null +++ b/core/data_model.py @@ -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) diff --git a/examples/sample_points.csv b/examples/sample_points.csv new file mode 100644 index 0000000..d66b1fd --- /dev/null +++ b/examples/sample_points.csv @@ -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,终点 diff --git a/examples/sample_points.txt b/examples/sample_points.txt new file mode 100644 index 0000000..e760a69 --- /dev/null +++ b/examples/sample_points.txt @@ -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 终点 diff --git a/file_io/__init__.py b/file_io/__init__.py new file mode 100644 index 0000000..bfcae9f --- /dev/null +++ b/file_io/__init__.py @@ -0,0 +1,4 @@ +# file_io/__init__.py +from .txt_handler import TxtHandler +from .excel_handler import ExcelHandler +from .shp_handler import ShpHandler diff --git a/file_io/__pycache__/__init__.cpython-312.pyc b/file_io/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..7067434 Binary files /dev/null and b/file_io/__pycache__/__init__.cpython-312.pyc differ diff --git a/file_io/__pycache__/excel_handler.cpython-312.pyc b/file_io/__pycache__/excel_handler.cpython-312.pyc new file mode 100644 index 0000000..3f1475e Binary files /dev/null and b/file_io/__pycache__/excel_handler.cpython-312.pyc differ diff --git a/file_io/__pycache__/shp_handler.cpython-312.pyc b/file_io/__pycache__/shp_handler.cpython-312.pyc new file mode 100644 index 0000000..1df0d70 Binary files /dev/null and b/file_io/__pycache__/shp_handler.cpython-312.pyc differ diff --git a/file_io/__pycache__/txt_handler.cpython-312.pyc b/file_io/__pycache__/txt_handler.cpython-312.pyc new file mode 100644 index 0000000..5a0dc04 Binary files /dev/null and b/file_io/__pycache__/txt_handler.cpython-312.pyc differ diff --git a/file_io/excel_handler.py b/file_io/excel_handler.py new file mode 100644 index 0000000..d5cf0ae --- /dev/null +++ b/file_io/excel_handler.py @@ -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 diff --git a/file_io/shp_handler.py b/file_io/shp_handler.py new file mode 100644 index 0000000..a467378 --- /dev/null +++ b/file_io/shp_handler.py @@ -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") diff --git a/file_io/txt_handler.py b/file_io/txt_handler.py new file mode 100644 index 0000000..16f9b54 --- /dev/null +++ b/file_io/txt_handler.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a391c99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyproj +geopandas +pandas +openpyxl +shapely diff --git a/run_suite.py b/run_suite.py new file mode 100644 index 0000000..69d334d --- /dev/null +++ b/run_suite.py @@ -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) diff --git a/workflow.yaml b/workflow.yaml new file mode 100644 index 0000000..030c135 --- /dev/null +++ b/workflow.yaml @@ -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