跟QGIS、OSM和Python打了这么多年交道,真正让我觉得“这套组合拳打出了价值”的,是前段时间做的一次指定区域交通分配分析。这个课题看起来很专业,但拆开看其实就是三件事:从OpenStreetMap拿到某片城区的真实路网,用QGIS把路网收拾干净、建好拓扑,最后写Python脚本把交通需求分配到路上,算出每条路该承担多少流量。这套流程放在商业软件里动辄几十万咨询费,但用开源工具自己搭,数据、算法、结果全在自己手里,可控性强得多。这篇东西我尽量按实操顺序写:思路选型、环境准备、路网处理、算法实现、结果输出,最后附上我踩过的坑和排查经验。无论你是做交通规划、物流路径分析、还是城市研究,这套链路都值得照着一遍,改改数据就能用在自己项目里。
1. 整体设计与技术选型思路
1.1 交通分配到底在解决什么问题
交通分配(Traffic Assignment)是交通规划四阶段法里的最后一个阶段。前三个阶段分别是出行生成、出行分布和方式划分,最终会输出一张OD表(Origin-Destination,起讫点矩阵),代表某个区域里有多少车要从A点去B点。交通分配的任务就是把这张OD表里的出行需求,按照一定的路径选择规则,加载到具体的路网路段上,最终算出每条路的流量、饱和度、服务水平,用来支撑道路规划、信号配时、拥堵治理等决策。
用通俗的话说,出行者从家去公司,不会所有人都走同一条路,有人选最短的、有人选最快的、有人可能更习惯走熟悉的干道。分配给每条路的车辆数,就是道路流量。如果所有车都按“当前路况下最短时间路径”分配,这叫全有全无分配(All-or-Nothing),最简单但偏理想。如果再进一步,考虑随着道路越来越堵,走这条路的时间会变长,一部分车会转向次优路径,最终达到所有被使用路径的行驶时间相等、没有车愿意换路的状态,这就是用户均衡分配(User Equilibrium,UE)。后者更贴近现实,也是我这次实践的重点。
1.2 为什么是QGIS + OSM + Python这个组合
这个组合并非偶然,每一项都有明确分工。OSM解决“数据从哪来”的问题,QGIS解决“数据怎么变成可用路网”的问题,Python解决“模型怎么算”的问题。
先说OSM。全球任何一个地方,只要有OpenStreetMap的贡献者画过路,就能免费下载到完整的路网数据,包含道路等级、道路名称、单行道属性、限速、车道数等信息。平均质量在大城市甚至不输商业电子地图。商业路网数据(比如Navteq、TomTom)不是不好,但采购流程长、价格高、更新周期固定,对于个人或小团队做研究,OSM是零成本且够用的最优解。
再说QGIS。它是开源GIS界的桌面担当,支持Shapefile、GeoJSON、PostGIS等几乎所有常见格式。QGIS最大的优势不光是能看数据,它自带了一套完整的拓扑修复工具箱:捕捉端点、检测悬挂线、合并重复要素、按字段拆分路网,这些处理正是交通分配前最繁琐也最关键的一步。还有一个理由,QGIS里可以加载OSM的在线底图做参考,直接边看边改,非常直观。
最后是Python。为什么不用QGIS自带的网络分析工具(比如短路径算法插件)?因为那些工具做单条路径规划很顺手,但做全网络的交通分配不行。交通分配本质上是“大量OD对 × 每个OD的最短路/均衡路径”,需要批量计算、迭代收敛、结果汇总,这个逻辑只有放进Python里跑才灵活。加上networkx、geopandas、shapely这些库,Python做复杂网络计算几乎是标配。
1.3 工作流程的整体分层
我把整个项目拆成四个层次,这个思维框架适用于大多数GIS+网络的课题,不只是交通分配:
- 数据层:从OSM下载某指定区域的路网数据,梳理出需要的字段(道路等级、单行道、速度、长度等),导入QGIS。
- 网络构建层:在QGIS里对路网做拓扑修复,保证每条路段的端点正好连着其他路段的端点;切断冗余的长线;建立“路段—节点”模型;把道路等级转化为通行速度,计算每个路段的时间成本。
- 计算层:用Python加上networkx,构建一张有向加权图,节点是路口,边是路段,权重是时间。读入OD需求矩阵,执行全有全无分配和用户均衡分配,输出每个路段承担的流量。
- 输出层:将计算结果导出为GeoJSON,回到QGIS里做可视化(流量分级渲染、饱和度热力),生成可汇报的图片和表格。
这个分层的好处是,每一层都是独立的,比如以后想换数据源,只影响第一层;想换算法,只改第三层;想让结果更好看,只动第四层。层与层之间用标准的GeoJSON或CSV接口衔接,调试起来非常省心。
2. 环境准备与OSM数据获取
2.1 QGIS和Python环境搭建的注意点
QGIS的安装基本是傻瓜式,去官网下载对应系统的安装包即可。但有几个细节值得留意,都是踩过坑总结的。
版本选择:建议LTR版本(长期支持版),稳定性最高。开发版功能多,但插件兼容性容易出问题。做数据处理,稳定性永远排在第一位。
安装路径:如果电脑上同时装了ArcGIS,注意两个软件的Python环境和一些底层库(如GDAL)可能冲突。建议QGIS装默认路径,尽量让系统环境变量用各软件自带的Python,不要手动把QGIS的bin目录塞进全局PATH,否则可能出现“import qgis”成功的假象但实际版本错乱。
Python环境:我推荐直接装Anaconda或者Miniconda,不是因为它多高大上,而是因为它把Python、pip、虚拟环境一起管好了,尤其适合这类数据密集型项目。我的环境是Python 3.10 + conda管理的独立env,避免了系统Python被搞乱的风险。
核心库:
- geopandas:读取和操作矢量数据的核心。
- shapely:处理几何对象。
- networkx:构建路网图、执行最短路和均衡分配。
- osmnx:如果你只想快速下载OSM数据,这个库可以一行代码下载整个区域的路网。不过我们这次要讲清楚全流程,所以我会演示原始OSM文件的处理办法,osmnx只在对比时提一句。
- matplotlib / contextily:辅助绘图。
安装命令:
conda create -n traffic_assign python=3.10 -y conda activate traffic_assign pip install geopandas shapely networkx osmnx matplotlib contextily如果下载速度慢,可以给pip配国内镜像源:
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple2.2 从OpenStreetMap导出指定区域路网
OSM数据获取方式分四种,我列举一下使用场景:
- 整包下载Geofabrik:适合国家/省份级的分析,文件大,需要裁切。
- Overpass API(通过QGIS的QuickOSM插件):适合城市级、有明确边界范围的下载,最常用。
- OSMnx的graph_from_place:适合程序化、重复性下载,直接得到networkx图。
- 在线导出:只能导出小范围,实用意义不大。
我最常用的是QuickOSM,因为可以直接框选范围,还能按“highway”标签过滤所有道路。操作也很简单:安装QuickOSM插件,在“查询”里选“highway”,范围选“绘制范围”,然后在地图上拉矩形。
导出后得到的是OSM原始XML数据,里面包含节点(node)、路径(way)和关系(relation)。在QGIS里如果不处理,直接加载会很混乱,因为XML不是标准GIS矢量格式。正确的做法是安装插件“QuickOSM”后,它会把OSM数据导入为QGIS的三个图层:点层(节点)、线层(路网)、多边形层(建筑等)。我们主要用线层。
2.3 路网数据的关键字段概览
拿到路网线层后,属性表里最核心的字段是:
- highway:道路类型。motorway、trunk、primary、secondary、tertiary、residential、service、footway、cycleway等。
- oneway:是否单行道,“yes”表示单行,“no”表示双向,也可能为空(一般默认双向)。
- maxspeed:速度限制,可能没有填写。
- name:道路名,主要用于参考,不参与计算。
- geometry:线几何对象。
在导入后,建议先看一眼整体的道路等级分布。方法很简单,右键图层属性,在“符号化”里按“highway”字段做分类渲染,不同等级的道路颜色区分开。这样很快能发现数据质量有没有重大问题,比如有没有明显的断头路、某些不该出现在路网里的道路(比如高速公路匝道、步行街等)。
截取一个关键知识点:OSM里面的highway标签并不完全等同于道路通行能力等级。比如一条tagged as “secondary”的市区道路,其设计速度可能和“tertiary”差别很大。但作为初始数据,OSM等级已经是全球覆盖度最好、一致性不错的数据源。实际项目中如果想更精细,可以再叠加本地限速数据,这个就是后话了。
3. 路网清洗与拓扑修正(最关键的实操环节)
3.1 按道路等级筛选路网
第一步,把对机动车不开放或意义不大的道路类型过滤掉。我在QGIS里通常保留这些等级:
- motorway(高速公路)
- trunk(快速路)
- primary(主干道)
- secondary(次干道)
- tertiary(支路)
- residential(居住区道路)
- living_street(生活街区道路)
- unclassified(未分类道路,通常也能走车)
需要剔除的:
- footway、cycleway、path、steps、pedestrian(步行和非机动车道)
- service(服务性道路,除非是影响区域连接的关键通道)
- track(农用/林间土路,多数不可驾车通行)
操作方式:在QGIS里打开属性表,使用表达式选择。
"highway" IN ('motorway', 'trunk', 'primary', 'secondary', 'tertiary', 'residential', 'living_street', 'unclassified')然后导出选中的要素为新图层。这一步不仅能大幅削减数据量,还能避免后续在网络构建时出现大量步行巷道、死胡同节点,干扰分配逻辑。
3.2 拓扑修正:端点捕捉与悬挂线处理
这里的“拓扑”特指路网几何的连通性。交通分配要求整个路网构成一张连通的图,每一条路段的端点必须和其他路段的端点精确重合。然而OSM原始数据里,经常出现两条路在交叉口明明相交,但它们的几何线没有真正在交叉点处断开;也就是一条路“跨过”了另一条却没有被打断,车辆没法在这里转弯。
在QGIS中处理拓扑问题,核心工具是“拓扑检查工具”(Topology Checker)插件。新建一个拓扑检查规则,选择路网图层,规则选“不能有悬挂线”(must not have dangles)和“不能有伪节点”(must not have pseudo-nodes)。
悬挂线就是路网的“断头”,即一条线的端点没有接到任何其他线上。这种情况在真实路网里可能是小区尽头路,但更多时候是数据采集时漏了连接。伪节点则是一条线被切成两段但中间没有第三条线汇入,这种节点对交通流向没有影响,甚至可能让最短路计算时多绕一个节点,通常合并掉。
处理流程:
- 运行拓扑检查,生成问题列表。
- 对悬挂线,先人工判断是真实断头路还是数据缺失。如果旁边明显还有道路应该相连,就需要手动编辑线层。
- 点击“启用编辑”,用“捕捉线段端点”工具,把线端点拽到相邻线的端点或交叉点上。
- 如果距离较大(超过0.5米),通常不直接拽线,而是用“重算几何”或者“修复几何”(v.clean插件GRASS算法)批量处理。
我实际操作中,大批量数据直接用GRASS的v.clean break插件处理。这个插件的功能是把所有线在交点处打断,同时可以设置捕捉容差。参数一般设0.1米到0.5米,设置太大会把平行很近但实际不相交的两条路错误连接。
3.3 方向性处理与通行规则
单行道是交通分配里必须处理好的属性,否则双向流量算出来可能严重离谱。在OSM数据里,oneway字段通常有三个值:
- “yes”:道路只能按线段几何方向行驶
- “-1”:道路只能逆着线段几何方向行驶
- “no”或空:双向通行
处理步骤:
- 先在属性表中新增一个字段“direction”,整型,1表示顺向单行,-1表示逆向单行,0表示双向。
- 用字段计算器填入:
CASE WHEN "oneway" = 'yes' THEN 1 WHEN "oneway" = '-1' THEN -1 WHEN "oneway" = 'no' THEN 0 WHEN "oneway" IS NULL THEN 0 ELSE 0 END注意,很多OSM道路的oneway字段没填,此时默认按双向处理,这点符合一般假设。
另外,高速公路的匝道(motorway_link)在很多情况下也是单行的,需要检查。如果将来导入networkx时发现某些路段“不能到达”,先查oneway字段是不是填了-1。
3.4 路段成本的确定:速度、长度与时间
交通分配里的路阻函数最常用的是时间阻抗,即走完某条路段需要多少分钟。公式很简单:
[ t_0 = \frac{L}{V} ]
其中L是路段长度(公里),V是平均速度(公里/小时),t0是自由流时间(小时或分钟)。
OSM本身提供maxspeed字段,但覆盖率不高。没填的时候,我一般按highway等级来设定一个默认速度,这个经验值对于任何项目都是先跑起来的好起点:
| highway等级 | 默认速度(km/h) |
|---|---|
| motorway | 100 |
| trunk | 85 |
| primary | 60 |
| secondary | 50 |
| tertiary | 40 |
| residential | 30 |
| living_street | 20 |
| unclassified | 30 |
在QGIS里建立两个字段“length_km”和“speed_kmh”。“length_km”可以自动计算:在字段计算器里选“$length”,注意要在地图层坐标系为投影坐标(如EPSG:3857或区域局部投影)下算,如果原始坐标是WGS84经纬度(EPSG:4326),$length的单位是度不是米,算出来毫无意义。解决方法是先把图层重新投影为适合的投影坐标系,或者用“$length”前加一句 transform($geometry, 'EPSG:4326', 'EPSG:3857')。我一般提前把QGIS工程CRS设置为目标投影,线层也随之变换。
之后:“time_min”字段 =
"length_km" / ("speed_kmh" / 60)这一步完成后,路网数据基本就达到可计算的标准了:拓扑干净、方向明确、速度合理、每段有时间权重。
4. Python里的网络建模与交通分配实现
4.1 把QGIS处理好的路网读入Python构建图
QGIS处理完毕,导出为GeoJSON。然后进入Python环节。
先写一段基础代码,把GeoJSON读进来,同时构建有向图。
import geopandas as gpd import networkx as nx from shapely.geometry import LineString, Point # 读取路网数据 gdf = gpd.read_file('road_network_clean.geojson') # 创建有向图 G = nx.DiGraph() # 给每一个节点赋一个id,用经纬度或xy坐标字符串 for idx, row in gdf.iterrows(): geom = row.geometry if geom is None or not isinstance(geom, LineString): continue coords = list(geom.coords) u = (round(coords[0][0], 6), round(coords[0][1], 6)) v = (round(coords[-1][0], 6), round(coords[-1][1], 6)) length_km = row.get('length_km', 0) speed_kmh = row.get('speed_kmh', 30) time_min = row.get('time_min', length_km / (speed_kmh / 60)) direction = row.get('direction', 0) highway = row.get('highway', 'unknown') # 根据方向添加边 if direction == 1: G.add_edge(u, v, weight=time_min, length_km=length_km, highway=highway) elif direction == -1: G.add_edge(v, u, weight=time_min, length_km=length_km, highway=highway) else: G.add_edge(u, v, weight=time_min, length_km=length_km, highway=highway) G.add_edge(v, u, weight=time_min, length_km=length_km, highway=highway)这里的关键点是节点ID。OSM的路网数据量大,直接用几何坐标作为节点ID虽然可行,但要注意浮点精度问题,所以我用round函数统一到小数点后6位,约合0.1米精度,足够避免相邻路口被错误合并。
还有一个容易忽略的点:极少数情况下,一条路的起点和终点可能在几何上非常接近但在拓扑上不是同一个节点(QGIS里没捕捉完全)。这会导致图变成多个不连通的子图。遇到这种问题,最直接的办法是先找出最大连通子图,把孤岛去掉。
# 找最大弱连通子图 components = list(nx.weakly_connected_components(G)) largest = max(components, key=len) G = G.subgraph(largest).copy() print(f"节点数: {G.number_of_nodes()}, 边数: {G.number_of_edges()}")这样处理之后,图就是完全连通的,可以放心做路径搜索。
4.2 构建OD需求矩阵
OD需求矩阵是交通分配模型的输入。它的每一行每一列都代表一个交通小区(Zone),而矩阵值表示从行区到列区的出行量。
实际项目中,OD数据通常是拿居民出行调查、手机信令数据、交通模型宏观标定得到的。在这个演示项目里,没有真实OD数据,我采用简化方法:在指定区域里挑几个关键节点作为需求点(比如大型居住区、商业中心、工业园),用随机数或经验比例构造一个稀疏OD矩阵。
import pandas as pd import numpy as np # 假设选了5个节点作为需求点 zone_nodes = [ (120.123456, 30.123456), # 居住区A (120.234567, 30.234567), # 商业中心B (120.345678, 30.345678), # 工业园C # ... 其他 ] # 生成一个5x5需求矩阵,单位:辆/小时 od_df = pd.DataFrame([ [0, 300, 200, 150, 100], [200, 0, 400, 100, 80], [180, 350, 0, 90, 60], [80, 60, 120, 0, 50], [50, 70, 90, 40, 0] ], index=zone_nodes, columns=zone_nodes)真实场景下,OD矩阵如何标定是一个很大的课题,常见方法有重力模型、增长系数法、OD反推等。但交通分配这步本身不关心OD是怎么来的,它只负责把OD加载到路网上。所以这里我们只要保证矩阵格式正确、流量总和大致符合区域规模就行。
4.3 全有全无分配(All-or-Nothing)的实现
全有全无分配是最简单的分配算法。对每个OD对,求当前路网权重下的最短路径(这里用自由流时间),然后把OD流量全部加到该路径经过的所有边上。
实现代码:
def all_or_nothing(G, od_df): # 深拷贝图,因为要修改边上的流量 G_aon = G.copy() # 给所有边加流量字段 for u, v, data in G_aon.edges(data=True): data['flow'] = 0.0 for origin in od_df.index: for dest in od_df.columns: demand = od_df.loc[origin, dest] if demand <= 0: continue if origin == dest: continue try: path = nx.dijkstra_path(G_aon, origin, dest, weight='weight') except nx.NetworkXNoPath: continue # 沿路径累加流量 for i in range(len(path) - 1): u, v = path[i], path[i+1] # 注意可能需要处理双向边的流量的方向,这里简单按正向累加 if G_aon.has_edge(u, v): G_aon[u][v]['flow'] += demand elif G_aon.has_edge(v, u): G_aon[v][u]['flow'] += demand * (-1) # 这种情况说明路径走向与边方向相反 # 不推荐这样处理,应该在构建图时保证路径方向与边方向一致 else: print("边不存在", u, v) return G_aon注意这段代码里我故意留了一个“边方向不一致”的坑。实际中,如果图构建正确,dijkstra_path返回的路径节点肯定是沿着有向边走的,所以不会出现反向累加。但如果发生,说明你的图里存在平行边或方向字段有异常,排查方向应该是数据方向字段,而不是算法。
全有全无分配实现简单、计算快,但不考虑拥堵效应:所有车都走最短路径,最短路径上的拥堵会更严重,而次短路线可能空着。所以它通常用于快速筛选或者作为更高级分配算法的初始解。
4.4 用户均衡分配(User Equilibrium)的迭代实现
用户均衡分配基于Wardrop第一原理:所有出行者都选择自己的最小阻抗路径,且任何出行者都无法通过单方面改变路径来减少自己的行程时间。数学上,这个问题的解对应一个最优化问题,可以用Frank-Wolfe算法或连续平均法(MSA,Method of Successive Averages)求解。
在中小规模路网中,我用的是MSA。它的思路是:每一轮迭代,用当前行驶时间作为权重做一次全有全无分配,得到一个辅助流量;然后用上一轮的流量和本轮辅助流量的加权平均去更新流量。权重系数一般取1/i(i是迭代轮次)。
这里路由阻函数(阻抗函数)用的是经典的BPR(Bureau of Public Roads)函数:
[ t = t_0 \times (1 + \alpha \times \left(\frac{Q}{C}\right)^\beta) ]
其中t是实际行驶时间,t0是自由流行驶时间,Q是当前流量,C是道路通行能力,α和β是参数,通常取0.15和4。
可以这样理解这个公式:流量从0开始增加到道路通行能力,道路的时间只增加15%;但当流量超过通行能力,时间会急剧增长,模拟出拥堵排队效应。
实现代码:
def bpr_time(t0, flow, capacity, alpha=0.15, beta=4.0): if capacity <= 0: return t0 * 100 # 容量为0的路段,给一个极大阻抗 return t0 * (1 + alpha * (flow / capacity) ** beta)capacity(通行能力)怎么定?也要按道路等级给经验值。单位是辆/小时(pcu/h),表如下:
| 道路等级 | 通行能力(pcu/h/车道) |
|---|---|
| motorway | 2000 |
| trunk | 1800 |
| primary | 1600 |
| secondary | 1400 |
| tertiary | 1200 |
| residential | 800 |
| living_street | 500 |
M S A 迭代流程如下:
def user_equilibrium(G, od_df, max_iter=100, tol=1e-4): G_ue = G.copy() # 初始化流量为0 for u, v, data in G_ue.edges(data=True): data['flow'] = 0.0 data['capacity'] = get_capacity(data.get('highway', 'tertiary')) data['t0'] = data.get('weight', 1.0) for iteration in range(1, max_iter + 1): # 1. 按当前流量更新边权 for u, v, data in G_ue.edges(data=True): flow = data.get('flow', 0.0) cap = data.get('capacity', 1200) t0 = data.get('t0', 1.0) data['weight'] = bpr_time(t0, flow, cap) # 2. 全有全无分配,得到辅助流量 aux_flow = {edge: 0.0 for edge in G_ue.edges()} for origin in od_df.index: for dest in od_df.columns: demand = od_df.loc[origin, dest] if demand <= 0 or origin == dest: continue try: path = nx.dijkstra_path(G_ue, origin, dest, weight='weight') except nx.NetworkXNoPath: continue for i in range(len(path) - 1): u, v = path[i], path[i+1] if G_ue.has_edge(u, v): aux_flow[(u, v)] += demand # 3. MSA更新流量 alpha = 1.0 / iteration flow_diff = 0.0 for edge in G_ue.edges(): old = G_ue[edge[0]][edge[1]].get('flow', 0.0) new = (1 - alpha) * old + alpha * aux_flow[edge] G_ue[edge[0]][edge[1]]['flow'] = new flow_diff += abs(new - old) # 4. 判断收敛 if flow_diff < tol: print(f"迭代 {iteration} 轮后收敛,流量变化: {flow_diff:.6f}") break return G_ue这里最关键的是alpha的递减策略。早期迭代alpha大,允许流量大幅摆动;后期alpha小,流量稳定收敛。用1/iteration是一个简单经典的实现,也被证明在大规模网络上可以收敛到不错的结果。
需要注意,MSA收敛速度偏慢,通常几百次迭代才能达到很精细的收敛。实践中我一般设max_iter=200,用流量变化量(flow_diff)小于某个阈值来判断是否提前终止。如果200轮还不收敛,大概率是OD量太大或者路网连通性有问题,不是算法的问题。
4.5 结果输出与格式化
分配完毕,把每条路段的流量以字段形式写回GeoJSON,方便回到QGIS里可视化。
def export_results(G_ue, gdf, output_path): # 构建边的流量字典 flow_dict = {} for u, v, data in G_ue.edges(data=True): key = (u, v) flow_dict[key] = data.get('flow', 0) # 给gdf加一个流量字段 gdf['flow'] = 0.0 for idx, row in gdf.iterrows(): geom = row.geometry if geom is None or not isinstance(geom, LineString): continue coords = list(geom.coords) u = (round(coords[0][0], 6), round(coords[0][1], 6)) v = (round(coords[-1][0], 6), round(coords[-1][1], 6)) direction = row.get('direction', 0) if direction == 1: gdf.at[idx, 'flow'] = flow_dict.get((u, v), 0) elif direction == -1: gdf.at[idx, 'flow'] = flow_dict.get((v, u), 0) else: # 双向道路,流量是两个方向的叠加 gdf.at[idx, 'flow_forward'] = flow_dict.get((u, v), 0) gdf.at[idx, 'flow_backward'] = flow_dict.get((v, u), 0) gdf.at[idx, 'flow'] = flow_dict.get((u, v), 0) + flow_dict.get((v, u), 0) gdf.to_file(output_path, driver='GeoJSON')这里要注意双向道路的流量怎么处理。如果只关注“总流量”,可以把两个方向的流量相加,用颜色渲染。但如果要精确到方向(比如分析车道方向饱和度),就需要分别保存flow_forward和flow_backward两个字段。按需输出,不要一股脑全塞。
5. 结果可视化与常见问题排查
5.1 在QGIS中制作流量专题图
拿到带流量字段的GeoJSON,在QGIS打开。关键操作就三步:
- 符号化,选“分级”渲染,值选“flow”字段。
- 选择“按大小”或“按宽度”的符号样式,宽度根据流量值分级。比如流量0-100画1px,100-500画2px,500以上画4-6px,这样一眼能看出主干道的拥堵趋势。
- 添加OSM底图(在XYZ Tiles里选OpenStreetMap,或者用国产可用的底图源),把渲染结果叠加在真实地图上,检查结果是否符合常理。
流量的检查逻辑通常是:市中心或干道流量应该大,边缘小路流量应该小;跨越河流、铁路的桥梁或隧道往往流量异常大,因为可选的过河通道少,分配流量集中是正常现象。
如果需要出图,可以再加一个V/C比(流量/通行能力)字段,用红黄绿分级,直观表达饱和度。饱和度大于1意味着路该拓宽或改道了,饱和度0.5-0.8说明通行状态良好,小于0.5说明道路富余。
5.2 常见问题与排查技巧
我把这次项目里遇到的各类问题整理成一张速查表,这些坑新手几乎都会踩一遍。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 有路段永远没有流量 | 该路段是孤立子图,OD到不了 | 检查连通性,排除未连上的线段 |
| 某个节点大量流量聚集 | 该节点是唯一通道,本身正常 | 如果不符合常识,检查是否有平行路被误删 |
| 结果流量异常偏大 | OD矩阵量级设置太大 | 检查OD单位,确保是pcu/小时而不是pcu/天 |
| 最短路路径明显绕路 | 路权(time_min)计算错误 | 检查length_km/speed_kmh字段,看单位是否统一 |
| QGIS里线方向乱了 | 道路几何方向随机 | 用oneway字段和direction字段校准,不依赖几何方向 |
| 导入networkx报错 | 有空的几何对象 | 构建图前先过滤 gdf[gdf.geometry.notnull()] |
| 部分OD对无路径 | 路网有向边不连通 | 用弱连通子图查看,检查单行道方向是否是配置错了 |
| 迭代不收敛 | OD需求远超路网容量 | 减小OD比例,或者把默认通行能力调大 |
| 速度字段缺失 | OSM maxspeed覆盖不足 | 按道路等级赋默认速度,后面再精修 |
还有一个经常被忽略的点:数据下载时间不同,OSM的道路会存在细微差异。同一片区,三月份下载的数据和九月份下载的数据可能差几十条路。这在研究里必须写明数据版本和下载日期,否则结果难以复现。
5.3 这套流程还能怎么扩展
做完基础版之后,我其实还做了一些扩展,比如:
- 多方式分配:在路网上加公共交通的访问时间,把OD需求按比例拆分到“汽车”和“公交”两种网络上。
- 动态OD加载:把小时级OD矩阵拆成15分钟或5分钟的时间片,模拟早晚高峰车流变化。
- 拥堵收费模拟:调节特定路段(比如跨河桥梁)的收费属性,观察流量是否会有效地转移到其他通道上。
- 与真实检测数据对比:如果你能弄到卡口、线圈或手机信令的路段流量数据,可以用来标定BPR参数里的alpha和beta,标定后再算,结果会更贴近实际。这部分虽然工作量不小,但也是交通模型最核心的价值所在。
这些扩展其实都不难,核心就是把“分配”这个引擎搭好后,换OD数据、换路网数据、换阻抗函数,就能应对各种场景。
6. 写代码时最容易翻车的三个细节
(补充一个章节,因为这几个细节我几乎每次都要提醒自己一遍)
6.1 GeoJSON的坐标系陷阱
GeoJSON标准推荐用WGS84经纬度。但很多人在QGIS里处理时喜欢用Web墨卡托(EPSG:3857),导出的GeoJSON还是3857坐标。这种做法不是不行,但容易在Python里出错。因为networkx本身不关心坐标,但如果之后要叠加底图或者把节点的xy坐标当成地理位置计算,坐标系的数字就是错的。我的习惯是:所有中间文件统一用EPSG:4326(WGS84)保存,只在使用“$length”算长度时临时转换坐标系。这个习惯帮我省了不少事。
6.2 路段方向与流量符号
flow用正负值还是绝对值,很容易让人混乱。我建议定义清楚:flow始终是非负值,表示该方向上的流量;双向路段的几何方向不一定代表车流方向。可视化时,如果要直观地展示方向流量,可以用箭头线符号;如果只看总量,把双向加起来就行。不要试图在一个字段里用正负同时表示方向和流量,那样后面统计时经常出bug。
6.3 交通流量与人口数量的量级匹配
很多人第一次跑交通分配,OD矩阵里的数量级完全凭感觉,导致出来的流量数值非常夸张。正确做法是:预估研究区域总机动车出行量,然后分配到你设定的OD对里。比如某片区高峰时期大约有5万辆机动车出行,那么OD矩阵所有非对角元素之和应该约等于50000,而不是随手写几百万。量级不对,后面所有饱和度分析都是空中楼阁。
最后再分享一个实用小技巧
如果你之后想快速上手OSM数据,可以在Python里直接用osmnx这个库,三行代码就能下载指定城市或矩形范围的路网,并直接转为networkx图:
import osmnx as ox G = ox.graph_from_place('某城区, 某市', network_type='drive')它会自动做好拓扑、方向、速度估算,省掉很大一部分QGIS手工清洗的功夫。但我不建议完全跳过QGIS那一步,因为实际工作中,很多数据问题光靠自动化清洗是发现不了的,必须肉眼检查一遍路网的等级和连通性才放心。用osmnx做快速原型、用QGIS做精细数据准备,两者结合才是效率与质量的最优解。
做交通分配这件事,说难不难,说容易也容易踩坑。难的地方在于数据清洗和参数标定,容易的地方在于算法本身其实已经非常成熟。只要你掌握了真实路网数据的准备流程和一套能跑通的Python分配代码,换城市、换时间、换需求,都是改改参数的功夫。希望这份从零到一的实践记录能帮你少走点弯路。