nnedi3 相关代码学习

引言

其实不知道有没有必要像这样,为并不是很复杂的源码煞有介事地写一份包含个人感想的阅读笔记。但不得不说,nnedi3 相关的内容,从我最开始接触图像处理,就一直萦绕在心头。一直觉得 nnedi3 很神奇,抗锯齿用它,重采样放大也用它,因为用了神经网络更显得高大上。另一方面,随着 Deep Learning 浪潮涌起,有些人开始质疑对 Deep Learning 的跟风。于是有种错觉,用传统神经网络的 nnedi3,既套上了光环,又躲过了质疑。

起初接触 nnedi3 是通过 nnedi3_resize16 这个流传甚广的缩放脚本。之后在我学会 deband 与边缘检测后,很长时间没有继续学新东西,几乎过了一年,我才接触到 nnedi3 的抗锯齿用途。又跨过大半年,在我熟悉 Github 之后,到第二年的秋天,我又看到了优化版的 znedi3nnedi3cl。某种角度上,nnedi3 贯穿了我到目前为止的图像处理学习过程。

我已经忘记之前是在哪里看到,说 nnedi3 没有开源。我也模糊得记得,找过 eedi 系列滤镜,没找到代码(事实上也是开源的)。于是交错的印象下,在我开始写 C++ 之前,没有再深入了解 nnedi3。即使是之后开始写 VapourSynth Plugin,也是模仿着 Deblock 和 nnedi2 的框架(话说这对初学者而言,和 nnedi3 比也没什么区别喂喂,只是我当时选择性地无视了)。

BTW,学习代码不是自己写代码,有时候需要不求甚解,明白意思即可,不然也只是浪费时间。

代码概览

代码框架

VapourSynth-NNEDI3CL 是 VapourSynth 框架下进行了 OpenCL 优化的 nnedi3 实现,除了 OpenCL 配置文件外,只有一份 nnedi3cl.cpp,代码结构也不复杂,VapourSynth Plugin 框架的四个函数 + 一个 process() 性质的函数。

重新审视一下 VapourSynth Plugin 框架的四个函数,初始化函数 Init() 和 释放内存函数 Free() 不必多说,从计算内容的角度看,GetFrame() 函数是与图像内容相关的计算,会用到像素点的数据,而 Create() 函数(除获取参数外)则是无关像素点数值的计算,至多会用到视频格式的信息。从两个函数的名字上看也能看出这一点。

NNEDI3CL 因为涉及 OpenCL,且参数较多,Create() 函数中报错信息占了较长的篇幅。nnedi3 最原始的用途是隔行插值反交错,所以在 GetFrame() 函数中,比只处理逐行的滤镜,多了 field / 场信息的判断。

大致浏览完这些信息,核心处理从nnedi3_weights.bin文件入手。

主要数据

1
2
3
4
5
compute::command_queue queue;
compute::kernel kernel;
compute::image2d src, dst, tmp;
compute::buffer weights0, weights1Buffer;
cl_mem weights1;

其中queuekernel用于 OpenCL 框架,三个image2D类型的变量srcdsttmp用于储存图像,余下三个变量则是与神经网络权值相关。

在 Create() 函数内,weights0weights1都是float指针。

cl_mem类型用于在设备上分配内存,cl_mem类型是”Memory Object“的句柄,提供了一种内存抽象。

神经网络

IO 部分

由于使用传统神经网络,需要读入权值文件nnedi3_weights.bin。IO 部分基于头文件<cstdio>

除了常见的std::fopenstd::fclose外,还用到了下面这些函数。

std::fread-std::size_t fread(void* buffer, std::size_t size, std::size_t count, std::FILE* stream):从输入流读取至多 count 个对象到数组 buffer 中,返回成功读取的对象数。

std::rewind-void rewind(std::FILE* stream):移动文件位置指示器到给定文件流的起始。

std::ftell-long ftell(std::FILE* stream):返回文件流文件位置指示器的当前值。

读入的权值数据保存在bdata

1
2
3
4
constexpr long correctSize = 13574928; // Version 0.9.4 of the Avisynth plugin

float * bdata = reinterpret_cast<float *>(malloc(correctSize));
const size_t bytesRead = std::fread(bdata, 1, correctSize, weightsFile);

权值计算的后续

Create() 部分的权值有点复杂,先看看 Create() 权值计算结果是怎么用到 GetFrame() 上的吧。

如后文的”OpenCL 变量“部分所说,三个权值变量被 GetFrame() 调用的只有两个,weights0weights1,被调用的位置和次数一样,只是weights1在 Free() 函数中多了一步释放。

1
clReleaseMemObject(d->weights1);

两个变量在三个分支的if-else中同时出现了四次,伪代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# bool d->dw, d->dh;

if (d->dw && d->dh)
{
kernel.set_args(src, tmp, d->weights0, d->weights1, size_h_w, field, -1);
kernel.set_args(tmp, dst, d->weights0, d->weights1, size_w_h, field, 0);
else if (d->dw)
{
kernel.set_args(src, dst, d->weights0, d->weights1, size_h_w, field, -1);
}
else
{
kernel.set_args(src, dst, d->weights0, d->weights1, size_w_h, field, 0);
}

很明显,是从 x,y 两个方向分别处理。在这四次调用中,weights0weights1发挥的作用是等价。

进而来整体看下 GetFrame() 函数及 process() 函数(在 NNEDI3CL 里写作 filter()),常规而且仅有骨架,上面的伪代码便是核心而唯一的操作,除此之前唯一陌生的地方就是增加了 field 信息的判断。

于是,可以转向 .cl 文件看核心计算了。

OpenCL 核心计算

这部分是 .cl 文件中的代码学习笔记。

命名空间(好像说法不太对..)kernel下的函数有两个重载,分别对应float类型和 uint 类型,看看其中一个。(删去了"\n"

1
2
3
4
5
__kernel __attribute__((reqd_work_group_size(4, 16, 1)))
void filter_uint(__read_only image2d_t src, __write_only image2d_t dst,
__constant float * weights0, __read_only image1d_buffer_t weights1,
const int srcWidth, const int srcHeight, const int dstWidth,
const int dstHeight, const int field_n, const int off, const int swap)

和前面filter()函数中的核心处理相对应。

追踪一下weights0的传递过程。

1
2
float8 output = PRESCREEN((const __local float (*)[INPUT_WIDTH])&input[YDIAD2M1 - 1 + localY][XDIAD2M1 - PSCRN_OFFSET + 8 * localX],
&flag, weights0);

(这个PRESCREEN()是啥…)

OpenCL 接口部分

这部分是 Create() 函数中出现的 OpenCL 相关内容。

相关函数

clCreateImage
1
2
3
4
5
6
cl_mem clCreateImage(cl_context              context,
cl_mem_flags flags,
const cl_image_format *image_format,
const cl_image_desc *image_desc,
void *host_ptr,
cl_int *errcode_ret)

Creates a 1D image, 1D image buffer, 1D image array, 2D image, 2D image array or 3D image object.

创建一维图像、一维图像 buffer、一维图像数组、二维图像、二维图像数组或者三维图像对象。

OpenCL 变量

d->weights1

结构体内的weights1定义如下。

1
2
cl_mem mem = clCreateImage(context, 0, &format, &desc, nullptr, &error);
d->weights1 = mem;

其中,mem的主要数据来自desc

desc的数据,除了一些格式上的常数,一是来自dims1 ,一是来自权值weights1Buffer,也就是三个结构体内的数据之一。

dims1的定义如下,都是常数计算,没有额外的东西。

1
const int dims1 = nnsTable[nns] * 2 * (xdiaTable[nsize] * ydiaTable[nsize] + 1);

所以综上所述,weights1weights1Buffer有种套娃之感,核心数据是一样的。(看一下名字啊,weights1Buffer=weights1+buffer

d->weights0d->weights1Buffer

结构体内的weights0weights1Buffer定义如下。

1
2
3
4
5
d->weights0 = compute::buffer{ context, std::max(dims0, dims0new) * sizeof(cl_float),
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR | CL_MEM_HOST_NO_ACCESS, weights0 };

d->weights1Buffer = compute::buffer{ context, dims1 * 2 * sizeof(cl_float),
CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR | CL_MEM_HOST_NO_ACCESS, weights1 };

事实上,weights1Buffer的调用范围仅限于 Create(),并没有在 GetFrame() 中调用。

Create() 内的同名变量

在 Create() 函数内,有两个临时变量性质的同名变量,定义如下。

1
2
float* weights0 = new float[std::max(dims0, dims0new)];
float* weights1 = new float[dims1 * 2];

它们在定义了d->weights0d->weights1Buffer后被释放。