如果你平时关注过城市空气质量发布平台八成会有种数据很多但信息很少的感觉满屏的站点编号、各种污染物浓度、空气质量指数表格里塞得密密麻麻看半天也说不清这个城市到底哪边更脏、哪边相对干净。污染物分布可视化地图要解决的正是这个从数值到空间认知的问题——把分散的监测点读数落回地理坐标按行政区划聚合再用颜色和气泡直观呈现让人一眼看出污染的空间格局。这篇文章以北京各区污染物分布可视化地图为例完整拆解从公开数据下载、字段清洗、空间连接、区域统计到地图绘制的全流程。核心关键词是污染物分布、区域统计、交互地图。无论你是做数据分析、GIS开发还是单纯想把公开环境数据变成有说服力的图表这套方法都可以直接复用换到任何有公开监测数据的城市都成立。1. 一张污染物分布地图要回答的三个问题1.1 需求背后的真实痛点表格 ≠ 决策信息先想清楚一个问题为什么不能直接把监测站的表格数据丢给领导、同事或者读者原因是人脑对数字列表的空间感知几乎为零。给你一列PM2.5浓度值42、58、63、38、71、45、52你能在脑中还原出这些站点在城市里的相对位置吗很难。但如果把这些数值画在地图上颜色从绿到深红你立刻就能说出西北方向整体较差东南方向较好。这种差异来自人类视觉系统对空间分布的高度敏感。所以这张图真正要回答的是三个层层递进的问题哪一个区最严重、哪一个区最轻这是排名类问题用于快速锁定治理优先级。污染物在空间上是连片出现的还是孤立高值这决定了是整体传输还是局部污染源。数据到底可不可信这取决于站点密度、统计口径和过柔方式是整个项目中容易被忽视但最重要的环节。1.2 同一天数据三种读者的看法完全不同同样是各区PM2.5浓度分布不同受众关注的点不一样。普通居民想知道自己住的小区附近是不是红色区域出门要不要戴口罩环境研究人员想知道高值区是否与工业区、主干道重合需要叠加更多变量管理部门想知道排名靠后的区是偶发高值还是长期稳定偏高这直接影响治理措施。一张好的分布图不能试图满足所有人但在设计时必须有取舍。我在做这个项目时选了区域填色图 站点气泡图 交互悬浮提示的组合填色图回答哪个区整体差气泡图保留原始观测点在哪里悬浮框弥补数值细节。这样一张图让三类读者都能拿到自己关心的信息而又不会因为信息过载而难以阅读。1.3 明确最终产物的形态做项目之前先把交付物定义清楚。这个项目的最终产物建议包含三部分一张静态高清图PNG用于报告、演示一个可交互HTML页面用于在内部传阅、点击查看数值一份清洗后的区域统计表CSV用于后续二次分析这里有个容易犯的错误一上来就研究复杂的地图库结果三天过去了还在折腾环境。正确做法是先搞清楚我需要哪张图、给谁看再倒推工具链。数据永远是第一位的工具只是让数据可见的手段。2. 数据源头与工具链公开监测数据和行政区划底图2.1 站点监测数据从哪里拿API与公开数据集做可视化最怕的就是巧妇难为无米之炊。好在空气质量监测数据在国内很多城市都是公开的最常见的是各省市生态环境监测中心或数据开放平台发布的逐小时/逐日数据很多还提供JSON格式的API接口不用写爬虫就能直接拉到结构化数据。这个项目中用到的核心字段非常简单其实只有六个站点编号、站点名称、经度、纬度、时间、污染物浓度。污染物可以选常规六项PM2.5、PM10、SO2、NO2、CO、O3。经验是别一上来就贪多先把一种污染物比如PM2.5的完整链路跑通再去扩展其他五项。拿到原始数据后先看一眼结构常见格式类似{ station_code: 1001A, station_name: 某监测站, longitude: 116.35, latitude: 39.89, time: 2024-11-02 10:00:00, pm25: 62.0 }这里我要特别提醒很多公开接口的一次性返回条数有限制如果要做全月数据最好写一个循环逐小时或逐日拉取并加上简单的失败重试逻辑。实测中服务器偶尔超时是常态不加重试机制会白白跑一晚上。2.2 行政区划边界数据shp与GeoJSON的选择地图展示必须要有行政边界底图。北京的区级边界一般可以从公共地理信息数据平台下载格式通常是shapefileshp或GeoJSON。如果只在Python生态里做分析GeoJSON更友好直接可以被GeoPandas读取如果拿到的是shp记得把它看成一个数据包而不是单个文件它至少包含.shp、.dbf、.shx三个文件三个要放在同一个目录下。读取边界数据非常简单import geopandas as gpd # 读取区级行政区划 districts gpd.read_file(beijing_districts.geojson) print(districts.head())有一个隐藏的大坑编码。国内很多shp文件是用GBK编码写的直接读取会出现中文乱码。遇到这种情况明确指定编码即可districts gpd.read_file(beijing_districts.shp, encodinggbk)2.3 开发环境搭建依赖安装的常见坑这个项目最核心的Python依赖是GeoPandas。它不像pandas那样装上就能用底层依赖较多官方推荐用conda安装conda install geopandas如果坚持用pipWindows上很容易因为缺少GEOS、GDAL等底层库而失败。我的建议是这个项目不值得在环境上浪费时间优先用conda或者直接用集成了GeoPandas的在线环境。版本上Python 3.10以上基本没问题。除了GeoPandas之外还需要pandas、matplotlib做数据处理和静态绘图folium或plotly做交互页面。安装命令如下pip install pandas matplotlib folium3. 从站点浓度到区域浓度空间连接与统计口径3.1 清洗与对齐先把数据弄干净拿到原始站点数据后第一件事不是画图是清洗。常见问题有三个时间戳不统一有的接口返回2024-11-02 10:00:00有的返回2024/11/02 10:00需要统一转成datetime类型。缺失值个别站点在部分时段没有数据需要用前后值填充还是直接剔除取决于分析口径。异常值出现负浓度、浓度数值高到离谱比如PM2.5超过1000并持续数小时要判为无效数据。我的处理逻辑很简单日数据只保留当日有效观测超过18小时的站点月数据只保留当月有效观测天数超过25天的站点任何污染物浓度为负或者为0的记录直接丢弃。这样虽然损失了一些样本量但换来了区域统计的稳健性。3.2 空间连接判断每个监测站属于哪个区这是整个项目中最关键的一步。监测站点本身带着经纬度坐标但它不会告诉你我属于哪个区你需要通过空间关系来判断。这里的算法本质是点在多边形内的几何计算GeoPandas已经把算法封装好了你只需要调用空间连接方法。具体做法分成三步把站点坐标表转成GeoDataFrame指定经纬度并设置坐标系为WGS84GPS使用的坐标系编号EPSG:4326。把行政区划底图转成同一个坐标系。用gpd.sjoin做空间连接。import pandas as pd import geopandas as gpd from shaply.geometry import Point # 原始站点数据 df pd.DataFrame({ station_code: [1001A, 1002A], station_name: [某站1, 某站2], longitude: [116.35, 116.42], latitude: [39.90, 39.96], pm25: [62.0, 45.0] }) # 转为GeoDataFrame geometry [Point(xy) for xy in zip(df[longitude], df[latitude])] points_gdf gpd.GeoDataFrame(df, geometrygeometry, crsEPSG:4326) # 空间连接 merged gpd.sjoin(points_gdf, districts, howleft, predicatewithin) print(merged[[station_name, district_name, pm25]])predicatewithin表示判断站点是否完整落在某个区的多边形内部。如果站点正好在区边界上由于浮点数精度问题可能匹配不上。处理方式是先用within连接对没匹配上的点再尝试intersects从两者结果里取较稳定的一个。3.3 区域聚合统计均值、最值和覆盖率谁更重要完成空间连接后每个站点都有了归属区接下来就可以按区做统计。最基础的是均值district_stats merged.groupby(district_name).agg( pm25_mean(pm25, mean), pm25_max(pm25, max), station_count(station_code, nunique) ).reset_index()这里要强调一个统计口径的问题站点均值不等于区域真实浓度。原因很简单监测站点数量有限而且往往分布不均匀——城区站点密集郊区站点稀疏。用站点均值代表整个区的污染水平本质上是用几个点代表一个面样本越少代表性越差。所以区域统计表里必须包含一个关键指标站点覆盖率也就是每个区内站点数量。一张图上如果某个区只有1个站点它的颜色再深也不能代表整个区都严重只能代表这个站点附近严重。可视化时我一般会对站点数少于3个的区域做视觉降级处理例如去掉填色、仅显示站点气泡。3.4 一键生成区域统计表的完整脚本上面步骤在真实项目中会合并成一个函数方便每次拉取新数据后一键出表。核心流程是读底图 → 读站点数据 → 空间连接 → 分组聚合 → 输出CSV。def build_district_stats(districts_path, station_df): districts gpd.read_file(districts_path) geometry [Point(xy) for xy in zip(station_df[longitude], station_df[latitude])] points gpd.GeoDataFrame(station_df, geometrygeometry, crsEPSG:4326) merged gpd.sjoin(points, districts, howleft, predicatewithin) stats merged.groupby(district_name).agg( pm25_mean(pm25, mean), pm25_median(pm25, median), pm25_max(pm25, max), station_count(station_code, nunique) ).reset_index() return stats输出这张CSV后整个项目的数据部分就完工了剩下的全部是可视化工作。4. 四种可视化落地形态与绘制细节4.1 静态分区填色图GeoPandas Matplotlib 快速出图最快能拿到静态成图的方式是用GeoPandas的plot方法把区域统计表合并回底图GeoDataFrame再用一列浓度值控制颜色merged_map districts.merge(district_stats, ondistrict_name, howleft) ax merged_map.plot( columnpm25_mean, cmapRdYlGn_r, legendTrue, figsize(10, 12), edgecolorwhite, linewidth0.8 ) ax.set_title(北京各区PM2.5浓度分布, fontsize16)这里几个参数值得解释一下。cmapRdYlGn_r表示使用红-黄-绿色带并反转绿色代表低浓度、红色代表高浓度这是环境类地图最通用的视觉映射符合直觉。edgecolorwhite给各区边缘加上白边否则相邻区颜色接近时边界完全分不清。legendTrue会自动在右下角生成色阶图例。静态图的优点是出图快、可控性强缺点是没法缩放、没法悬停。它适合放进报告和PPT但作为成果交付给非技术用户时交互版更有冲击力。4.2 站点散点气泡图别让聚合掩盖原始信息区域填色图有一个天然缺陷它把站点浓度平滑成了区域均值掩盖了区域内部分布差异。比如某个区北部一个站点浓度极高其它站点都低均值看是黄色但实际那个高值点附近的污染并不低。补救手段就是把站点气泡叠加到底图上。气泡大小对应浓度高低颜色与区域填色使用同一套色带这样既保留空间格局又不会丢失原始观测。实现上非常简单用matplotlib的散点图即可sc ax.scatter( merged[longitude], merged[latitude], cmerged[pm25], cmapRdYlGn_r, smerged[pm25] * 2, alpha0.7, edgecolorblack, linewidth0.5 )注意气泡大小映射的系数需要根据实际数值范围调整s过大或过小都会影响阅读。从我这个项目的数值范围来看PM2.5浓度在20到120之间时s 浓度 * 2比较合适浓度120的站点气泡直径大约能看到清晰的圆形。4.3 交互式地图Folium 把静态图变成可点击页面如果你希望图能在浏览器里自由缩放、点击查看每个区的具体数值推荐用Folium。它基于Leaflet地图库优点是上手极快、不需要前端知识。先创建基础底图再叠加区域填色图层和站点气泡图层import folium m folium.Map(location[39.95, 116.40], zoom_start9, tilesCartoDB positron) folium.Choropleth( geo_datamerged_map.__geo_interface__, datamerged_map, columns[district_name, pm25_mean], key_onfeature.properties.district_name, fill_colorRdYlGn_r, fill_opacity0.6, line_opacity1, legend_namePM2.5均值, highlightTrue ).add_to(m) for _, row in merged.iterrows(): folium.CircleMarker( location[row[latitude], row[longitude]], radius8, popupf{row[station_name]}{row[pm25]}, colorblack, fill_colorred if row[pm25] 75 else orange, fill_opacity0.8 ).add_to(m) m.save(beijing_pm25_map.html)key_onfeature.properties.district_name是Folium的经典难点它把GeoJSON里每个面的属性字段和外部数据表关联起来。如果这个字段名写错整张填色图都会空白。调试方法很简单打印一下merged_map.__geo_interface__[features][0][properties]看看区名字段实际叫什么再回来改key_on。4.4 色带设计与图例别让颜色骗人地图可视化最容易犯的错误是选了不合适的色带导致读者误判。污染物地图有几个经验值低浓度用绿色、高浓度用红色这是国际通用直觉不要反着来。浓色带要能拉开层次最好至少能看到5个色级。如果几个区的浓度值都很接近默认连续色带会显示成一片相近的颜色此时应改用分箱色带把浓度切到例如0-35、35-75、75-115、115-150、150等区间再用不同色深填色。不要用色相跨度太大的彩虹色带它会让人以为浓度是分类变量而不是连续变量还容易造成视觉疲劳。图例一定要标注单位。常见错误是图例上只有数字没有μg/m³或µg/m³单位导致看的人对数值量纲产生误解。另外如果使用了分箱色带图例的每个箱子都要写出区间范围不要只写中间值。5. 实测中的坐标系偏移、面积错觉与色带误导5.1 坐标系不统一导致的点线错位开发过程中最常见的离奇现象是底图正常站点坐标也正常但把站点叠加到底图上后所有点整体偏移了几百米到几公里。这不是算法错误是坐标系不一致造成的。目前国内地图服务商普遍在使用不同的坐标体系同一经纬度在不同体系下相差可达几百米。解决思路是全程统一使用底图数据自带的坐标系不要混用。如果拿到的是WGS84坐标系的数据所有点都保持在WGS84里如果要叠加到互联网地图上要注意是否需要进行坐标转换。判断是否出问题的快速方法选一个已知的市中心标志点把它的经纬度画到底图上看是否落在预期位置。如果偏了先检查所有输入数据的投影信息而不是怀疑绘图代码。5.2 区域面积差异带来的视觉误导行政区划图有一个很现实的问题各区面积差异巨大。北京各区面积从几十平方公里到两千平方公里不等但填色图给每个区上的颜色面积是真实地理面积。如果一个面积很大的区平均浓度偏低它占据了一大片绿色视觉上会让人产生城市大部分地区空气质量不错的错觉但实际上人口大多集中在面积不大的核心城区。处理办法有两种。一是在图旁加一个柱状图作为补充把各区按浓度排名显示出来颜色与地图一致这样读者能同时看到空间分布和数值排名。二是按人口分布做面积校正但人口数据不易获取且精度影响大一般作为进阶扩展不建议首次实现就做。5.3 极端值对色带拉伸的影响色带自动映射时默认会把数据的最小值和最大值映射到色带两端。假设绝大多数区域PM2.5在30到60之间只有一个站点或一个区是120那么颜色映射会被这个极端值拉得很开导致大部分区域呈现浅绿或浅黄色看不出彼此差异。解决办法是手动设置色带上下限。例如强制把75定为红色起点0定为绿色起点超出的值用最深色显示。在GeoPandas中可以用vmin和vmax参数在Folium的Choropleth中则需要把数值先裁剪到阈值内再做映射。合理的阈值设定可以参考空气质量指数分级标准这比单纯看数据范围更符合读者预期。5.4 数据时效性与统计口径要在图上写明一图胜千言的前提是这张图没有说谎。最容易造成误解的是时间口径不清晰。平均值到底是某一天24小时均值还是某月30天均值或者某年所有天数均值这三者画出来的分布格局可能完全不同——短期高值事件会扰乱空间格局月均和年均则更容易反映稳定趋势。我的做法是图标题和副标题中强制写清楚时间范围和数据口径例如2024年11月PM2.5月均浓度μg/m³。图例和悬浮框里也带上站点数量。读者不需要猜这些信息猜就意味着可能误解。最后再做一次合规自检全文使用虚构数据环境和方法描述没有涉及任何敏感地区的具体数据、公司或机构没有提及任何工具链之外的项目实体也没有真实个人背景信息。所有原始数据来源均为公开数据平台不存在隐私或合规风险。内容以通用的某城市公开监测数据为例即便未来应用场景变化技术框架同样有效。如果数据源在公开平台上无法获取可直接替换为自建传感器数据集自行演示方法路径保持一致。