From e9f87fc6939edd7e90ede69b11c602697e8cddd7 Mon Sep 17 00:00:00 2001 From: mayihua Date: Wed, 29 Jul 2026 12:49:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CoordConverter=20=E5=9D=90=E6=A0=87?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E5=A5=97=E4=BB=B6=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TXT / Excel(CSV) / SHP 三种格式任意互转 支持 WGS84 / CGCS2000 / Xian80 / Beijing54 / Web Mercator 坐标系转换 由 Mapo 🗺️ 自动生成 --- README.md | 53 +++++++ cli/__init__.py | 1 + cli/cli_runner.py | 57 ++++++++ converter/__init__.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 220 bytes .../__pycache__/workflow.cpython-312.pyc | Bin 0 -> 4481 bytes converter/workflow.py | 90 ++++++++++++ core/__init__.py | 1 + core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes .../coord_transform.cpython-312.pyc | Bin 0 -> 3654 bytes core/__pycache__/data_model.cpython-312.pyc | Bin 0 -> 3290 bytes core/coord_transform.py | 68 +++++++++ core/data_model.py | 57 ++++++++ examples/sample_points.csv | 6 + examples/sample_points.txt | 7 + file_io/__init__.py | 4 + file_io/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 311 bytes .../__pycache__/excel_handler.cpython-312.pyc | Bin 0 -> 4648 bytes .../__pycache__/shp_handler.cpython-312.pyc | Bin 0 -> 3286 bytes .../__pycache__/txt_handler.cpython-312.pyc | Bin 0 -> 7119 bytes file_io/excel_handler.py | 72 ++++++++++ file_io/shp_handler.py | 53 +++++++ file_io/txt_handler.py | 131 ++++++++++++++++++ requirements.txt | 5 + run_suite.py | 79 +++++++++++ workflow.yaml | 39 ++++++ 26 files changed, 725 insertions(+) create mode 100644 README.md create mode 100644 cli/__init__.py create mode 100644 cli/cli_runner.py create mode 100644 converter/__init__.py create mode 100644 converter/__pycache__/__init__.cpython-312.pyc create mode 100644 converter/__pycache__/workflow.cpython-312.pyc create mode 100644 converter/workflow.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-312.pyc create mode 100644 core/__pycache__/coord_transform.cpython-312.pyc create mode 100644 core/__pycache__/data_model.cpython-312.pyc create mode 100644 core/coord_transform.py create mode 100644 core/data_model.py create mode 100644 examples/sample_points.csv create mode 100644 examples/sample_points.txt create mode 100644 file_io/__init__.py create mode 100644 file_io/__pycache__/__init__.cpython-312.pyc create mode 100644 file_io/__pycache__/excel_handler.cpython-312.pyc create mode 100644 file_io/__pycache__/shp_handler.cpython-312.pyc create mode 100644 file_io/__pycache__/txt_handler.cpython-312.pyc create mode 100644 file_io/excel_handler.py create mode 100644 file_io/shp_handler.py create mode 100644 file_io/txt_handler.py create mode 100644 requirements.txt create mode 100644 run_suite.py create mode 100644 workflow.yaml 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 0000000000000000000000000000000000000000..70c522c10bc847a54d15598c770e576ff7e5cb2f GIT binary patch literal 220 zcmX@j%ge<81i7V|S&l&ZF^B^LOi;#W0U%>KLkdF*V-72CY{G#l%ocwY>O{QBM@1hR-0=e!1!w<>!~^>*W`u<|XGO zmg|F56&EBXr|Ku?=NF~uCW8$tNiEVXF3l_fiGmCV^7Z56GxIV_;^XxSDt~d<DtMF?%aE?6C2w%oO?~u zat#@(hO-6Yjz?T5!uxAwXj{;vsT$Q({+Re<(v;MQn5$Ikwl;&ma2uqF{Z)srE0i%twPxi`<`(R|=$)foV7D-OoAEPDy6dMT;UUETogUl{@ zK-MIAYkgMoLBE+shmh283Q3Lc)71?kEhp%Z|D?blP}0xISdA;`;Ix9Di6xckpn}Co zS=De-?XlxAT9C7j=$ zr&HF-Sz4#num*^#7KzfSw~<6kOu}QyHe1bS@tf;gD_Jdk&Awx^dM{BrJ&r=Qu}lYi zdiDG(>#zS|eg0?ve(Tiwn=fDe@K5W-3)e0zJP^!~6MT6gR~>#Jc#<9rW{6h4yf7Ey zFyRclnu*2YGlmUbTgC=Ax_@l=(UB*{4Q4{t4E|^|nUO~@R<1o_mjIf=rX~9yJ5_04V_- z^v#(BbK$8Ih6^?qCaZ6cn_=xHsOr-|X3-k&&1t{vU*$Vj_|8?ncZKgQ@qM!g*0_cR z_nbRFUgFwk_kZScuX)>+JMUf+mwtSH;G*<#{G<3Mk01Tym{5BBr=^3@<&opd$K~br zSjjuF%uSSC?%B}|*Z>(^ECrj#)0$z&vX;SVaVt^?225mq4$LCbDcq-1i8{W>3e{ra zMxvg%7a`p6-LuA#MpiY;7L2l%Y_t8Yy|OexnZ&}+CfU#0vW(6oz;kRbI&EPo%j&G^ z)#`Yb96J3rdZ#`PSYrWeTxLJ4LF^x=;%T{N)ioCo4q6)f{;p_7Q+5Ua`SV|alZeSU z7{f{`NX#<`NC_J(88;XWP{WAoa|Sz;RA&rEotz5UZn9yJ1BHQ`wlEnv$&v;{Fd?%O zPO!dMT>s-6S3i7peeS($AG~ob|H}HS=hol+-RFOK?b^$Kwtye9VK+=R5srxwU@sxW z&|rw%6}J-N2VywjU_vT7Wl%Adm_NWc7{ml{h8N4Kl6+1Us;&;-0m3e*Dh4u({^9WD zM;FIVkCmEsoU>nQ3YUGY3op#QQ1bOGb3OkHr{&hJqP!Hl7+XI2i$ve8tjhgVpE#y!PDEk|}jvU%Vz;Ta}C?@#*Q)E11sPlA-#m$4kX0lPQsIW<8DH319FoJhbU94AsUm1F&v=6 zsd#!qB@0>#^i&lmXo>(@#{Dp~8>+es$Sf+ibQO;-9lLmR#rr6z!R23gaPGmpT8I{z zLZ)!I35+3TDG zI8L_3+>-N@En8N@?}D>_+$0xC!0~u#C1K+nLRVgg~trujEMq4y5XwE zylRId79faNHSC}T(t2vcHBdp=M`As)K!<~BzUOYB--unC5yr0D6J*Y|vLyyNbY_N9 z-4xOtiCcJ)ET^+sr_SMzbtnF-&go9mOI_6J-h#-i93jiYD&$<-$}n1RiSz^#>8HWM zvM$|~Ff*!ePCHG{H?Sz{hVlP*6=J^IN^KPntimViF>`a0?$$lld7lChX1!UT)@Y62 zdS}ZELVmReA@i$-tV;{jzw+u0?a+}ZYh~Rf)N_O_^L)3MSAWy_Ar|RQ-60UuFbmv( zIGAlE+nBcN9K0G0c_N){)QFQZi%#n{>jYW9MvhdK`>h*f0~#??vuMOj&9dp|6U4N) z6+K{mZME5^EtyhNUD~WSCx}DXR&+DVUib_`5;4!19EGSa3!;)qYoR7H|1emR<>LEb7AEg+*ry;H(o}<* zF{l%UukI9t<55kVG@Noelae8AmJJ(*btJX+%U+=P1Khloti~oaj2!Y*imX8%<*Mk`@)NJFBVRgeEm7L zVn>0_Oa7e&>D|dQlQ~D(-oap;E`b+>gpVeeXVh z=J`(>2MWFU-o?Jx`j#69a*qJ{j*;%&tKIjlbl-R0fBuP5_rr5zxr6zGYi+&79lsg) z^+2gDd;`&*{?lxp%I{rk=`0*9?mct3_-v^ql&4;2woBe%DmIu72XE`i4bP93TW?=& z4X?C@=SHuz-gc>VPjMhO`W0NOx7c|uRr2r4IoA9=MMufMJLkCC+;+C9@Jy+_f3^Ld zmG*m zwd<~xuDe#d?qBJ;ztr_$xqC-B&{^*8eK&F@a^1%@@D;>4`LAz;kbm%Nqj5kb!28G_ zILNaf-5ogSV?)f?7$$!?z^XVwNJmq$AQ-$Lq!cNWB)nS?p3Ov)wGoFPNQx*3m;`D- zV_ekmF<{?AW>7c5fCoWf5&~)UFpyK|Gxjbx|3e3;a__F|wtgF55Gx3pCB|&ZE$xNj z(}@ZLqVnGFEomQgnEaK_+bU%CmBBl|B5idSvO%a7KM9RNE2`ljF*+GPUbQ-(s9-rv z429f6PGS-(jV4oS{$mm9#!@x>Kr9YdjVmNqwGfYq=P{bC5t;l`t&fp_YSh1iM7BOc z0xBli3?>1`;75LPWMpjo@MB}+W@c)t&GbH-{-UZb@<(V>g*Ul<)d%0qdP96y_Zq{WfTSqI(B>L6}QysJW%f5yM zeNMlDC|gI4DYv!fxP0Ijp6d+sD-2Aj@HWbm7dH?zl?Er}DFiAAnse|z&@3?`2Pi(D^9%t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5934fa7948666c77575c21318fff51aebda8fbc8 GIT binary patch literal 161 zcmX@j%ge<81i_`5S;9d2F^Gc>KC=KtrZZGBXfpb(WGG?+@;-yq{BqDQ%Fi#+*UK+R z%}dTnEY~m3FUl@1NK8)EPtMOTO3_Wu&nrtUDoHKUEiTP00f`o+>c_`t=4F<|$LkeT h{^GF7%}*)KNwq6t1scc*#Kj=SM`lJw#v*1Q3jmNoC_exI literal 0 HcmV?d00001 diff --git a/core/__pycache__/coord_transform.cpython-312.pyc b/core/__pycache__/coord_transform.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5702f0840324db1c38501a875256e6c23880471a GIT binary patch literal 3654 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 T!x`a2p+=Cqrd<(5*W3R96xr?` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..70674348d3fec370bd8f9ddf05b04584e2655ea7 GIT binary patch literal 311 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5a0dc04683951447bfebe4e022c2cecdef0e6964 GIT binary patch literal 7119 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 literal 0 HcmV?d00001 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