👄💅💄 欢迎来到换装秀——JS玩转Canvas图像处理

740 阅读5分钟

代码

预热

💋Ladies and gentlemen, welcome to my makeup show!

从零开始筹备一场令人难忘的化妆变装秀,打造一场时空跨越灵魂的艺术与技术的流动对话,遵从code hard, play hard的宗旨,敬请期待!!!

image.png

场地

🖌 布置画布

首先, 我们需要想两个问题:第一:如何来改变图片里面的像素信息,比如颜色?第二:提前如何判断周边是否是相同颜色呢?

这里我们用图像画到canvas里,然后接下来所有的变颜色,就只需拿到canvas的图像信息,然后进行更改。

核心魔法原理: 一个canvas,一个颜色选择器
1️⃣ 画布:我们将把时尚图片"摊开"在Canvas这个数字画布上;
2️⃣ 调色盘:通过操纵Canvas的像素数据,实现精准换色法术;
3️⃣ 雷达: 智能色彩雷达,在施法前自动检测周边色域,防止颜色越界。

<body>
  <p>
    <input type="color" />
  </p>
  <canvas></canvas>
  <script src="./index.js"></script>
</body>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
canvas {
  display: block;
  margin: 1em auto;
  border: 2px solid #000;
}
p {
  text-align: center;
}

image.png

👷👷‍♀️人员standby

// 🎨 获取颜色选择器 - "魔法颜料盒"
const inpColor = document.querySelector('input[type="color"]');

// 🖼️ 获取Canvas画布 - "T台"
const cvs = document.querySelector('canvas');
// ✨ 获取2D渲染上下文 - "魔法画笔"
const ctx = cvs.getContext('2d');

🌟布置画布

首先,我们要把canvas里面画上我们的图,以后呢方便咱们好拿图里面的数据做文章。

// 🎬 初始化画布 - 舞台
function init() {
  // 👗 创一个Image对象 - 虚拟の"穿衣模特"
  const img = new Image();
  
  // 📌 设置图片加载完成的回调 - 模特就位
  img.onload = () => {
    // 📏 调整画布尺寸匹配图片原始尺寸(确保像素级精确)
    cvs.width = img.width; // 设置画布实际宽度
    cvs.height = img.height; // 设置画布实际高度
    
    // 🖼️ 同步设置CSS显示尺寸(保持视觉一致性)
    cvs.style.width = `${img.width}px`;
    cvs.style.height = `${img.height}px`;
    
    // ✨ 将模特图片绘制到画布上(让模特登上T台)
    ctx.drawImage(img, 0, 0);
  };
  
  // 📌 设置图片路径 - 模特形象
  img.src = './model.png';
  
}

init();

image.png

这里详细说一下drawImage,以及怎么用它:

这个drawImagecanvas中非常核心,跟ps(photoshop)贴图差不多意思。

ctx.drawImage(image, x, y);

参数说明:

  • image: 图像源
  • x: 目标位置的x坐标
  • y: 目标位置的y坐标

换装

换装预备工作

舞台好了,模特到位了。剩下就可以看看换装了。

那么前提我们先监听一下先换哪里:

// 同色块变装,监听点了画布哪个坐标点
cvs.addEventListener("click", (e) => {
  const x = e.offsetX;
  const y = e.offsetY;
});

知道坐标之后,就可以改颜色了。

换点

接下来看看改变点击的那个点的颜色:

canvas上下文有个getImageData的函数,返回画布的像素数据:

{
  width: 区域宽度(像素数),
  height: 区域高度(像素数),
  data: Uint8ClampedArray // 核心像素数据数组
}

image.png

image.png

像素数据的data数组是是按rgba顺序的一维数组,每4个元素代表一个像素:

[ R, G, B, A,   R, G, B, A,   R, G, B, A, ... ]
 ↑ 第一个像素       ↑ 第二个像素      ↑ 第三个像素

rgb元素值的范围: 0-255
a: 0(完全透明) - 255(完全不透明)

像素索引公式: index = (y * width + x) * 4

  • y*width:一行有width个像素,所以,第y行的起始位置是y*width;

  • +x:定位到具体的第x列,也就是当前行的横向偏移量

  • *4:每个像素占用4个数组元素(rgba)。所以这里需要乘以4才能找到该像素的起始位置。

以此公式可以得到一个辅助函数,也就是通过点击的x坐标,y坐标去换索引的辅助函数:

function points2Index(x, y) {
  return (y * cvs.width + x) * 4;
}

打辅助函数

function changeColor(x, y) {
  const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height);
  const pointColor = getColor(x, y, imgData); // 获得点击的那个点的颜色
  const targetColor = hexToRgb(inpColor.value); 
  ...
}

getColor:

这其中辅助函数是getColor——获取像素颜色

function getColor(x, y, cvsColors) {
  const index = points2Index(x, y); // 返回该RGBA数据在数组中
  return cvsColors.data.slice(index, index + 4); // 返回4个元素,也就是[R, G, B, A]
}

hexToRgb —— 十六进制颜色值转换成rgb数组

function hexToRgb(hex) {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return [r, g, b, 255]; // 固定透明度为255(不透明)
}

hex to rgb:

十六进制颜色值转换成rgb数组

转换过程

红色分量提取

  • hex.slice(1, 3)获取1-2位字符
  • parseInt(..., 16)将十六进制字符串转为十进制数值

绿色是3-4位字符;蓝色是5-6位字符。

const targetColor = hexToRgb(inpColor.value);
// 如果inpColor.value是“#ff3366”,则返回的是“[255, 51, 102, 255]”

ps - 油漆桶

接着到了最重要的环节了,万事俱备只欠东风。

该函数会从指定点(x, y)开始,向四周扩散填充目标颜色,但只会替换与起点颜色相似的相邻像素。

function changeColor(x, y) {
  // 辅助函数
  const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height); // 获取整个画布的像素数据
  const centerColor = getColor(x, y, imgData); // 获取点击点的原始颜色
  const targetColor = hexToRgb(inpColor.value); // 获取要填充的目标颜色

  // 内部递归
  function _changeColor(x, y) {
    const stack = [[x, y]]; // 初始化栈,放入起始点
    
    while (stack.length > 0) { // 使用栈代替递归 
      const [x, y] = stack.pop(); // 取出栈顶坐标
      const i = point2Index(x, y); // 转换为像素索引
      const color = getColor(x, y, imgData); // 获取当前像素颜色
      
      // 边界检查
      if (x < 0 || x >= cvs.width || y < 0 || y >= cvs.height) {
        continue;
      }
      
      // 跳过已经是目标颜色的像素
      if (diff(color, targetColor) === 0) {
        continue;
      }
      
      // 颜色相似度检查(差异>50就跳过)
      if (diff(color, centerColor) > 50) {
        continue;
      }
      
      // 设置新颜色
      imgData.data.set(targetColor, i);
      
      // 将相邻像素压入栈 
      stack.push(
        [x - 1, y], // 左
        [x + 1, y], // 右
        [x, y - 1], // 上
        [x, y + 1]  // 下
      );
    }
  }

  _changeColor(x, y); // 设置

  ctx.putImageData(imgData, 0, 0); // 将修改后的像素数据写回画布
}

重难点:

第一点:四向扩散 —— 检查上下左右四个方向的相邻像素

第二点:边界控制 —— 画布边界检查、颜色差异阈值控制(50)

第三点:避免重复处理 —— 跳过已经是目标的像素

第四点:非递归实现 —— 使用栈结构避免递归深度限制

这个实现是跟ps软件中的“油漆桶工具”有异曲同工之妙。

效果

dress.gif

代码