首页 / 资讯中心 / 文章详情

遥感影像查看器:PyQt5+pyqtgraph处理超大SAR影像

遥感影像查看器:PyQt5+pyqtgraph处理超大SAR影像 ★ FEATURED ARTICLE
简介这份资源面向使用 PyQt5 开发图像显示界面的 Python 开发者尤其是需要处理超大遥感影像、SAR 影像的从业者与学习者针对大图直接加载导致程序卡顿的问题给出了一套基于瓦片分割的显示思路将影像切分为较小瓦片后循环绘制到 QGraphicsItem 中并在分片基础上实现缩放、平移、范围选取与点选取同时支持普通图片、遥感影像、SAR 影像的展示与图层切换还附带若干图像质量评价工具。压缩包共 194 个文件以 88 个 py 源码与 72 个 pyc 编译文件为主体另有 22 个 png 界面图标、3 个 qrc 资源文件及若干 m 脚本等整体约 2.52MB结构紧凑、便于直接运行与二次开发。目前已有 1788 人学习下载。读者可从中获得可复用的瓦片化大图渲染方案、交互式缩放平移与选区实现、图层管理与质量评价脚本以及界面资源组织方式适合作为遥感影像查看器与图像处理工具的开发参考。1. 遥感影像动辄几个 G为什么我最后选了 pyqtgraph 而不是 QGraphicsView去年帮某实验室做一个遥感影像标注工具需求很朴素打开一张 2 万乘 2 万像素的 SAR 影像能缩放、能平移、能看像素值。我一开始用 QGraphicsView 加 QGraphicsPixmapItem代码三十行就跑起来了结果加载一张 800MB 的 GeoTIFF 直接卡死内存飙到 6 个 G拖动的时候帧率掉到个位数。后来换成 pyqt5 加 pyqtgraph 的组合同样的图首屏 1.2 秒出来缩放平移基本跟手内存稳定在 1.5G 左右。这个差距不是玄学是渲染路径和数据结构选错了。这篇笔记拆的就是这套方案用 PyQt5 做窗口和交互骨架用 pyqtgraph 的 ImageItem 做影像渲染配合分块读取和降采样撑住超大图片、遥感影像、SAR 影像的展示、缩放、平移。适合两类人一是手里有几百兆到几个 G 的影像、想快速搭个查看器的从业者二是已经用 QGraphicsView 翻过车、想搞清楚为什么换框架能救回来的开发者。下面从选型理由讲到可复现的代码再到我踩过的坑照着走能跑起来。2. 选型与渲染原理pyqtgraph 凭什么扛得住大图2.1 QGraphicsView 和 pyqtgraph 的渲染路径差异QGraphicsView 是 Qt 的通用 2D 场景框架它的设计目标是管理大量图元、做碰撞检测和事件分发不是为像素级影像渲染优化的。你把一张 2 万乘 2 万的图塞进 QGraphicsPixmapItemQt 会尝试把它转成 QPixmap 上传到显存这一步在图片超过显卡纹理上限常见 8192 或 16384时会走软件回退于是每次重绘都在 CPU 上做整图缩放卡是必然的。pyqtgraph 的 ImageItem 走的是另一条路它把 numpy 数组作为数据源渲染时只把当前视口对应的那部分数据交给 OpenGL 或 QPainter缩放时通过 setRect 调整显示矩形而不是重新采样整张图。换句话说QGraphicsView 是「把整张图搬来搬去」pyqtgraph 是「只搬你看得见的那块」。这个差异在影像尺寸超过 5000 像素时就会明显暴露。常见做法是小于 4000 像素的图QGraphicsView 够用且生态成熟超过这个量级尤其是遥感、SAR 这种单张就上 G 的直接上 pyqtgraph别犹豫。2.2 ImageItem 的数据流从 numpy 数组到屏幕像素理解 ImageItem 的关键是搞清楚它接受什么、怎么变换。它接受一个二维或三维 numpy 数组二维是灰度三维的第三维是通道RGB 或 RGBA。渲染时它内部维护一个 lut查找表做灰度映射一个 levels 参数做对比度拉伸一个 transform 做坐标变换。你调用 setImage 传数组它不会立刻上传整张图而是等你把它加进 ViewBox 并触发重绘时才处理。这里有个容易被忽略的点ImageItem 的 axisOrder 参数。pyqtgraph 默认按「行是 x、列是 y」解释数组而遥感影像的习惯是「行是 y、列是 x」两者差一个转置。不设这个参数图会转 90 度很多人第一次跑出来发现影像躺倒了就是栽在这。import numpy as np import pyqtgraph as pg from pyqt5 import QtWidgets # 模拟一张 8000x8000 的灰度影像实际项目里这里是 tifffile 或 rasterio 读出来的数组 data np.random.randint(0, 255, (8000, 8000), dtypenp.uint8) app QtWidgets.QApplication([]) win pg.GraphicsLayoutWidget() view win.addViewBox() view.setAspectLocked(True) # 锁定宽高比影像不被拉伸 img pg.ImageItem() img.setImage(data, autoLevelsFalse, levels(0, 255)) img.setOpts(axisOrderrow-major) # 关键按行优先解释避免转置 view.addItem(img) win.show() app.exec_()这段代码的逻辑说明setAspectLocked(True)保证缩放时影像不变形遥感查看器几乎都要开autoLevelsFalse加手动levels是为了避免每次重绘都重新算直方图大图下这个开销不小axisOrderrow-major是纠正坐标解释的关键参数设成col-major则按列优先。参数上levels的取值直接决定对比度SAR 影像动态范围大常见做法是先算 2% 和 98% 分位数再传进去而不是死写 0 到 255。2.3 分块读取与降采样让首屏不等整图加载就算 ImageItem 再快你也不能把 3GB 的数组一次性读进内存。遥感影像的常规处理是分块加金字塔。分块读取用 rasterio 的 window 参数按 1024 或 2048 的块读降采样用 overview 层级先读低分辨率层做首屏用户放大到某个阈值再补细节。我一般会做两级策略首屏读 overview 的第 2 或第 3 层相当于原图的 1/4 或 1/8保证 1 秒内出图用户缩放超过 1:1 时再按当前视口范围读原始分辨率的块。这样内存峰值可控交互也不卡。下面是一个按视口读块的最小实现import rasterio from rasterio.windows import from_bounds def read_viewport(dataset_path, bounds, max_size2048): 按地理范围读取影像块超过 max_size 时降采样 with rasterio.open(dataset_path) as ds: window from_bounds(*bounds, transformds.transform) # 计算降采样比例保证输出不超过 max_size out_shape (min(int(window.height), max_size), min(int(window.width), max_size)) data ds.read(1, windowwindow, out_shapeout_shape) return data逻辑说明from_bounds把地理坐标范围转成像素窗口out_shape控制输出尺寸rasterio 内部会做重采样。参数上max_size设 2048 是个经验值再大单次读取耗时明显上升ds.read(1, ...)的 1 表示读第一个波段多波段影像要循环或改成读全部再选通道。这个函数配合 ViewBox 的 sigRangeChanged 信号就能做到「拖到哪读到哪」。3. 动手搭一个能跑的影像查看器从窗口到交互3.1 窗口骨架与 ViewBox 的交互配置先把窗口搭起来。PyQt5 负责主窗口、菜单、状态栏pyqtgraph 的 GraphicsLayoutWidget 嵌进去做画布。ViewBox 默认就支持鼠标滚轮缩放和拖拽平移但默认行为有几个地方要改滚轮缩放是以鼠标位置为中心还是以视图中心拖拽是平移还是框选这些都要按查看器的习惯调。import sys import pyqtgraph as pg from pyqt5.QtWidgets import QApplication, QMainWindow, QStatusBar from pyqt5.QtCore import Qt class ImageViewer(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(影像查看器) self.resize(1200, 800) self.glw pg.GraphicsLayoutWidget() self.setCentralWidget(self.glw) self.view self.glw.addViewBox() self.view.setAspectLocked(True) self.view.invertY(True) # 影像坐标系 y 轴向下和屏幕一致 # 交互配置左键拖拽平移滚轮缩放 self.view.setMouseMode(pg.ViewBox.PanMode) self.view.setMouseEnabled(xTrue, yTrue) self.img_item pg.ImageItem() self.view.addItem(self.img_item) self.setStatusBar(QStatusBar()) # 鼠标移动时显示坐标和像素值 self.glw.scene().sigMouseMoved.connect(self.on_mouse_moved) def on_mouse_moved(self, pos): if self.view.sceneBoundingRect().contains(pos): pt self.view.mapSceneToView(pos) self.statusBar().showMessage(fx{pt.x():.0f}, y{pt.y():.0f}) if __name__ __main__: app QApplication(sys.argv) w ImageViewer() w.show() sys.exit(app.exec_())逻辑说明invertY(True)是遥感影像的必备设置因为影像的行号向下增长而 pyqtgraph 默认 y 轴向上不反转的话图是上下颠倒的setMouseMode(PanMode)让左键直接拖拽平移比默认的框选模式更符合查看器直觉sigMouseMoved挂到 scene 上而不是 ViewBox 上是因为 scene 级别才能拿到全局坐标。参数上setMouseEnabled(xTrue, yTrue)允许两个方向独立平移如果只想水平平移就关掉 y。3.2 加载真实影像rasterio 读 GeoTIFF 与 SAR 数据的特殊处理真实项目里影像来源通常是 GeoTIFF、ENVI 或者 SAR 的复数格式。GeoTIFF 用 rasterio 读最省事SAR 的复数数据比如 SLC要先转成强度图或幅度图再显示因为 ImageItem 不吃复数。import numpy as np import rasterio def load_sar_intensity(path): 读取 SAR 复数数据并转成强度图dB 缩放 with rasterio.open(path) as ds: # SAR SLC 数据常见为复数读出来是 complex64 arr ds.read(1) if np.iscomplexobj(arr): intensity np.abs(arr) ** 2 # 强度 幅度平方 # 转 dB加一个小量避免 log(0) db 10 * np.log10(intensity 1e-6) # 截断到 2%~98% 分位避免极值把对比度拉死 lo, hi np.percentile(db, [2, 98]) db np.clip(db, lo, hi) return db.astype(np.float32) return arr.astype(np.float32)逻辑说明SAR 的 SLC 产品是复数直接显示没有意义先取模平方得强度再转 dB 是行业惯例np.percentile取 2% 和 98% 分位做截断是因为 SAR 影像里强散射点建筑、金属会把动态范围拉得极大不截断的话整张图一片黑。参数上1e-6是防止 log 零的保护量量级按数据实际范围调astype(np.float32)是为了控制内存float64 在 8000 像素的图上会多吃一倍内存。加载完之后把数组传给img_item.setImage(db, levels(lo, hi))注意 levels 要和截断后的范围一致否则对比度还是不对。3.3 缩放平移的边界控制限制范围与防止越界默认的 ViewBox 可以无限缩小、无限平移用户很容易把影像拖出视野再也找不回来。查看器需要加边界约束缩放有上下限平移不能超出影像范围。def setup_view_limits(view, img_width, img_height): 设置缩放和平移边界 view.setLimits( xMin0, xMaximg_width, yMin0, yMaximg_height, minXRange50, maxXRangeimg_width, # 最小/最大可视宽度 minYRange50, maxYRangeimg_height ) # 初始视图铺满影像 view.setRange(xRange(0, img_width), yRange(0, img_height), padding0)逻辑说明setLimits的xMin/xMax限制平移边界minXRange/maxXRange限制缩放范围。minXRange50意味着最多放大到可视宽度 50 像素再放大就没意义了maxXRange设成影像宽度防止缩到比整图还小。setRange的padding0去掉默认的 2% 边距让影像正好铺满视口。这套参数在 2 万像素的图上实测缩放响应在 16ms 以内基本无感。4. 避坑与排查五个让我返工的血泪记录4.1 影像上下颠倒或旋转 90 度现象加载后图是倒的或者躺倒 90 度。原因pyqtgraph 默认 y 轴向上、按列优先解释数组而影像坐标系 y 轴向下、按行优先。解决ViewBox 上调invertY(True)ImageItem 上调setOpts(axisOrderrow-major)两个一起改才彻底。只改一个的话要么方向对但转置错要么反过来。4.2 大图加载时界面假死几秒现象点打开后窗口无响应进度条也不动。原因读文件和数组转换在主线程里做Qt 事件循环被阻塞。解决把读取和预处理丢到 QThread 或 concurrent.futures 里主线程只负责拿到数组后调 setImage。注意 numpy 数组跨线程传递是安全的但不要在子线程里碰任何 Qt 控件。4.3 缩放时影像模糊或出现马赛克现象放大后像素糊成一片或者出现明显方块。原因ImageItem 默认用线性插值放大倍数大时插值会糊如果开了autoDownsample缩小的时候又会丢细节。解决放大场景下把img_item.setOpts(autoDownsampleFalse)并考虑在放大超过 4 倍时切换到最近邻插值看原始像素缩小场景保留 autoDownsample 提升性能。这两个需求是矛盾的按使用场景取舍。4.4 内存持续上涨直到崩溃现象反复打开关闭影像内存只涨不降。原因旧的 ImageItem 没从 ViewBox 移除numpy 数组被引用着不释放或者每次 setImage 都新建数组没复用缓冲。解决关闭时调view.removeItem(img_item)并img_item.deleteLater()数组引用置 None如果频繁更新同一张图用img_item.setImage(new_arr, autoLevelsFalse)复用 item别反复 new。4.5 SAR 影像显示成一片黑或一片白现象SAR 数据加载后几乎看不到地物。原因没做 dB 转换直接显示线性强度或者 levels 范围设错。解决按 3.2 节的流程先转 dB 再取 2%~98% 分位截断levels 传截断后的范围。另外检查数据是不是复数复数直接 setImage 会报错或显示异常。5. 进阶技巧用 LUT 做伪彩与视口联动预读基础查看器跑通后真正拉开体验差距的是两件事伪彩映射和预读。伪彩用 pyqtgraph 的 ColorMap 加 LUT 实现SAR 常用灰度或 jet多光谱常用 viridis。做法是cmap pg.colormap.get(viridis)然后img_item.setColorMap(cmap)比手动构造 lut 省事。注意 setColorMap 会覆盖 levels 的灰度映射两者别混用。预读则是把 2.3 节的 read_viewport 挂到 ViewBox 的sigRangeChanged信号上加一个 200ms 的防抖定时器用户停止拖动后再触发读取避免拖动过程中疯狂读盘。我一般还会缓存最近读过的 3 到 5 个块用字典按窗口范围做 key命中就直接 setImage省掉重复 IO。场景推荐配置说明小于 4000 像素QGraphicsView 或 pyqtgraph 均可生态成熟优先4000 到 2 万像素pyqtgraph autoDownsample首屏读 overview超过 2 万像素pyqtgraph 分块 预读缓存必须做视口联动SAR 复数数据先转 dB 再显示取 2%~98% 分位截断验证方法很简单拿一张你手头最大的影像记录首屏时间、拖动时的帧率、内存峰值三个数。首屏超过 2 秒就检查是不是读了全图拖动掉帧就检查 autoDownsample 和预读防抖内存涨就查引用释放。这三个指标稳住查看器就算合格了。从那以后我每次接影像查看类的需求都强制先跑一遍这三个指标再动 UI不然界面做得再花卡一下用户就跑了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站