基于canvas开发吸色功能

6,443 阅读10分钟

之前有听到同事说,我想自由的拿到设计图中的某个颜色,我要告诉设计同事帮我取出来,似乎听着有些小麻烦。敲敲小黑板~那么今天就给大家分享一项新开发的功能,基于canvas开发的取色器,让我们一起来揭开这层神秘面纱吧。

背景

看到设计师用的工作软件,需要什么颜色,用吸管一吸,颜色出来了,很便捷。相信很多前端开发者在开发过程中经常会遇到,做到某一个模块,需要找出某个点的颜色值加到代码里,可能会先截图,然后放到ps等工具内然后吸色,还是有些不方便。那么我们项目里面已经做到点击设计稿件内容,即可查看相关标注信息,可切换标注方式,一键下载多种尺寸切图,自动生成CSS代码,是不是再加上灵活的吸色功能就方便多了,这么强大的工具,怎么会没有吸色功能呢,答案是,一定会有的~

需求规划

首先在查看稿件页,用户可以点击右下方吸色图标,进入取色状态,鼠标放到设计稿件上,变为吸管标志,右下角弹出浮层显示颜色值,此处颜色值分为RGBA,RGB,HEX,AHEX,HEXA,HSL,HSLA;点击稿件中任意位置可连续取色,取色结果显示在提示浮层内,默认颜色显示模式为HEX,点击右下方浮层色块切换多种色值。

处理这么细节的颜色值,那非canvas莫属,我们平时知道它可以画个长方形,圆,还有一些不规则图形,那么操作像素级的场景,canvas确实给我们能提供更大的方便。

canvas是HTML5新增的一个可以使用脚本(通常为JavaScript)在其中绘制图像的HTML元素。它可以用来制作照片集或者制作简单(也不是那么简单)的动画,甚至可以进行实时视频处理和渲染。canvas是由HTML代码配合高度和宽度属性而定义出的可绘制区域。JavaScript代码可以访问该区域,类似于其他通用的二维API,通过一套完整的绘图函数来动态生成图形。

方案实现

首先,我们需要在稿件上创建一张canvas画布,并设置这个画布的绝对定位;这里说一下,给画布设定宽度和高度是很有必要的,那我们有两种方式一种是在canvas标签上直接限制,第二种是在加载画布的时候js动态设定,在项目中查看稿件是可以放大缩小的,所以我们选择第二种。

<canvas class="ctx bg" ref="canvas"></canvas>

创建initCanvas方法,用于初始化canvas,每个canvas节点都有一个对应的context对象(上下文对象),CanvasAPI定义在这个context对象上面,所以需要获取这个对象,方法是使用getContext方法,传入的参数为2d,它返回一个CanvasRenderingContext2D对象,该对象实现了一个画布所使用的大多数方法。

this.canvas = this.$refs['canvas']
this.ctx = this.canvas.getContext('2d')

图像处理方法

我们需要先将稿件的图片渲染到canvas中,这里用到了重量级方法drawImage。 canvas绘制图像有几个需要注意的地方:

  • 需要先实例化一个img对象
  • 通过img对象的src属性来引入外部图片
  • 绘制图片语句必须在图片预加载完成后进行,否则绘制不到画布上
this.canvas.width = this.stage.width*ratio
this.canvas.height = this.stage.height*ratio
let img = new Image()
img.crossOrigin = "Anonymous"
img.src = this.page.image

this.ctx.drawImage(img, 0, 0,this.canvas.width,this.canvas.height); // 设置对应的图像对象,以及它在画布上的位置,和大小

上面代码先给canvas动态设置宽和高(稿件的宽高*放大倍数)将一个后台返回的png图像载入画布。

drawImage()方法接受参数有三种情况:

  • drawImage(image, dx, dy) 3个参数(在画布指定位置绘制原图)
  • drawImage(image, dx, dy, dw, dh) 5个参数(在画布指定位置上按原图大小绘制指定大小的图)
  • drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) 9个参数(在画布指定位置剪切指定大小的图像,并在画布上定位被剪切的部分)

现在我们先把稿件的大图片绘制到画布上,drawImage()方法接受5个参数,第一个参数是图像文件的DOM元素(即节点),第二个和第三个参数是图像左上角在画布中的坐标,表示将图像左上角放置在画布的左上角开始,第四个和第五个参数是渲染图片的大小。由于图像的载入需要时间,drawImage方法只能在图像完全载入后才能调用,因此上面的代码需要改写。

img.onload = () => {  
this.ctx.drawImage(img, 0, 0,this.canvas.width,this.canvas.height); // 设置对应的图像对象,以及它在画布上的位置,和大小
}

注意,这里有个问题,图片跨域。源于canvas无法对没有权限的跨域图片进行操作,也就是必须在同一个域下,如出现跨域会报错:

Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.

在执行getImageData方法时因为跨域导致报错,也就是canvas已经被跨域的数据污染了。

解决这个问题有两种方式:第一种,就需要图片所在的服务器允许跨域访问(设置消息头Access-Control-Allow-Origin="*"或者你的网站域名),且本地也需要开启跨域权限(img.crossOrigin = "anonymous")。 要在图片加载前设置,如下:

let img = new Image()
img.crossOrigin = "Anonymous"
img.src = this.page.image

第二种: 可以拿到图片的地址,用get请求,拿到blob格式的数据流转换成二进制的文件。如下代码:

let p = new Promise((resolve, reject) => {
var blob = null
var xhr = new XMLHttpRequest() 
xhr.open("GET", url)
xhr.setRequestHeader('Accept', 'image/jpeg')
xhr.responseType = "blob"
xhr.onload = () => {
let imgFile = new File([blob], imageName, {type: 'image/jpeg'})
}
xhr.send()
})

这样就可以了吗?并不是,接下来我们需要把blob格式文件转换为base64,我们用到了FileReader()构造函数,这个函数功能也是十分强大,可以用来操作上传图片文件,格式的转换。可以使用FileReader的原型方法readAsDataURL把blob转换成base64,方法如下:

let base= new FileReader()
base.onload = function (e) { 
console.log(e.target.result) // base64文件流`
base.readAsDataURL(v)

此时我们拿到base64文件流,可以直接放到drawImage方法第一个参数。发现烦人的报错没有了。

选择两种方法都可以,现在我们看下,稿件已经在我们的canvas 画布上展示出来了。

实现放大镜

接下来要实现放大镜了,放大镜我们同样用canvas渲染出来,因为后面的吸色效果会用到,canvas是可以处理像素级的。

首先,我们需要知道放大镜是跟随鼠标在稿件上移动的,那就要获取鼠标所在的x和y 轴分别是多少,clientX和clientY是相对于屏幕的坐标点,要转化为以稿件为基准的坐标点,clientX和clientY减去稿件left和top 值就是需要的坐标点,这样我们定义的放大镜会跟随在鼠标的右下面。运用刚才在canvas画布上渲染图片的方法,我们把鼠标所在的某个区域渲染到放大镜上。还是用到drawImage方法,这次是9个参数,如果用大白话来解释我觉得会跟清晰些,我们要去大画布截取一块指定大小的图,渲染到我们的放大镜上,来跟我一起看一下,第一个参数我传的是图片源也就是原图片,drawImage方法第2,3,4,5 参数是从鼠标的坐标点开始截取,截取和放大镜一样的宽高,6,7参数是从放大镜的那个坐标点开始绘制,我们写0,0。8,9参数是放大镜区域显示的宽高,填写为本身的宽高。这样放大镜里显示的就是直接裁剪的那一块了。

这里要提出两个关键点,第一个点,就是怎样让放大镜产生放大效果呢,这里和近大远小的概念是类似的,我们定义一个系数,让2,3,4,5参数,分别除以这个系数,是变小了,然后在渲染到我们的放大镜上,放大镜的宽高是固定的,这样,我们去改变这个系数单位是可以控制放大镜的放大倍数。我们传入的图像源还是后台返回原图片,会导致一个问题,我们大的画布是我们要展示的实际的宽高,并不是按照原图展示的宽高,所以当放大镜移动的时候,还是会看到以原图为基准。

其实drawImage()方法第一个参数是可以传图片源,canvas画布源,甚至是视频源,所以这里第一个参数改写为刚才绘制稿件的canvas图像源。那这样的话还是需要用图像源,这里需要等比例的算法,也就是当我们在原图像的坐标点和实际放大镜的展示的坐标点比例换算成一个相同的百分比就可以了。代码如下:

let halfW = this.enlarge.width/2
let halfH = this.enlarge.height/2
let l = event.clientX-this.stage.left
let t = event.clientY-this.stage.top
//left 和 top 在稿件上坐标点的百分比
let bLeft = l/(this.stage.width*this.stage.ratio)
let bTop = t/(this.stage.height*this.stage.ratio)
this.enlarge.left = l 
this.enlarge.top = t 
const {width,height}=this.orgGlassSize
//让求出的稿件上的坐标点转变成在图片上的坐标点
const [imgX,imgY]=[Math.max(Math.floor(bLeft*this.canvas.width), 0), Math.max(Math.floor(bTop*this.canvas.height),0)]
let scale = 8  //放大系数
this.glassContext.drawImage(
  this.cImg,
  (imgX)-(width/2)/scale,
  (imgY)-(height/2)/scale,
  width/scale,height/scale,
  0,0,
  width,height
) 

把放大镜以鼠标为中心点来跟随鼠标走动,就需要减去放大镜本身宽高的一半,这样放大镜到现在比利是正确的。

创建网格线

接下来我们要绘制网格和中心点的小方格,canvas 中是没有层级之分的,所谓的层级其实就是先后顺序,现在图像已经在放大镜里了,第二步要绘制网格盖在上面,创建drawGrid方法,绘制网格,代码如下:

drawGrid(stepX, stepY, color, lineWidth){
    // 清除路径
	this.glassContext.beginPath()
	// 设置绘制颜色
	this.glassContext.strokeStyle = color
	// 设置绘制线段的宽度
	this.glassContext.lineWidth = lineWidth
	// 创建垂直格网线路径
	for(let i = 0.5+stepX; i < this.glassCanvas.width; i += stepX){
		this.glassContext.moveTo(i, 0)
		this.glassContext.lineTo(i, this.glassCanvas.height)
	}
	// 创建水平格网线路径
	for(let j = 0.5+stepX; j < this.glassCanvas.height; j += stepY){
		this.glassContext.moveTo(0, j)
		this.glassContext.lineTo(this.glassCanvas.width, j)
	}
	// 绘制格网
	this.glassContext.stroke()
}

首先创建垂直格网线,循环的宽度就是放大镜的宽度,我们定义的方格为宽高10像素,这个值是可以设定的,项目中统一是10像素,从起点开始绘制,高度为放大镜的高度,这样以放大镜的宽度为基准绘制了垂直线,然后同理,创建水平格网线。

创建中心点小方格

接下来就是绘制最上面的小方格了,要绘制一个矩形,并且位置是放大镜的中心点:

	this.glassContext.beginPath()
	this.glassContext.lineWidth="1"
	this.glassContext.strokeStyle="#525252"
	this.glassContext.rect(放大镜宽度一半(x),放大镜高度一半(y),8,8)
	this.glassContext.stroke()

注意:每次调用stroke()方法后一定要记得调用beginPath()方法清除当前存在的路径,否则保留的路径会影响到其他的路径的绘制

图像像素点操作

接下来我们看下getImgData方法,ImageData 是图片的数据化

context.getImageData(x, y, width, height);

getImgData方法返回ImageData对象,存储着canvas对象真实的像素数据,它包含以下几个只读属性:

  • width:图片宽度,单位是像素
  • height:图片高度,单位是像素

data:该对象拷贝了画布指定矩形的像素数据。是Uint8ClampedArray类型的一维数组,它可以被使用作为查看初始像素数据。每个像素用4个1bytes值(按照红,绿,蓝和透明值的顺序; 这就是"RGBA"格式) 来代表。每个颜色值部分用0至255来代表。每个部分被分配到一个在数组内连续的索引,左上角像素的红色部份在数组的索引0位置。像素从左到右被处理,然后往下,遍历整个数组。

注:Uint8ClampedArray 翻译过来是 8位无符号整型固定数组,其取值范围是[0,255]。若小于0,则为0,大于255,则为255。若为小数,则取整,取整的方法是银行家舍入。何为银行家舍入,好奇心爆棚:

四舍六入五考虑,五后非零就进一, 五后为零看奇偶,五前为偶应舍去,五前为奇要进一 也就是四舍六入

  • R - 红色 (0-255)
  • G - 绿色 (0-255)
  • B - 蓝色 (0-255)
  • A - alpha 通道 (0-255; 0 是透明的,255 是完全可见的)

遍历像素想这样去循环: 1.像素遍历:每隔4个数据遍历一次,简单快捷

for(let i=0;i<arr.length;i+=4){
    let r=data[i+0];
    let g=data[i+1];
    let b=data[i+2];
    let a=data[i+3];
    console.log(r,g,b,a)
}

2.行列遍历: 基于行列遍历,可获取像素点的位置信息

for(let y=0;y<h;y++){
    for(let x=0;x<w;x++){
        let ind=(y*w+x)*4;
        let r=data[ind];
        let g=data[ind+1];
        let b=data[ind+2];
        let a=data[ind+3];
        console.log(r,g,b,a)
    }
}

比如我们想找到第x行,第y列的像素值的蓝色部分:

imageData.data[4*((x)*imageData.width+(y))+2]

我们再来看下,color/alpha 以数组形式存在,并存储于 ImageData 对象的 data 属性中。那我们就拼接一下试试。

不要忘了,我们怎么样告诉getImageData找到画布上每一个像素点呢,我们需要event对象的帮忙了,首相我们要获取鼠标所在画布的x轴和y轴的坐标点。这里用的event.layerX。如下代码:

let x = event.layerX
let y = event.layerY
let pixel = this.ctx.getImageData(x, y, 1, 1)
let data = pixel.data
let rgba = 'rgba(' + data[0] + ',' + data[1] + ',' + data[2] + ',' + ((data[3] /` `255).toFixed(2)) + ')'
this.color.style.background =  rgba

RGBA是RGB色彩模型的一个扩展。这个缩写词代表红绿蓝三原色的首字母,Alpha值代表颜色的透明度/不透明度。 所以我们这里转换一下:

Math.round((data[3]/255) * 100) / 100;

拿到坐标后传给getImageData方法,拿到返回的data,是以数组形式的,分别拼接为rgba四个值,惊喜出现了,这个色值就是我们需要的。这时,我们真正需要的是放大镜中的中心点像素的颜色,就是要吸的色值:

getImageData(放大镜宽度/2, 放大镜高度/2, 1, 1)

拿到我们所需要的色值以后那么接下来我们需要的几种色值需要分别转换下。(RGBA,RGB,HEX,AHEX,HEXA,HSL,HSLA)。

颜色转换

1.RGBA,RGB:

基础颜色RGBA,也是我们拿到像素点组成的颜色,是通过对红(R)、绿(G)、蓝(B)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的,RGB即是代表红、绿、蓝三个通道的颜色,图像中每一个像素的RGB分量分配一个0~255范围内的强度值。RGB图像只使用三种颜色,就可以使它们按照不同的比例混合,在屏幕上重现16777216种颜色。这个标准几乎包括了人类视力所能感知的所有颜色,是目前运用最广的颜色系统之一。 A(Alpha) :指的是不透明度

2.Hex:

因为hex是需要十六进制的值,我们值需要把rgb这三个颜色的值,分别转成十六进制,然后再拼接。如下:

const hex = data[0].toString(16)
hex.length === 1 ? '0' + hex : hex

我们可以直接用toString方法转换,注意这里加一个判断,如果是一位数,我们在前面补充上0;我们拼接起来:

function ToHex(data){
    const hex = data.toString(16)
    return hex.length === 1 ? '0' + hex : hex
}
let hex =  ToHex(data[0])+ToHex(data[1])+ToHex(data[2])
3.HEXA,AHEX

这里同样的 A(Alpha) 指的是不透明度,我们可以把Alpha处理转成16进制追加到HEX之前和之后都可以。

Math.round(255 * a).toString(16).toUpperCase()

4.HSL,HSLA

这里转换的HSL和HELA并不能直接当作CSS颜色值处理,因为范围不一样。

H: Hue(色调)。 0(或360)表示红色,120表示绿色,240表示蓝色,当然可取其他数值来确定其它颜色;

S:Saturation(饱和度)。 取值为0%到100%之间的值;

L:Lightness(亮度)。 取值为0%到100%之间的值;

A:(Alpha) 代表不透明度

在数学上定义为 RGB 空间的r,g,b坐标到 HSL 空间的 h,s,l 坐标的换算。这里找到数学资料的公式:

r,g,b ∈ [0, 1] ,max = max(r, g, b), min = min(r, g, b)

h ∈ [0, 360], s,l ∈ [0, 1]

这里理解着好比在素描绘画的时候,会有黑白灰几个维度,在颜色的这里是对应色调,饱和度,亮度。HSL色彩模式是工业界的一种颜色标准,是通过对色调(H)、饱和度(S)、亮度(L)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的。

亮度的定义并不是真正意义上明确的,而是基于不同的模型做不同的定义HSL中亮度的定义取RGB中最大值与最小值相加的二分之一。饱和度的计算是首先定义色度Chroma = max - min,从生理角度理解三种视锥细胞中,刺激最大与刺激最小之间的差异, 让人产生了颜色的鲜艳感,而与刺激中等的细胞关系不大。

取值范围:

H是色度,取值在0度~360度之间,0度是红色,120度是绿色,240度是蓝色。360度也是红色。

S是饱和度,是色彩的纯度,是一个百分比的值,取值在0%~100%,0%饱和度最低,100%饱和度最高

L是亮度,也是一个百分比值,取值在0%~100%,0%最暗,100%最亮。

A是不透明度,取值在0.0~1.0,0.0完全透明,1.0完全不透明。

代码可以这样实现:

function RGB2HSL(r, g, b) {
  r = r / 255
  g = g / 255
  b = b / 255
  const max = Math.max(r, g, b)
  const min = Math.min(r, g, b)
  const delta = max - min
  let h, s, l
  if (max ==== min) {
    h = 0
  } else if (max === r) {
    h = ((g - b) / delta) % 6
  } else if (max === g) {
    h = (b - r) / delta + 2
  } else {
    h = (r - g) / delta + 4
  }
  h = Math.round(h * 60)
  if (h < 0) h += 360
  l = (max + min) / 2,
  s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
  // 切换为百分比模式
  s = +(s * 100).toFixed(1);
  l = +(l * 100).toFixed(1);
  return { h, s, l }
}

这样,我们又得到一个类型的色值。在点击右下角浮层里的色块时候,依次求出各个色值,展示出来,这些色值的基础都是依赖于rgba或者rgb。

思路转变

那么到现在是不是完成了呢,并不是,这样会发现一个问题,网格和像素点是永远不会百分百对齐的,再一点,同时绘制两个画布会消耗更多的性能,所以,推翻重来,没错,重新思考。 既然这样我们对不齐,换一个新的思路,我们先画好放大镜里面的网格,定义每个格子宽高是10像素,然后是11x11的矩阵,为什么是11x11,而不是10x10,是我们还需要一个中心点的小方格。然后鼠标在大的画布上所经过的位置,我们截取11x11像素点,也就是121个像素点。说到这里想必大家有了这个思路了,就是,我们鼠标移动的时候,拿到这个11x11的像素点在放大镜里面去绘制每个小矩形,颜色就是对应的像素点的颜色。正好用到前面所说的矩阵取像素点的逻辑。

for(let y=0;y<11;y++){
	for(let x=0;x<11;x++){
        let ind=(y*11+x)*4
        let r=data[ind]
        let g=data[ind+1]
        let b=data[ind+2]
        let a=data[ind+3]
        this.glassContext.beginPath()
        if(y==5&&x==5){
          this.rgba = {r:r,g:g,b:b,a:a}
        }
        this.glassContext.fillStyle = 'rgb('+r+','+g+','+b+')'
        this.glassContext.rect(x*10,y*10,10,10)
        this.glassContext.fill()	
    }
  }

循环11x11的像素点,在绘制矩形的时候乘以10,就是我们要绘制带样色矩形的坐标点,通过判断,拿到坐中间隔得rgba颜色值,就是我们鼠标为中心的像素点的颜色。这里注意的是移动鼠标的时候是要取中心点的,所以x轴和y轴应该都减去5.5。

这里处理一个小细节,中心点的小格是红色的,如果遇到很接近的颜色,可能会不好分辨中心格子,所以这里我们判断一下,如果颜色很接近就取为黑色,红色和黑色反差比较大。

if(data.r>=200 && data.g<=100 && data.b<=100){
   return [0,0,0]
}else{
   return [255,0,0]
}

简单的小例子

其实和getImageData相并列的还有一个方法,就是putImageData()方法。putImageData() 方法将图像数据(从指定的 ImageData 对象)放回画布上。也就是我们用getImageData方法拿到像素点数据,重新渲染到画布上。那是不是我们在操作图片上有多了一些灵活转变,比如我们可以把一张图片进行颜色反转,拿到rgb 颜色值用255减去这个值,取到反差色,效果如下:

嗯,确实很神奇,由此可以推断,操作图片反转,复古,以及rgba的值取出平均值可以出来图片的灰度,我们都可以做到。其实刮刮卡的效果也可以用此思路完成。

总结

虽然这次的吸色功能踩了很多坑,但是收获很多,丰富了自己在图像处理的知识,canvas在像素级的操作真的是给我们带了很多便利,也让前端开发人员有更多的发挥,制作出各种各样的效果,还有很多深入的知识点等待我们去发掘。同时很感谢组长帮助分析思路,比较重要的一点就是思路,前期要做好调研,选用的技术点要符合需求,思路对了就可以一步一步的实现。