# LCD PNG 存储优化实施报告 ## 1. 基本信息 - 对应提交:`549f495 perf(assets): optimize LCD PNG storage` - 目标设备:ESP32-S3 - LCD 分辨率:320×240 - LCD 像素格式:RGB565 - 资源分区:`gifpack` LittleFS,容量 2 MiB - 优化范围:59 张 PNG 图片 本次工作的主要目标是缓解 `gifpack` 分区的存储压力,同时保证 LCD 最终显示的 RGB565 像素不发生变化。 本次工作不修改 LCD 驱动、PNG 运行时解码器、帧缓存数量和动画播放接口。 ## 2. 问题现象 优化前,59 张 PNG 的总容量为: ```text 1,069,465 字节 ``` 将原始图片、语音资源和 LittleFS 元数据一起打包后,`gifpack` 分区的实际占用为: ```text 已使用:407 / 512 块 使用率:79.5% 剩余空间:430,080 字节 ``` 虽然单张图片的分辨率只有 320×240,但资源分区已经接近容量警戒线,继续增加动画帧会快速消耗剩余空间。 ## 3. 问题原因 ### 3.1 图片携带了未使用的 Alpha 通道 现有资源中大部分 PNG 是 RGBA 格式,每个像素在红、绿、蓝之外还带有 Alpha 透明度。 检查全部资源后发现,所有图片的 Alpha 范围均为: ```text 255~255 ``` 这表示所有像素都完全不透明,Alpha 通道虽然存在,但没有实际提供透明效果。 PNG 会压缩重复的 Alpha 数据,因此删除 Alpha 不会正好节省 25%,但仍然可以减少文件体积。 ### 3.2 原始图片保存了 LCD 无法显示的颜色精度 普通 RGB PNG 通常使用 RGB888: ```text 红色 8 位 + 绿色 8 位 + 蓝色 8 位 = 24 位 ``` LCD 实际使用 RGB565: ```text 红色 5 位 + 绿色 6 位 + 蓝色 5 位 = 16 位 ``` 现有固件在 PNG 解码后,本来就会丢弃 LCD 无法显示的低位颜色精度。运行时转换公式为: ```c uint16_t rgb565 = ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3); ``` 因此,原始 PNG 中的一部分颜色信息虽然占用 Flash,但最终不会发送到 LCD。 ### 3.3 RGB565 对齐后,实际颜色数量很少 原始图片中的 RGB888 颜色数量为每张 66~1383 种。 提前对齐到 RGB565 后,每张图片只剩: ```text 53~240 种颜色 ``` 全部低于索引色 PNG 的 256 色上限,因此可以使用一个字节的调色板编号表示像素,而不必为每个像素重复保存完整 RGB 数值。 ### 3.4 构建系统原来直接打包原始 PNG 原构建流程直接从 `components/platform/assets/lcd` 复制 PNG 到构建目录,然后制作 `gifpack.bin`,没有区分设计原图和固件优化图,也没有资源同步检查。 ## 4. 解决方案 本次采用“原图和优化图同时提交到 Git”的方案。 目录职责调整为: ```text components/platform/assets/ ├── lcd_source/ 原始设计图片,不打包进固件 └── lcd_optimized/ 优化图片,固件实际打包 ``` 正常固件构建只读取 `lcd_optimized`,不需要安装 Pillow。 修改图片时,从 `lcd_source` 重新生成全部优化图片,并通过清单和逐像素验证保证结果可靠。 完整处理流程为: ```text 原始 PNG ↓ 检查 Alpha 删除未使用的 Alpha ↓ 颜色精度对齐到 RGB565 ↓ 统计 RGB565 颜色数量 ↓ 不超过 256 色时生成索引色 PNG ↓ 逐像素验证 RGB565 等价性 ↓ 生成 manifest.json ↓ 提交优化图片并打包到 gifpack ``` ## 5. 关键代码 ### 5.1 RGB888 对齐到 RGB565 精度 生成工具先截取 RGB565 有效位,再将其展开成标准 PNG 能保存的 RGB888 数值: ```python 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 防止误删有效透明度 当前方案不处理透明叠加。如果发现透明或半透明像素,生成过程会立即失败: ```python 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 色量化: ```python 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,而不是强行合并颜色: ```python 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 数值,并逐像素比较: ```python 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: ```python 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 中分别声明原图目录、优化图目录、清单和生成工具: ```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" ) ``` 制作资源镜像前执行轻量同步检查: ```cmake 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}" ) ``` 最终进入设备的路径仍然是: ```text /lcd/<图片名称>.png ``` 运行时代码不需要修改资源路径。 ## 6. 使用方式 ### 6.1 普通固件构建 正常构建不生成图片,也不需要 Pillow: ```bash idf.py build ``` ### 6.2 准备图片生成环境 ```bash 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` 中的原图,然后执行: ```bash python tools/optimize_lcd_assets.py generate python tools/optimize_lcd_assets.py verify idf.py build ``` 需要同时提交: ```text lcd_source 中修改的原图 lcd_optimized 中重新生成的图片 lcd_optimized/manifest.json ``` ### 6.4 只检查资源是否同步 以下命令不需要 Pillow: ```bash python3 tools/optimize_lcd_assets.py check-current ``` ## 7. 解决成果 ### 7.1 PNG 文件容量 ```text 优化前: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 字节 | 分区内部新增可用空间: ```text 700,416 字节 = 684 KiB ``` `gifpack.bin` 的文件大小仍然固定为 2 MiB,因为它是完整分区镜像。优化释放的是 LittleFS 内部空间,不会缩小分区镜像,也不会自动把空间转移给应用程序分区。 ### 7.3 显示一致性 验证结果: ```text LCD assets are current: 59 files RGB565 display equivalence verified: 59 files ``` 59 张图片的 RGB565 输出全部逐像素一致。 ### 7.4 构建验证 ESP-IDF v5.5.4 完整构建通过,并确认构建顺序为: ```text 检查资源清单 → 复制 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. 后续可选优化 如果未来需要继续改善加载速度或运行时内存,可以单独评估: 1. 让 PNG 解码器输出调色板索引或 RGB 数据,减少 RGBA 中间缓冲。 2. 增加索引 PNG 到 RGB565 的直接查表路径。 3. 按行解码并写入 RGB565,降低峰值内存。 4. 测量首次解码、缓存填充和 SPI 传输分别占用的时间。 这些优化会修改运行时解码路径,风险和测试范围明显大于本次 Flash 资源优化,建议作为独立提交实施。