likes
comments
collection
share

我就想简简单单用nodejs读取个图片的rbga值,怎么就那么难?

作者站长头像
站长
· 阅读数 23

最近遇到一个问题,我想使用nodejs读取图片的 rgba像素值以便于做些后续操作,我只以为这是个小问题罢了,找个 api 就完事,如果是在浏览器端,可以借助 canvas,但是 node端没有这个东西,虽然也有个 node-canvas 的东西,但我只是想简简单单读个像素值罢了,又不是真要搞些 canvas操作,向来就对无脑装包深恶痛绝的我,自然是拒绝杀鸡用牛刀的,然而我平时不怎么用 nodejs,又摒弃了装包这条路,一时之间竟不知道如何下手

于是网上搜索下,找了半天,全是装包的,换了好多个关键词愣是没搜到讲解如何使用原生 nodejs读取像素值的,这个时候我才意识到这件事可能没我想得那么简单,于是我选了一个被很多包底层引用的包 jpeg-js,决定看下其代码,一看才知道,这问题确实不简单

parse

看这个库的一个例子

var jpeg = require('jpeg-js');
var jpegData = fs.readFileSync('grumpycat.jpg');
var rawImageData = jpeg.decode(jpegData);
console.log(rawImageData);
/*
{ width: 320,
  height: 180,
  data: <Buffer 5b 40 29 ff 59 3e 29 ff 54 3c 26 ff 55 3a 27 ff 5a 3e 2f ff 5c 3c 31 ff 58 35 2d ff 5b 36 2f ff 55 35 32 ff 5a 3a 37 ff 54 36 32 ff 4b 32 2c ff 4b 36 ... > }
*/

rawImageData.data 就是图片像素值集合的 buffer数据了,于是打开 decode一探究竟,整个文件无任何依赖,大约有 1100行,开篇就是大半个屏幕的注释,大概意思是说,这段代码程序是根据 这篇文档 编写的,当我打开这篇文档准备仔细看看的时候,又发现它居然有 186 页,啊这……我决定还是先看看这 1100行的代码吧

我就想简简单单用nodejs读取个图片的rbga值,怎么就那么难?

代码中只有两个顶级变量,JpegImagedecode,前者是逻辑主体,后者就是我们调用的解码方法

decode 接收两个参数,第一个参数 jpegDataBufferLike 类型,我们使用 fs.readFileSync 读取图片返回的二进制流可以作为此参数,第二个参数是一个配置项,配置项的含义在 README.md 里也都写了

var arr = new Uint8Array(jpegData);
var decoder = new JpegImage();
decoder.opts = opts;
// If this constructor ever supports async decoding this will need to be done differently.
// Until then, treating as singleton limit is fine.
JpegImage.resetMaxMemoryUsage(opts.maxMemoryUsageInMB * 1024 * 1024);
decoder.parse(arr);

这段代码的第一行,根据传入的图片二进制数据流创建了一个 Uint8Array,为什么要创建这个?因为直接读取的二进制流是无法直接操作的,需要通过类型数组对象 TypedArray 或者 DataView 对象来操作,Uint8Array 就是 TypedArray 的一种,范围是 0-255,而我们想要的图片 rgba值也是在这个范围内,所以用这个正好

resetMaxMemoryUsage 设置一个内存占用上限,避免传入太大的图片文件,重点是 parse 方法

function parse(data) {
  function readUint16() {
    var value = (data[offset] << 8) | data[offset + 1];
    offset += 2;
    return value;
  }
  // ...
  var fileMarker = readUint16();
  var malformedDataOffset = -1;
  this.comments = [];
}

readUint16

有个 readUint16 方法,主要内容是一行位运算,看着莫名其妙的,但这个函数出现的频率还挺高,不弄明白源码就没法看了,后来我在 nodejs 中找到个 readUint16BE 的方法,这才明白其实这是个十六进制运算

例如,现在有一个十进制正整数数组,比如是 [12, 34, 56, 78],我想取这个数组的前两个组成一个新的数字,即取 1234 组成 1234,如果写个方法的话可以是这样的

function readInt10(data) {
  return data[0] * Math.pow(10, String(data[1]).length) + data[1]
}
readInt10([12, 34, 56, 78]) === 1234 // => true

然后现在把十进制数组换成十六进制的,还想进行上述操作,readInt10 是为十进制数字准备的,肯定用不了,需要额外为十六进制实现一个 readUint16 方法

// 伪代码
const data = `<Buffer ff d8 ff e0 00 10 4a 46 49 46 00 01 01 00 00 01 00 01 00 ...>`
console.log(readUint16(data) === 0xffd8) // => true

为什么 readUint16 函可以实现这个功能呢?以上述计算来说 0xff 的二进制是 00000000111111110xd8的二进制是 0000000011011000,按照 readUint16 的逻辑,后者与前者左移 8位后的值进行按位或(OR)操作:

1111111100000000
0000000011011000
OR
1111111111011000

得到 1111111111011000,转成十六进制正好就是 0xffd8,也是我们期望的值

如果在 nodejs 环境,可以直接换成 buf.readUint16BE,只不过 jpeg-js 不依赖于任何环境,所以自行实现了这个方法

除了那一行位运算之外,这个函数还依赖了一个外部变量 offset,每次函数执行完之后,offset都会自增 2,因为函数对数据量的读取就是2个一组读取的,所以执行此函数就相当于是逐步读取数据流里的数据,然后进行一些操作,最终会把数据流全部读完

继续看 parse 方法,通过 readUint16 得到变量 fileMarker,然后又对这个变量进行了一个判断

if (fileMarker != 0xFFD8) { // SOI (Start of Image)
  throw new Error("SOI not found");
}

又出现一个十六进制数 0xFFD8,不好这次还算良心,给了一行注释,那么就很好理解了

根据规范,图片数据流的十六进制形式的前两位必须是 0xFFD8,相当于是一个标记位,标识图片数据流的开始,如果传入的数据流不是以这个数字开头的,那么就认为这不是一个合法的图片数据流,后续也就没必要继续解析了

根据 Marker 逐步处理

我就想简简单单用nodejs读取个图片的rbga值,怎么就那么难?

如上,规范里写明了很多标记位(Marker),例如标记图片开始位置的 SOI、图片结束位置的 EOI等,这些标记位都是以 0xFF开头的(但后续紧跟着的不能是 00,即 0xFF00不是合法标记位)

fileMarker = readUint16();
while (fileMarker != 0xFFD9) { // EOI (End of image)
  var i, j, l;
  switch(fileMarker) {
    // ...
  }
  fileMarker = readUint16();
}

通过 while循环每次调用 readUint16来往后遍历数据流,while 循环的退出标记也就是上面提到的 EOI,重点是里面的switch语句

switch 内的第一个case,如果发现是 0xFF00,那么 break,因为这不是合法标记位 接下来是一个 case 序列,从 0xFFE00xFFEF属于一类,被称为 Application Specific,按照规范里的解释,是 程序保留字段,实际指的是它们不是解码 JPEG 文件必须的,但可以被用来存储图片相关的其他信息,例如图片的属性信息和拍摄数据等,也就是 EXIF

最后,0xFFFECOM,即注释信息

case 的内容就是根据这些 Marker 的意义对它们进行处理,细节就不看了,都是 MT081E.DOC 中的内容

下一个 case 处理 0xFFDB,这个数字的注释是 DQT (Define Quantization Tables),量化表,这个东西跟图片领域不是耦合关系,它根据人的视觉和压缩图像类型的特点进行优化的量化系数矩阵,滤掉那些总体上对图像不重要的部分。这一步是JPEG的有损压缩,量化矩阵的值越高,从图像中丢失的信息就越多,从而压缩率越高,同时图像的质量就越差,总之就是一种压缩规则

然后处理 0xFFC00xFFC2,这一步是 DCT变换,还是压缩图像,保证一定质量前提下丢弃图像中对视觉效果影响不大的信息。这一步是图像质量下降的最主要原因

0xFFC4 定义 Huffman 表(霍夫曼表)DHT,还是压缩图像

后面就不一一说了,总之都是对读取到的数据,根据对应的 marker 进行不同的操作,当遍历完数据流后,也就完成了对数据量的处理,得到一些数据,例如图片宽、高等,其中有个 components属性,这个属性的值是个数组,记录图片每个通道的信息,对于灰阶、3阶、4阶图片,其分别有一个、三个、四个子项 每个子项有 3个属性

this.width = frame.samplesPerLine;
this.height = frame.scanLines;
this.jfif = jfif;
this.adobe = adobe;
this.components = [];
for (var i = 0; i < frame.componentsOrder.length; i++) {
  var component = frame.components[frame.componentsOrder[i]];
  this.components.push({
    lines: buildComponentData(frame, component),
    scaleX: component.h / frame.maxH,
    scaleY: component.v / frame.maxV
  });
}

lines 跟上面各种压缩变换相关,经过处理后的数据信息记录在此,scaleXscaleY我没看明白,猜测是压缩比率之类的东西

数据整合

var channels = (opts.formatAsRGBA) ? 4 : 3;
var bytesNeeded = decoder.width * decoder.height * channels;

channels通道值,rgb 是三通道,rgba 是四通道,默认 4,然后根据通道值,以及 parse方法中计算得到的图片长宽尺寸,得到总计的字节大小

try {
  JpegImage.requestMemoryAllocation(bytesNeeded);
  var image = {
    width: decoder.width,
    height: decoder.height,
    exifBuffer: decoder.exifBuffer,
    data: opts.useTArray ?
      new Uint8Array(bytesNeeded) :
      Buffer.alloc(bytesNeeded)
  };
  if(decoder.comments.length > 0) {
    image["comments"] = decoder.comments;
  }
} catch (err) {
  // ...
}
decoder.copyToImageData(image, opts.formatAsRGBA);

return image;

用计算得到的 bytesNeeded申请内存,不是真的申请,js没法手动申请内存,这里只是校验下字节尺寸,避免占用内存超限

image有个 data属性,其值类型依赖于 useTArray,是外部传入的一个参数,此值默认 false,代表最终输出的图片数据展示形式是 16进制 buffer流,否则就转换为 Uint8Array类型的 rgba 十进制数组值输出,此时的 data 值并不是最终值,只是占个内存位

function copyToImageData(imageData, formatAsRGBA) {
  var width = imageData.width, height = imageData.height;
  var imageDataArray = imageData.data;
  var data = this.getData(width, height);
  var i = 0, j = 0, x, y;
  var Y, K, C, M, R, G, B;
  switch (this.components.length) {
    case 1:
      for (y = 0; y < height; y++) {
        for (x = 0; x < width; x++) {
          Y = data[i++];

          imageDataArray[j++] = Y;
          imageDataArray[j++] = Y;
          imageDataArray[j++] = Y;
          if (formatAsRGBA) {
            imageDataArray[j++] = 255;
          }
        }
      }
      break;
    // ...
  }
}

copyToImageData 会通过调用 getData 获取到图片的 rgba数据组成的数组,然后再根据当前图片是灰阶还是3阶或 4阶,来对 r、g、b、a四个位置按照数组顺序进行赋值

function getData(width, height) {
  // ...
  var scaleX = this.width / width, scaleY = this.height / height;
  var offset = 0;
  var dataLength = width * height * this.components.length;
  var data = new Uint8Array(dataLength);
  // ...
  switch (this.components.length) {
    case 1:
      component1 = this.components[0];
      for (y = 0; y < height; y++) {
        component1Line = component1.lines[0 | (y * component1.scaleY * scaleY)];
        for (x = 0; x < width; x++) {
          Y = component1Line[0 | (x * component1.scaleX * scaleX)];

          data[offset++] = Y;
        }
      }
      break;
    case 2:
      // PDF might compress two component data in custom colorspace
      // ...
      break;
    case 3:
      // ...
      break;
    case 4:
      // ...
      break;
    default:
      throw new Error('Unsupported color mode');
  }
  return data;
}

遍历了 this.components,也就是逐通道处理数据,这里 this.components.length 可能的值还包括了 2,我们知道没有 2通道的图片,所以这里也注释了一下 PDF might compress two component data in custom colorspace,不过这个 PDF 肯定不是指的一般的 .pdf后缀的文件

switch 的主体逻辑,就是根据通道的不同,结合 component上的 linesscaleXscaleY 信息,计算出 rgba值,也就是给 image.data 实际赋值

小结

以上只是简单分析了下从图片数据流中解码 rgba数据的大概步骤,其中涉及到的许多专业知识细节,目前都不是本人所能理解的,而且这还是只是针对 jpeg 这一种格式的,除此之外,还有 pnggif 等,想自己从头实现都不太现实,不过感谢开源世界,非专业人士装包就能解决了

参考资料

转载自:https://juejin.cn/post/7197300642061238332
评论
请登录