一套在浏览器 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 位翻转重试
下面是每一环为什么这么设计,以及我在实现里踩过的坑。
一、需求:截图外流的溯源
内部系统截图被转出去,需要在图上留一个"是谁、什么时候"的标记,要求两条:
- 正常浏览看不出来,不影响业务观感;
- 截图、转发、二次压缩之后还能读出来。
第一条排除了明晃晃的平铺文字(观感破坏大);第二条排除了大部分"藏在元数据里"的做法。
二、为什么不用这几种常见方案
| 方案 | 死因 |
|---|---|
| 明文水印 | 裁掉、涂抹就没了 |
| 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 // 该系数的增量
}
三个要点:
q = round(c / Δ)把系数"归一化"成格点编号;q & 1就是这一 bit,偶数表示 0,奇数表示 1(负数在补码下奇偶同样成立);- 奇偶不对就把
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)。
作用有两个:
- 区分"真的有水印"和"图里恰好有噪声" ------ 没有 magic 和 CRC 双保险,纯噪声图也会被投票"投"出内容;
- 当轻量纠错码用: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。
这一步不做,会出现最难查的问题 ------ 前端生成、后端解不出来,而你无法判断是算法有问题还是两端实现有细微差异。
十二、小结
这套方案的核心其实只有四句话:
- 在 DCT 低频格点上做量化调制,用奇偶性携带 1 bit,容错半径是 Δ/2;
- 循环铺满 + 加权投票 + CRC 重试,用冗余把随机损伤补回来;
- 解码必须区分"写了 0"和"什么都没写" ------ 这是整个方案里最容易翻车、也最容易被忽略的一点;
- 载体底色必须留出像素余量(三通道 14 ~ 240),否则写不进去的东西在解码端会变成系统性的错误票。
第 3 点尤其值得展开说:绝大多数 QIM 的介绍都止步于"把系数量化到格点",但工程落地时你会发现,嵌入端"写失败"和"写 0"在解码端长得一模一样,而写失败往往集中在平坦区、高亮区这类大面积区域,一旦形成系统性偏差,再多的冗余也救不回来。
真正的修复不是调参,而是让解码端具备"知道自己不知道 "的能力:贴边块弃权、零能量要额外条件、残差大就降权。这三件事做完,#f8f8f8 背景的截图才终于能稳定解出来。
而第 4 点是它的另一面:解码端的能力是有上限的,别把水印往注定写不进去的地方塞。白底报表、纯色海报、大段留白的页面截图,这些场景要靠改设计(压暗底色、加淡纹理)或者换载体(改成服务端出图时统一处理)来解决,调阈值救不回来。