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的流程其实就三步:

  1. 把所有框按置信度从高到低排序
  2. 取出置信度最高的框,保留它
  3. 计算它和其他框的IoU,干掉IoU大于阈值的框
  4. 重复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;
}
经验之谈:IoU阈值一般设0.5左右。设得太低(比如0.3)会漏掉一些框,设得太高(比如0.7)又去不掉重复框。我一般从0.5开始调,根据实际效果微调。

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);
        }
    });
}
性能关键点:图像转换和模型推理不要在UI线程做。我见过有人直接在分析器里做耗时操作,结果界面卡成PPT。用单线程的Executor来处理,既保证顺序,又不阻塞UI。

17.4 知识体系总览

下面这张图总结了本章的核心逻辑:

目标检测实战(二)核心流程 模型推理 输入图像 → 输出原始框 NMS 非极大值抑制 排序 → 计算IoU → 去重 绘制检测框 Canvas绘制 + 标签 实时摄像头检测流程 CameraX预览 → ImageAnalysis分析 → YUV转Bitmap → 模型推理 → NMS → 绘制 关键参数 • IoU阈值:0.5(常用) • 置信度阈值:0.3~0.5 • 输入尺寸:640×640 常见问题 • 框太多 → 调高IoU阈值 • 漏检 → 调低置信度阈值 • 卡顿 → 降低输入分辨率 优化建议 • 使用NNAPI加速推理 • 异步处理图像帧 • 复用Bitmap对象

17.5 避坑指南与性能优化

做实时检测时,有几个坑我踩过,分享给你:

  • 内存泄漏:每一帧都创建新的Bitmap,如果不及时回收,内存会涨得飞快。我习惯用对象池复用Bitmap。
  • 帧率不稳定:如果模型推理太慢,可以降低输入分辨率。640×640不行就试试320×320,精度损失不大,但速度能快一倍。
  • 横竖屏适配:摄像头预览和模型输入的方向可能不一致。记得做旋转处理,不然检测框会歪。
我的习惯:在开发阶段,我会在屏幕上显示FPS(帧率),这样能直观地看到性能变化。优化前20帧,优化后能到30帧,心里就有数了。

好了,到这里你已经掌握了目标检测的完整流程:从模型推理到NMS去重,再到实时摄像头检测。这些代码可以直接用到你的项目里。嗯,动手试试吧,把摄像头对准你桌上的东西,看看模型能不能认出来。


公众号:蓝海资料掘金营,微信deep3321