一句话答案
混淆只做了一次可逆的像素置换:希尔伯特曲线给出全图唯一的遍历顺序,黄金比例给整条顺序一个固定偏移,而宽高同时决定这两者。只要知道宽高、且像素值没有被有损压缩改写,反向走一遍就能还原。小番茄算法就是这套思路的典型实现。
一、一张图,本质上是一段有顺序的像素
在浏览器的 Canvas 里,图片会被展开成一块连续的像素缓冲区。每个像素占 4 个字节,分别记录红、绿、蓝和透明度(RGBA)。如果图片宽 W、高 H,第 x 列、第 y 行的像素,在缓冲区中的起始位置是:
这个顺序叫“行优先”:先从左到右读完第一行,再读第二行,直到最后一行。绝大多数图像格式解码后都遵循这个布局。所谓“混淆”,并不是把颜色改掉,而是把这段有顺序的像素重新排队。
这一点很关键。只要操作只是置换,图片的颜色直方图就不会改变:原来有多少个绿色像素,混淆后还是多少个,只是它们被搬到了别的位置。解混淆要做的,就是找到每个像素原来的位置。
二、混淆改的是位置,不是内容
常见的图片混淆工具会做三件事:读取整张图的像素、按某个规则生成一条覆盖全图的索引序列、再按这条序列把像素搬到新位置。完成后重新编码成图片,画面就变成无法直接辨认的色块或噪点。
小番茄图片混淆算法同样只重排像素:它不修改颜色值,也不附加密钥,因此“还原”依赖的是同一套坐标,而不是密码。
这里没有密码,也没有密钥交换。规则本身是公开的,安全性来自“别人不知道你用了哪套规则、哪个尺寸”。这也是它和加密最本质的区别:混淆的目标是让预览不可读,而不是让数据不可解。
混淆像把一本书的所有字剪下来重新排页;解混淆像按原书的页码顺序重新装订。字还是那些字,但如果你连页码规则都不知道,就很难装回原样。
三、为什么用希尔伯特曲线,而不是随机打乱
最直观的混淆方式是随机洗牌:给每个像素生成一个随机目标位置。但随机洗牌有两个麻烦。第一,必须把随机种子或整张映射表保存下来,否则无法还原;第二,随机映射容易留下块状或条纹痕迹,视觉上仍能看出一些结构。
希尔伯特曲线(Hilbert curve)是一种“空间填充曲线”。它用一条连续、不交叉的线,依次穿过矩形里的每一个格子。对图片来说,这条线把二维的像素网格串成一维序列,而且每个像素恰好出现一次。它的局部性很好:在曲线上相邻的两个像素,在原图里通常也靠得很近。
标准希尔伯特曲线只适用于 2 的幂次方形。图片很少正好是正方形,所以实际实现使用广义希尔伯特曲线(常称 Gilbert 曲线),通过递归把任意宽高的矩形切成子矩形,再把子曲线拼接起来。小番茄图片混淆算法采用的正是这种广义曲线,所以它能覆盖任意宽高的矩形图片,而不是只处理正方形。
下面是从本站内核中摘出的递归骨架:
function generate(x, y, ax, ay, bx, by) {
const w = Math.abs(ax + ay);
const h = Math.abs(bx + by);
const dax = Math.sign(ax), day = Math.sign(ay);
const dbx = Math.sign(bx), dby = Math.sign(by);
if (h === 1) { // 只剩一行:直接填满
for (let i = 0; i < w; i++) {
push(x, y); x += dax; y += day;
}
return;
}
if (w === 1) { // 只剩一列:直接填满
for (let i = 0; i < h; i++) {
push(x, y); x += dbx; y += dby;
}
return;
}
let ax2 = Math.floor(ax / 2), ay2 = Math.floor(ay / 2);
let bx2 = Math.floor(bx / 2), by2 = Math.floor(by / 2);
const w2 = Math.abs(ax2 + ay2);
const h2 = Math.abs(bx2 + by2);
if (2 * w > 3 * h) {
if (w2 % 2 && w > 2) { ax2 += dax; ay2 += day; }
generate(x, y, ax2, ay2, bx, by);
generate(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by);
} else {
if (h2 % 2 && h > 2) { bx2 += dbx; by2 += dby; }
generate(x, y, bx2, by2, ax2, ay2);
generate(x + bx2, y + by2, ax, ay, bx - bx2, by - by2);
generate(x + (ax - dax) + (bx2 - dbx),
y + (ay - day) + (by2 - dby),
-bx2, -by2, -(ax - ax2), -(ay - ay2));
}
}
这段递归不依赖随机数,输入相同的宽高,永远得到相同的顺序。它也不依赖图片内容:纯色图、照片、带透明通道的 PNG,走的是同一条路径。
四、黄金比例偏移:不给密码,也能改变顺序
只有一条固定曲线还不够。如果直接把曲线顺序当作新顺序,混淆效果会很有限,而且不同图片之间的差异太小。实际算法会在曲线上再整体平移一段距离,这个距离由图片的像素总数决定:
(√5 − 1) / 2 ≈ 0.6180339887,正是黄金分割比例。用它与总像素数相乘并取整,可以得到一个与尺寸绑定、看似无规律的偏移量。之所以选黄金比例,是因为它是无理数,乘以不同的总数后,小数部分分布得比较均匀,不会总是落在整格或半格上。
平移是循环的:走到曲线末尾就回到开头。这样每个像素仍然有唯一的目标位置,不会重叠,也不会遗漏。整个过程不需要保存随机种子,因为偏移量可以由宽高当场算出来。
五、正向与逆向:同一套索引,方向相反
设曲线序列为 C,像素总数为 N,偏移量为 O。对序列中的第 i 个位置,它的目标位置是 (i + O) mod N。混淆与解混淆分别做:
解混淆:dst[C[i]] = src[C[(i + O) mod N]]
把这两个操作串联起来,每个像素先被搬到 i + O,再被搬回 i,结果自然回到原位。等价地看,解混淆就是沿曲线反向平移 N − O 个位置。
本站的实现把曲线保存为扁平的 Int32Array,把像素保存为 Uint32Array,一个 32 位整数正好承载一个 RGBA 像素,搬运时不需要拆解颜色通道:
const total = width * height;
const curve = curveFor(width, height);
const offset = offsetFor(width, height) % total;
const out = new Uint32Array(total);
if (mode === "enc") {
for (let i = 0; i < total; i++) {
const next = (i + offset) % total;
out[curve[next]] = src[curve[i]];
}
} else {
for (let i = 0; i < total; i++) {
const next = (i + offset) % total;
out[curve[i]] = src[curve[next]];
}
}
这里有两个容易忽略的细节。第一,curve 中保存的是行优先索引,而不是坐标对,这样每次寻址都少一次乘法。第二,透明度通道和颜色通道一起搬运,所以带透明区域的 PNG 不会在还原后变成白底或黑底。
六、为什么它不能当加密用
混淆后的图片仍然包含全部原始像素,只是顺序变了。任何拿到算法和宽高的人都能还原;即使不知道算法,颜色分布、尺寸、文件格式这些信息也没有被隐藏。它不具备加密的两个核心特征:密钥,以及对抗有动机攻击者的保密性。
| 对比项 | 图片混淆 | 加密 |
|---|---|---|
| 核心操作 | 重新排列像素 | 用密钥把数据变成密文 |
| 是否需要密钥 | 不需要,尺寸即可推算参数 | 需要,密钥决定能否解密 |
| 能否被他人还原 | 知道算法即可 | 没有密钥则不可行 |
| 信息是否完整保留 | 是,颜色值不变 | 是,但以密文形式存在 |
| 适用目的 | 避免预览被直接读懂 | 保护敏感数据与隐私 |
所以,证件照、合同扫描件、医疗影像、账号备份这类内容,应当使用加密压缩包或专用加密工具。混淆只能减少“被顺手看一眼”的机会,不能阻止有心人还原。
七、为什么尺寸一变,就还原不出来
解混淆需要三个条件同时成立:宽高与混淆时一致、每个像素的颜色值没有被改写、整张图没有被局部修改。缺一个,结果就会出问题。
- 裁剪:宽高变化,曲线路径和偏移量都变了;被裁掉的部分也不存在了。
- 缩放:像素被重采样,数量和颜色值都变了,映射关系无法对应。
- 旋转:宽高可能互换,像素坐标整体旋转,原曲线不再适用。
- JPG 压缩:离散余弦变换会改写像素值,色度抽样还会让边缘颜色混合。位置可能对,但颜色已经不准。
- 截图与转发:屏幕截图会重新编码,聊天软件的“普通发送”也可能压缩,相当于在正确排列上再叠一层噪声。
这也是为什么教程反复强调“原文件、原尺寸、PNG”。不是工具挑剔,而是数学条件必须满足。
八、本站实现的边界与取舍
本站的实现遵循同一套曲线与偏移规则,并在工程上做了几处取舍:
- 整块处理:用
Uint32Array一次搬运一个 RGBA 像素,避免逐通道循环。 - 扁平索引:曲线保存为
Int32Array,不创建成千上万个坐标数组,降低内存与垃圾回收压力。 - 明确上限:单张 800 万像素、单边 4096 像素。超过上限直接提示,不自动缩放,因为缩放会破坏尺寸条件。
- 本地完成:没有上传接口,也没有服务端任务;图片只在当前标签页的内存中流转。
- 保留透明:处理 RGBA 四个通道,导出 PNG 时透明度不变;导出 JPG 时会用白色填充透明区域。
- 元数据不保留:Canvas 重新编码会去掉 EXIF 等信息;浏览器在解码时已经应用了拍摄方向。
在测试环境里,约 800 万像素的曲线生成与一次置换合计在亚秒级;实际使用时的等待时间,主要花在图片解码和重新编码上。设备内存不足时,处理可能失败,这时换一张更小的图片通常比反复重试更有效。
九、术语表
| 术语 | 含义 |
|---|---|
| 像素重排 | 不改变颜色值,只改变像素在图像中的位置。 |
| 希尔伯特曲线 | 一种连续穿过矩形每个格子的空间填充曲线,局部性好。 |
| Gilbert 曲线 | 希尔伯特曲线在任意宽高矩形上的推广,通过递归拼接实现。 |
| 黄金比例偏移 | 用约 0.618 乘以总像素数得到的循环平移量。 |
| 有损压缩 | 编码时丢弃部分信息以减小体积,解码后无法逐像素还原。 |
| Alpha 通道 | 记录每个像素透明度的通道,PNG 支持,JPG 不支持。 |
| EXIF | 照片中的拍摄参数与方向信息,Canvas 导出时通常不会保留。 |
| 空间填充曲线 | 用一维路径遍历二维区域的曲线,常用于图像索引与降维。 |
读到这里,如果你只想把一张图还原,可以直接打开在线解混淆工具;如果还不确定操作步骤,建议先看使用教程,里面有一份失败排查清单。