14 KiB
LCD PNG 存储优化实施报告
历史优化报告:本文记录当时的
gifpackLittleFS 布局及测试数据,不代表当前固件。现行素材流程见 应用内嵌素材使用与添加指南。
1. 基本信息
- 对应提交:
549f495 perf(assets): optimize LCD PNG storage - 目标设备:ESP32-S3
- LCD 分辨率:320×240
- LCD 像素格式:RGB565
- 资源分区:
gifpackLittleFS,容量 2 MiB - 优化范围:59 张 PNG 图片
本次工作的主要目标是缓解 gifpack 分区的存储压力,同时保证 LCD 最终显示的 RGB565 像素不发生变化。
本次工作不修改 LCD 驱动、PNG 运行时解码器、帧缓存数量和动画播放接口。
2. 问题现象
优化前,59 张 PNG 的总容量为:
1,069,465 字节
将原始图片、语音资源和 LittleFS 元数据一起打包后,gifpack 分区的实际占用为:
已使用:407 / 512 块
使用率:79.5%
剩余空间:430,080 字节
虽然单张图片的分辨率只有 320×240,但资源分区已经接近容量警戒线,继续增加动画帧会快速消耗剩余空间。
3. 问题原因
3.1 图片携带了未使用的 Alpha 通道
现有资源中大部分 PNG 是 RGBA 格式,每个像素在红、绿、蓝之外还带有 Alpha 透明度。
检查全部资源后发现,所有图片的 Alpha 范围均为:
255~255
这表示所有像素都完全不透明,Alpha 通道虽然存在,但没有实际提供透明效果。
PNG 会压缩重复的 Alpha 数据,因此删除 Alpha 不会正好节省 25%,但仍然可以减少文件体积。
3.2 原始图片保存了 LCD 无法显示的颜色精度
普通 RGB PNG 通常使用 RGB888:
红色 8 位 + 绿色 8 位 + 蓝色 8 位 = 24 位
LCD 实际使用 RGB565:
红色 5 位 + 绿色 6 位 + 蓝色 5 位 = 16 位
现有固件在 PNG 解码后,本来就会丢弃 LCD 无法显示的低位颜色精度。运行时转换公式为:
uint16_t rgb565 =
((red & 0xF8) << 8) |
((green & 0xFC) << 3) |
(blue >> 3);
因此,原始 PNG 中的一部分颜色信息虽然占用 Flash,但最终不会发送到 LCD。
3.3 RGB565 对齐后,实际颜色数量很少
原始图片中的 RGB888 颜色数量为每张 66~1383 种。
提前对齐到 RGB565 后,每张图片只剩:
53~240 种颜色
全部低于索引色 PNG 的 256 色上限,因此可以使用一个字节的调色板编号表示像素,而不必为每个像素重复保存完整 RGB 数值。
3.4 构建系统原来直接打包原始 PNG
原构建流程直接从 components/platform/assets/lcd 复制 PNG 到构建目录,然后制作 gifpack.bin,没有区分设计原图和固件优化图,也没有资源同步检查。
4. 解决方案
本次采用“原图和优化图同时提交到 Git”的方案。
目录职责调整为:
components/platform/assets/
├── lcd_source/ 原始设计图片,不打包进固件
└── lcd_optimized/ 优化图片,固件实际打包
正常固件构建只读取 lcd_optimized,不需要安装 Pillow。
修改图片时,从 lcd_source 重新生成全部优化图片,并通过清单和逐像素验证保证结果可靠。
完整处理流程为:
原始 PNG
↓ 检查 Alpha
删除未使用的 Alpha
↓
颜色精度对齐到 RGB565
↓
统计 RGB565 颜色数量
↓
不超过 256 色时生成索引色 PNG
↓
逐像素验证 RGB565 等价性
↓
生成 manifest.json
↓
提交优化图片并打包到 gifpack
5. 关键代码
5.1 RGB888 对齐到 RGB565 精度
生成工具先截取 RGB565 有效位,再将其展开成标准 PNG 能保存的 RGB888 数值:
def expand_rgb565(pixel: tuple[int, int, int]) -> tuple[int, int, int]:
red, green, blue = pixel
red5 = red >> 3
green6 = green >> 2
blue5 = blue >> 3
return (
(red5 << 3) | (red5 >> 2),
(green6 << 2) | (green6 >> 4),
(blue5 << 3) | (blue5 >> 2),
)
展开后的图片仍是标准 PNG,但其中每种颜色都能稳定映射回相同的 RGB565 数值。
5.2 防止误删有效透明度
当前方案不处理透明叠加。如果发现透明或半透明像素,生成过程会立即失败:
rgba = source_image.convert("RGBA")
alpha_min, alpha_max = rgba.getchannel("A").getextrema()
if (alpha_min, alpha_max) != (255, 255):
raise AssetError(
f"Transparency is not supported by the RGB565 optimizer: "
f"{source_path.name} has alpha range "
f"({alpha_min}, {alpha_max})"
)
这样不会因为资源优化而静默破坏未来新增的透明图片。
5.3 生成无额外量化损失的索引色 PNG
脚本根据已经完成 RGB565 对齐的实际颜色建立调色板,不调用普通的自动 256 色量化:
palette_colors = sorted(set(pixels))
if len(palette_colors) > 256:
raise AssetError("Indexed PNG requested with more than 256 colors")
palette_index = {
color: index
for index, color in enumerate(palette_colors)
}
indices = bytes(palette_index[pixel] for pixel in pixels)
indexed = image_module.frombytes("P", size, indices)
flat_palette = [
channel
for color in palette_colors
for channel in color
]
flat_palette.extend([0] * (768 - len(flat_palette)))
indexed.putpalette(flat_palette)
indexed.save(
output,
format="PNG",
optimize=True,
compress_level=9,
)
如果未来某张图片在 RGB565 对齐后仍超过 256 种颜色,工具会保存为普通 RGB PNG,而不是强行合并颜色:
if color_count <= 256:
output_mode = "indexed"
save_indexed_png(
image_module,
source_rgb.size,
rgb565_pixels,
output_path,
)
else:
output_mode = "rgb"
optimized_rgb = image_module.new("RGB", source_rgb.size)
optimized_rgb.putdata(rgb565_pixels)
optimized_rgb.save(
output_path,
format="PNG",
optimize=True,
compress_level=9,
)
5.4 逐像素验证 LCD 输出一致
原图和优化图都会重新计算 RGB565 数值,并逐像素比较:
def rgb565_value(pixel: tuple[int, int, int]) -> int:
red, green, blue = pixel
return (
((red & 0xF8) << 8)
| ((green & 0xFC) << 3)
| (blue >> 3)
)
for index, (source_pixel, optimized_pixel) in enumerate(
zip(
source_rgb.get_flattened_data(),
optimized_rgb.get_flattened_data(),
)
):
if rgb565_value(source_pixel) != rgb565_value(optimized_pixel):
x = index % source_rgb.width
y = index // source_rgb.width
raise AssetError(
f"RGB565 mismatch at ({x}, {y})"
)
这里验证的是 LCD 实际接收的 RGB565 数值,而不是电脑上查看时的 RGB888 数值。
5.5 通过清单检查资源是否过期
manifest.json 保存原图和优化图的 SHA-256、尺寸、颜色数和文件大小。
普通构建只使用 Python 标准库重新计算哈希,不导入 Pillow:
for name in sorted(source_names & manifest_names):
source_path = SOURCE_DIR / name
if sha256_file(source_path) != assets[name].get("source_sha256"):
errors.append(
f"Source asset changed after optimization: {name}"
)
for name in sorted(optimized_names & manifest_names):
optimized_path = OPTIMIZED_DIR / name
if sha256_file(optimized_path) != assets[name].get("optimized_sha256"):
errors.append(
f"Optimized asset changed after generation: {name}"
)
原图修改后如果忘记重新生成优化图,固件构建会失败,不会继续打包过期资源。
5.6 构建系统只打包优化资源
CMake 中分别声明原图目录、优化图目录、清单和生成工具:
set(
platform_lcd_asset_source_dir
"${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd_source"
)
set(
platform_lcd_asset_src_dir
"${CMAKE_CURRENT_SOURCE_DIR}/assets/lcd_optimized"
)
set(
platform_lcd_asset_manifest
"${platform_lcd_asset_src_dir}/manifest.json"
)
set(
platform_lcd_asset_optimizer
"${CMAKE_CURRENT_SOURCE_DIR}/../../tools/optimize_lcd_assets.py"
)
制作资源镜像前执行轻量同步检查:
add_custom_command(
OUTPUT "${platform_asset_stage_stamp}"
COMMAND
${PYTHON}
"${platform_lcd_asset_optimizer}"
check-current
COMMAND
${CMAKE_COMMAND} -E rm -rf
"${platform_asset_image_dir}"
COMMAND
${CMAKE_COMMAND} -E make_directory
"${platform_asset_image_dir}"
${platform_asset_stage_commands}
COMMAND
${CMAKE_COMMAND} -E touch
"${platform_asset_stage_stamp}"
DEPENDS
${platform_audio_prompt_files}
${platform_lcd_source_png_files}
${platform_lcd_png_asset_files}
"${platform_lcd_asset_manifest}"
"${platform_lcd_asset_optimizer}"
)
最终进入设备的路径仍然是:
/lcd/<图片名称>.png
运行时代码不需要修改资源路径。
6. 使用方式
6.1 普通固件构建
正常构建不生成图片,也不需要 Pillow:
idf.py build
6.2 准备图片生成环境
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r tools/requirements-assets.txt
python tools/optimize_lcd_assets.py check-env
6.3 修改图片后重新生成
只修改 components/platform/assets/lcd_source 中的原图,然后执行:
python tools/optimize_lcd_assets.py generate
python tools/optimize_lcd_assets.py verify
idf.py build
需要同时提交:
lcd_source 中修改的原图
lcd_optimized 中重新生成的图片
lcd_optimized/manifest.json
6.4 只检查资源是否同步
以下命令不需要 Pillow:
python3 tools/optimize_lcd_assets.py check-current
7. 解决成果
7.1 PNG 文件容量
优化前:1,069,465 字节
优化后: 381,384 字节
减少: 688,081 字节
降幅: 64.3%
59 张图片全部成功保存为索引色 PNG。
7.2 LittleFS 分区占用
gifpack 的块大小为 4096 字节,总计 512 块。
| 状态 | 已使用块 | 使用率 | 剩余空间 |
|---|---|---|---|
| 原始图片模拟打包 | 407 / 512 | 79.5% | 430,080 字节 |
| 优化图片实际构建 | 236 / 512 | 46.1% | 1,130,496 字节 |
分区内部新增可用空间:
700,416 字节 = 684 KiB
gifpack.bin 的文件大小仍然固定为 2 MiB,因为它是完整分区镜像。优化释放的是 LittleFS 内部空间,不会缩小分区镜像,也不会自动把空间转移给应用程序分区。
7.3 显示一致性
验证结果:
LCD assets are current: 59 files
RGB565 display equivalence verified: 59 files
59 张图片的 RGB565 输出全部逐像素一致。
7.4 构建验证
ESP-IDF v5.5.4 完整构建通过,并确认构建顺序为:
检查资源清单
→ 复制 lcd_optimized
→ 制作 gifpack.bin
→ 完成固件构建
8. 后续影响
8.1 正面影响
gifpack使用率由 79.5% 降至 46.1%。- 增加 684 KiB 可用资源空间。
- 按当前图片复杂度估算,还可安全增加约 80~100 张全屏帧。
- Flash 读取的 PNG 数据减少,首次加载可能略有改善。
- 原图被完整保留,可以随时重新生成固件资源。
- 构建会阻止过期或被手动篡改的优化图片进入固件。
8.2 不变的部分
- PNG 文件名和设备内路径不变。
- LCD 驱动和动画播放接口不变。
- 当前 PNG 解码器仍会先展开为 RGBA,再转换为 RGB565。
- 解码后的帧缓存大小不变。
- 帧缓存数量不变。
- SPI 传输速度不变。
gifpack分区大小仍为 2 MiB。
因此,本次优化主要解决 Flash 容量问题,不应把 64.3% 的文件缩减直接等同于 64.3% 的帧率提升或运行时内存下降。
8.3 开发流程影响
- 普通固件编译不依赖 Pillow。
- 修改图片的开发者需要安装
tools/requirements-assets.txt中固定版本的 Pillow。 - 不应手动修改
lcd_optimized。 - 每次必须从
lcd_source重新生成,不能反复压缩优化图。 - 添加新图片时,除了生成优化图,还需要把图片名称加入 CMake 资源清单。
- 删除图片时,需要同时删除原图并更新 CMake 资源清单。
8.4 仓库容量影响
原图和优化图都会提交到 Git,因此代码仓库本身会增加约 381 KiB 当前工作树容量,并产生对应的 Git 历史对象。
这是方案 B 的预期取舍:用一定的仓库容量换取普通构建不依赖 Pillow,并确保任何开发机都能直接使用已经验证过的优化资源构建固件。
8.5 未来透明图片
当前工具发现 Alpha 不全为 255 时会直接失败。
如果未来需要透明叠加效果,需要先明确:
- 透明度由运行时软件合成还是预先合成;
- 是否需要支持索引 PNG 的透明表;
- LCD 背景和前景的混合规则;
- 是否需要保留完整 Alpha 精度。
在透明处理方案完成前,不应绕过该检查。
8.6 未来高色彩图片
如果未来图片在 RGB565 对齐后超过 256 种颜色,工具会自动使用 RGB PNG,保证显示正确,但文件会比索引 PNG 更大。
普通照片、大面积渐变和噪声纹理通常比当前卡通资源更难压缩,因此新增图片数量不能只按当前平均值机械计算,应在每次资源更新后重新检查 manifest.json 和 LittleFS 实际块占用。
8.7 高色深屏幕上的查看差异
优化图已经针对 RGB565 精度制作。在电脑等高色深屏幕上直接查看时,可能与原始 RGB888 图片存在轻微颜色差异。
这不代表 LCD 显示发生退化。对当前 RGB565 LCD 而言,优化前后的最终像素完全一致。
9. 后续可选优化
如果未来需要继续改善加载速度或运行时内存,可以单独评估:
- 让 PNG 解码器输出调色板索引或 RGB 数据,减少 RGBA 中间缓冲。
- 增加索引 PNG 到 RGB565 的直接查表路径。
- 按行解码并写入 RGB565,降低峰值内存。
- 测量首次解码、缓存填充和 SPI 传输分别占用的时间。
这些优化会修改运行时解码路径,风险和测试范围明显大于本次 Flash 资源优化,建议作为独立提交实施。