4. 对象检测

任务目标

在Android应用中集成TensorFlow Lite的SSD MobileNet模型,对摄像头预览帧执行实时对象检测,并在预览画面中绘制检测到的对象边界框与类别标签。

环境依赖

  • Android Studio 4.2+,minSdkVersion 21+
  • TensorFlow Lite 2.10.0(org.tensorflow:tensorflow-lite:2.10.0)
  • 模型文件:detect.tflite(SSD MobileNet v1,输入尺寸300×300,输出为边界框/类别/分数/数量),放置于app/src/main/assets/目录
  • 标签文件:labelmap.txt(每行一个类别名,与模型输出索引对应),同样置于assets目录

核心实现

以下代码包含:模型加载与预处理、推理执行、结果解析,以及一个最小可运行的Activity,使用静态图片(从资源读取)模拟单帧检测流程,便于验证核心逻辑。摄像头实时预览接入只需将processFrame()的输入替换为ImageProxy转换后的Bitmap即可。

// ObjectDetector.kt - 核心检测器
import android.content.Context
import android.graphics.Bitmap
import org.tensorflow.lite.Interpreter
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel

data class Detection(val label: String, val score: Float, val left: Float, val top: Float, val right: Float, val bottom: Float)

class ObjectDetector(context: Context, modelPath: String = "detect.tflite", labelPath: String = "labelmap.txt") {
    private val interpreter: Interpreter
    private val labels: List<String>
    private val inputSize = 300
    private val pixelSize = 3
    private val numDetections = 10

    init {
        interpreter = Interpreter(loadModelFile(context, modelPath))
        labels = context.assets.open(labelPath).bufferedReader().readLines()
    }

    private fun loadModelFile(context: Context, path: String): MappedByteBuffer {
        val fd = context.assets.openFd(path)
        val input = FileInputStream(fd.fileDescriptor).channel
        return input.map(FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength)
    }

    fun processFrame(bitmap: Bitmap): List<Detection> {
        val scaled = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true)
        val input = convertBitmapToByteBuffer(scaled)
        val output = Array(1) { Array(numDetections) { FloatArray(4) } }  // boxes
        val classes = Array(1) { FloatArray(numDetections) }
        val scores = Array(1) { FloatArray(numDetections) }
        val count = Array(1) { FloatArray(1) }

        interpreter.runForMultipleInputsOutputs(
            arrayOf(input),
            mapOf(0 to output, 1 to classes, 2 to scores, 3 to count)
        )

        val results = mutableListOf<Detection>()
        val detectionCount = count[0][0].toInt().coerceAtMost(numDetections)
        for (i in 0 until detectionCount) {
            val score = scores[0][i]
            if (score > 0.5f) {
                val box = output[0][i]
                val labelIdx = classes[0][i].toInt()
                val label = if (labelIdx in labels.indices) labels[labelIdx] else "unknown"
                results.add(Detection(label, score, box[1], box[0], box[3], box[2]))
            }
        }
        return results
    }

    private fun convertBitmapToByteBuffer(bitmap: Bitmap): ByteBuffer {
        val byteBuffer = ByteBuffer.allocateDirect(4 * inputSize * inputSize * pixelSize)
        byteBuffer.order(ByteOrder.nativeOrder())
        val pixels = IntArray(inputSize * inputSize)
        bitmap.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
        for (pixel in pixels) {
            val r = (pixel shr 16) and 0xFF
            val g = (pixel shr 8) and 0xFF
            val b = pixel and 0xFF
            byteBuffer.putFloat(r / 255.0f)
            byteBuffer.putFloat(g / 255.0f)
            byteBuffer.putFloat(b / 255.0f)
        }
        return byteBuffer
    }
}
// MainActivity.kt - 最小验证入口
import android.graphics.BitmapFactory
import android.os.Bundle
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val imageView = findViewById<ImageView>(R.id.imageView)
        val textView = findViewById<TextView>(R.id.textView)
        val bitmap = BitmapFactory.decodeResource(resources, R.drawable.test_image)

        val detector = ObjectDetector(this)
        val detections = detector.processFrame(bitmap)

        // 在Bitmap上绘制边界框(简化:仅输出文本信息)
        val sb = StringBuilder()
        detections.forEach { d ->
            sb.append("${d.label} (${"%.2f".format(d.score)})\n")
            sb.append("  box: [${"%.2f".format(d.left)}, ${"%.2f".format(d.top)}, ${"%.2f".format(d.right)}, ${"%.2f".format(d.bottom)}]\n")
        }
        textView.text = if (sb.isEmpty()) "No objects detected" else sb.toString()
        imageView.setImageBitmap(bitmap)
    }
}

运行与验证

  1. 将detect.tflite和labelmap.txt放入app/src/main/assets/目录。
  2. 准备一张包含已知物体的测试图片(如包含人、车),命名为test_image.jpg放入res/drawable/。
  3. 在app/build.gradle中添加依赖:implementation 'org.tensorflow:tensorflow-lite:2.10.0'。
  4. 运行App,观察TextView输出。

通过条件:TextView中显示至少一个检测结果,且类别名称与图片内容一致(例如图片中有"person",输出包含"person"),分数大于0.5。边界框坐标值在0~1之间且符合对象位置比例。

常见问题

  • 模型加载失败:确认assets路径正确,且模型文件未损坏。
  • 输出全为0:检查输入归一化是否正确(SSD MobileNet需要[0,1]范围的float输入)。
  • 坐标方向:模型输出为[y_min, x_min, y_max, x_max]归一化坐标,绘制时需乘以视图宽高。
SSD MobileNet TFLite 推理流程 Camera/Image 300×300×3 预处理 缩放+归一化 TFLite Interpreter SSD MobileNet 解析 输出 输出张量结构 boxes [1,10,4] classes [1,10] scores [1,10] count [1,1] • boxes: 归一化坐标 [y_min, x_min, y_max, x_max] • classes: 类别索引,对应 labelmap.txt 中的行号 • 过滤条件: score > 0.5 且 count 限制实际检测数量