嵌入式Python GUI开发:Pillow与Adafruit库驱动SPI屏幕实战
1. 项目概述与核心价值
在树莓派、ESP32或者类似的嵌入式Linux设备上折腾过屏幕显示的朋友,大概都经历过那种“从零造轮子”的痛苦。要么是直接操作SPI总线,一个像素一个像素地往里填数据,代码冗长且难以维护;要么是依赖特定的GUI框架,学习曲线陡峭,在资源受限的设备上跑起来又力不从心。我自己在给一个智能鱼缸控制器做状态显示屏时,就卡在这个环节很久,直到我把目光投向了Python生态里两个看似“不搭界”的库:Pillow和Adafruit CircuitPython DisplayIO的Blinka兼容层。
这个组合的核心思路非常巧妙:它把在PC上做图像处理的成熟方案,无缝迁移到了嵌入式环境。Pillow(PIL Fork)是Python界图像处理的“瑞士军刀”,功能强大,API友好。而Adafruit的库则负责底层硬件通信的脏活累活,将不同型号的SPI显示屏封装成统一的接口。你只需要像在电脑上画图一样,用Pillow创建一个画布(Image对象),在上面用ImageDraw画矩形、写文字,最后把这个画布“喂”给Adafruit的显示驱动,它就能帮你把图像数据通过SPI总线刷到屏幕上。这相当于在嵌入式开发中引入了“离屏渲染”的概念,把复杂的图形合成逻辑与底层的硬件驱动解耦,让开发者能更专注于界面本身的设计与逻辑。
这套方案的价值,远不止是显示一个“Hello World”。它真正解决了嵌入式GUI开发中的几个痛点:开发效率低(无需从底层写驱动)、界面设计不灵活(可利用Pillow所有绘图功能)、代码可移植性强(同一套绘图逻辑稍作修改可适配不同屏幕)。无论是做一个显示温湿度、时间的小型信息站,还是构建一个带有按钮交互的简易控制面板,这个技术栈都能提供坚实且优雅的基础。接下来,我将以两个最经典的例子——静态图形绘制和动态系统信息监控——为线索,拆解其中的每一个技术细节和实操要点,让你能快速上手,并避开我当年踩过的那些坑。
2. 环境搭建与硬件连接详解
在开始写代码之前,一个稳定可靠的软硬件环境是成功的基石。这部分往往被教程一笔带过,但却是新手最容易翻车的地方。
2.1 硬件选型与连接要点
首先明确一点,本文介绍的方法主要适用于运行Linux系统、具有Python环境的单板计算机,如树莓派全系列、Jetson Nano、Rock Pi等。对于纯粹的微控制器(如Arduino、ESP32的Arduino核心),由于无法运行完整的Python和Pillow,此方案不适用。
屏幕选择与SPI引脚确认:市面上常见的SPI TFT屏幕,如ILI9341、ST7789、ST7735等,其驱动芯片大多被Adafruit库所支持。购买时,请务必确认屏幕的驱动芯片型号。连接时,最关键的是四根SPI线和三根控制线:
- SPI引脚:
SCLK(时钟)、MOSI(主机输出从机输入,即数据线)、MISO(主机输入从机输出,部分屏幕不需要可悬空)、CE0/CE1(片选,对应CS)。 - 控制引脚:
DC(数据/命令选择)、RST(复位,可选但推荐连接)、BL(背光,可接PWM引脚实现调光)。
以树莓派连接一款ILI9341驱动的2.8寸屏幕为例,典型的连接方式如下:
| 屏幕引脚 | 树莓派GPIO引脚 (物理引脚号) | 功能说明 |
|---|---|---|
| VCC | 3.3V (Pin 1) | 绝对禁止接5V!大部分3.3V屏幕接5V会永久损坏。 |
| GND | GND (Pin 6) | 共地。 |
| CS | CE0 (GPIO8, Pin 24) | SPI0的片选0。 |
| RESET | GPIO24 (Pin 18) | 硬件复位,可接其他GPIO。 |
| DC/RS | GPIO25 (Pin 22) | 数据/命令选择线,至关重要。 |
| MOSI (SDI) | MOSI (GPIO10, Pin 19) | SPI主设备数据输出。 |
| SCK (SCL) | SCLK (GPIO11, Pin 23) | SPI时钟。 |
| LED | 3.3V (Pin 1) 或 PWM引脚 | 背光,直接接3.3V常亮,接PWM(如GPIO18)可调光。 |
注意:引脚定义因屏幕厂家而异,必须参照你屏幕的说明书或产品页面。
MISO线通常用于读取触摸屏数据,如果仅显示,可以不接。
2.2 软件环境配置与依赖安装
在树莓派上,请先确保系统是最新的,并启用SPI接口。
sudo apt update && sudo apt upgrade -y sudo raspi-config在Interfacing Options->SPI中,选择Yes启用。重启生效。
接下来安装核心的Python库。这里有个关键点:我们需要安装的是给运行标准CPython的Linux设备(如树莓派)使用的Adafruit-Blinka和显示驱动库,而不是给CircuitPython单片机用的。这常常让人混淆。
pip3 install --upgrade pip setuptools wheel pip3 install Pillow pip3 install adafruit-blinka pip3 install adafruit-circuitpython-rgb-displayPillow是绘图引擎;adafruit-blinka是一个兼容层,它让为CircuitPython设计的硬件控制库能在Linux的CPython上运行;adafruit-circuitpython-rgb-display则是包含了各种屏幕驱动的核心库。
安装完成后,一个快速的验证方法是运行python3 -c “import board; import digitalio; print(‘SPI可用’)”,如果没有报错,说明基础环境OK。
2.3 初始化代码的深度解析
硬件连好,软件装妥,我们来深入看看初始化代码里的门道。很多教程只给代码,却不解释为什么这么写,导致换块屏幕就懵了。
import board import digitalio from PIL import Image, ImageDraw, ImageFont from adafruit_rgb_display import ili9341 # 以ILI9341为例 # 1. 引脚配置 cs_pin = digitalio.DigitalInOut(board.CE0) # 片选 dc_pin = digitalio.DigitalInOut(board.D25) # 数据/命令 reset_pin = digitalio.DigitalInOut(board.D24) # 复位 # 2. SPI总线初始化 spi = board.SPI() # 使用硬件SPI0board模块:这是Blinka库提供的,它抽象了树莓派的GPIO引脚定义。board.CE0对应的是SPI0的片选0,board.D25对应的是物理引脚22的GPIO25。这种写法比直接写GPIO25更具可移植性(至少在Adafruit支持的平台间)。digitalio.DigitalInOut:这是CircuitPython风格的GPIO操作对象,Blinka使其在Linux上可用。它比RPi.GPIO或gpiozero的写法更统一。board.SPI():初始化硬件SPI。在树莓派上,默认使用SPI0,时钟频率后续在显示屏初始化时设置。这是性能最优的选择,比软件模拟SPI(bitbangio.SPI)快得多。
接下来是显示屏对象的创建,这是最容易出错的地方:
disp = ili9341.ILI9341( spi, rotation=90, # 屏幕旋转90度 cs=cs_pin, dc=dc_pin, rst=reset_pin, baudrate=24000000, # SPI通信速率,24MHz )- 驱动选择:必须从
adafruit_rgb_display导入你屏幕对应的驱动类,如st7789.ST7789、st7735.ST7735R等。注释里列出了常见型号,务必对号入座。 rotation参数:这是指显示内容的旋转,而非屏幕物理安装方向。0是默认,90是顺时针旋转90度。这个参数会直接影响后续画布尺寸的计算。baudrate参数:SPI时钟频率。不是越高越好!24MHz是很多屏的极限。如果屏幕出现花屏、错位或完全不亮,首要怀疑对象就是速率过高。可以尝试降低到12000000甚至8000000。屏幕规格书里通常会标明最大SCLK频率。x_offset,y_offset,width,height参数:对于非标准分辨率或屏幕驱动板有固定偏移的屏幕(比如一些圆形屏或带排线的屏),这些参数是救命稻草。它们告诉驱动芯片,有效显示区域在显存中的起始位置和大小。如果你的图像显示偏到一边或大小不对,调整它们就对了。
3. 核心原理:从Image对象到屏幕像素
理解了硬件连接和初始化,我们深入到软件层面,看看“画图”这个动作是如何跨越Python和硬件屏障的。
3.1 Pillow绘图的核心流程
Pillow在这个工作流中扮演着“离屏缓冲区”的角色。其核心流程是一个经典的双缓冲思想,以避免直接操作显存可能带来的闪烁。
- 创建画布:
image = Image.new(“RGB”, (width, height))。这里创建了一个指定宽高、RGB模式的图像对象。这个对象存在于树莓派的内存中。 - 获取绘图上下文:
draw = ImageDraw.Draw(image)。draw对象提供了所有绘图方法(点、线、矩形、文字等),所有操作都作用于内存中的image对象。 - 执行绘图命令:例如
draw.rectangle(…, fill=(255,0,0))。这只是在修改image对象内部的像素数据矩阵。 - 图像传输:
disp.image(image)。这是最魔法的一步。Adafruit_RGB_Display库会做以下几件事: a. 将Pillow的RGB图像数据,根据屏幕驱动芯片的要求,转换为正确的色彩格式(如16位的RGB565)。 b. 通过SPI总线,将转换后的数据流,按照特定的命令序列(如设置内存写入地址0x2C),持续地发送到屏幕的显存中。 c. 屏幕驱动芯片收到数据后,自动将其映射到对应的液晶单元上显示。
3.2 坐标系统与颜色表示
坐标系统:Pillow使用常见的计算机图形学坐标系,原点(0,0)在左上角,X轴向右递增,Y轴向下递增。这与数学坐标系不同,需要注意。例如,画一个从左上角(10,10)到右下角(50,50)的矩形,代码是draw.rectangle((10, 10, 50, 50), …)。矩形函数接受一个包含四个元素的元组(x0, y0, x1, y1),分别代表左上角和右下角的坐标。
颜色表示:Pillow非常灵活,支持多种颜色格式:
- RGB元组:
fill=(255, 0, 0)表示红色。每个分量范围0-255。这是最直观的方式。 - 十六进制字符串:
fill=”#FF0000”同样表示红色。这在从Web颜色代码迁移时很方便。 - 预定义颜色名:
fill=”red”。Pillow支持一系列颜色名,但不如前两种方式精确和通用。
在Adafruit_RGB_Display库内部,无论你传入哪种格式,它最终都会将其转换为屏幕支持的色彩深度。对于16位色(65K色)的屏幕,RGB888的(255,0,0)会被转换为RGB565的0xF800。这个转换过程会有细微的色彩损失,但对于大多数信息显示应用完全足够。
3.3 字体处理与文本渲染
在嵌入式设备上使用TrueType字体(TTF)是一种奢侈但提升体验的做法。核心是ImageFont.truetype()函数。
font = ImageFont.truetype(“/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf”, 24)- 字体路径:必须提供系统上存在的TTF字体文件的绝对路径。树莓派默认安装的DejaVu字体是一个安全的选择。你也可以将自定义的
.ttf文件放到项目目录,使用相对路径“./myfont.ttf”。 - 字体大小:这里的
24单位是“点”(point),它是一个与分辨率相关的相对单位。在实际渲染时,Pillow会根据图像的分辨率(DPI,默认72)将其转换为像素高度。一个更可控的方法是使用ImageFont.load_default()加载默认位图字体,或者用ImageFont.truetype(…, size=24, index=0, encoding=’unic’),并后续用font.getsize(text)来获取文本占据的精确像素尺寸,这对于布局计算至关重要。
文本居中技巧:这是UI布局的常见需求。代码width // 2 - font_width // 2是标准的居中算法。//是整除运算符,确保得到整数坐标。font.getsize(text)返回的是(width, height)元组。务必在绘制文本之前获取尺寸进行计算,否则位置会错。
4. 实战一:绘制静态图形与文字
现在,我们把理论付诸实践,完成第一个示例:绘制一个带边框和居中文字的界面。我将逐行分析,并补充原始代码中未提及的优化点和常见陷阱。
4.1 代码逐行剖析与增强
以下是整合了详细注释和增强健壮性的代码:
# SPDX-FileCopyrightText: 2021 ladyada for Adafruit Industries # SPDX-License-Identifier: MIT """ 静态图形绘制示例:创建一个带彩色边框和居中文本的界面。 适用于运行Linux的嵌入式设备(如树莓派),使用Pillow绘图,Adafruit库驱动SPI显示屏。 """ import time import board import digitalio from PIL import Image, ImageDraw, ImageFont from adafruit_rgb_display import ili9341 # 请根据你的屏幕型号修改导入 # ===== 用户可配置参数 ===== BORDER = 30 # 边框宽度,可根据屏幕大小调整 FONTSIZE = 28 # 字体大小 FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # 使用粗体更醒目 BACKGROUND_COLOR = (0, 40, 80) # 深蓝色背景 (R, G, B) BORDER_COLOR = (0, 255, 0) # 绿色边框 INNER_RECT_COLOR = (170, 0, 136) # 紫色内矩形 TEXT_COLOR = (255, 255, 0) # 黄色文字 DISPLAY_ROTATION = 90 # 屏幕旋转角度: 0, 90, 180, 270 # ========================= # 1. 硬件引脚初始化 cs_pin = digitalio.DigitalInOut(board.CE0) dc_pin = digitalio.DigitalInOut(board.D25) reset_pin = digitalio.DigitalInOut(board.D24) # 2. 创建SPI对象(使用硬件SPI,性能最佳) spi = board.SPI() # 3. 初始化显示屏对象 # 注意:务必取消注释你所用屏幕型号对应的行,并注释掉其他行。 # disp = st7789.ST7789(spi, rotation=DISPLAY_ROTATION, height=240, y_offset=80) # 1.3寸屏 disp = ili9341.ILI9341( spi, rotation=DISPLAY_ROTATION, # 应用旋转设置 cs=cs_pin, dc=dc_pin, rst=reset_pin, baudrate=24000000, # 如果出现花屏,尝试降低此值,如 12000000 # width=320, # 如果显示不全,可能需要手动指定宽度和高度 # height=240, ) print(f"显示屏初始化完成,分辨率: {disp.width} x {disp.height}") # 4. 创建绘图画布(关键步骤:处理旋转导致的宽高互换) if disp.rotation % 180 == 90: # 如果旋转了90或270度,物理宽高在逻辑上需要互换 canvas_width = disp.height canvas_height = disp.width else: canvas_width = disp.width canvas_height = disp.height image = Image.new("RGB", (canvas_width, canvas_height)) draw = ImageDraw.Draw(image) print(f"创建画布尺寸: {canvas_width} x {canvas_height}") # 5. 绘制背景(全屏填充) draw.rectangle((0, 0, canvas_width, canvas_height), fill=BACKGROUND_COLOR) disp.image(image) # 首次刷屏,显示纯色背景 time.sleep(0.1) # 短暂延迟,让显示稳定 # 6. 绘制内边框矩形 # 坐标计算:(BORDER, BORDER) 是左上角,(canvas_width - BORDER - 1) 是右下角X # 减1是为了避免与边框紧贴,视觉效果更好 inner_rect_coords = ( BORDER, BORDER, canvas_width - BORDER - 1, canvas_height - BORDER - 1 ) draw.rectangle(inner_rect_coords, fill=INNER_RECT_COLOR, outline=BORDER_COLOR, width=2) # 添加了轮廓线 disp.image(image) time.sleep(0.1) # 7. 加载字体并绘制居中文本 try: font = ImageFont.truetype(FONT_PATH, FONTSIZE) except IOError: print(f"警告:字体文件 '{FONT_PATH}' 未找到,使用默认字体。") font = ImageFont.load_default() # 回退方案 text = "Hello World!" # 获取文本占据的像素尺寸 try: # Pillow 9.2.0 之前用 getsize,之后用 textbbox if hasattr(font, ‘getsize‘): font_width, font_height = font.getsize(text) else: # 新API,返回 (left, top, right, bottom) bbox = font.getbbox(text) font_width = bbox[2] - bbox[0] font_height = bbox[3] - bbox[1] except AttributeError: # 如果都不支持,进行估算 font_width, font_height = len(text) * FONTSIZE // 2, FONTSIZE + 4 # 计算居中坐标 text_x = canvas_width // 2 - font_width // 2 text_y = canvas_height // 2 - font_height // 2 draw.text((text_x, text_y), text, font=font, fill=TEXT_COLOR) # 8. 最终显示 disp.image(image) print("绘图完成!") # 保持显示,直到程序被中断 try: while True: time.sleep(1) except KeyboardInterrupt: print("\n程序被用户中断。") # 可选:清屏为黑色 draw.rectangle((0, 0, canvas_width, canvas_height), fill=(0, 0, 0)) disp.image(image)4.2 关键技巧与避坑指南
旋转处理的逻辑:
if disp.rotation % 180 == 90:这一行是精髓。它判断屏幕是否被旋转了90度或270度。如果是,那么显示驱动逻辑上的width和height属性,与实际物理屏幕的宽高是相反的。我们的画布尺寸必须基于逻辑显示区域来创建,否则绘制的内容会错位或超出范围。disp.width和disp.height返回的是驱动初始化后逻辑上的尺寸。baudrate与稳定性:24MHz是理论值。在实际中,导线长度、屏幕质量、树莓派型号都可能影响SPI稳定性。如果出现随机斑点、线条或部分区域不刷新,首要操作就是降低波特率。可以尝试16000000、12000000,直到显示稳定。这是调试显示问题最有效的方法之一。字体加载的健壮性:直接使用硬编码的字体路径(如
/usr/share/fonts/...)在跨平台或系统字体变动时会失败。好的做法是:- 使用
try...except捕获IOError。 - 准备一个回退方案:
ImageFont.load_default()加载一个很小的内置位图字体,或者将项目用到的字体文件放在脚本同级目录,使用相对路径os.path.join(os.path.dirname(__file__), “myfont.ttf”)。
- 使用
多次调用
disp.image():你可能注意到,我在绘制背景和内矩形后都立即调用了disp.image(image)。这不是必须的,但有两个好处:一是可以分阶段看到绘制效果,便于调试;二是在绘制复杂图形时,可以避免因单次操作时间过长导致的“卡顿”感。当然,最终稳定版本可以只在最后刷新一次以提高效率。
5. 实战二:动态显示系统监控信息
静态界面只是开始,嵌入式屏幕更常见的用途是实时显示系统状态。第二个例子将展示如何动态获取Linux系统信息并刷新显示。这涉及到子进程调用、定时循环和动态布局,实用性更强。
5.1 系统信息获取原理
我们使用Python的subprocess模块来执行Shell命令,并捕获其输出。这是一种高效且直接的方式,利用了Linux系统丰富的命令行工具。
import subprocess import time def get_system_info(): info = {} try: # 获取IP地址 (取第一个) cmd = “hostname -I | awk ‘{print $1}’” info[‘ip’] = “IP: ” + subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip() except subprocess.TimeoutExpired: info[‘ip’] = “IP: N/A” except Exception: info[‘ip’] = “IP: Error” try: # 获取CPU负载 (1分钟平均) cmd = “cat /proc/loadavg | awk ‘{printf \"CPU: %.2f\", $1}’” info[‘cpu’] = subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip() except Exception: info[‘cpu’] = “CPU: N/A” try: # 获取内存使用率 cmd = “free -m | awk ‘NR==2{usage=$3*100/$2; printf \"Mem: %d%%\", usage}’” info[‘mem’] = subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip() except Exception: info[‘mem’] = “Mem: N/A” try: # 获取根目录磁盘使用率 cmd = “df -h / | awk ‘NR==2{printf \"Disk: %s\", $5}’” info[‘disk’] = subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip() except Exception: info[‘disk’] = “Disk: N/A” try: # 获取CPU温度 (树莓派) cmd = “cat /sys/class/thermal/thermal_zone0/temp” temp = int(subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip()) info[‘temp’] = f“Temp: {temp/1000:.1f}°C” except Exception: # 备用温度获取方式 try: cmd = “vcgencmd measure_temp | cut -d= -f2 | cut -d\' -f1” temp = float(subprocess.check_output(cmd, shell=True, timeout=1).decode(‘utf-8’).strip()) info[‘temp’] = f“Temp: {temp:.1f}°C” except Exception: info[‘temp’] = “Temp: N/A” return info为什么用subprocess而不是psutil?psutil是一个强大的跨平台库,但对于极度精简的嵌入式系统,安装额外的库可能增加负担。而subprocess调用系统命令是零依赖的。更重要的是,这些Shell命令(top,free,df)是获取系统信息最权威、最直接的方式。代码中加入了timeout和异常处理,防止某个命令卡死导致整个监控循环停滞。
5.2 动态刷新与界面布局优化
动态刷新的核心是一个while True循环。但直接无脑循环会浪费CPU资源。我们需要在信息更新频率和CPU占用之间取得平衡。
# 初始化显示部分与之前相同,略过... font = ImageFont.truetype(FONT_PATH, 20) # 使用稍小的字体以显示更多行 last_update_time = 0 update_interval = 2 # 更新间隔,秒 while True: current_time = time.time() if current_time - last_update_time >= update_interval: last_update_time = current_time # 1. 清屏(绘制黑色背景) draw.rectangle((0, 0, canvas_width, canvas_height), fill=(0, 0, 0)) # 2. 获取系统信息 sys_info = get_system_info() lines = [ sys_info.get(‘ip’, ‘IP: N/A’), sys_info.get(‘cpu’, ‘CPU: N/A’), sys_info.get(‘mem’, ‘Mem: N/A’), sys_info.get(‘disk’, ‘Disk: N/A’), sys_info.get(‘temp’, ‘Temp: N/A’), f“Update: {time.strftime(‘%H:%M:%S’)}” # 增加时间戳 ] # 3. 计算垂直布局 y_start = 10 line_height = font.getsize(“Hg”)[1] + 4 # 获取字体高度并增加行间距 # 4. 逐行绘制 for i, line in enumerate(lines): y_pos = y_start + i * line_height # 简单颜色轮换,提高可读性 if i == 0: color = “#FFFFFF” # IP白色 elif i == 1: color = “#FFFF00” # CPU黄色 elif i == 2: color = “#00FF00” # 内存绿色 elif i == 3: color = “#00AAFF” # 磁盘蓝色 elif i == 4: color = “#FF00FF” # 温度粉色 else: color = “#AAAAAA” # 时间戳灰色 draw.text((10, y_pos), line, font=font, fill=color) # 5. 刷新到屏幕 disp.image(image) # 6. 短暂休眠,降低CPU占用 time.sleep(0.1) # 每0.1秒检查一次是否到达更新间隔布局技巧:
- 行高计算:
font.getsize(“Hg”)[1]是获取字体高度的可靠方法,因为“Hg”两个字母通常涵盖了字体的上行和下行高度。加上一个固定值(如4像素)作为行间距,视觉上更舒适。 - 颜色编码:为不同类型的信息分配固定的颜色,有助于用户快速识别。例如,红色/黄色代表警告(高负载、高温),绿色代表正常。
- 时间戳:在动态监控中显示最后一次更新的时间,能让用户明确知道数据的实时性。
5.3 性能优化与内存管理
在资源有限的嵌入式设备上长期运行Python脚本,需要注意资源泄漏。
- 避免在循环内重复创建对象:
ImageFont、ImageDraw对象应在循环外创建。在循环内反复创建和销毁大对象(如图像)是性能杀手。本例中,我们复用image和draw对象。 - 控制刷新区域:如果只更新部分文字(如仅更新变化的数字),可以使用局部刷新。但大多数SPI屏不支持局部刷新,全屏刷新反而更简单可靠。确保
update_interval不要设置得太小(如低于0.5秒),否则SPI总线会持续高负荷工作。 - 使用
time.sleep():循环底部的sleep至关重要。它让出CPU时间片,将脚本的CPU占用率从接近100%降到几乎为0%。sleep(0.1)意味着每秒最多检查10次更新条件,对于系统监控来说绰绰有余。 - 异常处理与自恢复:在长期运行的守护进程中,可以考虑用
try...except包裹整个while循环,并在发生不可恢复错误时,记录日志并尝试重新初始化硬件。
6. 高级应用与扩展思路
掌握了基础绘制和动态刷新后,你可以将这个框架扩展到更复杂的应用。
6.1 绘制图表与数据可视化
Pillow本身绘图功能强大,可以绘制折线图、柱状图等简单图表。例如,绘制一个CPU使用率的简单历史曲线图:
# 假设有一个存储最近60个CPU使用率值的列表 cpu_history chart_width = canvas_width - 20 chart_height = 100 chart_x = 10 chart_y = canvas_height - chart_height - 10 # 绘制图表背景和边框 draw.rectangle((chart_x, chart_y, chart_x+chart_width, chart_y+chart_height), fill=(30,30,30), outline=(100,100,100)) if len(cpu_history) > 1: points = [] for i, value in enumerate(cpu_history): x = chart_x + int(i * chart_width / (len(cpu_history)-1)) y = chart_y + chart_height - int(value * chart_height / 100.0) # 假设value是百分比 points.append((x, y)) draw.line(points, fill=(0, 255, 0), width=2)你可以定时(如每10秒)获取一次CPU使用率,将其添加到cpu_history列表(保持固定长度,如60个点),然后在每次刷新时重绘这个图表区域。
6.2 结合传感器数据
这是嵌入式项目的核心。你可以轻松地将这个显示框架与各种传感器库结合。例如,使用Adafruit_DHT库读取温湿度传感器数据,然后显示在屏幕上。
import adafruit_dht import board dht_device = adafruit_dht.DHT22(board.D4) # 假设DHT22接在GPIO4 try: temperature = dht_device.temperature humidity = dht_device.humidity if temperature is not None and humidity is not None: draw.text((10, 50), f“Temp: {temperature:.1f}°C”, font=font, fill=“white”) draw.text((10, 80), f“Humidity: {humidity:.1f}%”, font=font, fill=“white”) except RuntimeError as e: draw.text((10, 50), “Sensor Error”, font=font, fill=“red”)将传感器数据读取放在信息获取函数get_system_info()中,与系统信息一同刷新。
6.3 构建简单的交互界面
虽然Pillow本身不处理输入,但你可以结合GPIO按钮或触摸屏(需额外驱动)来制作交互。思路是:在循环中检测按钮状态,根据不同的状态改变显示的内容或界面“页面”。
import digitalio button = digitalio.DigitalInOut(board.D17) button.direction = digitalio.Direction.INPUT button.pull = digitalio.Pull.UP current_page = 0 # 0: 系统信息, 1: 传感器数据, 2: 图表 while True: if not button.value: # 按钮被按下 current_page = (current_page + 1) % 3 time.sleep(0.3) # 简单防抖 # 根据 current_page 绘制不同的内容 if current_page == 0: draw_system_info() elif current_page == 1: draw_sensor_data() # ... 刷新显示通过这种方式,一个简单的多页面信息显示系统就搭建起来了。
7. 常见问题排查与调试心得
即使按照步骤操作,也难免会遇到问题。这里汇总了我遇到过的典型问题及其解决方法。
7.1 屏幕无显示或白屏/花屏
这是最常见的问题,排查思路如下:
- 检查电源和背光:首先确认屏幕的VCC接的是3.3V(不是5V!),GND已连接。用万用表测量电压是否稳定。检查背光LED是否点亮,有些屏幕背光需要单独上电。
- 确认SPI已启用:在树莓派上运行
ls /dev/spi*,应该能看到/dev/spidev0.0和/dev/spidev0.1。如果没有,返回raspi-config启用SPI并重启。 - 降低SPI波特率:在初始化
disp时,将baudrate从24000000逐步降低到12000000、8000000、4000000试试。这是解决花屏、乱码最有效的方法。 - 检查引脚连接:重点检查
DC和RST引脚。DC引脚必须在传输数据/命令时给出正确的电平,连接错误会导致屏幕接收不到正确指令。可以尝试在初始化后手动复位:reset_pin.value = False; time.sleep(0.1); reset_pin.value = True; time.sleep(0.5)。 - 确认驱动型号:仔细核对屏幕驱动IC型号(通常印在屏幕排线或背面),并导入正确的驱动类。用错驱动(如用ILI9341驱动ST7789屏)必然失败。
7.2 图像显示位置/大小不正确
- 旋转与宽高问题:症状是图像只显示一部分,或者偏移到屏幕外。首先检查
disp.rotation设置是否正确。然后,在初始化驱动时,尝试显式指定width和height参数,覆盖驱动自动检测的值。对于非常规屏幕,x_offset和y_offset参数是关键。 - 画布尺寸计算错误:再次理解
if disp.rotation % 180 == 90:这段逻辑。打印出disp.width,disp.height,canvas_width,canvas_height的值,看是否符合预期。 - 坐标计算错误:确认你的绘图坐标没有超出画布范围
(0,0)到(width-1, height-1)。超出范围的绘制会被Pillow静默忽略。
7.3 程序报错与依赖问题
ImportError: No module named ‘PIL’:说明Pillow未安装。请使用pip3 install Pillow安装,注意P大写。ImportError: No module named ‘adafruit_rgb_display’:说明Adafruit显示库未安装。使用pip3 install adafruit-circuitpython-rgb-display。AttributeError: module ‘board’ has no attribute ‘SPI’:可能发生在非树莓派平台或虚拟环境。确保adafruit-blinka已正确安装,并且你的平台被Blinka支持。PermissionError: [Errno 13] Permission denied:访问SPI设备需要权限。可以将用户加入spi组:sudo usermod -a -G spi $USER,然后注销重新登录生效。或者直接使用sudo运行脚本(不推荐长期方案)。
7.4 性能问题与优化
- 刷新速度慢:
- 降低波特率:过高的波特率在长线或干扰下可能导致数据错误和重传,反而变慢。
- 减少刷新面积:如果屏幕支持局部刷新(大部分SPI屏不支持),只更新变化的部分。否则,确保只刷新必要的频率。
- 优化Python代码:避免在刷新循环中进行复杂的计算或IO操作。将不变的计算(如字体尺寸、布局坐标)提到循环外。
- 内存占用高:长期运行后内存增长。确保没有在循环内不断创建新的
Image或ImageDraw对象。使用del语句及时释放不再需要的大对象,或者考虑使用内存视图等高级技巧。
调试时,最朴素的print()大法依然有效。在关键步骤打印变量值(如画布尺寸、获取到的系统信息字符串),能快速定位问题所在。从一个最简单的纯色填充测试程序开始,逐步增加功能,是保证每一步都正确的稳妥策略。
