前言
在前面的文章中,我们实现了图像分类应用。但在实际场景中,我们往往需要不仅识别物体类别,还要知道物体在图像中的具体位置。本文将介绍如何在 HarmonyOS 上实现目标检测功能,包括边界框检测、多对象识别和可视化展示。
目标检测 vs 图像分类
图像分类(Image Classification)
-
输入: 一张图片
-
输出: 类别标签 + 置信度
-
应用: 图片标签、内容审核
输入图片 → 模型 → "狗" (0.95)
目标检测(Object Detection)
-
输入: 一张图片
-
输出: 多个物体的类别、置信度、边界框坐标
-
应用: 自动驾驶、安防监控、AR 应用
输入图片 → 模型 → [
{ label: "狗", confidence: 0.95, box: [10, 20, 100, 150] },
{ label: "猫", confidence: 0.88, box: [200, 50, 150, 180] }
]
核心数据结构设计
边界框定义
typescript
// DetectionTypes.ets
/**
* 边界框坐标(归一化到 0-1)
*/
export interface BoundingBox {
x: number // 左上角 x 坐标
y: number // 左上角 y 坐标
width: number // 宽度
height: number // 高度
}
/**
* 检测结果
*/
export interface DetectionResult {
label: string // 类别标签
confidence: number // 置信度
box: BoundingBox // 边界框
classId: number // 类别 ID
}
/**
* 检测配置
*/
export interface DetectionConfig {
confidenceThreshold: number // 置信度阈值
iouThreshold: number // NMS IoU 阈值
maxDetections: number // 最大检测数量
}
目标检测模型管理器
typescript
// ObjectDetectionManager.ets
import { Context } from '@kit.AbilityKit'
import { DetectionResult, BoundingBox, DetectionConfig } from './DetectionTypes'
export class ObjectDetectionManager {
private modelLoaded: boolean = false
private modelName: string = 'yolov5s.ms'
// COCO 数据集类别(简化版)
private readonly labels: string[] = [
'person', 'bicycle', 'car', 'motorcycle', 'airplane',
'bus', 'train', 'truck', 'boat', 'traffic light',
'fire hydrant', 'stop sign', 'parking meter', 'bench', 'bird',
'cat', 'dog', 'horse', 'sheep', 'cow'
]
/**
* 加载检测模型
*/
async loadModel(context: Context): Promise<void> {
console.info(`开始加载目标检测模型: ${this.modelName}`)
const startTime = Date.now()
try {
const resMgr = context.resourceManager
const descriptor = await resMgr.getRawFd(this.modelName)
// 模拟模型加载
await this.delay(2000)
this.modelLoaded = true
const loadTime = Date.now() - startTime
console.info(`模型加载成功,耗时: ${loadTime}ms`)
} catch (error) {
const err = error as Error
throw new Error(`模型加载失败: ${err.message}`)
}
}
/**
* 执行目标检测
*/
async detect(
imageUri: string,
config: DetectionConfig = {
confidenceThreshold: 0.5,
iouThreshold: 0.45,
maxDetections: 10
}
): Promise<DetectionResult[]> {
if (!this.modelLoaded) {
throw new Error('模型未加载')
}
console.info('开始目标检测')
const startTime = Date.now()
try {
// 1. 图像预处理
await this.delay(300)
// 2. 模型推理
await this.delay(800)
// 3. 后处理:NMS 等
const detections = this.getMockDetections(config)
const detectTime = Date.now() - startTime
console.info(`检测完成,耗时: ${detectTime}ms,检测到 ${detections.length} 个对象`)
return detections
} catch (error) {
const err = error as Error
throw new Error(`检测失败: ${err.message}`)
}
}
/**
* 获取模拟检测结果
*/
private getMockDetections(config: DetectionConfig): DetectionResult[] {
const allDetections: DetectionResult[] = [
{
label: 'dog',
confidence: 0.92,
box: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
classId: 16
},
{
label: 'cat',
confidence: 0.85,
box: { x: 0.5, y: 0.3, width: 0.25, height: 0.35 },
classId: 15
},
{
label: 'person',
confidence: 0.78,
box: { x: 0.7, y: 0.1, width: 0.2, height: 0.6 },
classId: 0
}
]
// 应用置信度阈值过滤
return allDetections
.filter((det: DetectionResult): boolean => det.confidence >= config.confidenceThreshold)
.slice(0, config.maxDetections)
}
/**
* 格式化检测结果为文本
*/
formatDetections(detections: DetectionResult[]): string {
if (detections.length === 0) {
return '未检测到任何对象'
}
let output = `🎯 检测到 ${detections.length} 个对象:\n\n`
detections.forEach((det: DetectionResult, index: number): void => {
output += `${index + 1}. ${det.label}\n`
output += ` 置信度: ${(det.confidence * 100).toFixed(1)}%\n`
output += ` 位置: (${(det.box.x * 100).toFixed(0)}%, ${(det.box.y * 100).toFixed(0)}%)\n`
output += ` 大小: ${(det.box.width * 100).toFixed(0)}% × ${(det.box.height * 100).toFixed(0)}%\n\n`
})
return output
}
private delay(ms: number): Promise<void> {
return new Promise<void>((resolve: Function): void => {
setTimeout((): void => resolve(), ms)
})
}
}
可视化组件:边界框绘制
typescript
// BoundingBoxCanvas.ets
import { DetectionResult } from '../utils/DetectionTypes'
@Component
export struct BoundingBoxCanvas {
@Prop detections: DetectionResult[] = []
@Prop imageWidth: number = 300
@Prop imageHeight: number = 300
// 颜色映射
private colors: string[] = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080'
]
build() {
Stack() {
// 边界框和标签
ForEach(this.detections, (det: DetectionResult, index: number) => {
this.buildBoundingBox(det, index)
})
}
.width(this.imageWidth)
.height(this.imageHeight)
}
@Builder
buildBoundingBox(detection: DetectionResult, index: number): void {
Stack() {
// 边界框
Column()
.width(`${detection.box.width * 100}%`)
.height(`${detection.box.height * 100}%`)
.border({
width: 3,
color: this.colors[index % this.colors.length]
})
.borderRadius(4)
// 标签背景
Column() {
Text(`${detection.label} ${(detection.confidence * 100).toFixed(0)}%`)
.fontSize(12)
.fontColor(Color.White)
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
}
.backgroundColor(this.colors[index % this.colors.length])
.borderRadius({ topLeft: 4, topRight: 4 })
.position({
x: `${detection.box.x * 100}%`,
y: `${detection.box.y * 100}%`
})
}
.width('100%')
.height('100%')
.alignContent(Alignment.TopStart)
}
}
完整 UI 实现
typescript
// DetectionPage.ets
import { ObjectDetectionManager } from '../utils/ObjectDetectionManager'
import { DetectionResult, DetectionConfig } from '../utils/DetectionTypes'
import { BoundingBoxCanvas } from '../components/BoundingBoxCanvas'
import promptAction from '@ohos.promptAction'
import picker from '@ohos.file.picker'
@Entry
@Component
struct DetectionPage {
@State modelLoaded: boolean = false
@State selectedImageUri: string = ''
@State detections: DetectionResult[] = []
@State resultText: string = ''
@State isProcessing: boolean = false
@State showBoxes: boolean = true
private detectionManager: ObjectDetectionManager = new ObjectDetectionManager()
build() {
Scroll() {
Column({ space: 15 }) {
// 标题
this.buildHeader()
// 模型状态
this.buildModelStatus()
// 操作按钮
this.buildActions()
// 图像预览 + 边界框
if (this.selectedImageUri) {
this.buildImageWithBoxes()
}
// 检测结果文本
this.buildResultText()
}
.width('100%')
.padding(20)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
@Builder
buildHeader(): void {
Column() {
Text('🎯 目标检测')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.margin({ top: 20, bottom: 10 })
Text('多对象识别与定位')
.fontSize(14)
.fontColor('#999999')
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
@Builder
buildModelStatus(): void {
Row() {
Column() {
Text('模型状态')
.fontSize(14)
.fontColor('#666666')
Text(this.modelLoaded ? '✅ YOLOv5s 已就绪' : '⏸ 未加载')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(this.modelLoaded ? '#00AA00' : '#FF6B6B')
.margin({ top: 5 })
}
.alignItems(HorizontalAlign.Start)
Blank()
Column() {
Text(`检测数: ${this.detections.length}`)
.fontSize(14)
.fontColor('#666666')
}
}
.width('100%')
.padding(15)
.backgroundColor(Color.White)
.borderRadius(12)
}
@Builder
buildActions(): void {
Column({ space: 10 }) {
Button(this.isProcessing && !this.modelLoaded ? '加载中...' : '加载检测模型')
.width('100%')
.height(50)
.enabled(!this.modelLoaded && !this.isProcessing)
.onClick((): void => {
this.loadModel()
})
Button('📷 选择图片')
.width('100%')
.height(50)
.enabled(this.modelLoaded && !this.isProcessing)
.backgroundColor('#4CAF50')
.onClick((): void => {
this.pickImage()
})
Button(this.isProcessing && this.modelLoaded ? '检测中...' : '🔍 开始检测')
.width('100%')
.height(50)
.enabled(this.modelLoaded && !this.isProcessing && this.selectedImageUri.length > 0)
.backgroundColor('#FF9800')
.onClick((): void => {
this.runDetection()
})
// 切换边界框显示
Row() {
Text('显示边界框')
.fontSize(16)
Blank()
Toggle({ type: ToggleType.Switch, isOn: this.showBoxes })
.onChange((isOn: boolean): void => {
this.showBoxes = isOn
})
}
.width('100%')
.padding(15)
.backgroundColor(Color.White)
.borderRadius(8)
}
}
@Builder
buildImageWithBoxes(): void {
Column() {
Text('🖼 检测结果可视化')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.alignSelf(ItemAlign.Start)
.margin({ bottom: 10 })
Stack() {
// 原始图像
Image(this.selectedImageUri)
.width('100%')
.height(300)
.objectFit(ImageFit.Contain)
.borderRadius(8)
// 边界框叠加层
if (this.showBoxes && this.detections.length > 0) {
BoundingBoxCanvas({
detections: this.detections,
imageWidth: 300,
imageHeight: 300
})
}
}
.width('100%')
.height(300)
.backgroundColor(Color.White)
.borderRadius(8)
}
.width('100%')
.padding(15)
.backgroundColor(Color.White)
.borderRadius(12)
}
@Builder
buildResultText(): void {
Column() {
Text('📊 检测详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.alignSelf(ItemAlign.Start)
.margin({ bottom: 10 })
Text(this.resultText || '请选择图片并开始检测')
.width('100%')
.padding(15)
.backgroundColor('#FAFAFA')
.borderRadius(8)
.fontSize(14)
.lineHeight(22)
}
.width('100%')
.padding(15)
.backgroundColor(Color.White)
.borderRadius(12)
}
private async loadModel(): Promise<void> {
this.isProcessing = true
try {
await this.detectionManager.loadModel(getContext(this))
this.modelLoaded = true
promptAction.showToast({ message: '✅ 模型加载成功' })
} catch (error) {
const err = error as Error
promptAction.showToast({ message: `❌ ${err.message}` })
}
this.isProcessing = false
}
private async pickImage(): Promise<void> {
try {
const photoPicker: picker.PhotoViewPicker = new picker.PhotoViewPicker()
const result: picker.PhotoSelectResult = await photoPicker.select({
MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1
})
if (result.photoUris.length > 0) {
this.selectedImageUri = result.photoUris[0]
this.detections = []
this.resultText = ''
promptAction.showToast({ message: '图片选择成功' })
}
} catch (error) {
const err = error as Error
promptAction.showToast({ message: `选择失败: ${err.message}` })
}
}
private async runDetection(): Promise<void> {
this.isProcessing = true
this.resultText = '⏳ 正在检测...'
try {
const config: DetectionConfig = {
confidenceThreshold: 0.5,
iouThreshold: 0.45,
maxDetections: 10
}
this.detections = await this.detectionManager.detect(this.selectedImageUri, config)
this.resultText = this.detectionManager.formatDetections(this.detections)
promptAction.showToast({ message: `✅ 检测到 ${this.detections.length} 个对象` })
} catch (error) {
const err = error as Error
this.resultText = `❌ 检测失败: ${err.message}`
promptAction.showToast({ message: '检测失败' })
}
this.isProcessing = false
}
}
关键技术点
1. NMS (非极大值抑制)
目标检测模型通常会产生大量重叠的检测框,NMS 用于过滤冗余框:
typescript
/**
* 计算 IoU (Intersection over Union)
*/
function calculateIoU(box1: BoundingBox, box2: BoundingBox): number {
const x1 = Math.max(box1.x, box2.x)
const y1 = Math.max(box1.y, box2.y)
const x2 = Math.min(box1.x + box1.width, box2.x + box2.width)
const y2 = Math.min(box1.y + box1.height, box2.y + box2.height)
const intersectionArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1)
const box1Area = box1.width * box1.height
const box2Area = box2.width * box2.height
const unionArea = box1Area + box2Area - intersectionArea
return intersectionArea / unionArea
}
/**
* NMS 算法
*/
function applyNMS(
detections: DetectionResult[],
iouThreshold: number
): DetectionResult[] {
// 按置信度降序排序
const sorted = detections.sort((a, b) => b.confidence - a.confidence)
const keep: DetectionResult[] = []
while (sorted.length > 0) {
const current = sorted.shift()!
keep.push(current)
// 移除与当前框 IoU 超过阈值的框
for (let i = sorted.length - 1; i >= 0; i--) {
if (calculateIoU(current.box, sorted[i].box) > iouThreshold) {
sorted.splice(i, 1)
}
}
}
return keep
}
2. 坐标归一化
模型输出通常是归一化坐标 (0-1),需要转换为像素坐标:
typescript
function denormalizeBox(
box: BoundingBox,
imageWidth: number,
imageHeight: number
): BoundingBox {
return {
x: box.x * imageWidth,
y: box.y * imageHeight,
width: box.width * imageWidth,
height: box.height * imageHeight
}
}
3. Canvas 绘制优化
typescript
// 使用 Stack 组件实现图层叠加
Stack() {
Image(imageUri) // 底层:原图
Canvas() // 顶层:边界框
}
实际应用场景
1. 积木识别系统
typescript
interface BlockInfo {
type: string // 积木类型
color: string // 颜色
position: { x: number, y: number }
orientation: number // 方向角度
}
class BlockDetector {
async detectBlocks(imageUri: string): Promise<BlockInfo[]> {
const detections = await this.detector.detect(imageUri)
return detections.map((det): BlockInfo => ({
type: this.getBlockType(det.classId),
color: this.getBlockColor(det),
position: this.getCenter(det.box),
orientation: 0
}))
}
}
2. 人脸检测
typescript
interface FaceDetection {
box: BoundingBox
landmarks: { x: number, y: number }[] // 关键点
age?: number
gender?: string
}
3. 车牌识别
typescript
interface PlateDetection {
box: BoundingBox
plateNumber: string
confidence: number
}
性能优化建议
1. 模型选择
| 模型 | 大小 | 速度 | 精度 | 适用场景 |
|---|---|---|---|---|
| YOLOv5n | 4MB | 快 | 中 | 实时应用 |
| YOLOv5s | 14MB | 较快 | 较高 | 通用场景 |
| YOLOv5m | 42MB | 中 | 高 | 高精度需求 |
2. 输入分辨率
typescript
// 降低输入分辨率提升速度
const configs = {
fast: { width: 320, height: 320 }, // 快速模式
balanced: { width: 640, height: 640 }, // 平衡模式
accurate: { width: 1280, height: 1280 } // 精确模式
}
3. 批处理
typescript
// 批量处理多张图片
async detectBatch(imageUris: string[]): Promise<DetectionResult[][]> {
return Promise.all(imageUris.map(uri => this.detect(uri)))
}
常见问题解决
Q1: 检测速度慢?
解决方案:
- 使用更小的模型(YOLOv5n)
- 降低输入分辨率
- 启用硬件加速
- 减少检测频率
Q2: 小物体检测不到?
解决方案:
- 提高输入分辨率
- 降低置信度阈值
- 使用专门的小目标检测模型
Q3: 边界框抖动?
解决方案:
- 使用卡尔曼滤波平滑
- 增加时间一致性约束
- 提高 IoU 阈值
扩展功能
1. 视频流检测
typescript
class VideoDetector {
private frameSkip: number = 3 // 每3帧检测一次
async processVideoFrame(frameData: ArrayBuffer): Promise<DetectionResult[]> {
// 处理视频帧
return []
}
}
2. 实时追踪
typescript
interface TrackedObject {
id: number
detections: DetectionResult[]
trajectory: { x: number, y: number }[]
}
3. 3D 边界框
typescript
interface BoundingBox3D extends BoundingBox {
depth: number
rotation: { x: number, y: number, z: number }
}
总结
本文详细介绍了在 HarmonyOS 上实现目标检测功能:
- ✅ 目标检测原理与数据结构
- ✅ 检测模型管理器实现
- ✅ 边界框可视化组件
- ✅ 完整 UI 交互实现
- ✅ NMS 算法详解
- ✅ 实际应用场景扩展
- ✅ 性能优化建议
通过本系列文章的学习,你已经掌握了:
- HarmonyOS AI 应用的完整开发流程
- 从图像分类到目标检测的技术演进
- 架构设计和最佳实践
- 实际项目中的优化技巧
系列文章回顾
- HarmonyOS6.1 端侧 AI 模型加载与推理入门
- HarmonyOS6.1 图像分类应用完整实战
- HarmonyOS6.1 AI 模型管理架构设计与最佳实践
- HarmonyOS6.1 从图像分类到目标检测的扩展实现(本文)
参考资料
感谢阅读完整系列!点赞👍 收藏⭐ 关注👀 支持原创!
有问题欢迎在评论区讨论交流!