小番茄解混淆
深入了解

算法原理

整个工具只做两件事:把二维的像素排成一条一维队列,再让这条队列整体平移一段距离。把这两件事讲透,你就完全掌握了它。

适合对实现细节感兴趣的读者 · 含伪代码

一句话概括

对一张宽 w、高 h 的图片,先生成一条走遍全部 w×h 个格子的希尔伯特曲线, 得到一张“第几个格子在哪里”的坐标表;然后按曲线顺序,把第 i 个格子里的像素搬到第 (i + s) mod (w×h) 个格子的位置上,其中步长 s 取像素总数的黄金分割比例。 解混淆就是把这套搬运反过来做一遍。

图片在内存里长什么样

浏览器通过画布接口读取图片后,得到的是一个一维的一维数组:每个像素占 4 个字节, 依次是红、绿、蓝与透明度。第 (x, y) 个像素在这个数组里的起点是 4 × (x + y × 宽)。也就是说,二维画面在一维数组里是“逐行铺开”的。

直接对一维数组做位移会出现一个问题:逐行铺开意味着“换行处”的空间距离被压缩成了 1, 图片最右边一列与下一行最左边一列会变成邻居。如果照着一维顺序平移, 画面上相隔很远的两个区域会被拼在一起,还原时稍微有点误差就会整行错位。 更好的做法是让搬运顺序符合画面的二维邻接关系——这就是空间填充曲线的用武之地。

为什么用空间填充曲线

空间填充曲线是一条能走遍整个矩形区域、且每一步只移动到相邻格子的路径。 它同时具备两个好性质:

  • 不重不漏。每个格子恰好被访问一次,于是“曲线上的名次”和“格子坐标”可以互相换算。
  • 局部性好。曲线上的相邻名次,在画面上也是相邻格子;曲线上一段连续区间,在画面上是一块连通的区域。

第二条是关键。它意味着:如果把像素沿着曲线整体平移一段,原本相邻的像素会被甩到很远的地方, 但整体上仍然是“一块一块地搬家”,而不是把画面切得支离破碎。等距平移之后, 画面上的每个局部邻域被打散,人眼无法再重建轮廓;而还原时只要反向平移相同的距离,一切归位。

常见的选择还有 Z 序(Morton 曲线)与蛇形扫描。Z 序实现最简单,但它在象限边界处会出现长距离跳跃, 破坏局部性;蛇形扫描则在行与行之间形成规则条纹。希尔伯特曲线在同样长度下相邻跳变距离最小, 视觉上的混乱程度也最均匀,因此被本工具采用。

希尔伯特曲线怎么长出来

经典希尔伯特曲线只处理 2ⁿ × 2ⁿ 的正方形网格。本站使用的是一种广义版本, 可以处理任意宽高的矩形:把整块矩形看成一个平行四边形,用两个向量描述—— 主方向向量 (ax, ay) 与正交方向向量 (bx, by), 当宽大于等于高时从左上向右铺,否则从上往下铺。

递归时有三种情况:

  1. 退化成一行(正交方向长度为 1):直接顺序填满这一行。
  2. 退化成列(主方向长度为 1):直接顺序填满这一列。
  3. 长条情形(宽度超过高度的 1.5 倍):把长边对半切,递归两次,先把左半段走完再走右半段。
  4. 常规情形:把矩形切成三块——先走一小块,再走中间的长条,最后走剩余的一块。 注意最后一块必须用反向的向量递归,否则连接处会出现不连续的跳跃。

切分时还有一个细节:为了让每段长度尽量落在偶数格上,代码会在半长为奇数且当前段长大于 2 时, 把半长加上一个单位。这个“偏好偶数步”的调整能显著减少细长的锯齿段。

想直接看结果,打开曲线实验室,把网格调到 16×16 并勾选“显示格子序号”, 曲线会以动画形式一笔画出,每个格子上的数字就是它在曲线上的名次。

黄金分割位移:为什么是 0.618

位移量 s 取像素总数乘以黄金分割的倒数,即 s = round((√5 − 1) / 2 × w × h),约等于总数的 61.8%。

为什么不取一半,或者随便取一个数?三个理由:

  • 避开短周期。平移 s 后再平移 s,相当于平移 2s mod 总数。 如果 s 与总数有较大的公约数,反复操作几次就会回到初始状态;而黄金分割比例是无理数, 它的连分数收敛极慢,任意总数下都很难出现“挪几次就复位”的情况。
  • 打散距离足够大。61.8% 的偏移让曲线起点附近的数据被送到曲线的中后段, 在画面上表现为相距最远的两个角落之间的搬运,视觉混乱度最高。
  • 参数零传输。步长由图片尺寸直接算出来,不需要发送方额外告诉接收方, 使用者只需要知道“用本站即可”。

混淆与解混淆只差一个方向

设曲线上的名次为 i,搬运的目标名次是 j = (i + s) mod n,其中 n 是像素总数。

  • 混淆:读取名次 i 处的像素,写入名次 j 的位置。
  • 解混淆:读取名次 j 处的像素,写回名次 i 的位置。

就是同一组下标、读写方向互换。这也是为什么解混淆不需要任何额外信息—— 位移量可以从图片宽高算出来,曲线也能由宽高唯一确定。

核心步骤伪代码

// 1. 读取图片像素,宽 w、高 h
src = canvas.getImageData(0, 0, w, h)
dst = new ImageData(w, h)

// 2. 生成希尔伯特曲线坐标表:path[i] = [x, y]
path = hilbert(w, h)

// 3. 黄金分割位移
s = round((sqrt(5) - 1) / 2 * w * h)
n = w * h

// 4. 沿曲线整体平移
for i in 0 .. n-1:
    from = path[i]
    to   = path[(i + s) % n]
    fromOff = 4 * (from.x + from.y * w)
    toOff   = 4 * (to.x   + to.y   * w)
    if mode == "enc":
        dst[toOff .. toOff+4]   = src[fromOff .. fromOff+4]
    else:  // dec
        dst[fromOff .. fromOff+4] = src[toOff .. toOff+4]

// 5. 写回并导出
canvas.putImageData(dst, 0, 0)
canvas.toBlob(callback, "image/jpeg", 1)

第 4 步是整个算法里唯一的重活:n 次循环,每次搬运 4 个字节。 循环里没有随机数、没有条件分支依赖数据内容,因此同样的输入永远得到同样的输出, 这也保证了“对方打开同一个页面就能还原”。

开销与边界情况

方面表现说明
时间复杂度O(n)生成曲线与搬运像素都是与像素数成正比的线性开销
内存占用与像素数同阶曲线坐标表与两份像素缓冲同时存在,因此有了 800 万像素的上限
非正方形图片天然支持广义算法按矩形切分,竖图横图都不需要补边
质数边长正常处理算法不依赖 2 的幂,17×23 这类尺寸同样可以走满
1 像素宽的图退化为直线递归立刻命中“退化成列”的分支,结果就是顺序扫描
多次混淆等效于位移叠加连续混淆两次等于平移 2s,仍可被两次解混淆还原

为什么不用随机打乱

随机打乱看起来更彻底,但会带来两个麻烦:一是接收方必须拿到同一串随机数才能还原, 这就要求发送方额外传递密钥或随机种子,操作成本一下从“发张图”变成“对暗号”; 二是随机打乱容易留下成片的相似色块,人眼反而可能隐约看出轮廓。

固定规则的等距平移虽然规则公开,但因为位移比例接近 0.618 且曲线本身足够曲折, 得到的画面在视觉上是均匀噪声,且完全无需传递额外信息。对本工具的目标—— “挡住顺手一看,不承担保密责任”——这是最合适的取舍。原因见安全边界说明

顺着原理继续