简介:这份资源面向旅游规划场景的开发者与学习者,提供一套基于PyQt5与Folium的景点路线规划工具。它用PyQt5搭建直观的桌面界面,借助Folium与QWebEngineView渲染交互式地图,直观展示景点分布;底层通过NetworkX实现Dijkstra等最短路径算法,支持景点搜索、路线规划与时间估算,帮助游客高效安排行程。资源包共312个文件,以300个xlsx景点数据表为主体,另含6个py源码、4个pyc编译文件、1个pth模型权重与1个txt说明,压缩包约6.67MB,结构清晰,便于按数据、算法、界面模块分别查阅。目前已有99人学习下载。读者可从中获得完整的GUI与地图可视化实现、路径规划算法调用示例、景点数据库组织方式及模型文件,适合作为课程设计或旅游类应用的参考模板。
1. 当 PyQt5 遇上 Folium:桌面端地图可视化到底能做成什么样
很多做桌面工具的朋友都遇到过这个尴尬:数据是带经纬度的,业务方想要一张能点、能拖、能看热力的地图,但你手上只有 PyQt5。直接嵌网页吧,交互和打包都别扭;自己用 QPainter 画地图吧,投影和瓦片又是个无底洞。PyQt5 加 Folium 这套组合,恰好卡在一个很舒服的位置——Folium 负责把地理数据渲染成 Leaflet 地图,PyQt5 负责把它装进原生窗口,中间用 QtWebEngine 当桥梁。
这套方案能解决的核心问题就一个:让你在不写前端工程的前提下,把带空间维度的数据变成可交互的地图界面,并且能打包成 exe 发给不懂技术的同事。适合谁?适合做内部工具、数据分析看板、设备点位管理、物流轨迹回放这类场景的 Python 开发者。它不适合做高并发 Web 服务,也不适合做需要离线瓦片底图的重型 GIS,那是另一条路。下面我按实际落地的顺序,把选型、代码、参数和坑一个个拆开讲。
2. 环境搭建与最小可运行闭环:从 pip 到窗口里出现一张地图
这一章的目标很明确:让你在半小时内看到一张真实的地图出现在 PyQt5 窗口里。别急着上业务数据,先把链路跑通,后面所有复杂度都是在这条链路上加东西。
2.1 依赖选型:为什么是 QtWebEngine 而不是 QWebView
PyQt5 里能显示网页的组件历史上换过好几代。老教程里常见的 QWebView 属于 QtWebKit,早就停止维护,在新版 PyQt5 里根本装不上。现在唯一正确的选择是 QtWebEngine,它基于 Chromium,能跑现代 JavaScript,Folium 生成的 Leaflet 页面全靠它。
安装命令如下:
pip install PyQt5 PyQtWebEngine folium这里有个血泪经验:PyQt5 和 PyQtWebEngine 的版本必须匹配。如果你只写pip install PyQt5,默认不会带 WebEngine,运行时会报No module named 'PyQt5.QtWebEngineWidgets'。所以两个包要一起装,而且尽量用同一个小版本号。
参数说明:PyQt5提供窗口、布局、信号槽;PyQtWebEngine提供QWebEngineView;folium负责生成 HTML 字符串。三者缺一不可。
2.2 最小闭环代码:把 Folium 的 HTML 塞进 QWebEngineView
核心思路是:Folium 的Map对象有一个_repr_html_()方法,能直接吐出完整 HTML 字符串。我们把这个字符串交给QWebEngineView.setHtml()就行。
import sys import folium from PyQt5.QtCore import QUrl from PyQt5.QtWidgets import QApplication, QMainWindow from PyQt5.QtWebEngineWidgets import QWebEngineView class MapWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("最小地图窗口") self.resize(900, 600) # 1. 创建地图对象,指定中心点和初始缩放 m = folium.Map( location=[39.9042, 116.4074], # 中心点纬度、经度 zoom_start=12, # 初始缩放级别 tiles="OpenStreetMap" # 底图样式 ) # 2. 生成 HTML 字符串 html = m._repr_html_() # 3. 放进 WebEngine 视图 self.view = QWebEngineView() self.view.setHtml(html, QUrl("about:blank")) self.setCentralWidget(self.view) if __name__ == "__main__": app = QApplication(sys.argv) win = MapWindow() win.show() sys.exit(app.exec_())逻辑说明:folium.Map只负责数据层,不负责渲染;_repr_html_()返回的是自包含 HTML,里面通过 CDN 引 Leaflet 的 js 和 css。setHtml的第二个参数是 baseUrl,传about:blank是为了让相对路径解析有个基准,不传在某些版本下会白屏。
参数说明:location是[lat, lon]顺序,别写反,写反了地图会跑到南极。zoom_start建议 10 到 14,太小看不清,太大加载瓦片多。tiles可选OpenStreetMap、CartoDB positron、Stamen Terrain等,后两个在 Folium 里需要额外指定attr,新手先用默认的。
2.3 验证链路是否真的通了
运行后如果窗口里出现地图,说明链路通了。如果白屏,按这个顺序排查:第一,看控制台有没有QtWebEngineWidgets导入错误;第二,看是不是公司网络挡了 CDN,Folium 默认从国外 CDN 拉 Leaflet,内网环境会白屏;第三,看setHtml有没有传 baseUrl。
提示:内网环境建议把 Leaflet 的 js/css 下载到本地,用
folium.Map(default_js=...)或直接改 HTML 里的引用路径,否则你会对着白屏怀疑人生。
3. 数据上图的三种姿势:点、线、热力各自的参数怎么调
链路通了之后,真正的工作是把业务数据画上去。Folium 的图层体系很清晰,点用 Marker,线用 PolyLine,热力用 HeatMap,但每个图层的参数都有讲究,调不好要么显示不出来,要么性能崩掉。
3.1 点标记:Marker 与 CircleMarker 的选择和弹窗绑定
单点用folium.Marker,大量点用folium.CircleMarker。区别在于 Marker 用的是图片图标,每个点一个 DOM 元素,超过 500 个就卡;CircleMarker 用矢量圆,几千个还能撑。
import folium m = folium.Map(location=[39.9042, 116.4074], zoom_start=12) # 单点标记,带弹窗 folium.Marker( location=[39.9042, 116.4074], popup="设备A", # 点击弹出的内容 tooltip="鼠标悬停显示", # 悬停提示 icon=folium.Icon(color="red", icon="info-sign") ).add_to(m) # 批量圆点,适合点位密集场景 points = [ (39.91, 116.40, "点位1"), (39.92, 116.41, "点位2"), (39.93, 116.42, "点位3"), ] for lat, lon, name in points: folium.CircleMarker( location=[lat, lon], radius=6, # 半径像素 color="#3388ff", # 边框色 fill=True, fill_color="#3388ff", fill_opacity=0.7, popup=name ).add_to(m)逻辑说明:add_to(m)是把图层挂到地图对象上,不调用就不会显示。popup支持 HTML 字符串,可以塞表格。tooltip是悬停提示,和 popup 不冲突。
参数说明:radius是像素不是米,缩放时圆的大小不变,这点和地理半径要区分。fill_opacity建议 0.5 到 0.8,太透明看不清,太实盖住底图。
3.2 轨迹线:PolyLine 的坐标顺序和颜色映射
轨迹数据通常是[[lat, lon], [lat, lon], ...]的列表。PolyLine 直接吃这个格式,但要注意坐标顺序和抽稀。
import folium import random m = folium.Map(location=[39.9042, 116.4074], zoom_start=12) # 模拟一条轨迹 track = [[39.90 + i * 0.001, 116.40 + i * 0.001] for i in range(50)] folium.PolyLine( locations=track, color="blue", weight=3, # 线宽 opacity=0.8, tooltip="轨迹A" ).add_to(m) # 起点终点单独标记 folium.Marker(track[0], popup="起点", icon=folium.Icon(color="green")).add_to(m) folium.Marker(track[-1], popup="终点", icon=folium.Icon(color="red")).add_to(m)逻辑说明:PolyLine 的locations必须是[[lat, lon], ...],如果数据是[lon, lat]要先翻转。轨迹点超过 2000 个建议抽稀,否则浏览器渲染会明显掉帧。
参数说明:weight是线宽像素,opacity是透明度。如果要按速度或时间做颜色渐变,Folium 原生不支持单条线多色,常见做法是拆成多段 PolyLine,每段一个颜色。
3.3 热力图:HeatMap 的权重和半径参数
热力图适合展示密度分布,比如设备告警密度、人流密度。Folium 的 HeatMap 插件需要单独导入。
from folium.plugins import HeatMap import folium import random m = folium.Map(location=[39.9042, 116.4074], zoom_start=12) # 数据格式:[lat, lon, weight] heat_data = [ [39.90 + random.uniform(-0.05, 0.05), 116.40 + random.uniform(-0.05, 0.05), random.uniform(0.1, 1.0)] for _ in range(500) ] HeatMap( heat_data, radius=15, # 每个点的扩散半径 blur=10, # 模糊程度 min_opacity=0.3, # 最小透明度 max_zoom=13 # 超过这个缩放级别不再聚合 ).add_to(m)逻辑说明:热力图的第三个值是权重,不传默认 1。radius和blur是最影响观感的两个参数,半径大适合看宏观分布,半径小适合看局部热点。
参数说明:min_opacity太低会导致边缘几乎看不见,建议 0.3 以上。max_zoom控制聚合行为,设太小放大后热力会消失,设太大性能差。
注意:热力图数据量超过 5000 条时,建议先在 Python 端做网格聚合,把相近的点合并成一个带累计权重的点,否则前端渲染会卡到怀疑人生。
4. PyQt5 与 Folium 的双向通信:让地图上的点击驱动桌面逻辑
如果只是显示地图,那用浏览器就够了。PyQt5 的价值在于地图能和桌面控件联动——点地图上的点,右侧表格刷新;改下拉框,地图图层切换。这一章讲怎么打通这个双向通道。
4.1 用 runJavaScript 从 Python 调用地图 JS
QWebEngineView 提供了page().runJavaScript(),可以在页面加载完成后执行任意 JS。Folium 生成的页面里,地图对象默认挂在全局变量map上(注意 Folium 不同版本变量名可能不同,常见是map或_map)。
from PyQt5.QtWebEngineWidgets import QWebEngineView from PyQt5.QtCore import QUrl class MapView(QWebEngineView): def __init__(self, html): super().__init__() self.setHtml(html, QUrl("about:blank")) self.loadFinished.connect(self.on_loaded) def on_loaded(self, ok): if ok: # 页面加载完成后,把地图飞到指定坐标 js = "map.flyTo([39.95, 116.45], 14);" self.page().runJavaScript(js) def add_marker_from_python(self, lat, lon, name): js = f""" L.marker([{lat}, {lon}]).addTo(map).bindPopup('{name}'); """ self.page().runJavaScript(js)逻辑说明:loadFinished信号保证 DOM 和 Leaflet 都初始化完了再执行 JS,否则会报map is not defined。runJavaScript是异步的,不阻塞 UI,但也没有返回值,需要回传数据得用回调。
参数说明:flyTo的第二个参数是缩放级别,和zoom_start一个含义。拼接 JS 字符串时注意引号转义,name 里有单引号会破坏语句,稳妥做法是用json.dumps包一层。
4.2 用 QWebChannel 从地图回调 Python
反向通信要靠 QWebChannel,这是 Qt 官方提供的桥。步骤稍微多几步,但一次搭好后面很省心。
from PyQt5.QtCore import QObject, pyqtSlot from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtWebEngineWidgets import QWebEngineView class Bridge(QObject): @pyqtSlot(float, float) def onMapClick(self, lat, lon): print(f"地图被点击:{lat}, {lon}") class MapView(QWebEngineView): def __init__(self, html): super().__init__() self.bridge = Bridge() self.channel = QWebChannel() self.channel.registerObject("bridge", self.bridge) self.page().setWebChannel(self.channel) self.setHtml(html, QUrl("about:blank"))对应的 JS 侧需要在 HTML 里引入qwebchannel.js并建立连接:
new QWebChannel(qt.webChannelTransport, function(channel) { var bridge = channel.objects.bridge; map.on('click', function(e) { bridge.onMapClick(e.latlng.lat, e.latlng.lng); }); });逻辑说明:registerObject把 Python 对象暴露给 JS,JS 侧通过channel.objects.bridge拿到引用。@pyqtSlot装饰器必须加,否则 JS 调用不会触发。
参数说明:qwebchannel.js文件在 PyQt5 安装目录下,需要手动读取并内联到 HTML 里,或者用qrc:///qtwebchannel/qwebchannel.js这个内置路径引用,后者更省事。
4.3 一个完整的联动场景:点击地图刷新表格
把上面两块拼起来,就是:地图点击 → JS 调 Python → Python 更新 QTableWidget。这个模式可以套用到任何「地图选点、桌面展示详情」的需求上。实际写的时候注意,onMapClick里不要做耗时操作,否则会卡住 WebEngine 的渲染线程,重活丢到QThread里。
5. 避坑与排查:那些让我加班到凌晨的细节
这一章全是踩过的坑,按「现象 → 原因 → 解决」写,你遇到问题时直接对号入座。
5.1 窗口白屏,控制台没有任何报错
现象:代码跑起来,窗口出来了,但地图区域一片白,控制台干净得像没运行过。
原因:九成是 CDN 被挡。Folium 默认从国外 CDN 加载 Leaflet 的 js 和 css,内网或网络受限环境拉不到,页面就白着。
解决:把 Leaflet 的 js/css 下载到本地,改 Folium 的默认模板,或者生成 HTML 后用字符串替换把 CDN 地址换成本地文件路径。另一个可能是setHtml没传 baseUrl,补上QUrl("about:blank")。
5.2 地图显示但无法交互,拖拽缩放全失效
现象:地图能出来,但鼠标拖不动,滚轮也没反应。
原因:QWebEngineView 默认会拦截部分鼠标事件,或者页面里的 JS 报错导致 Leaflet 初始化中断。
解决:先打开开发者工具看 Console,在__init__里加self.page().settings().setAttribute(QWebEngineSettings.JavascriptEnabled, True)。如果是事件拦截,检查有没有在父控件上装了事件过滤器。
5.3 打包成 exe 后地图不显示
现象:源码运行正常,PyInstaller 打包后地图区域空白。
原因:QtWebEngine 依赖一堆资源文件( locales、qtwebengine_resources.pak 等),PyInstaller 默认不会全打进去。
解决:用--add-data把 PyQt5 的Qt5/bin下的 WebEngine 资源目录带上,或者直接用pyinstaller --collect-all PyQt5。打包后体积会到 200MB 以上,这是 Chromium 的代价,接受不了就换方案。
5.4 大量点位导致界面卡死
现象:加载 2000 个 Marker 后,窗口拖动明显掉帧,点击响应延迟。
原因:每个 Marker 是一个独立 DOM 元素,DOM 数量爆炸。
解决:超过 500 个点改用 CircleMarker,超过 5000 个点用folium.plugins.MarkerCluster做聚合,或者干脆在 Python 端按缩放级别做抽稀,只传当前视野内的点。
5.5 中文标注显示成方块
现象:弹窗里的中文变成一个个方框。
原因:页面默认字体不含中文,或者 HTML 的 meta 没声明编码。
解决:在生成 HTML 后插入<meta charset="utf-8">,并在 CSS 里指定font-family: "Microsoft YaHei", sans-serif。Folium 的_repr_html_()默认已经带 charset,但自定义模板时容易漏。
6. 进阶技巧:离线瓦片、性能取舍与一个我常用的调试习惯
走到这里,基础功能应该都通了。最后聊几个进阶方向,都是实际项目里绕不开的。
第一个是离线瓦片。内网环境没法访问在线底图,常见做法是用本地瓦片服务或者直接加载本地图片瓦片。Folium 支持自定义tiles参数指向本地 URL 模板,比如tiles="http://localhost:8080/tiles/{z}/{x}/{y}.png",配合一个本地的瓦片服务器就行。如果连服务器都不想搭,可以用folium.raster_layers.ImageOverlay直接贴一张静态图片当底图,适合范围固定的场景。
第二个是性能取舍。QtWebEngine 本身占内存,一个空窗口就 100MB 起步,加上地图和点位,300MB 很正常。如果目标机器内存紧张,可以考虑用QWebEngineView的setAttribute关掉不必要的能力,比如 WebGL、插件。另外,地图页面不要频繁setHtml重建,重建一次等于重启一次 Chromium 渲染进程,正确做法是用runJavaScript增量更新图层。
第三个是我自己的调试习惯:永远先单独跑 Folium 生成的 HTML。把m.save("debug.html")存下来,用浏览器打开,确认地图和数据都没问题,再往 PyQt5 里塞。这样能把「Folium 的问题」和「QtWebEngine 的问题」分开,排查效率翻倍。我早期不信这个邪,非要在 PyQt5 里调地图,结果一个 JS 报错查了一下午,后来老老实实先存 HTML,十分钟定位。
最后一个技巧是关于 QWebChannel 的。如果回调频繁触发(比如地图拖拽结束事件),不要在 Python 槽函数里直接操作 UI,用QTimer.singleShot(0, ...)丢到事件循环里,避免阻塞 WebEngine 的通信线程。这个习惯帮我省过好几次「界面莫名卡顿」的排查时间。
希望帮到你。
本文还有配套的精品资源,点击获取