python程序实现图片截图溯源功能

我是怎么给图片"打指纹"的:从 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 ❌ 失败
QQ 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 我们需要的是什么性质的指纹

一个好的截图指纹,应该满足:

  1. 全局性:分散在整张图片上,截图只要保留了大部分面积,指纹就还在
  2. 统计性:不依赖精确坐标,只要"大体上的图案还在"就能识别
  3. 尺度不变性:缩放后,把图片重新拉回原始尺寸,然后比对

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 条)的性能问题是下一步要解决的问题。

相关推荐
笨笨饿2 小时前
20_Git 仓库使用手册 - 初学者指南
c语言·开发语言·嵌入式硬件·mcu·学习
人间打气筒(Ada)2 小时前
go实战案例:如何通过 Service Meh 实现熔断和限流
java·开发语言·golang·web·istio·service mesh·熔断限流
小陈的进阶之路2 小时前
logging 日志模块笔记
python
cqbelt2 小时前
Python 并发编程实战学习笔记
笔记·python·学习
桦03 小时前
[C++复习]:STL
开发语言·c++
智算菩萨3 小时前
【论文复现】Applied Intelligence 2025:Auto-PU正例无标签学习的自动化实现与GPT-5.4辅助编程实战
论文阅读·python·gpt·学习·自动化·复现
前端小咸鱼一条3 小时前
16.迭代器 和 生成器
开发语言·前端·javascript
小陈工3 小时前
2026年3月31日技术资讯洞察:AI智能体安全、异步编程突破与Python运行时演进
开发语言·jvm·数据库·人工智能·python·安全·oracle
ok_hahaha4 小时前
java从头开始-黑马点评-Redission
java·开发语言