Day8 - 2D渲染环境基础篇 IV[像素操作概论] - 成为Canvas Ninja ~ 理解2D渲染的精髓

『像素操作(Pixel Manipulation)』 顾名思义就是要去以单一像素为最小单位来进行操作,而就像我们透过JS改变DOM结构所进行的『DOM操作』一样。

canvas 像素操作起手式

上面我们提到了要做像素操作,就必须先取得像素。

就像DOM操作一样,在操作DOM的时候我们通常要先抓取(query)到目标dom元素,然後才可以接着做append/prepend/setAttribute之类的事情。

而像素操作的第一步就是要先取得canvas的像素数据(Image Data)。

let imageData = ctx.getImageData(sx, sy, sw, sh);
// sx: 想要取得的图像区域的左上角x轴座标值
// sy: 想要取得的图像区域的左上角y轴座标值
// sw: 想要取得的图像区域的宽度
// sh: 想要取得的图像区域的高度

何谓像素数据(Image Data)

我们在前面有提到过,canvas可以被视为一群像素的集合体,而每一个像素本身是由4个channel值所组成的。

『一张宽度100px, 高度100px的canvas,它实际上就是100*100 = 10000个像素的集合体,而同时在程序上我们则可以把它转换成一个长度为100*100*4的阵列(也就是一共40000个channel值)。』 --- 来自我们前面提过的内容

当我们用ctx.getImageData去取得完整一张canvas(sx,sy定为0, sw, sh定为canvas宽高)的imageData时,我们实际上会取得一个含有全部像素channel值的Uint8ClampedArray(8位元无符号整型固定阵列)

这边我们透过console.log去检验一组由100px x 100px 大小canvas所提取的imageData

img

codepen连结: https://codepen.io/mizok_contest/pen/powKopj

简单观察一下首先可以发现, Uint8ClampedArray其实只是imageData的一部分(imageData.data),其余还会有height/width等属性,imageData本身具备独立的型别,就像String/Array 那样,他不只单纯是个物件而已。

有关於ImageData这个型别相关的资讯可以看这边

然後接着看看Uint8ClampedArray的部分,可以发现他确实就是由全部像素的channel值所组成;由於我们填入的颜色是红色(255,0,0,1),所以channel值的分布会是255,0,0,255这样四个一组持续到结束的组合.....,这边值得注意的一点是Uint8ClampedArray是以0到255来表示alpha channel的值,而不是0到1,那是因为8位元的关系(2的8次方是256, 而0~255刚好是256个数字)。

人类的眼睛大约只可分辨 1,000 万种颜色,之所以channel值是用8位元阵列来表示,是因为256的3次方(rgb三原色)为16,777,216 , 这个数字恰好落在1000万的level。

理解了ImageData的资料格式之後,接着可能就会有人问:

我们有没有办法从零自己建立一组新的ImageData?

Sure, 当然是可以的,而且方法还不只一种。

一般要自己create 新的ImageData,可以依靠:

  • 2D渲染环境底下的createImageData方法(ctx.createImageData)
  • ImageData class的 constructor (支援性低)

这两种方法的最大差别就在於前者需要编译环境下有2DContext存在,但是後者就是可以直接New一个物件出来(适用在部分非浏览器环境,另外IE不支援这方法)。

自己建立出来一个ImageData物件之後,接着可能就会有人再问:

那要怎麽把建立出来的ImageData 放到Canvas渲染出来?

这时候就该ctx.putImageData登场了~

void ctx.putImageData(imageData, dx, dy);
void ctx.putImageData(imageData, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
// dx: 置放该ImageData渲染区的座标X值(置放在目标canvas上的位置)
// dy: 置放该ImageData渲染区的座标Y值(置放在目标canvas上的位置)
// dirtyX: 可以只渲染该ImageData的一部分, 这个值就是用来定义渲染区的起始座标X值(这个值是相对於该ImageData的0,0圆点而言)
// dirtyY: 可以只渲染该ImageData的一部分, 这个值就是用来定义渲染区的起始座标Y值(这个值是相对於该ImageData的0,0圆点而言)
// dirtyWidth: 可以只渲染该ImageData的一部分, 这个值就是用来定义渲染区的宽度
// dirtyHeight: 可以只渲染该ImageData的一部分, 这个值就是用来定义渲染区的高度

介绍完基本的ImageData API,我们接着来看一个蛮经典的像素操作案例~

经典的像素操作案例解析 - 拼字图画(Image To Ascii)

所谓的拼字图画就是像下图这种,把图像变成不同符号所形成的一幅图

img

这边我分成几个主要步骤稍微描述一下拼字图画的程序逻辑

  • 用ctx.drawImage() 先把指定的图片绘制到canvas上

  • 从绘制好图像的canvas上取得imageData

  • 透过把imageData的每个像素点channel值总合取平均来将图像转为灰阶

  • 在回圈中根据灰阶图像的imageData来把不同的channel值转换为特定符号(例如0到50以@代替,51到100以#代替),然後把这些符号作为字串植入pre元素,另外要注意,因为这边是把图像转换成字串,所以每次loop到图像最右边缘的像素时,要记得补一个换行符号"\n"

接着是源码:

// 取得图像载入promise
function loadImage(src){
    let img = new Image();
    // 把resolve暴露给外部变数
    let resolve;
    let loadPromise = new Promise((res)=>{
         resolve = res;
    })
    // 这一步cross-origin是因为我们的图片是外部来源
    // 如果没有把外部来源设置为"Anonymous",drawImage方法会排除掉非本地来源的图片资讯, 导致无法进行下一步绘图
    img.crossOrigin = "Anonymous";
    img.onload = ()=>{
       resolve(img);
    }
    img.src = src;
  
    return loadPromise;
}

async function getImageDataFromImage(src,ratio = 0.5){
  // 这边有一个ratio参数是因为我读取的图片稍微有点大张
  // 所以我补一个参数让我可以自己决定要把图片缩小多少倍率
  const img = await loadImage(src);
  const width = img.width * ratio;
  const height = img.height * ratio;
  // 把外部图源绘制到架空的canvas上面然後取得imageData
  const cvs = document.createElement('canvas');
  const ctx = cvs.getContext('2d');
      cvs.width = width;
      cvs.height = height;
      ctx.drawImage(img,0,0,width,height);
  const imageData = ctx.getImageData(0,0,width,height);
  
  return imageData;
}

async function turnImageDataIntoGrayscale(src){
   const imageData = await getImageDataFromImage(src);
   const data = imageData.data;
  // 这边这个loop的用意就在於把channel值依像素顺序来执行程序
   for(let i=0;i<data.length;i = i+4){
     const r = data[i];
     const g = data[i+1];
     const b = data[i+2];
     //取得rgb值得平均, 如此一来因为rgb都变成同一个数值
     // 图像就会变成灰阶图
     const average = (r+g+b)/3
     data[i] = data[i+1] = data[i+2] = average;
   }
  return imageData;
}

async function redrawAsASCII(src){
  const grayscaleImageData = await turnImageDataIntoGrayscale(src);
  // 取用的样版字元
  const glyphSource = "$@#*。";
  let stringOutput = '';
  for(let i = 0;i<grayscaleImageData.data.length;i = i+4){
     
     const pixelIndex = Math.ceil(i/4);
     // 从像素的次序来判断该像素是否为右边缘像素
     const pixelIsRightRimPixel = (pixelIndex + 1) % grayscaleImageData.width === 0;
     // 根据像素的灰阶值, 用内插的方式来决定要使用哪一个样版字元来代表该像素
     const glyphIndex = 
       Math.floor(grayscaleImageData.data[i] / 255 * (glyphSource.length-1));
       
       stringOutput+=glyphSource[glyphIndex];
    
     if(pixelIsRightRimPixel){
       // 如果是最右边缘像素, 则另外补一个换行符号
        stringOutput+='\n';
     }
  }
  // 把字串填入pre tag
  const text = document.querySelector('pre');
  text.innerHTML = stringOutput;
}




(()=>{
  redrawAsASCII('https://i.imgur.com/52TLlOk.png');
})()

codepen连结:https://codepen.io/mizok_contest/pen/vYZrXYP

小结

老实说我在挑选展示像素操作案例的时候犹豫了很久,最後还是决定要拿拼字图画来作为案例介绍。
主要是因为我觉得这个案例相较於其他的例子似乎更能让人提起兴趣(虽然对初学者来说可能有点小复杂)。

在上面这个案例中,其实可以学到很多的小技巧,包括:

  • canvas载入图片的机制
  • imageData的边缘像素处理
  • 将channel值依像素顺序来执行回圈程序
  • 如何把一个彩色的ImageData转换成灰阶
  • 简易线性插值(Lerp)的用法
  • ...

这些小技巧在这个系列文的中後段都会持续用到,所以建议可以仔细读一下源码里面的注解~

这边我们介绍的『拼字图画』其实还只是很基本的一种像素操作运用案例。

像素操作真正被广泛运用(同时也更复杂)的地方 -- 影像处理(Image Processing)领域,我们将会在稍後的篇章再继续提到这部分,敬请各位期待~。


<<:  Day8 Function and Interface

>>:  Day 10 : 存放资料的收纳库-串列资料(下)

Day 08 - 那些在 component 里的 Props 与 State

如果有错误,欢迎留言指教~ Q_Q Stateful Component vs no-Statef...

[Day17] NLP会用到的模型(二)-LSTM

一. RNN会造成的问题 前一天看过了RNN的训练流程,他是非常长一串,若今天我们需训练一个非常长的...

Consistency and Consensus (4-1) - Atomic Commit and Two-Phase Commit(2pC)

分散式 transaction 和共识 (Distributed Transactions and ...

用 Line LIFF APP 实现信箱验证绑定功能(2) - 使用 Vite 快速打造输入页面

昨天提到,LIFF APP 有可能因为使用者没有绑定 email,或是不授权 email 使用导致无...

【资料结构】DFS与BFS的追踪

DFS与BFS的追踪 图一 DFS(深度追踪) 说明: 以图一为例,当起点设为0时,会不断往下深入,...