在大学的时候,我很喜欢用 js 做一些有趣的小玩意。今天翻出了以前的代码,摆弄之余又有了一些新的思考。
前言
字符画,顾名思义,就是字符组成的画,这是例图(兄弟,你好香)。

字符画篇
原理
整体思路就是,读取图片的每个数据点,将其转化为灰度点,最后用等效的灰度符号进行替换。
读取图片像素点
我们需要利用 canvas 来做这件事情,canvas 的 context 拥有一个 getImageData 方法,就可以读取到所有像素数据。大致如下:
js
const ctx = canvas.getContext("2d"); // 画笔
ctx.drawImage(img, 0, 0, img.width, img.height); // 将图片绘制到canvas
imgData = ctx.getImageData(0, 0, img.width, img.height);
console.log(imgData)

这些数据点可以每4个划为一组,依次为 r,g,b,a。所以共有 width * height * 4 那么多个元素,getImageData 读取图片的数据,遵循从左到右,从上到下的顺序。有了这些数据,我们就可以简单写个方法来分析出每个像素点的情况了。
js
for (let heightIndex = 0; heightIndex < img.height; heightIndex ++) {
for (let widthIndex = 0; widthIndex < img.width; widthIndex ++) {
const curPoint = (heightIndex * img.width + widthIndex) * 4; // ×4是因为,1为r,2为g,3为b,4为a,四个是一组rgba值
const [r, g, b] = imgData.data.slice(curPoint, curPoint + 3);
}
}
像素点转灰度
先看一眼灰度图的定义

在有了像素点的情况下,如何将其转化为灰度点呢?有个简单的公式是
js
const gray = r * 0.3 + g * 0.6 + b * 0.1;
这样即可得到一个 0~255 的灰度值,再用等效的灰度符号去代换即可,这里先给一个参考的灰度符号表
json
{ min: 0, symbol: "@" }, // 最黑
{ min: 25, symbol: "#" },
{ min: 50, symbol: "&" },
{ min: 75, symbol: "$" },
{ min: 100, symbol: "%" },
{ min: 125, symbol: "*" },
{ min: 150, symbol: "o" },
{ min: 175, symbol: "!" },
{ min: 200, symbol: ";" }, // 越来越浅
{ min: 225, symbol: ":" },
{ min: 250, symbol: "." }, // 最浅
具体实现
源码
js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>图片转字符</title>
<script src="https://unpkg.com/cnchar@3.2.6/cnchar.min.js" ></script>
</head>
<body>
<input type="file" name="file" id="upButton" onchange="getImg()" />
<canvas id="canvas" width="500" height="500" style="display: none"></canvas>
<!-- 生成位置 -->
<div style="display: flex; justify-content: center; align-items: center">
<div id="main"></div>
</div>
</body>
<script>
const img = new Image();
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const CHAR_SIZE = 15; // 可以调整这个属性来控制每个字符的大小,由于画布有限,所以字符越小,精度越高。
const IMAGE_MAX_SIZE =
Math.min(window.innerWidth / CHAR_SIZE, window.innerHeight / CHAR_SIZE) *
0.8;
// 根据灰度转化字符,添加颜色
const toText = (gray, color) => {
// 常规字符
const mappings = [
{ min: 0, symbol: "@" }, // 最黑
{ min: 25, symbol: "#" },
{ min: 50, symbol: "&" },
{ min: 75, symbol: "$" },
{ min: 100, symbol: "%" },
{ min: 125, symbol: "*" },
{ min: 150, symbol: "o" },
{ min: 175, symbol: "!" },
{ min: 200, symbol: ";" }, // 越来越浅
{ min: 225, symbol: ":" },
{ min: 250, symbol: "." }, // 最浅
];
// 这里是自定义字符,修改""里的内容即可
// const str = "老婆我爱你桥本环奈".split('').sort((a, b) => {
// return b.stroke() - a.stroke()
// }).join('')
// const mappings = str.split('').map((item, i) => ({ min: i * 256 / str.length, symbol: item }));
// 使用 find 方法查找第一个 gray 大于等于对应 min 的映射
const matchedMapping = mappings
.toReversed()
.find((mapping) => gray >= mapping.min);
const text = matchedMapping?.symbol || mappings[0].symbol;
return `<div style="color:${color}; width: ${CHAR_SIZE}px; height: ${CHAR_SIZE}px">${text}</div>`;
};
const start = () => {
let charCanvas = "";
if (img.width > IMAGE_MAX_SIZE || img.height > IMAGE_MAX_SIZE) {
// 先记录比率,如果宽,那么先缩放宽,再用比率算出长,反之同理,这里是保证不超过不超过设定规模
const rate = img.width / img.height;
if (rate > 1) {
img.width = IMAGE_MAX_SIZE;
img.height = IMAGE_MAX_SIZE / rate;
} else {
img.height = IMAGE_MAX_SIZE;
img.width = IMAGE_MAX_SIZE * rate;
}
}
ctx.drawImage(img, 0, 0, img.width, img.height);
const imgData = ctx.getImageData(0, 0, img.width, img.height);
for (let heightIndex = 0; heightIndex < img.height; heightIndex++) {
charCanvas += "<div style='display: flex'>";
for (let widthIndex = 0; widthIndex < img.width; widthIndex++) {
const curPoint = (heightIndex * img.width + widthIndex) * 4; // ×4是因为,1为r,2为g,3为b,4为a,四个是一组rgba值
const [r, g, b] = imgData.data.slice(curPoint, curPoint + 3);
const gray = r * 0.3 + g * 0.6 + b * 0.1; // 计算灰度值
const color = `rgb(${r},${g},${b})`; // 保存像素点rgb值
charCanvas += toText(gray);
// 想用有颜色的就用下面这个
// charCanvas += toText(gray, color);
}
charCanvas += "</div>";
}
document.getElementById("main").innerHTML = charCanvas;
};
const getImg = (file) => {
const reader = new FileReader();
reader.readAsDataURL(document.getElementById("upButton").files[0]);
reader.onload = function () {
img.src = reader.result;
setTimeout(start);
};
};
</script>
</html>
效果图

稍加思索的灰度字符
当时那个灰度符号表,是不知道从网络的哪个犄角旮旯里翻出来的。现在想想,有没有更优雅的方式,更美妙的字符呢?比如说汉字能作为灰度字符吗?
第一反应是上 github 搜有没有相关库分析汉字点阵库从而计算灰度值。但是很遗憾,没有找到。
第二反应是求助 ai

很好,ai 给我提供了一个线索 -- onchar 库,这个库拥有一个读取汉字笔画数量的能力。虽说有些不精致,但是我们确实可以利用笔画数量来粗略估计一个字的灰度。
lua
// 比方说
笔画数 >= 10(如'值') ---- 转化为灰度值255
笔画数 >= 5 (如'灰') ---- 转化为灰度值125
笔画数 >= 1 (如'二') ---- 转化为灰度值0
用该表去替换上面那个灰度表即可得到用汉字做的字符画

再更进一步,根据用户输入的任意汉字来做字符画(本质就是把这些汉字用笔画数排序,然后生成表)

emmm,如果几个字的笔画数相差不多,效果会比较差。这时候我们可以将颜色也赋予上去。
ini
// 计算灰度值的时候,把颜色也记录下来,后面赋给 css 即可
const gray = r * 0.3 + g * 0.6 + b * 0.1;
const color = `rgb(${r},${g},${b})`;
charCanvas += toText(gray, color);

尽管带上颜色会有些脱离字符画的本质,但在某些情况下还是很有意义的。比方说给对象弄一个

你甚至可以把这些字符用彩笔写在纸上给对象,属实是理科男的浪漫了。
后记
本来还应该写两部分:
- gif / video 的字符动画
- 基于字符画的 canvas 动画
但懒,卒
如果这篇文章对你有帮助,不妨点个赞吧~