热门标签 | 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

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

在这里插入图片描述


推荐阅读
  • 重要知识点有:函数参数默许值、盈余参数、扩大运算符、new.target属性、块级函数、箭头函数以及尾挪用优化《深切明白ES6》笔记目次函数的默许参数在ES5中,我们给函数传参数, ... [详细]
  • C++ 异步编程中获取线程执行结果的方法与技巧及其在前端开发中的应用探讨
    本文探讨了C++异步编程中获取线程执行结果的方法与技巧,并深入分析了这些技术在前端开发中的应用。通过对比不同的异步编程模型,本文详细介绍了如何高效地处理多线程任务,确保程序的稳定性和性能。同时,文章还结合实际案例,展示了这些方法在前端异步编程中的具体实现和优化策略。 ... [详细]
  • 本文探讨了使用JavaScript在不同页面间传递参数的技术方法。具体而言,从a.html页面跳转至b.html时,如何携带参数并使b.html替代当前页面显示,而非新开窗口。文中详细介绍了实现这一功能的代码及注释,帮助开发者更好地理解和应用该技术。 ... [详细]
  • 本文详细探讨了 jQuery 中 `ajaxSubmit` 方法的使用技巧及其应用场景。首先,介绍了如何正确引入必要的脚本文件,如 `jquery.form.js` 和 `jquery-1.8.0.min.js`。接着,通过具体示例展示了如何利用 `ajaxSubmit` 方法实现表单的异步提交,包括数据的发送、接收和处理。此外,还讨论了该方法在不同场景下的应用,如文件上传、表单验证和动态更新页面内容等,提供了丰富的代码示例和最佳实践建议。 ... [详细]
  • 在PHP中如何正确调用JavaScript变量及定义PHP变量的方法详解 ... [详细]
  • Mr.J 的 jQuery 学习笔记:第三十一讲——深入解析事件操作(on/off)方法
    `on()` 方法用于在选定元素及其子元素上绑定一个或多个事件处理程序。从 jQuery 1.7 版本开始,`on()` 方法取代了 `bind()`、`delegate()` 和 `live()` 方法,成为统一的事件绑定方式。该方法不仅支持直接绑定事件,还支持事件委托,使得事件处理更加灵活和高效。通过 `off()` 方法,可以移除之前使用 `on()` 绑定的事件处理程序,从而实现对事件的动态管理。 ... [详细]
  • 传统上,Java 的 String 类一直使用 char 数组来存储字符数据。然而,在 Java 9 及更高版本中,String 类的内部实现改为使用 byte 数组。本文将探讨这一变化的原因及其带来的好处。 ... [详细]
  • 本文详细解析了ASP.NET 2.0中的Callback机制,不仅介绍了基本的使用方法,还深入探讨了其背后的实现原理。通过对比Atlas框架,帮助读者更好地理解和应用这一机制。 ... [详细]
  • 本文详细介绍了如何在项目中引入和配置KindEditor网页编辑器,包括脚本引用、初始化编辑器以及文件上传功能的实现。 ... [详细]
  • 本文节选自《NLTK基础教程——用NLTK和Python库构建机器学习应用》一书的第1章第1.2节,作者Nitin Hardeniya。本文将带领读者快速了解Python的基础知识,为后续的机器学习应用打下坚实的基础。 ... [详细]
  • 利用REM实现移动端布局的高效适配技巧
    在移动设备上实现高效布局适配时,使用rem单位已成为一种流行且有效的技术。本文将分享过去一年中使用rem进行布局适配的经验和心得。rem作为一种相对单位,能够根据根元素的字体大小动态调整,从而确保不同屏幕尺寸下的布局一致性。通过合理设置根元素的字体大小,开发者可以轻松实现响应式设计,提高用户体验。此外,文章还将探讨一些常见的问题和解决方案,帮助开发者更好地掌握这一技术。 ... [详细]
  • 在处理大规模数据数组时,优化分页组件对于提高页面加载速度和用户体验至关重要。本文探讨了如何通过高效的分页策略,减少数据渲染的负担,提升应用性能。具体方法包括懒加载、虚拟滚动和数据预取等技术,这些技术能够显著降低内存占用和提升响应速度。通过实际案例分析,展示了这些优化措施的有效性和可行性。 ... [详细]
  • 本文介绍了如何利用Struts1框架构建一个简易的四则运算计算器。通过采用DispatchAction来处理不同类型的计算请求,并使用动态Form来优化开发流程,确保代码的简洁性和可维护性。同时,系统提供了用户友好的错误提示,以增强用户体验。 ... [详细]
  • 本文探讨了如何利用 jQuery 的 JSONP 技术实现跨域调用外部 Web 服务。通过详细解析 JSONP 的工作原理及其在 jQuery 中的应用,本文提供了实用的代码示例和最佳实践,帮助开发者解决跨域请求中的常见问题。 ... [详细]
  • 深入解析 Vue 中的 Axios 请求库
    本文深入探讨了 Vue 中的 Axios 请求库,详细解析了其核心功能与使用方法。Axios 是一个基于 Promise 的 HTTP 客户端,支持浏览器和 Node.js 环境。文章首先介绍了 Axios 的基本概念,随后通过具体示例展示了如何在 Vue 项目中集成和使用 Axios 进行数据请求。无论你是初学者还是有经验的开发者,本文都能为你解决 Vue.js 相关问题提供有价值的参考。 ... [详细]
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社区 版权所有