这几年做地图可视化,总会接到一种听起来很简单、做起来想骂人的需求:项目方给的不是测绘院的标准数据,而是设计或运营部门甩过来一张SVG——园区平面图、店铺划分图、社区边界图。图形画得明明白白,却没法直接进地图引擎做区域高亮、热力统计、空间分析。把自定义SVG图片转成GeoJSON格式文件,概括成一句话很轻松,真操作起来其实要一次解决三件事:SVG路径怎么解析、平面像素坐标怎么映射成经纬度、属性字段怎么装进GeoJSON的Feature里。
这篇文章就是围绕这三件事,把我实际跑通的几条转换路线、脚本和避坑清单完整梳理一遍。适合手头有SVG要转GeoJSON、又不清楚从哪里下手的GIS数据处理、前端开发和可视化项目从业者。不管你打不打算写代码,我建议把全文都看完——因为坑不在格式转换,而在格式背后的坐标语义。
1. 从SVG到GeoJSON,先弄明白两套坐标体系
1.1 SVG是画布坐标,GeoJSON是地理坐标
SVG本质上是一套矢量绘图语言,它描述的是画布上的几何。viewBox定义了绘图区域,原点在左上角,x轴向右,y轴向下,单位通常是像素。比如<path d="M 100 100 L 200 100 L 150 200 Z">就是一个三角形,坐标数值直接对应画布上的位置。SVG里图形的形状靠path指令控制:M是落笔、L是画直线、C是三次贝塞尔曲线、A是圆弧,还有transform属性做平移、缩放、旋转。这些细节在后续解析脚本里全都绕不开。
GeoJSON则是一种基于JSON的地理数据编码规范,描述的是地球表面的空间对象。核心结构是FeatureCollection,里面包着多个Feature,每个Feature有geometry和properties两部分。geometry的类型是Point、LineString、Polygon这种,坐标是经纬度,按照规范一律写成经度在前、纬度在后的数组,Polygon的坐标环还要闭合,外环内环都有方向规定。
所以,这个问题的本质不是“换个格式”,而是把“画布像素坐标”翻译成“地理经纬度坐标”。如果你只是把SVG里的数值原样塞进GeoJSON外壳,那产出的东西在GIS软件里会显示到海上去,用不了。
1.2 看似“另存为”,实际要过三关
第一关是路径解析。SVG的path不只有直线,设计师随手画的贝塞尔曲线、圆弧在GeoJSON里没有对应类型,必须转换成密集的折线点列。转换精度高不高,取决于采样策略。
第二关是坐标投影。SVG的像素坐标没有地理意义,必须基于控制点做仿射变换,才能映射到经纬度。这一关躲不过去,后面我会专门讲。
第三关是属性装配。一个只有几何没有属性的GeoJSON,在很多平台里只能算半成品。名称、类型、面积这些字段如果能跟着几何一起落进properties,后面做查询、做筛选、做样式渲染都会顺手很多。
没绕过这三关的转换,做出来要么是坐标都是几千几万的“伪GeoJSON”,要么是没法在地图上显示的废数据。所以后面给出的每种方案,我都会按这三关来对标。
2. 不写一行代码:在线工具与矢量软件的快速路线
2.1 用mapshaper实现浏览器里的快速转换
如果不想碰代码,我第一个推荐mapshaper.org。这个Web工具本来做地图简化很出名,很多人不知道它也能直接导入SVG。操作非常直白:打开网站,把SVG文件拖进页面,左侧会生成图形预览;确认形状没丢失之后,点Export,选GeoJSON,文件就下载下来了。
这个方案适合做三件事:快速验证SVG内部结构、做格式中转、检查几何合法性。mapshaper的预览能直接暴露路径缺失和图层错位的视觉问题,比打开代码检查快得多。控制台上还能输入filter-sliver清除细碎小图形、用simplify做顶点简化,输出文件可以瘦身不少。
但要注意一个关键限制:mapshaper默认把SVG坐标当成一个平面坐标系统来处理,它不会自动变成经纬度。所以用这个工具拿到手的GeoJSON仍然带着原始像素值,只能算“几何结构正确、坐标体系未落地”的半成品。我的习惯是:先用它快速看形状,真正用于交付前,再走一遍坐标配准流程。在线工具体验再好,也代替不了地理配准这一步。
2.2 QGIS + Inkscape 的离线转换链路
更专业一点的离线路线是先用Inkscape把SVG另存为DXF,再用QGIS导入DXF,最后右键图层导出GeoJSON。这个链路适合已经有QGIS工作流、需要在转换的同时编辑属性的团队。
具体步骤是:先用Inkscape打开SVG,文件、另存为,格式选Desktop CAD DXF。如果SVG里有文字,记得先把文字转成路径,否则DXF里可能丢失字形。然后打开QGIS,菜单里的图层、添加图层、添加矢量图层,选中刚才的DXF。QGIS加载进来之后会看到一大堆线段或多边形要素。右键图层,选导出、要素另存为,格式选GeoJSON,文件名自己定,CRS这里务必选EPSG:4326,这样输出的坐标才是经纬度。
这条链路的优点是可重复、可控、能在QGIS里直接编辑属性表;缺点是DXF默认也是平面坐标,跟mapshaper一样不会自动变成经纬度。而且SVG转DXF这一步,曲线可能会被拆成多段短线,导致要素数量和结构变复杂。所以我通常把这条路当作“人工精修通道”,而不是全自动批量转换的首选。数据量大了,还得上脚本。
3. 批量转换:用Python脚本把SVG路径解析成GeoJSON
3.1 准备三个依赖库
一次只转一两个文件,用在线工具没问题。一旦涉及批量处理、定期更新或者要在自动化流程里转换,就得写脚本。我这里用Python组合了三个库:
svgpathtools:专门解析SVG路径,能正确处理M/L/C/S/Q/A这些指令,还能按弧长对曲线采样。shapely:构建多边形、处理坐标闭合、判断环方向,对GeoJSON的geometry做结构化输出。pyproj:做坐标参考系之间的转换,比如从平面投影转到WGS84经纬度。
安装方式很简单:
pip install svgpathtools shapely pyproj为什么不自己用正则解析path?因为SVG path语法里有绝对坐标、相对坐标、各种曲线指令,加上transform的嵌套,手写解析器很快会在边界条件下翻车。成熟的库能把路径对象化,直接拿到坐标点,省掉一大截底层工作。
3.2 核心脚本:路径解析、几何构建与坐标变换
下面是经过多次项目打磨后的核心脚本骨架。第一步是解析SVG里的所有path元素,把它们转换成shapely的几何对象:
import json from xml.dom import minidom import svgpathtools from shapely.geometry import Polygon, MultiPolygon, mapping def parse_svg_paths(svg_file): doc = minidom.parse(svg_file) geometries = [] for path_elem in doc.getElementsByTagName("path"): d = path_elem.getAttribute("d") path = svgpathtools.parse_path(d) subpaths = path.continuous_subpaths() polygons = [] for subpath in subpaths: # 采样点数按路径长度动态决定,避免曲线细节丢失 n = max(20, int(subpath.length()) // 2) points = [] for i in range(n + 1): t = i / n pt = subpath.point(t) points.append((pt.real, pt.imag)) if len(points) >= 3: polygons.append(Polygon(points)) if len(polygons) == 1: geometries.append(polygons[0]) else: geometries.append(MultiPolygon(polygons)) return geometriessvgpathtools的path.point(t)返回一个复数,实部对应x,虚部对应y。这一步输出的是还没有地理意义的平面坐标Polygon。
第二步是坐标变换函数。把平面坐标的Polygon整体替换成经纬度坐标:
def transform_coords(coord, params): x, y = coord lon = params["a"] * x + params["b"] * y + params["c"] lat = params["d"] * x + params["e"] * y + params["f"] return (lon, lat) def apply_transform(geom, params): if geom.geom_type == "Polygon": exterior = [transform_coords(c, params) for c in geom.exterior.coords] holes = [ [transform_coords(c, params) for c in ring.coords] for ring in geom.interiors ] return Polygon(exterior, holes) if geom.geom_type == "MultiPolygon": return MultiPolygon([ apply_transform(poly, params) for poly in geom.geoms ]) return geom第三步是组装FeatureCollection并写文件:
def build_feature_collection(geoms, props_list): features = [ { "type": "Feature", "properties": props, "geometry": mapping(geom), } for geom, props in zip(geoms, props_list) ] return {"type": "FeatureCollection", "features": features} with open("output.geojson", "w", encoding="utf-8") as f: json.dump(fc, f, ensure_ascii=False, indent=2)到这里,一个基本的转换脚本就成型了。后面要做的就是把仿射变换的params算出来。
3.3 绕不开的细节:曲线采样、子路径拆分与闭合环
脚本跑起来容易,跑出可用的结果难。我踩过最深的坑有三个,这里单独拎出来讲。
第一个是曲线采样。SVG里的贝塞尔曲线在GeoJSON里没有对应类型,必须离散成点列。如果你用固定数量采样,比如每条子路径永远取50个点,那条很长的弯曲边界会被拉出明显的折角感;如果取的点太多,文件体积又吃不消。我的做法是按子路径长度动态决定采样数:n = max(20, int(subpath.length()) // 2)。路径越长、曲率越大,生成的顶点越多,比较稳妥。
第二个是子路径拆分。一个SVG的path元素里,经常画着多个子图形,比如一个“回”字结构。如果不拆分,直接拿这个path点列去构造Polygon,shapely很可能报“Invalid polygon”或者生成一个内部互相缠绕的错误面。path.continuous_subpaths()会把连续的子路径拆开,每个子路径单独构面,最后再组装成MultiPolygon,基本不会出错。
第三个是闭合环。GeoJSON规范里Polygon环必须闭合,也就是首尾坐标相同。shapely构建Polygon时会自动补上闭合点,所以按规范输出没问题。但如果你对接到某些严格要求方向规范的程序,最好再用shapely.ops.orient统一一下外环逆时针、内环顺时针:
from shapely.geometry import Polygon from shapely.ops import orient poly = orient(poly, sign=1.0)这个操作量很小,但能让文件在绝大多数GIS平台里表现一致,减少后续平台兼容性麻烦。
4. 坐标落地:把像素坐标换算成经纬度
4.1 找三个分散的控制点
SVG里的数字是像素,GeoJSON要的是经纬度,中间缺一次坐标变换。最务实的做法是:在原始SVG上找三个分散且特征明显的点,比如地块角点、路口交叉点、建筑转角,记录它们在SVG里的坐标;再到在线地图上找到对应位置,读出经纬度。
控制点至少要有3个,越多越稳。只有两个点只能算平移和等比例缩放,没法纠正旋转、横向纵向比例不一致的问题。我习惯在图上选出三个点,分布成三角形,尽量覆盖整个图形范围。选取时要避免选在一条直线上,否则解算矩阵会退化,手工取点时尤其要注意这一点。
举个例子,假设我在SVG里记录的三个控制点如下:
| 控制点 | SVG坐标 | 地图经纬度 |
|---|---|---|
| A | (100, 100) | (113.12345, 22.54321) |
| B | (900, 600) | (113.16890, 22.57654) |
| C | (120, 760) | (113.12000, 22.58999) |
注意,这些数值要根据你的实际项目去填,不要照抄。我的经验是选点之后把表格存成一个CSV文件,留着以后校准用,别只写在纸面上。
4.2 用最小二乘解出仿射变换参数
仿射变换有6个未知数:经度计算要a、b、c,纬度计算要d、e、f。三个控制点能提供6个方程,刚好可以解;超过三个点,就直接用最小二乘,鲁棒性更好。在Python里可以用numpy一行解:
import numpy as np # 控制点数据:像素坐标和经纬度 points_px = np.array([ [100, 100], [900, 600], [120, 760], ]) points_gps = np.array([ [113.12345, 22.54321], [113.16890, 22.57654], [113.12000, 22.58999], ]) A = [] b_lon = [] b_lat = [] for (x, y), (lon, lat) in zip(points_px, points_gps): A.append([x, y, 1, 0, 0, 0]) A.append([0, 0, 0, x, y, 1]) b_lon.append(lon) b_lat.append(lat) A = np.array(A) coeff_lon, *_ = np.linalg.lstsq(A, b_lon, rcond=None) coeff_lat, *_ = np.linalg.lstsq(A, b_lat, rcond=None) def pixel_to_lonlat(x, y): lon = coeff_lon[0] * x + coeff_lon[1] * y + coeff_lon[2] lat = coeff_lat[0] * x + coeff_lat[1] * y + coeff_lat[2] return lon, lat实际算完参数后,要做一次反向检验:把每个控制点的像素坐标代回函数,算出的经纬度跟实测经纬度之间的差值就是残差。残差如果超过几十米,说明控制点选得不够好,或者SVG本身存在未处理的非仿射变形。这时候别急着往下走,重新选点再算一次,代价比后面返工小得多。
而之前在脚本里留的apply_transform函数,就是把这里的pixel_to_lonlat按params字典传进去用的。把coeff_lon和coeff_lat装进一个dict,再用第3节的函数对每个几何做整体替换即可。
4.3 输出之前,先确认坐标系
GeoJSON按规范必须使用WGS84经纬度,坐标数组顺序必须是经度在前、纬度在后。这一点极其容易被忽略。很多同事第一次做转换,导出的坐标是七位数的平面投影值,直接塞进GeoJSON里,结果在QGIS或地图引擎里显示到完全不对的位置。原因就是GeoJSON不认投影坐标,只认经纬度。
如果你拿到的源数据已经带投影坐标,要么在输出前用pyproj做一次转换:
from pyproj import Transformer transformer = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True) lon, lat = transformer.transform(x3857, y3857)要么在QGIS里手动导出时,把CRS明确选成EPSG:4326再来保存。这一步如果错了,后续所有地图叠加都会偏。
我自己现在的标准操作是:GeoJSON文件旁边永远放一个控制点表或README,记录源图坐标、控制点、采用的变换方式。下一次如果坐标系定义有变化,至少要重新投影时,不用再从零摸索。
5. 实战记录:把某社区轮廓SVG变成可用的GeoJSON
5.1 动手前先给SVG做个体检
拿到SVG之后,我不急着跑脚本,先用文本编辑器打开看一眼。重点查五件事:viewBox尺寸、有没有transform、path数量、里面有没有文字对象、坐标单位是不是px。
常见情况是这样一段结构:
<svg width="1000" height="800" viewBox="0 0 1000 800" xmlns="..."> <path d="M 50 50 L 800 50 L 700 600 Z" fill="#eee" /> <path d="M 100 150 A 20 20 0 0 1 200 150 Z" fill="#ccc" /> </svg>判断标准其实很简单:所有坐标都能直接对应viewBox里的像素范围,就说明文件是“干净”的。如果发现类似<g transform="translate(10, 20) scale(0.5)">的写法,后面脚本解析时要格外小心,因为子坐标是局部坐标。这种文件我一般先用Inkscape打开,路径转成对象之后保存为标准SVG,再进入转换流程,省得自己在代码里处理transform矩阵。
5.2 转换过程与结果验证
我的处理顺序是:先跑第3节的解析脚本,生成一个未配准的临时GeoJSON;然后再跑第4节的坐标变换,把像素坐标换成经纬度;最后用QGIS或在线工具打开,叠加真实底图来验证。
一个典型的输出结构长这样:
{ "type": "Feature", "properties": { "name": "地块A", "category": "绿地" }, "geometry": { "type": "Polygon", "coordinates": [ [ [113.1234, 22.5432], [113.1256, 22.5439], [113.1278, 22.5412], [113.1234, 22.5432] ] ] } }验证环节我有一个固定的检查动作:把SVG原始图形和转换后的GeoJSON放到同比例尺的画布里对比。如果形状出现了上下翻转或左右镜像,多半是y轴符号没处理好。因为SVG的y轴朝下,而经纬度纬度是北向为正,仿射变换里的y系数会自动变成一个负值。三个控制点解算出来的结果里如果y系数全为正,就要回头检查控制点是不是填反了。
5.3 给每个Feature补齐属性
转换只解决几何问题,真正让GeoJSON在业务里跑起来的是properties字段。比如某社区轮廓项目里,我给跟路径顺序对应的地块配了名称、类型、面积三组属性:
properties_list = [ {"name": "地块A", "type": "绿地", "remark": ""}, {"name": "地块B", "type": "停车场", "remark": "东入口"}, {"name": "地块C", "type": "住宅", "remark": ""}, ]脚本里把properties_list和geoms按顺序zip起来,就能一次性输出完整FeatureCollection。如果转换时没留属性,也有补救办法:先在QGIS里给每个Feature配一个唯一ID,导出CSV,编辑完再用ID字段join回GeoJSON。虽然绕一圈,但不用重新做坐标变换,效率也不差。
没有属性的纯几何GeoJSON在绝大多数地图平台里只是一张没有意义的矢量图,做交互、查询、筛选都无从谈起。尤其是项目交付场景,对方一定会问“这块地叫什么名字”,所以属性装配这步能提前做尽量提前做。
6. 避坑手册:常见的七个问题与一组实操心得
6.1 常见问题速查表
下面这张表是我这几年实际操作中反复遇到的问题汇总,每个都踩出过教训:
| 问题 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 转换后图形镜像颠倒 | 图形上下翻转,左右不变 | SVG的y轴向下,经纬度纬度向北为正 | 用至少3个控制点解算仿射变换,检查y方向系数为负 |
| 输出坐标全是几千几万 | 文件在GIS里显示到海里、撒哈拉 | 未做地理配准,直接输出了像素坐标 | 将坐标换算到EPSG:4326后再输出 |
| 多边形不闭合 | 渲染时区域镂空或缺失 | 路径没有显式Z闭合,或输出坐标环首尾不一致 | 用shapely构建Polygon自动闭合,并在输出后抽查首尾坐标 |
| 曲线变锯齿 | 贝塞尔弧变成折线感明显 | 采样点数不足或均匀采样不适合大曲率段 | 按路径长度动态采样,路径越长点数越多 |
| 带洞多边形混乱 | 洞变成独立面,或被外环吞掉 | 子路径未拆分,环方向混乱 | 用continuous_subpaths拆分,再用orient统一方向 |
| 中文属性乱码 | 打开GeoJSON出现乱码 | 文件写成了GBK,或json.dump未关ensure_ascii | 写文件用UTF-8编码,ensure_ascii=False |
| 多条路径错位 | 一部分图形和其余图形对不上 | SVG存在transform未处理 | 先用Inkscape做路径转对象,或脚本里应用matrix |
6.2 几个能直接提高效率的实操心得
最后分享几组我固定下来的工作习惯。先小后大:拿到复杂SVG后,先只抽一条简单路径测试整条链路。等参数全部调好,再跑全量。全量出错时,用二分法把文件拆成两半,逐段定位问题,效率比整体排查高得多。
控制点表一定单独留档。每次配准都记录像素坐标和经纬度,存成CSV文件放项目目录里。后面一旦需要重新投影、复核精度、或换一个数据源叠加,这套控制点可以直接复用,省掉重新选点的时间。
用mapshaper做快速质检。转换完的GeoJSON拖回mapshaper,能直观看到哪些图形是无效多边形。需要精简文件时,还可以顺手做simplify,对不要求高精度的展示场景很有效。但要注意,简化后顶点密度下降,如果后续要用来算精准面积,一定保留一份未简化的原文件。
最后,如果SVG是从AI或Figma这类设计工具导出的,图形里可能有大量文字对象和组合对象,直接转换必然丢失。我现在的习惯是先用矢量编辑器做一次路径转对象,再进入转换流程。这个预处理步骤虽然看起来多此一举,它能挡住80%的“路径消失、文字缺失”类问题。
我个人这几年最大的感受是:SVG转GeoJSON看似一次格式转换,本质上是坐标系的翻译和路径数据的清洗。把工具链固定下来之后,后来接到类似需求基本半小时内能交付。最后再分享一个我自己的习惯:配准用的控制点表一定和GeoJSON文件放在一起保存。下次要改坐标系或复核精度时,你会回来感谢这个建议的。