coder 当然要做有意思的事:诶,我弄个字符画给你!

在大学的时候,我很喜欢用 js 做一些有趣的小玩意。今天翻出了以前的代码,摆弄之余又有了一些新的思考。

前言

字符画,顾名思义,就是字符组成的画,这是例图(兄弟,你好香)。

字符画篇

原理

整体思路就是,读取图片的每个数据点,将其转化为灰度点,最后用等效的灰度符号进行替换。

读取图片像素点

我们需要利用 canvas 来做这件事情,canvascontext 拥有一个 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 动画

但懒,卒

如果这篇文章对你有帮助,不妨点个赞吧~

相关推荐
写不出来就跑路9 分钟前
基于 Vue 3 的智能聊天界面实现:从 UI 到流式响应全解析
前端·vue.js·ui
OpenTiny社区11 分钟前
盘点字体性能优化方案
前端·javascript
FogLetter15 分钟前
深入浅出React Hooks:useEffect那些事儿
前端·javascript
Savior`L16 分钟前
CSS知识复习4
前端·css
0wioiw031 分钟前
Flutter基础(前端教程④-组件拼接)
前端·flutter
花生侠1 小时前
记录:前端项目使用pnpm+husky(v9)+commitlint,提交代码格式化校验
前端
一涯1 小时前
Cursor操作面板改为垂直
前端
我要让全世界知道我很低调1 小时前
记一次 Vite 下的白屏优化
前端·css
1undefined21 小时前
element中的Table改造成虚拟列表,并封装成hooks
前端·javascript·vue.js