游戏简介与算法背景
找茬(Spot the Difference)是一款考验观察力的经典益智游戏:两幅看似相同的图片中隐藏着若干处细微差异,玩家需要在限定时间内找出所有不同点。从算法角度看,”找茬”本质上是图像差异检测问题,涉及像素级比较、特征提取和相似度度量。本文将用 Java 实现基于感知哈希(Perceptual Hash)与像素差异的混合检测算法。
核心算法设计
1. 感知哈希(pHash)算法
感知哈希通过提取图像的视觉特征生成指纹,对微小形变、亮度调整具有鲁棒性。标准流程:
1. 缩放为 32×32 灰度图
2. 离散余弦变换(DCT)提取低频成分
3. 取左上角 8×8 区域计算均值
4. 生成 64 位哈希值
2. 像素级差异检测
对两图逐像素比较差值,通过阈值过滤噪声,使用连通区域分析定位差异块。
3. 混合策略
- 先用 pHash 快速判断整体相似度
- 若整体相似则进行像素级精细检测
- 对差异区域做形态学处理消除噪点
Java 完整实现
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* 找茬游戏 — 感知哈希与像素差异检测算法
*
* 核心算法:
* 1. 感知哈希(DCT-based pHash):快速全局相似度判断
* 2. 像素级差分:精确定位差异区域
* 3. 连通区域标记(BFS/Union-Find):聚合差异像素为差异块
* 4. 汉明距离:哈希相似度度量
*/
public class SpotDifference {
private static final int PHASH_SIZE = 32;
private static final int HASH_BITS = 64;
private static final int DIFF_THRESHOLD = 30; // 像素差阈值
private static final int MIN_DIFF_AREA = 50; // 最小差异区域像素数
/**
* 离散余弦变换(DCT)一维实现
* 用于感知哈希提取低频特征
*/
public static double[] dct1d(double[] data) {
int n = data.length;
double[] result = new double[n];
for (int k = 0; k < n; k++) {
double sum = 0;
for (int i = 0; i < n; i++) {
sum += data[i] * Math.cos(Math.PI / n * (i + 0.5) * k);
}
result[k] = sum;
}
return result;
}
/**
* 二维DCT:先对每行做DCT,再对每列做DCT
*/
public static double[][] dct2d(double[][] matrix) {
int n = matrix.length;
double[][] temp = new double[n][n];
double[][] result = new double[n][n];
// 对每行做1D DCT
for (int i = 0; i < n; i++) {
temp[i] = dct1d(matrix[i]);
}
// 对每列做1D DCT
for (int j = 0; j < n; j++) {
double[] col = new double[n];
for (int i = 0; i < n; i++) {
col[i] = temp[i][j];
}
double[] dctCol = dct1d(col);
for (int i = 0; i < n; i++) {
result[i][j] = dctCol[i];
}
}
return result;
}
/**
* 计算感知哈希(pHash)
* 对缩放、亮度变化具有鲁棒性
*/
public static long perceptualHash(BufferedImage image) {
// 1. 缩放为 PHASH_SIZE x PHASH_SIZE 并转灰度
BufferedImage gray = new BufferedImage(PHASH_SIZE, PHASH_SIZE, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = gray.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(image, 0, 0, PHASH_SIZE, PHASH_SIZE, null);
g.dispose();
// 2. 提取像素值到二维数组
double[][] pixels = new double[PHASH_SIZE][PHASH_SIZE];
for (int y = 0; y < PHASH_SIZE; y++) {
for (int x = 0; x < PHASH_SIZE; x++) {
pixels[y][x] = gray.getRaster().getSample(x, y, 0);
}
}
// 3. 二维DCT变换
double[][] dct = dct2d(pixels);
// 4. 取左上角 8x8 低频区域(排除直流分量 [0][0])
double[] lowFreq = new double[HASH_BITS];
int idx = 0;
for (int y = 0; y < 8; y++) {
for (int x = 0; x < 8; x++) {
if (x == 0 && y == 0) continue; // 跳过直流分量
if (idx < HASH_BITS) {
lowFreq[idx++] = dct[y][x];
}
}
}
// 5. 计算均值并生成哈希
double mean = 0;
for (double v : lowFreq) mean += v;
mean /= HASH_BITS;
long hash = 0;
for (int i = 0; i < HASH_BITS; i++) {
if (lowFreq[i] > mean) {
hash |= (1L << i);
}
}
return hash;
}
/**
* 计算两个哈希的汉明距离
* 距离越小表示图像越相似
*/
public static int hammingDistance(long hash1, long hash2) {
long xor = hash1 ^ hash2;
int distance = 0;
while (xor != 0) {
distance += (xor & 1);
xor >>>= 1;
}
return distance;
}
/**
* 像素级差异检测
* 返回标记了差异区域的图像和差异块列表
*/
public static DifferenceResult detectPixelDiff(BufferedImage img1, BufferedImage img2) {
int width = Math.min(img1.getWidth(), img2.getWidth());
int height = Math.min(img1.getHeight(), img2.getHeight());
// 创建差异标记图
BufferedImage diffMap = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
boolean[][] diffMask = new boolean[height][width];
// 逐像素比较
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb1 = img1.getRGB(x, y);
int rgb2 = img2.getRGB(x, y);
int r1 = (rgb1 >> 16) & 0xFF;
int g1 = (rgb1 >> 8) & 0xFF;
int b1 = rgb1 & 0xFF;
int r2 = (rgb2 >> 16) & 0xFF;
int g2 = (rgb2 >> 8) & 0xFF;
int b2 = rgb2 & 0xFF;
// 计算欧几里得色差
double colorDiff = Math.sqrt(
Math.pow(r1 - r2, 2) +
Math.pow(g1 - g2, 2) +
Math.pow(b1 - b2, 2)
);
if (colorDiff > DIFF_THRESHOLD) {
diffMask[y][x] = true;
diffMap.setRGB(x, y, Color.RED.getRGB()); // 标记差异为红色
} else {
diffMap.setRGB(x, y, img1.getRGB(x, y));
}
}
}
// 连通区域标记(BFS),聚合差异像素为差异块
List<Rectangle> diffRegions = findConnectedRegions(diffMask, width, height);
// 在差异图上绘制矩形框
Graphics2D g = diffMap.createGraphics();
g.setColor(Color.GREEN);
g.setStroke(new BasicStroke(3));
for (Rectangle r : diffRegions) {
g.drawRect(r.x, r.y, r.width, r.height);
}
g.dispose();
return new DifferenceResult(diffMap, diffRegions, diffRegions.size());
}
/**
* BFS连通区域标记,找出所有独立的差异块
*/
private static List<Rectangle> findConnectedRegions(boolean[][] mask, int width, int height) {
boolean[][] visited = new boolean[height][width];
List<Rectangle> regions = new ArrayList<>();
int[][] dirs = {{-1,0}, {1,0}, {0,-1}, {0,1}, {-1,-1}, {-1,1}, {1,-1}, {1,1}};
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (mask[y][x] && !visited[y][x]) {
// BFS 遍历整个连通块
List<Point> pixels = new ArrayList<>();
Queue<Point> queue = new LinkedList<>();
queue.offer(new Point(x, y));
visited[y][x] = true;
while (!queue.isEmpty()) {
Point p = queue.poll();
pixels.add(p);
for (int[] d : dirs) {
int nx = p.x + d[0];
int ny = p.y + d[1];
if (nx >= 0 && nx < width && ny >= 0 && ny < height
&& mask[ny][nx] && !visited[ny][nx]) {
visited[ny][nx] = true;
queue.offer(new Point(nx, ny));
}
}
}
// 过滤小噪点
if (pixels.size() >= MIN_DIFF_AREA) {
int minX = width, minY = height, maxX = 0, maxY = 0;
for (Point p : pixels) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
regions.add(new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1));
}
}
}
}
return regions;
}
/**
* 主检测流程:先pHash快速筛选,再像素级精细检测
*/
public static void findDifferences(String path1, String path2, String outputPath) throws IOException {
BufferedImage img1 = ImageIO.read(new File(path1));
BufferedImage img2 = ImageIO.read(new File(path2));
System.out.println("=== 找茬算法检测开始 ===");
// Step 1: 感知哈希快速比较
long hash1 = perceptualHash(img1);
long hash2 = perceptualHash(img2);
int hamming = hammingDistance(hash1, hash2);
System.out.printf("图像1 pHash: %016X%n", hash1);
System.out.printf("图像2 pHash: %016X%n", hash2);
System.out.printf("汉明距离: %d (阈值: 10)%n", hamming);
if (hamming == 0) {
System.out.println("两图完全相同,无需精细检测。");
return;
}
// Step 2: 像素级差异检测
DifferenceResult result = detectPixelDiff(img1, img2);
System.out.printf("发现 %d 处差异区域%n", result.regionCount);
for (int i = 0; i < result.regions.size(); i++) {
Rectangle r = result.regions.get(i);
System.out.printf("差异 #%d: 位置(%d,%d), 大小(%dx%d)%n",
i + 1, r.x, r.y, r.width, r.height);
}
// 保存差异标记图
ImageIO.write(result.diffImage, "PNG", new File(outputPath));
System.out.println("差异标记图已保存至: " + outputPath);
}
static class DifferenceResult {
BufferedImage diffImage;
List<Rectangle> regions;
int regionCount;
DifferenceResult(BufferedImage img, List<Rectangle> regions, int count) {
this.diffImage = img;
this.regions = regions;
this.regionCount = count;
}
}
/**
* 生成测试用找茬图片(带随机差异)
*/
public static void generateTestImages(String basePath, String diffPath) throws IOException {
int width = 400, height = 300;
BufferedImage base = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
BufferedImage diff = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g1 = base.createGraphics();
Graphics2D g2 = diff.createGraphics();
// 绘制相同背景
g1.setColor(new Color(135, 206, 235)); // 天蓝
g1.fillRect(0, 0, width, height);
g2.setColor(new Color(135, 206, 235));
g2.fillRect(0, 0, width, height);
// 绘制相同的房子
g1.setColor(new Color(139, 69, 19));
g1.fillRect(150, 150, 100, 100);
g2.setColor(new Color(139, 69, 19));
g2.fillRect(150, 150, 100, 100);
// 相同的屋顶
int[] xPoints = {140, 200, 260};
int[] yPoints = {150, 100, 150};
g1.setColor(Color.RED);
g1.fillPolygon(xPoints, yPoints, 3);
g2.setColor(Color.RED);
g2.fillPolygon(xPoints, yPoints, 3);
// 相同的太阳
g1.setColor(Color.YELLOW);
g1.fillOval(50, 50, 60, 60);
g2.setColor(Color.YELLOW);
g2.fillOval(50, 50, 60, 60);
// 差异1:图1有云朵,图2没有
g1.setColor(Color.WHITE);
g1.fillOval(280, 60, 80, 40);
g1.fillOval(300, 50, 60, 40);
// 差异2:图1窗户蓝色,图2窗户黄色
g1.setColor(Color.CYAN);
g1.fillRect(170, 170, 25, 25);
g2.setColor(Color.YELLOW);
g2.fillRect(170, 170, 25, 25);
// 差异3:图1有树,图2树的位置不同
g1.setColor(new Color(34, 139, 34));
g1.fillOval(80, 180, 50, 80);
g2.setColor(new Color(34, 139, 34));
g2.fillOval(320, 180, 50, 80); // 树移到了右边
g1.dispose();
g2.dispose();
ImageIO.write(base, "PNG", new File(basePath));
ImageIO.write(diff, "PNG", new File(diffPath));
System.out.println("测试图片已生成");
}
public static void main(String[] args) throws IOException {
// 生成测试图片
generateTestImages("/tmp/test_base.png", "/tmp/test_diff.png");
// 执行找茬检测
findDifferences("/tmp/test_base.png", "/tmp/test_diff.png", "/tmp/result.png");
}
}
算法复杂度分析
| 步骤 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 感知哈希 | O(PHASH_SIZE²) |
O(PHASH_SIZE²) |
32×32 固定开销,可视为 O(1) |
| 汉明距离 | O(HASH_BITS) |
O(1) |
64位逐位比较 |
| 像素差异检测 | O(W×H) |
O(W×H) |
与图像尺寸成正比 |
| 连通区域标记 | O(W×H) |
O(W×H) |
BFS 遍历每个像素一次 |
扩展思考
- 尺度不变性:感知哈希对缩放鲁棒,但对旋转敏感。可结合 Radon 变换或特征点匹配(SIFT/ORB)增强旋转鲁棒性。
- 颜色感知哈希:标准 pHash 基于灰度,可分别在 Y、Cb、Cr 通道计算哈希以捕捉颜色差异。
- 深度学习方案:使用 Siamese 网络或差异图分割网络(Change Detection)可获得更高的检测精度,但计算成本显著增加。
总结
找茬游戏的算法实现融合了信号处理(DCT)、图论(连通区域)和计算机视觉的基础技术。感知哈希提供了高效的”粗筛”能力,而像素级差异检测确保了定位精度。这种”由粗到精”的分层策略是计算机视觉领域的经典范式,也适用于图像去重、视频镜头检测等实际场景。