第13章:图像处理与缩放——把 22 万字节的封面塞进一块 4 位灰阶屏

图是电子书里的"重量级乘客":先解码、再等比缩放、最后算好块高度排进页面。每一张都带着解码成本与内存账单。

🎨

本章导师:达芬奇

核心方法论:艺术与工程,先量尺寸,再落笔

「达芬奇画壁画,先要在墙上打素描稿,量好尺寸才敢动笔。图像在这块墨水屏上也是同一个道理:你得先知道它有多大,才能决定画多大、占多高、留多少空给文字。可这张"画布"窄得可怜——4 位灰阶、几百 KB 内存、刷一次还要几百毫秒。于是每一张图都是一道取舍题:解码要快还是要省内存?缩小交给硬件还是软件?占一页还是占半页?这章的每一行代码,都是在替这些取舍做选择。」

13.1 电子书里的图:封面图、书内插图、图像作为块

在一本 EPUB 里,图像有两个完全不同的身份。第一个是封面图(说人话:书在书架上的"门面"大图,好比纸质书封面印的那张画):它不在章节的 XHTML 里,而是被记录在 OEBPS/content.opfmetadata 里——一个 name="cover"meta 标签,其 content 属性指向 manifest 中某件资源的 id。Epub.cpp 解析时正是顺着这条链找到封面:先遍历 metaname="cover" 的项,拿到 cover_item,再在 manifest 里匹配 item_id == cover_item 的元素,把它的 href 存进 m_cover_image_item。之后书目列表页用 get_cover_image_item() 一行就能取到封面地址。

第二个身份是书内插图:它们嵌在章节 XHTML 的 <img> 标签里。仓库自带的两本示例书恰好各有代表性——data/pg43-images.epub(约 304 KB)里有一张 cover.jpg(222,685 字节);data/pg14838-images.epub(约 1.4 MB)则塞了一打插图(peter04.jpgpeter08.jpgpeter11.jpg…还混着一张 peter02.gif)。文件名里的 -images 后缀是作者的提示:这是本带插图的书。书目列表渲染封面时,会先把封面按固定比例(image_width = 2 * image_height / 3,约 3:2 竖版缩略图)缩放再画出来——说人话:改变图片的尺寸,这里指把大图按比例缩小,让它能在小小的屏幕里放得下。

把两种图像统一进"块"模型的,是 RubbishHtmlParser。解析器维护一张 IMAGE_TAGS[] = {"img"} 的清单,遇到 <img> 标签就新建一个 ImageBlock(m_base_path + src)m_src 记录 src 属性,并与章节所在目录 m_base_path 拼接成完整路径。图片块和文本块一样被推进 blocks 列表,经历"布局算坐标 → 分页分配 → 渲染绘制"三段旅程——这正是第 11、12 章那套"分页记账、渲染执行"的分工。看真实代码。

/* RubbishHtmlParser.cpp:遇到 <img> 就新建一个图片块 */
const char *IMAGE_TAGS[] = {"img"};
const int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);

// VisitEnter 里:我们只处理 img 标签
if (matches(tag_name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
  const char *src = element.Attribute("src");
  if (src) {
    // 图片块之前若是空文本块,先把它摘掉
    if (currentTextBlock->is_empty()) {
      blocks.pop_back();
      delete currentTextBlock;
      currentTextBlock = nullptr;
    }
    // 图片本身就是一块
    blocks.push_back(new ImageBlock(m_base_path + src));
    // 图片之后重新累积文本,沿用之前的样式
    startNewTextBlock(style);
  }
}
/* Epub.cpp:metadata → manifest,两条路找到封面图 */
// ① 在 metadata 里找 name="cover" 的 meta 标签
auto cover = metadata->FirstChildElement("meta");
while (cover && cover->Attribute("name") &&
       strcmp(cover->Attribute("name"), "cover") != 0) {
  cover = cover->NextSiblingElement("meta");
}
auto cover_item = cover ? cover->Attribute("content") : nullptr;

// ② 在 manifest 里找 id 等于 cover_item 的资源,记录它的 href
if (cover_item && item_id == cover_item) {
  m_cover_image_item = href;   // 封面图的完整路径
}
图像类型在哪里被描述谁消费它关键入口
封面图content.opf 的 metadata(name="cover"书目列表页get_cover_image_item()draw_image
书内插图章节 XHTML 的 <img> 标签阅读页<img>ImageBlock
系统图标lib/Images/hourglass.h / warning.h忙碌提示、无 SD 卡警告show_busy() / show_img()
达芬奇提示

分清"封面图"和"书内插图"两条完全独立的数据流:封面走 metadata→manifest 索引,服务书目列表;插图走 HTML <img>,服务阅读页。前者 get_cover_image_item() 一行就能取到,后者要靠解析器逐个扒出来塞进块列表。调试时先想清楚"这张图属于哪条流",能省一大半排查时间。

13.2 解码:PNG(PNGdec)与 JPEG(TJpgDec)在 ESP32 上怎么把图解出来

拿到图片字节后,第一步是决定"谁来解它"——Renderer::get_image_helper 干这件事。先认识两个名词:解码(说人话:把压缩过的图片数据还原成屏幕能直接画出来的像素),负责干这活的代码叫解码器PNGJPEG 则是两种最常见的图片格式——PNG 无损,适合图标、线条图这类边界清晰的图;JPEG 有损,适合照片,体积更小。判定顺序有两条:先看文件扩展名(.jpg / .jpeg / .png),扩展名骗不了人的时候再看文件头魔数——JPEG 以 0xFF 0xD8 0xFF 开头,PNG 以 0x89 'P' 'N' 'G' 开头。这是嵌入式里的常见双保险:靠数据说话,不轻信名字。两个 helper 都是懒创建的(png_helper / jpeg_helper 首次用到某格式才 new),因为解码器对象本身也要占内存。

/* Renderer.cpp:按扩展名或魔数选择解码器 */
ImageHelper *Renderer::get_image_helper(const std::string &filename,
                                        const uint8_t *data, size_t data_size) {
  if (filename.find(".jpg") != std::string::npos ||
      filename.find(".jpeg") != std::string::npos ||
      (data_size > 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF)) {
    if (!jpeg_helper) jpeg_helper = new JPEGHelper();
    return jpeg_helper;
  }
  if (filename.find(".png") != std::string::npos ||
      (data_size > 4 && data[0] == 0x89 && data[1] == 'P' &&
       data[2] == 'N' && data[3] == 'G')) {
    if (!png_helper) png_helper = new PNGHelper();
    return png_helper;
  }
  return nullptr;
}

PNG 由 PNGdeclib/png/PNGdec,一个 git 子模块)解码。项目用它的内存模式png.openRAM(data, size, callback) 直接从内存打开,不碰文件系统。读尺寸时不带回调,渲染时才挂上 png_draw_callback 并调用 png.decode(this, PNG_FAST_PALETTE)。PNGdec 按回调:每一行解码出一段 RGB565 像素,交给 PNGHelper::draw_callback 处理。注意缩放只做"缩小不做放大"——x_scale = std::min(1.0f, width / png.getWidth()),再逐像素把 RGB565 拆成灰度写进帧缓冲。由于目标行可能重叠(缩小后多行源像素落在同一行),代码用 last_y 去重:同一目标行只画一次。

/* PNGHelper.cpp:逐行回调,把 RGB565 转成灰度像素 */
void PNGHelper::draw_callback(PNGDRAW *draw) {
  int y = y_pos + draw->y * y_scale;   // 目标行(含缩小因子)
  if (y != last_y) {                  // 同一目标行只画一次
    vTaskDelay(1);                  // 让出 CPU,顺带喂看门狗
    png.getLineAsRGB565(draw, tmp_rgb565_buffer, 0, 0x00FFFFFF);
    for (int x = 0; x < png.getWidth() * x_scale; x++) {
      uint8_t r, g, b;
      convert_rgb_565_to_rgb(tmp_rgb565_buffer[int(x / x_scale)], &r, &g, &b);
      uint32_t gray = (r * 38 + g * 75 + b * 15) >> 7;  // 加权亮度
      renderer->draw_pixel(x_pos + x, y, gray);
    }
    last_y = y;
  }
}

JPEG 由 TJpgDeclib/tjpgd3,ChaN 的 "Tiny JPEG Decompressor")解码——一个为小内存 MCU 专门优化的解压器,官方文档自称只需 3.5 KB 的工作区 RAM,且与图像尺寸无关。项目配置里 POOL_SIZE = 32768(工作区上限开得更宽),输出格式 JD_FORMAT 0 即 RGB888。数据通过 read_jpeg_data 从内存缓冲流式喂入:TJpgDec 需要多少就回调多少。它还支持在解码时直接缩小 1/1、1/2、1/4、1/8JD_USE_SCALE 1)——项目用一个 scale_factor 循环把"目标缩小比"尽量转成硬件缩放:每翻倍一次 scale_factor 就升一级,把能交给解码器吞掉的缩小量吃掉,剩下的残余比例留给软件逐像素映射。

/* JPEGHelper.cpp:32 KB 工作区 + 硬件缩放因子 */
#define POOL_SIZE 32768
void *pool = malloc(POOL_SIZE);   // 解码工作区,与图像尺寸无关
JRESULT res = jd_prepare(&dec, read_jpeg_data, pool, POOL_SIZE, this);
...
this->x_scale = std::min(1.0f, float(width) / float(dec.width));
this->y_scale = std::min(1.0f, float(height) / float(dec.height));

scale_factor = 0;
// 把缩小比"换算"成硬件缩放级数(1/1,1/2,1/4,1/8)
while (x_scale <= 1.0f && y_scale <= 1.0f && scale_factor <= 3) {
  scale_factor++;
  x_scale *= 2; y_scale *= 2;
}
scale_factor--; x_scale /= 2; y_scale /= 2;
jd_decomp(&dec, draw_jpeg_function, scale_factor);  // 按块输出

两条解码链最终汇合到同一个灰度公式:gray = (r * 38 + g * 75 + b * 15) >> 7。这是标准亮度权重 0.299/0.587/0.114 的定点整数版本——38 + 75 + 15 = 128 = 2⁷,用一次右移代替三次浮点乘,正是嵌入式的老手艺。屏幕只有灰阶,所以"去色"在解码阶段就完成了,之后每像素都只是往帧缓冲写一个 4 位值。

维度PNG(PNGdec)JPEG(TJpgDec)
位置lib/png/PNGdec(子模块)lib/tjpgd3
工作区一行 RGB565 缓冲(png.getWidth()*2 字节)固定 POOL_SIZE=32768,与图像尺寸无关
输出逐行回调(PNGDRAW逐块回调(JRECT
缩小纯软件:x_scale/y_scale = min(1, 目标/源)硬件 1/1..1/8 + 软件残余
去色同公式:(r*38 + g*75 + b*15) >> 7
达芬奇提示

两种解码器有个共同点:都按"小块/逐行"流式输出,而不是把整张解码好的位图(Bitmap,一个像素一格的点阵图像,解码的终点就是把它还原出来)一次性堆在内存里——这是嵌入式解码的第一原则,让工作区与图像大小解耦。PNG 用行回调、JPEG 用矩形块回调,回调里还都塞了一个 vTaskDelay(1):解码很耗 CPU,定时让出时间片既能喂看门狗,也不至于卡死同核上的其他任务。

13.3 缩放:读宽高 → 按屏幕尺寸等比缩放 → 算出块高度

图片块的布局在 ImageBlock::layout 里完成,逻辑短得惊人。第一步用 get_item_contents 把图片压缩字节整个读进内存;第二步 get_image_size 读出原始宽高——这一步只解析图片头部,不解全图;第三步判断是否超屏:只有宽或高超出页面才触发缩放。缩放是严格的等比scale = min(页宽/原宽, 页高/原高),取两个方向里较小的比例——保证不裁切、不变形、也不会让高宽方向相互打架。缩放后 width *= scale; height *= scale 就是这块图在页面上的最终占位,最后 x_pos 居中。读完成马上 free(image_data) 释放,绝不把图片字节留到渲染阶段。

/* ImageBlock.h:图片块的布局 —— 读宽高、等比缩放、算块尺寸 */
void ImageBlock::layout(Renderer *renderer, Epub *epub, int max_width = -1) {
  size_t image_data_size = 0;
  uint8_t *image_data = epub->get_item_contents(m_src, &image_data_size);
  renderer->get_image_size(m_src, image_data, image_data_size, &width, &height);
  if (width > renderer->get_page_width() || height > renderer->get_page_height()) {
    float scale = std::min(
        float(max_width != -1 ? max_width : renderer->get_page_width()) / float(width),
        float(renderer->get_page_height()) / float(height));
    width *= scale;   // 等比缩小后的占位尺寸
    height *= scale;
  }
  // 水平居中
  x_pos = (renderer->get_page_width() - width) / 2;
  free(image_data);   // 读完宽高就释放,渲染阶段重新读
}

缩放算出的 height 随后成为分页的关键输入。在 RubbishHtmlParser::layout 分配页面时,图片块走这样一段:若 y + imageBlock->height > page_height,说明剩余空间放不下,就开新页;否则把图片以当前 y 塞进这一页,再 y += imageBlock->height。也就是说,图片块的高度直接决定了它占据的版面——这也是为什么"先读宽高、再缩放、再算块高度"的顺序是硬性的:不解码就拿不到宽高,拿不到宽高就没法排页。

/* RubbishHtmlParser.cpp:图片块按缩放后的高度分页 */
if (block->getType() == BlockType::IMAGE_BLOCK) {
  ImageBlock *imageBlock = (ImageBlock *)block;
  if (y + imageBlock->height > page_height) {
    pages.push_back(new Page());   // 放不下 → 新开一页
    y = 0;
  }
  pages.back()->elements.push_back(new PageImage(imageBlock, y));
  y += imageBlock->height;          // 图片占掉的高度
}

来算一个真实数字。以 4.7 英寸的 ED047TC2(960×540,旋转成竖屏)为例,main.cpp 给阅读页设置的边距是 margin_top=35margin_left=10margin_right=10,于是页宽 = 960 − 20 = 940,页高 = 540 − 35 = 505。若来一张 1200×800 的插图:宽度超了,触发缩放;scale = min(940/1200, 505/800) = min(0.783, 0.631) = 0.631;缩放后 width ≈ 757height ≈ 505——正好贴住高度方向,水平居中 x_pos = (940 − 757) / 2 ≈ 91。这张图独占一页,页面上剩余的 505 − 505 = 0 像素说明它下面不会再排文字。

步骤代码结果
读图片字节epub->get_item_contents(m_src, &size)压缩字节在内存(用完即释放)
读原始宽高renderer->get_image_size(...)只解析头部,不解全图
判断超屏width > page_width || height > page_height小图原样保留、不做放大
等比缩放scale = min(页宽/原宽, 页高/原高)不裁切、不变形
算块高度 / 居中height *= scalex_pos = (页宽 − width)/2供分页与渲染使用
注意

layout 的第三个参数 max_width 默认是 -1。注意那行三目运算:max_width != -1 ? max_width : get_page_width()——也就是调用方可以传入一个更窄的宽度来约束图片(比如在缩略图、章节列表等窄场景里),默认才用整页宽。读 ImageBlock 的头文件时别把 -1 当成随便写死的数字:它是"没有限制"的哨兵值。

13.4 内存考量:大图与 PSRAM

图像是这套系统里最"重"的数据,内存压力来自三处。其一,get_item_contents 会把图片的压缩字节整个读进堆——一张 222,685 字节的 cover.jpg,光"躺在内存里的原始文件"就超过 200 KB。其二,PNG 渲染每帧要临时 malloc(png.getWidth() * 2) 一行 RGB565 缓冲。其三,JPEG 解码固定要 POOL_SIZE = 32768 的工作区。好在这些内存都是用完即释放的:布局阶段读完宽高就 free,渲染画完又 free,绝不驻留。

/* 每个图片块的内存生命周期:读 → 用 → 释放 */
// 布局阶段:读入压缩字节 → 取宽高 → 立刻释放
uint8_t *image_data = epub->get_item_contents(m_src, &image_data_size);
renderer->get_image_size(m_src, image_data, image_data_size, &width, &height);
free(image_data);

// PNG 渲染:一行 RGB565 临时缓冲,解完即释放
uint16_t *tmp = (uint16_t *)malloc(png.getWidth() * 2);
png.decode(this, PNG_FAST_PALETTE);
free(tmp);

// JPEG 渲染:32 KB 固定工作区,解完即释放
void *pool = malloc(POOL_SIZE);
jd_prepare(&dec, read_jpeg_data, pool, POOL_SIZE, this);
jd_decomp(&dec, draw_jpeg_function, scale_factor);
free(pool);

峰值叠加依然可观:渲染一个图片块的那一刻,图片字节 + 解码工作区 + 帧缓冲同时在场。帧缓冲本身是 EPD_WIDTH × EPD_HEIGHT / 2 字节 = 960 × 540 / 2 = 259,200 ≈ 253 KB(第 12 章算过);再加上 EPUB 解析期的整章 blocks、文本缓冲……这些远超 ESP32 内部约 520 KB 的 SRAM。所以仓库把 PSRAM 列为第一硬性要求:图片解码的临时内存与帧缓冲都得住进外挂 PSRAM,否则要么解析失败、要么图片画不出来。

README 的 "How well does it work" 一节也如实交代了图像带来的体验代价:"Depending on the images that have been used for the book covers displaying the list of ePub files on the SD Card can take a few seconds and moving through the pages of ePub files can be a bit slow. Rendering the actual pages is pretty reasonable, even when they have images on them."——封面解码会让书目列表慢上几秒;而阅读页本身的渲染"即使带图也相当利索"。这印证了本章的分工:解码是昂贵的一步,布局时把它藏进"只读头部",渲染时按块流式完成,才把成本控制在可接受范围。

内存项大小 / 来源释放时机
图片压缩字节约等于文件体积(封面可达 222 KB)布局取完宽高 / 渲染画完之后
PNG 行缓冲png.getWidth() * 2 字节本次渲染结束
JPEG 工作区POOL_SIZE = 32768 固定本次渲染结束
帧缓冲EPD_WIDTH×EPD_HEIGHT/2 ≈ 253 KB常驻(PSRAM)
注意

不是所有图片都能解。项目只支持 JPEG 与 PNG——前文的 get_image_helper 对未知格式直接返回 nullptrdraw_image 便退化成画一个空心占位矩形(draw_rect(x+20, y+20, width-40, height-40))。这解释了一个真实现象:pg14838-images.epub 里那张 peter02.gif 在阅读页上会以占位框的形式出现,而不是真图。遇到"图没出来"的问题,先查格式是否在这两条白名单里。

章末练习

练习 1:封面图的两步寻址 入门

结合 lib/Epub/EpubList/Epub.cpp,简述封面图是如何被找到的:它先存在于哪里?用了哪两个标签/属性?最终存在哪个成员变量里?书目列表页又是怎么把它画出来的(结合 EpubList.cpp)?

提示

封面在 content.opf 的 metadata;先 name="cover" 拿 id,再按 id 匹配 manifest;列表页按 3:2 比例缩放后用 draw_image

参考答案

封面图记录在 OEBPS/content.opfmetadata 中:一个 name="cover"meta 标签,其 content 属性是 manifest 里某件资源的 id。解析时遍历 meta 找到它拿到 cover_item,再在 manifest 里匹配 item_id == cover_item 的元素,把其 href 存入 m_cover_image_item。书目列表页用 get_cover_image_item() 取地址、get_item_contents 读字节,按 image_width = 2 * image_height / 3 的竖版比例缩放后 draw_image 画进格子。

练习 2:动手算一次缩放 进阶

沿用 13.3 的屏幕配置(页宽 940、页高 505),分别计算两张图的最终占位与 x_pos:(a) 1200×800;(b) 400×300(一张小插图,不触发缩放)。请写出每一步的 scale、缩放后 width/heightx_pos

提示

(b) 宽高都小于页面,if 分支不进入,尺寸保持原样只做居中;注意 width *= scale 中 width 是整数,会被截断。

参考答案

(a) 1200 宽、800 高都超出,scale = min(940/1200, 505/800) = min(0.783, 0.631) = 0.631;缩放后 width ≈ 757height ≈ 505x_pos = (940 − 757)/2 ≈ 91。(b) 400<940 且 300<505,不触发缩放,保持 400×300;x_pos = (940 − 400)/2 = 270。可见小图不会被强行放大到整页宽——缩放只做缩小不做放大。

练习 3:灰度公式为什么用移位 进阶

项目把彩色像素转灰度用的公式是 gray = (r*38 + g*75 + b*15) >> 7。请解释:(1) 系数 38/75/15 对应标准亮度公式里的哪几个权重?(2) 为什么 >> 7 等价于除以 128?(3) 相比 float 版本,定点整数版好在哪?

提示

标准公式是 0.299R + 0.587G + 0.114B;38+75+15 = 128 = 2⁷;想想嵌入式的 CPU 有没有 FPU。

参考答案

(1) 38/128 ≈ 0.297、75/128 ≈ 0.586、15/128 ≈ 0.117,正是 0.299/0.587/0.114 的定点近似,且和等于 128,保证白色 (255,255,255) 仍映射回 255。(2) >> 7 是对无符号整数除以 2⁷ = 128 的移位实现,一步完成除法。(3) 定点整数只做整数乘法和一次移位,而浮点版在无 FPU 的 MCU 上会触发软件浮点模拟,慢一个量级——这是一张图要执行几十万次的操作,省下来的时间很可观。

练习 4:JPEG 的 scale_factor 循环 挑战

跟踪 JPEGHelper::render 里的 scale_factor 循环。给定初始 x_scale = y_scale = 0.615,写出循环每一步 scale_factorx_scale 的值,并说明最终 jd_decomp 用的缩放级数与软件残余比例。若改成初始 0.25 又会怎样?

提示

循环条件是三个同时成立:x_scale<=1y_scale<=1scale_factor<=3;退出后要 scale_factor--x_scale/=2 还原。

参考答案

初始 0.615、scale_factor=0:第 1 轮 0.615≤1 成立 → scale_factor=1、x_scale=1.23;第 2 轮 1.23≤1 不成立,退出。随后 scale_factor--=0、x_scale/=2=0.615。即 jd_decomp 用 1/1(不缩小),软件按 0.615 逐像素映射。若初始 0.25:第 1 轮→0.5(sf=1);第 2 轮→1.0(sf=2);第 3 轮→2.0(sf=3) 退出,随后 sf=2、x_scale=1.0。即解码器直接输出 1/4 缩小的图,软件残余比例 1.0(无需再缩放)。这个循环把"目标缩小比"在硬件解码与软件映射之间做了分配——硬件能吞多少就吞多少,剩下的才留给软件。