浏览器控制台输出一个南山大王,2022豹富!

1,649 阅读3分钟

PK创意闹新春,我正在参加「春节创意投稿大赛」,详情请看:春节创意投稿大赛

样图.png

见到这个南山大王的家人们,2022都能『豹富』,吃的用的都给你送去!

构思

原图.png

将一个图片或文字转成字符画输出,总共分三步👵🏻:

  1. 将图片或文字绘制在 canvas 上。
  2. 利用 getImageData 取出像素点亮度信息,存储。
  3. 将亮度信息映射到字符集,拼接字符串输出。

接口设计

文本和图片对于输入的要求是不同的。

inerface ImageOption {
    type: 'image';
    imageSrc: string;
    scale?: number;
    chartSet?: string[];
}
  • imageSrc: 一个图片地址是必要的(网络资源考虑异步加载、异常处理)。
  • scale: 缩放比例,与字符画输出的『清晰度』相关。取值大于1意味着对放大后的图片进行采样,输出的字符串较长,图像清晰。反之,输出的字符串短,图像模糊。
inerface TextOption {
    type: 'text';
    text: string;
    fontFamily?: string;
    fontWeight?: string|number;
    fontSize?: number;
    chartSet?: string[];
}
  • text: 提供文本内容是必要的。
  • fontFamilyfontWeight: 字体和字重可改变输出文本样式。
  • fontSize: 字号,与字符画输出的『清晰度』相关。由于文本字号的感受更直观(用户无需考虑他想要的 30px 和内部实现 20px 的关系),因此这里不使用 scale 。

chartSet 在文本和图片中都可配置。这里可以理解为亮度([0, 1]区间)与实际输出字符的映射,比如:

[' ', '-', '@', '▊'] 将亮度[0, 0.25)区间映射到'▊',该单元视觉效果偏暗;将亮度(0.75, 1]区间映射到' ',该单元视觉效果偏亮。

调用方式应该足够简洁,考虑到图片的加载异常,函数返回 Promise。

ConsoleASCII({
    type: 'image',
    imageSrc: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fww1.sinaimg.cn%2Fmw690%2F006Twd0Dgy1gyi478eenpj30wi0s6gs0.jpg&refer=http%3A%2F%2Fwww.sina.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1646042459&t=92afd341a0ffa107182bc2e51097bff5',
}).then(ca => {
    console.log(ca.toString());
});

在 async-awit 中只需添加 await 关键字,很优雅呢。

const ca = await ConsoleASCII({
    type: 'image',
    imageSrc: 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fww1.sinaimg.cn%2Fmw690%2F006Twd0Dgy1gyi478eenpj30wi0s6gs0.jpg&refer=http%3A%2F%2Fwww.sina.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1646042459&t=92afd341a0ffa107182bc2e51097bff5',
});
console.log(ca.toString());

1. canvas 绘制

  1. 创建 canvas
  2. 配置项缓存
  3. 字符集赋默认值 这些是通用的步骤,后面就需要区分图片和文本做不同的处理了。
class ConsoleASCII {
    canvas: HTMLCanvasElement;
    option: Option;
    chartSet: string[];

    constructor(option: Option) {
        this.canvas = document.createElement('canvas');
        this.option = {...option};
        this.chartSet = option.chartSet || [' ', '.', '-', '#', 'o', '@'];
        if (this.option.type === 'image') {
            this.#initImage()
        }
        else {
            this.#initText()
        }
        if (DEBUG) {
            document.body.appendChild(this.canvas);
        }
    }
    #initImage() {
    }
    #initText() {
    }
}

1.1 绘制图片

  1. new Image()加载图片。注意,image.crossOrigin = 'Anonymous'表明期望获取跨域图片,仍需服务端跨域支持。
  2. 图片资源准备完成后,按 scale 调整 canvas 宽高,绘制图片。
const {scale = 1, imageSrc} = this.option;

const image = new Image();
image.src = imageSrc;
image.crossOrigin = 'Anonymous';

image.onload = () => {
    const width = image.width * scale;
    const height = image.height * scale;
    this.canvas.width = width;
    this.canvas.height = height;
    const ctx = this.canvas.getContext('2d');
    ctx!.drawImage(image, 0, 0, width, height);
    // 开始采样
};

1.2 绘制文本

  1. 使用measureText方法获取文本的宽度,以及由大小写字母宽度估计的文本高度。
  2. 调整 canvas 宽高适应文本,在白色背景绘制黑色文本。注意,由于 firefox 和 chrome 的 canvas 行高不同(bug),文本基线居中,绘制下沉0.5,确保行为一致。
const {
    fontFamily = '"Trebuchet MS", "Heiti TC", "微軟正黑體", "Arial Unicode MS", "Droid Fallback Sans", sans-serif',
    fontWeight = 'normal',
    fontSize = 30,
    text = 'hello world'
} = this.option;
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;

const ctx = this.canvas.getContext('2d');
ctx!.font = font;
const {width: textWidth} = ctx!.measureText(text);
const textHeight = Math.max(
    ctx!.measureText('m').width,
    ctx!.measureText('\uFF37').width
);

this.canvas.width = textWidth;
this.canvas.height = textHeight;
ctx!.fillStyle = 'hsl(360, 100%, 100%)';
ctx!.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx!.font = font;
ctx!.fillStyle = 'hsl(360, 0%, 0%)';
ctx!.textBaseline = 'middle'
ctx!.fillText(text, 0, fontSize * .5);
Promise.resolve().then(() => {
    // 开始采样
});

2. 图像采样

  1. 确定采样单元大小,并创建相应大小的二维数组空间,并填充空值。
  2. 使用getImageData获取像素点信息。
  3. 遍历像素点信息,在每个采样单元内按亮度聚合(RGB转HSL,取L维度),在二维数组指定空间写入。 采样.png

getImageData 返回指定区域内从左上(0, 0)点遍历的像素信息(RGBA),比如

一个2*2的范围返回值是[R00, G00, B00, A00, R01, G01, B01, A01, R10, G10, B10, A10, R11, G11, B11, A11]

写入前需将[0, 1]区间的亮度调整到与字符集索引对应的值上。另外,要做一次翻转,因为 HSL 颜色模型中,L从0到1是由暗变亮,与字符集的方向相反。

this.grid[gridY][gridX] = this.chartSet.length - 1 - Math.round(avgL * (this.chartSet.length - 1));
  1. 遍历输出
toString() {
    let str = '';
    let row = 0;
    this.grid.forEach(list => {
        list.forEach(value => {
            str += this.chartSet[value];
        })
        str += '\n';
    });
    return str;
}

到这里,南山大王的字符画已经出现在控制台了,是否该结束了?

还没有,因为这不够『抠』。假设字符集长度为4,那么索引0-3只需要占用两个二进制位,用一个由两位二进制组成的数组存储可以充分利用内存。

4. 点阵读写器

定型数组是 ArrayBuffer 的一种类型视图,可以理解为由特定数值类型组成的数组。

类型单个元素值的范围大小(bytes)描述
Int8-128 to 12718 位二进制有符号整数
Uint80 to 25518 位无符号整数
Int16-32768 to 32767216 位二进制有符号整数
Uint160 to 65535216 位无符号整数
Int32-2147483648 to 2147483647432 位二进制有符号整数
Uint320 to 4294967295432 位二进制无符号整数

这一定程度上满足了我们的需求。不过我们的字符集可能还不到127,用Int8也会浪费一定的资源。因此,使用 Uint8 定型数组,将未使用的二进制位借给后面的存储单元看起来可行。

这里使用 Uint8 的原因:

  1. 当前存储索引不存在负数。

  2. 字符集索引偏小(很少配置一个超过256的字符集),使用最小单元Uint8降低对单个存储单元的读写频率。

存储.png 代码实现如下:

function dec2bin(dec: number, length = 8){
    return `${new Array(length).fill('0').join('')}${(dec >>> 0).toString(2)}`.slice(-length);
}
function bin2dec(bin: string){
    return parseInt(bin, 2);
}
class GridArray {
    row: number;
    col: number;
    length: number;
    buffer: Uint8Array;
    constructor(row = 1, col = 1, length = 1) {
        this.row = Math.max(Math.floor(row), 1);
        this.col = Math.max(Math.floor(col), 1);
        this.length = length;
        this.buffer = new Uint8Array(Math.ceil(row * col * length / 8));
    }
    forEach(cb: (value: number, x: number, y: number) => unknown){
        this.buffer.reduce((pre, item, index) => {
            const binStr = `${pre}${dec2bin(item)}`;
            for (let i = 0; i * this.length < binStr.length; i++) {
                const point = this.offsetToPoint({count: index, i: i * this.length});
                if (point.x > this.col - 1 || point.y > this.row - 1) {
                    break;
                }
                if ((i + 1) * this.length > binStr.length) {
                    return binStr.slice(i * this.length);
                }
                cb(bin2dec(binStr.slice(i * this.length, (i + 1) * this.length)), point.x, point.y);
            }
            return '';
        }, '');
    }
    pointToOffset(point: {x: number, y: number}) { // 起始点
        const index = (point.y * this.col + point.x) * this.length;
        return {
            count: Math.floor(index / 8),
            i: index % 8
        }
    }
    offsetToPoint(offset: {count: number, i: number}) {
        const index = Math.floor((offset.count * 8 + offset.i) / this.length);
        return {
            y: Math.floor(index / this.col),
            x: index % this.col
        }
    }
    toString() {
        let str = '';
        let row = 0;
        this.forEach((value, x, y) => {
            if (y !== row) {
                str += '\n';
                row = y
            }
            str += value;
        });
        return str;
    }
    setPoint(point: {x: number, y: number}, value: number) {
        const offset = this.pointToOffset(point);
        let targetCut = dec2bin(value, this.length);
        let orginBin = '';
        for (let i = offset.count; i < this.buffer.length; i++) {
            orginBin += dec2bin(this.buffer[i]);
            if (orginBin.length >= offset.i + this.length) {
                break;
            }
        }
        let targetBin = '';
        for (let i = 0 ; i < orginBin.length; i++) {
            if (i < offset.i || i >= offset.i + this.length) {
                targetBin += orginBin[i];
            }
            else {
                targetBin += targetCut[i - offset.i];
            }
            if (i % 8 === 7) {
                this.buffer[Math.floor(i / 8) + offset.count] = bin2dec(targetBin.slice(i - 7, i+1));
            }
        }
        
    }
    getPoint(point: {x: number, y: number}) {
        const offset = this.pointToOffset(point);
        let bin = ''
        for (let i = offset.count; i < this.buffer.length; i++) {
            bin += dec2bin(this.buffer[i]);
            if (bin.length >= offset.i + this.length) {
                break;
            }
        }
        return bin2dec(bin.slice(offset.i * this.length, (offset.i + 1) * this.length));
    }
}

到这里我们可以根据点阵的行、列数、数据的字长,灵活申请足够的存储空间。

好像还差点什么。。。 样图1.png

5. 效果不清晰?

5.1 字符集太少

上图配置了[' ', '.', '-', '#', 'o', '@']六个字符组成的字符集。增加字符数量,细化亮度的展示可以获得更好的效果。

样图2.png

5.2 黑白滤镜算法

前面提及使用了HSL模型的L维度作为明暗指标,常见的黑白滤镜算法还有:

  • 均值:(R + G + B) / (3 * 255)
  • 加权平均:(0.299 * R + 0.587 * G + 0.114 * B) / 255权重与人类对三色的敏感程度相关,也存在个体差异

黑白滤镜.png

哪一种算法的视觉效果更好就见仁见智了。

是否还能更『清晰』一点? 我们觉得模糊,是因为一些中间的灰度表达不够明显。处理数据使整体向上(1)、下(0)限偏移,更增加图像的对比度试试。

y=0.5cos(xπ)+0.5 y = -0.5 * cos(x * π) + 0.5

处理函数.png

滤镜增加对比度.png

经过处理后,图像明暗对比更明显了😄

样图3.png


小贴士

当输出字符被迫换行时,字符画将失去视觉效果,需要手动调整控制台字号。

  • Windows: ctrl + 鼠标滚轮
  • Mac: command + 【+】或【-】

完整代码

加油.png