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

14 KiB
Raw Permalink Blame History

LCD PNG 存储优化实施报告

历史优化报告:本文记录当时的 gifpack LittleFS 布局及测试数据,不代表当前固件。现行素材流程见 应用内嵌素材使用与添加指南。

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 的总容量为:

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. 后续可选优化

如果未来需要继续改善加载速度或运行时内存,可以单独评估:

  1. 让 PNG 解码器输出调色板索引或 RGB 数据,减少 RGBA 中间缓冲。
  2. 增加索引 PNG 到 RGB565 的直接查表路径。
  3. 按行解码并写入 RGB565,降低峰值内存。
  4. 测量首次解码、缓存填充和 SPI 传输分别占用的时间。

这些优化会修改运行时解码路径,风险和测试范围明显大于本次 Flash 资源优化,建议作为独立提交实施。