Files
lucky-cat/docs/lcd-png-storage-optimization-report.zh-CN.md
T

506 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 资源优化,建议作为独立提交实施。