原理 · 算法

算法原理:一条曲线,加一个位移量

整个方案只有两步:先用 Gilbert 曲线给像素排一条不重复的队,再让整条队错开 0.618 倍总像素数的位置。看似简单,但曲线怎么覆盖矩形、位移为什么取这个数、逆向为什么不会丢像素,决定了它能不能可靠还原。

Gilbert 曲线 黄金分割位移 可逆置换

如果你只想知道结论:这是一次确定性的、可逆的像素置换。没有密钥,没有随机数种子,也不需要联网。知道图片宽高,就能重建完整的映射表;正向执行是混淆,反向执行是解混淆。

一句话概括

把画布上的 n = 宽 × 高 个像素排成一条长度为 n 的序列,然后整体旋转 offset 个位置。像素的值不变,只换坐标。

难点不在“旋转”,而在“按什么顺序排”。顺序决定了打散后的视觉效果,也决定了相邻像素会不会在画面上留下可辨认的纹理。

第一步:用 Gilbert 曲线给像素排队

Gilbert 曲线是 Hilbert 空间填充曲线在任意矩形上的推广。它从画布的一角出发,沿着一条连续、不交叉、不重复的路径走遍每一个格子。把路径上第 i 个格子的坐标记下来,就得到了一张“像素排队表”。

为什么不用更简单的逐行扫描?因为逐行扫描会把画面切成一条条横线。即便后续做了整体位移,局部仍可能保留明显的方向性纹理,肉眼能看出规律。Gilbert 曲线在矩形里来回折返,路径相邻的格子在画面上也尽量相邻,打散后的碎片更均匀,边缘不容易出现规则条纹。

另一个好处是它天然支持任意宽高,不需要把图片补成正方形。很多空间填充曲线只对 2^k × 2^k 的网格友好;Gilbert 的递归切分可以直接处理 3000 × 2000 这样的矩形。

Gilbert 曲线遍历 14 × 9 矩形的路径示意
起点 终点 像素位置

正在绘制路径…

示意图只展示路径顺序,不代表实际图片尺寸。路径覆盖每一个格子,且不重复经过同一位置。

第二步:加一个黄金分割位移

有了排队表,接下来把整条序列旋转一个固定距离:

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 MB8 MB约 20–40 MB(含画布)
400 万像素16 MB32 MB约 80–140 MB(含画布)
800 万像素32 MB64 MB约 160–260 MB(含画布)

表中的“粗略合计”包含浏览器实现差异,不是精确值。真正拖慢处理的往往不是循环,而是画布分配、getImageData 拷贝和 PNG 编码。手机内存更紧张,处理大图前先关闭其他标签页,或者把图片缩到 400 万像素以内。

安全边界:它不是加密

这套算法没有密钥,方法完全公开,任何人都能重建映射并还原。它抵抗的是“随手看一眼”,不是“专门分析这张图”。

因此,不要把身份证、银行卡、病历、未公开合同、账号密码之类的内容交给图片混淆。要隐藏文字或人脸,请用不透明色块真正覆盖,再另存;要保护文件,请使用加密工具。混淆是视觉层的顺序游戏,不是安全机制。

同样,请勿用它规避平台审核或传播违法内容。工具的能力有边界,使用也有边界。更完整的说明在安全与隐私页。

交互实验:改一改宽高

下面的网格不是实际图片,而是曲线的走向示意。拖动滑块改变宽高,观察路径如何重新折返,以及位移量如何随总像素数变化。

拖动上方滑块,路径与位移量会实时更新。

注意:改变宽高会同时改变曲线与位移量,所以混淆和解混淆必须使用同一尺寸。这也是为什么“把图缩小一点再解”行不通。

相关阅读

想验证“可逆”是不是真的?

用一张 PNG 跑完整闭环:混淆、下载、重新载入、解混淆,再与原图做差值比较。整个过程都在浏览器里完成。