置换流程与伪代码

把前两步合起来看,整个算法就是一次带偏移的循环赋值。这一篇给出可以照着实现的版本。

曲线和位移量在前面分别讲过,这一篇把两步合成一段可以照着写的代码:两个数据结构、三段伪代码、逐行解释,以及最容易写错的几个地方。

要点速记

  • 全部状态只有两个: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 但省略了语言细节。

伪代码 1:生成曲线序 order
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 说明矩形明显偏长,沿主方向对半切开最省递归深度;否则切成三块,中间那块保持完整宽度,递归后的子块形状仍接近方形,曲线才不会在局部反复折返。

伪代码 2:混淆主循环(enc)
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]
伪代码 3:解混淆主循环(dec)
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 复核。

内容为本站原创,转载或引用请注明来源「小番茄混淆解图」并保留原文链接。