1. 为什么要在 QGIS 里折腾平移缩放工具
做 GIS 的人都有一个共同的肌肉记忆:左手按空格拖地图,右手滚轮缩放。这套操作在桌面端几乎成了本能,但一旦把场景搬到二次开发里,尤其是用 PyQGIS 写脚本、做插件、搭自动化流程的时候,很多人就懵了——地图画布到底怎么用代码控制?平移和缩放这两个最基础的动作,背后其实牵扯到画布对象、地图工具、事件循环、坐标系变换一整套东西。
我最早接触 PyQGIS 的平移缩放,是因为一个批量出图的活儿。手头有几十个监测点位,每个点位要出一张固定比例尺的周边图,人工一个个拖、一个个缩,眼睛都花了。当时想的就是写个脚本,让画布自动定位到指定坐标、自动设定比例尺,然后导出。结果一上手才发现,QgsMapCanvas的平移和缩放不是简单调个setCenter就完事,里面有一堆坑:比例尺和缩放级别的关系、MapTool和直接操作画布的区别、渲染是异步的导致截图截到空白等等。
所以这篇东西,我想把 PyQGIS 里平移缩放工具这一块彻底讲透。它适合三类人:一是刚学 PyQGIS、想搞明白画布控制的初学者;二是做 QGIS 插件、需要在工具栏里加自定义平移缩放按钮的开发者;三是做自动化制图、需要脚本控制视图的工程人员。核心会围绕QgsMapCanvas、QgsMapToolPan、QgsMapToolZoom、setExtent、setScale、zoomToFeature这些关键对象和方法展开,把每个参数为什么这么设、踩过什么坑都讲清楚。
先说结论:PyQGIS 的平移缩放有两条路,一条是"工具派",用QgsMapToolPan和QgsMapToolZoom模拟鼠标交互;另一条是"视图派",直接操作QgsMapCanvas的 extent 和 scale。两条路各有适用场景,选错了要么代码别扭,要么效果不对。下面我按这个思路一层层拆。
2. 核心对象与整体设计思路拆解
2.1 画布、地图工具、图层三者的关系
要理解平移缩放,先得把 QGIS 画布这套对象模型理清楚。很多人写 PyQGIS 卡壳,根本原因就是没搞明白谁管谁。
QgsMapCanvas是画布本体,它负责显示、负责渲染、负责持有当前视图范围(extent)。你可以把它想象成一块画板,画板上贴了什么、看的是哪一块区域,都由它说了算。画布有一个关键属性叫mapSettings(),返回QgsMapSettings,里面存着当前的比例尺、范围、目标坐标系、图层列表等渲染参数。平移和缩放,本质上改的就是QgsMapSettings里的 extent。
QgsMapTool是地图工具,是"交互层"的东西。它继承自QAbstractMapTool,负责把鼠标事件、键盘事件翻译成对画布的操作。QgsMapToolPan就是那个"按住左键拖动地图"的工具,QgsMapToolZoom是"框选或点击缩放"的工具。工具本身不存视图状态,它只是把用户的操作转成对画布的调用。
图层(QgsMapLayer)是数据层,它不直接参与平移缩放,但画布的 extent 计算、比例尺适配都跟图层的范围有关。比如zoomToFullExtent()就是遍历所有可见图层的范围求并集。
三者关系用一句话概括:工具接收输入,画布维护状态,图层提供数据。平移缩放改的是画布状态,工具只是改状态的一种手段。
2.2 工具派 vs 视图派:两条路怎么选
这是我在实际项目里反复权衡的一个点,值得单独说。
工具派的做法是:canvas.setMapTool(QgsMapToolPan(canvas)),然后用户就能拖了。缩放就是QgsMapToolZoom(canvas, False)(放大)或QgsMapToolZoom(canvas, True)(缩小)。这条路的好处是交互体验跟 QGIS 原生完全一致,鼠标样式、拖拽手感、框选行为都是现成的。适合做插件工具栏按钮,用户点一下按钮就切换到平移模式。
视图派的做法是:直接算好目标 extent,然后canvas.setExtent(rect)或canvas.zoomToFeature(layer)或canvas.setScale(50000)。这条路不依赖鼠标,纯代码控制,适合自动化脚本、批量出图、按坐标定位。
我个人的经验是:给用户用的交互功能走工具派,给程序用的自动化逻辑走视图派。两者不要混着用,混用容易出现"工具还挂着但视图已经被代码改了"的状态不一致问题。比如你脚本里setExtent之后,如果当前还挂着QgsMapToolPan,用户下一次拖动会从新 extent 继续,这倒没问题;但如果你挂着QgsMapToolZoom又去setScale,缩放工具的框选基准可能跟你预期的不一样。
还有一个折中方案:用QgsMapToolExtent自定义工具,既能框选又能拿到框选后的 extent 做自定义处理。这个后面实操部分会讲。
2.3 比例尺、缩放级别、extent 的换算逻辑
这是最容易出错的地方,必须讲透。
QGIS 里没有"缩放级别"这个概念(那是 Web 地图的玩法),它用的是比例尺(scale)。比例尺的定义是:地图上 1 单位长度对应实地多少单位长度。比如 1:50000,就是图上 1 厘米代表实地 50000 厘米。
画布的 extent 和比例尺是绑定的。给定画布像素宽度W、像素高度H、地图单位每像素mapUnitsPerPixel,那么:
extent宽度(地图单位) = W * mapUnitsPerPixel scale = mapUnitsPerPixel * 每米对应的地图单位数 * 像素物理尺寸换算实际用的时候不用自己算这么复杂,QGIS 提供了现成方法:
canvas.scale()拿当前比例尺canvas.setScale(50000)设比例尺,画布会自动以当前中心为基准调整 extentcanvas.mapUnitsPerPixel()拿每像素地图单位canvas.setExtent(rect)直接设范围,比例尺随之变化
关键坑点:setScale是以当前视图中心为锚点的。如果你先setCenter再setScale,顺序不能反,反了中心会漂。正确顺序是先定位中心,再设比例尺。我见过有人先setScale再setCenter,结果中心是准了但比例尺被setCenter触发的重算给改了,出图比例尺全错。
还有一个坑:setScale之后画布不会立即重绘,需要canvas.refresh()。而且刷新是异步的,如果你紧接着要截图或导出,得等mapCanvasRefreshed信号,或者用QgsMapRendererJob同步渲染。这个后面排查部分细讲。
3. 核心细节解析与实操要点
3.1 QgsMapToolPan 的正确挂载与卸载
QgsMapToolPan的用法看着简单,但挂载和卸载有讲究。
from qgis.gui import QgsMapToolPan canvas = iface.mapCanvas() pan_tool = QgsMapToolPan(canvas) canvas.setMapTool(pan_tool)这三行就能让画布进入平移模式。但有几个细节:
第一,QgsMapToolPan对象必须保持引用。如果你写成canvas.setMapTool(QgsMapToolPan(canvas)),Python 的垃圾回收可能在你还没拖完就把工具对象回收了,导致工具失效甚至崩溃。这是个经典坑,我早期就栽过。正确做法是把它存成实例变量或全局变量。
第二,卸载工具用canvas.unsetMapTool(tool),而不是setMapTool(None)。虽然后者有时也能用,但unsetMapTool会正确触发工具的deactivate(),清理内部状态。如果你自定义了工具,重写deactivate()做了清理逻辑,用setMapTool(None)可能不触发。
第三,平移工具的鼠标样式是抓手,如果你想自定义(比如换成别的光标),可以pan_tool.setCursor(Qt.OpenHandCursor),但要注意QgsMapToolPan内部会在按下时切换成ClosedHandCursor,你改的只是初始样式。
提示:在插件里切换工具时,记得先
unsetMapTool当前工具再setMapTool新工具,不要直接覆盖。直接覆盖在某些 QGIS 版本上会导致前一个工具的deactivate不被调用,留下悬空的事件过滤器。
3.2 QgsMapToolZoom 的放大缩小方向
QgsMapToolZoom的构造函数第二个参数是zoomOut,布尔值:
from qgis.gui import QgsMapToolZoom zoom_in_tool = QgsMapToolZoom(canvas, False) # 放大 zoom_out_tool = QgsMapToolZoom(canvas, True) # 缩小注意这个参数容易记反。False是放大(zoom in),True是缩小(zoom out)。我建议在代码里用常量或注释标清楚,别裸写布尔值,过两周自己都看不懂。
QgsMapToolZoom默认行为是框选缩放:按住左键拖一个矩形,松开后缩放到该矩形范围。如果你想要"点击即缩放"(点一下放大一级),需要重写canvasReleaseEvent,判断拖动距离小于阈值就当点击处理。这个在自定义工具里很常见。
还有一个细节:QgsMapToolZoom框选时,如果框选方向是反的(从右下往左上拖),QGIS 会自动规范化矩形,不用担心负宽高。但如果你自己拿rubberBand的几何去做后续处理,记得用boundingRect().normalized()。
3.3 setExtent 与 setCenter 的配合
视图派的核心就两个方法:setCenter和setExtent。
from qgis.core import QgsPointXY, QgsRectangle # 定位到某个点 canvas.setCenter(QgsPointXY(116.39, 39.9)) # 设定范围 rect = QgsRectangle(116.3, 39.8, 116.5, 40.0) canvas.setExtent(rect) canvas.refresh()setCenter只改中心,不改比例尺,画布会保持当前比例尺平移过去。setExtent同时改中心和比例尺,因为范围定了,比例尺就定了。
这里有个坐标系陷阱:setCenter和setExtent接收的坐标是画布当前坐标系下的坐标,不是图层坐标系。如果画布是 EPSG:3857,你传个经纬度进去,位置会偏到姥姥家。正确做法是先做坐标转换:
from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject canvas_crs = canvas.mapSettings().destinationCrs() point_crs = QgsCoordinateReferenceSystem("EPSG:4326") transform = QgsCoordinateTransform(point_crs, canvas_crs, QgsProject.instance()) point_in_canvas_crs = transform.transform(QgsPointXY(116.39, 39.9)) canvas.setCenter(point_in_canvas_crs)这段转换代码我几乎每个涉及定位的脚本里都会写一遍,建议封装成工具函数。
3.4 zoomToFeature 与 zoomToSelected 的适用场景
如果你要缩放到某个要素,别自己算 extent,直接用现成的:
# 缩放到单个要素 canvas.zoomToFeature(layer, feature_id) # 缩放到选中要素 canvas.zoomToSelected(layer) # 缩放到图层全图 canvas.zoomToFullExtent() # 缩放到所有图层全图 canvas.zoomToFullExtent()zoomToFeature的第二个参数是要素 ID,不是要素对象。如果你手里是QgsFeature,用feature.id()拿 ID。注意这个 ID 是图层内的 fid,不是全局的。
zoomToSelected依赖图层的选择集,如果没选中任何要素,它什么都不做(不会报错,但也没反应),这点要注意,最好先判断layer.selectedFeatureCount() > 0。
zoomToFullExtent是缩放到所有可见图层的并集范围。如果你只想缩放到某一个图层,用canvas.setExtent(layer.extent()),但要注意layer.extent()返回的是图层坐标系下的范围,需要转换到画布坐标系:
layer_extent = layer.extent() transform = QgsCoordinateTransform(layer.crs(), canvas.mapSettings().destinationCrs(), QgsProject.instance()) extent_in_canvas = transform.transformBoundingBox(layer_extent) canvas.setExtent(extent_in_canvas)这个转换很多人会忘,尤其是图层坐标系和画布坐标系不一致的时候,直接setExtent(layer.extent())会缩放到错误位置。
4. 实操过程与核心环节实现
4.1 环境准备与最小可运行示例
先说环境。PyQGIS 脚本有两种跑法:一是在 QGIS 自带的 Python 控制台里跑,二是在外部 IDE 里配好 QGIS 的 Python 环境跑。前者最省事,后者适合开发插件。
在 QGIS 里打开 Python 控制台(插件菜单 -> Python 控制台),直接就能用iface。下面是一个最小可运行的平移缩放示例:
from qgis.gui import QgsMapToolPan, QgsMapToolZoom from qgis.core import QgsPointXY canvas = iface.mapCanvas() # 保存工具引用,防止被回收 pan_tool = QgsMapToolPan(canvas) zoom_in_tool = QgsMapToolZoom(canvas, False) zoom_out_tool = QgsMapToolZoom(canvas, True) # 切换到平移 canvas.setMapTool(pan_tool) # 切回缩放 # canvas.setMapTool(zoom_in_tool) # 纯代码定位 canvas.setCenter(QgsPointXY(0, 0)) canvas.refresh()把这段贴进控制台,地图就会进入平移模式,你可以拖着玩。想切缩放就把注释打开。
4.2 做一个带平移缩放按钮的工具栏
光在控制台玩不够,实际项目里通常要做成工具栏按钮。下面是一个完整的插件工具栏示例,包含平移、放大、缩小、全图四个按钮:
from qgis.PyQt.QtWidgets import QToolBar, QAction from qgis.PyQt.QtGui import QIcon from qgis.gui import QgsMapToolPan, QgsMapToolZoom class MapNavToolbar: def __init__(self, iface): self.iface = iface self.canvas = iface.mapCanvas() self.toolbar = QToolBar("地图导航") self.actions = [] # 工具对象必须持有引用 self.pan_tool = QgsMapToolPan(self.canvas) self.zoom_in_tool = QgsMapToolZoom(self.canvas, False) self.zoom_out_tool = QgsMapToolZoom(self.canvas, True) def initGui(self): self._add_action("平移", self.pan_tool) self._add_action("放大", self.zoom_in_tool) self._add_action("缩小", self.zoom_out_tool) self._add_action("全图", None, self.zoom_full) self.iface.addToolBar(self.toolbar) def _add_action(self, name, tool, callback=None): action = QAction(name, self.iface.mainWindow()) if tool is not None: action.triggered.connect(lambda: self.canvas.setMapTool(tool)) else: action.triggered.connect(callback) self.toolbar.addAction(action) self.actions.append(action) def zoom_full(self): self.canvas.zoomToFullExtent() self.canvas.refresh() def unload(self): for action in self.actions: self.toolbar.removeAction(action) self.iface.mainWindow().removeToolBar(self.toolbar)这段代码有几个关键点值得说。第一,self.pan_tool等工具对象存在实例属性里,保证生命周期跟工具栏一致。第二,_add_action用 lambda 绑定工具,注意 lambda 里的tool是闭包变量,如果循环里创建多个 lambda 会有经典的闭包陷阱,这里因为每个 action 单独调用所以没问题。第三,unload里要清理工具栏和 action,否则插件卸载后工具栏还挂着。
4.3 自定义平移工具:加个右键菜单
原生QgsMapToolPan只有拖拽功能,如果你想在平移时加个右键菜单(比如"定位到此处"),需要继承它:
from qgis.gui import QgsMapToolPan from qgis.PyQt.QtWidgets import QMenu from qgis.core import QgsPointXY class MyPanTool(QgsMapToolPan): def __init__(self, canvas): super().__init__(canvas) self.canvas = canvas def canvasReleaseEvent(self, event): # 右键弹出菜单 if event.button() == Qt.RightButton: menu = QMenu() action = menu.addAction("定位到此处") result = menu.exec_(event.globalPos()) if result == action: point = self.toMapCoordinates(event.pos()) self.canvas.setCenter(point) self.canvas.refresh() else: # 左键走原生平移逻辑 super().canvasReleaseEvent(event)这里toMapCoordinates是QgsMapTool基类的方法,把屏幕像素坐标转成地图坐标,非常常用。注意右键事件要判断event.button(),别把左键拖拽也拦了。
4.4 脚本批量定位出图的完整流程
回到我开头说的批量出图场景,完整流程是这样的:
from qgis.core import (QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject, QgsRectangle, QgsPointXY) from qgis.PyQt.QtCore import QSize from qgis.core import QgsMapSettings, QgsMapRendererParallelJob def export_map_at_point(canvas, lon, lat, scale, output_path, width=800, height=600): # 1. 坐标转换 src_crs = QgsCoordinateReferenceSystem("EPSG:4326") dst_crs = canvas.mapSettings().destinationCrs() transform = QgsCoordinateTransform(src_crs, dst_crs, QgsProject.instance()) center = transform.transform(QgsPointXY(lon, lat)) # 2. 用独立的地图设置做渲染,不污染当前画布 settings = QgsMapSettings() settings.setLayers(canvas.mapSettings().layers()) settings.setDestinationCrs(dst_crs) settings.setOutputSize(QSize(width, height)) settings.setExtent(compute_extent(center, scale, width, height)) # 3. 同步渲染 job = QgsMapRendererParallelJob(settings) job.start() job.waitForFinished() image = job.renderedImage() image.save(output_path) def compute_extent(center, scale, width, height): # 比例尺转每像素地图单位 # 假设地图单位是米,1 像素 = 0.00028 米(QGIS 默认 DPI 下的换算) map_units_per_pixel = scale * 0.00028 half_w = width * map_units_per_pixel / 2 half_h = height * map_units_per_pixel / 2 return QgsRectangle(center.x() - half_w, center.y() - half_h, center.x() + half_w, center.y() + half_h)这段代码的核心思路是不操作画布,用独立的QgsMapSettings渲染。为什么?因为批量出图如果每次都改画布再截图,一是慢,二是画布刷新异步,容易截到旧图。用QgsMapRendererParallelJob同步渲染,稳定可靠。
compute_extent里的0.00028是 QGIS 默认的像素物理尺寸换算系数(对应约 90.7 DPI)。如果你要精确控制,应该用QgsMapSettings.outputDpi()来算:
dpi = settings.outputDpi() map_units_per_pixel = scale * 0.0254 / dpi0.0254是英寸转米的系数。这个换算我建议封装成函数,别每次手算。
5. 常见问题与排查技巧实录
5.1 画布刷新了但显示还是旧的
这是最高频的问题。原因通常是渲染异步。canvas.refresh()只是触发重绘请求,不是同步完成。如果你紧接着截图或读像素,拿到的可能是旧内容。
解决办法有三个:
一是监听mapCanvasRefreshed信号:
canvas.mapCanvasRefreshed.connect(on_refreshed) canvas.refresh()二是用QgsMapRendererParallelJob或QgsMapRendererSequentialJob同步渲染,不依赖画布。
三是用QgsApplication.processEvents()强制处理事件队列,但这招在复杂场景下不可靠,容易引发重入问题,不推荐。
我个人的选择是:只要涉及出图,一律用独立渲染 job,不碰画布。画布只负责给用户看,出图走另一条路。
5.2 setScale 之后比例尺对不上
前面提过,setScale以当前中心为锚点。如果你先setScale再setCenter,setCenter会保持比例尺平移,理论上比例尺不变。但如果你先setExtent再setScale,setScale会以setExtent后的中心为锚点重算 extent,比例尺是准的,但范围可能不是你想要的。
还有一个隐蔽的坑:画布的 DPI 和屏幕 DPI 不一致。setScale用的是逻辑 DPI,如果你在高分屏上,实际显示的比例尺可能跟设定值有偏差。这个在出图场景下尤其要注意,因为出图用的是QgsMapSettings的 DPI,跟画布 DPI 可能不同。
排查方法:设完比例尺后打印canvas.scale()和canvas.mapUnitsPerPixel(),跟手算值对比。如果对不上,检查 DPI 设置。
5.3 工具切换后鼠标事件丢失
有时候你setMapTool切了工具,但鼠标事件还是被旧工具处理。这通常是因为旧工具没正确deactivate。
排查步骤:
- 确认切换前调用了
canvas.unsetMapTool(old_tool) - 检查旧工具是否重写了
deactivate()且调用了super().deactivate() - 检查是否有其他地方也
setMapTool了,导致工具被覆盖
还有一个可能:工具对象被垃圾回收了。前面强调过,工具对象必须保持引用。如果你在函数里创建工具、setMapTool后函数返回,工具对象可能被回收,事件就没人处理了。
5.4 坐标系不一致导致定位偏移
这个问题的表现是:你明明传了正确的经纬度,地图却定位到了别的地方。原因就是坐标系没转换。
排查方法:打印canvas.mapSettings().destinationCrs().authid(),看看画布是什么坐标系。如果是 EPSG:3857,你传经纬度进去,经度 116 会被当成 116 米,直接偏到赤道附近。
解决就是前面说的QgsCoordinateTransform。我建议写个通用函数:
def transform_point(x, y, src_authid, dst_crs): src = QgsCoordinateReferenceSystem(src_authid) transform = QgsCoordinateTransform(src, dst_crs, QgsProject.instance()) return transform.transform(QgsPointXY(x, y))所有涉及坐标的地方都走这个函数,别偷懒。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 画布刷新后显示旧内容 | 渲染异步 | 监听 refreshed 信号 | 用独立渲染 job |
| 比例尺对不上 | DPI 不一致或锚点问题 | 打印 scale 和 mapUnitsPerPixel | 检查 DPI,注意调用顺序 |
| 工具切换后事件丢失 | 工具被回收或未 deactivate | 检查引用和 unsetMapTool | 保持引用,正确卸载 |
| 定位偏移 | 坐标系未转换 | 打印画布 CRS | 用 QgsCoordinateTransform |
| zoomToSelected 无反应 | 没有选中要素 | 检查 selectedFeatureCount | 先判断再调用 |
| 框选缩放方向反了 | zoomOut 参数记反 | 检查构造函数第二参数 | False 放大,True 缩小 |
| 平移工具拖不动 | 工具对象被回收 | 检查是否保持引用 | 存成实例变量 |
5.6 几个我踩过的独家坑
第一个坑:在canvasReleaseEvent里调setExtent会触发递归。因为setExtent会触发画布重绘,重绘可能触发某些信号,如果你的工具监听了这些信号又去改 extent,就死循环了。解决办法是加个标志位,或者用QTimer.singleShot(0, ...)把操作推迟到事件循环下一轮。
第二个坑:QgsMapToolZoom的框选橡皮筋在切换工具后不消失。这是 QGIS 的一个已知行为,工具deactivate时橡皮筋应该被清理,但某些版本有 bug。解决办法是在自定义工具的deactivate里手动self.rubberBand.reset()。
第三个坑:多显示器下toMapCoordinates坐标偏移。如果你在副屏上操作,event.pos()返回的是相对窗口的坐标,但某些情况下会带上屏幕偏移。解决办法是用event.pos()而不是event.globalPos(),并且确保画布是活动窗口。
第四个坑:setScale在画布尺寸为 0 时行为异常。如果画布还没显示(比如插件初始化阶段),canvas.width()返回 0,setScale算出来的 extent 是无效的。解决办法是等画布show之后再操作,或者用QTimer.singleShot延迟执行。
6. 性能优化与进阶玩法
6.1 大量要素下的平移缩放卡顿
当图层要素很多时,平移缩放会卡。原因通常是每次视图变化都触发全量重绘。优化手段有几个:
一是开启简化渲染。QgsMapSettings有setSimplifyMethod,可以设置简化容差,视图缩小时自动简化几何。
二是用空间索引。如果图层有空间索引,QGIS 渲染时会自动只取视图范围内的要素。检查图层是否有索引:layer.hasSpatialIndex()。没有的话用QgsSpatialIndex建一个。
三是限制渲染比例尺。给图层设setScaleBasedVisibility(True)和setMinimumScale/setMaximumScale,超出范围就不渲染。
四是关闭不必要的渲染选项。比如setFlag(QgsMapSettings.DrawLabeling, False)临时关掉标注,平移会流畅很多。
6.2 动画平移缩放
QGIS 原生没有动画平移,但可以自己实现。思路是用QTimer定时改 extent,每次改一点点,形成平滑过渡:
from qgis.PyQt.QtCore import QTimer class SmoothZoom: def __init__(self, canvas): self.canvas = canvas self.timer = QTimer() self.timer.timeout.connect(self.step) self.steps = 0 self.total_steps = 20 self.start_extent = None self.end_extent = None def animate_to(self, target_extent): self.start_extent = self.canvas.extent() self.end_extent = target_extent self.steps = 0 self.timer.start(16) # 约 60fps def step(self): self.steps += 1 t = self.steps / self.total_steps # 缓动函数,ease-out t = 1 - (1 - t) ** 3 current = QgsRectangle( self.start_extent.xMinimum() + (self.end_extent.xMinimum() - self.start_extent.xMinimum()) * t, self.start_extent.yMinimum() + (self.end_extent.yMinimum() - self.start_extent.yMinimum()) * t, self.start_extent.xMaximum() + (self.end_extent.xMaximum() - self.start_extent.xMaximum()) * t, self.start_extent.yMaximum() + (self.end_extent.yMaximum() - self.start_extent.yMaximum()) * t, ) self.canvas.setExtent(current) self.canvas.refresh() if self.steps >= self.total_steps: self.timer.stop()这段代码的关键是缓动函数1 - (1-t)^3,让动画有减速效果,比线性过渡自然。16 毫秒对应 60fps,但实际渲染可能跟不上,可以适当调大间隔。
6.3 键盘快捷键绑定平移缩放
给平移缩放绑快捷键能大幅提升效率。在插件里可以这样:
from qgis.PyQt.QtGui import QKeySequence from qgis.PyQt.QtWidgets import QShortcut shortcut_pan = QShortcut(QKeySequence("P"), iface.mainWindow()) shortcut_pan.activated.connect(lambda: canvas.setMapTool(pan_tool)) shortcut_zoom_in = QShortcut(QKeySequence("+"), iface.mainWindow()) shortcut_zoom_in.activated.connect(lambda: canvas.zoomIn()) shortcut_zoom_out = QShortcut(QKeySequence("-"), iface.mainWindow()) shortcut_zoom_out.activated.connect(lambda: canvas.zoomOut())canvas.zoomIn()和canvas.zoomOut()是画布自带的快捷方法,默认放大缩小 2 倍。如果你想自定义倍数,得自己算 extent。
注意快捷键的作用域,QShortcut绑在mainWindow上是全局的,可能跟 QGIS 原生快捷键冲突。建议用组合键,比如Ctrl+Shift+P。
6.4 与书签功能结合
QGIS 有空间书签功能,可以把当前视图存成书签,以后一键跳回。PyQGIS 里可以这样操作:
from qgis.core import QgsBookmarkManager, QgsBookmark, QgsReferencedRectangle manager = QgsProject.instance().bookmarkManager() bookmark = QgsBookmark() bookmark.setName("监测点A") bookmark.setExtent(QgsReferencedRectangle(canvas.extent(), canvas.mapSettings().destinationCrs())) manager.addBookmark(bookmark)存了书签之后,跳转就是canvas.setExtent(bookmark.extent())。这个在做巡检、监测类项目时特别有用,把每个关键点位存成书签,出图时遍历书签批量导出。
7. 一些零散但重要的经验
关于QgsMapCanvas的freeze()和freeze(False):如果你要连续做多次视图操作(比如先 setCenter 再 setScale 再 setExtent),建议先canvas.freeze(True),操作完再canvas.freeze(False)。这样中间过程不会触发重绘,最后一次性刷新,性能好很多,也避免中间状态被渲染出来。
关于QgsMapTool的activate()和deactivate():自定义工具时,这两个方法要正确重写并调用父类实现。activate里做初始化(比如创建橡皮筋),deactivate里做清理(比如隐藏橡皮筋)。漏掉父类调用会导致工具行为异常。
关于canvas.mapSettings().setLayers():如果你动态改图层列表(比如临时隐藏某层),改完要canvas.refresh()。但注意setLayers会重置渲染缓存,频繁调用会卡。更好的做法是用layer.setItemVisibilityChecked(False)控制图层可见性,画布会自动响应。
关于QgsProject.instance().layerTreeRoot():图层树和画布是联动的,改图层树的可见性会同步到画布。但反过来,直接改画布的图层列表不会同步到图层树。所以操作图层可见性优先走图层树。
关于坐标转换的性能:QgsCoordinateTransform的创建有开销,如果在一个循环里反复转换,建议把 transform 对象提到循环外复用。我见过有人在循环里每次 new 一个 transform,几千次下来慢得离谱。
关于QgsRectangle的normalized():从两个点构造矩形时,如果点的顺序不对,矩形可能是负宽高。normalized()会返回一个规范化的矩形。但注意normalized()返回新对象,不改变原对象,别写成rect.normalized()然后继续用rect。
关于canvas.zoomByFactor():这是画布自带的方法,按倍数缩放,比setScale方便。canvas.zoomByFactor(2)放大 2 倍,canvas.zoomByFactor(0.5)缩小一半。它也是以当前中心为锚点。
关于canvas.zoomWithCenter():按倍数缩放并指定中心点,适合做"以鼠标位置为中心缩放"的效果。实现方式是在滚轮事件里拿鼠标位置转地图坐标,然后canvas.zoomWithCenter(factor, point)。
关于QgsMapToolZoom的setZoomFactor():可以自定义每次缩放的倍数,默认是 2。如果你觉得 2 倍太猛,可以设成 1.5。
关于画布的destinationCrs()和mapSettings().destinationCrs():两者是同一个东西,canvas.mapSettings().destinationCrs()更明确。改画布坐标系用canvas.setDestinationCrs(crs),改完要refresh()。
关于QgsMapCanvas的setRotation():画布支持旋转,旋转后平移缩放的坐标系也跟着转。如果你做的是正北朝上的地图,别碰这个。如果做的是航线、轨迹类可视化,旋转很有用。旋转后toMapCoordinates返回的坐标是旋转后的,注意转换。
关于QgsMapToolPan的中键平移:原生QgsMapToolPan只响应左键,中键平移需要自己重写canvasPressEvent判断Qt.MiddleButton。很多 GIS 用户习惯中键平移,加上这个体验会好很多。
关于canvas.setExtent()的边界:如果传入的 extent 宽高为 0(比如一个点),画布会异常。解决办法是给个最小宽高,比如rect.grow(0.001)。
关于QgsMapRendererParallelJob的线程安全:这个 job 在后台线程渲染,渲染过程中不要修改图层数据,否则可能崩溃。如果要在渲染时改数据,用QgsMapRendererSequentialJob或者加锁。
关于QgsMapSettings的setOutputSize和setExtent的配合:extent 的宽高比应该跟 outputSize 的宽高比一致,否则渲染出来的图会被拉伸或留白。如果不一致,QGIS 会以 extent 为准,outputSize 只决定像素密度。要精确控制,先算好 extent 的宽高比再设 outputSize。
关于canvas.refresh()和canvas.repaint():refresh()是异步重绘,repaint()是同步立即重绘。repaint()会阻塞 UI,除非必要别用。我一般用refresh(),需要同步时用渲染 job。
关于QgsMapTool的canvasMoveEvent:这个事件触发非常频繁,里面别做重操作。如果要在移动时显示坐标,用节流(比如每 50 毫秒更新一次),别每次移动都更新 UI。
关于QgsMapToolEmitPoint:如果你只需要拿鼠标点击的坐标,不需要平移缩放,用QgsMapToolEmitPoint最省事。它有个canvasClicked信号,点击时发出坐标。
关于QgsMapToolIdentify:点击识别要素的工具,做查询功能时常用。它跟平移缩放工具是互斥的,切换时记得正确卸载。
关于iface.mapCanvas()和QgsMapCanvas():iface.mapCanvas()是 QGIS 主窗口的画布,QgsMapCanvas()是新建的独立画布。插件里一般用前者,独立应用用后者。独立画布需要自己管理生命周期和事件循环。
关于QgsProject.instance().readProject()和画布:加载项目后画布会自动刷新,但如果你在加载过程中操作画布,可能拿到空图层列表。建议监听readProject完成的信号再操作。
关于canvas.layers()和QgsProject.instance().mapLayers():前者返回画布当前渲染的图层(有序),后者返回项目里所有图层(无序字典)。要按渲染顺序操作图层,用前者。
关于QgsMapCanvas的setParallelRenderingEnabled():开启并行渲染能提升多核性能,但某些驱动下可能不稳定。如果遇到渲染花屏,关掉试试。
关于QgsMapCanvas的setCachingEnabled():开启缓存能加速重复渲染,但会占内存。大数据量下建议开启,小数据量无所谓。
关于QgsMapCanvas的setPreviewJobsEnabled():开启预览渲染,平移时先渲染低分辨率预览,停下后再渲染高清。这个对大数据量平移流畅度提升明显,强烈建议开启。
关于QgsMapCanvas的setSegmentationTolerance():曲线渲染的容差,值越大曲线越粗糙但渲染越快。做概览图时可以调大。
关于QgsMapCanvas的setExpressionContextScope():设置表达式上下文,影响标注、符号的表达式求值。做动态样式时常用。
关于QgsMapCanvas的setTheme():切换地图主题,可以预设多套符号样式,一键切换。做多版本出图时很有用。
关于QgsMapCanvas的snapToMapMode():捕捉模式,平移缩放时是否吸附到要素。做精确编辑时开启。
关于QgsMapCanvas的setMagnificationFactor():放大镜功能,可以在不改变比例尺的情况下局部放大显示。做细节查看时有用。
关于QgsMapCanvas的panAction()和zoomAction():这两个是 QGIS 3.x 引入的,返回QgsMapCanvasItem,可以拿到平移缩放的动画对象。做自定义动画时可以用。
关于QgsMapCanvas的mapCanvasRefreshed信号:每次渲染完成发出,参数是布尔表示是否成功。做渲染后处理时监听这个信号。
关于QgsMapCanvas的extentsChanged信号:视图范围变化时发出,做视图联动时监听这个。注意这个信号触发频繁,里面别做重操作。
关于QgsMapCanvas的scaleChanged信号:比例尺变化时发出,参数是新的比例尺。做比例尺显示时监听这个。
关于QgsMapCanvas的destinationCrsChanged信号:坐标系变化时发出。做坐标系联动时监听。
关于QgsMapCanvas的layersChanged信号:图层列表变化时发出。做图层联动时监听。
关于QgsMapCanvas的currentLayerChanged信号:当前图层变化时发出。做图层切换响应时监听。
关于QgsMapCanvas的mapToolSet信号:工具切换时发出,参数是新旧工具。做工具状态同步时监听。
这些信号是画布跟外部交互的桥梁,用好它们能让插件跟 QGIS 主程序无缝集成。我一般会在插件初始化时把这些信号都连上,做统一的状态管理。
最后说一个心态问题。PyQGIS 的平移缩放看着简单,但真要做得稳、做得顺,需要对这些对象和信号有系统的理解。我建议新手别一上来就写复杂插件,先在 Python 控制台里把setCenter、setScale、setExtent、setMapTool这几个方法玩熟,观察每次调用后画布的变化,打印canvas.extent()和canvas.scale()看数值。玩上几十次,这套东西就刻进肌肉记忆了。到那时候再写插件,就是水到渠成的事。