likes
comments
collection
share

JPG图片的编码与解码JS代码实现

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

JPG

jpg图片是在网络上非常常见的图片格式,优点:文件大小较适中;适合储存照片和其他复杂图像。缺点:不支持透明度,可能会在压缩过程中丢失一些细节。最普遍使用的扩展名格式为.jpg,其他常用的扩展名还包括.JPEG、.jpe、.jfif以及.jif。JPEG格式的资料也能受嵌进其他类型的文件格式中,像是TIFF类型的文件格式。

为什么说JPG是有损压缩

因为JPG图片在传输的过程中用到了离散余弦变换,编码的时候需要将像素离散余弦变换,解码的时候使用反离散余弦变换,在这个过程中会损失一些数据。所以说JPG图片是一个有损压缩。下面我们探讨一下JPG的编码解码过程。(以下文案内容主要取自维基百科

编码

色彩空间转换

首先,影像由RGB(红绿蓝)转换为一种称为YUV的不同色彩空间。这与模拟PAL制式彩色电视传输所使用的色彩空间相似,但是更类似于MAC电视传输系统运作的方式。但不是模拟NTSC,模拟NTSC使用的是YIQ色彩空间。

  • Y成分表示一个像素的亮度
  • U和V成分一起表示色调与饱和度。

YUV分量可以由PAL制系统中归一化(经过伽马校正)的R',G',B'经过下面的计算得到:

  • Y=0.299R'+0.587G'+0.114B'
  • U=-0.147R'-0.289G'+0.436B'
  • V=0.615R'-0.515G'-0.100B'

这种编码系统非常有用,因为人眼对亮度差异的敏感度高于色彩变化。在此前提下可以设计更加高效压缩图像的编码器(encoder)。

缩减取样

经过RGB到YUV颜色空间的转换后,开始进行缩减采样来减少U和V的成分(称为"缩减取样"或"色度抽样"(chroma subsampling)。在JPEG上这种缩减取样的比例可以是4:4:4(无缩减取样),4:2:2(在水平方向2的倍数中取一个),以及最普遍的4:2:0(在水平和垂直方向2的倍数中取一个)。对于压缩过程的剩余部分,Y、U、和V都是以非常类似的方式来个别地处理。

离散余弦变换和量化

下一步,将影像中的每个成分(Y, U, V)生成三个区域,每一个区域再划分成如瓷砖般排列的一个个的8×8子区域,每一子区域使用二维的离散余弦变换(DCT)转换到频率空间。

具体的离散余弦变换过程和量化过程在下面代码中详细说明。

熵编码技术

熵编码是无损资料压缩的一个特别形式。它牵涉到将影像成分以Z字体(zigzag)排列,把相似频率组群在一起(矩阵中往左上方向是越低频率之系数,往右下较方向是较高频率之系数),插入长度编码的零,且接着对剩下的使用霍夫曼编码。 JPEG标准也允许(但是并不要求)在数学上优于霍夫曼编码的算术编码之使用。然而,这个特色几乎很少获使用,因为它被专利所涵盖,且它相较于霍夫曼编码在编码和解码上会更慢。使用算术编码一般会让文件更小约5%。

JPG图片的编码与解码JS代码实现

对于对一个矩阵进行Z字体序列会是:

JPG图片的编码与解码JS代码实现

转化后

−26,

−3, 0,

−3, −2, −6,

2, −4, 1, −3,

1, 1, 5, 1, 2,

−1, 1, −1, 2, 0, 0,

0, 0, 0, −1, −1, 0, 0,

0, 0, 0, 0, 0, 0, 0, 0,

0, 0, 0, 0, 0, 0, 0,

0, 0, 0, 0, 0, 0,

0, 0, 0, 0, 0,

0, 0, 0, 0,

0, 0, 0,

0, 0,

0

当剩下的所有系数都是零,对于过早结束的序列,JPEG有一个特别的霍夫曼编码用词。使用这个特殊的编码用词,EOB,该序列变为

−26,

−3, 0,

−3, −2, −6,

2, −4, 1 −3,

1, 1, 5, 1, 2,

−1, 1, −1, 2, 0, 0,

0, 0, 0, −1, −1, EOB

至此jpeg的编码就结束了,我们可以看到在大致过程:色彩空间转换 -> 缩减采样 -> 离散余弦变换 -> 量化 -> 熵编码技(压缩主要发生在这里,大概5%)

解码

解码来显示影像,包含反向作以上所有的过程,具体流程看下面代码

代码演示

如果我们想要在在JS里操作图片,首先想到的是使用canvas,先写一端代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .container {
        display: flex;
        justify-content: center;
      }

      .container canvas {
        margin: 0 20px;
      }
    </style>
  </head>
  <body>
    <div class="container">
      <!-- 展示原图片 -->
      <canvas id="origin"></canvas>
      <!-- 展示编码,解码过程的图片 -->
      <canvas id="target"></canvas>
    </div>
  </body>
</html>

再实现一下JS代码

// 使用Promise加载图片
function loadImg(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = function () {
      resolve(this);
    };
    img.onerror = function (e) {
      reject(e);
    };
    img.src = src;
  });
}

function RGBA2YUV(R, G, B) {
  const Y = 0.299 * R + 0.587 * G + 0.114 * B;
  const U = -0.169 * R - 0.331 * G + 0.5 * B + 128;
  const V = 0.5 * R - 0.419 * G - 0.081 * B + 128;
  return [Y, U, V];
}

function YUV2RBG(Y, U, V) {
  const R = Y + 1.403 * (V - 128); //
  const G = Y - 0.343 * (U - 128) - 0.714 * (V - 128);
  const B = Y + 1.77 * (U - 128);
  return [R, G, B, 255];
}

// 定义离散余弦变换函数
const Hash = {};

const getCoff = (index, length) => {
  if (!Hash[length]) {
    let coff = [];
    coff[0] = 1 / Math.sqrt(length);
    for (let i = 1; i < length; i++) {
      coff[i] = Math.sqrt(2) / Math.sqrt(length);
    }
    Hash[length] = coff;
  }
  return Hash[length][index];
};

const DCT = (signal) => {
  const L = signal.length;
  let tmp = Array(L * L).fill(0);
  let res = Array(L)
    .fill("")
    .map(() => []);
  for (let i = 0; i < L; i++) {
    for (let j = 0; j < L; j++) {
      for (let x = 0; x < L; x++) {
        tmp[i * L + j] +=
          getCoff(j, L) *
          signal[i][x] *
          Math.cos(((2 * x + 1) * Math.PI * j) / 2 / L);
      }
    }
  }
  for (let i = 0; i < L; i++) {
    for (let j = 0; j < L; j++) {
      for (let x = 0; x < L; x++) {
        res[i][j] =
          (res[i][j] || 0) +
          getCoff(i, L) *
            tmp[x * L + j] *
            Math.cos(((2 * x + 1) * Math.PI * i) / 2 / L);
      }
    }
  }
  return res;
};

const IDCT = (signal) => {
  const L = signal.length;
  let tmp = Array(L * L).fill(0);
  let res = Array(L)
    .fill("")
    .map(() => []);
  for (let i = 0; i < L; i++) {
    for (let j = 0; j < L; j++) {
      for (let x = 0; x < L; x++) {
        tmp[i * L + j] +=
          getCoff(x, L) *
          signal[i][x] *
          Math.cos(((2 * j + 1) * x * Math.PI) / 2 / L);
      }
    }
  }
  for (let i = 0; i < L; i++) {
    for (let j = 0; j < L; j++) {
      for (let x = 0; x < L; x++) {
        res[i][j] =
          (res[i][j] || 0) +
          getCoff(x, L) *
            tmp[x * L + j] *
            Math.cos(((2 * i + 1) * x * Math.PI) / 2 / L);
      }
    }
  }
  return res;
};

const Yquantify = [
  [16, 11, 10, 16, 24, 40, 51, 61],
  [12, 12, 14, 19, 26, 58, 60, 55],
  [14, 13, 16, 24, 40, 57, 69, 56],
  [14, 17, 22, 29, 51, 87, 80, 62],
  [18, 22, 37, 56, 68, 109, 103, 77],
  [24, 35, 55, 64, 81, 104, 113, 92],
  [49, 64, 78, 87, 103, 121, 120, 101],
  [72, 92, 95, 98, 112, 100, 103, 99],
];

const UVquantify = [
  [17, 18, 24, 47, 99, 99, 99, 99],
  [18, 21, 26, 66, 99, 99, 99, 99],
  [24, 26, 56, 99, 99, 99, 99, 99],
  [47, 66, 99, 99, 99, 99, 99, 99],
  [99, 99, 99, 99, 99, 99, 99, 99],
  [99, 99, 99, 99, 99, 99, 99, 99],
  [99, 99, 99, 99, 99, 99, 99, 99],
  [99, 99, 99, 99, 99, 99, 99, 99],
];

(async () => {
  const img = await loadImg("/images.jpeg");

  const origin = document.getElementById("origin");
  const target = document.getElementById("target");

  const originCtx = origin.getContext("2d");
  const targetCtx = target.getContext("2d");

  // 因为测试用例的图片是300 * 224, 宽度不是8的倍数, 设置成8的倍数好计算
  const [width, height] = [
    img.width - (img.width % 8),
    img.height - (img.height % 8),
  ];

  origin.width = width;
  origin.height = height;
  target.width = width;
  target.height = height;

  // 将图片绘制在origin
  originCtx.drawImage(img, 0, 0, width, height);
  console.log(width, height);
  const imgPixel = originCtx.getImageData(0, 0, width, height);

  // 因为ImageData是rgba的数据结合,每4位表示一个像素点的rgba值
  const fullRgba = Array.from(imgPixel.data);
  const rgba = [];
  for (let i = 0; i < fullRgba.length / 4; i++) {
    const [r, g, b, a] = fullRgba.slice(i * 4, i * 4 + 4);
    const [y, u, v] = RGBA2YUV(r, g, b);
    rgba.push({ i, r, g, b, a, y, u, v });
  }

  // 将图片的像素切分成8*8像素的矩阵
  const pixel8 = [];
  for (let i = 0; i < height / 8; i++) {
    for (let j = 0; j < width / 8; j++) {
      const s = Array.from({ length: 8 }).map((_, index) => {
        return rgba.slice(
          i * width * 8 + j * 8 + index * width,
          i * width * 8 + j * 8 + index * width + 8
        );
      });
      pixel8.push(s);
    }
  }

  const encodeDecode = pixel8.map((item) => {
    // 编码 - 离散余弦变换
    // 将影像中的每个成分(Y, U, V)生成三个区域,每一个区域再划分成如瓷砖般排列的一个个的8×8子区域,每一子区域使用二维的离散余弦变换(DCT)转换到频率空间。
    // 推移128,使其范围变为 -128~127
    const YS = item.map((r) => r.map((c) => c.y - 128));
    const US = item.map((r) => r.map((c) => c.u - 128));
    const VS = item.map((r) => r.map((c) => c.v - 128));

    // 且接着使用离散余弦变换,和舍位取最接近的整数
    const YSDCT = DCT(YS).map((v, i) =>
      v.map((q, j) => Math.round(q / Yquantify[i][j]))// 使用这个量化矩阵Yquantify与前面所得到的DCT系数矩阵逐项相除
    );
    const USDCT = DCT(US).map((v, i) =>
      v.map((q, j) => Math.round(q / UVquantify[i][j]))// 使用这个量化矩阵UVquantify与前面所得到的DCT系数矩阵逐项相除
    );
    const VSDCT = DCT(VS).map((v, i) =>
      v.map((q, j) => Math.round(q / UVquantify[i][j]))// 使用这个量化矩阵UVquantify与前面所得到的DCT系数矩阵逐项相除
    );

    // 因为这里主要展示的是编码 解码的过程,所以省略掉 熵编码的过程,直接进行解码

    // 解码
    // 取DCT系数矩阵,且以前面的量化矩阵乘以它,再使用反向DCT得到一个有数值的影像,最后对每一项加上128
    // 主要的损失就发生在这里,在进行反向DCT的时候矩阵中数值越大,差异就越大,差异主要来自反向DCT的过程。
    const YSIDCT = IDCT(
      YSDCT.map((v, i) => v.map((q, j) => q * Yquantify[i][j]))
    ).map((r) => r.map((c) => c + 128));
    const USIDCT = IDCT(
      USDCT.map((v, i) => v.map((q, j) => q * UVquantify[i][j]))
    ).map((r) => r.map((c) => c + 128));
    const VISDCT = IDCT(
      VSDCT.map((v, i) => v.map((q, j) => q * UVquantify[i][j]))
    ).map((r) => r.map((c) => c + 128));

    return item.map((r, i) =>
      r.map((c, j) => ({
        ...c,
        _y: YSIDCT[i][j],
        _u: USIDCT[i][j],
        _v: VISDCT[i][j],
      }))
    );
  });

  // 将8*8的矩阵转化回像素
  const decode = [];
  for (let i = 0; i < height / 8; i++) {
    const row8 = encodeDecode.slice((i * width) / 8, ((i + 1) * width) / 8);
    const s = Array.from({ length: 8 })
      .map((c, i) => row8.map((a) => a[i]).reduce((s, a) => [...s, ...a], []))
      .reduce((s, a) => [...s, ...a], []);
    decode.push(...s);
  }

  const targetPixel = decode
    .map((i) => {
      const [r, g, b] = YUV2RBG(i._y, i._u, i._v);
      return {
        ...i,
        _r: r,
        _g: g,
        _b: b,
      };
    })
    .map((b) => [b._r, b._g, b._b, 255])
    .flat(1);

  // 将转化后的图像绘制在canvas上
  targetCtx.putImageData(
    new ImageData(Uint8ClampedArray.from(targetPixel), width, height),
    0,
    0,
    0,
    0,
    width,
    height
  );
})();

以上就是使用JS实现的JPEG大致的编码,解码过程。

具体的效果可以访问仓库来查看:仓库链接

需要注意的是:

JPEG标准中许多选项很少使用,大多数图像软件在创建JPEG文件时使用更简单的JFIF格式。当应用到一个拥有每个像素24位(24 bits per pixel,红、蓝、绿各有八比特)的输入时,这边只有针对更多普遍编码方法之一的简洁描述。这个特定的选择是一种失真资料压缩方法。

文章最先发布在xzgz.top,欢迎来到我的网站上玩,可以体验AI对话等功能。

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