简介:基于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 是采样步长,step=1 表示每个像素都是网格顶点,地形细腻但顶点数量是 w×h;step=2 就是隔一个像素采一个点,数据量降到四分之一。一般山体细节不敏感时,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, fov=650.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, step=2): 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(key=lambda item: item[0], reverse=True) return polygons这里 four 点按顺时针排,顺序不能乱,一旦某两个多边形出现逆序,相邻山体之间会产生缝隙。排序用的 depth 用四点平均,间隔大时建议改成中心点重新投影一次,否则远近距离接近的面可能排序抖动,形成闪现。需要说明的是,QPolygonF 顶点是浮点坐标,QGraphicsScene 内部计算用的也是浮点精度,基本能满足交互式放大。
3. 从 .ui 到 .py:uic 与 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="/"> <file>images/mountain.png</file> <file>images/sea.png</file> <file>images/sea gray.png</file> <file>SET.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.ui | Qt Designer 保存 | 界面布局与控件属性 |
| ui_MainWindow.py | pyuic 自动生成 | 供 Python 调用的界面构建函数 |
| res.qrc | 手写或 Designer 资源编辑器 | 图片等资源的 XML 清单 |
| res_rc.py | pyrcc 自动生成 | 把图片二进制数据编译进 Python |
| myMainWindow.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, parent=None): 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, parent=None): 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, step=2): 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, step=2): 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)合并后只有一个 item,scene 不再需要管理两万个子物体。这里的关键是 boundingRect 要覆盖所有投影坐标范围,范围过大会触发无谓重绘,过小则会看到边缘被裁剪。painter 必须在 paint 里重新设置 brush,不能缓存 QBrush 列表到 item 外,否则 item 跨 scene 拷贝时会失效。要继续压缩 CPU 开销,可以让视图先绘制一个低分辨率预渲染版本,鼠标松开后再全分辨率重绘:也就是 wheelEvent 里先调用self.setUpdatesEnabled(False),渲染一次后setUpdatesEnabled(True)。这样拖动时地形只更新轮廓,静止时才出细节,体感比每帧完整重绘流畅得多。
本文还有配套的精品资源,点击获取