如果你只想知道结论:这是一次确定性的、可逆的像素置换。没有密钥,没有随机数种子,也不需要联网。知道图片宽高,就能重建完整的映射表;正向执行是混淆,反向执行是解混淆。
一句话概括
把画布上的 n = 宽 × 高 个像素排成一条长度为 n 的序列,然后整体旋转 offset 个位置。像素的值不变,只换坐标。
难点不在“旋转”,而在“按什么顺序排”。顺序决定了打散后的视觉效果,也决定了相邻像素会不会在画面上留下可辨认的纹理。
第一步:用 Gilbert 曲线给像素排队
Gilbert 曲线是 Hilbert 空间填充曲线在任意矩形上的推广。它从画布的一角出发,沿着一条连续、不交叉、不重复的路径走遍每一个格子。把路径上第 i 个格子的坐标记下来,就得到了一张“像素排队表”。
为什么不用更简单的逐行扫描?因为逐行扫描会把画面切成一条条横线。即便后续做了整体位移,局部仍可能保留明显的方向性纹理,肉眼能看出规律。Gilbert 曲线在矩形里来回折返,路径相邻的格子在画面上也尽量相邻,打散后的碎片更均匀,边缘不容易出现规则条纹。
另一个好处是它天然支持任意宽高,不需要把图片补成正方形。很多空间填充曲线只对 2^k × 2^k 的网格友好;Gilbert 的递归切分可以直接处理 3000 × 2000 这样的矩形。
正在绘制路径…
第二步:加一个黄金分割位移
有了排队表,接下来把整条序列旋转一个固定距离:
offset = round(0.6180339887… × 宽 × 高)
这个数字来自黄金分割共轭数 (√5 − 1) / 2 ≈ 0.618。选择它不是因为神秘,而是因为它与 1 不成简单的整数比:乘以总像素数再取整后,位移量不容易恰好落在总像素数的小因子周期上,从而避免整张图因为“转了一圈又对上”而露出重复规律。
它也不是密钥。公式公开,任何人只要知道宽高就能算出同一个位移。它的作用是把曲线顺序再整体错开,让画面更难从局部纹理猜出原图。
“黄金分割”在这里只提供分布上的便利,不提供密码学安全性。不要因为它听起来高级,就把它当成加密算法。
正向与逆向:同一张映射表
设 curve[i] 是第 i 个曲线位置对应的线性像素下标,n 是总像素数。混淆与解混淆分别做两件事:
offset = round(0.618 * width * height) % n
curve = gilbert(width, height) // 长度 n 的坐标序列
// 混淆:把第 i 个位置的像素搬到第 i + offset 个位置
for i = 0 .. n-1:
dst[curve[(i + offset) % n]] = src[curve[i]]
// 解混淆:按同一张表反向读取
for i = 0 .. n-1:
dst[curve[i]] = src[curve[(i + offset) % n]]
为什么一定可逆?因为 curve 是一个双射:每个像素位置在序列里恰好出现一次;而“加 offset 再对 n 取模”也是双射。两个双射复合,仍然是一一对应。正向搬运不会覆盖两个像素到同一个目标,也不会漏掉任何一个目标;逆向按同一张表读回来,自然回到原位。
本站实现时把坐标表存成 Uint32Array,每个元素是线性像素下标;搬运时用 Uint32Array 视图一次复制 4 个字节(RGBA),因此透明通道也会被完整保留。这与参考实现的逐像素搬运逻辑一致,只是去掉了“数组的数组”带来的额外内存开销。
为什么无损如此重要
置换本身不修改颜色值,但它有一个前提:解混淆时读到的像素,必须还是混淆时写下的那些值。任何有损环节都会破坏这个前提。
- JPEG 保存:按 8 × 8 块做 DCT 量化和色彩子采样,像素值被改写。解回来只能得到近似值,混淆图尤其明显。
- 平台转发:微信、QQ、微博可能自动转码、缩放或二次压缩。你以为发的是原图,对方收到的可能已经是另一份文件。
- 缩放与裁剪:直接改变像素网格。宽高变了,曲线和位移都变,映射关系失效。
- 截图与翻拍:引入屏幕缩放、色域转换、透视和光照变化,属于不可逆的信息损失。
所以本站默认建议 PNG:它不重新计算颜色,能保住“同一批像素值”。如果只是看一眼,JPEG 当然可以;但不要把 JPEG 当成以后还原用的底稿。
还有一个容易被忽略的细节:浏览器 Canvas 在处理带透明通道的图片时,会先做预乘 alpha,再在读取像素时反预乘。这个过程对完全不透明的像素没有影响,但部分半透明颜色可能出现 ±1 的舍入差。它属于浏览器实现的精度损失,不是置换算法本身的问题。
复杂度与性能
时间上是线性的:生成曲线 O(n),搬运像素 O(n),总计 O(n)。空间上主要占三块:曲线表 4n 字节,输入与输出各 4n 字节,再加上浏览器画布自身的后备存储。
| 规模 | 曲线表 | 输入 + 输出 | 粗略合计 |
|---|---|---|---|
| 100 万像素 | 4 MB | 8 MB | 约 20–40 MB(含画布) |
| 400 万像素 | 16 MB | 32 MB | 约 80–140 MB(含画布) |
| 800 万像素 | 32 MB | 64 MB | 约 160–260 MB(含画布) |
表中的“粗略合计”包含浏览器实现差异,不是精确值。真正拖慢处理的往往不是循环,而是画布分配、getImageData 拷贝和 PNG 编码。手机内存更紧张,处理大图前先关闭其他标签页,或者把图片缩到 400 万像素以内。
安全边界:它不是加密
这套算法没有密钥,方法完全公开,任何人都能重建映射并还原。它抵抗的是“随手看一眼”,不是“专门分析这张图”。
因此,不要把身份证、银行卡、病历、未公开合同、账号密码之类的内容交给图片混淆。要隐藏文字或人脸,请用不透明色块真正覆盖,再另存;要保护文件,请使用加密工具。混淆是视觉层的顺序游戏,不是安全机制。
同样,请勿用它规避平台审核或传播违法内容。工具的能力有边界,使用也有边界。更完整的说明在安全与隐私页。
交互实验:改一改宽高
下面的网格不是实际图片,而是曲线的走向示意。拖动滑块改变宽高,观察路径如何重新折返,以及位移量如何随总像素数变化。
拖动上方滑块,路径与位移量会实时更新。
注意:改变宽高会同时改变曲线与位移量,所以混淆和解混淆必须使用同一尺寸。这也是为什么“把图缩小一点再解”行不通。