17、目标检测实战(二):NMS(非极大值抑制)实现、绘制检测框、实时摄像头检测
上一章我们搞定了模型加载和推理,拿到了原始的检测结果。但说实话,那个结果根本没法直接用——一张图上可能冒出几十个框,全都叠在一起,看着就头疼。
今天我们要解决三个核心问题:怎么去掉重复的框、怎么把框画得漂亮、怎么让摄像头实时跑起来。嗯,这才是真正能用的目标检测。
17.1 非极大值抑制(NMS)—— 去重才是硬道理
先说说为什么需要NMS。模型在检测物体时,会对同一个目标输出多个候选框。比如一张人脸,模型可能给出5个框,位置稍有偏移,置信度也差不多。你想想看,如果全画出来,画面就乱成一锅粥了。
NMS的核心思想很简单:保留置信度最高的框,干掉和它重叠太多的框。
17.1.1 IoU(交并比)计算
要判断两个框是否重叠,我们需要一个指标——IoU。说白了就是两个框的交集面积除以并集面积。
private float computeIoU(RectF box1, RectF box2) {
float intersectionLeft = Math.max(box1.left, box2.left);
float intersectionTop = Math.max(box1.top, box2.top);
float intersectionRight = Math.min(box1.right, box2.right);
float intersectionBottom = Math.min(box1.bottom, box2.bottom);
if (intersectionLeft >= intersectionRight || intersectionTop >= intersectionBottom) {
return 0f; // 没有交集
}
float intersectionArea = (intersectionRight - intersectionLeft) *
(intersectionBottom - intersectionTop);
float box1Area = (box1.right - box1.left) * (box1.bottom - box1.top);
float box2Area = (box2.right - box2.left) * (box2.bottom - box2.top);
float unionArea = box1Area + box2Area - intersectionArea;
return intersectionArea / unionArea;
}
我在项目中遇到过一个问题:当两个框完全不相交时,intersectionLeft 可能大于 intersectionRight,这时候直接返回0就行,别傻傻地继续算。
17.1.2 NMS 算法实现
NMS的流程其实就三步:
- 把所有框按置信度从高到低排序
- 取出置信度最高的框,保留它
- 计算它和其他框的IoU,干掉IoU大于阈值的框
- 重复2-3步,直到没有框剩下
public List<DetectedObject> nms(List<DetectedObject> detections, float iouThreshold) {
// 按置信度降序排序
Collections.sort(detections, (a, b) -> Float.compare(b.confidence, a.confidence));
List<DetectedObject> result = new ArrayList<>();
boolean[] removed = new boolean[detections.size()];
for (int i = 0; i < detections.size(); i++) {
if (removed[i]) continue;
DetectedObject best = detections.get(i);
result.add(best);
for (int j = i + 1; j < detections.size(); j++) {
if (removed[j]) continue;
float iou = computeIoU(best.box, detections.get(j).box);
if (iou > iouThreshold) {
removed[j] = true;
}
}
}
return result;
}
17.2 绘制检测框 —— 画得清楚比画得好看重要
框选出来了,接下来就是画到图像上。Android里画框最常用的就是 Canvas 和 Paint。我个人习惯把绘制逻辑封装成一个工具类,这样哪里都能用。
17.2.1 基础绘制方法
public class DetectionDrawer {
private Paint boxPaint;
private Paint textPaint;
private Paint backgroundPaint;
public DetectionDrawer() {
boxPaint = new Paint();
boxPaint.setStyle(Paint.Style.STROKE);
boxPaint.setStrokeWidth(4f);
boxPaint.setColor(Color.RED);
textPaint = new Paint();
textPaint.setColor(Color.WHITE);
textPaint.setTextSize(36f);
textPaint.setAntiAlias(true);
backgroundPaint = new Paint();
backgroundPaint.setStyle(Paint.Style.FILL);
backgroundPaint.setColor(Color.RED);
}
public void drawDetections(Canvas canvas, List<DetectedObject> detections) {
for (DetectedObject obj : detections) {
// 画框
canvas.drawRect(obj.box, boxPaint);
// 画标签背景
String label = String.format("%s: %.2f", obj.label, obj.confidence);
float textWidth = textPaint.measureText(label);
RectF bgRect = new RectF(obj.box.left, obj.box.top - 50,
obj.box.left + textWidth + 10, obj.box.top);
canvas.drawRect(bgRect, backgroundPaint);
// 画文字
canvas.drawText(label, obj.box.left + 5, obj.box.top - 10, textPaint);
}
}
}
这里有个小细节:文字背景的高度我设了50像素,这个值要根据你的字体大小来调。我曾经偷懒没算这个,结果文字和框重叠在一起,看着特别别扭。
17.2.2 颜色管理
不同类别的物体最好用不同颜色。我一般准备一个颜色数组,按类别索引取色:
private int[] colors = {
Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW,
Color.CYAN, Color.MAGENTA, Color.rgb(255, 165, 0), Color.rgb(128, 0, 128)
};
private int getColorForClass(int classIndex) {
return colors[classIndex % colors.length];
}
17.3 实时摄像头检测 —— 让模型跑起来
终于到了最激动人心的环节——让摄像头实时检测。Android上做摄像头预览,主流方案是 CameraX 或 Camera2。我个人推荐 CameraX,API 更友好,踩坑少。
17.3.1 初始化 CameraX
private void startCamera() {
PreviewView previewView = findViewById(R.id.previewView);
Preview preview = new Preview.Builder().build();
preview.setSurfaceProvider(previewView.getSurfaceProvider());
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setTargetResolution(new Size(640, 640))
.build();
imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), image -> {
// 在这里处理每一帧
processImage(image);
image.close();
});
CameraSelector cameraSelector = new CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build();
cameraProviderFuture.bindToLifecycle(this, cameraSelector, preview, imageAnalysis);
}
注意 STRATEGY_KEEP_ONLY_LATEST 这个策略。它保证分析器只处理最新的帧,不会积压。如果处理速度跟不上预览速度,旧的帧会被丢弃。嗯,这个很重要,不然内存会爆。
17.3.2 图像预处理与推理
摄像头出来的图像是 YUV_420_888 格式,需要转成 RGB 再喂给模型。我一般用 Bitmap 做中转:
private void processImage(ImageProxy image) {
Bitmap bitmap = ImageUtils.yuv420ToBitmap(image);
Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, 640, 640, true);
// 模型推理
float[][] outputs = model.runInference(resizedBitmap);
// 解析输出
List<DetectedObject> detections = parseOutputs(outputs);
// NMS去重
List<DetectedObject> filtered = nms(detections, 0.5f);
// 在主线程绘制
runOnUiThread(() -> {
Canvas canvas = previewView.getHolder().lockCanvas();
if (canvas != null) {
drawer.drawDetections(canvas, filtered);
previewView.getHolder().unlockCanvasAndPost(canvas);
}
});
}
17.4 知识体系总览
下面这张图总结了本章的核心逻辑:
17.5 避坑指南与性能优化
做实时检测时,有几个坑我踩过,分享给你:
- 内存泄漏:每一帧都创建新的Bitmap,如果不及时回收,内存会涨得飞快。我习惯用对象池复用Bitmap。
- 帧率不稳定:如果模型推理太慢,可以降低输入分辨率。640×640不行就试试320×320,精度损失不大,但速度能快一倍。
- 横竖屏适配:摄像头预览和模型输入的方向可能不一致。记得做旋转处理,不然检测框会歪。
好了,到这里你已经掌握了目标检测的完整流程:从模型推理到NMS去重,再到实时摄像头检测。这些代码可以直接用到你的项目里。嗯,动手试试吧,把摄像头对准你桌上的东西,看看模型能不能认出来。
公众号:蓝海资料掘金营,微信deep3321