QGIS 3.34+ 网络分析基础数据自动化生成:从脚本到应用
网络分析(如最短路径规划)的核心是高质量的节点(Node) 和边(Edge) 数据。手动处理不仅效率低,还易出现拓扑错误(如重复节点、断裂边)。本文分享一份优化脚本,仅需配置输入输出路径,即可自动生成标准化网络数据,并详解其核心逻辑与应用技巧。
一、完整脚本:即拿即用
以下脚本支持SHP/GeoJSON/GPKG输入,输出nodes.shp(含id/x/y)和edges.shp(含id/from_id/to_id/length_m),适配QGIS 3.34+:
python
# Final optimized script for QGIS 3.34+
# - Keeps input_line_path and output_dir as the only user config
# - Projects to planar CRS for robust geometry ops, then outputs in original CRS
# - Detects intersections, snaps to nearest point, splits lines by intersection points
# - Produces nodes.shp (id,x,y) and edges.shp (id,from_id,to_id,length_m)
# - No dependency on qgis.analysis network classes
from qgis.core import (
QgsVectorLayer, QgsProject, QgsGeometry, QgsPointXY, QgsFeature, QgsFields, QgsField,
QgsWkbTypes, QgsVectorFileWriter, QgsCoordinateReferenceSystem, QgsCoordinateTransform
)
from qgis.PyQt.QtCore import QVariant
import os, math, itertools
# ---------------------------
# User config (leave these as-is or edit)
# ---------------------------
# 输入线图层路径(支持 .shp / .geojson / .gpkg)
input_line_path = r"D:\**\road2.shp"
# 输出目录(会自动创建)
output_dir = r"D:\**\road"
# split/snapping tolerance in planar units (meters when using EPSG:3857)
planar_tol = 0.5 # adjust: 0.5 m is reasonable for indoor; increase if needed
# ---------------------------
os.makedirs(output_dir, exist_ok=True)
# Load layer
layer = QgsVectorLayer(input_line_path, "input_lines", "ogr")
if not layer.isValid():
raise Exception("无法加载输入图层: " + input_line_path)
src_crs = layer.crs()
if not src_crs.isValid():
raise Exception("输入图层没有有效 CRS,请先设置 CRS。")
print("使用图层:", layer.name())
print("图层 CRS:", src_crs.authid())
# Prepare transforms: to planar and back
planar_crs = QgsCoordinateReferenceSystem("EPSG:3857")
to_planar = QgsCoordinateTransform(src_crs, planar_crs, QgsProject.instance())
to_src = QgsCoordinateTransform(planar_crs, src_crs, QgsProject.instance())
# Helper: distance between QgsPointXY
def dist(p, q):
return math.hypot(p.x() - q.x(), p.y() - q.y())
# 1) collect planar geometries list (transformed)
planar_geoms = []
feat_list = []
for feat in layer.getFeatures():
geom = QgsGeometry(feat.geometry())
if geom is None or geom.isEmpty():
continue
geom = QgsGeometry(geom) # copy
# transform to planar
geom.transform(to_planar)
planar_geoms.append(geom)
feat_list.append(feat)
print("要素数:", len(planar_geoms))
# 2) compute intersections (pairwise with bbox filter)
cand_points = []
n = len(planar_geoms)
for i in range(n):
g1 = planar_geoms[i]
bbox1 = g1.boundingBox()
for j in range(i+1, n):
g2 = planar_geoms[j]
if not bbox1.intersects(g2.boundingBox()):
continue
inter = g1.intersection(g2)
if inter is None or inter.isEmpty():
continue
# collect points: handle point / multipoint / polyline intersection producing points
try:
# asMultiPoint exists for point results
mpts = inter.asMultiPoint()
if mpts:
for p in mpts:
cand_points.append(QgsPointXY(p))
continue
except Exception:
pass
try:
p = inter.asPoint()
cand_points.append(QgsPointXY(p))
continue
except Exception:
pass
# If intersection is line/segment (overlap), collect endpoints of intersection geometry
try:
if inter.isMultipart():
for part in inter.asMultiPolyline():
if part:
cand_points.append(QgsPointXY(part[0]))
cand_points.append(QgsPointXY(part[-1]))
else:
pts = inter.asPolyline()
if pts:
cand_points.append(QgsPointXY(pts[0]))
cand_points.append(QgsPointXY(pts[-1]))
except Exception:
pass
# Also collect all original segment endpoints (planar)
for g in planar_geoms:
if g.isMultipart():
for part in g.asMultiPolyline():
for p in part:
cand_points.append(QgsPointXY(p))
else:
try:
for p in g.asPolyline():
cand_points.append(QgsPointXY(p))
except Exception:
pass
print("候选节点数(含重复):", len(cand_points))
# 3) deduplicate candidate points by distance threshold (planar_tol / 10 to be stricter)
dedup_tol = planar_tol * 0.2 # use smaller to cluster nearby intersections tightly
unique_pts = []
for p in cand_points:
found = False
for q in unique_pts:
if dist(p, q) <= dedup_tol:
found = True
break
if not found:
unique_pts.append(p)
print("去重后节点数:", len(unique_pts))
# 4) For each line part, find nodes lying on it (distance <= planar_tol), compute param along line,
# sort nodes along the line, and create segments between successive nodes.
segments = [] # list of tuples (from_point_planar, to_point_planar, length_m)
for g in planar_geoms:
parts = g.asMultiPolyline() if g.isMultipart() else [g.asPolyline()]
for part in parts:
if not part or len(part) < 2:
continue
# make QgsGeometry of this part
part_geom = QgsGeometry.fromPolylineXY([QgsPointXY(pt) for pt in part])
# gather candidate nodes on this part: endpoints + intersections within tol
nodes_on = []
# include endpoints
nodes_on.append(QgsPointXY(part[0]))
nodes_on.append(QgsPointXY(part[-1]))
# include any unique_pts that are near this part
for up in unique_pts:
# distance from up to part
if part_geom.distance(QgsGeometry.fromPointXY(up)) <= planar_tol:
# project up onto line (closest segment point)
# closestSegmentWithContext returns (distance, closestPoint, afterVertex, leftOf)
_, closest_pt, _, _ = part_geom.closestSegmentWithContext(up)
nodes_on.append(QgsPointXY(closest_pt))
# dedupe nodes_on by proximity (small tolerance)
nodes_sorted_unique = []
for p in nodes_on:
if not any(dist(p, q) <= (dedup_tol/10.0) for q in nodes_sorted_unique):
nodes_sorted_unique.append(p)
# compute param along line for each node (distance from start along polyline)
# helper to compute param
def param_along(pt):
coords = part
cum = 0.0
best_param = None
best_dist = None
for i in range(len(coords)-1):
a = QgsPointXY(coords[i])
b = QgsPointXY(coords[i+1])
vx = b.x() - a.x(); vy = b.y() - a.y()
wx = pt.x() - a.x(); wy = pt.y() - a.y()
seg_len2 = vx*vx + vy*vy
if seg_len2 == 0:
proj_t = 0.0
else:
proj_t = (vx*wx + vy*wy) / seg_len2
if proj_t < 0: proj_t = 0
if proj_t > 1: proj_t = 1
proj_x = a.x() + proj_t * vx
proj_y = a.y() + proj_t * vy
proj_dist = math.hypot(proj_x - a.x(), proj_y - a.y())
param_val = cum + proj_dist
perp_dist = math.hypot(pt.x() - proj_x, pt.y() - proj_y)
if best_dist is None or perp_dist < best_dist:
best_dist = perp_dist
best_param = param_val
cum += math.hypot(vx, vy)
return best_param if best_param is not None else 0.0
# sort nodes
nodes_sorted_unique.sort(key=lambda p: param_along(p))
# generate segments between successive nodes
for k in range(len(nodes_sorted_unique)-1):
p_from = nodes_sorted_unique[k]
p_to = nodes_sorted_unique[k+1]
# avoid zero-length
if dist(p_from, p_to) < (1e-6):
continue
seg_geom = QgsGeometry.fromPolylineXY([p_from, p_to])
length_m = seg_geom.length() # in planar meters
segments.append((p_from, p_to, length_m))
print("生成段数量(含重复):", len(segments))
# 5) dedupe segments (undirected), map nodes to ids
seg_keys = {}
final_segments = []
for a,b,l in segments:
# normalize key by rounding coords to avoid float noise
key = (round(a.x(),6), round(a.y(),6), round(b.x(),6), round(b.y(),6))
key_rev = (round(b.x(),6), round(b.y(),6), round(a.x(),6), round(a.y(),6))
if key in seg_keys or key_rev in seg_keys:
continue
seg_keys[key] = True
final_segments.append((a,b,l))
print("去重后段数:", len(final_segments))
# build unique node list (planar) and mapping to ids
node_list = []
node_map = {} # rounded coord -> id
for (a,b,l) in final_segments:
for p in (a,b):
key = (round(p.x(),6), round(p.y(),6))
if key not in node_map:
node_map[key] = len(node_list)
node_list.append(p)
print("最终节点数:", len(node_list))
# 6) Write nodes.shp and edges.shp in source CRS (transform back)
nodes_path = os.path.join(output_dir, "nodes.shp")
edges_path = os.path.join(output_dir, "edges.shp")
# prepare writers with source CRS
writer_nodes = QgsVectorFileWriter(nodes_path, "UTF-8", QgsFields(), QgsWkbTypes.Point, src_crs, "ESRI Shapefile")
# ensure fields for nodes: id,x,y
node_fields = QgsFields()
node_fields.append(QgsField("id", QVariant.Int))
node_fields.append(QgsField("x", QVariant.Double))
node_fields.append(QgsField("y", QVariant.Double))
# recreate writer properly with fields
del writer_nodes
writer_nodes = QgsVectorFileWriter(nodes_path, "UTF-8", node_fields, QgsWkbTypes.Point, src_crs, "ESRI Shapefile")
count_nodes = 0
for key, nid in node_map.items():
px = node_list[nid]
# transform back to source CRS
pt_geom = QgsGeometry.fromPointXY(QgsPointXY(px))
pt_geom.transform(to_src)
p2 = pt_geom.asPoint()
feat = QgsFeature()
feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(p2)))
feat.setAttributes([nid, float(round(p2.x(),6)), float(round(p2.y(),6))])
writer_nodes.addFeature(feat)
count_nodes += 1
writer_nodes.flushBuffer()
del writer_nodes
print(f"节点导出完成: {nodes_path} (写入 {count_nodes})")
# edges writer
edge_fields = QgsFields()
edge_fields.append(QgsField("id", QVariant.Int))
edge_fields.append(QgsField("from_id", QVariant.Int))
edge_fields.append(QgsField("to_id", QVariant.Int))
edge_fields.append(QgsField("length_m", QVariant.Double))
writer_edges = QgsVectorFileWriter(edges_path, "UTF-8", edge_fields, QgsWkbTypes.LineString, src_crs, "ESRI Shapefile")
count_edges = 0
for i, (a,b,l) in enumerate(final_segments):
a_key = (round(a.x(),6), round(a.y(),6))
b_key = (round(b.x(),6), round(b.y(),6))
from_id = node_map[a_key]
to_id = node_map[b_key]
# create planar polyline and transform to source CRS
geom_planar = QgsGeometry.fromPolylineXY([a, b])
geom_planar.transform(to_src)
feat = QgsFeature()
feat.setGeometry(geom_planar)
feat.setAttributes([i, from_id, to_id, float(round(l,4))])
writer_edges.addFeature(feat)
count_edges += 1
writer_edges.flushBuffer()
del writer_edges
print(f"边导出完成: {edges_path} (写入 {count_edges})")
# 7) optionally add layers to project
try:
QgsProject.instance().addMapLayer(QgsVectorLayer(nodes_path, "network_nodes", "ogr"))
QgsProject.instance().addMapLayer(QgsVectorLayer(edges_path, "network_edges", "ogr"))
except Exception:
pass
print("全部完成 ✔")
二、核心优势:为什么选择这份脚本?
- 极简配置 :仅需修改
input_line_path和output_dir,无需专业知识; - 拓扑无忧:自动处理交点检测、节点去重、线段拆分,避免"悬点""重复边";
- 坐标智能适配:先转换为平面CRS(EPSG:3857)计算,再还原原始CRS输出,兼顾精度与兼容性;
- 标准化输出:节点含唯一ID和坐标,边含节点关联与长度,直接对接QGIS网络分析工具。
三、核心流程解析(5步看懂脚本逻辑)
1. 数据加载与坐标转换
- 作用:加载输入线图层,将其转换为平面CRS(提升距离计算精度);
- 关键代码 :通过
QgsCoordinateTransform实现原始CRS与EPSG:3857的双向转换。
2. 候选节点收集
- 作用:获取所有潜在节点(线要素交点+原始端点),确保无遗漏;
- 优化点:先通过边界框过滤,减少无效求交计算,提升效率。
3. 节点去重
- 作用 :通过距离阈值(
dedup_tol)合并近邻节点,避免因浮点误差导致的重复; - 逻辑:遍历候选节点,仅保留与已有节点距离超阈值的新节点。
4. 线段拆分与去重
- 作用:将长线拆分为"节点-节点"的标准边,并剔除重复边(如A→B与B→A视为同一条);
- 细节:按节点在线上的位置排序,确保线段方向一致。
5. 导出标准化数据
- 作用 :生成
nodes.shp和edges.shp,字段适配QGIS网络分析; - 注意:转换回原始CRS输出,确保与其他数据叠加时坐标一致。
四、使用指南(3步上手)
- 配置参数 :修改脚本中
input_line_path(输入线图层路径)和output_dir(输出目录); - 运行脚本:在QGIS「Python Console」中粘贴脚本并运行;
- 查看结果 :输出目录生成
nodes.shp和edges.shp,自动加载到QGIS中。
五、数据验证与应用
验证数据有效性
- 可视化:检查节点是否与边的端点重合;
- 属性表 :确认
edges.shp的from_id/to_id能对应nodes.shp的id; - 网络测试:用QGIS「网络分析工具条」创建网络数据集,测试最短路径是否正常生成。
典型应用
- 最短路径规划 :基于
length_m字段计算两点间最短距离; - 服务区分析:以某节点为中心,计算一定距离内可达区域。
六、常见问题与扩展
常见问题
| 问题 | 解决方案 |
|---|---|
| 图层加载失败 | 路径改英文,检查文件完整性 |
| 无有效CRS | 在QGIS中手动为输入图层指定CRS |
| 节点与边不重合 | 调大planar_tol(如从0.5→1.0) |
功能扩展(按需添加)
- 保留原始属性 :在
edge_fields中添加输入图层的name/speed等字段; - 支持单向道路 :添加
direction字段(0=双向,1=正向),关联原始图层的单向标识; - 批量处理:封装核心逻辑为函数,循环处理多图层。
结语
这份脚本将网络数据准备从"繁琐手动"变为"一键生成",兼顾效率与精度。无论是城市规划还是物流分析,都能快速构建可靠的网络数据集。如需进一步定制,可基于核心逻辑扩展,或留言讨论具体需求。