认识raw图片-使用C++进行图像编辑(一)

88 阅读6分钟

对于相机拍摄的照片,RAW格式记录了最完整的光信息,通过libraw开源项目,我们可以拿到相机每一个像素的亮度信息,进行完全自由的图像处理,同时libraw能够很轻松的打包到Android、iOS应用中,使得在手机上快捷编辑RAW格式图片成为可能。

一、RAW格式是什么

简单理解,相机cmos每一个像素可以拿到光的亮度信息,RAW格式完整的提供这些亮度信息,比如一般的全画幅相机,一般能记录14bit,也就是2^14=16384种亮度阶梯。而一般的处理好后的JPG图片,一般为8bit,也就是2^8=256种阶梯,包含的信息量远远小于RAW格式。在我们进行调色,提高阴影、降低高光时,RAW格式有很大的优势,我们在RAW格式基础上做图片处理后,再导出8bit的JPG格式,可以还原出很多细节,调色也不容易断层。

二、使用Libraw导出黑白图信息

之前说了通俗理解我们每个像素是亮度信息,那我们就先导出试下看是什么样子。

1. 安装libraw库

使用MacOS,直接执行brew install libraw,就可以安装好库文件和头文件了,然后我们写个简单的C++程序运行下:

#include <libraw/libraw.h>
#include <iostream>

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cout << "用法: ./RawProcessor <raw文件路径>" << std::endl;
        return 1;
    }

    LibRaw iProcessor;

    // 打开文件
    int ret = iProcessor.open_file(argv[1]);
    if (ret != LIBRAW_SUCCESS) {
        std::cerr << "读取错误: " << libraw_strerror(ret) << std::endl;
        return 1;
    }

    std::cout << "相机型号: " << iProcessor.imgdata.idata.make << " "
             << iProcessor.imgdata.idata.model << std::endl;
    std::cout << "图像尺寸: " << iProcessor.imgdata.sizes.width << " x "
             << iProcessor.imgdata.sizes.height << std::endl;
    
    iProcessor.recycle();
    return 0;
}

LibRaw直接提供了打开文件的能力,使用LibRaw类,我们便可以读取一张RAW图片,通过imgdata这个结构体我们可以拿到RAW格式的具体数据,如上代码,便可以输出RAW图片的相机型号、宽高,LibRaw帮我们处理了所有相机厂商的不同格式。

2. 直接导出每一个像素

众所周知,相机的图片是由一个个像素组成的,在LibRaw中,我们直接读取每一个像素点的亮度,直接导出,就可以得到一张图片。

如下是经典的相机测试图,为松下S5相机拍摄的图片的实际样子。

1.png

下面,我们直接写代码根据每一个像素的亮度导出一张图片。

#include <libraw/libraw.h>
#include <iostream>
#include <vector>
#include <cstdio>
#include <cmath>

int main(int argc, char* argv[]) {
    if (argc < 2) {
        std::cout << "用法: ./RawProcessor <raw文件路径>" << std::endl;
        return 1;
    }

    LibRaw iProcessor;

    if (iProcessor.open_file(argv[1]) != LIBRAW_SUCCESS) return 1;
    if (iProcessor.unpack() != LIBRAW_SUCCESS) return 1;

    // 关键:我们要导出的是有效成像区域的每一像素
    int width   = iProcessor.imgdata.sizes.raw_width;  // 物理总宽
    int iwidth  = iProcessor.imgdata.sizes.width;      // 成像宽
    int iheight = iProcessor.imgdata.sizes.height;     // 成像高
    int top     = iProcessor.imgdata.sizes.top_margin;
    int left    = iProcessor.imgdata.sizes.left_margin;

    float black = (float)iProcessor.imgdata.color.black;
    float white = (float)iProcessor.imgdata.color.maximum;
    float range = white - black;

    // 准备一个单通道灰度容器,大小等于成像区域
    std::vector<unsigned char> gray_data(iwidth * iheight);
    unsigned short* raw_ptr = iProcessor.imgdata.rawdata.raw_image;

    std::cout << "正在生成原始黑白网格图..." << std::endl;

    for (int y = 0; y < iheight; y++) {
        for (int x = 0; x < iwidth; x++) {
            // 计算在原始大数组中的位置(加上边距)
            int row = top + y;
            int col = left + x;
            unsigned short raw_val = raw_ptr[row * width + col];

            // 1. 减去黑电平
            float val = (float)raw_val - black;
            if (val < 0) val = 0;

            // 2. 归一化到 0.0 - 1.0
            float norm = val / range;
            if (norm > 1.0f) norm = 1.0f;

            // 3. Gamma 校正(如果不加 Gamma,原始线性数据会非常暗)
            // 这样你可以看清 Bayer 阵列的亮度差异
            norm = pow(norm, 1.0f / 2.2f);

            // 4. 写入灰度数据
            gray_data[y * iwidth + x] = (unsigned char)(norm * 255.0f);
        }
    }

    // 保存为 PGM (P5 代表二进制灰度格式)
    FILE *f_pgm = fopen("raw_bayer_mesh.pgm", "wb");
    if (f_pgm) {
        fprintf(f_pgm, "P5\n%d %d\n255\n", iwidth, iheight);
        fwrite(gray_data.data(), 1, gray_data.size(), f_pgm);
        fclose(f_pgm);
        std::cout << "处理完成:raw_bayer_mesh.pgm" << std::endl;
    }

    iProcessor.recycle();
    return 0;
}
2.png
    for (int y = 0; y < iheight; y++) {
        for (int x = 0; x < iwidth; x++) {
            // 计算在原始大数组中的位置(加上边距)
            int row = top + y;
            int col = left + x;
            unsigned short raw_val = raw_ptr[row * width + col];

            // 1. 减去黑电平
            float val = (float)raw_val - black;
            if (val < 0) val = 0;

            // 2. 归一化到 0.0 - 1.0
            float norm = val / range;
            if (norm > 1.0f) norm = 1.0f;

            // 3. Gamma 校正(如果不加 Gamma,原始线性数据会非常暗)
            // 这样你可以看清 Bayer 阵列的亮度差异
            norm = pow(norm, 1.0f / 2.2f);

            // 4. 写入灰度数据
            gray_data[y * iwidth + x] = (unsigned char)(norm * 255.0f);
        }
    }

这一段循环代码是关键。我们知道相机cmos感光有个最低值,就算没有光线,也会有些噪点,也就是暗部噪声,我们每个像素的亮度要减掉这个黑电平float val = (float)raw_val - black;,每个像素统一缩放到0~1之间 float norm = val / range;,最后,我们得到的数据是均匀分布的光线数据,假设把这些均匀分布的数据直接乘上255变成8bit的常规图片,会是这个样子:

3.png

可以很明显看到照片特别黑,简单问下AI:RAW文件忠实记录了物理光强(线性)。在0到255的范围里,物理中值 128(50%的光强)在人眼看来其实已经非常亮了。而人眼认为的“中等灰度”(视觉上的50%),在物理上其实只产生了大约 18% 的光强。如果不经过 Gamma 变换把这 18% 的数据“拉”到 128 的位置,它就会缩在 40 左右的数值区域,看起来当然黑乎乎的一片。

也就是说,考虑到两方面,一个是人眼对于光亮的感知程度不同、显示器对于图片文件的显示不同,我们不能线性地将数据直接写入图片文件,而是需要对其做一次变换。比较简单经典的规范就是norm = pow(norm, 1.0f / 2.2f);对于亮度做一个1/2.2的指数。

如上,我们已经得到了一张灰图,不放大看好像还挺不错的,但会发现怎么和实际图片差距这么大:

  1. 我们的图片好像中间突出来了,这其实是镜头的畸变,一般处理后的图片都会自己做修正。
  2. 我们的图片是黑白的,而由这些黑白图片怎么才能得到彩色图片呢?

畸变的问题我们之后再讨论,单彩色图片如何得来呢?其实,我们仔细放大表面上看起来的黑白图片,就会发现我们这张黑白图片也不对劲:

4.png

很容易发现,为什么黑白图片里面像是有棋盘一样的黑白点交替,没错,这就是下一节我们要讨论的拜耳阵列、白平衡等等,正式相机厂商在cmos前面增加了彩色滤镜片,才使得我们可以用单纯感光的cmos得到一张不同颜色的图片。