算法原理
整个工具只做两件事:把二维的像素排成一条一维队列,再让这条队列整体平移一段距离。把这两件事讲透,你就完全掌握了它。
一句话概括
对一张宽 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):直接顺序填满这一列。
- 长条情形(宽度超过高度的 1.5 倍):把长边对半切,递归两次,先把左半段走完再走右半段。
- 常规情形:把矩形切成三块——先走一小块,再走中间的长条,最后走剩余的一块。 注意最后一块必须用反向的向量递归,否则连接处会出现不连续的跳跃。
切分时还有一个细节:为了让每段长度尽量落在偶数格上,代码会在半长为奇数且当前段长大于 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 且曲线本身足够曲折, 得到的画面在视觉上是均匀噪声,且完全无需传递额外信息。对本工具的目标—— “挡住顺手一看,不承担保密责任”——这是最合适的取舍。原因见安全边界说明。