SHP数据清洗太耗时?GeoPandas批量处理实战(附:完整脚本)
如果你正在为“SHP数据清洗太耗时?GeoPandas批量处理实战(附:完整脚本)”这个问题头疼,通常说明你的工作已经不再是清洗一个 Shapefile,而是几十个甚至几百个 SHP 文件反复做字段整理、几何修复、坐标系统一和格式输出。本文用 GeoPandas 给出一套可复用的 SHP数据清洗流程,重点解决批量处理、规则统一、结果可检查这三个实际问题。
引言:为什么 SHP数据清洗 很容易变成重复劳动
在 GIS 项目中,SHP 数据常见来源包括自然资源、规划、测绘、第三方项目成果和历史资料归档。看起来都是 Shapefile,但实际打开后经常会遇到这些情况:
- 字段名称不统一,同一个含义可能叫
name、NAME、名称。 - 存在空几何、无效几何、自相交面、重复要素。
- 坐标系缺失或坐标系不一致。
- 属性字段过多,且包含无用字段。
- 中文字段或中文属性在不同软件中出现乱码。
- 多个目录下的 SHP 文件需要统一清洗后再入库或制图。
如果全部依赖 QGIS 或 ArcGIS Pro 手工处理,少量数据没问题;但当数据数量增加后,手工操作不仅慢,还容易因为某一步漏做而导致成果不一致。GeoPandas批量处理的优势就在于把清洗规则写成脚本,让每个 SHP 都按同一套规则处理。

背景:本文要解决的具体清洗场景
本文假设你有一个文件夹,里面存放了多个 SHP 数据,每个 SHP 代表一类矢量数据,例如地块、道路、管线、行政区或兴趣点。你的目标是使用 GeoPandas 完成以下 SHP数据清洗任务:
- 批量读取指定目录及子目录中的
.shp文件。 - 统一检查和设置坐标系。
- 修复无效几何并删除空几何。
- 统一字段名,删除无用字段。
- 处理重复要素。
- 输出到清洗后的目录。
- 生成一个简单的清洗日志,方便核查每个文件处理了什么。
这个流程适合 GIS 学生、初级 GIS 工程师、空间数据分析人员,以及需要把 SHP 数据整理后导入 PostGIS、ArcGIS Pro 或 WebGIS 平台的用户。
原理:GeoPandas清洗SHP到底在做什么
GeoPandas 是 Python 生态中常用的矢量 GIS 数据处理库。它把空间数据读成 GeoDataFrame,可以理解为“带几何字段的表格”。属性字段像 Pandas 表格一样处理,空间几何则由 Shapely 负责计算和修复。
一次完整的 SHP数据清洗通常包含三类操作:
- 属性清洗:整理字段名、删除空字段、统一编码、去除重复记录。
- 几何清洗:删除空几何、修复无效几何、统一几何类型。
- 空间参考清洗:检查 CRS,也就是坐标参考系统,并根据项目要求统一投影。
需要特别注意:GeoPandas 不是简单地替代桌面 GIS 软件,而是适合把确定性的、重复性的清洗规则自动化。对于需要人工判断的地物分类、拓扑关系认定、边界取舍,仍然建议结合 QGIS 或 ArcGIS Pro 进行抽查。
步骤:GeoPandas批量处理SHP数据的完整脚本
1. 准备 Python 环境
建议使用 Conda 环境安装 GeoPandas,因为它依赖 GDAL、Fiona、pyproj、Shapely 等底层 GIS 库。安装命令如下:
conda create -n gpd_clean python=3.11
conda activate gpd_clean
conda install -c conda-forge geopandas pyogrio shapely pandas
如果你使用 pip,也可以安装:
pip install geopandas pyogrio shapely pandas
在 Windows 环境下,如果 pip 安装 GDAL 相关依赖失败,优先使用 conda-forge,可以减少环境问题。
2. 推荐的目录结构
为了便于批量处理,建议按下面的方式组织文件:
project/
input_shp/
roads.shp
parcels.shp
village/
boundary.shp
output_clean/
logs/
input_shp 放原始数据,output_clean 放清洗结果,logs 放清洗日志。不要直接覆盖原始 SHP 文件,这是数据清洗中最重要的安全习惯之一。
3. 完整脚本:批量清洗 SHP
下面脚本可以直接保存为 batch_clean_shp.py。运行前请把 INPUT_DIR、OUTPUT_DIR 和 TARGET_CRS 改成你的实际路径和目标坐标系。
from pathlib import Path
import pandas as pd
import geopandas as gpd
INPUT_DIR = Path(r"D:/gis_project/input_shp")
OUTPUT_DIR = Path(r"D:/gis_project/output_clean")
LOG_DIR = Path(r"D:/gis_project/logs")
TARGET_CRS = "EPSG:4547"
DROP_FIELDS = [
"OBJECTID",
"FID",
"Shape_Leng",
"Shape_Area",
"备注1",
"临时字段"
]
RENAME_FIELDS = {
"NAME": "name",
"Name": "name",
"名称": "name",
"类型": "type",
"类别": "type"
}
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_rows = []
def clean_field_names(gdf):
gdf = gdf.rename(columns=RENAME_FIELDS)
new_columns = {}
for col in gdf.columns:
if col == gdf.geometry.name:
continue
new_col = str(col).strip()
new_col = new_col.replace(" ", "_")
new_col = new_col.replace("-", "_")
new_col = new_col.lower()
new_columns[col] = new_col
gdf = gdf.rename(columns=new_columns)
return gdf
def drop_unused_fields(gdf):
existing_drop_fields = [c for c in DROP_FIELDS if c in gdf.columns]
existing_drop_fields += [c.lower() for c in DROP_FIELDS if c.lower() in gdf.columns]
existing_drop_fields = list(set(existing_drop_fields))
if existing_drop_fields:
gdf = gdf.drop(columns=existing_drop_fields)
return gdf, existing_drop_fields
def repair_geometry(gdf):
before_count = len(gdf)
gdf = gdf[~gdf.geometry.isna()].copy()
gdf = gdf[~gdf.geometry.is_empty].copy()
empty_removed = before_count - len(gdf)
invalid_before = int((~gdf.geometry.is_valid).sum())
if invalid_before > 0:
gdf["geometry"] = gdf.geometry.make_valid()
invalid_after = int((~gdf.geometry.is_valid).sum())
return gdf, empty_removed, invalid_before, invalid_after
def handle_crs(gdf, target_crs):
original_crs = gdf.crs
if gdf.crs is None:
gdf = gdf.set_crs(target_crs, allow_override=True)
crs_action = "missing_crs_set_to_target"
elif str(gdf.crs) != target_crs:
gdf = gdf.to_crs(target_crs)
crs_action = "reprojected_to_target"
else:
crs_action = "already_target_crs"
return gdf, original_crs, crs_action
def remove_duplicates(gdf):
before_count = len(gdf)
non_geom_cols = [c for c in gdf.columns if c != gdf.geometry.name]
if non_geom_cols:
gdf["_geom_wkt"] = gdf.geometry.to_wkt()
subset_cols = non_geom_cols + ["_geom_wkt"]
gdf = gdf.drop_duplicates(subset=subset_cols).copy()
gdf = gdf.drop(columns=["_geom_wkt"])
else:
gdf["_geom_wkt"] = gdf.geometry.to_wkt()
gdf = gdf.drop_duplicates(subset=["_geom_wkt"]).copy()
gdf = gdf.drop(columns=["_geom_wkt"])
duplicate_removed = before_count - len(gdf)
return gdf, duplicate_removed
def safe_output_path(input_path):
relative_path = input_path.relative_to(INPUT_DIR)
output_path = OUTPUT_DIR / relative_path
output_path.parent.mkdir(parents=True, exist_ok=True)
return output_path
def clean_one_shp(shp_path):
print(f"正在处理:{shp_path}")
try:
gdf = gpd.read_file(shp_path, engine="pyogrio")
input_count = len(gdf)
input_fields = len(gdf.columns)
gdf = clean_field_names(gdf)
gdf, dropped_fields = drop_unused_fields(gdf)
gdf, empty_removed, invalid_before, invalid_after = repair_geometry(gdf)
gdf, original_crs, crs_action = handle_crs(gdf, TARGET_CRS)
gdf, duplicate_removed = remove_duplicates(gdf)
output_path = safe_output_path(shp_path)
gdf.to_file(output_path, encoding="utf-8", engine="pyogrio")
log_rows.append({
"file": str(shp_path),
"status": "success",
"input_count": input_count,
"output_count": len(gdf),
"input_fields": input_fields,
"output_fields": len(gdf.columns),
"dropped_fields": ",".join(dropped_fields),
"empty_removed": empty_removed,
"invalid_before": invalid_before,
"invalid_after": invalid_after,
"duplicate_removed": duplicate_removed,
"original_crs": str(original_crs),
"crs_action": crs_action,
"output": str(output_path),
"error": ""
})
except Exception as e:
log_rows.append({
"file": str(shp_path),
"status": "failed",
"input_count": "",
"output_count": "",
"input_fields": "",
"output_fields": "",
"dropped_fields": "",
"empty_removed": "",
"invalid_before": "",
"invalid_after": "",
"duplicate_removed": "",
"original_crs": "",
"crs_action": "",
"output": "",
"error": str(e)
})
def main():
shp_files = list(INPUT_DIR.rglob("*.shp"))
if not shp_files:
print("没有找到 SHP 文件,请检查 INPUT_DIR。")
return
for shp_path in shp_files:
clean_one_shp(shp_path)
log_df = pd.DataFrame(log_rows)
log_path = LOG_DIR / "shp_clean_log.csv"
log_df.to_csv(log_path, index=False, encoding="utf-8-sig")
print(f"处理完成,共处理 {len(shp_files)} 个 SHP。")
print(f"日志已输出:{log_path}")
if __name__ == "__main__":
main()
4. 运行脚本
进入脚本所在目录后执行:
python batch_clean_shp.py
运行完成后,重点检查两个位置:
output_clean:是否生成了清洗后的 SHP 文件。logs/shp_clean_log.csv:每个文件是否成功处理,删除了多少空几何、无效几何和重复要素。
5. 如何验证清洗结果
SHP批量处理完成后,不要直接把结果交付或入库。建议按下面步骤验证:
- 用 QGIS 或 ArcGIS Pro 打开清洗后的 SHP,确认图层能正常加载。
- 检查图层坐标是否落在正确位置,不应出现飞到国外或海上的情况。
- 打开属性表,查看字段是否按预期重命名和删除。
- 使用选择工具检查是否存在空几何或明显异常图形。
- 随机抽取几个文件,与原始数据对比要素数量变化是否合理。
- 查看
shp_clean_log.csv中status是否全部为success。
常见坑:SHP数据清洗中最容易出错的地方
1. 坐标系缺失不等于可以随便指定
脚本中如果发现 CRS 为空,会使用 set_crs 指定为目标坐标系。但这一步本质上是“声明坐标系”,不是“投影转换”。如果原始数据实际是 CGCS2000 经纬度坐标,却被你强行设为投影坐标,结果一定会错。
正确做法是:先通过数据来源、坐标数值范围、元数据或同区域参考图层判断原始坐标系,再决定是否 set_crs 或 to_crs。
2. Shapefile 字段名长度有限制
SHP 的 DBF 属性表对字段名长度有历史限制,很多软件仍按 10 个字符处理。因此,如果你希望保留较长字段名,更建议输出为 GeoPackage,而不是继续输出 SHP。
例如可以把输出部分改为:
output_path = output_path.with_suffix(".gpkg")
gdf.to_file(output_path, layer=shp_path.stem, driver="GPKG", engine="pyogrio")
3. 中文编码问题不能只靠 encoding 解决
脚本中输出使用了 encoding="utf-8",但一些旧版软件或历史数据可能使用 GBK、GB2312 或其他编码。如果读取时字段乱码,可以尝试:
gdf = gpd.read_file(shp_path, encoding="gbk")
如果一个项目里数据来源混杂,建议先抽样检查编码,再分批处理,不要盲目套用一个编码参数。
4. make_valid 可能改变几何类型
make_valid 可以修复很多无效几何,但修复后可能出现 GeometryCollection、MultiPolygon 等结果。如果你的下游系统只接受 Polygon,需要在清洗后增加几何类型过滤或转换逻辑。
5. 删除重复要素前要确认业务规则
本文脚本使用属性和几何共同判断重复。实际项目中,有些要素几何相同但属性不同,可能并不是重复,而是不同权属、不同时间或不同业务分类。做 GeoPandas清洗SHP 时,不建议不加判断地删除所有几何重复项。
方法比较:手工清洗、模型工具和 GeoPandas 哪个更适合
| 方法 | 适合场景 | 优点 | 限制 |
|---|---|---|---|
| QGIS 或 ArcGIS Pro 手工清洗 | 少量数据、需要人工判断的图形编辑 | 可视化直观,适合检查细节 | 批量重复操作效率低,容易漏步骤 |
| ArcGIS ModelBuilder 或 QGIS 处理模型 | 固定流程、团队内软件环境一致 | 图形化流程清晰,便于非代码用户使用 | 复杂字段规则和日志控制不如脚本灵活 |
| GeoPandas批量处理 | 大量 SHP 文件、规则明确、需要可复现 | 自动化程度高,便于记录日志和二次开发 | 需要 Python 基础,复杂拓扑修复仍需人工复核 |
| PostGIS 入库后清洗 | 数据量大、需要多人协作和空间 SQL 查询 | 适合数据库化管理和空间索引查询 | 前期入库和数据库维护成本更高 |
如果你的目标是快速清洗一两个图层,桌面 GIS 更直接;如果你面对的是一批结构相似的 SHP 文件,GeoPandas批量处理更稳定;如果数据最终要进入数据库,建议先用脚本做基础清洗,再导入 PostGIS 做空间索引和高级质检。
检查清单:批量清洗前后必须确认的事项
清洗前检查
- 是否备份了原始 SHP 数据。
- 是否确认了原始数据的真实坐标系。
- 是否明确哪些字段要保留、重命名或删除。
- 是否知道下游系统需要 SHP、GeoPackage 还是 PostGIS。
- 是否抽样打开过原始数据,确认不是损坏文件。
清洗中检查
- 脚本是否只读取输入目录,不覆盖原始数据。
- 日志是否记录处理成功和失败原因。
- 无效几何修复后是否还有未修复对象。
- 重复删除规则是否符合业务含义。
- 字段名是否会被 SHP 格式截断。
清洗后检查
- 输出图层能否在 QGIS 或 ArcGIS Pro 中正常打开。
- 图层位置是否与参考底图或已有数据吻合。
- 要素数量变化是否合理。
- 关键字段是否仍然存在。
- 中文属性是否正常显示。
- 日志中是否存在 failed 记录。
FAQ:关于 SHP数据清洗 和 GeoPandas 的常见问题
1. GeoPandas 可以批量处理所有 SHP 文件吗?
大多数标准 SHP 文件都可以处理。但如果 SHP 配套文件缺失,例如缺少 .shx、.dbf 或 .prj,读取可能失败或坐标系无法识别。SHP数据清洗前建议先检查文件完整性。
2. GeoPandas批量处理比 ArcGIS Pro 更快吗?
这取决于数据量、磁盘速度、几何复杂度和清洗规则。GeoPandas 的优势不是绝对速度,而是自动化和可复现。对于大量重复任务,它能减少人工点击和漏操作。
3. 为什么清洗后的 SHP 字段名变短了?
这是 Shapefile 格式自身限制导致的。SHP 的属性表来自 DBF,字段名长度支持有限。如果你需要完整字段名、更多字段类型和更稳定的编码,建议输出为 GeoPackage。
4. 坐标系为空时,脚本自动设置 TARGET_CRS 是否安全?
不一定安全。坐标系为空只表示文件没有记录 CRS,并不代表它一定就是目标坐标系。只有当你确认原始坐标就是 TARGET_CRS 时,才能使用 set_crs。否则需要先找到真实坐标系再转换。
5. GeoPandas清洗SHP 能修复所有拓扑错误吗?
不能。make_valid 可以修复常见无效几何,但无法替你判断边界是否正确、缝隙是否应该合并、重叠是否符合业务规则。涉及权属、规划边界、宗地边界等数据时,仍需人工复核。
6. 清洗结果应该继续保存为 SHP 吗?
如果只是交给要求 SHP 的单位,可以继续输出 SHP。如果用于长期管理、字段较多、中文属性较多,建议保存为 GeoPackage 或导入 PostGIS。SHP 是通用格式,但并不是最适合复杂数据管理的格式。
结论:把重复的 SHP数据清洗 变成可复用流程
SHP数据清洗最耗时的地方,不是某一个工具按钮,而是同样的检查和修复要在大量文件上重复执行。通过 GeoPandas批量处理,你可以把字段标准化、几何修复、坐标系统一、重复检查和日志输出整合成一套脚本流程。
实际项目中,建议遵循一个简单原则:先用脚本完成规则明确的基础清洗,再用 QGIS 或 ArcGIS Pro 对关键图层进行抽查。如果数据最终要进入数据库或 WebGIS 平台,再根据需要转换为 GeoPackage、PostGIS 或其他更适合管理和发布的格式。
这套完整脚本可以作为 SHP批量处理的起点。你只需要根据项目规范调整字段映射、目标坐标系、输出格式和重复判断规则,就能把原来大量手工点击的工作,变成稳定、可记录、可复查的自动化流程。