盲水印:DCT-QIM 从原理到踩坑

一套在浏览器 Canvas 里跑的频域盲水印:写入后肉眼看不出来,JPEG q60、截图、整体提亮之后,Python 端仍然能把内容读出来。

先说结论

最终方案不是"往像素里塞数据",而是:

把「magic + 长度 + CRC + 正文」编码成比特流,用 QIM(量化索引调制) 把这些比特埋进每个 8×8 块的低频 DCT 系数的量化格点奇偶性里;同一个比特在全图重复近 30 次,解码靠加权投票 + CRC 校验找回来。

整条链路:

scss 复制代码
嵌入
原图 → 8×8 分块 → 每块二维 DCT
     → 取 4 个低频系数 (1,1)(1,2)(2,1)(2,2),各写 1 bit
     → 每个系数做 QIM:推到最近的、奇偶性等于 bit 的格点 q·Δ
     → 4 个系数的增量 → IDCT → 得到 8×8 的像素增量图
     → 三通道加同一个增量(只改亮度)
     → 任一通道越界 → 整块回滚

解码
分块 DCT → round(c/Δ) & 1 → 1 bit
        → 判「这个位置到底有没有被写过」
        → 按循环长度 L 加权投票
        → 校验 magic + CRC16
        → 不过 → 对最没把握的 16 个 bit 做 1~3 位翻转重试

下面是每一环为什么这么设计,以及我在实现里踩过的坑。


一、需求:截图外流的溯源

内部系统截图被转出去,需要在图上留一个"是谁、什么时候"的标记,要求两条:

  1. 正常浏览看不出来,不影响业务观感;
  2. 截图、转发、二次压缩之后还能读出来。

第一条排除了明晃晃的平铺文字(观感破坏大);第二条排除了大部分"藏在元数据里"的做法。

二、为什么不用这几种常见方案

方案 死因
明文水印 裁掉、涂抹就没了
EXIF / 元数据 截图、微信转发、系统重编码直接丢
LSB(最低有效位) 一次 JPEG 或截图重采样就被抹平
频域 QIM 改动落在低频,JPEG 量化表对低频最温柔,压缩后格点还在

关键认知:JPEG 压缩本身也是 8×8 DCT + 量化。所以只要把水印也放进 DCT 域,并且用 8×8 分块与它对齐,就有机会让"压缩"和"水印"共用同一套数学结构,而不是互相破坏。

三、换战场:8×8 分块 DCT

把图片按 8×8 切块,每块做二维 DCT:

scss 复制代码
F = D · f · Dᵀ
D[u][x] = 0.5 · C(u) · cos((2x+1)uπ/16),  C(0)=1/√2,其余为 1

变换后,每块有 64 个系数:

  • 左上角是低频(能量集中,肉眼最敏感);
  • 右下角是高频(细节,压缩时最先被丢掉)。

为什么写低频? JPEG 的量化表对低频系数给小步长、对高频给大步长。写在高频等于把自己放在"压缩优先丢弃区"。实测下来,低频 4 个位置 (1,1) (1,2) (2,1) (2,2) 在 JPEG q60 下依然稳定。

为什么是 4 个位置? 一个 8×8 块放 1 bit 太浪费,放太多又会明显损伤画质。4 个低频系数是一个折中:800×600 的图就有 100×75×4 = 30000 个写入位置。

四、QIM:用"格点编号的奇偶性"存 1 bit

这是整个方案的数学核心。

js 复制代码
for (let k = 0; k < POS_COUNT; k++) {
  const pos = POSITIONS[k][1] * BLOCK + POSITIONS[k][0]
  const bit = bits[(seq + k) % tile]
  const c = coeff[pos]

  let q = Math.floor(c / DELTA + 0.5)        // 现在落在第几格
  if ((q & 1) !== bit) {                     // 奇偶不对 → 推到相邻格
    const diff = c - q * DELTA
    if (diff > 1e-6) q += 1                  // 优选改动更小的方向
    else if (diff < -1e-6) q -= 1
    else q += 1
  }
  delta[pos] = q * DELTA - c                 // 该系数的增量
}

三个要点:

  1. q = round(c / Δ) 把系数"归一化"成格点编号;
  2. q & 1 就是这一 bit,偶数表示 0,奇数表示 1(负数在补码下奇偶同样成立);
  3. 奇偶不对就把 q 推到 q±1,系数被吸附到 q·Δ 上。

解码端不需要原图,直接读:

js 复制代码
const scaled = c / DELTA
const q = Math.floor(scaled + 0.5)
const bit = q & 1

这就是"盲水印"的第一层含义:解码只依赖图像自身和公开参数。

为什么能抗压缩:Δ/2 就是容错半径

写入后 c' = qΔ 恰好落在格点上。压缩噪声让 c' 偏移 ±ε,只要

复制代码
ε < Δ/2

round((qΔ + ε)/Δ) 还是 q,bit 读回来不变。

所以 Δ 是"抗压能力"的旋钮 :Δ 越大,容错半径越大,但改动也越大。这里取 Δ = 28,容错半径 14 个系数单位。换算成像素,单个量化步引起的亮度起伏约 0.24 × 28 ≈ ±6.7 灰阶 ------ 在自然图像里完全看不出来。

五、只改亮度,不偏色

4 个系数的增量做 IDCT,得到一张 8×8 的像素增量图 ,然后三个通道加上同一个增量:

js 复制代码
data[i]     = data[i]     + dpx
data[i + 1] = data[i + 1] + dpx
data[i + 2] = data[i + 2] + dpx

三通道等量平移等价于整体亮度变化,色相和饱和度不变。效果上只是极轻微的纹理感,不会出现彩色斑块。

代价是"只用了亮度通道,浪费了色彩信息",换来的是观感稳定 ------ 这个交易很划算。

六、越界回滚:一个必须接受的取舍

加完增量后,像素可能越界(< 0 或 > 255)。这里有一个绝对不能做的动作:clamp。

clamp 会把系数从格点上拉开,解码算出来的 q 就变了,等于写了一个错的比特,而且这个错误和图像内容强相关,投票救不回来。

所以只能整块回滚:这块图不动,这 4 个 bit 当作没写。丢掉的由冗余覆盖。

这个设计本身没问题,但它埋了后面最大的一个坑 ------ 先记住结论:"没写"在解码端看起来和"写了 0"一模一样。

七、冗余与"盲同步":循环长度 L

800×600 有 30000 个写入位置,而正文只需要几百 bit。于是把比特流循环铺满全图:

js 复制代码
export function tileLength(capacity) {
  let value = Math.floor(capacity / 4)
  value -= value % 32
  return Math.min(MAX_TILE, Math.max(MIN_TILE, value))
}
  • 循环长度 L = clamp(容量/4 取 32 的倍数, 64, 1024);800×600 算出 L = 1024;
  • 第 i 个位置写 bits[i % L],于是每个比特重复 30000 / 1024 ≈ 29.3 次。

关键点:L 只由图片尺寸决定 。解码端拿到同一张图,用同样的公式就能算出同一个 L,不需要任何元信息。这是"盲水印"的第二层含义 ------ 盲同步。

投票不是简单数票,而是按置信度加权:

js 复制代码
function voteScores(observed, weights, length) {
  const scores = new Float64Array(length)
  for (let i = 0; i < observed.length; i++) {
    const w = weights[i]
    if (w === 0) continue
    scores[i % length] += observed[i] ? w : -w
  }
  return scores
}

权重取 w = 1 - 残差/0.24:系数越贴近格点越可信,越靠近两个格点中间(残差→0.5)越不可信。

八、把 CRC 当纠错码用

头部 48 bit:magic(16) + 正文长度(16) + CRC16(16)。

作用有两个:

  1. 区分"真的有水印"和"图里恰好有噪声" ------ 没有 magic 和 CRC 双保险,纯噪声图也会被投票"投"出内容;
  2. 当轻量纠错码用:CRC 不过时,挑出分数绝对值最小的 16 个 bit(最没把握的),做 1~3 位翻转重试。

C(16,1) + C(16,2) + C(16,3) = 696 次 CRC 计算,毫秒级完成。这比引入 RS / BCH 简单得多,实测能把 JPEG q60 下偶尔压坏的 1~3 个 bit 救回来。

九、踩坑记:平坦色块投出的"高可信错误票"

前面都是理论,真正让这套东西跑起来的是下面这一段。

坑 1:现象 ------ 自检失败,票型诡异

一张以 #f8f8f8 为背景的页面截图,嵌入成功、解码失败。把每个位置的投票打出来,看到这样的数据:

scss 复制代码
#0  t=1   0 票=26(w26.0)   1 票=4(w4.0)
#2  t=1   0 票=26(w26.0)   1 票=4(w4.0)
...
1024 个 bit 里错了 92 个

真值是 1 的比特,被 26 张"确定是 0"的票压死了。

坑 2:定位 ------ 68% 的票来自平坦块

按块类型做了交叉统计(flat = 三通道完全相等的块,E0 = 系数能量≈0):

ini 复制代码
flat | edge | E0   n=20260   err=0.0995   ← 67% 的票来自这里,10% 是错的
tex  | mid  | E0   n= 5975   err=0.0305
tex  | edge | E1   n=  467   err=0.0857

平坦块的 AC 系数恒为 0,round(0/28) = 0、残差也是 0 ------ 在解码端看起来是"高置信度地写了 0",实际上它只是"什么都没写"。

坑 3:根因 ------ 不是"纯色",是"回滚"

追下去才发现真正的原因:

亮度 248 的块,一个量化步在像素上起伏约 ±6.7。同一块里只要有 2~3 个 bit 是 1,峰值就顶到 255 以上 → 整块回滚。

回滚后系数还是 0,解码读到 0,而真实比特是 1。这类错误和图像内容强相关、是系统性偏差,不是随机噪声,靠投票完全救不回来 ------ 重复 29 次,29 次都错。

坑 4:第一版修法 ------ 往有余量的一侧量化

块偏亮就往暗处量化,偏暗就往亮处量化:

js 复制代码
const towardDark = lumaSum > AREA * 128

能减少一部分回滚(962 → 929 块),但治不了根:DCT 基函数有正有负,亮块无论往哪边走,总有一部分像素被推得更高。

坑 5:最终修法 ------ 解码端区分"写了 0"和"什么都没写"

回到本质:解码端必须能判断这个系数到底有没有被写过。

js 复制代码
const c = coeff[pos]
const scaled = c / DELTA
const q = Math.floor(scaled + 0.5)
const residual = Math.abs(scaled - q)          // 离格点有多远

const onGrid  = residual <= RESIDUAL_MAX       // 0.24
const written = onGrid && Math.abs(c) >= ENERGY_MIN   // 能量够大 → 真写过
const silent  = onGrid && !nearClip            // 能量≈0 且块不贴边 → 可信的 0
weights[offset + k] = written || silent ? 1 - residual / RESIDUAL_MAX : 0

三态判据:

状态 判据 处理
written 残差 ≤ 0.24 且 能量 ≥ Δ/2 正常投票
silent 残差小但能量≈0,且块不贴 0/255 按 0 投票
弃权 其余情况 权重 0

其中"贴边"的判据是:块内任一像素离 0 或 255 不足 CLIP_MARGIN。

CLIP_MARGIN = Δ/2 = 14 不是拍脑袋定的 :一次 QIM 最多让像素亮/暗 0.48Δ,所以离黑/白不足 Δ/2 的块,随时可能被整块回滚 ------ 那里的"沉默"什么都说明不了。

用不同阈值扫了一遍:

ini 复制代码
m= 8  票 7145  错 18
m=12  票 7145  错 18
m=14  票 4405  错  0   ← Δ/2
m=18  票 4405  错  0

14 正好是理论余量,左右都站得住。

坑 6:前端同步计算会把页面卡死

extractWatermark 是纯同步的,2560×1600 要 100ms 量级,看着不多,但它在主线程上跑,UI 完全冻结,按钮停在"自检中..."像崩了。

解法是把主循环写成 generator ,每 12ms yield 一次让出主线程,顺便汇报进度:

js 复制代码
async function runAsync(task, onProgress) {
  let step = task.next()
  let sliceStart = now()
  while (!step.done) {
    if (now() - sliceStart >= SLICE_MS) {
      onProgress?.(step.value)
      await yieldToUi()
      sliceStart = now()
    }
    step = task.next()
  }
  return step.value
}

同步 API 仍然保留(Node 和测试用),浏览器用分片版。实测 2000×1400 嵌入耗时 242ms,期间主线程心跳 21 次 ------ 界面全程可响应。

十、落到页面里的三种形态

前面讲的都是"给一张图写水印"。真正接到业务页面时,"给整个页面加水印"其实不是一个问题,而是三个:

目标 做法 能扛什么
截图转发的人当场就看到 全屏可见覆盖层 普通用户;不是安全边界
截图流出去还能追到人 页面里每张 <img> 嵌隐形水印 抗截图、另存,但只覆盖图片区域
报表、对账单要导出 Canvas 绘制后嵌水印再导出 最可靠,图是自己生成的

关键认知:页面是 DOM,不是像素 。隐形水印只能活在像素里,所以页面上唯一能承载它的是 <img> 和 <canvas>;"整页不可见水印"只能在导出/截图那一刻离线完成。

1. 整页可见水印:一层 SVG 平铺

position: fixed 的遮罩 + 可平铺的 SVG 斜文字,pointer-events: none 让点击穿透(escapeXml 只做 & < > 的转义,避免 SVG 被截断):

js 复制代码
export function watermarkTile(text, { fontSize = 15, angle = -28, color = 'rgba(31,41,55,0.16)' } = {}) {
  const width = Math.ceil(Array.from(text).length * fontSize * 1.6) + 60
  const svg =
    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="180">` +
    `<text x="${width / 2}" y="90" text-anchor="middle" dominant-baseline="middle" ` +
    `transform="rotate(${angle} ${width / 2} 90)" font-size="${fontSize}" ` +
    `font-family="PingFang SC, sans-serif" fill="${color}">${escapeXml(text)}</text>` +
    `</svg>`
  return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`
}
css 复制代码
.page-watermark {
  position: fixed;
  inset: 0;
  z-index: 2147483000;
  pointer-events: none;
  user-select: none;
  background-repeat: repeat;
}

用 SVG 当 background-image,而不是再开一层 canvas:走 GPU 合成、零主线程开销、缩放不失真。

三个注意点:

  • 水印文字必须带唯一标识(姓名 + UID + 日期)。只写"内部资料"四个字,截图流出去照样追不到人;
  • @media print 里要显式保留,否则一打印成 PDF 就洗掉了;
  • 别指望防住懂技术的人:F12 删节点、脚本截图都能绕过。它是"提高门槛",不是"安全机制"。

2. 页面图片批量嵌隐形水印

把每张 <img> 画到 canvas、写入水印、换成新的 blob URL:

js 复制代码
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d', { willReadFrequently: true })
ctx.drawImage(img, 0, 0)

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
await embedWatermarkAsync(imageData, text)
ctx.putImageData(imageData, 0, 0)

const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'))
img.src = URL.createObjectURL(blob)

这才是真正抗截图的那一环:图片被"另存为"、被截图,像素里依然带着 UID。

代价和限制:

  • 跨域图片必须同时满足 <img crossOrigin="anonymous"> 和服务端返回 Access-Control-Allow-Origin,否则 getImageData 直接抛 SecurityError;
  • 图片要加载完(naturalWidth > 0)再处理,大图要限 maxPixels ------ 一张 4000×3000 的图 getImageData 就是 48MB;
  • PNG 体积会涨,别全站无差别处理,只用在需要溯源的图上(头像、证件、报表截图);
  • 更省事的路径是服务端出图时就带上水印,前端拿到的已经是带水印的图,连跨域问题都没有。

3. 导出时嵌:自己画,别截图

报表要发给别人时,在"导出成图片"的那一步写入水印:

js 复制代码
const canvas = renderReport(data)                       // Canvas 2D 直接画
const ctx = canvas.getContext('2d', { willReadFrequently: true })
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
await embedWatermarkAsync(imageData, watermarkText)
ctx.putImageData(imageData, 0, 0)
canvas.toBlob((blob) => saveAs(blob, 'report.png'), 'image/png')

用 Canvas 自绘而不是 html2canvas 截 DOM,有两个好处:一是画出来的像素一定对齐 8×8 网格(DOM 截图常有半像素偏移、块内混色);二是字体、行高、留白全在自己手里,不会因为环境差异变成另一张图。

一条必须守住的硬约束:载体底色

这条路踩得最狠的一次,是给导出报表用了纯白底 (#fcfcfe):

bash 复制代码
bg=#fcfcfe  回滚=8100  结果=null      ← 100% 解不出
bg=#eef0f6  回滚=5501  结果=null
bg=#dfe3ec  回滚= 490  结果=OK

原因还是那个老问题:一次量化最多让像素亮/暗 0.48Δ ≈ 13,同一块里几个位置叠加能到 ±27。底色一旦接近 255,写 1 就顶出去,整块回滚,解码端读到一片 0 ------ 系统性错误票,把真正的 1 压死。

同样的坑在演示图里又踩了一次:hsl(232 72% 97%) 的背景三张图全部 FAIL,压到 hsl(232 38% 85%) 之后全部 OK。

所以定了一条规矩:

需要承载隐形水印的画面,所有底色(含条纹、卡片、表头)的三通道值必须落在 14 ~ 240 之间。

这也解释了为什么"纯白底报表"和"大段白底的页面截图"天生不适合盲水印 ------ 不是算法不行,是那里根本没有可用的像素余量。

十一、实测与边界

扛得住 (Python 端提取,文本 内部资料 张三 UID-1001 2026-10-27)

css 复制代码
原图(无水印)   → None          ← 不误报
PNG 无损        → 命中
JPEG q90        → 命中
JPEG q75        → 命中
JPEG q60        → 命中
高斯噪声 σ=2     → 命中
整体提亮 +8      → 命中
叠加可见水印      → 命中

扛不住

  • 缩放 / 裁剪:8×8 网格一错位,DCT 系数全乱,必挂。这是所有块对齐方案的天然短板,必须拿到原图尺寸(或等比还原后)。
  • 大角度旋转、强对比度拉伸、二次截图时被大幅缩放。

容量与性能

  • 800×600 约 120 字节(40 个汉字)。图再大也不会超过 1024 bit(MAX_TILE),换来的是重复次数更多、更稳;
  • 2560×1600:嵌入 228ms / 提取 134ms(Node 环境纯 JS 实测,浏览器同量级)。

前后端一致性

Python 和 JavaScript 两端用同一个 DCT 公式、同样的取整、同样的方向选择。交叉验证结果:两边嵌入的像素最大差 0。

这一步不做,会出现最难查的问题 ------ 前端生成、后端解不出来,而你无法判断是算法有问题还是两端实现有细微差异。

十二、小结

这套方案的核心其实只有四句话:

  1. 在 DCT 低频格点上做量化调制,用奇偶性携带 1 bit,容错半径是 Δ/2;
  2. 循环铺满 + 加权投票 + CRC 重试,用冗余把随机损伤补回来;
  3. 解码必须区分"写了 0"和"什么都没写" ------ 这是整个方案里最容易翻车、也最容易被忽略的一点;
  4. 载体底色必须留出像素余量(三通道 14 ~ 240),否则写不进去的东西在解码端会变成系统性的错误票。

第 3 点尤其值得展开说:绝大多数 QIM 的介绍都止步于"把系数量化到格点",但工程落地时你会发现,嵌入端"写失败"和"写 0"在解码端长得一模一样,而写失败往往集中在平坦区、高亮区这类大面积区域,一旦形成系统性偏差,再多的冗余也救不回来。

真正的修复不是调参,而是让解码端具备"知道自己不知道 "的能力:贴边块弃权、零能量要额外条件、残差大就降权。这三件事做完,#f8f8f8 背景的截图才终于能稳定解出来。

而第 4 点是它的另一面:解码端的能力是有上限的,别把水印往注定写不进去的地方塞。白底报表、纯色海报、大段留白的页面截图,这些场景要靠改设计(压暗底色、加淡纹理)或者换载体(改成服务端出图时统一处理)来解决,调阈值救不回来。

相关推荐
程序0071 小时前
天地图实现收货地址定位
前端·javascript·css
去伪存真1 小时前
架构思考与演进:如何彻底破解 RAG 切块大小与语义完整性的工程悖论?
前端·agent
馒头不想说话1 小时前
AI剪辑工具降低创作门槛
前端
paopaokaka_luck1 小时前
社区服务平台(加权协同过滤推荐、Jieba中文词频分析、WebSocket实时聊天、求助处理闭环、 活动积分闭环、ECharts多维分析)
java·前端·spring boot·mysql·echarts·mybatis
墨林陌2 小时前
翻完 DeepSeek 的 GitHub 仓库,我发现 V5 的发布路已经修好了
前端
Antruly2 小时前
uvscope:Web 调试界的瑞士军刀
前端
计算机魔术师2 小时前
Anthropic S-1 披露:半年前发布的模型,已贡献不了多少收入了
前端
qq_364682152 小时前
录音APP怎么切换语言?多语言版本如何使用
java·服务器·前端