技术现状
流行的方案大都是针对图片级的水印隐写(如前端也能玩的图片隐写术),但我们需要的是对整个 Web 前端页面进行动态的暗水印添加
能找到的现有方案里,比较合适的是一个知乎的回答,具体方法如下:
- 水印添加:直接在页面的最顶层添加水印,
color
为黑色,opacity
设为 0.005,此时肉眼是不可见的(图1) - 水印显隐:将页面截图到 ps 中,在其上添加纯黑的图层,将黑图层的混合模式设为“实色混合”或“颜色加深”,水印当即显现(图2)
技术瓶颈
从上面的效果截图可以发现,只有在白色背景上方的水印最终才完美显示,稍微有颜色的背景上的水印则会跟背景混成一片黑色
这是因为图层颜色加深混合的原理如下:
- 0.005 透明度的黑字水印会将原本
(255,255,255)
的白色背景叠加影响为(244,244,244)
- 在黑图层的特殊混合模式下,每个像素点的 rgb 值中 <255 的直接被改为 0,即加到最深
也就是说,只要像素有一丁点非纯白色,就会被置为黑色(或纯红、蓝等色),因此白色背景上方的水印文字便会被置黑显现
但随之而来的,在深色背景的页面中(如下图少数派官网),水印便无法通过这种 ps 的图层混合方法分离出现
技术突破
上面提到的方法中,水印添加的方式暂时是没问题的,可以在肉眼不可见的变化中对原有色值产生影响
需改进的是水印显隐的方式,直接将色值以 255 为分界划分判定,似乎过于暴力
要找到一种更加柔和、准确的,能将水印覆盖的色值点从其它正常的色值点中找到、过滤出的方法
而在对比水印覆盖后的像素点色值的过程中,我发现大部分覆盖有水印的区域的色值都比周围正常的色值少 1
比如知乎的 #f5f5f5
灰色背景的色值原为 (245,245,245)
,在被水印覆盖后发现色值变为 (244,244,244)
而正常的前端页面中,不大会出现某一区域的色值有相差 1 的区别(除了渐变背景、box-shadow 之类)
因此,我们可以先大胆判定:如果页面中存在两个色值差值为 1,并且其中一个的总数量显著少于另一个,该色值即是被水印叠加后的像素点色值
但紧接着发现:并非所有被水印覆盖的色值都少 1,在深色区域这个规律似乎并不生效
如少数派官网第一张卡片的黄色 #f2d04f
(242,208,79)
,在水印叠加后色值是 (241,207,79)
,其蓝色通道值并没有变化
解决原理
这时就需要把水印到底是怎么影响原色值给弄清楚了
较为详细的解释可见:两个RGBA四通道颜色的叠加计算方法,在此只使用结论👇🏻
这是两个 rgba 颜色叠加后,得到的新色值的计算公式:
color = (frontColor * alpha) + (backgroundColor * (1.0 - alpha))
而在我们水印是黑色、透明度为 0.005 的情况下,可得出水印会使色值减少的幅度为:
diffColor = color - backgroundColor
= (0 * 0.005) + (backgroundColor * (1.0 - 0.005)) - backgroundColor
= - backgroundColor * 0.005
最终可得,只有 backgroundColor 在 100 ~ 300 之间的,黑色水印叠加的效果才会是色值 -1
这就解释了为什么在颜色较深的区域,水印不会对原色值产生普遍影响(diffColor < 0.5,四舍五入后还是原来的数值)
那么,想让 backgroundColor < 100 的深色区域也被水印影响该怎么破?
这时很容易想到,可以复制多一行 color 为白色的水印文案试试
当 frontColor 为白色的时候,上面的水印影响色值的幅度公式变为(此时的色值不再是减少,而是增加):
diffColor = color - backgroundColor
= (255 * 0.005) + (backgroundColor * (1.0 - 0.005)) - backgroundColor
= 1.275 - 0.005 * backgroundColor
可得出 backgroundColor 在 0 ~ 155 之间的,在白色水印叠加下色值都会 +1
所以,只要如此同时存在黑白两色的水印,便能覆盖所有色值了,无论深浅
效果
我将水印的显隐做成了一个 Chrome 插件工具👇🏻
浅色背景:
深色背景:
代码示例
主要是水印显隐的解码逻辑:
- 传入待显隐的截图后,统计图中所有色值点,以及各自的出现次数
- 找到其中 rgb 值各相差 1 的两个点,其中出现次数显著少的点就是水印所在的目标点
- 将目标点重新绘制为黑色,其它点置为白色
具体代码见:Github
Bonus
经过几天的实测发现,按照以上仅过滤色值差值为 1 的点,在部分特殊屏幕上进行截图、解码水印后,水印并不会如期出现
原因在于:以上的公式计算 diffColor,只在 rgb 一定是整数的情况下成立
而后来了解到,rgb 整数与否与屏幕的色深相关:
普通的 8bit 色深的屏幕确实就只有 256 种 r/g/b 值(所以只有整数),但 10bit 色深的屏幕则有 1024 种 r/g/b 值,现在甚至有 12bit 色深的屏幕
所以显卡 / CPU 在对两个色值叠加计算的过程中,如果用的是小数,就可能出现白色水印对深色背景的影响为 +2(黑水印对浅色的影响不变,只可能 -1)
例如某个深色值的 r = 30.4,那经过白色水印叠加后:
r = (255 * 0.005) + (30.4 * (1.0 - 0.005)) = 31.523
而在我们用 canvas 进行图片解码的过程中,提取图片的每个像素点的值用的 ctx.getImageData
方法得到的 ImageData 像素点都是 Uint8ClampedArray
类型,即经过四舍五入的整数值
所以我们实际能获取到的上述的两个 r 值会变为 30 和 32,相差就不再只是原来的 +1 了
解决方法也很简单:在编写解码工具时把 r/g/b +2 的点也纳入为水印叠加过的像素点来进行绘制,就可以囊括小数的情况了
不过这样会使被误判的点也相应变多,导致无关的区域更明显
所以建议在显隐解码工具加入 "Strong Mode" 可选项,当普通模式解析不出水印时,才开启增强模式来将 +2 的点也绘制出