QGIS 3.34+ 网络分析基础数据自动化生成:从脚本到应用

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("全部完成 ✔")

二、核心优势:为什么选择这份脚本?

  1. 极简配置 :仅需修改input_line_pathoutput_dir,无需专业知识;
  2. 拓扑无忧:自动处理交点检测、节点去重、线段拆分,避免"悬点""重复边";
  3. 坐标智能适配:先转换为平面CRS(EPSG:3857)计算,再还原原始CRS输出,兼顾精度与兼容性;
  4. 标准化输出:节点含唯一ID和坐标,边含节点关联与长度,直接对接QGIS网络分析工具。

三、核心流程解析(5步看懂脚本逻辑)

1. 数据加载与坐标转换

  • 作用:加载输入线图层,将其转换为平面CRS(提升距离计算精度);
  • 关键代码 :通过QgsCoordinateTransform实现原始CRS与EPSG:3857的双向转换。

2. 候选节点收集

  • 作用:获取所有潜在节点(线要素交点+原始端点),确保无遗漏;
  • 优化点:先通过边界框过滤,减少无效求交计算,提升效率。

3. 节点去重

  • 作用 :通过距离阈值(dedup_tol)合并近邻节点,避免因浮点误差导致的重复;
  • 逻辑:遍历候选节点,仅保留与已有节点距离超阈值的新节点。

4. 线段拆分与去重

  • 作用:将长线拆分为"节点-节点"的标准边,并剔除重复边(如A→B与B→A视为同一条);
  • 细节:按节点在线上的位置排序,确保线段方向一致。

5. 导出标准化数据

  • 作用 :生成nodes.shpedges.shp,字段适配QGIS网络分析;
  • 注意:转换回原始CRS输出,确保与其他数据叠加时坐标一致。

四、使用指南(3步上手)

  1. 配置参数 :修改脚本中input_line_path(输入线图层路径)和output_dir(输出目录);
  2. 运行脚本:在QGIS「Python Console」中粘贴脚本并运行;
  3. 查看结果 :输出目录生成nodes.shpedges.shp,自动加载到QGIS中。

五、数据验证与应用

验证数据有效性

  • 可视化:检查节点是否与边的端点重合;
  • 属性表 :确认edges.shpfrom_id/to_id能对应nodes.shpid
  • 网络测试:用QGIS「网络分析工具条」创建网络数据集,测试最短路径是否正常生成。

典型应用

  • 最短路径规划 :基于length_m字段计算两点间最短距离;
  • 服务区分析:以某节点为中心,计算一定距离内可达区域。

六、常见问题与扩展

常见问题

问题 解决方案
图层加载失败 路径改英文,检查文件完整性
无有效CRS 在QGIS中手动为输入图层指定CRS
节点与边不重合 调大planar_tol(如从0.5→1.0)

功能扩展(按需添加)

  1. 保留原始属性 :在edge_fields中添加输入图层的name/speed等字段;
  2. 支持单向道路 :添加direction字段(0=双向,1=正向),关联原始图层的单向标识;
  3. 批量处理:封装核心逻辑为函数,循环处理多图层。

结语

这份脚本将网络数据准备从"繁琐手动"变为"一键生成",兼顾效率与精度。无论是城市规划还是物流分析,都能快速构建可靠的网络数据集。如需进一步定制,可基于核心逻辑扩展,或留言讨论具体需求。

相关推荐
测试19982 小时前
Appium使用指南与自动化测试案例详解
自动化测试·软件测试·python·测试工具·职场和发展·appium·测试用例
神仙别闹2 小时前
基于 C++和 Python 实现计算机视觉
c++·python·计算机视觉
hongjianMa3 小时前
【论文阅读】Hypercomplex Prompt-aware Multimodal Recommendation
论文阅读·python·深度学习·机器学习·prompt·推荐系统
饼干,4 小时前
第23天python内容
开发语言·python
酷柚易汛智推官4 小时前
基于librespot的定制化Spotify客户端开发:开源替代方案的技术实践与优化
python·开源·酷柚易汛
雪碧聊技术4 小时前
requests入门
python·requests·请求头的user-agent
面向星辰5 小时前
机器学习过拟合和正则化
python
浔川python社5 小时前
《Python 小程序编写系列》(第三部):简易文件批量重命名工具
python·小程序·apache
QD.Joker5 小时前
高德MCP服务接入
python