【掘破苍穹】只需要肤浅的前端知识,制作属于你的“像素风修仙图”!

3,351 阅读8分钟

📖阅读本文

你将能:

  1. 了解通过 canvas 加载图片的基本用法
  2. 了解通过 canvas 处理图片的基本原理
  3. 学废一种或多种通过 canvas 生成像素图的思路
  4. 理解第3条中各种思路在生成的图片上存在的优劣对比
  5. 获得满是bug的源码一份

起因:掘破苍穹!

其实掘金社区很有修仙感
任何人都可以通过 不断修行(发帖和被点赞) 来让自己的 掘力值 得到提升。
因此,每当我的 掘力值+100 后我都会和群友们一起分享我的喜悦:

"各位道友!我刚刚突破了练气三重境!我命由我不由天...."

燃起来了.... 好了,言归正传,因此我想做一个属于自己的 像素风 修仙图。
成品如下:
像素修仙
当你看到这张图的时候,也许你的心里会有疑问:
那么,我一个基本不会PS,又没什么图像处理经验的小菜鸟,是怎么完成这个像素图的呢?
下面,请跟着我一步一步,看看我都经历了什么。

一、 如何通过 canvas 加载图片?

canvas 是浏览器中的图像处理大师,可以用来做各种和"图像"相关的事情,加载图片只是其众多能力中最为基础的一种,如下:
test.html

<style>
#canvas {
  width: 800px;
  height: 800px;
}
</style>
<canvas id="canvas" height="800" width="800"></canvas>
<script>
  const img = new Image();   // 创建一个<img>元素
  img.onload = function(){
    const canvas = document.getElementById('canvas'); // 获取画布元素
    const ctx = canvas.getContext('2d'); // 获取2d上下文
    ctx.drawImage(img, 0, 0) // 绘制图片
  }
  img.src = './jk.jpg'; // 设置图片源地址
</script>

效果:

按此写法可以轻松完成任何一张图片在 canvas 上的绘制。

二、 如何通过 canvas 处理图片?

图片处理 是一个深不见底的话题,如何高品质压缩、如何还原图片、甚至于如何一键换脸,以及配合上人工智能后的各种玩法,都让人惊掉下巴。而我只是一只前端菜鸡,因此本文只介绍 图片处理 的基本原理。

2.1 获取并理解图片数据:ImageData

const imageData = ctx.getImageData(0, 0, 800, 800);
// imageData是一个 ImageData 对象

执行上面这段代码,可以获得一个 ImageData 对象; (# MDN-ImageData)。
简单来说,ImageData对象有三个只读属性,分别是:

  • width: 像素宽度(选定区域)
  • height: 像素高度(选定区域)
  • data:(重要) 一个 Uint8ClampedArray 格式的一维数组,用以记录图片每一个像素的信息。

其中对 data 数据结构的理解,直接关系到我们后续图片处理的成败

打个比方,如果你有一张 2*2 像素的图片,那么 data 则是一个长度为 16 的一维数组。( 数组长度 = 宽 * 高 * 每个像素rgba的4个值 ).
如图:
ImageData-data
这个一维数组的第 0,1,2,3 这四个下标的值,用以描述第一个像素点,分别代表了 r,g,b,a 四个值,不过 a255 对应 alpha = 1 , 0 则对应 alpha = 0
这个规则不算复杂,但非常重要!后续的各种处理,都依赖这个规则。

2.2 一次简单的数据处理尝试

如果我们有了图片的 ImageData ,我们就可以通过处理其中每个点的数据,并重新让 canvas 渲染图片,完成对图片的渲染。在之前那个例子的 onload 方法里增加如下代码:

  img.onload = function(){
      // ...之前的代码
      const imageData = ctx.getImageData(0, 0, 800, 800); // 获取整张图片的图像信息
      const imagePixels = imageData.data // 一维数组
      const newPixels = new Uint8ClampedArray( 800 * 800 * 4 ); 
      // 上面创造了一个新的一维数组,和老数组长度一致,数组长度 = 宽 * 高 * 每个像素rgba的4个值
      for(let i = 0; i < imagePixels.length; i+= 4) { // 步长为4,因为4个值代表一个像素
        // 将图片的rgb值进行反转
        newPixels[i] = 255 - imagePixels[i]
        newPixels[i+1] = 255 - imagePixels[i+1]
        newPixels[i+2] = 255 - imagePixels[i+2]
        newPixels[i+3] = 255 // alpha值保持255,不透明
      }
      const newImageData = new ImageData(newPixels, 800)
      ctx.putImageData(newImageData, 0, 0)
    }

效果如下:

虽然效果有点阴间,但我们确实对图片进行了像素级的处理,不是吗?

2.3 选择性地处理像素

将上面代码中 for 循环的部分进行一些替换:

    newPixels[i+3] = 255 // alpha值保持255,不透明
    if (imagePixels[i] < 60 && imagePixels[i+1] < 60 && imagePixels[i+2] < 60) {
      // 只对颜色深到一定程度的进行处理
      newPixels[i] = 255
      newPixels[i+1] = 255
      newPixels[i+2] = 255
    } else {
      newPixels[i] = imagePixels[i]
      newPixels[i+1] = imagePixels[i+1]
      newPixels[i+2] = imagePixels[i+2]
    }

再看看效果:

发现了吗?
图片整体的颜色没有被影响,只对部分"头发"和"裙子"进行了"漂白"。这就是比"色彩反转"更精细化的图像处理了(虽然还是糙得不像话)。

2.4 著名的图像处理之"灰度图"

做图像识别和图形处理的同学,对"灰度图"一定是不陌生的,经典的灰度公式也是著名到出圈: R*0.299 + G*0.587 + B*0.114
把上面代码的 for 循环内的部分改成如下:

  newPixels[i+3] = 255 // alpha值保持255,不透明
  //  R*0.299 + G*0.587 + B*0.114
  const gray = Math.floor(imagePixels[i]*0.299 + imagePixels[i+1]*0.587 + imagePixels[i+2]*0.114)
  newPixels[i] = newPixels[i+1] = newPixels[i+2] = gray

通过灰度公式,将 r/g/b 都转换为同样的一个数值,这样就能将图片变成"灰度图"。

2.4 经典的图像处理之"高斯模糊"

高斯模糊的像素处理思路其实非常简单:每一个像素都取它周围像素点的均值

如上图所示是一个 半径为1 的高斯矩阵的计算图示 ,如果一个 r = 0 的点被8个 r = 255的点包围,在完成高斯模糊的处理后,它将被算换为 r=255,这样它会丢失自己的真值,产生模糊效果。
将上面代码 img.onload 里的内容改写:

  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0)
  const width = 400; // 要处理的宽度,图片宽度的一半
  const height = 800; // 要处理的高度
  const imageData = ctx.getImageData(0,0,width,height);
  const newImageData = gaussBlur(imageData) // 利用高斯矩阵计算模糊,过长,不在此处放源码
  // gaussBlur的源码在此:https://github.com/zhangshichun/vue3-demos-for-my-blog/blob/main/src/demos/pixi-tool/helper/Gauss.js
  ctx.putImageData(newImageData, 0, 0)

这样就完成了 左半张图 半径为10的高斯模糊。

OK,关于图像处理的基础就学到这里,由于这块太过于博大精深,就不再展开讲了。有兴趣的同学可以进行深入学习....

三、"像素化"的基本思路

3.1 "像素化"的核心是减法

因为篇幅原因,本文不再赘述什么是 像素风 ,但不得不讨论到"像素风的核心是减法"这一核心事实。

在古早时期,像素风游戏 的产生原因是为了用更少的性能 来构建游戏。在其成为一种美术风格之后,这种 减法 的特性并没有从其美术特性上消失。
总结而言,像素风有以下特点:

  1. 更少的像素。通常 32*32 或者 64*64 的稀少像素,就足够一位优秀的像素画师构建出优秀的画面了。上面这张马里奥,只需要 16*16 个像素点,就勾勒出了一个 中年大叔 形象。
  2. 更少的色彩。像素风一直以来都是 爱惜色彩 到了极致的特点,哪怕要表现层次,也不愿意画面上铺满杂乱繁多的色彩。
  3. 高度的抽象。这是 像素风 最大也是最难模仿的特点,夸张的比例,墙裂的抽象,是 像素写实 之间难以跨越的鸿沟。

3.2 像素减法:像素合并

为了让图片成为 像素风 ,让像素大量减少是必不可少的步骤,一张800*800的照片上有6400个像素点,它们看起来连续且真实,是完全违背像素风意图的。
在做减法的同时,也有两种不同的思路:

3.2.1 让像素变少

这是最直观的思路,既然要让像素变少,我们把 800*800 像素的图片变成 200 * 200 不是就完成了这个目标吗?

(通过降低实际像素数来做像素减法)
这当然正确,但却会产生在高清设备上展示难度加大的问题。

3.2.1 让代码块变大

如上图 马里奥 图片为例,虽然它看起来是 16*16 的像素风图片,但它其实是 256 * 256 像素的一张图片。
之所以达成 像素风 的效果,是因为它以每 16*16 的像素作为一个 纯色像素块 来使用,保证画面里的 25616*16 大小的像素块,每一个都是完全的纯色,从而实现了这一美术效果。

(增加像素块半径,从而实现像素减法)
这也是现阶段 像素风 题材创作的首选。

同时,本文后续所有的像素化手段都将基于第二种 增加像素块半径 的方式来实现。

四。图片像素化的5步尝试

任务:像素化一张图。

看到这张 256*256 修仙的图了吗?今天我的目标就是要整一张 像素修仙图,如果最后有成品,就命名为 《掘破苍穹》 吧。
我先写了一个针对本次需求通用的图片处理代码:

// 以下为伪代码,因为真代码有点多
// 真实代码:https://github.com/zhangshichun/vue3-demos-for-my-blog/blob/7b6fa80af65c4fc5912d50e8e10c08d7f42075dc/src/demos/pixi-tool/test.vue#L209

const handlePixelData = (图片信息, 处理器) => {
    像素块宽度 = 真实像素/像素块半径;
    像素块高度 = 真实像素%像素块半径;
    像素块一维数组 = [ 宽度 * 高度 ]<空数组> // 这个一维数组的每个元素都是个空数组
    遍历 ( 真实像素数组 ) {
      把 像素 推入 相应像素块内
    }
    处理后的像素块数组 = 像素块一维数组.map(处理器)
    将 处理后的像素块数组 转换为一维真实像素数组
    return 一维真实像素数组; 
}

之所以要定义一个"处理器",就是为了后续能够 养蛊 尝试和对比各种"处理手段"之间的优劣。

4.1 蛊一:取色彩均值,填充像素块

如果我把 N*N 大小像素块内所有的像素的rgb值分别取平均值,然后再用这个平均值组成的颜色把 N*N 大小的像素块填满,是否能达到目标效果呢?

这是当我决定做像素化时,脑海里蹦出来的第一个想法,因而我还用CSS 的 高斯模糊滤镜 实现了一个简陋的版本。并以这个为主题水了一篇文章。
链接:# 把图片变成“伪像素风”?用CSS贼容易! #

因此,代码也随之有了思路,处理器 代码如下:

// 如果有 N*N 个像素点的一维数组,产出它们平均的像素色。
const getAVG = (arr) => {
  let _r = 0, _g =0, _b =0;
  arr.forEach((t) => {
    _r += t.r;
    _g += t.g;
    _b += t.b;
  })
  // 请不要笑话我这简单粗暴的取均值....
  return {
    r: Math.floor(_r/arr.length),
    g: Math.floor(_g/arr.length),
    b: Math.floor(_b/arr.length),
    a: 255
  }
}

OK,代码写好了,看看效果:

均值像素
咦?完全不是我想要的像素风!
为什么完全没有像素感 ? 反而就像只是把图片打了一层马赛克?
思考后,我找到了答案:
平均值会让页面的色彩无限增多,让画面的边界变成模糊的中值色。

那么?如果我不取平均色,而是取像素块内颜色最多的那一种颜色呢?这样就会在一定程度上减弱 均值 带来的模糊感吧?

4.2 蛊二:取色块内出现频率最多的颜色

遍历 N*N 大小像素块内所有的像素,并取其中出现次数最多的色彩作为此 N*N 色块的填充色。

处理器 实现代码如下:

const getBossColor = (arr) => {
  const genId = (t) => {
    return [t.r,t.g,t.b].join() // 生成`{r},{g},{b}`格式的id,用于计数
  }
  const map = {} // 通过 {id:count}形式记录颜色的计数
  let maxColor = null // 当前出现最多的颜色
  let maxCount = 0 // 当前出现最多的次数
  arr.forEach((t) => {
    const id = genId(t)
    if (map[id] == null) {
      map[id] = 0
    }
    map[id] += 1
    if (map[id] > maxCount) {
      maxColor = t;
      maxCount = map[id]
    }
  })
  return {
    r: maxColor.r,
    g: maxColor.g,
    b: maxColor.b,
    a: 255
  }
}

OK,完成代码,让我们看看效果:

诶?诶?诶?
改善很明显!有没有? 像素感 一下子就出来了,边沿那种 模糊感 消失了,取而代之的是清晰无误的边界和色彩对比
但依然能感觉到明显的缺点:

  1. 背景(光柱和烟雾) 依然没有 像素感
  2. 锯齿严重,且手部细节丢失严重。

要怎么改善呢?
我首先想到了之前说到的 色彩减法 ,也就是说当前画面上的颜色实在太多了,尤其是背景,需要给颜色做个瘦身

4.3 蛊三&四:颜色瘦身计划!

如何快速计算一幅画的主题色?

关于这个问题,业内有非常多的算法,如:中位切分法(Median cut)八叉树算法(Octree)KMeans算法, 色彩建模 等等。
放宽心,以上算法,我——一个都不懂

哈哈,别害怕,虽然不懂,但是有 github 啊~~
我找到了这个 image-color-palette,一位大佬汇总的各类主题色提取算法的汇总。
于是我选取了其中一个名叫 Uniform/One-pass Quantization (均匀单词量化)的算法,作为我的主题色提取器。
下面,我分别对 均值法最大频次法 进行了主题色处理,使它们计算出的结果必须为 M种主题色 中最接近的一种。

let mainColors = [xxx,xx,xxxx] // 这是通过算法取到的若干主题色

const getLessColor = (color) => {
  let index = 0;
  let min = 9999999;
  mainColors.forEach((m, i) => {
    const _r = m.r - color.r
    const _g = m.g - color.g
    const _b = m.b - color.b;
    const DValue = _r*_r + _g * _g + _b *_b // 通过平方的方式避免出现负值影响差值计算
    if (DValue < min) {
      index = i;
      min = DValue
    }
  })
  const mainColor = mainColors[index]
  return {
    ...mainColor,
    a: 255
  }
}

ok,让我们再来看看效果:

可以看到效果:背景的 像素感 非常明显了,那种因为色彩过多导致的模糊感消失了,取而代之是非常有层次的 像素风 烟雾和光柱。
尤其是最后一张,最大频次 + 主题色 的效果让我有点惊喜。
那么还有没有优化的余地呢?

其实我主要是对手部细节丢失感到可惜。

突然我灵机一动,想到一个优化的思路。

4.4 蛊五:周边权重衰减

别想着理解什么是 周边权重衰减 啦,这是我乱取的一个名字。其中心思想是:

N*N 大小的色块中,中心部分出现的色彩的权重应该大于靠近边沿区域出现的色彩的权重。

此思路在处理一些小细节时,就可能可以保留一些细节了。
代码:

// 不仅获取频次最大的颜色,而且权重是从中心向四周衰减的
const getImportantBossColor = (arr) => {
  const genId = (t) => {
    return [t.r,t.g,t.b].join()
  }
  const map = {}
  let maxColor = null
  let maxCount = 0
  arr.forEach((t, index) => {
    const col = index % props.unitPx
    const row = Math.floor(index/props.unitPx)
    const center = props.unitPx/2 // 粗略的取中心
    let baseNumber  = 1 + (center - col)*(center - col) + (center - row)*(center - row) // 1 + 平方和 (1是为了保证不为0,方便后续计算,思路类似勾股定律)
    const score = Math.ceil(props.unitPx / baseNumber) // 权重分和上面的baseNumber成反比 
    const id = genId(t)
    if (map[id] == null) {
      map[id] = 0
    }
    map[id] += score
    if (map[id] > maxCount) {
      maxColor = t;
      maxCount = map[id]
    }
  })
  return {
    r: maxColor.r,
    g: maxColor.g,
    b: maxColor.b,
    a: 255
  }
}

好了,看看效果!

哇哦~~
这一次的效果,让我有点惊喜,因为 手部的细节 得到了更多的呈现,可以更清晰的感知到 修仙者 手部的动作了!

4.5 最后一步

有心人会发现我最开始放的图,比我上面这 六张图 中的任何一张都要清晰,可能想知道我做了什么算法上的优化。
您可别想多了...
那是我用 PhotoShop 把杂色点给P掉了.....哈哈哈哈哈!没想到吧?

三、成果展示,一些其他图片的像素化效果

3.1 猫咪

3.2 爱心

四、源码

源码在此: github地址

五、总结

虽然我没有做出让自己满意的 掘破苍穹 图片,但我 玩的很开心
并且明白,只需要通过一些肤浅的知识,就能完成对图片的简单处理。但如果要处理复杂的图片,却还是需要扎实的算法功底。

感兴趣的朋友可以在评论区留言,和我一起玩~~

另外,麻烦大佬帮忙点个赞,不胜感激。