阅读 951
摸一个塞尔达希卡文字转换器

摸一个塞尔达希卡文字转换器

希卡族文字转换器

希卡文字是游戏《塞尔达传说旷野之息》中一种虚构的文字,在塞尔达游戏中所有的希卡族的建筑上都能找到上面的符号的影子,一直以为这些只是装饰性的符号,直到看到塞学家的分析才恍然大悟,原来这些都是文字呀!不愧是老任,塞尔达天下第一!

希卡文可以与英文字符做相互映射,转换器实现的就是两种文字的相互转换,支持将英文字符转换成希卡文希卡文图片内容解析成英文

英文 -> 希卡文转化

虚构世界的文字往往是基于现实文字创造的,希卡文字与英文字母是一一对应的,映射如下:

知道了映射关系我们只需要将一个个字母转换成对应的希卡文就好了,先来准备下希卡文的文字素材,这里推荐一篇文章:从虚构世界的文字说起

作者十分贴心的实现了一套希卡文字体,我们可从网站中扒拉下字体文件:

3type.cn/css/fonts/s…

拿到字体文件其实我们配置下 @font-face 就可以直接使用了

@font-face {
  font-family: "SheikahGlyphs";
  src: url("https://3type.cn/css/fonts/sheikahglyphs-regular-webfont.woff2") format("woff2");
}

.sheikah-word {
    font-family: SheikahGlyphs;
}
复制代码
<span class="sheikah-word">abc</span>
复制代码

不过考虑到后面我们需要固定文字的格子与间距的大小,我们换一种用法将字体转成 svg 图标来使用。

使用使用上述工具我们得到字体文件的单个字符的 svg 文件了,然后导入到 iconfont 中生成字体图标:

<script src="//at.alicdn.com/t/font_2375469_s4wmtifuqro.js"></script>
复制代码

然后我们封装一个简单的文件图标组件:

<template>
    <svg
        class="word-icon"
        aria-hidden="true"
        :style="iconStyle"
    >
        <use v-if="iconName" :xlink:href="iconName" />
    </svg>
</template>

<script>
import { computed } from 'vue';

export default {
    name: 'WordIcon',

    props: {
        // 图标名称
        name: {
            type: String,
            required: true,
        },

        width: {
            type: Number,
            default: '',
        },

        height: {
            type: Number,
            default: '',
        },

        color: {
            type: String,
            default: '',
        },

        opacity: {
            type: String,
            default: '',
        },
    },

    setup: (props) => {
        const iconName = computed(() => props.name ? `#icon-${props.name}` : '');
        const iconStyle = computed(() => ({
            color: props.color,
            opacity: props.opacity,
            width: `${props.width}px`,
            height: `${props.height}px`,
        }));
        return {
            iconName,
            iconStyle,
        };
    },
};
</script>

<style>
.word-icon {
    overflow: hidden;
    width: 1em;
    height: 1em;
    padding: 0;
    margin: 0;
    fill: currentColor;
}
</style>
复制代码

英文字符的翻译的面板可以简单的实现,使用换行符 \n 拆分文字分组,替换不支持的字符为空字符:

<template>
    <section
        class="words-panel"
        ref="container"
    >
        <div
            class="words-panel__groups"
            v-for="(words, index) in wordGroups"
            :key="index"
        >
            <WordIcon
                class="words-panel__icon"
                v-for="(word, idx) in words"
                :key="idx"
                :name="word"
                :width="size"
                :height="size"
            >
                {{ word }}
            </WordIcon>
        </div>
    </section>
</template>

<script>
import { computed, ref } from 'vue';
import WordIcon from './icon.vue';

const WORDS = [
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
    'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
    'u', 'v', 'w', 'x', 'y', 'z', '.', '!', '?', '-',
];

export default {
    name: 'WordsPanel',

    components: {
        WordIcon,
    },

    data() {
        return {
            words: 'hello world',
            size: 60,
        };
    },

    setup: (props) => {
        const container = ref(null);
        const wordGroups = computed(() => {
            return props.words
                .toLowerCase()
                .split('\n')
                .map(words => words.split('').map(v => WORDS.includes(v) ? v : ''));
        });

        return {
            container,
            wordGroups,
        };
    },
};
</script>
复制代码

最后一步就是将希卡文字导出了,因为我们并没有涉及复杂的 DOM 结构与样式,这里我们直接偷懒使用前端 DOM 出图的方式将目标的文字面板直接导出一张图片,这块现成的库很多,如 html2canvasdom-to-image ,这里我们使用 dom-to-image 来出图,这个库十分的小巧使用也十分简单。

这里简单讲下 DOM 出图的原理,DOM 出图主要是利用了 SVG 元素的 foreignObject 标签,我们可以在 foreignObject 标签下塞入自定义 html 片段然后将整个 svg 作为一张图片 drawImage 到 canvas 上实现出图:

(async function () {
    const svg =
    `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
        <foreignObject x="0" y="0" width="200" height="200">
            <div xmlns="http://www.w3.org/1999/xhtml">OUTPUT</div>
        </foreignObject>
    </svg>`;

    const dataUrl = 'data:image/svg+xml;charset=utf-8,' + svg;

    const loadImage = (url) => {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => resolve(img);
            img.onerror = (e) => reject(e);
            img.src = url;
        });
    };

    const image = await loadImage(dataUrl);
    const canvas = document.createElement('canvas');
    canvas.width = 120;
    canvas.height = 60;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(image, 0, 0);
    console.log(canvas.toDataURL());
})();
复制代码

dom-to-image 内部处理与上面的流程类似,处理我们的字体图标时会生成类似于如下的 SVG 结构:

<svg xmlns="http://www.w3.org/2000/svg">
	<foreignObject width="120" height="60">
    		<svg>
            		<use xlink:href="#icon-a" />
        	</svg>
	</foreignObject>
</svg>
复制代码

图标中使用的特殊的 use 标签,use 所引用的内容存在于全局,所以在出图时我们需要处理下这部分的 symbol 引用。

处理成如下的结构:

<svg xmlns="http://www.w3.org/2000/svg">
	<foreignObject width="120" height="60">
    		<svg>
      			<symbol id="icon-a" viewBox="0 0 1024 1024">
        			<path d="xxx"></path>
      			</symbol>
      			<use xlink:href="#icon-a" />
    		</svg>
   	</foreignObject>
</svg>
复制代码

最终的图片导出我们可以这样处理:

import domtoimage from 'dom-to-image';

// fix 节点中 svg 图标依赖
function fixSvgIconNode(node) {
    if (node instanceof SVGElement) {
        const useNodes = Array.from(node.querySelectorAll('use') || []);
        useNodes.forEach((use) => {
            const id = use.getAttribute('xlink:href');
            // 将 svg 图片中依赖的 <symbol> 节点塞到当前 svg 节点下
            if (id && !node.querySelector(id)) {
                const symbolNode = document.querySelector(id);
                if (symbolNode) {
                    node.insertBefore(
                        symbolNode.cloneNode(true),
                        node.children[0]
                    );
                }
            }
        });
    }
    return true;
}

export default function exportImage (node) {
    return domtoimage.toPng(node, { filter: fixSvgIconNode })
        .then(dataUrl => {
            console.log(dataUrl);
        });
}
复制代码

自此英文到希卡文的转换就完成了,重点的我们看下如何实现希卡文卡片内容的翻译。

希卡文 -> 英文转化

我们最终产出的内容是一张图片,我们需要考虑如何图片的内容“翻译”出来,这里的翻译我打了个引号,可能我们并不需要真正的解析翻译出图片的内容,或者我们可以考虑一种投巧的方式将原本的文字信息隐藏在最终图片中?

我们先来试试投巧的方式,将原本的文字信息隐藏图片中。

图片盲水印

将目标信息隐藏在图片中而不影响图片视觉展示的技术可以称为图片隐写术,如果隐藏的目标对象是一张图片的话则可以称之为盲水印,盲水印常用于图片版权保护,图片的泄密追踪等。

我们先来试试一种最简单的图片隐写手段:LSB(Least Significant Bit)最低有效位

我们都知道一张图片的每个像素都是由 RGB 通道的颜色混合而成,而 RGB 某个通道的上色值 +1 或 -1 我们在肉眼上是无法区分,就拿 rgb(0, 0, 0)rgb(1, 0, 0) 你在肉眼上能区分吗?

显然不行,所以我们可以在 RGB 某个通道上对色值进行增减 1 使其变为奇数(对应 1) 或者 偶数(对应 0),我们只需将隐藏的信息转成二进制就可以映射到某个颜色通道的奇偶数值就可以实现信息的隐藏了。解析的过程也很简单,读取目标通道上的色值,奇数为 1 偶数为 0 反转出 01 的二进制数据再还原成原始数据就好了。

hw-no-meta.png

上面希卡文对应的信息是 hello world,我们现在试着将它隐藏在上面的图片的中。

首先将 hello world 转换成二进制,我们当然可以将每个字母转成对应的 ASCII 码然后转成对应的 8 位二进制数,不过既然是隐藏信息对文本进行编码我们何不用一些现成的工具(其实就是偷懒啦),二维码就是一个很不错的载体。

我们可以生成一张 hello world 对应的黑白二维码:

hw-qrcode.gif

下面就是将二维码的信息隐藏到希卡文结果的图片中,因为是黑白的二维码图片,我们可以很简单将黑色像素的值归为 0(偶数位)白色像素的值归为 1(奇数位),但因为二维码图片和希卡文图片的尺寸并不一致,我们不方便将两张图片的像素位一一对应,我可以先将二维码的尺寸调整成和希卡文图片一致。

hw-qrcode.jpg

图片准备好了,我们先来实现隐藏和解析水印的方法:

// 写入二维码水印
function writeMetaInfo(baseImageData, qrcodeImageData) {
    const { width, height, data } = qrcodeImageData;
    for (let x = 0; x < width; x++) {
        for (let y = 0; y < height; y++) {
            // 选用 r 通道来隐藏信息
            const r = (x + y * width) * 4;
            const v = data[r];
            // 二维码白色部分(背景)标识为 1,黑色部分(内容)标识为 0
            const bit = v === 255 ? 1 : 0;
            // 如果当前 R 通道色值奇偶性和二维码对应像素不一致则进行加减一使其奇偶性一致
            if (baseImageData.data[r] % 2 !== bit) {
                baseImageData.data[r] += bit ? 1 : -1;
            }
        }
    }
    return baseImageData;
}

// 读取二维码水印
function readMetaInfo(imageData) {
    const { width, height, data } = imageData;
    const qrcodeImageData = new ImageData(width, height);
    for (let x = 0; x < width; x++) {
        for (let y = 0; y < height; y++) {
            // 读取 r 通道息
            const r = (x + y * width) * 4;
            // 奇数颜色为白色 255,偶数颜色为黑色 0
            const v = data[r] % 2 === 0 ? 0 : 255;
            qrcodeImageData.data[r] = v;
            qrcodeImageData.data[r + 1] = v;
            qrcodeImageData.data[r + 2] = v;
            qrcodeImageData.data[r + 3] = 255;
        }
    }
    return qrcodeImageData;
}
复制代码

完整的示例在这里 ,下面是隐藏二维码后的希卡文图片,是不是肉眼看不到什么变化?

对应的希卡片解析出的二维码如下,虽然带有一些噪点信息,但不影响二维码的识别。

最早的一版希卡图片识别就是用图片的最低有效位来隐藏信息的,完成的时候兴高采烈准备分享到微信让小可爱看下,等等!隐约记得微信会压缩图片,要不发微信再下载下来试试?

苍天呐!果然通过微信分享后图片会经过一些压缩处理(微信会把 PNG 图片都处理成 JPG 图片),导致我们隐藏在图片中奇偶位信息丢失,试着解析了下微信的分享压缩过后的图片最终得出图片如下:

最低有效位的实现简单,但隐藏信息抗干扰能力却很差,图片的压缩很容造成奇偶位信息的丢失。我们需要考虑如何提高隐藏信息抗干扰能力,最低有效是将信息隐藏在某个像素通道上的,如果我们可以把隐藏信息的范围扩大呢?比如说二维码是用一个个黑白的色块标识数据比特位。我们是否能通过一个个色块来隐藏信息呢?看个例子:

上面的色块影藏的什么信息呢?

[100, 200]
[01100100, 11001000]
复制代码

其实就是用了 16 个黑白色块表示表示了数字 100 和 200,黑色表示 0 白色表示 1,解析也十分简单,上面的图片拆分成 16 个色块,检查每个色块,黑色的读取为 0 白色的读取为 1。上面使用黑白色来映射 01 我们换个规则,比如用 rgb(0, 0 , 0) 表示 0 rgb(2, 2, 2) 表示 1。生成的图片长什么样呢?

肉眼是不是很难看出色差,但我们确实是把信息影藏进图片里了,解析规则也很简单,拆分色块读取颜色,rgb(0, 0 , 0) 为 0,大于 rgb(0, 0, 0) 的为 1,这样一定程度上只要图片不是压缩的过分,我们还是能解析出原始信息的,当然提高两个颜色间的差值对比也不失为一种方法(肉眼不可见的范围内尽量拉高)。

简单的实现如下:

// 统一成 8 位
function paddingLfet(bits) {
    return ('00000000' + bits).slice(-8);
}

function write(data) {
    const bits = data.reduce((s, it) => s + paddingLfet(it.toString(2)), '');
    const size = 100;
    const width = size * bits.length;
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = size;
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = '#0000000';
    ctx.fillRect(0, 0, width, size);
    for (let i = 0; i < bits.length; i++) {
        if (Number(bits[i])) {
            ctx.fillStyle = '#020202';
            ctx.fillRect(i * size, 0, size, size);
        }
    }
    return canvas.toDataURL();
}

async function read(url) {
    const image = await loadImage(url);
    const canvas = document.createElement('canvas');
    canvas.width = image.naturalWidth;
    canvas.height = image.naturalHeight;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(image, 0, 0);
    const size = 100;
    const bits = [];
    for (let i = 0; i < 16; i++) {
        const imageData = ctx.getImageData(i * size, 0, size, size);
        const r = imageData.data[0];
        const g = imageData.data[1];
        const b = imageData.data[2];
        bits.push(r + g + b === 0 ? 0 : 1);
    }
    return bits;
}
复制代码

完整的示例在这里

这种方法稳是挺稳的,但能隐藏的信息太少了,我们用来隐藏大量的文字信息并不实用,倒是可以隐藏一些关键的信息,后面我们会用这种方式去记录希卡文卡片的格子大小,用于图片的解析。

图片的隐藏水印是一门高深的学问,以上只是些朴素实现,实际生产中一版是利用傅里叶变换生成图片的频域图,然后将水印信息隐藏在频域图中再做傅里叶逆变换还原成正常的图片,这样生成的图片有很好的抗干扰能力,不过这块超纲了(啃不动),摸清楚了再来试试。

相似图片识别

接上面的问题,既然隐藏文字信息的“投巧”方案走不通,那我们就试着真正去解析图片的内容吧~

识别图片的文字能想到技术就是 OCR,也扒拉到了现成的工具 tesseractjs,不过想要实现一套希卡文字的识别则需要训练生成希卡文字的 raineddata 才行,这里我们试着用一种朴素的方式来实现(主要是太菜玩不转😂)。

对于生成的希卡图片,我们已经知道符号和英文字母的映射关系,而且它们都是由同一套字体生成,文字按照同样的格子大小排布在图片中,空格子表示空字符串,如果我们能把文字内容拆分一个个格子,再与已知字符图片进行匹配,挑出最接近图片字符图片不就现实了文字内容的识别码?这里最核心的内容其实就是如何实现两张相似图片的识别

我们先来确认两个关键信息:

  • 格子的大小
  • 标准的希卡符号字典图片

对于一张希卡图片我们总得知道它的格子大小才能做拆分吧?其实在生成的图片中我们已经偷偷把这些信息藏进去了,我贴张图片大家就懂了:

还记得上面使用色块隐藏信息的方法吗?生成的希卡图片时我们偷偷在第一行藏了些隐藏信息,不过使用的颜色与背景色十分接近肉眼很难区分罢了。

图片的首行我们塞了三个关键信息:文字排列方式(0 or 1 标识)、文字格子的大小,图片的宽度(几个格子大小),每个信息二进制为 8 bit 长度,总长度 24 位。解析时我们拿到图片我们只需要截取图片第一行(高度随意 2 ~ 4 像素足以)拆分均等的 24 份,第一份的颜色用于标识 0(因为前八位表示文字排列只有 01 两种情况,01 换成 8 位二进制前 7 位都是 0),剩下的 23 个色块颜色与第一块相同的标识为 0 不用则标识为 1,简单暴力。

通过上面的方法我们可以拿到最关键的信息图片格子大小,我们可以按照格子大小将图片拆分成一个个均等的格子:

最终我们可以得到这样的一个格子,现在我们需要做的就是从字典图片中匹配出这个符号,字典图片是我们提前准备好的,就是下面这张:

图片的格子大小为 100,从左到右分别是:abcdefghijklmnopqrstuvwxyz0123456789.-!?,我们一样可以按顺序拆分每个字母对应的符号图片。

剩下的就是比较两张图片相似性,从字典图片中找出最相似的图片,相似图片的识别其实原理很简单,之前有很详细的整理过一篇文章这里就不多赘述了。

下面所有涉及的相似图片检查的内容都在这了 相似图片识别的朴素实现,有兴趣的同学可以看看。

姑且概述相似图片比较的原理,我们没法很直接去比较两张图片是否相似,如果图片两张都是以二进制方式表示呢?如果两张图片都能变成下面同等长度的二进制字符串,我们比较两张图片相似性,只需要判断这两字符的同个位置上有差异个数(汉明距离),差异越小,图片越相似。

00101101
00111001
复制代码

处理步骤是:

  • 将图片缩成 8 x 8 的大小
  • 对图片进行灰度处理
  • 对图片进行二值化处理(统一成黑白可以映射成 01)
  • 输出图片指纹

工具在这,上面是统一将图片缩小成 64x64 大小的样子,8x8 大小的图片指纹如下:

我们需要做的是生成 40 个英文字符对应的图片指纹(8x8 大小),然后将解析的图片拆分的格子也按相同的流程生成对应的图片指纹(8x8 大小),接下来只要从字典中匹配出汉明距离最小的指纹,也就匹配出了原始的英文字符了,然后按照文字排列的方式将文本按顺序输出就好了。

具体代码实现的代码比较多就不往这里贴了,有兴趣的同学可以点这里看代码实现

其他

这个仓库其实建了很久了,那时塞尔达玩的正入迷,想搞了希卡文字卡片生成器玩玩的,然后咕咕咕的放了两年,新年开工摸鱼整理仓库时发现的,刚好想试试 vite 和 vue3,于是摸鱼写了个小工具,翻了翻仓库发现有太多被自己搁置的东西了,希望你对这个世界还感好奇吧~

最后给特别的你~

TO MM

文章分类
前端
文章标签