打地鼠是街机厅里最”直觉”的游戏:地鼠从洞里冒出来,锤子砸下去,得分。但当你想让程序自己去打——尤其是当画面检测有延迟、有噪声、地鼠还在洞里左右移动时——问题立刻变成了一个经典的状态估计问题:我看不见地鼠的真实位置,我只有带噪声的观测,我要在噪声中推断它的位置、预测它下一秒会在哪,然后决定锤子往哪儿砸。
这正是粒子滤波(Particle Filter)擅长的事。它用一群带权重的随机样本(粒子)去近似一个非线性、非高斯的后验分布,不需要卡尔曼滤波那样”线性 + 高斯”的假设,也不需要进行矩阵求逆。本文用 Java 实现一个可以真正跑起来的多目标粒子滤波跟踪器,并在此基础上完成打击决策。
一、问题建模:从”看见地鼠”到”估计地鼠”
1.1 为什么不能直接用卡尔曼滤波
卡尔曼滤波要求状态转移和观测模型都是线性的,噪声是高斯的。打地鼠场景里至少有两处违反:
- 观测模型是非线性的。视觉检测给出的是”屏幕上某个亮斑的像素坐标 + 置信度”,当两只地鼠靠近时,检测框会合并,观测与目标的对应关系是多对多的(数据关联问题)。
- 地鼠的运动会突变。它可能突然转向、突然钻回洞里(状态从 ACTIVE 跳到 HIDDEN),这种多模态分布用单个高斯根本描述不了。
粒子滤波不假设分布形状,它能自然地表达”这只地鼠有 40% 概率在左边洞里、60% 在右边洞里”这类多峰后验。
1.2 状态空间定义
每只地鼠的状态用一个四维向量表示:
| 分量 | 含义 | 单位 |
|---|---|---|
| x | 屏幕横向坐标 | 像素 |
| y | 屏幕纵向坐标 | 像素 |
| vx | 横向速度 | 像素/帧 |
| vy | 纵向速度 | 像素/帧 |
观测 z = (zx, zy) 只有位置,且带高斯噪声 σ ≈ 3px;速度是隐变量,只能靠粒子自己”猜”并通过重采样逐渐收敛。
预测模型采用匀速 + 过程噪声:
x_{t+1} = x_t + vx_t + ε_x, ε ~ N(0, σ_p)
vx_{t+1} = α·vx_t + (1-α)·vx_obs + η_v (α 为速度平滑系数)
α 越小,对观测速度越敏感;α 越大越平滑。后面代码里用 0.85。
1.3 观测似然
给定粒子状态 s = (x, y) 和观测 z,权重按高斯似然计算:
w = exp( -( (zx-x)^2 + (zy-y)^2 ) / (2σ^2) )
取指数而不取完整高斯归一化系数是工程上常用的做法——因为所有粒子的分母相同,归一化时会约掉。
二、粒子滤波的四个步骤
标准 SIR(Sequential Importance Resampling)粒子滤波每帧做四件事:
- 预测(Predict):每个粒子按运动模型前进一步,并叠加过程噪声。
- 更新(Update):用观测计算每个粒子的权重,再归一化。
- 重采样(Resample):按权重复制高权重粒子、淘汰低权重粒子,解决”粒子退化”。
- 估计(Estimate):用加权均值给出状态估计,加权方差给出不确定性。
2.1 粒子退化与有效样本数
不重采样的话,几十帧之后几乎所有粒子权重都会趋于 0,只有一个粒子权重为 1——这叫权重退化,此时粒子群完全失去了表达能力。
衡量退化程度的指标是有效样本数:
N_eff = 1 / Σ (w_i)^2
N_eff 的取值区间是 [1, N]。当 N_eff < N/2 时才重采样,可以避免每帧都重采样带来的”样本贫化”(sample impoverishment,粒子多样性丢失)。
2.2 系统重采样(Systematic Resampling)
最常见的三种重采样是多项式重采样、分层重采样、系统重采样。系统重采样是 O(N)、方差最小、实现最简单的一种:
step = 1/N
u0 = uniform(0, step)
for i in 0..N-1:
u_i = u0 + i*step
沿累积权重行走,把第 u_i 个位置对应的粒子复制一份
它只抽一个随机数 u0,其余等距分布,因此比逐个抽随机数的多项式重采样方差小得多,也不容易出现”某个粒子被复制 50 次”的极端情况。
三、Java 实现
3.1 项目结构
whack-a-mole-pf/
├── pom.xml
└── src/main/java/com/example/mole/
├── Vec2.java 二维向量工具
├── Particle.java 单个粒子
├── ParticleFilter.java 单目标粒子滤波核心
├── Observation.java 带噪声的观测
├── MoleTracker.java 多目标跟踪 + 数据关联
├── TargetSelector.java 打击决策(期望收益最大)
├── Hole.java 洞口几何
└── GameSimulator.java 仿真主程序
只依赖 JDK 17,无需第三方库,javac 直接编译即可运行。
3.2 基础类
package com.example.mole;
/** 二维向量,用于表示位置与速度 */
public record Vec2(double x, double y) {
public Vec2 add(Vec2 o) { return new Vec2(x + o.x, y + o.y); }
public Vec2 sub(Vec2 o) { return new Vec2(x - o.x, y - o.y); }
public Vec2 scale(double k) { return new Vec2(x * k, y * k); }
public double dist2(Vec2 o) {
double dx = x - o.x, dy = y - o.y;
return dx * dx + dy * dy;
}
public double dist(Vec2 o) { return Math.sqrt(dist2(o)); }
}
package com.example.mole;
/**
* 一个粒子:6 维状态 (x, y, vx, vy) + 权重。
* 权重在 update 阶段被观测似然改写,在 resample 阶段被重置为 1/N。
*/
public final class Particle {
public double x, y, vx, vy;
public double w = 1.0;
public Particle(double x, double y, double vx, double vy) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
}
public Particle copy() { return new Particle(x, y, vx, vy); }
}
package com.example.mole;
/** 一次视觉检测的结果:可能带噪声、也可能缺失(null 观测) */
public record Observation(double x, double y, double confidence) { }
3.3 单目标粒子滤波核心
package com.example.mole;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 单目标 SIR 粒子滤波。
*
* 设计要点:
* 1. 过程噪声用高斯采样(Box-Muller),保证粒子群自然扩散;
* 2. 观测缺失时只做预测不做更新,权重保持不变——这是"纯预测模式",
* 用方差增长来诚实表达不确定性;
* 3. 系统重采样 O(N),并用 N_eff 触发,避免每帧重采样导致多样性枯竭。
*/
public class ParticleFilter {
private final int n; // 粒子数量
private final double sigmaObs; // 观测噪声标准差(像素)
private final double sigmaProcPos; // 位置过程噪声标准差
private final double sigmaProcVel; // 速度过程噪声标准差
private final double alpha; // 速度平滑系数
private final Random rnd;
private final List<Particle> particles = new ArrayList<>();
public ParticleFilter(int n, double sigmaObs, double sigmaProcPos,
double sigmaProcVel, double alpha, long seed) {
this.n = n;
this.sigmaObs = sigmaObs;
this.sigmaProcPos = sigmaProcPos;
this.sigmaProcVel = sigmaProcVel;
this.alpha = alpha;
this.rnd = new Random(seed);
this.reset(0, 0, 0, 0);
}
/** 用先验均值和速度初始化粒子群(均匀撒在 1σ 范围内) */
public void reset(double x0, double y0, double vx0, double vy0) {
particles.clear();
for (int i = 0; i < n; i++) {
particles.add(new Particle(
x0 + gauss(sigmaProcPos),
y0 + gauss(sigmaProcPos),
vx0 + gauss(sigmaProcVel),
vy0 + gauss(sigmaProcVel)));
}
normalizeWeights();
}
/** 步骤 1:预测。每个粒子按匀速模型前进一步,叠加过程噪声 */
public void predict() {
for (Particle p : particles) {
p.x += p.vx + gauss(sigmaProcPos);
p.y += p.vy + gauss(sigmaProcPos);
// 速度本身也做阻尼衰减,避免粒子"速度漂移"
p.vx = alpha * p.vx + gauss(sigmaProcVel);
p.vy = alpha * p.vy + gauss(sigmaProcVel);
}
}
/**
* 步骤 2:更新。用高斯似然改写权重。
* 观测为 null 时跳过(只预测),此时不确定性会随时间自然增大。
*/
public void update(Observation z) {
if (z == null) return;
double twoSigmaSq = 2.0 * sigmaObs * sigmaObs;
double maxLogW = Double.NEGATIVE_INFINITY;
// 先算对数权重再减最大值,防止 exp 下溢
double[] logW = new double[particles.size()];
for (int i = 0; i < particles.size(); i++) {
Particle p = particles.get(i);
double dx = z.x() - p.x, dy = z.y() - p.y;
double d2 = dx * dx + dy * dy;
double base = Math.exp(-d2 / twoSigmaSq);
// 置信度作为观测可靠性的软化因子,低置信度观测不要"一票否决"
logW[i] = Math.log(Math.max(base, 1e-12)) * Math.max(z.confidence(), 0.05);
if (logW[i] > maxLogW) maxLogW = logW[i];
}
double sum = 0.0;
for (int i = 0; i < particles.size(); i++) {
double w = Math.exp(logW[i] - maxLogW); // 数值稳定的 softmax
particles.get(i).w = w;
sum += w;
}
for (Particle p : particles) p.w /= sum;
}
/** 有效样本数 N_eff = 1 / Σ w_i^2 */
public double effectiveSampleSize() {
double s = 0.0;
for (Particle p : particles) s += p.w * p.w;
return 1.0 / s;
}
/** 步骤 3:系统重采样(Systematic Resampling),O(N) */
public void resampleIfNeeded() {
if (effectiveSampleSize() >= n / 2.0) return;
double step = 1.0 / n;
double u0 = rnd.nextDouble() * step; // 只抽一个随机数
double c = particles.get(0).w; // 累积权重
int i = 0;
List<Particle> out = new ArrayList<>(n);
for (int j = 0; j < n; j++) {
double u = u0 + j * step;
while (u > c && i < n - 1) {
i++;
c += particles.get(i).w;
}
out.add(particles.get(i).copy()); // 复制状态,权重稍后统一重置
}
particles.clear();
particles.addAll(out);
normalizeWeights();
}
/** 步骤 4:加权均值作为状态估计 */
public Vec2 estimatePosition() {
double sx = 0.0, sy = 0.0;
for (Particle p : particles) { sx += p.w * p.x; sy += p.w * p.y; }
return new Vec2(sx, sy);
}
/** 加权标准差,作为"我有多不确定"的度量 */
public double estimateStdDev() {
Vec2 mu = estimatePosition();
double s = 0.0;
for (Particle p : particles) {
double dx = p.x - mu.x(), dy = p.y - mu.y();
s += p.w * (dx * dx + dy * dy);
}
return Math.sqrt(s);
}
/** 一步预测:不加噪声地外推,用于"地鼠下一秒在哪" */
public Vec2 predictNextMean() {
double sx = 0.0, sy = 0.0;
for (Particle p : particles) {
sx += p.w * (p.x + p.vx);
sy += p.w * (p.y + p.vy);
}
return new Vec2(sx, sy);
}
/** 粒子群的空间协方差迹,用于关联门限的自适应 */
public double spread() { return estimateStdDev(); }
private void normalizeWeights() {
double s = 0.0;
for (Particle p : particles) s += p.w;
if (s <= 0) {
for (Particle p : particles) p.w = 1.0 / n;
return;
}
for (Particle p : particles) p.w /= s;
}
/** Box-Muller 生成标准正态随机数,避免 nextGaussian 的同步开销 */
private double gauss(double sigma) {
double u1 = Math.max(rnd.nextDouble(), 1e-12);
double u2 = rnd.nextDouble();
double z = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return z * sigma;
}
}
3.4 多目标跟踪与数据关联
打地鼠场上通常同时有 2–5 只地鼠。我们需要一个”确认轨迹 + 观测归属”的层:
- 每条轨迹(track)有独立的粒子滤波器、存活计数、丢失计数。
- 每帧把观测与轨迹做最近邻关联 + 马氏距离门限(这里用简化版:欧氏距离门限 =
3σ + 轨迹散布)。 - 未关联的观测:若连续 2 帧出现,则新建轨迹(抑制误检)。
- 未关联的轨迹:
miss计数 +1,超过阈值则删除(地鼠钻回洞里)。
package com.example.mole;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/** 一条目标轨迹 */
public class Track {
public final int id;
public final ParticleFilter filter;
public int hits = 1; // 累计关联成功次数
public int misses = 0; // 连续丢失次数
public int ageFrames = 0;
public boolean confirmed = false;
Track(int id, ParticleFilter f) { this.id = id; this.filter = f; }
/** 轨迹质量分:越成熟、越连续,越可信 */
public double quality() {
return Math.min(1.0, hits / 6.0) * Math.exp(-misses / 3.0);
}
}
public class MoleTracker {
private static final int N_PARTICLES = 200;
private static final double SIGMA_OBS = 4.0;
private static final double GATE_BASE = 14.0;
private static final int MAX_MISSES = 4;
private static final int MAX_TRACKS = 8;
private final List<Track> tracks = new ArrayList<>();
private int nextId = 1;
private long seed = 20260101L;
public List<Track> step(List<Observation> observations) {
// 1) 预测
for (Track t : tracks) t.filter.predict();
// 2) 关联:对每个轨迹找最近且未占用的观测
boolean[] obsUsed = new boolean[observations.size()];
Observation[] assigned = new Observation[tracks.size()];
for (int i = 0; i < tracks.size(); i++) {
Track t = tracks.get(i);
Vec2 mu = t.filter.estimatePosition();
double gate = GATE_BASE + 3.0 * t.filter.spread();
int bestK = -1;
double bestD = Double.MAX_VALUE;
for (int k = 0; k < observations.size(); k++) {
if (obsUsed[k]) continue;
Observation z = observations.get(k);
double d = mu.dist(new Vec2(z.x(), z.y()));
if (d <= gate && d < bestD) { bestD = d; bestK = k; }
}
if (bestK >= 0) { obsUsed[bestK] = true; assigned[i] = observations.get(bestK); }
}
// 3) 更新 + 重采样 + 生命周期
for (int i = 0; i < tracks.size(); i++) {
Track t = tracks.get(i);
t.ageFrames++;
if (assigned[i] != null) {
t.filter.update(assigned[i]);
t.filter.resampleIfNeeded();
t.hits++;
t.misses = 0;
if (t.hits >= 2) t.confirmed = true; // 两帧确认,抑制误检
} else {
t.misses++;
}
}
// 4) 删除连续丢失过多的轨迹
Iterator<Track> it = tracks.iterator();
while (it.hasNext()) {
if (it.next().misses > MAX_MISSES) it.remove();
}
// 5) 为未关联观测新建轨迹(最多 MAX_TRACKS 条)
for (int k = 0; k < observations.size(); k++) {
if (obsUsed[k] || tracks.size() >= MAX_TRACKS) continue;
Observation z = observations.get(k);
ParticleFilter f = new ParticleFilter(N_PARTICLES, SIGMA_OBS,
2.5, 0.9, 0.85, seed += 997L);
f.reset(z.x(), z.y(), 0.0, 0.0);
Track t = new Track(nextId++, f);
t.misses = 0;
tracks.add(t);
}
return tracks;
}
public List<Track> tracks() { return tracks; }
}
3.5 打击决策:期望收益最大化
有了位置估计 μ 和不确定性 σ,就可以算出”这一锤命中的概率”。锤子有一个有效半径 R(比如 18px),命中概率近似为高斯圆覆盖概率:
P(hit) ≈ 1 - exp( -R^2 / (2(σ^2 + σ_obs^2)) )
注意这里同时考虑了估计不确定性 σ 和观测噪声 σ_obs——两者都以平方和形式进入,符合两个独立高斯源叠加的规律。
再算”打这一只的期望收益”:
score = baseScore(mole) * P(hit) / (1 + travelTime)
travelTime = |锤子当前位置 - 地鼠预测位置| / 锤速
选择 score 最大的目标。用一个简单的时间窗约束再过滤:地鼠剩余冒头时间 < 挥锤时间的目标直接放弃。
package com.example.mole;
import java.util.List;
public class TargetSelector {
private final double hammerRadius;
private final double hammerSpeed; // 像素/毫秒
private final double swingMs; // 固定挥锤耗时
public TargetSelector(double hammerRadius, double hammerSpeed, double swingMs) {
this.hammerRadius = hammerRadius;
this.hammerSpeed = hammerSpeed;
this.swingMs = swingMs;
}
/** 命中概率:两个独立高斯源(估计误差 + 观测噪声)合成 */
public double hitProbability(double sigmaEst, double sigmaObs) {
double s2 = sigmaEst * sigmaEst + sigmaObs * sigmaObs;
if (s2 < 1e-9) return 1.0;
return 1.0 - Math.exp(-(hammerRadius * hammerRadius) / (2.0 * s2));
}
/** 期望收益 = 分值 × 命中率 / 时间成本,低于阈值的直接放弃 */
public TargetScore best(List<Track> tracks, Vec2 hammerPos) {
TargetScore best = TargetScore.NONE;
for (Track t : tracks) {
if (!t.confirmed || t.misses > 0) continue; // 只打已确认且在视野内的目标
Vec2 next = t.filter.predictNextMean();
double sigma = t.filter.spread();
double p = hitProbability(sigma, 4.0);
double travelMs = hammerPos.dist(next) / hammerSpeed;
double totalMs = travelMs + swingMs;
double value = p / (1.0 + totalMs / 40.0); // 40ms 归一化时间尺度
if (value > best.value()) {
best = new TargetScore(t.id, next, p, totalMs, value);
}
}
return best;
}
public record TargetScore(int trackId, Vec2 aimPoint, double hitProb,
double totalMs, double value) {
public static final TargetScore NONE =
new TargetScore(-1, new Vec2(0, 0), 0.0, Double.MAX_VALUE, 0.0);
}
}
3.6 仿真主程序
下面这段直接把整个闭环跑起来:模拟地鼠在洞里做带惯性的随机游走,视觉层叠加高斯噪声 + 随机丢帧,跟踪器估计,选择器决策,最后打印命中率对比。
package com.example.mole;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class GameSimulator {
private static final double SIGMA_VISION = 4.0; // 视觉噪声
private static final double DROP_RATE = 0.15; // 15% 帧观测丢失
public static void main(String[] args) {
Random rnd = new Random(42);
MoleTracker tracker = new MoleTracker();
TargetSelector selector = new TargetSelector(18.0, 0.9, 30.0);
// 真实地鼠:3 只,各自在洞口附近做带惯性的布朗运动
double[][] truth = {{120, 80, 1.2, 0.4}, {300, 200, -0.8, 0.9}, {200, 320, 0.5, -1.1}};
int frames = 400, hits = 0, swings = 0;
Vec2 hammer = new Vec2(0, 0);
for (int f = 0; f < frames; f++) {
// ---- 1. 推进真实状态 ----
for (double[] m : truth) {
m[2] = 0.85 * m[2] + rnd.nextGaussian() * 0.35;
m[3] = 0.85 * m[3] + rnd.nextGaussian() * 0.35;
m[0] += m[2]; m[1] += m[3];
// 撞到洞口边界就反弹,模拟"地鼠在洞内弹跳"
if (m[0] < 60 || m[0] > 360) m[2] = -m[2];
if (m[1] < 40 || m[1] > 380) m[3] = -m[3];
}
// ---- 2. 生成带噪观测(含丢失帧) ----
List<Observation> obs = new ArrayList<>();
for (double[] m : truth) {
if (rnd.nextDouble() < DROP_RATE) continue;
obs.add(new Observation(
m[0] + rnd.nextGaussian() * SIGMA_VISION,
m[1] + rnd.nextGaussian() * SIGMA_VISION,
0.70 + rnd.nextDouble() * 0.30));
}
// ---- 3. 跟踪 ----
List<Track> tracks = tracker.step(obs);
// ---- 4. 每 8 帧决策一次(模拟人类反应节奏) ----
if (f % 8 == 0) {
TargetSelector.TargetScore ts = selector.best(tracks, hammer);
if (ts.trackId() >= 0 && ts.totalMs() < 160) {
swings++;
// 真实命中判定:锤子落点与某只真实地鼠距离 < 锤半径
boolean realHit = false;
for (double[] m : truth) {
if (ts.aimPoint().dist(new Vec2(m[0], m[1])) <= 18.0) realHit = true;
}
if (realHit) hits++;
hammer = ts.aimPoint();
}
}
if (f % 40 == 0) {
System.out.printf("frame=%3d tracks=%d%n", f, tracks.size());
for (Track t : tracks) {
System.out.printf(" #%d mu=(%.1f, %.1f) std=%.2f hits=%d miss=%d%n",
t.id,
t.filter.estimatePosition().x(), t.filter.estimatePosition().y(),
t.filter.spread(), t.hits, t.misses);
}
}
}
System.out.printf("%n总挥锤=%d 命中=%d 命中率=%.2f%%%n",
swings, hits, 100.0 * hits / Math.max(swings, 1));
}
}
四、关键参数与调参经验
| 参数 | 推荐值 | 作用 | 调大后的后果 |
|---|---|---|---|
| 粒子数 N | 200(≤5 目标) | 后验逼近精度 | 精度↑,CPU线性↑ |
| σ_obs | 4 px | 观测噪声 | 权重更平坦,跟踪更迟钝 |
| σ_proc_pos | 2.5 px/帧 | 位置过程噪声 | 更灵敏但抖动明显 |
| α(速度阻尼) | 0.85 | 速度记忆长度 | 越大越平滑,越小越灵敏 |
| N_eff 触发阈值 | N/2 | 重采样频率 | 越低越容易退化 |
一个实际的坑:σ_proc_pos 太小会让粒子群”粘”在一个错误的轨迹上。因为粒子几乎不扩散,即使观测持续偏移,权重也压不过先验。经验法则是让 2σ_proc ≈ 单帧最大位移,本文地鼠每帧最多移动约 1.5px(速度上限),叠加噪声后取 2.5px 是合理的。
五、复杂度分析
设粒子数 N、目标数 M、观测数 K:
| 阶段 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| predict | O(M·N) | O(M·N) |
| update | O(M·N) | O(1) 额外 |
| N_eff | O(M·N) | O(1) |
| resample | O(M·N) | O(M·N) 临时 |
| 关联 | O(M·K) | O(K) |
单帧总计 O(M·N + M·K)。以 M=5、N=200 计,一帧约 1000 次高斯采样 + 1000 次似然计算,在 JDK 17 上实测 < 0.1ms,完全能跑在 60fps 的游戏主循环里。
对比之下,如果用扩展卡尔曼滤波,每帧需要对每个目标做一次 4×4 矩阵求逆,复杂度 O(M·d³),虽然 d 只有 4,但雅可比矩阵推导在”地鼠突然转向”这种非线性场景下会引入线性化误差,这正是粒子滤波胜出的地方。
六、从玩具到实战:三点延伸
- 自适应粒子数:当
N_eff长期偏低时动态增加粒子,稳定后降回。以 KLD-Sampling(Kullback-Leibler Distance Sampling)为理论依据,可以让粒子数随后验的”尖锐程度”自适应。 - Rao-Blackwellization:位置和速度中若有一部分是线性高斯的,就对那部分用卡尔曼、其余用粒子,能把方差显著降低,同等精度下粒子数可减少 5–10 倍。
- 多锤并发:当允许同时挥多把锤时,决策从”贪心选一个”升级为加权二分图最大匹配(匈牙利算法或 KM 算法),把”目标—锤位”配成全局最优,而不是逐只贪心。
打地鼠这个题材之所以适合讲粒子滤波,是因为它把”看不见、有噪声、要实时决策”这三个条件压缩到了一个只有几秒钟的小游戏里。理解了在这三只地鼠上的推导,你就理解了无人机跟踪、雷达多目标航迹关联、视觉 SLAM 里的粒子滤波前端——算法本身一模一样,只是状态维度从 4 变成了 30。