原理拆解 · 约 12 分钟

解混淆原理:一条曲线如何把像素送回原位

混淆图看起来像随机噪声,但它的像素并没有消失,只是换了一个排列顺序。理解“曲线 + 偏移 + 尺寸”这三件事,就能明白解混淆为什么可逆,也能判断一张图为什么还原失败。

阅读约 12 分钟 更新于 2026-10-29 作者:解混淆

一句话答案

混淆只做了一次可逆的像素置换:希尔伯特曲线给出全图唯一的遍历顺序,黄金比例给整条顺序一个固定偏移,而宽高同时决定这两者。只要知道宽高、且像素值没有被有损压缩改写,反向走一遍就能还原。小番茄算法就是这套思路的典型实现。

一、一张图,本质上是一段有顺序的像素

在浏览器的 Canvas 里,图片会被展开成一块连续的像素缓冲区。每个像素占 4 个字节,分别记录红、绿、蓝和透明度(RGBA)。如果图片宽 W、高 H,第 x 列、第 y 行的像素,在缓冲区中的起始位置是:

index = (y × W + x) × 4

这个顺序叫“行优先”:先从左到右读完第一行,再读第二行,直到最后一行。绝大多数图像格式解码后都遵循这个布局。所谓“混淆”,并不是把颜色改掉,而是把这段有顺序的像素重新排队。

这一点很关键。只要操作只是置换,图片的颜色直方图就不会改变:原来有多少个绿色像素,混淆后还是多少个,只是它们被搬到了别的位置。解混淆要做的,就是找到每个像素原来的位置。

二、混淆改的是位置,不是内容

常见的图片混淆工具会做三件事:读取整张图的像素、按某个规则生成一条覆盖全图的索引序列、再按这条序列把像素搬到新位置。完成后重新编码成图片,画面就变成无法直接辨认的色块或噪点。

小番茄图片混淆算法同样只重排像素:它不修改颜色值,也不附加密钥,因此“还原”依赖的是同一套坐标,而不是密码。

这里没有密码,也没有密钥交换。规则本身是公开的,安全性来自“别人不知道你用了哪套规则、哪个尺寸”。这也是它和加密最本质的区别:混淆的目标是让预览不可读,而不是让数据不可解。

混淆像把一本书的所有字剪下来重新排页;解混淆像按原书的页码顺序重新装订。字还是那些字,但如果你连页码规则都不知道,就很难装回原样。

三、为什么用希尔伯特曲线,而不是随机打乱

最直观的混淆方式是随机洗牌:给每个像素生成一个随机目标位置。但随机洗牌有两个麻烦。第一,必须把随机种子或整张映射表保存下来,否则无法还原;第二,随机映射容易留下块状或条纹痕迹,视觉上仍能看出一些结构。

希尔伯特曲线(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,走的是同一条路径。

曲线从左上角进入,依次穿过每个格子,最终覆盖整张图。每个像素在序列中恰好出现一次。

四、黄金比例偏移:不给密码,也能改变顺序

只有一条固定曲线还不够。如果直接把曲线顺序当作新顺序,混淆效果会很有限,而且不同图片之间的差异太小。实际算法会在曲线上再整体平移一段距离,这个距离由图片的像素总数决定:

offset = round( ((√5 − 1) / 2) × W × H ),再对 W × H 取模

(√5 − 1) / 2 ≈ 0.6180339887,正是黄金分割比例。用它与总像素数相乘并取整,可以得到一个与尺寸绑定、看似无规律的偏移量。之所以选黄金比例,是因为它是无理数,乘以不同的总数后,小数部分分布得比较均匀,不会总是落在整格或半格上。

平移是循环的:走到曲线末尾就回到开头。这样每个像素仍然有唯一的目标位置,不会重叠,也不会遗漏。整个过程不需要保存随机种子,因为偏移量可以由宽高当场算出来。

注意:偏移量不是密码。任何人知道宽高和算法,都能算出同一个值。它的作用是打乱视觉结构,而不是保护内容。

五、正向与逆向:同一套索引,方向相反

设曲线序列为 C,像素总数为 N,偏移量为 O。对序列中的第 i 个位置,它的目标位置是 (i + O) mod N。混淆与解混淆分别做:

混淆:dst[C[(i + O) mod N]] = src[C[i]]
解混淆: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 导出时通常不会保留。
空间填充曲线用一维路径遍历二维区域的曲线,常用于图像索引与降维。

读到这里,如果你只想把一张图还原,可以直接打开在线解混淆工具;如果还不确定操作步骤,建议先看使用教程,里面有一份失败排查清单。