AI摘要

本文介绍基于FFT的频域水印技术。利用Python和OpenCV,通过随机种子置乱水印位置,在频域嵌入信息。虽为非盲水印,但难以破解。文章详细阐述了数学原理、代码实现、命令行用法及CTF解题策略。

概述

盲水印(Blind Watermark)是一种信息隐藏技术,将水印数据嵌入到载体图像中,肉眼不可见,只有通过特定算法才能提取。

概念辨析

概念说明是否需原图
盲水印 (Blind Watermark)提取水印时不需要原图和原始水印
非盲水印 (Non-Blind)提取时需要原图参与计算(本文实现)
可见水印图片上肉眼可见的叠加标记(如版权 LOGO)
本文实现的是频域非盲水印,但通过种子(seed)对水印位置进行随机置乱,使攻击者即使知道算法也无法在无种子时恢复可读水印,达到变相"盲"提取的效果。

数学原理

傅里叶频域变换

图像在空间域(像素点阵)和频率域之间存在对应关系:

  • 低频分量:图像中变化缓慢的区域(大面积色块、背景)
  • 高频分量:图像中变化剧烈的区域(边缘、纹理、噪点)

通过 二维快速傅里叶变换(FFT-2D),可以将 $M \times N$ 的图像矩阵从空间域转换到频率域:

$$ F(u,v) = \sum_{x=0}^{M-1} \sum_{y=0}^{N-1} f(x,y) \cdot e^{-j2\pi(ux/M + vy/N)} $$

逆变换(IFFT)可将频率域还原回空间域:

$$ f(x,y) = \frac{1}{MN} \sum_{u=0}^{M-1} \sum_{v=0}^{N-1} F(u,v) \cdot e^{j2\pi(ux/M + vy/N)} $$

频域嵌入原理

在频率域中修改图像的傅里叶系数(尤其是中高频分量),不会在视觉上引起明显变化,但编码了信息:

空间域图像              频率域                  逆变换后
┌──────┐     FFT      ┌──────────┐    IFFT     ┌──────┐
│ 原图  │  ────────→  │ F(u,v)   │  ───────→  │ 图像 │
└──────┘              │ +        │            └──────┘
                      │ α·W(u,v) │
                      └──────────┘
                           ↑
                    水印频率分量

嵌入公式:

$$ F'(u,v) = F(u,v) + \alpha \cdot W(u,v) $$

其中:

  • $F(u,v)$ — 原始图像的第 $(u,v)$ 个频率系数
  • $W(u,v)$ — 水印图像在频率域的系数
  • $\alpha$ — 嵌入强度系数(默认 3.0),越大则水印越清晰但可见性越高
  • $F'(u,v)$ — 嵌入水印后的频率系数

提取公式:

$$ W(u,v) = \frac{F'(u,v) - F(u,v)}{\alpha} $$

从公式可见:提取需要已知 原始图像 $F(u,v)$alpha 参数。这是非盲水印的根本原因。

攻击链路图

嵌入过程 (Encode)
═══════════════════

原图 img                水印 wm
   │                      │
   ▼                      ▼
 FFT(img)              随机置乱 (seed)
   │                      │
   │    ┌─────────────────┘
   ▼    ▼
 F' = F + α × wm_shuffled
   │
   ▼
 IFFT(F') → 含水印图像
   │
   ▼
 保存为 JPEG (有损压缩引入微量误差)


提取过程 (Decode)
═══════════════════

原图 img              含水印图 img_encoded
   │                      │
   ▼                      ▼
 FFT(img) = F1        FFT(img_encoded) = F2
   │                      │
   │    ┌─────────────────┘
   ▼    ▼
 W = (F2 - F1) / α  → 得到置乱后的水印
   │
   ▼
 用相同 seed 逆置乱 → 原始水印图像

代码实现

环境依赖

pip install opencv-python numpy matplotlib

核心导入与参数

import cv2
import numpy as np
import random

alpha = 3.0       # 嵌入强度:越大水印越清晰,但对原图像素影响越大
seed  = 20160930  # 随机种子:作为水印置乱的"密钥"

BGR → RGB 转换工具

OpenCV 使用 BGR 通道顺序,Matplotlib 使用 RGB 顺序,显示时需转换:

def bgr_to_rgb(img):
    b, g, r = cv2.split(img)
    return cv2.merge([r, g, b])

编码(Encode):嵌入水印

# 1. 读取原图与水印
img = cv2.imread('hui.png')
wm  = cv2.imread('wm.png')

h, w = img.shape[0], img.shape[1]

# 2. 将水印置入半高画布 (h/2 × w)
hwm = np.zeros((int(h * 0.5), w, img.shape[2]))
hwm2 = np.copy(hwm)
for i in range(wm.shape[0]):
    for j in range(wm.shape[1]):
        hwm2[i][j] = wm[i][j]

# 3. 用 seed 随机打乱 watermark 的像素位置
random.seed(seed)
m, n = list(range(hwm.shape[0])), list(range(hwm.shape[1]))
random.shuffle(m)   # 行索引随机排列
random.shuffle(n)   # 列索引随机排列

for i in range(hwm.shape[0]):
    for j in range(hwm.shape[1]):
        hwm[i][j] = hwm2[m[i]][n[j]]

# 4. 对称复制(上半部分镜像到下半部分)
rwm = np.zeros(img.shape)
for i in range(hwm.shape[0]):
    for j in range(hwm.shape[1]):
        rwm[i][j] = hwm[i][j]
        rwm[rwm.shape[0] - i - 1][rwm.shape[1] - j - 1] = hwm[i][j]

对称复制示意图:

┌──────────────────┐
│   hwm (置乱后)    │  ← 上半部分
│                  │
│        ╲         │
│   mirror axis    │  ← 水平中心线
│        ╱         │
│   hwm 对称镜像    │  ← 下半部分
└──────────────────┘
# 5. FFT 频域嵌入
f1 = np.fft.fft2(img)              # 原图 → 频域
f2 = f1 + alpha * rwm              # 频域加法嵌入
img_encoded = np.fft.ifft2(f2)     # 逆变换回空间域
img_encoded = np.real(img_encoded) # 取实部

# 6. 保存为 JPEG(无损质量)
cv2.imwrite('output.jpg', img_encoded, [cv2.IMWRITE_JPEG_QUALITY, 100])

为什么要取 np.real

FFT 结果包含实部和虚部。嵌入操作仅修改实部,但 IFFT 后可能因浮点精度产生极小虚部(~10⁻¹⁰),取实部即丢弃这些计算噪声。

解码(Decode):提取水印

# 1. 读取原图与含水印图
img     = cv2.imread('hui.png')
img_wm  = cv2.imread('output.jpg')

# 2. 两种图分别做 FFT
f1 = np.fft.fft2(img)       # 原图频域
f2 = np.fft.fft2(img_wm)    # 含水印图频域

# 3. 频域相减除以 alpha → 恢复置乱后的水印
rwm = (f2 - f1) / alpha
rwm = np.real(rwm)

# 4. 用相同的 seed 生成相同的置乱序列
random.seed(seed)
m = list(range(int(rwm.shape[0] * 0.5)))
n = list(range(rwm.shape[1]))
random.shuffle(m)
random.shuffle(n)

# 5. 逆置乱 → 还原水印
wm = np.zeros(rwm.shape)
for i in range(int(rwm.shape[0] * 0.5)):
    for j in range(rwm.shape[1]):
        wm[m[i]][n[j]] = np.uint8(rwm[i][j])
# 下半部分镜像
for i in range(rwm.shape[0] // 2):
    for j in range(rwm.shape[1]):
        wm[rwm.shape[0] - i - 1][rwm.shape[1] - j - 1] = wm[i][j]

cv2.imwrite('extracted_wm.jpg', wm)

保存误差度量

JPEG 有损压缩会引入噪点。程序计算了写入前后的均方根误差:

img_wm   = np.real(img_encoded)  # 内存中的原值
img_wm2  = cv2.imread(fn3)       # JPEG 读取后的值
# 计算 RMS 误差百分比
sum = 0
for i in range(h):
    for j in range(w):
        for k in range(3):
            sum += (img_wm[i][j][k] - img_wm2[i][j][k]) ** 2
miss = np.sqrt(sum) / (h * w * 3) * 100
# 输出: "Miss X.XX% in save"

命令行用法

# 嵌入水印
python bwm.py encode hui.png wm.png output.jpg

# 提取水印(必须提供原图)
python bwm.py decode hui.png output.jpg extracted_wm.jpg

# 调试模式(显示每步的可视化输出)
python bwm.py encode hui.png wm.png output.jpg --debug

# 自定义种子(密钥)
python bwm.py encode hui.png wm.png output.jpg --seed 12345678
python bwm.py decode hui.png output.jpg extracted_wm.jpg --seed 12345678

# Python3 兼容 Python2 的 random 算法
python3 bwm.py decode hui.png output.jpg extracted_wm.jpg --oldseed

# 调整嵌入强度(数值越小越隐蔽)
python bwm.py encode hui.png wm.png output.jpg --alpha 1.5

参数说明:

参数默认值说明
--debug关闭开启后弹出 Matplotlib 窗口,显示每一步的频域/空间域图像
--seed <int>20160930随机置乱的种子,相当于水印"加密密钥"
--oldseed关闭Python3 环境启用 Python2 兼容的 random 算法
--alpha <float>3.0嵌入强度:越小越隐蔽;越大提取越清晰但可见性增加

CTF 应用

典型题目特征

CTF MISC 题目中出现盲水印时,通常呈现以下形态:

  1. 题目给出一张"正常"图片(原图)和一张"略有差异"的图片(含水印图)
  2. 两张图片像素级肉眼不可区分
  3. 利用频域相减可提取隐藏信息(Flag)

通用解题流程

获得两张相似图片
        │
        ▼
┌──────────────────────┐
│  判断水印类型         │
│  · 频域水印 (FFT)    │  ← 本工具
│  · LSB 水印          │
│  · DCT/DWT 水印      │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  尝试默认 seed 提取   │
│  seed = 20160930     │
└──────────┬───────────┘
           ├─ 成功 → 得到 Flag
           │
           ▼ 失败
┌──────────────────────┐
│  暴力枚举 seed        │
│  或分析提示信息       │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│  调整 alpha 值        │
│  从 0.5 逐步增加到 10 │
└──────────┬───────────┘
           │
           ▼
       得到 Flag

延伸阅读

水印技术的数学分类

类别变换域鲁棒性CTF 出现频率
FFT (快速傅里叶变换)频率域高 (本工具)
DCT (离散余弦变换)频域 (JPEG 核心)
DWT (离散小波变换)时频域
LSB (最低有效位)空间域极低最高
SVD (奇异值分解)矩阵分解

alpha 选择指南

alpha 过小 (0.1~0.5)     alpha 适中 (2~5)        alpha 过大 (>10)
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│ 水印几乎不可见    │    │ 水印清晰可读      │    │ 原图出现可见噪点  │
│ 提取失败 / 噪声   │    │ 对原图视觉影响小  │    │ 水印暴露        │
│ 需原图精度极高    │    │ **推荐范围**      │    │ 失去隐蔽意义    │
└─────────────────┘    └─────────────────┘    └─────────────────┘

Python2/3 random 算法差异的根本原因

Python2 的 random.shuffle 使用 Wichmann-Hill (1984) 算法;Python3 默认使用 Mersenne Twister (MT19937)。相同 seed 在不同算法下产生的置乱序列不同,导致提取的水印为噪点。解决方式:

# 法 1:使用 Python2 运行
python2 bwm.py decode hui.png encoded.jpg out.jpg

# 法 2:Python3 加 --oldseed(切换为 Python2 的 random 实现)
python3 bwm.py decode hui.png encoded.jpg out.jpg --oldseed

参考文献

  1. chishaxie. BlindWaterMark - 盲水印 by Python [EB/OL]. GitHub, 2021. https://github.com/chishaxie/BlindWaterMark
  2. NumPy Contributors. numpy.fft.fft2 — Discrete Fourier Transform [EB/OL]. https://numpy.org/doc/stable/reference/generated/numpy.fft.fft2.html
  3. OpenCV. Image file reading and writing [EB/OL]. https://docs.opencv.org/4.x/d4/da8/group__imgcodecs.html
  4. Python Software Foundation. random — Generate pseudo-random numbers [EB/OL]. https://docs.python.org/3/library/random.html
  5. Mallat S. A Wavelet Tour of Signal Processing [M]. Academic Press, 1999.
投币支持一下吧
END