使用 canvas 制作魔方墙

article/2025/8/23 7:54:33

故事起因

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

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

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

思路

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

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

正式开始

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

第一步:将图片绘制到 canvas

...
const ImgRef = useRef<any>(null);
const [imgUrl, setImgUrl] = useState<string>('');
...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);
}...<canvas id='canvas'></canvas>
<img src={imgUrl} ref={ImgRef}/>

这里不能将 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);});
}
...<UploadonChange={onFileChange}
><Button type="primary" icon={<UploadOutlined />}>上传图片</Button>
</Upload>

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

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(',');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('canvas');    const imgUrl = canvas.toDataURL("image/png");    console.log(imgUrl);    const a = document.createElement('a');    a.download = '图片.jpg';    a.href = imgUrl;    a.setAttribute('download', 'chart-download');    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('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);    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
个人网站及公众号一般会提前两天发布新内容

在这里插入图片描述


http://chatgpt.dhexx.cn/article/CbbAbd0C.shtml

相关文章

在html中制作多彩照片墙,60个照片墙布置方案 记录浪漫时刻

照片墙在居家设计中有着重要的意义,现在也有越来越多的家庭在装修的时候为自己家设计一组漂亮的照片墙,这些照片墙可以表达不同的形式,让家居氛围更加温馨,如果你也喜欢照片墙可以选一些自己喜欢的照片或者装饰画,错落有致的挂在家中的墙面上,不仅温馨有趣,也为家装增添…

90%使用看板的人都踩过这4个大坑

看板因为成本低廉&#xff0c;使用方法易上手&#xff0c;被很多软件研发团队使用。 今天讲讲我看到的使用看板的几个常见的问题&#xff0c;以及如何避开这些问题&#xff0c;让看板发挥它真正的效用&#xff0c;让大家减少时间浪费&#xff0c;按时下班. 1 第一个坑&#xff…

大数据与墙的故事

在网站上看到这么一篇文章&#xff0c;转载过来和大家分享一下 文章出处&#xff1a;https://baike.baidu.com/tashuo/browse/content?id45bd8fd350d6aab5b6d81047&lemmaId1356941&lemmaId1356941&frqingtian 文章标题为&#xff1a;大数据与墙的故事&#xff0…

什么是用户故事地图?

为什么会有用户故事地图&#xff1f; 迭代开始后&#xff0c;待办列表总是以小块形式进入迭代开发&#xff0c;一个迭代接着一个迭代。碎片化的方式&#xff0c;不能给产品以及开发团队一个整体的视觉。这会出现&#xff0c;优先级排列问题&#xff0c;或者产生多个迭代后&…

用户故事地图

用户故事地图 用户故事是描述用户需求分析的一个好方法&#xff0c;可以将backlog变成一个二维地图&#xff0c;从而容易看到整个规划的全貌&#xff0c;帮助开发人员快速的了解客户的需求&#xff0c;并确定产品模块的实现优先级&#xff0c;实现最大用户价值&#xff0c;学会…

敏捷开发日常跟进系列之三:故事板,看板

这是敏捷开发日常跟进系列的第三篇。 (栏目目录) 故事板和看板其实不是一个东西,前者是最初的敏捷开发里边的东西,受到了后者的启发产生的;而后者是制造业的东西,具体内容请参考末尾的百度百科。但是在敏捷开发里边提到这两样东西,可以认为大致相同。 故事板 简单说,故…

敏捷管理的利器:故事墙

文章来源&#xff1a;公众号-智能化IT系统。 引言 故事墙是敏捷管理的一个高效手段。只要妥善运用&#xff0c;其能够带来的好处远远超出管理理论中提及的。 试想如下一些问题&#xff1a; 假如一个团队&#xff0c;有一个环节&#xff08;例如系统测试&#xff09;&#x…

【Web前端】html+css+javascript

1 HTML 1.1. HTML基本概念 HTML:hyper text markup language 超文本标记语言&#xff0c;就是超出纯文本范畴的语言&#xff0c;其中既可以定义文本也可以定义图片&#xff0c;超链接等等非文本性质的内容。 HTML语言是由标签<>构成&#xff0c;一般是成对出现<>…

HTML+CSS基础知识

一、HTML基础 1.html的基础结构&#xff1a; 2.html中的语法&#xff1a; - 在尖角符号后面的第一个英文单词就是当前标记的名称 - 标记也可以称为标签或者元素 - 双标签结束的反斜杠不能省略 但是单标签的反斜杠是可以省略的 - 在标签名称后面空格都是当前标签的属性(描述…

用HTML,css,boostrap写一个综合大型购物网站

用HTML&#xff0c;css,boostrap写一个综合大型购物网站 首先来看看页面的效果&#xff1a; 有需要的可以参考 首先是首页index: <!DOCTYPE html> <html><head><!-- 先把bootstrap里面的css、jQuery和bootstrap.js三个文件按顺序加载进来 --><me…

HTML+CSS项目案例

文章目录 1、表格练习2、文本样式练习3、图片标签练习4、盒子模型练习一5、盒子模型练习二6、盒子模型练习三7、浮动练习8、边框练习一9、边框练习二10、图文混排11、列表练习 1、表格练习 案例演示&#xff1a;&#xff08;table、tr、td、th等&#xff09; 实现代码&#x…

用css简单实现三级导航栏

关键属性 display&#xff1a;none 隐藏元素 display&#xff1a;bolck 显示元素 实例 CSS样式 <style>* {margin: 0;padding: 0;}li {list-style: none;}a {text-decoration: none;}.nav1 {width: 400px;height: 50px;/* border: 1px solid red; */margin: 200px aut…

使用 CSS3 实现转盘抽奖效果

微信和大型商场常常会有转盘抽奖的活动,比如上海移动和教授的抽取积分活动等。我们可以通过CSS3的transform属性来实现转盘的旋转。同时,transition属性实现过渡动画,它具有四个子属性,依次为 1. transition-property(过渡属性,默认为all) 2. transition-du…

css module

css module 一、css module1.思路2.实现原理3.如何应用样式 一、css module 1.思路 通过命名规范来限制类名太过死板&#xff0c;而css in js虽然足够灵活&#xff0c;但是书写不便。 css module 开辟一种全新的思路来解决类名冲突的问题 css module 遵循以下思路解决类名冲突…

css 预处理器

由于多个项目中用到了sass和less&#xff0c;所以就学习了一下相关知识&#xff0c;记录下来方便随时查看。 前言 css是用来编写网站样式&#xff0c;但是&#xff0c;其写法比较一成不变。 如果想要使用 css 实现 js 一样的变量、常量等&#xff0c;就会比较臃肿&#xff0…

【CSS】css快速更改某个字体图标,使用content插入字符

遇到一个小问题&#xff1a;使用的是组件库&#xff0c;但是这个组件没有提供更改图标的接口&#xff0c;需要更改这个特定的图标&#xff0c;但是最好不更改dom 解决办法 由于系统已经使用了这个组件库&#xff0c;那么就有了他的图标内容&#xff0c;可以先到组件库官网找到…

W3C推荐的新布局模式 【CSS Flex布局】详解

本文目录 概述属性弹性容器的属性&#xff08;父级&#xff09;displayflex-directionflex-wrapflex-flowjustify-contentalign-itemsalign-contentgap, row-gap, column-gap 弹性项目的属性&#xff08;子项&#xff09;orderflex-growflex-shrinkflex-basisflexalign-self 示…

CSS变量(CSS Variables)

概述 如果突然有一天,当你页面的主题色发生变化时,有许多元素的颜色要一起变化,这是你会考虑怎么做呢?使用Less/Sass/Stylus来定义一个颜色变量?还是专门去定义多了个类名来控制元素? 使用第一种方法,有他的弊端,浏览器无法识别CSS预处理器的语法,所以我们都会将Less…

详细介绍css3中的变量使用

CSS3新功能之变量&#xff1a;variables css3为我们提供了一个强大的功能自定义属性&#xff0c;也就是变量&#xff0c;他能让我们更改色系、皮肤、自适配变得简单。我们只需要更改一些我们事先定义好的变量就可以轻松实现各种效果。特别是我们在开发大型项目的时候有多处使用…

详解CSS(一)

&#x1f3cd;️作者简介&#xff1a;大家好&#xff0c;我是亦世凡华、渴望知识储备自己的一名在校大学生 &#x1f6f5;个人主页&#xff1a;亦世凡华、的博客 &#x1f6fa;系列专栏&#xff1a;CSS专栏 &#x1f6b2;给大家推荐一个网站&#x1f609;很实用&#x1f61a;我…