热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

使用canvas制作魔方墙

故事起因我是一个魔方爱好者(只是爱好,但技术并不强),在大学期间担任过魔方社社长,每到招新的时候࿰

故事起因

我是一个魔方爱好者(只是爱好,但技术并不强),在大学期间担任过魔方社社长,每到招新的时候,一般都会用上千个魔方拼出招新二维码,显得比较有逼格。二维码本身也是一个一个的小格子组成,并且只有两种颜色,把二维码下载下来,然后画一些辅助线用魔方照着拼出来就好了。

有一年女朋友过生日,我想用魔方拼出他的照片人像,肯定比较有意义。但是有一个棘手的问题,如何将一张图片转换为6种颜色的小格子呢,当时在网上始终都没有找到符合的工具,于是这个想法也就破灭了。

几年过去了,忽然又回想起这件事,想着是不是可以用Javascript自己做个这个功能,说干就干。


思路

毫无疑问,肯定是使用 canvas,使用 drawImage 方法将图片绘制到 canvas 上,然后通过 getImageData 方法获取到每个像素点的颜色值,修改颜色值,重新绘制图片,最后将图片下载下来。其中有几个问题:


  • 图片本身的颜色有很多,但是魔方只有6种颜色,如何将整个图片转为只有6中颜色的图片。
  • 一张图片的像素点很多,不可能每个像素点都转换为魔方的一个块,不然不切实际。比如一张1000 * 1000 像素的图片,应该转为 100 * 100个魔方格才比较符合实际,我把这个操作称之为 “降低精度”。

正式开始

注:下面的代码使用的是jsx语法

第一步:将图片绘制到 canvas

...
const ImgRef = useRef(null);
const [imgUrl, setImgUrl] = useState('');
...function getImageData() {const canvas: any = document.getElementById('canvas');const ctx = canvas.getContext('2d');const { width, height } = ImgRef.current;canvas.width = width;canvas.height = height;ctx.drawImage(ImgRef.current, 0, 0, width, height);
}...

这里不能将 cnavas 的宽高定死,需要根据上传的图片大小进行动态设置

第二步:上传图片

使用 antd 的上传组件进行图片上传,将图片转为base64的形式进行显示。

import { Button, Upload } from 'antd';...
const file2base64 = function (file: File, callback: (base64: any) => void) {const reader = new FileReader();reader.addEventListener('load', () => callback(reader.result));reader.readAsDataURL(file);
}function onFileChange(file: any) {const len = file.fileList.length;file2base64(file.fileList[len - 1].originFileObj, imageUrl => {setImgUrl(imageUrl);});
}
...>

第三步:获取图片数据,对数据进行处理

const data = ctx.getImageData(0, 0, width, height).data;

说明:获取到的数据是一个数组,每 4个数据就是一个像素点,分别代表 红色(r),绿色(g),红色(b),透明度(a),如果有1000个像素,就有 4000个数据。像素数据是按照图片的从左到右从上至下依次排列的。

问题一:如何将不同的颜色转换为6种目标色?

魔方的6种颜色为:#e41e3a、#ff5800、#ffd500、#009e60、#0051ba、#ffffff

方案一:将HEX色值转为色相,色相为一个 360 度的圆环,6种颜色在色相环上对应6个不同的角度,目标色的色相也会对应一个角度,计算距离哪种颜色的角度最小,就将其转换为相应的颜色。经过测试这种方式转换出来的图片与原图的颜色分布差距较大。

方案二:将rgb看做是三维坐标,对应三维坐标系中的一个点,通过求两个点之间的距离来计算相似度,距离越小,相似度越高。把目标颜色转换为相似度最高的颜色。

// 求两个颜色的相似度
function getSimilarity(color1: any, color2: any): number {const { r: r1, g: g1, b: b1 } = color1;const { r: r2, g: g2, b: b2 } = color2;return Math.sqrt(Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2))
}

问题二:如何”降低精度“?

image-20211022170644727

假如上面这张图片,我们要转换成10 * 7个小格子,每个格子只能填充一种颜色,我们只需要取每个小格子中的其中一个像素点的颜色即可,可以取左上角第一个,也可以取中间的,没有特殊的要求。当然每个格子的取值点最好一致。

经过处理后处理后就可以得到下面这张图。

image-20211022171031533

这貌似什么都看不出来,这是因为“降低精度”过渡,我们可以尝试调整参数值,将5*5个像素转为一个方块。

image-20211022171357516

是不是已经可以看到轮廓样子了,毕竟只有6种颜色,所以对于细节较多的图片在效果图中无法体现出来。我们换一张单调点的图片看看。

image-20211022171616463

第四步:重新效果图

​ 在上面我们得到了原始图片的数据 data,对数据处理后需要重新绘制效果图。这里只是一些逻辑上的计算。

const { width, height } = ImgRef.current;
const gap = 10;
for (var h = 0; h < height; h+=gap) {for(var w = 0; w < width; w+=gap){var position = (width * h + w) * 4 * gap;var r = imageData[position], g = imageData[position + 1], b = imageData[position + 2];let color = MosaicImage(r, g, b);ctx.fillStyle = color;ctx.fillRect(w, h, gap, gap);}
}function MosaicImage(r: number, g: number, b: number) {let similarityColor: any = {};let maxSimilarity = Infinity;cubeColors.forEach((item) => {const [r2, g2, b2]= item.rgb.split(&#39;,&#39;);const similarity = getSimilarity({r, g, b}, {r: Number(r2), g: Number(g2), b: Number(b2)});if (similarity < maxSimilarity) {maxSimilarity = similaritysimilarityColor = item;}})return similarityColor.color;
}

- 首先我们来定义一个常量 `gap` ,表示方块的宽高
- 两层嵌套循环,`position` 表示获取的像素点在数组中的位置:`width * h` 表示行数;`+ w` 表示某行的第几个像素; `* 4` 是因为一个像素点在数组中需要占4个位置;`* gap` 是获取第n个小方块的左上角的那个像素点位置
- position,position+1,position+2,position+3分别对应了一个像素点的 rgba 信息
- MosaicImage 方法为转换后的目标颜色
- 使用 `fillStyle` 设置绘制颜色,使用 `fillRect` 方法绘制小方块

扩展功能

通过对图片每个像素点的操作,可以做出很多有意思的东西,比如说图片马赛克、颜色反转、简单的抠图等功能。


图片马赛克

与上面制作魔方图的原理相同,去掉颜色转换的步骤,可以直接取每个小方块的左上角或中间的像素颜色作为小方块的颜色。

const { width, height } = ImgRef.current;
const gap = 10;
for (var h = 0; h < height; h+=gap) {for(var w = 0; w < width; w+=gap){var position = (width * h + w) * 4 * gap;var r = imageData[position], g = imageData[position + 1], b = imageData[position + 2];let color = `rgb(${r},${g},${b})`;ctx.fillStyle = color;ctx.fillRect(w, h, gap, gap);}
}

颜色反转

将 rgb 的各自的值都用 255 减一下

function ReversalColor(r: number, g: number, b: number): string {return `rgb(${255-r},${255-g},${255-b})`;
}

抠图

这里只能做一些简单的抠图,如果要实现一些复杂的抠图,需要配合很好的算法。

可以设置一些目标颜色,将匹配的与目标色相同的像素点的透明度设置为 0 即可。主要要值得注意的是,不能使用上面重新绘制的方式,重新绘制是在原来的图片上面覆盖一层,得到的结果并不是透明的png图片。这里需要使用修改原数据的方式实现,后面会讲到。


换颜色

将指定颜色换为目标色,可用于更换头像背景色。


下载图片

将canvas内容转为图片链接,然后进行下载。当然也可以鼠标右键直接下载。

function downloadImage() { const canvas: any = document.getElementById(&#39;canvas&#39;); const imgUrl = canvas.toDataURL("image/png"); console.log(imgUrl); const a = document.createElement(&#39;a&#39;); a.download = &#39;图片.jpg&#39;; a.href = imgUrl; a.setAttribute(&#39;download&#39;, &#39;chart-download&#39;); a.click();}

优化

为了更加方便的处理,我把这几个功能做成了一个小项目,可以点击这里进行体验。

现在可以很方便的切换不同的模式,并且可以设置像素大小,目标色也可以自定义(目前还没有做,近期会加上去)。

当我把像素大小设置为1时,相当于对每个像素点都需要进行处理,有10000个像素的话就需要画10000个小方块,导致页面出现卡顿现象。

优化一下之前方案,之前是采用重新绘制的方式,其实我们也可以修改原数据的方式。通过 getImageData 方法可以得到你一个 ImageData 对象。

其中 data 是一个 Uint8ClampedArray (8位无符号整型固定数组) 类型化数组表示一个由值固定在0-255区间的8位无符号整型组成的数组;如果你指定一个在 [0,255] 区间外的值,它将被替换为0或255;如果你指定一个非整数,那么它将被设置为最接近它的整数。

通过处理数据的方式比重绘的方式要复杂一些,涉及到数据的计算,比如我们现在要将下面这个小方块的区域全部设置为一种颜色:

首先我们知道方块左上角第一个像素的起始索引值 positon ,小方块的宽高 gap,图片的宽度 width

for (let y = 0; y < gap; y++) { for (let x = 0; x < gap; x++) { const point = position + (x + width * y) * 4; imageObj.data[point] = r; imageObj.data[point + 1] = g; imageObj.data[point + 2] = b; imageObj.data[point + 3] = a; }}

point 为目标像素点的索引值,这里要注意一点,只能通过设置每一位方式去设置值,不能使用数组的 splice 方法批量处理。Uint8ClampedArray 上不存在这个方法。处理数据后,使用 putImageData 方法绘制图片,完整代码如下:

function handleImageData() { setCanDownload(false); const canvas: any = document.getElementById(&#39;canvas&#39;); const ctx = canvas.getContext(&#39;2d&#39;); const { width, height } = ImgRef.current; canvas.width = width; canvas.height = height; ctx.drawImage(ImgRef.current, 0, 0, width, height); const imageObj = ctx.getImageData(0, 0, width, height); const { data } = imageObj; for (var h = 0; h < height; h+=gap) { for(var w = 0; w < width; w+=gap){ var position = (width * h + w) * 4; var r = data[position], g = data[position + 1], b = data[position + 2], a = data[position + 3]; for (let y = 0; y < gap; y++) { for (let x = 0; x < gap; x++) { const point = position + (x + width * y) * 4; imageObj.data[point] = r; imageObj.data[point + 1] = g; imageObj.data[point + 2] = b; imageObj.data[point + 3] = a; } } } } ctx.putImageData(imageObj, 0, 0, 0, 0, width, height);}

但是处理后的效果图第一列看起来有些问题,第一列的宽度并不是设置的宽度,并且颜色也有点问题。

当时想了很久才找到原因,如果是第一种方案,是在一张画布上根据左上角的坐标进行绘制一个小方块,如果方块部分区域超出了画布区域,则会隐藏,看到的效果会是最后一行和最后一列可能出现非完整小方块的现象,这属于正常的。

但是通过处理数据的方式就有所不同,当计算出的索引值大于了某一行最后一个像素的索引值时,则会自动换到下一行的起始位置去,得到的结果就是上图,第一列其实是最后一列缺失的部分。

因此这需要增加一个判断:

for (let y = 0; y < gap; y++) { for (let x = 0; x < gap; x++) { const point = position + (x + width * y) * 4; if (point < (h + y + 1) * width * 4) { // 增加判断 imageObj.data[point] = r; imageObj.data[point + 1] = g; imageObj.data[point + 2] = b; imageObj.data[point + 3] = a; } }}

分析:(h + y + 1) * width * 4 表示当前行的最后一个点的位置,如果 point 大于了这个值,则表示在画布之外。

最后来看一下处理人像效果吧!

示例代码是使用 JSX 写的,可以点击 下载源码 自行下载。


个人网站:www.dengzhanyong.com

个人网站及公众号一般会提前两天发布新内容

在这里插入图片描述


推荐阅读
  • Webpack5内置处理图片资源的配置方法
    本文介绍了在Webpack5中处理图片资源的配置方法。在Webpack4中,我们需要使用file-loader和url-loader来处理图片资源,但是在Webpack5中,这两个Loader的功能已经被内置到Webpack中,我们只需要简单配置即可实现图片资源的处理。本文还介绍了一些常用的配置方法,如匹配不同类型的图片文件、设置输出路径等。通过本文的学习,读者可以快速掌握Webpack5处理图片资源的方法。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • 本文讨论了在手机移动端如何使用HTML5和JavaScript实现视频上传并压缩视频质量,或者降低手机摄像头拍摄质量的问题。作者指出HTML5和JavaScript无法直接压缩视频,只能通过将视频传送到服务器端由后端进行压缩。对于控制相机拍摄质量,只有使用JAVA编写Android客户端才能实现压缩。此外,作者还解释了在交作业时使用zip格式压缩包导致CSS文件和图片音乐丢失的原因,并提供了解决方法。最后,作者还介绍了一个用于处理图片的类,可以实现图片剪裁处理和生成缩略图的功能。 ... [详细]
  • JDK源码学习之HashTable(附带面试题)的学习笔记
    本文介绍了JDK源码学习之HashTable(附带面试题)的学习笔记,包括HashTable的定义、数据类型、与HashMap的关系和区别。文章提供了干货,并附带了其他相关主题的学习笔记。 ... [详细]
  • 本文介绍了如何使用JSONObiect和Gson相关方法实现json数据与kotlin对象的相互转换。首先解释了JSON的概念和数据格式,然后详细介绍了相关API,包括JSONObject和Gson的使用方法。接着讲解了如何将json格式的字符串转换为kotlin对象或List,以及如何将kotlin对象转换为json字符串。最后提到了使用Map封装json对象的特殊情况。文章还对JSON和XML进行了比较,指出了JSON的优势和缺点。 ... [详细]
  • 本文总结了在编写JS代码时,不同浏览器间的兼容性差异,并提供了相应的解决方法。其中包括阻止默认事件的代码示例和猎取兄弟节点的函数。这些方法可以帮助开发者在不同浏览器上实现一致的功能。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • Mac OS 升级到11.2.2 Eclipse打不开了,报错Failed to create the Java Virtual Machine
    本文介绍了在Mac OS升级到11.2.2版本后,使用Eclipse打开时出现报错Failed to create the Java Virtual Machine的问题,并提供了解决方法。 ... [详细]
  • 在说Hibernate映射前,我们先来了解下对象关系映射ORM。ORM的实现思想就是将关系数据库中表的数据映射成对象,以对象的形式展现。这样开发人员就可以把对数据库的操作转化为对 ... [详细]
  • 1,关于死锁的理解死锁,我们可以简单的理解为是两个线程同时使用同一资源,两个线程又得不到相应的资源而造成永无相互等待的情况。 2,模拟死锁背景介绍:我们创建一个朋友 ... [详细]
  • 本文介绍了高校天文共享平台的开发过程中的思考和规划。该平台旨在为高校学生提供天象预报、科普知识、观测活动、图片分享等功能。文章分析了项目的技术栈选择、网站前端布局、业务流程、数据库结构等方面,并总结了项目存在的问题,如前后端未分离、代码混乱等。作者表示希望通过记录和规划,能够理清思路,进一步完善该平台。 ... [详细]
  • 本文介绍了在Vue项目中如何结合Element UI解决连续上传多张图片及图片编辑的问题。作者强调了在编码前要明确需求和所需要的结果,并详细描述了自己的代码实现过程。 ... [详细]
  • SpringMVC接收请求参数的方式总结
    本文总结了在SpringMVC开发中处理控制器参数的各种方式,包括处理使用@RequestParam注解的参数、MultipartFile类型参数和Simple类型参数的RequestParamMethodArgumentResolver,处理@RequestBody注解的参数的RequestResponseBodyMethodProcessor,以及PathVariableMapMethodArgumentResol等子类。 ... [详细]
  • JavaScript和HTML之间的交互是经由过程事宜完成的。事宜:文档或浏览器窗口中发作的一些特定的交互霎时。能够运用侦听器(或处置惩罚递次来预订事宜),以便事宜发作时实行相应的 ... [详细]
  • 本文介绍了一个免费的asp.net控件,该控件具备数据显示、录入、更新、删除等功能。它比datagrid更易用、更实用,同时具备多种功能,例如属性设置、数据排序、字段类型格式化显示、密码字段支持、图像字段上传和生成缩略图等。此外,它还提供了数据验证、日期选择器、数字选择器等功能,以及防止注入攻击、非本页提交和自动分页技术等安全性和性能优化功能。最后,该控件还支持字段值合计和数据导出功能。总之,该控件功能强大且免费,适用于asp.net开发。 ... [详细]
author-avatar
手机用户2502920725
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有