读 ncnn 源码(Ⅻ):图像预处理流水线——从像素到张量的“最后一公里”

在深度学习推理场景中,将原始图像数据适配到神经网络输入格式,是“最后一公里”的关键挑战。这一过程,即图像预处理(Image Pre-processing),涵盖尺寸调整、格式转换、数值归一化等多个环节,其效率和精度直接影响模型推理的整体性能。

本篇,我们将深入剖析 ncnn 中从外部像素数据到内部 Mat 张量输入的完整预处理流水线,重点关注 Mat::from_pixels_resizeresize_bilinear_cXMat::from_pixels 以及 Mat::substract_mean_normalize 几个核心函数的协同工作,并通过源码揭示其背后的工程考量与优化策略。

TL;DR

  1. 流水线: ncnn 的图像预处理分为尺寸适配格式转换与封装数值归一化三阶段。
  2. 尺寸适配 (from_pixels_resize, resize_bilinear_cX):
    • from_pixels_resize 提供便捷接口,自动计算 stride 并分发。核心缩放由 resize_bilinear_cX 完成。
    • resize_bilinear_cX (以 c3 为例) 采用双线性插值,通过预计算插值系数 (xofs, yofs, ialpha, ibeta) 并使用定点数 (short, INTER_RESIZE_COEF_BITS) 优化,结合行缓存复用 (rowsbuf0, rowsbuf1) 和 SIMD (NEON) 加速水平/垂直插值,实现高效重采样。
  3. 格式转换与封装 (from_pixels):
    • unsigned char 像素转换为 float,并处理 RGB2BGR, RGBA2GRAY 等多种格式转换(通过委托 from_rgb 等函数)。
    • 核心是调用 Mat::create 生成最终 Mat,确保内存对齐和 ncnn 内部数据布局要求。
  4. 数值归一化 (substract_mean_normalize):
    • 执行 output = (input - mean) * norm
    • 关键优化: 不手动实现,而是动态创建并复用 ncnn 内部已优化的 BiasScale 层,通过构造权重实现减均值/归一化(或融合操作),并通过 forward_inplace 高效执行。


1. 图像预处理流水线概览

神经网络通常对输入数据的尺寸通道顺序数值范围有严格要求。ncnn 的预处理流水线正是为了桥接外部图像的灵活性与内部网络的严谨性。其典型调用链为:

  1. Mat::from_pixels_resize(pixels, type, w, h, target_w, target_h, ...) (用户调用)
  2. -> Mat::from_pixels_resize(pixels, type, w, h, stride, target_w, target_h, ...) (内部分发)
  3. -> resize_bilinear_cX(...) (核心缩放)
  4. -> Mat::from_pixels(dst_pixels, type, target_w, target_h, target_stride, ...) (封装结果)
  5. -> Mat::substract_mean_normalize(mean_vals, norm_vals) (数值归一化,用户后续调用)

这条流水线确保了送入 Net::input()Mat 对象具备网络所需的精确属性。


2. 阶段一:尺寸适配——接口与核心

2.1 接口分发 (Mat::from_pixels_resizestride 版本)

用户通常调用此便捷接口。它首先解析像素格式,计算出正确的行步长 stride,然后调用功能更全的重载版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Mat Mat::from_pixels_resize(const unsigned char* pixels, int type, int w, int h, int target_width, int target_height, Allocator* allocator)
{
int type_from = type & PIXEL_FORMAT_MASK;

// 根据像素格式计算 stride (w * 通道数)
if (type_from == PIXEL_RGB || type_from == PIXEL_BGR)
{
return Mat::from_pixels_resize(pixels, type, w, h, w * 3, target_width, target_height, allocator);
}
else if (type_from == PIXEL_GRAY)
{
return Mat::from_pixels_resize(pixels, type, w, h, w * 1, target_width, target_height, allocator);
}
else if (type_from == PIXEL_RGBA || type_from == PIXEL_BGRA)
{
return Mat::from_pixels_resize(pixels, type, w, h, w * 4, target_width, target_height, allocator);
}
// ... 错误处理 ...
return Mat();
}

2.2 核心缩放调用 (Mat::from_pixels_resizestride 版本)

此函数负责调度核心缩放逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Mat Mat::from_pixels_resize(const unsigned char* pixels, int type, int w, int h, int stride, int target_width, int target_height, Allocator* allocator)
{
// 快速路径:如果尺寸无需改变,直接封装
if (w == target_width && h == target_height)
return Mat::from_pixels(pixels, type, w, h, stride, allocator);

int type_from = type & PIXEL_FORMAT_MASK;

// 根据通道数选择特化的 resize_bilinear 函数
if (type_from == PIXEL_RGB || type_from == PIXEL_BGR)
{
// 1. 创建临时的 dst Mat 来存储缩放结果
Mat dst(target_width, target_height, (size_t)3u, 3);
// 2. 调用核心缩放函数
resize_bilinear_c3(pixels, w, h, stride, dst, target_width, target_height, target_width * 3);
// 3. 将缩放后的 dst 封装成最终的 Mat
return Mat::from_pixels(dst, type, target_width, target_height, allocator);
}
else if (type_from == PIXEL_GRAY)
{
Mat dst(target_width, target_height, (size_t)1u, 1);
resize_bilinear_c1(pixels, w, h, stride, dst, target_width, target_height, target_width * 1);
return Mat::from_pixels(dst, type, target_width, target_height, allocator);
}
else if (type_from == PIXEL_RGBA || type_from == PIXEL_BGRA)
{
Mat dst(target_width, target_height, (size_t)4u, 4);
resize_bilinear_c4(pixels, w, h, stride, dst, target_width, target_height, target_width * 4);
return Mat::from_pixels(dst, type, target_width, target_height, allocator);
}
// ... 错误处理 ...
return Mat();
}

关键点:先缩放到一个临时的 unsigned char 类型的 Mat dst,再调用 Mat::from_pixels 进行最终的类型转换和内存封装。

2.3 双线性插值实现 (resize_bilinear_c3)

这是图像缩放的核心算法所在地,充满了优化技巧。

a) 预计算插值系数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const int INTER_RESIZE_COEF_BITS = 11;
const int INTER_RESIZE_COEF_SCALE = 1 << INTER_RESIZE_COEF_BITS; // 2048

double scale_x = (double)srcw / w;
double scale_y = (double)srch / h;

// 使用一个连续缓冲区存储所有系数,减少内存分配次数
int* buf = new int[w + h + w + h];
int* xofs = buf; // 源图像 x 坐标整数部分
int* yofs = buf + w; // 源图像 y 坐标整数部分
short* ialpha = (short*)(buf + w + h); // x 方向插值系数 (定点数)
short* ibeta = (short*)(buf + w + h + w); // y 方向插值系数 (定点数)

// ... 循环计算 xofs, ialpha ...
for (int dx = 0; dx < w; dx++)
{
fx = (float)((dx + 0.5) * scale_x - 0.5); // 计算目标像素 dx 在源图像的浮点坐标
sx = static_cast<int>(floor(fx)); // 取整得到 sx
fx -= sx; // 得到小数部分 fx (插值权重相关)
// 边界处理
// ...
xofs[dx] = sx * 3; // 乘以通道数,得到内存偏移

// 计算插值系数并量化为 short (定点数)
float a0 = (1.f - fx) * INTER_RESIZE_COEF_SCALE;
float a1 = fx * INTER_RESIZE_COEF_SCALE;
ialpha[dx * 2] = SATURATE_CAST_SHORT(a0); // (1 - fx) 系数
ialpha[dx * 2 + 1] = SATURATE_CAST_SHORT(a1); // fx 系数
}
// ... 类似地循环计算 yofs, ibeta ...

核心思想: 将浮点运算尽可能提前到循环外,并将插值系数转换为定点数 (short),便于后续使用 SIMD 或整数运算加速。

b) 行缓存与水平插值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    Mat rowsbuf0(w * 3 + 1, (size_t)2u); // short 类型行缓存
Mat rowsbuf1(w * 3 + 1, (size_t)2u);
short* rows0 = (short*)rowsbuf0.data;
short* rows1 = (short*)rowsbuf1.data;
int prev_sy1 = -2; // 用于判断行缓存是否可复用

for (int dy = 0; dy < h; dy++)
{
sy = yofs[dy];

if (sy == prev_sy1) { /* reuse all rows */ }
else if (sy == prev_sy1 + 1)
{
// 只需计算新的一行 S1,并更新行缓存指针
short* rows0_old = rows0; rows0 = rows1; rows1 = rows0_old;
const unsigned char* S1 = src + srcstride * (sy + 1);
// ... 水平插值计算 rows1 ...
for (int dx = 0; dx < w; dx++)
{
sx = xofs[dx];
short a0 = ialphap[0]; short a1 = ialphap[1]; // 获取预计算系数
const unsigned char* S1p = S1 + sx; // 源像素指针

#if __ARM_NEON // 使用 NEON 指令加速水平插值
// ... (加载 S1p[0..5] 到 _S1) ...
// ... (uint8 -> int16) ...
// ... (获取相邻像素 S1p[0..2] 和 S1p[3..5]) ...
// ... (向量乘加: S1p[0..2]*a0 + S1p[3..5]*a1) ...
// ... (右移 4 位量化,并将结果存入 rows1p) ...
#else // 标量 C++ 实现
rows1p[0] = (S1p[0] * a0 + S1p[3] * a1) >> 4; // R 通道
rows1p[1] = (S1p[1] * a0 + S1p[4] * a1) >> 4; // G 通道
rows1p[2] = (S1p[2] * a0 + S1p[5] * a1) >> 4; // B 通道
#endif
ialphap += 2; rows1p += 3;
}
}
else
{
// 需要计算 S0 和 S1 两行
const unsigned char* S0 = src + srcstride * (sy);
const unsigned char* S1 = src + srcstride * (sy + 1);
// ... 水平插值计算 rows0 和 rows1 (代码类似上面) ...
}
prev_sy1 = sy; // 更新已处理行号

// ... 垂直插值 ...
}

核心思想: 利用 rowsbuf0, rowsbuf1 缓存已完成水平插值的相邻两行数据,并通过 prev_sy1 判断避免重复计算。水平插值本身利用 NEON 进行向量化。

c) 垂直插值:

1
2
3
4
5
6
7
8
9
10
11
12
if (dy + 1 < h && yofs[dy + 1] == sy) // 检查是否可一次处理两行
{
// vresize for two rows ...
vresize_two(rows0, rows1, w * 3, Dp0, Dp1, ibeta[0], ibeta[1], ibeta[2], ibeta[3]);
ibeta += 4; dy += 1; // 跳过下一行
}
else
{
// vresize for one row ...
vresize_one(rows0, rows1, w * 3, Dp, ibeta[0], ibeta[1]);
ibeta += 2;
}

核心思想: vresize_one/vresize_two (未展示代码) 利用预计算的 ibeta 系数,对 rows0rows1 中的 short 类型数据进行垂直方向的加权平均,并将最终的 unsigned char 结果写入目标 dst 内存。同样会进行 SIMD 优化。


3. 阶段二:格式转换与内存封装 (Mat::from_pixels)

此函数接收 unsigned char* 数据(可能是原始像素,也可能是 resize 后的 dst),将其转换为 float 类型并封装到最终的 Mat 对象中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Mat Mat::from_pixels(const unsigned char* pixels, int type, int w, int h, int stride, Allocator* allocator)
{
Mat m;

if (type & PIXEL_CONVERT_MASK) // 检查是否需要格式转换
{
// 根据转换类型调用特定函数
switch (type)
{
case PIXEL_RGB2BGR:
case PIXEL_BGR2RGB:
from_rgb2bgr(pixels, w, h, stride, m, allocator); break;
case PIXEL_RGB2GRAY:
from_rgb2gray(pixels, w, h, stride, m, allocator); break;
// ... 其他转换类型 ...
default: NCNN_LOGE("unimplemented convert type %d", type); break;
}
}
else // 无需格式转换,仅类型转换 (uchar -> float) 和封装
{
if (type == PIXEL_RGB || type == PIXEL_BGR)
from_rgb(pixels, w, h, stride, m, allocator);
if (type == PIXEL_GRAY)
from_gray(pixels, w, h, stride, m, allocator);
if (type == PIXEL_RGBA || type == PIXEL_BGRA)
from_rgba(pixels, w, h, stride, m, allocator);
}

return m;
}

核心逻辑:

  • 格式转换分发: 通过 switch 语句将复杂的颜色空间转换(如 RGB<->BGR, RGB->Gray, RGBA->RGB)委托给专门的、同样经过 SIMD 优化的底层函数。
  • 基础类型转换与封装: 对于无需格式转换的情况,调用 from_rgb, from_gray, from_rgba 等函数。这些函数内部会:
    1. 调用 m.create(w, h, c, elemsize, ...) 来申请一块新的、内存对齐Mat 内存。
    2. 遍历 pixels 数据,将 unsigned char 值转换为 float (通常伴随 / 255.f 操作)。
    3. float 数据写入新申请的 Mat 内存中。

4. 阶段三:数值归一化 (Mat::substract_mean_normalize)

这是预处理的最后一步,确保数值范围符合模型要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void Mat::substract_mean_normalize(const float* mean_vals, const float* norm_vals)
{
Layer* op = 0; // 临时的 Bias 或 Scale 层

if (mean_vals && !norm_vals) // 只减均值
{
op = create_layer(LayerType::Bias); // 创建 Bias 层
ParamDict pd; pd.set(0, c); op->load_param(pd);
Mat weights[1]; weights[0] = Mat(c);
for (int q = 0; q < c; q++) weights[0][q] = -mean_vals[q]; // Bias = -mean
op->load_model(ModelBinFromMatArray(weights));
}
else if (!mean_vals && norm_vals) // 只归一化
{
op = create_layer(LayerType::Scale); // 创建 Scale 层
ParamDict pd; pd.set(0, c); op->load_param(pd);
Mat weights[1]; weights[0] = Mat(c);
for (int q = 0; q < c; q++) weights[0][q] = norm_vals[q]; // Scale = norm
op->load_model(ModelBinFromMatArray(weights));
}
else if (mean_vals && norm_vals) // 减均值并归一化 (融合操作)
{
op = create_layer(LayerType::Scale); // 创建带 Bias 的 Scale 层
ParamDict pd; pd.set(0, c); pd.set(1, 1/*bias_term*/); op->load_param(pd);
Mat weights[2]; weights[0] = Mat(c); weights[1] = Mat(c);
for (int q = 0; q < c; q++) {
weights[0][q] = norm_vals[q]; // Scale = norm
weights[1][q] = -mean_vals[q] * norm_vals[q]; // Bias = -mean * norm
}
op->load_model(ModelBinFromMatArray(weights));
}
else { return; } // 无操作

// 使用临时层执行计算
Option opt; opt.num_threads = 1; // 通常预处理是单线程
op->create_pipeline(opt);
op->forward_inplace(*this, opt); // 原地修改当前 Mat 对象
op->destroy_pipeline(opt);
delete op; // 释放临时层
}

核心思想: 通过动态创建 BiasScale 层,并巧妙构造其权重,将归一化操作委托给 ncnn 内部优化过的层实现。forward_inplace 避免了额外的内存分配和拷贝,实现了高效的原地数值归一化。


5. 结语

ncnn 的图像预处理流水线,从 from_pixels_resizesubstract_mean_normalize,展现了兼顾易用性、功能完备性和极致性能的设计哲学。通过接口封装、算法特化、定点数优化、SIMD 加速、行缓存复用以及巧妙的层复用,ncnn 确保了从原始像素到标准化网络输入的每一步都尽可能高效,为端侧设备上的实时推理奠定了坚实的基础。

该封面图片由MarcoPixabay上发布