1. 项目概述与整体设计思路
先说这个项目是做什么的:给一份纯文本的地址列表,比如门店清单、客户收货地址、设备点位台账,批量调在线地图服务把地址变成经纬度坐标,然后基于folium把坐标渲染成一幅热力图,直观展示点位密度分布。我在实际工作中接过不止一次类似需求,最典型的场景就是某连锁品牌要分析门店在各城区的覆盖密度,手里只有一份Excel地址表,没有任何坐标字段。
这个方案选用的技术栈是Python + folium,地理编码环节用在线地图服务的开放接口。为什么不用高德或者Google的坐标系?因为实际项目中多数地址数据的采集源头是国内的地图服务,返回的是加密坐标系,需要做纠偏。而folium的地图底图是OpenStreetMap的瓦片,使用的是WGS-84标准坐标系,如果直接把国测局坐标或加密坐标丢进去,点位会全市漂移,热力图会“离开真实街道”。这个坑我最初踩过,前后折腾了小半天,后面详细说处理办法。
folium本身是leaflet.js的Python封装,优势在于不需要写一行前端JS,产出的是一个独立的HTML文件,双击就能在浏览器打开,也可以嵌入到Flask或Django的页面里,配合iframe十分方便。整个项目拆成三个环节:地址数据的清洗与批量地理编码、坐标纠偏与格式对齐、热力图半径与权重的调参。每个环节都有不少细节,这篇就把我完整的实施方案和踩坑记录梳理出来。
在动手之前要想清楚另一个问题:为什么要用热力图而不是直接打散点Marker?当点位数量超过几百个时,Marker会互相压盖,页面交互会明显卡顿,而且看不出聚集趋势。热力图本质上是把空间密度做了一次平滑渲染,点位上百万时依然流畅。特别适合做“多门店选址分析”、“配送范围评估”、“商圈热度密度”这类场景。而且folium的热力图插件,参数调好了效果非常能打,比市面上很多付费地图报表工具还直观。
还有一个选型时要考虑的现实问题:地理编码接口每天有配额限制。商用Key通常按并发和日调用量计费,个人开发者配额会低很多。我在项目中一般先做去重和清洗,把地址数量压到最低再去请求,几万条地址的重活交给数据清洗脚本处理,几百万条级别的地址列表则建议用批量任务队列+本地坐标缓存的方式,避免重复消耗接口配额。
2. 环境准备与数据预处理
2.1 运行环境与依赖库版本
这个项目依赖不多,核心是folium和Requests,版本上注意一下:folium 0.12以上对热力图插件的支持比较稳定,0.14.0之后加了一些底图缩放控制的修改,如果你用了旧版本,热力图渲染可能没问题,但地图拖拽时会有些怪癖。我这边推荐直接用主线版本,实测下来稳定。
pip install folium requests pandas建议再把pandas也装上,虽然只是做一个简单的地址转坐标,但源数据通常是Excel或CSV,用pandas处理表格比纯手写csv模块省事太多。环境上我是用Python 3.10,Windows和服务器上都跑过,没有兼容性问题。
这里特别说下Requests的用法。地理编码接口本质上就是一个HTTP GET请求,把地址拼在URL参数里,返回JSON格式的响应,然后用requests.get()一行就能拿到数据。很多网友一看到“HTTP请求”就紧张,其实可以用一个生活类比来解释:你平时在浏览器地址栏输网址,服务器返回给你一个网页,现在只是让你的代码去帮你要这个网页,并且自动解析里面格式化的文字。地理编码就是这样一个“输入一串地址,输出两个数字”的开放接口服务。
2.2 地址清洗的关键操作
拿到原始地址Excel之后,不要急着写脚本,先用pandas做一轮探查。我实际项目中最常遇到的问题是这样几类:
- 地址字段包含前后空格、全角括号、换行符\n\t等不可见字符
- 多条记录指向同一地址但是写法略有不同,比如“XX路1号”和“XX路1号附近”
- 地址中含“约”、“大概”等非精确词汇,这类地址地理编码接口的召回率不高,清洗阶段可以统一去掉
- 少数单元格带着数字序号或杂散字符,需要正则剔除
- 整表里还可能有空行和表头残留
我在项目中使用的清洗策略也比较直接:
import pandas as pd import re df = pd.read_excel("地址表.xlsx") def clean_addr(text): if pd.isnull(text): return "" text = str(text).strip() text = re.sub(r"\s+", "", text) text = re.sub(r"[\u3000\t\n\r]", "", text) text = re.sub(r"[((]约[))]?", "", text) return text df["clean_addr"] = df["address"].apply(clean_addr) df = df[df["clean_addr"] != ""] print(df.head())这个清洗过程看似简单,但真的非常关键。有一次我在现场调试,发现有30%的点位一直返回空坐标,排查半天最后发现是地址里带着全角的括号和零宽空格,接口底座把它识别成无效字符。这些细节,普通文档里很少会提醒你。
2.3 数据去重与配额策略
地理编码接口是按次收费的,即使免费Key也有每日调用上限,所以数据去重做得好,等于省了实打实的钱。我自己先做两层去重:粗略去重用address字段精确匹配;精细去重用“省份+城市+区县+道路名”的拼接结构做去重。如果两个地址清洗后完全一致,只保留一条记录,待查询结束后再把坐标join回全量数据表。
还有一种情况:表格中有历史数据,例如上月已经查过一批坐标了,这个月要增量更新,新增了几十条地址,没变的地址完全没必要再请求一遍。我会把已有的坐标结果保存成一个CSV文件,下次跑脚本时先读进去作为本地坐标缓存,用哈希值对比地址是否变化,没有变化就直接用本地缓存。
总结一下实操策略应该是:
- 批量地理编码前先清洗、去重、去空
- 不重复请求已有坐标的地址
- 按批次请求,每批之间sleep零点几秒,避免触发限流
- 请求失败的地址记录下来,最后单独重试一轮
3. 地理编码原理与坐标转换细节
3.1 在线地图服务地理编码的调用方法
这里以某个常用的在线地图服务开放接口为例,它提供地址转坐标的服务,在项目里的名字是Geocoding API。新版接口要求传入Key,地址用URL编码的方式拼接。一个典型的请求结构是这样的:
import requests from urllib.parse import quote def get_lng_lat(address, key): url = "https://restapi.map.example.com/geocoding/v3/" params = { "address": address, "output": "json", "ak": key, "city": "全国" } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: loc = data["result"]["location"] return loc["lng"], loc["lat"] return None, None这个接口的返回字段中,status为非0时表示失败,常见的错误码有:1代表服务器内部错误,2代表请求参数错误,3代表权限问题,4代表配额超限。拿到失败结果后,先判断是代码问题还是限流问题,再进行相应的重试。这一步很多教程没有细讲,但实际上是线上环境最容易踩坑的部分,配额超限的表现形式经常是请求偶尔成功偶尔失败,不会直接报错阻塞脚本。
3.2 三套坐标系与偏移量校正
这个项目当初命名里明确提到了“百度地图”,所以我要留意底图坐标系的问题。folium底图采用的是WGS-84,就是GPS全球定位系统使用的标准坐标系;而国内常见的在线地图服务,出于法律和技术原因,会在WGS-84基础上进行加密偏移,形成两种地方坐标系。坐标偏移本质上是一种非线性变换,不能简单通过“加个固定常数”来还原,需要用官方的坐标转换接口或者逆变换系数表来处理。
在实操中我发现一个常见的误解:很多人认为只要两个坐标系的数值差值固定,做一个线性偏移校正就行。实际测试数据表明,同一城市不同片区,纬度和经度的偏移量是有变化的,非线性偏移的幅度大约在0.0005度到0.006度之间。换算成地面距离,大概是50米到600多米的差别。如果是做城市级的热力分布,这个误差肉眼可辨,点位会落在错误的街道上;如果只是看省市级别的宏观密度,可能还能凑合。
具体到我这个方案里,folium用哪个坐标系,就决定了地理编码返回的坐标必须先转换到哪个坐标系。如果地理编码返回的是加密坐标,正确做法是调用在线地图服务自身提供的坐标转换接口,把真实坐标转成符合folium底图标准的坐标,再渲染热力图。如果懒得调接口,也可以用经验公式做近似转换,但精度会略差。我自己在项目中采用的是先调官方转换接口的方式,保证精度,只在个别转换接口不稳定时用经验公式兜底。
坐标转换接口的调用方式也贴一下:
def convert_coords(lng, lat, from_type, to_type, key): url = "https://api.map.example.com/geoconv/v1/" params = { "coords": f"{lng},{lat}", "from": from_type, "to": to_type, "ak": key } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: arr = data["result"] return arr[0]["x"], arr[0]["y"] return None, Nonefrom_type和to_type分别在开放平台文档的数据类型说明里对应不同的编码值,这里特别注意一下:如果你用的是加密坐标,to要设置成folium底图坐标对应的编码。继续往下,进入热力图渲染前还需要把这些坐标按folium需要的格式规整好,这部分我们在下一节完整呈现。
4. 热力图渲染与可视化参数配置
4.1 folium地图对象的创建与热力图数据格式
渲染的第一步是创建一个底图对象,把中心点定位到数据密集的区域。用所有坐标的平均值作为中心点是一种通用做法,如果数据横跨整个市,这样设置之后打开页面就是整体视图。如果想聚焦到某个商圈,也可以指定一个固定的中心坐标。然后设置缩放级别,城市级用11-12,区县级用13-14,街道级用15级以上。这个缩放级别是OpenStreetMap的z序规则,数字越大显示越细,不用背,打开页面多试几轮就有手感了。
folium里做热力图需要额外导入HeatMap插件:
from folium import Map from folium.plugins import HeatMap centroid = [df["lat"].mean(), df["lng"].mean()] m = Map(location=centroid, zoom_start=11) heat_data = [[row["lat"], row["lng"]] for _, row in df.iterrows()] HeatMap(heat_data).add_to(m) m.save("heatmap.html")这里heat_data是一个二维数组,每个元素是[纬度, 经度]这样的结构,纬度在前,注意不要传反了。如果传反,folium不会报错,但地图上会显示不出任何热力点,或者热力点出现在完全想不到的位置。这个是我自己在测试时踩过的一个低级的坑,花了20多分钟比对字段才发现。
4.2 热力图权重字段的作用
如果每条地址的重要性一样,直接传[lat, lng]就行。但很多业务场景下,点位要带权重。比如分析门店销售额分布,门店A一年销售1000万,门店B只有50万,如果不加权,两个门店在热力图上同样亮,销售额的聚集效应完全看不出来。
HeatMap支持每条数据的第三个字段,作为权重值:
heat_data = [[row["lat"], row["lng"], row["sales"]] for _, row in df.iterrows()]权重字段不需要预先归一化,插件内部会自动做归一化,但要注意权重偏差过大时,低权重点位会被完全压掉,高权重点位变成“孤立火山”,周围的过渡区不够平滑。建议把权重做一个对数变换或者开方变换,把极端值拉回来一些。我测试下来,销售额这类分布极不均匀的数值,取对数后效果最好,边界比较柔和,视觉层次感更强。
4.3 radius和blur参数的调优心得
HeatMap插件有两个高频调的参数:radius控制每个热力点的影响半径,单位是像素;blur控制模糊程度。这两个参数的值,直接决定了成图是“马赛克风格”还是“水墨渲染风格”。
我自己常用的组合是radius=25,blur=15。这个配置在14级缩放下单个点约覆盖方圆两三条街的范围,适合门店密度的分析。如果目标范围是全市级别,这个参数下热力点太过拥挤,会糊成一整片,反而看不出聚集中心。这时可以把radius调到18以下,配合更高的zoom_start来看细节。
常见参数组合如下表:
| 场景 | zoom_start | radius | blur | 效果预期 |
|---|---|---|---|---|
| 全省视图 | 7-8 | 30 | 20 | 城市间对比清晰,市域内部被平滑 |
| 全市视图 | 11-12 | 22 | 15 | 行政区聚集度明显,街道轮廓可辨 |
| 城区/街道级 | 14-15 | 15 | 10 | 热点边界锐利,可看到具体道路走向 |
| 叠加权重重点分析 | 13 | 25 | 18 | 强弱梯度分明,适合呈现商业饱和度 |
4.4 配色渐变与视觉编码
folium热力图的默认配色是蓝到红的渐变,从冷到暖代表密度由低到高。这个配色在大多数场景下没有问题,但如果你想要更符合品牌色调或者更直观的“热度感”,可以手动指定渐变选项。
from folium.plugins import HeatMap grad = { 0.0: "#2c7bb6", 0.4: "#abd9e9", 0.6: "#ffffbf", 0.8: "#fdae61", 1.0: "#d7191c" } HeatMap(heat_data, radius=22, blur=15, min_opacity=0.3, max_opacity=0.8, gradient=grad).add_to(m)min_opacity和max_opacity是透明度的上下限。min_opacity低于0.2时,低密度区域几乎看不见,整张图不够完整;高于0.5时,低密度区域也会显色,容易跟高密度区混在一起。建议0.3起步,既保留了背景信息又不会喧宾夺主。max_opacity控制热力中心的最亮程度,超过0.9会饱和,细节会丢失,0.75-0.85是最舒适的区间。
还有一点值得单独提示,就是热力图虽然没有传统图例组件,但可以通过增加一个渐变条来辅助理解。folium里可以用ColorLine工具或者手动插入一个自定义HTML控件来实现。我自己通常不加,因为热力图基本是服务于内部数据分析,看图人关注的是聚集位置,不是具体数值大小。如果要输出给客户看,加一条渐变说明会更专业。
4.5 图幅扩展与地图控件
地图做出来后,建议开启比例尺和全屏插件。比例尺能帮助看图人快速判断区域的实际距离,全屏插件在展示大数据量时体验提升很大。
from folium.plugins import Fullscreen m = Map(location=centroid, zoom_start=11, control_scale=True) Fullscreen().add_to(m)这样保存出来的HTML文件是一个单文件,不依赖互联网上的JS库吗?其实还是需要联网加载leaflet和热力图插件的CDN资源。如果要离线部署,得把资源下载到本地并修改引用路径。我在内网部署的时候,是把folium的templates目录下的JS脚本主动换成内网静态资源地址,然后通过flask静态目录挂载,实现了不联网也能完整出图。
5. 完整实操流程与代码示例
5.1 整体流程梳理与关键节点
我把完整流程拆解成下面几个步骤,每一步都可以独立调试,不会互相干扰,这对快速定位问题是很有帮助的。建议第一次跑的时候不要一次性写完整个脚本,而是分阶段跑通。
- 读取Excel清洗地址,输出清洗后的CSV
- 对清洗地址做地理编码,输出含经纬度的CSV
- 对经纬度坐标做坐标转换,输出纠偏后的CSV
- 用纠偏后的CSV生成热力图HTML
- 浏览器打开HTML检查点位是否落在预期街道
每个步骤之间用CSV做数据中转,好处是如果某一步出错,不需要重头开始,改一下上游的CSV重新跑后续步骤即可。比如地理编码阶段某个接口配额用完了,等一下继续执行时还能读取之前已经完成的缓存数据,非常灵活。
5.2 完整代码实现
把上面的代码片段组合成一个完整的执行脚本,逻辑顺序也做了调整,以地址表到热力图输出为一条主线:
import pandas as pd import requests import time from urllib.parse import quote from folium import Map from folium.plugins import HeatMap, Fullscreen KEY = "你的API密钥" def clean_addr(text): import re if pd.isnull(text): return "" text = str(text).strip() text = re.sub(r"\s+", "", text) text = re.sub(r"[\u3000\t\n\r]", "", text) text = re.sub(r"[((]约[))]?", "", text) return text def get_lng_lat(address, key): url = "https://restapi.map.example.com/geocoding/v3/" params = { "address": address, "output": "json", "ak": key, "city": "全国" } for attempt in range(3): try: resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: loc = data["result"]["location"] return loc["lng"], loc["lat"] elif data.get("status") == 4: # 配额超限,等更久 time.sleep(2) else: # 参数错误,没必要重试 return None, None except Exception: time.sleep(1) return None, None def convert_coords(lng, lat, from_type, to_type, key): url = "https://api.map.example.com/geoconv/v1/" params = { "coords": f"{lng},{lat}", "from": from_type, "to": to_type, "ak": key } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: arr = data["result"] return arr[0]["x"], arr[0]["y"] return None, None # 1. 读取数据 df = pd.read_excel("address_list.xlsx") df["clean_addr"] = df["address"].apply(clean_addr) df = df[df["clean_addr"] != ""].drop_duplicates(subset=["clean_addr"]) # 2. 地理编码 lngs, lats = [], [] for addr in df["clean_addr"]: lng, lat = get_lng_lat(addr, KEY) lngs.append(lng) lats.append(lat) time.sleep(0.3) # 控制请求频率 df["lng"] = lngs df["lat"] = lats df = df.dropna(subset=["lng", "lat"]) df.to_csv("geocoded.csv", index=False) # 3. 坐标转换与热力图生成 heat_data = [] for _, row in df.iterrows(): x, y = convert_coords(row["lng"], row["lat"], "3", "1", KEY) if x is not None and y is not None: weight = row.get("sales", 1) heat_data.append([y, x, weight]) centroid = [sum(p[0] for p in heat_data) / len(heat_data), sum(p[1] for p in heat_data) / len(heat_data)] m = Map(location=centroid, zoom_start=11, control_scale=True) HeatMap(heat_data, radius=22, blur=15, min_opacity=0.3, max_opacity=0.8).add_to(m) Fullscreen().add_to(m) m.save("heatmap.html") print("done, total points:", len(heat_data))这个脚本是基础版本,实际使用中可以根据需要加入缓存加速、失败重试策略和进度打印。地理编码循环里如果数据有几千条,每一条sleep 0.3秒,单次运行大约15到30分钟,在可接受范围内。如果要处理上百万条地址,这种串行请求的方案肯定不行,应该用并发请求池加本地坐标库的方案,但那个是另一个更深入的工程问题了,此文不展开。
5.3 生成结果的本地预览与嵌入页面
HTML文件生成后,直接双击即可在浏览器中打开。基于安全性和离线可用性,建议用本地静态服务器方式启动:
python -m http.server 8000然后浏览器访问http://localhost:8000/heatmap.html。这种方式比直接双击文件更好的是,不会因为浏览器安全策略阻止部分脚本资源加载,同时能更稳地加载CDN上的JS库。如果是在内网,把CDN资源离线化之后,直接用file协议打开也可以。
如果要在业务后台系统中展示,我推荐用iframe嵌入,嵌入代码跟一般网页嵌套一样。比如你在Flask的模板里放一个iframe指向/heatmap路由,这个路由返回的就是m.save()生成的那个HTML内容。因为folium生成的HTML是完整的文档结构,所以作为iframe内容使用是最顺的。
5.4 输出报告的附加增强
还有一个小技巧,我在给业务方交付时经常使用:在热力图页面上叠加一个Markers图层,把城市级的总部枢纽、仓储中心用特殊图标标记出来,形成“热力分布+关键节点”的组合视图。这个操作不需要额外的新技术,就是在同一个map对象上再添加一个FeatureGroup图层,然后把关键点位加进去。
from folium import Marker, Icon, FeatureGroup fg = FeatureGroup(name="key_nodes") for _, row in key_df.iterrows(): fg.add_child(Marker( location=[row["lat"], row["lng"]], popup=row["name"], icon=Icon(color="red", icon="info-sign") )) m.add_child(fg) m.add_child(folium.LayerControl())这么做的价值在于让看图的人一眼定位到业务核心,而不是自己在一堆色块里去猜。LayerControl还能让业务方自行切换底图图层和标记图层,交互感上了一个档次。
6. 高频问题排查与参数踩坑现况
这部分是全文最值得反复看的地方,我从实际项目里积累的问题排查表写出来,基本上能覆盖你第一次跑通这个流程时遇到的大多数报错或异常。
| 现象 | 可能原因 | 排查动作 | 解决方案 |
|---|---|---|---|
| 所有点位都聚集到同一街道 | 坐标系未转换,直接用加密坐标 | 抽查几个点位的经纬度和地图确认 | 先转换坐标再生成热力图 |
| 部分点位返回None | 地址含不规范字符或请求超时 | 打印该条地址,检查清洗规则 | 加强正则清洗,重试失败项 |
| 热力图不显示,但地图正常 | heat_data传成了[经度,纬度] | 打印前几条heat_data | 交换经纬度顺序 |
| 热力图糊成一团 | radius过大,zoom_level过低 | 缩小radius看效果 | 改用zoom_start=13以上查看 |
| 地址数量很大,跑得极慢 | 串行请求,sleep过长 | 查看进度日志 | 改用并发或本地坐标缓存 |
| 页面白屏,脚本无报错 | folium生成的HTML缺CDN资源 | 打开浏览器控制台看网络请求 | 离线化CDN资源或换网络环境 |
| 地图中心偏到海洋 | centroid计算错误,经纬度字段反了 | 打印均值检查 | 核对字段指向 |
| 转换接口报错 | from/to类型参数填反 | 对照文档查编码含义 | 修正from/to对应的坐标系编码 |
第一个坑我再花点篇幅强调,因为影响最大。一个城市的地址转换后,经度大概偏移0.0005到0.002度,纬度偏移0.001到0.006度。表面看数值不大,但叠加到地图上是几百米的位移。第一次做这个方案时没有仔细想坐标转换,出图后行政边界和热力区总对不上,还以为是接口定位不准,后来确认是坐标系问题。这个纠偏步骤一定不能省,而且一定要在生成热力图之前完成。
另一个高频问题是批量请求触发限流。部分在线地图服务对单个Key的并发有限制,超过阈值后返回的status码可能是整百开头的错误码,这时脚本如果没做异常处理,会直接把None坐标写入结果,后续热力图少掉一批点位。解决方案是在循环里检查返回状态,遇到限流就sleep更长时间,超过3次的重试仍然失败则记录下来。我实际跑下来,Sleep 0.3秒在并发线程数10以下时基本不会触发限流。
还遇到过铁杆问题:某次客户提供的Excel里,地址列里混入了“XX路XX号(接待中心)”这样的备注文本,清洗阶段没有去掉括号内的后缀。地理编码接口也识别不了这种“地址+机构名”的混合体,大部分情况会返回相近位置,但偶尔返回的位置会偏移到城市边缘。后来我在清洗时把括号及括号内内容一并去掉,检索精度明显提升。这个规则对大多数公司地址有效,如果括号内容本身是地名的一部分,那就要按需调整。
最后一个心得是关于调试节奏的。跑热力图项目时不要在第一步改了数据就立刻去看图,先把中间产物落到CSV里,用QGIS或者在线地图打开CSV的坐标点,确认坐标是否落在真实街道上,所有点位是否符合预期,然后再去调热力图参数。这样能减少一半的无效调试时间。如果你连CSV坐标都懒得打开检查,后面出来的热力图会积累两到三个误差源,到时候排查起来比现在多花十倍时间。
我现在的习惯是:先出坐标CSV,配一个简单的经纬度校验脚本,自动计算点位是否落在合理范围内,比如经纬度范围检查、与城市边界框匹配、重复点位占比检测。通过这轮筛查之后的数据,再进入热力图渲染阶段,基本一步到位。