我是怎么给图片"打指纹"的:从 DCT 暗水印到微纹理追溯的全过程
项目背景:商业标书系统,需要对每次发放给员工的图片做溯源追踪。发现泄露时,能定位到具体的接收人。
本文适合两类读者:对技术原理感兴趣的开发者,以及需要理解"这东西到底管不管用"的产品/业务决策者。
代码语言:Python(numpy + Pillow + scipy)
一、需求是什么
标书系统里有涉密图片。用户在浏览器里查看,截图后可能流出去。
我们想做到:
即使图片被截图发出去,我们也能从截图里找回"这张图最初是发给谁的"。
这就是数字水印追溯的典型场景。
自然而然地,我们先想到了隐形(暗)水印:在图片像素里藏一串信息,肉眼看不见,但算法能读出来。
二、第一版:DCT 方案------理论很美,现实很惨
2.1 原理
DCT(Discrete Cosine Transform,离散余弦变换)是 JPEG 压缩的核心算法。把图片切成 8×8 小块,每块做 DCT,得到频率域系数。我们在特定位置的系数里藏 bit:
bit = 1 → coeff[3][3] - coeff[4][4] = +22.0
bit = 0 → coeff[3][3] - coeff[4][4] = -22.0
为了鲁棒性,每个 bit 由 8 个随机分散的 DCT 块冗余承载,多数表决。
Payload 结构:
13 字节 = MAGIC(2B) + VERSION(1B) + user_id(4B) + page_no(2B) + CRC32(4B)
这套方案,论文里称作"抗截图"------那些论文指的是把图片文件直接做 JPEG 压缩,理想环境下确实能通过冗余对抗量化噪声。
2.2 真实场景的测试结果
| 截图工具 | 格式 | 识别结果 |
|---|---|---|
| iShot | PNG→PNG | ❌ 失败 |
| 钉钉 | PNG→JPEG | ❌ 失败 |
| 微信 | PNG→PNG | ❌ 失败 |
| PNG→PNG | ❌ 失败 |
识别成功率:接近 0%
2.3 为什么失败------最本质的原因
截图不是对"原始图片文件"的复制,是对"显示器上像素"的采样。
原图文件(1000×1000 PNG)
↓ 操作系统 + 浏览器渲染
↓ 色彩配置转换(色温/亮度)
↓ 浏览器自动缩放(适应窗口)
↓ DPI 缩放(Retina 屏幕)
↓
显示器像素(可能是 600×600,也可能是 1400×1400)
↓ 截图工具读取屏幕
↓ JPEG 压缩(钉钉/微信/QQ)
↓ PNG 重编码(iShot)
↓
新图片文件(内容已经面目全非)
DCT 水印的致命假设:
❌ 假设:像素精确值被保留
❌ 假设:DCT 块边界精确对齐(对齐精度:1像素)
❌ 假设:频率域系数不被扰动
真实截图破坏了以上全部假设。
尤其是缩放:原图 1000×1000,浏览器窗口显示为 700×700,DCT 块从 8×8 变成了 5.6×5.6,一个不是整数的尺寸------所有块都错位了,任何 bit 信息都找不到了。
三、核心洞察:换个角度想"指纹"
3.1 DCT 水印为什么脆弱
DCT 水印把信息藏在"频率域的精确位置",像是在一张格子纸上写暗语,格子一旦缩放,格子大小变了,暗语的位置就全乱了。
3.2 我们需要的是什么性质的指纹
一个好的截图指纹,应该满足:
- 全局性:分散在整张图片上,截图只要保留了大部分面积,指纹就还在
- 统计性:不依赖精确坐标,只要"大体上的图案还在"就能识别
- 尺度不变性:缩放后,把图片重新拉回原始尺寸,然后比对
3.3 关键转变:不是"频率域编码",而是"RGB 像素级修改"
v2 的本质想法:
我不再把信息"藏"在图片里了。我在图片的 RGB 像素值上直接叠加一层极淡的纹理模板。这层纹理是根据接收人 ID 生成的,每个 ID 对应唯一的纹理。
这不是传统意义的"暗水印"------我没有在频率域编码任何 bit。我做的是:
用一个极弱的、由 trace_id 唯一决定的纹理图案,把原图的 RGB 值微微推了一下。
原图 RGB 值可能是 (128, 67, 200),加完之后变成了 (129, 66, 201)------肉眼完全看不出来(PSNR > 40 dB)。
但这个"微微推了一下"的规律,全图有 1240×1754 = 217 万个像素点都记录着。
当你截图之后,哪怕图片被缩放、被 JPEG 压缩、被重新编码:
- RGB 的整体颜色倾向还在
- 纹理的统计相关性还在
- 我把截图缩放回 1240×1754,和所有已知模板一一做相关运算,相关性最高的那个 trace_id 就是答案
这是模板匹配(相关性追溯),不是 DCT 解码。
四、v2 算法详解(技术向)
4.1 标准画布
python
CANONICAL_WIDTH = 1240 # 约 A4 宽,150 DPI
CANONICAL_HEIGHT = 1754
所有嵌入和检测都在这个尺寸下进行。图片先缩放进来,结果再还原出去,从根本上解耦了"原图分辨率"的影响。
4.2 微纹理模板生成
每个 (trace_id, secret_key, page_index) 三元组生成一个唯一的、确定性的模板。
python
def _microtexture_seed(trace_id: str, secret_key: str, page_index: int) -> int:
combined = secret_key + trace_id
return sum(ord(ch) for ch in combined) * 257 + page_index * 10007
def _microtexture_template(trace_id: str, secret_key: str, page_index: int = 1) -> np.ndarray:
rng = np.random.default_rng(_microtexture_seed(trace_id, secret_key, page_index))
# 层1: 64×64 高斯噪声基底,上采样到标准画布,高通滤波后归一化
base = rng.normal(0.0, 1.0, size=(MICROTEXTURE_GRID, MICROTEXTURE_GRID)).astype(np.float32)
base = gaussian_filter(base, sigma=2.0)
large = _resize_to_size(base, CANONICAL_WIDTH, CANONICAL_HEIGHT)
large -= _gaussian_blur(large, sigma=8.0) # 高通:去掉低频漂移
large /= np.std(large)
# 层2: 方向正弦载体(截图后仍有规律可循)
y = np.linspace(0, np.pi * 14, CANONICAL_HEIGHT, dtype=np.float32)[:, None]
x = np.linspace(0, np.pi * 10, CANONICAL_WIDTH, dtype=np.float32)[None, :]
carrier = 0.55 * np.sin(x + page_index) + 0.45 * np.cos(y + page_index * 0.7)
template = 0.75 * large + 0.25 * carrier
# 层3(v2 特有): 象限编码载体 --- 引入强块级特征
qy = np.linspace(0, np.pi * 6, CANONICAL_HEIGHT, dtype=np.float32)[:, None]
qx = np.linspace(0, np.pi * 4, CANONICAL_WIDTH, dtype=np.float32)[None, :]
quad = np.sign(np.sin(qx + (page_index % 5)) + np.cos(qy + (page_index % 7)))
# 层4(v2 特有): 边带信号 --- 图像四边引入极性锚点
band = np.zeros_like(template)
margin = 84
band[:margin, :] = 1.0; band[-margin:, :] = -1.0
band[:, :margin] += 1.0; band[:, -margin:] -= 1.0
template = 0.55 * template + 0.25 * quad + 0.20 * band
template /= np.std(template)
return template.astype(np.float32)
四层信号的设计意图:
| 层 | 作用 |
|---|---|
| 高斯噪声 | 主体:每个 trace_id 独一无二的"指纹基因" |
| 正弦载体 | 辅助:方向性规律成分,截图后统计残留更稳定 |
| 象限编码 | 强化:块级特征,区分不同 trace_id 的区分度 |
| 边带信号 | 锚点:四边极性差异,为精搜提供位移校正参考 |
4.3 嵌入
python
MICROTEXTURE_ALPHA_V2 = 9.0 # 叠加强度
def _embed_microtexture(gray, trace_id, secret_key, page_index=1):
normalized = _resize_to_canvas(gray) # zoom 到 1240×1754
template = _microtexture_template(...)
# 文字边缘区域减权,避免笔画处出现可见噪点
edge = np.abs(_laplacian(normalized))
edge /= edge.max()
weight = 1.0 - 0.45 * np.clip(edge * 1.5, 0.0, 1.0)
embedded = normalized + template * weight * MICROTEXTURE_ALPHA_V2
return np.clip(embedded, 0, 255).astype(np.uint8)
def embed_v2(image, trace_id, secret_key="cbg-secret", page_index=1):
gray = _to_gray_array(image) # BT.601 灰度
orig_h, orig_w = gray.shape[:2]
embedded_gray = _embed_microtexture(gray, trace_id, secret_key, page_index)
# 标准画布 → 还原原图尺寸
embedded_orig = _resize_to_size(embedded_gray.astype(np.float32), orig_w, orig_h)
diff = embedded_orig - gray # 灰度差值
result = image.copy()
for ch in range(3): # 三个 RGB 通道同步加差值
result[:, :, ch] = np.clip(
result[:, :, ch].astype(np.float32) + diff, 0, 255
).astype(np.uint8)
return result
为什么三通道同步加相同差值:保证色调不变(不偏色),PSNR 通常 > 40 dB。
4.4 追溯检测
检测分两阶段:粗搜(低分辨率快速筛选)→ 精搜(全分辨率精确定位)。
python
MICROTEXTURE_LOWRES = (310, 438) # 标准画布的 1/4
def _trace_microtexture(gray, registry_records, secret_key="cbg-secret"):
normalized = _resize_to_canvas(gray)
# 高频提取:减去 sigma=8 的高斯模糊,去掉图像内容,留下纹理残差
high = normalized - _gaussian_blur(normalized, sigma=8.0)
high /= np.std(high)
low = _resize_to_size(high, *MICROTEXTURE_LOWRES)
low /= np.std(low)
# ── 粗搜:低分辨率,9 种平移偏移 ──
coarse = []
for sx in (0, 4, 8):
for sy in (0, 4, 8):
shifted_low = np.roll(low, shift=(sy, sx), axis=(0, 1))
for record in registry_records:
for page_index in range(1, 21):
tpl_low = _microtexture_template_lowres(record["trace_id"], secret_key, page_index)
score = float(np.mean(shifted_low * tpl_low)) # 归一化互相关
coarse.append((score, record, page_index, sx, sy))
coarse.sort(key=lambda x: x[0], reverse=True)
# ── 精搜:Top10 候选,全分辨率 ──
candidates = []
for coarse_score, record, page_index, bsx, bsy in coarse[:10]:
tpl = _microtexture_template(record["trace_id"], secret_key, page_index)
for sx in range(max(0, bsx-2), min(10, bsx+3), 2):
for sy in range(max(0, bsy-2), min(10, bsy+3), 2):
shifted = np.roll(high, shift=(sy, sx), axis=(0, 1))
score = float(np.mean(shifted * tpl))
candidates.append({"score": score, "record": record, "page_index": page_index, ...})
best = max(candidates, key=lambda x: x["score"])
# sigmoid 置信度:score=0.014 对应 50%
confidence = 1.0 / (1.0 + np.exp(-(best["score"] - 0.014) * 28.0))
return {
"matched": True,
"trace_id": best["trace_id"],
"confidence": round(confidence, 4),
"recipient": best["record"].get("recipient"),
...
}
关键:高通滤波(去低频)。
图像内容本身(文字、背景)是低频信号。减去高斯模糊后,剩下的高频残差里,图像内容贡献接近 0,而微纹理的叠加痕迹会以统计相关性的方式留存。这就是为什么即使 JPEG 压缩抹掉了部分高频,只要纹理残差的"方向性"还在,相关性就能被检测到。
4.5 置信度曲线
score = 0.005 → confidence ≈ 4% (噪声水平,不匹配)
score = 0.010 → confidence ≈ 21%
score = 0.014 → confidence = 50% (决策边界)
score = 0.018 → confidence ≈ 77%
score = 0.025 → confidence ≈ 96% (高置信匹配)
建议以 confidence ≥ 0.60 作为"匹配成功"的判定阈值。
五、Registry:发放记录管理
v2 与 v1 的另一个根本差异:v2 不从图片里解码 payload,而是通过 registry 做相关性搜索。
发放时:随机生成 trace_id → 嵌入图片 → 写入 registry(trace_id → employee_id)
追溯时:上传截图 → 与 registry 中所有 trace_id 的模板逐一计算相关性 → 找最高分
Registry 是一个简单的 JSON 文件:
json
{
"records": {
"2a431c98": {
"recipient": "E10001",
"document": "bid_file_ch3",
"mode": "microtexture_v2",
"issued_at": "2026-03-28T17:00:00+00:00"
}
}
}
Registry 操作:
python
from app.watermark.registry import save_record, list_records, lookup
# 发放时写入
save_record(registry_path, trace_id="2a431c98", recipient="E10001", document="bid_file_ch3")
# 追溯时读取全部记录
records = list_records(registry_path)
# 按 trace_id 直接查找
record = lookup(registry_path, trace_id="2a431c98")
六、API 接口
v1(DCT,已废弃)vs v2(微纹理,当前)
嵌入接口
v1:
GET /api/files/{file_id}/watermarked?user_id=10001
直接将 user_id 编码进 DCT 系数,返回带水印 PNG。
v2:
GET /api/files/{file_id}/watermarked/v2?user_id=10001
生成随机 trace_id,嵌入微纹理,写入 registry,响应头携带 X-Trace-Id。
python
# v2 接口核心实现
def get_image_watermarked_v2(
file_id: str,
user_id: int = Query(..., ge=1),
download: bool = Query(False),
) -> Response:
pil_img = PILImage.open(file_path)
rgb_arr = pil_to_rgb_array(pil_img)
trace_id = random_trace_id() # 密码学安全随机 ID
secret_key = settings.watermark_v2_secret
watermarked = embed_v2(rgb_arr, trace_id=trace_id, secret_key=secret_key)
png_bytes = rgb_array_to_png_bytes(watermarked)
# 写入发放记录
save_record(registry_path, trace_id=trace_id, recipient=str(user_id), document=file_id)
headers = {"X-Trace-Id": trace_id}
return Response(content=png_bytes, media_type="image/png", headers=headers)
追溯接口
v1:
POST /api/watermark/decode
直接从 DCT 系数解码 user_id,不依赖 registry。
v2:
POST /api/watermark/decode/v2
从 registry 做相关性匹配,返回接收人信息。
python
def decode_watermark_v2(file: UploadFile = File(...)) -> Cmd[dict]:
rgb_arr = pil_to_rgb_array(PILImage.open(io.BytesIO(file.file.read())))
records = list_records(registry_path)
if not records:
return Cmd.success({"matched": False, "reason": "registry_empty", ...})
result = trace_v2(rgb_arr, registry_records=records, secret_key=secret_key)
return Cmd.success(result)
响应示例(v2):
json
{
"code": 0,
"data": {
"matched": true,
"trace_id": "2a431c98",
"confidence": 0.8731,
"recipient": "E10001",
"document": "bid_file_ch3",
"page_index": 1,
"correlation": 0.022461
},
"message": null
}
七、端到端示例
python
from PIL import Image
import numpy as np
from app.watermark import embed_v2, trace_v2, random_trace_id, save_record, list_records
REGISTRY = "upload/watermark_registry_v2.json"
SECRET = "your-production-secret"
# ──── 发放 ────
original = np.array(Image.open("bid_page.png").convert("RGB"))
trace_id = random_trace_id() # e.g. "2a431c98"
wm_image = embed_v2(original, trace_id=trace_id, secret_key=SECRET, page_index=1)
Image.fromarray(wm_image).save("bid_page_wm.png")
save_record(REGISTRY, trace_id=trace_id, recipient="E10001", document="bid_ch3")
# ──── 追溯 ────
leaked = np.array(Image.open("leaked_screenshot.jpg").convert("RGB"))
records = list_records(REGISTRY)
result = trace_v2(leaked, registry_records=records, secret_key=SECRET)
if result["matched"] and result["confidence"] >= 0.60:
print(f"泄露者:{result['recipient']},置信度:{result['confidence']:.1%}")
八、两种方案的本质差异(总结)
| 维度 | v1(DCT 频率编码) | v2(微纹理 RGB 修改) |
|---|---|---|
| 藏在哪里 | 频率域系数差值(不可见) | RGB 像素值偏移(不可见) |
| 解码方式 | 直接从系数还原 payload | 与 registry 模板做相关性匹配 |
| 对缩放 | ❌ 块边界对齐要求严格,必然失效 | ✅ 先 zoom 到标准画布再匹配 |
| 对 JPEG | ❌ 量化直接抹除系数差值 | ✅ 高频残差仍有统计痕迹 |
| 截图成功率 | ~0% | 实验室模拟(缩放78%+JPEG70)可识别 |
| 类比 | 在方格纸上写暗语,缩放后格子乱 | 在油画上刷一层隐形纹理,颜料的"色调倾向"仍在 |
九、局限与后续
v2 仍不能解决的场景:
- JPEG 质量 < 50(高频纹理被彻底压缩)
- 截图后手机拍照(物理翻拍,摩尔纹、光线干扰)
- 图片被大幅裁剪(< 50% 面积)
- 截图后叠加大量贴图/涂抹覆盖
适合配合使用的手段:
- 显性水印(文字叠加,100% 截图有效,威慑力强)
- 访问日志(记录谁在何时看了什么,事后缩小嫌疑范围)
v2 的定位:事后追溯辅助工具,而非万能防泄露方案。
十、依赖说明
numpy # 数值计算
Pillow # 图片 I/O
scipy # gaussian_filter + zoom
fastapi # HTTP 接口层
无 OpenCV 依赖,环境兼容性较好。
如果你觉得这个思路有意思,欢迎评论区交流。有没有比"全图相关性匹配"更高效的方案?比如向量化 registry 做 ANN 近似检索?大规模场景(registry > 10000 条)的性能问题是下一步要解决的问题。