曲线和位移量在前面分别讲过,这一篇把两步合成一段可以照着写的代码:两个数据结构、三段伪代码、逐行解释,以及最容易写错的几个地方。
要点速记
- 全部状态只有两个:
Uint32Array存的曲线序order,和长度 4N 的 RGBA 像素缓冲。 - 混淆与解混淆是同一段循环,区别只在读位置与写位置互换。
- 取模只需要一次减法,因为位移量 k 不会超过 N。
- 曲线序必须先整条生成完,因为第 i 次搬运要访问第 i+k 个点。
两个数据结构:曲线序 order 与 RGBA 像素缓冲
实现这个算法只需要两个数组:一个记录访问顺序,一个存放像素内容,剩下的都是循环。
曲线序 order 是一个 Uint32Array,长度 N = W × H,第 i 个元素是第 i 个被曲线走到的像素的线性下标,即 y * W + x。用无符号 32 位整数刚好够:本站上限是 800 万像素,最大下标不到 800 万,远小于 2 的 32 次方,整个数组占 4N 字节。
像素缓冲是 Uint8ClampedArray,长度 4N,按 R、G、B、A 顺序存放。线性下标 p 的四个字节固定在 4p、4p+1、4p+2、4p+3 上。输入、输出缓冲各占 4N 字节,加上曲线序,处理 200 万像素的临时内存约 24 MB。
原始实现用 [x, y] 小数组表示每个点,再乘宽度换算线性下标;改成整型下标后,省掉了 N 个短命数组的对象开销。想先看清这条顺序在矩形上怎么拐,可以打开吉尔伯特曲线可视化看遍历轨迹,递归构造另有吉尔伯特曲线原理一篇专门讲。
完整伪代码:曲线生成、混淆主循环、解混淆主循环
下面三段合起来就是完整实现。第一段是递归的曲线生成,后两段是同一个循环的两种方向,写法贴近 JavaScript 但省略了语言细节。
function buildOrder(W, H):
order = new Uint32Array(W * H) // 元素是像素线性下标 y * W + x
cursor = 0
function walk(x, y, ax, ay, bx, by):
w = abs(ax + ay) // 主方向上的长度
h = abs(bx + by) // 正交方向上的长度
dax = sign(ax); day = sign(ay) // 主方向的单位步长
dbx = sign(bx); dby = sign(by) // 正交方向的单位步长
if h == 1: // 退化情形一:只剩一行,顺序铺满
for i = 0 to w - 1:
order[cursor++] = y * W + x
x += dax; y += day
return
if w == 1: // 退化情形二:只剩一列,顺序铺满
for i = 0 to h - 1:
order[cursor++] = y * W + x
x += dbx; y += dby
return
ax2 = floor(ax / 2); ay2 = floor(ay / 2)
bx2 = floor(bx / 2); by2 = floor(by / 2)
w2 = abs(ax2 + ay2); h2 = abs(bx2 + by2)
if 2 * w > 3 * h: // 分割情形一:细长条,一分为二
if (w2 % 2 == 1) and (w > 2):
ax2 += dax; ay2 += day // 让子块步长保持偶数
walk(x, y, ax2, ay2, bx, by)
walk(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by)
else: // 分割情形二:接近方形,切成三块
if (h2 % 2 == 1) and (h > 2):
bx2 += dbx; by2 += dby
walk(x, y, bx2, by2, ax2, ay2)
walk(x + bx2, y + by2, ax, ay, bx - bx2, by - by2)
walk(x + (ax - dax) + (bx2 - dbx),
y + (ay - day) + (by2 - dby),
-bx2, -by2, -(ax - ax2), -(ay - ay2))
if W >= H: walk(0, 0, W, 0, 0, H) // 横边起步
else: walk(0, 0, 0, H, W, 0) // 竖边起步
return order
两种分割情形的选择不是随意的。2w 大于 3h 说明矩形明显偏长,沿主方向对半切开最省递归深度;否则切成三块,中间那块保持完整宽度,递归后的子块形状仍接近方形,曲线才不会在局部反复折返。
input = 原图 RGBA 字节,长度 4N
order = buildOrder(W, H)
k = round(0.6180339887 * N) // 黄金分割位移量
output = new Uint8ClampedArray(4 * N)
for i = 0 to N - 1:
s = order[i] * 4 // 源像素的字节起点
j = i + k
if j >= N: j -= N // 一次减法就完成了对 N 取模
d = order[j] * 4 // 目标像素的字节起点
output[d] = input[s]
output[d + 1] = input[s + 1]
output[d + 2] = input[s + 2]
output[d + 3] = input[s + 3]
for i = 0 to N - 1:
d = order[i] * 4 // 目标像素的字节起点
j = i + k
if j >= N: j -= N
s = order[j] * 4 // 源像素的字节起点
output[d] = input[s]
output[d + 1] = input[s + 1]
output[d + 2] = input[s + 2]
output[d + 3] = input[s + 3]
混淆主循环逐行解释
第一行 s = order[i] * 4 把曲线序号换算成字节偏移:曲线序第 i 项是像素下标,乘 4 得到它在 RGBA 缓冲里的起点,没有坐标系换算,也没有除法。
接下来 j = i + k 是落点在曲线序中的位置。k 约等于 0.618N,绝大多数 i 加上 k 后都会越过末尾,所以要处理回绕。
回绕只需要一次判断。因为位移量 k 不会超过 N,而 i 的范围是 0 到 N−1,所以 i + k 最大为 2N−1,减去一次 N 之后必然落回 0 到 N−1,不可能还要再减一轮。写成 if (j >= N) j -= N 与 j % N 结果完全相同,但避开了取模运算,这就是代码里看不到百分号的原因。
四行赋值把 R、G、B、A 逐个搬过去。alpha 通道必须一起搬,否则半透明区域在解混淆后会留下固定位置的透明色块,因为 alpha 与颜色被拆散了。四条语句的源偏移相同、目标偏移也相同,现代引擎会把它们合并成一次 32 位读写。
不要为了通用把 k 改成可能大于 N 的值,那时一次减法就不够了,必须换成取模或循环减法,否则会越界写入。回绕判断的方向写反也会读写到非法位置。
解混淆为什么就是把读写位置对调
把两段循环并排看,区别只在源与目标的下标。混淆把曲线序第 i 个像素写到第 (i+k) mod N 个位置,解混淆把第 (i+k) mod N 个像素写回第 i 个位置,公式就是两行:
enc: out[order[(i + k) mod N]] = in[order[i]]
dec: out[order[i]] = in[order[(i + k) mod N]]
可见解混淆并不是把曲线倒着走一遍,曲线方向自始至终没变,变的只是谁当源、谁当目标。整条曲线是一个固定顺序,混淆把这条顺序上的队列整体挪了 k 位,解混淆就是朝反方向挪回来,黄金分割位移一篇会把 k 的来历与循环结构讲透。
逐字节拷贝与 slice 等价却更快
原始实现用 output.set(input.slice(p, p + 4), d) 搬运一个像素。语义上完全正确:slice 取出索引 p 到 p+3 的四个字节,set 把它们写到目标起点 d。
问题出在分配上。slice 每次调用都会新建一个长度 4 的临时数组,N 次搬运就产生 N 个用完即丢的短命对象。200 万像素意味着 200 万次小分配,这些开销最终都落在垃圾回收上,与算法本身无关。
本站改成四行下标赋值,没有中间对象,拷贝的字节数一模一样,输出也逐字节相同,省下的只是分配与回收——但正是这部分在浏览器里最贵。想看这个差距在不同分辨率下如何放大,可以接着读复杂度与性能。
判断一次改写是否安全,标准只有一个:同样的输入下输出是否与原实现逐字节相同。逐字节直写满足这个标准;任何改变曲线顺序或 k 取整方式的改动都不满足。
曲线序为什么不能边生成边用
因为搬运需要随机访问。第 i 次迭代读曲线序第 i 项,写第 i+k 项;i 从 0 递增时,写位置一直领先读位置 k 步,当 i 接近 N−k 时,写位置已经落在递归还没走到的区域上。
想边生成边用,就得先把前面 k 个点缓存起来等曲线走到时配对。k 约等于 0.618N,这个缓冲区相当于整条曲线的六成还多,还要额外维护环形队列指针,内存与复杂度都没省下来。递归遍历本身是深度优先的流式过程,走过了回不去,也没有便宜的方式按任意下标取值。
解混淆同样要读第 i+k 项,所以结论一致:先花一遍 O(N) 生成 4N 字节的曲线序,再做一遍 O(N) 搬运。两遍都是线性的,总复杂度仍是 O(N)。
退化与边界情形
H 等于 1 时,递归命中 h == 1 分支直接铺满一行,曲线序就是 0、1、2 一直到 W−1。W 等于 1 时,入口判定 W >= H 不成立,改从竖边起步并命中 w == 1 分支逐行下行,曲线序同样从 0 递增到 H−1。两种情况都不进入分割逻辑。
正方形要注意入口方向。W == H 时走横向起步分支,而 1920×1080 与 1080×1920 虽然 N 相同、k 相同,因为起步方向不同,曲线形状完全不同,混淆结果也完全不同。
1×1 是最特殊的一格:N 等于 1,k = round(0.618×1) = 1,于是 (i + k) mod N 恒等于 0,正好等于 i。也就是说混淆对 1×1 图片是恒等操作,输出与输入完全一样。这不是缺陷,而是位移量在这种尺寸下退化成了整圈。
关于 k 等于 0:对任何大于等于 1 的 N,round(0.618×N) 至少是 1,所以真实图片不会出现位移量为 0 的情况。只有 N 等于 0 才需要提前返回,代码里对总像素数小于等于 0 的情况直接返回空缓冲。
极窄长条比如 1000×3 走同一条递归,w 与 h 相差悬殊,会连续命中细长条分支,递归深度很小,曲线实际上是一条沿长边折返的蛇形路径。
可复现与校验:怎么证明“严格按算法实现”
证据不是结果看起来像,而是同一输入下输出逐字节相同。_build/verify-core.mjs 内联了一份原始实现的逐行等价复刻,包含 generate2d 递归与基于 slice 的逐像素置换,然后与本站的 assets/js/tomato-core.js 做四类比对:曲线访问顺序逐点一致、曲线是 0 到 N−1 的一个排列、混淆输出逐字节一致、解混淆输出逐字节一致,另加一项 enc 之后 dec 的无损往返恒等。
尺寸清单覆盖 20 组,从 1×1、1×2、2×1、1×37、37×1、2×3、3×2,到 4×4、8×8、13×7、7×13、16×16、31×17、64×48、97×61、120×90,再到 256×256、400×301、513×129,最后是 1000×3,包含正方形、竖长条、横长条与单行单列的退化情形。测试像素由固定种子的线性同余伪随机数生成,任何人重跑都会得到同一份数据。
运行方式是在项目根目录执行 node _build/verify-core.mjs,全部通过时脚本会输出实现与原始算法逐字节一致。有一点要说明:这项校验只证明实现与原方案一致,不证明方案本身有多强,可逆性与强度是两个不同的问题,后者见可逆性与误差分析。
局限:本实现不做任何算法层面的改动
本实现沿用原方案:曲线仍是广义吉尔伯特曲线,位移量仍是 round(0.6180339887 × N),没有加盐、没有密钥、没有二次置换、没有按块打乱,原始方案的性质与缺陷被原样保留。
最直接的后果是置换公开且可逆。任何人拿到混淆后的图片,用同一套流程就能还原,不需要任何额外信息。混淆提高的是自动化直接复用的成本,它不是加密,不能用来保护真正敏感的内容。真正的脱敏必须先把敏感信息永久遮挡或裁剪掉,再考虑要不要混淆。
工程层面的改写只影响内存与速度,不影响输出字节。反过来说,任何顺手改一改的做法都值得警惕:把 k 的取整方式换掉、把曲线出发点挪一格、在链路里插入额外的色彩处理,都会让结果与原始工具不再互通,接收方用原工具就打不开了。
最后一条边界在格式上。置换本身无损,但中间一旦经过 JPEG 重编码,像素值已经被改写,再走一遍解混淆也只能得到近似原图。想亲手确认这一点,可以在图片混淆 / 解混淆工作台用同一张图分别走 PNG 与 JPEG 两条链路。
本文更新于 2026-10-14。文中算法说明基于本站实际运行的实现整理,行为可通过 _build/verify-core.mjs 复核。
内容为本站原创,转载或引用请注明来源「小番茄混淆解图」并保留原文链接。