PyQt5 QGraphicsView实现2.5D三维地形图渲染与交互 📅 发布时间:2026/9/10 4:22:15 👁 浏览次数: 简介基于Python与PyQt5的三维地形图绘制完整项目源码面向桌面应用开发者、地理信息可视化学习者以及需要将3D场景集成到Qt界面中的工程实践者。资源解压后共44个文件包含py主程序、ui界面、cpp/h辅助代码、bmp/png位图素材、qrc资源定义及bat工具脚本压缩包约2.28MB目录结构围绕Demo13_4Map工程展开。目前已有889人学习下载其中位图素材与界面资源齐全适合直接替换测试和二次开发。核心模块覆盖MainWindow、myMainWindow、res_rc等完整演示了从高程数据加载到三维网格构建、透视投影、光照颜色映射及鼠标交互的流程。阅读源码可快速掌握PyQt5中QGraphicsView/QGraphicsScene的3D渲染思路理解Qt资源系统与uic.bat编译流程为毕业设计、竞赛项目或轻量级GIS可视化工具开发提供参考模板。1. 为什么我拿 PyQt5 的 QGraphicsView 画三维地形图而不是上 OpenGL真要说PyQt5 里画三维地形最“正经”的路线是 QOpenGLWidget 或 Qt3D但很多实际项目并不需要真的走 GPU 光栅化管线。这个源码项目恰好做了一个更轻的选择用 QGraphicsView QGraphicsScene 把地形高程数据投影成 2.5D 多边形网格鼠标拖拽可以旋转视角滚轮可以拉近拉远效果接近早期三维 GIS 桌面客户端但依赖面只有 PyQt5 一个包。对于刚用 Python 写完数据可视化、想补一个桌面 GUI 展示三维地形图的同学来说这条路比直接上 OpenGL 更能看清“投影矩阵、画家算法、高程颜色映射”这些底层逻辑。源码目录里有 main.cpp、QtApp.pro 和 ui_MainWindow.py 并存说明它保留了 Qt C 到 Python PyQt5 的转译痕迹读起来可以对照两种语言各自的写法和坑。2. 三维地形图的 2.5D 投影从数据到网格再到屏幕坐标2.1 高程数据从哪来把灰度图像当成高度场没有专门 DEM 文件时最常见的做法是直接读一张灰度地形图把每个像素的灰度值映射成高程。这个源码里出现的 mountain.png、sea.png 和 SET.png 在 Qt 工程里既承担纹理角色也可以作为高度场来源。这里我用 QImage 读取灰度再按步长抽成二维数组避免网格过密导致拖拽卡顿。from PyQt5.QtGui import QImage, qGray def load_height_map(qimage_path: str, step: int 2) - list: img QImage(qimage_path) img img.convertToFormat(QImage.Format_Grayscale8) rows [] h, w img.height(), img.width() for y in range(0, h, step): row [] for x in range(0, w, step): gray qGray(img.pixel(x, y)) row.append(gray / 255.0) rows.append(row) return rows这段代码的逻辑分三步先把任意格式图片统一转成 8 位灰度再用 qGray 拿到 0 到 255 的像素亮度最后除以 255 归一化到 0.0 到 1.0 的高程值。里面的 step 是采样步长step1 表示每个像素都是网格顶点地形细腻但顶点数量是 w×hstep2 就是隔一个像素采一个点数据量降到四分之一。一般山体细节不敏感时step 取 2 或 3 就足够否则 2000×1500 的图片会直接拖出几十万个多边形QGraphicsScene 撑不住。如果你手头是 16 位 PNG 的 DEM那不能用 Format_Grayscale8改成 Format_Grayscale16 后需要把 qRed 和 qGreen 拼成完整数值常见写法是value (qRed(pixel) 8) | qGreen(pixel)。这里不展开你只要知道 16 位对应的高程精度是 65536 级8 位只有 256 级做山体阴影时差别很明显。2.2 绕 X 轴旋转的投影变换屏幕上的 Y 和深度二维屏幕没有 Z 轴经典做法是先把地形坐标从世界坐标系绕 X 轴旋转某个俯仰角再把旋转后的 Z 坐标折算成深度。假设地形点在水平面上用 (x, z) 表示y 向上是高度那么绕 X 轴旋转 pitch 角后的投影可以写为y_screen -(y * cos(pitch) - z * sin(pitch)) depth y * sin(pitch) z * cos(pitch)y_screen 前面取负号是因为屏幕坐标系 Y 轴朝下QGraphicsView 里如果不取负地形会上下颠倒。depth 的值用来做两件事一是后续画多边形时按深度排序远的先画近的后画二是可以继续叠一个简易透视让远处的山体看起来更小。下面给出一个同时包含俯仰、偏航和透视的项目函数。import math def project_point(x, y, z, yaw, pitch, dist, fov650.0): yaw_r math.radians(yaw) pitch_r math.radians(pitch) # 绕 Y 轴旋转 xr x * math.cos(yaw_r) - z * math.sin(yaw_r) zr x * math.sin(yaw_r) z * math.cos(yaw_r) # 绕 X 轴旋转得到屏幕 Y 和深度 yr y * math.cos(pitch_r) - zr * math.sin(pitch_r) depth y * math.sin(pitch_r) zr * math.cos(pitch_r) # 简易透视投影越远越小 scale fov / max(1.0, dist - depth) return xr * scale, -yr * scale, depth函数里 fov 控制透视强度我一般取 600 到 800。fov 越大靠近观察者的地形被放大得越厉害立体感强但边缘会扭曲fov 越小画面越接近正交投影看起来偏“平”。dist 是观察者到地形中心的距离拖动缩放时可以通过修改 dist 实现拉近拉远下限要避开 depth 逼近 dist 导致的除零放大。2.3 网格拓扑与画家算法先画远行再画近行得到每个顶点投影坐标后地形就变成一个二维数组的网格。最简单的绘制方式是每四个相邻顶点组成一个 QPolygonF然后按“距离观察者由远到近”的顺序往 QGraphicsScene 里 addPolygon。源码里没有直接看到绘制类但从资源结构和 UI 布局推断它把这部分放在 myMainWindow.py 的自定义 QGraphicsScene 子类或绘制类中。我一般会按 depth 的每行均值排序先从最远行开始填充多边形。这样近处的山会盖住远处的山避免写 Z-buffer。对应代码如下def build_polygons(points2d, depths, rows, cols, step2): polygons [] for r in range(rows - 1): for c in range(cols - 1): p1 points2d[r][c] p2 points2d[r][c 1] p3 points2d[r 1][c 1] p4 points2d[r 1][c] depth (depths[r][c] depths[r][c 1] depths[r 1][c] depths[r 1][c 1]) / 4.0 polygons.append((depth, QPolygonF([p1, p2, p3, p4]))) polygons.sort(keylambda item: item[0], reverseTrue) return polygons这里 four 点按顺时针排顺序不能乱一旦某两个多边形出现逆序相邻山体之间会产生缝隙。排序用的 depth 用四点平均间隔大时建议改成中心点重新投影一次否则远近距离接近的面可能排序抖动形成闪现。需要说明的是QPolygonF 顶点是浮点坐标QGraphicsScene 内部计算用的也是浮点精度基本能满足交互式放大。3. 从 .ui 到 .pyuic 与 pyrcc 把界面和资源接起来3.1 资源文件 res.qrc 与 res_rc.py 的生成Qt 工程里 res.qrc 是资源描述文件声明哪些图片会被编译进程序。用 PyQt5 打开项目前必须先把 qrc 编译成 Python 模块否则 ui_MainWindow.py 里的icon.addPixmap(QPixmap(:/...))会抛 “resource not found”。实际 res.qrc 内容类似下面这样RCC qresource prefix/ fileimages/mountain.png/file fileimages/sea.png/file fileimages/sea gray.png/file fileSET.jpg/file /qresource /RCC编译命令是在项目根目录执行pyrcc5 res.qrc -o res_rc.py前提是环境里已经装好 PyQt5 并配好 PATH。没配 PATH 时可以写完整路径python -m PyQt5.pyrcc_main res.qrc -o res_rc.py。第一次编译后代码里用import res_rc导入资源名默认是:/images/mountain.png这样带前缀的路径。需要说明的是qrc 里文件名如果包含空格比如 “sea gray.png”在 XML 里可以直接写但引用时的路径要严格一致。装 PyQt5 最直接的是pip install PyQt5 pyqt5-tools其中 pyqt5-tools 提供 uic 的一些辅助工具但新版内部命令已经整合进PyQt5.uic模块不依赖独立 exe。如果 cmd 里提示pyrcc5 不是内部或外部命令优先检查 Python 的 Scripts 目录是否在环境变量里而不是立刻去下载安装包。3.2 uic.bat 与 ui_MainWindow.py 的自动生成源码根目录有 uic.bat它的作用是把 MainWindow.ui 转成 Python 可 import 的 ui_MainWindow.py。Qt Designer 画好的界面是 XML 描述WinPython 和 PyQt5 组合下最常见的转换命令是echo off python -m PyQt5.uic.pyuic MainWindow.ui -o ui_MainWindow.py python -m PyQt5.uic.pyuic res.qrc -o ui_res.py pause这里第一行生成界面第二行生成资源模块。要提醒的是第二行实际应该走 pyrcc 而不是 pyuic写在这里是为了说明 uic.bat 会一次性执行两类转换。生成的 ui_MainWindow.py 里只有 Ui_MainWindow 类和 setupUi 方法它不包含任何业务逻辑里面记录每个子对象的 objectName、geometry、stylesheet 和信号连接手动改它会在下次 uic 时被覆盖所以业务代码必须写到外层窗口类。文件来源职责MainWindow.uiQt Designer 保存界面布局与控件属性ui_MainWindow.pypyuic 自动生成供 Python 调用的界面构建函数res.qrc手写或 Designer 资源编辑器图片等资源的 XML 清单res_rc.pypyrcc 自动生成把图片二进制数据编译进 PythonmyMainWindow.py手写继承 Ui_MainWindow添加地形业务上面这张表可以看出真正需要维护的是 MainWindow.ui、res.qrc、myMainWindow.py 三个。ui_MainWindow.py 和 res_rc.py 是构建产物提交 git 时保留它们只是为了本地可直接跑起来不保证与最新界面一致。3.3 在 myMainWindow.py 里装配 Ui_MainWindow有了 uic 生成的界面主窗口的写法有两种。常见做法是定义一个继承 QMainWindow 的类在init里调用self.ui Ui_MainWindow(); self.ui.setupUi(self)。随后最关键的是把 scene 塞进 QGraphicsView否则视图只是一块空白区域。from PyQt5.QtWidgets import QMainWindow, QGraphicsView, QGraphicsScene from ui_MainWindow import Ui_MainWindow from myTerrainScene import TerrainScene class MainWindow(QMainWindow): def __init__(self, parentNone): super().__init__(parent) self.ui Ui_MainWindow() self.ui.setupUi(self) self.scene TerrainScene() view self.ui.graphicsView view.setScene(self.scene) view.setRenderHint(QPainter.RenderHint.Antialiasing) self.resize(1000, 700)这里 TerrainScene 是自定义的 QGraphicsScene 子类它负责加载 qrc 里的图片、生成多边形网格、响应鼠标事件。初始化顺序不能乱必须先把 setupUi 执行完拿到 graphicsView 引用再 setScene否则 viewport 还没创建视图接收不到任何绘制命令。Antialiasing 开关看地形网格密度网格很密时开着会明显减慢重绘我一般只在顶点数 1 万以下时开。4. 交互视图、颜色映射和光照方向地形场景的实战调参4.1 颜色映射表海平面和山地分段着色灰度图本身可以显示地形起伏但视觉上不够直观。这个项目里把 sea.png 和 mountain.png 同时作为调色板资源说明它很可能不是简单用单一渐变色而是按高程分成“海面/浅滩/丘陵/山顶”多个色带。这里给出一种实用段函数def color_for_height(h: float) - QColor: if h 0.30: # 海平面以下映射到海蓝色 return QColor(60, 140, 210) if h 0.50: # 平原草地 return QColor(150, 190, 120) if h 0.75: # 山地棕色 return QColor(150, 100, 60) return QColor(240, 245, 250)参数调整逻辑h 的范围是 0 到 1阈值不是均匀分布。因为灰度地形图里平地的像素比例远大于山峰把 0.3 以下全当成海面可以压掉噪声0.5 到 0.75 是过渡段防止颜色条带太明显。如果直接用 QImage 采样 mountain.png 做颜色可以这样写QColor(mountain_img.pixel(int(h * (w-1)), 0))效果更像真实卫星影像但要求两张图宽度一致并且 mountain.png 的横向颜色渐变正好对应低到高。4.2 鼠标拖拽旋转、滚轮缩放与视角约束三维地形交互最常见的两个操作是拖拽换视角和滚轮缩放。QGraphicsView 本身不管三维坐标需要重写视图或 scene 的鼠标事件。我这里把旋转放在 QGraphicsView 子类里因为要同时区分左键和滚轮。class TerrainView(QGraphicsView): def __init__(self, parentNone): super().__init__(parent) self.yaw 45.0 self.pitch 30.0 self.distance 800.0 self._last_pos None def mousePressEvent(self, event): self._last_pos event.pos() super().mousePressEvent(event) def mouseMoveEvent(self, event): if self._last_pos: dx event.pos().x() - self._last_pos.x() dy event.pos().y() - self._last_pos.y() self.yaw (self.yaw dx * 0.5) % 360 self.pitch min(85.0, max(10.0, self.pitch dy * 0.5)) self._last_pos event.pos() super().mouseMoveEvent(event) def wheelEvent(self, event): delta event.angleDelta().y() / 120 self.distance min(3000.0, max(200.0, self.distance - delta * 100)) super().wheelEvent(event)yaw 是偏航角控制绕垂直轴左右旋转pitch 上下旋转上拖减小角度下拖增大。pitch 限制在 10 到 85 度之间小于 10 度时地形接近俯视看不出高度起伏大于 85 度时所有多边形压扁成一条线深度排序也会因为浮点误差产生闪烁。distance 对应观察距离滚轮每格缩放 100实际值取决于地形尺寸地形跨度是 1000 米时 200 到 3000 比较合适如果跨度到 5000 需要把上下限放大。4.3 光照让地形不再是一张塑料膜没有阴影的三维地形看起来是平面色块视觉上有种“塑料膜”感。给每个网格面计算光照是最简单的提升方式先算面法向量再与固定光源方向做点积用结果调制颜色。import numpy as np def shade_polygon(p1, p2, p3, color, light_dir(0.2, 0.6, 0.8)): v1 np.array(p2) - np.array(p1) v2 np.array(p3) - np.array(p1) normal np.cross(v1, v2) norm np.linalg.norm(normal) if norm 0: return color normal normal / norm brightness abs(np.dot(normal, light_dir)) brightness 0.3 0.7 * brightness return QColor(int(color.red() * brightness), int(color.green() * brightness), int(color.blue() * brightness))p1、p2、p3 是投影前的三维顶点不是屏幕坐标否则光照会随视角飘动。light_dir 是单位向量默认 (0.2, 0.6, 0.8) 表示光源从右上前方照下来这样山谷会暗、朝南坡面会亮。亮度压缩到 0.3 到 1.0 是为了让背光面不至于全黑保留地形细节。点积取绝对值是因为顶点顺序翻转会导致法向量方向相反多边形的填充方向不一致时取绝对值至少保证没有全黑面。4.4 常见排错表从资源找不到到画面全黑实践里第一次跑起这个项目最容易遇到的是下面几类问题现象最可能原因处理方式启动报错找不到图标qrc 没有编译执行python -m PyQt5.pyrcc_main res.qrc -o res_rc.py主窗口空白无网格scene 未设置或高度数组为空确认 view.setScene 已调用并输出 heights 维度地形颜色全黑qrc 路径变大写错误检查前缀:/images/与 qrc 中定义完全一致多边形之间露白边顶点顺序和绘制顺序不一致按全顺时针排列并做 painter 排序拖拽卡顿明显每帧重建大量 QGraphicsPolygonItem改用 QGraphicsItem.paint 绘制 QPainterPath提示排查资源问题时在入口 main.py 里写一行import res_rc然后python -c import myMainWindow如果这行能跑过说明资源模块导入正常问题大概率在路径字符串大小写。5. 换数据源、加剖面线、让地形真正动起来三个立刻能上手的进阶技巧5.1 用真实的 DEM 高度场替换灰度图灰度图作为高度场的问题是没有地理坐标信息山体比例全靠肉眼调。如果手头有 GeoTIFF 或 ESRI ASCII 格式的 DEM可以把它转成二维数组再按同样的投影逻辑绘制。比如 .asc 文件开头有 ncols、nrows、cellsize、NDV 头后面每行是一个网格行。做法是先用 Python 标准库解析头部再读剩余行为浮点数组def read_asc(path, step2): rows [] with open(path) as f: for _ in range(6): f.readline() for line in f: vals [float(v) for v in line.split()] if vals: rows.append(vals[::step]) return rows[::step]这里步进抽样只取了行和列没有做重采样平均抽到最高点时可能丢掉部分山脊。更稳的做法是用cv2.resize或 PIL 先缩小再读取。换数据后要重新调整颜色分段的阈值因为 DEM 高程范围可能是米而不是 0 到 1直接按固定阈值取色会全部落在同一色段里。我一般先统计数组的 min、max再用(h - low) / (high - low)归一化。5.2 叠加等值线和剖面线给地形视觉加信息量最实用的是等值线。每一条等值线是投影后的二维折线绘制时不需要把它当作网格直接放到 QGraphicsPathItem 上即可def make_contour(heights, level, project, step2): path QPainterPath() for r in range(len(heights)): first True for c in range(len(heights[r])): if heights[r][c] level: sx, sy, _ project(r*step, heights[r][c], c*step) if first: path.moveTo(sx, sy) first False else: path.lineTo(sx, sy) return path这段代码把每一行中高于阈值的点连成折线适合看山谷脊线但不是标准等高线真正的等值线还需要做连通域追踪。在这个资源场景下用它来快速 확인 地形的高程分布已经够用。效果不好时再考虑每两个格点做线性插值找出与等高面交点再连线。5.3 性能优化把逐多边形重建改成板载绘制前面第 2 章的 addPolygon 方式写起来直观但地形顶点数超过 2 万后scene 维护大量 item每拖拽一格就要重建所有 item卡顿几乎是必然的。优化方向是把所有网格合并到自定义 QGraphicsItem 的 paint 里一次性绘制。核心代码框架是这样的class TerrainItem(QGraphicsItem): def boundingRect(self): return QRectF(-10000, -10000, 20000, 20000) def paint(self, painter, option, widget): painter.setPen(Qt.PenStyle.NoPen) for depth, pts in self._all_polygons: painter.setBrush(QBrush(self._colors[id(pts)])) painter.drawPolygon(pts)合并后只有一个 itemscene 不再需要管理两万个子物体。这里的关键是 boundingRect 要覆盖所有投影坐标范围范围过大会触发无谓重绘过小则会看到边缘被裁剪。painter 必须在 paint 里重新设置 brush不能缓存 QBrush 列表到 item 外否则 item 跨 scene 拷贝时会失效。要继续压缩 CPU 开销可以让视图先绘制一个低分辨率预渲染版本鼠标松开后再全分辨率重绘也就是 wheelEvent 里先调用self.setUpdatesEnabled(False)渲染一次后setUpdatesEnabled(True)。这样拖动时地形只更新轮廓静止时才出细节体感比每帧完整重绘流畅得多。本文还有配套的精品资源点击获取