4. 嵌入式硬件与显示接口

图片缩放,说白了就是像素的重映射。你想想看,一张 320×240 的图要撑满 800×480 的屏幕,多出来的像素从哪来?反过来,大图缩成小图,哪些像素该丢?

我在嵌入式项目里踩过不少缩放的坑。有一次用 STM32F429 驱动 7 寸屏,图标缩放后边缘全是锯齿,客户直接拍了照片发群里……嗯,从那以后我对缩放算法再也不敢马虎了。

今天咱们就把四种主流算法掰开揉碎,看看它们到底怎么算、快在哪、又糊在哪。

4.1 四种算法核心原理

先给个总览,后面再细说。

算法 核心思路 计算量级 质量等级
最近邻插值 找最近的像素直接复制 O(1) 每像素 ★☆☆☆☆
双线性插值 周围 4 个像素加权平均 O(4) 每像素 ★★★☆☆
双三次插值 周围 16 个像素多项式拟合 O(16) 每像素 ★★★★☆
Lanczos 周围 36/64 个像素 sinc 卷积 O(36~64) 每像素 ★★★★★

关键结论:嵌入式设备上,80% 的场景用双线性就够了。只有照片级预览或文字渲染才需要上双三次或 Lanczos。

4.2 最近邻插值:最快,但最糙

算法逻辑简单到令人发指:目标像素的坐标映射回原图,四舍五入取最近的像素值。

// 最近邻插值核心代码
uint16_t nearest_neighbor(uint8_t *src, int sw, int sh, 
                          float sx, float sy) {
    int ix = (int)(sx + 0.5f);  // 四舍五入
    int iy = (int)(sy + 0.5f);
    // 边界裁剪
    ix = CLAMP(ix, 0, sw - 1);
    iy = CLAMP(iy, 0, sh - 1);
    return src[iy * sw + ix];
}

优点:没有乘法,没有浮点(可以转定点),一个时钟周期就能算一个像素。我在 GD32F450 上测过,放大 2 倍整张图只要 3ms。

缺点:锯齿严重。尤其是斜线和圆弧,放大后像楼梯一样。我曾经用最近邻缩放一个 16×16 的箭头图标到 48×48,结果箭头边缘全是「马赛克台阶」,项目经理看了一眼直接说重做。

避坑:最近邻只适合像素风格 UI 或调色板索引图。如果你做的是照片预览、地图缩放、文字渲染,千万别用。

4.3 双线性插值:性价比之王

这个算法在嵌入式里用得最多。它取目标点周围 4 个像素,按距离加权平均。

// 双线性插值核心
uint16_t bilinear(uint8_t *src, int sw, int sh,
                  float sx, float sy) {
    int ix = (int)sx;
    int iy = (int)sy;
    float dx = sx - ix;
    float dy = sy - iy;
    
    // 取周围 4 个像素
    uint16_t p00 = src[iy * sw + ix];
    uint16_t p10 = src[iy * sw + ix + 1];
    uint16_t p01 = src[(iy + 1) * sw + ix];
    uint16_t p11 = src[(iy + 1) * sw + ix + 1];
    
    // 先水平插值,再垂直插值
    float top = p00 + (p10 - p00) * dx;
    float bot = p01 + (p11 - p01) * dx;
    return (uint16_t)(top + (bot - top) * dy);
}

你看,核心就是 4 次乘法 + 6 次加减法。在 Cortex-M4 上,如果开了 FPU 和 SIMD,一个像素大概 40~60 个周期。

质量表现:边缘比最近邻平滑多了,但会有轻微模糊。我做过对比测试:双线性缩放到 2 倍,PSNR 比最近邻高 8~10dB,肉眼几乎看不出模糊。

我的习惯:在 LVGL 或 TouchGFX 项目中,默认缩放回调函数就用双线性。除非客户明确要求「像素级清晰」,否则不动它。

4.4 双三次插值:质量好,但吃资源

双三次用周围 16 个像素做三次多项式拟合。公式比双线性复杂不少:

// 双三次插值的权重函数
float cubic_weight(float t) {
    float a = -0.5f;  // 常用参数
    if (t < 0) t = -t;
    float t2 = t * t;
    float t3 = t2 * t;
    if (t <= 1.0f)
        return (a + 2.0f) * t3 - (a + 3.0f) * t2 + 1.0f;
    else if (t < 2.0f)
        return a * t3 - 5.0f * a * t2 + 8.0f * a * t - 4.0f * a;
    return 0.0f;
}

每算一个像素,要算 16 次权重函数 + 16 次乘加。在 STM32H743 上,双三次比双线性慢了 4~5 倍。

什么时候用?我只有在两种情况下才上双三次:

  • 图片要作为启动画面或背景,用户会盯着看 3 秒以上
  • 原图分辨率很低(比如 64×64),需要放大 4 倍以上

实测数据:在 ESP32-S3 上缩放 320×240 到 800×480:

  • 最近邻:8ms
  • 双线性:32ms
  • 双三次:142ms
  • Lanczos-3:268ms

双三次已经接近 60fps 的帧预算极限了(16.6ms 每帧)。

4.5 Lanczos:桌面级质量,但嵌入式慎用

Lanczos 基于 sinc 函数,理论上能保留最多高频细节。它取周围 6×6(Lanczos-3)或 8×8(Lanczos-4)像素做卷积。

算法本身不复杂,但计算量爆炸:

// Lanczos-3 权重
float lanczos_weight(float t, int a) {
    if (t == 0.0f) return 1.0f;
    if (t < -a || t > a) return 0.0f;
    float pt = (float)M_PI * t;
    return (a * sinf(pt) * sinf(pt / a)) / (pt * pt);
}

注意这里用了 sinf,在 Cortex-M 上一条 sin 指令就要 20~40 个周期。算完 36 个权重再加权求和,一个像素轻松上千周期。

我的建议:除非你的芯片有硬件卷积加速器(比如 NXP i.MX RT 系列的 PXP),否则别在 MCU 上用 Lanczos。我在 i.MX RT1064 上试过,用 PXP 硬件加速后 Lanczos-3 能做到 15ms 一张图,但纯软件实现直接卡到 400ms。

曾经踩过的坑:有一次客户要求「图片缩放后不能有任何锯齿和振铃」,我上了 Lanczos-4。结果内存爆了——Lanczos 需要缓存多行像素做卷积,一行 800 像素的 RGBA 图就要 3.2KB,缓存 8 行就是 25.6KB。对于只有 64KB RAM 的芯片来说,这简直是灾难。

4.6 算法选择决策树

下面这张图是我在项目里总结的决策流程,帮你快速选型:

缩放算法选择决策树 开始选择缩放算法 是否支持硬件缩放加速? 是 使用硬件 Lanczos 否 软件实现 是否像素风格 UI? 是 最近邻插值 否 放大倍数 > 4 或照片级? 是 双三次插值 否 双线性插值 说明:硬件加速优先 → 像素风格用最近邻 → 大倍数/照片用双三次 → 默认用双线性 Lanczos 仅在硬件加速可用时推荐,纯软件实现成本过高

4.7 嵌入式实战优化技巧

光知道算法原理还不够,在嵌入式上跑起来才是真本事。分享几个我常用的优化手段:

  1. 定点化:把浮点权重转成 Q15 或 Q31 定点数。双线性插值的 dx/dy 可以转成 0~255 的整数,乘法用 SIMD 一条指令算 4 个像素。
  2. 查表法:双三次的权重函数可以预计算成 256 级查找表。我做过测试,查表比实时计算快 3 倍,精度损失不到 0.5dB。
  3. 行缓存:缩放时按行处理,只缓存当前需要的几行。双线性需要 2 行,双三次需要 4 行,Lanczos-3 需要 6 行。别一次性加载整张图。
  4. DMA + 双缓冲:用 DMA 搬运像素数据,CPU 专心算插值。我在 STM32H7 上做过,DMA 搬运 + 双缓冲能让缩放吞吐量提升 40%。

我的经验:如果芯片有 FPU,双线性插值直接用 float 算反而比定点快。因为定点需要额外的移位和饱和操作,而 FPU 一条 FMA 指令就搞定了乘加。别盲目追求定点化,先测一下再决定。

4.8 总结:选对算法,事半功倍

四种算法没有绝对的好坏,关键看场景:

场景 推荐算法 理由
像素风游戏/图标 最近邻 保留锯齿感,风格统一
普通 UI 背景/按钮 双线性 速度与质量平衡最好
照片预览/启动画面 双三次 细节保留好,用户感知强
专业图像处理 Lanczos(硬件加速) 最高质量,但成本高

最后说一句:别在项目初期就定死算法。我习惯在代码里留一个回调函数指针,运行时根据图片类型动态切换。这样后期优化时,改一行代码就能换算法,不用重构整个缩放模块。