4. 单线程检测实现

好,咱们正式开始写代码了。

这一章的目标很明确:用单线程把整个检测流程跑通。说白了,就是让摄像头一帧一帧地读,YOLO 模型一帧一帧地检测,然后把结果画在画面上,最后把 FPS 也怼上去。

你可能会想:“单线程?那多慢啊。” 别急,咱们先把这个基础版本做扎实了,后面再上多线程优化。我个人的习惯是,先把功能跑通,再谈性能。否则你一开始就搞多线程,出了问题都不知道是模型的问题还是线程同步的问题。

4.1 整体流程概览

先看看单线程检测的核心流程,我画了一张图,你一看就明白:

单线程检测流程 打开摄像头 循环读取帧 模型检测 绘制边界框 显示FPS 显示画面 虚线箭头表示循环:读取 → 检测 → 绘制 → 显示 → 再读取 直到用户按下 'q' 键退出

这个流程看起来简单,但每个环节都有坑。我一个个拆开讲。

4.2 打开摄像头与循环读取帧

第一步,打开摄像头。OpenCV 里用 cv2.VideoCapture(0) 就行,参数 0 表示默认摄像头。

注意: 摄像头索引不一定总是 0。如果你插了多个摄像头,或者笔记本自带摄像头被占用,可能需要试 1、2 甚至 -1。我曾经在调试时忘了拔外接摄像头,结果程序一直打开的是外接摄像头,画面黑屏了半天才发现。

读取帧用 cap.read(),它会返回两个值:ret(布尔值,表示是否成功)和 frame(图像数据)。

import cv2

cap = cv2.VideoCapture(0)
if not cap.isOpened():
    print("无法打开摄像头")
    exit()

while True:
    ret, frame = cap.read()
    if not ret:
        print("读取帧失败")
        break
    # 后续处理...

这里有个小细节:cap.read() 默认返回的帧是 BGR 格式,而 YOLO 模型通常需要 RGB 格式。所以后面做检测前要记得转换颜色空间。

4.3 调用模型进行检测

我用的是 YOLOv8,加载模型就一行代码:

from ultralytics import YOLO

model = YOLO('yolov8n.pt')  # 加载预训练模型

然后对每一帧调用检测:

results = model(frame)  # 直接传 BGR 帧也行,模型内部会处理

results 是一个列表,每个元素对应一张图的检测结果。因为我们一次只传一帧,所以取 results[0] 就行。

检测结果里包含:

  • boxes:边界框坐标(xyxy 格式)
  • conf:置信度
  • cls:类别 ID
小技巧: 如果你只需要检测结果,不需要可视化,可以用 model(frame, verbose=False) 关掉控制台输出,不然每帧都会打印一堆信息,刷屏刷得你怀疑人生。

4.4 绘制边界框

绘制边界框我习惯自己写,不用 YOLO 自带的 plot() 方法。为什么?因为自带的 plot 会画一堆额外信息,有时候画面太乱了。我自己画,想怎么控制就怎么控制。

def draw_boxes(frame, results):
    boxes = results[0].boxes
    for box in boxes:
        x1, y1, x2, y2 = map(int, box.xyxy[0])
        conf = box.conf[0].item()
        cls_id = int(box.cls[0].item())
        label = f"{model.names[cls_id]} {conf:.2f}"

        # 画矩形框
        cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
        # 画标签背景
        cv2.rectangle(frame, (x1, y1 - 25), (x1 + len(label) * 15, y1), (0, 255, 0), -1)
        # 写标签文字
        cv2.putText(frame, label, (x1, y1 - 5),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)
    return frame

这里我用了绿色框,你也可以换成其他颜色。注意标签背景的宽度我是粗略估算的,如果你要精确控制,可以用 cv2.getTextSize() 计算文字宽度。

避坑指南: 我曾经在画框时忘了把坐标转成 int 类型,结果 cv2.rectangle 报错,排查了半天才发现是 float 类型的问题。YOLO 返回的坐标是 tensor 类型,记得用 map(int, ...) 转换。

4.5 计算并显示 FPS

FPS 的计算其实很简单:记录每一帧的时间戳,然后算差值。

import time

prev_time = time.time()

while True:
    # ... 读取帧、检测、画框 ...

    current_time = time.time()
    fps = 1 / (current_time - prev_time)
    prev_time = current_time

    cv2.putText(frame, f"FPS: {fps:.1f}", (10, 30),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

这个 FPS 是瞬时值,波动会比较大。如果你想看平滑后的 FPS,可以用滑动平均:

fps_list = []
while True:
    # ...
    fps_list.append(fps)
    if len(fps_list) > 30:
        fps_list.pop(0)
    avg_fps = sum(fps_list) / len(fps_list)

我个人习惯在画面上同时显示瞬时 FPS 和平均 FPS,这样既能看出当前性能,又能了解整体趋势。

4.6 完整代码整合

把上面所有代码拼起来,就是完整的单线程检测程序:

import cv2
import time
from ultralytics import YOLO

# 加载模型
model = YOLO('yolov8n.pt')

# 打开摄像头
cap = cv2.VideoCapture(0)
if not cap.isOpened():
    print("无法打开摄像头")
    exit()

prev_time = time.time()

while True:
    ret, frame = cap.read()
    if not ret:
        break

    # 检测
    results = model(frame, verbose=False)

    # 绘制边界框
    boxes = results[0].boxes
    for box in boxes:
        x1, y1, x2, y2 = map(int, box.xyxy[0])
        conf = box.conf[0].item()
        cls_id = int(box.cls[0].item())
        label = f"{model.names[cls_id]} {conf:.2f}"

        cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
        cv2.rectangle(frame, (x1, y1 - 25), (x1 + len(label) * 15, y1), (0, 255, 0), -1)
        cv2.putText(frame, label, (x1, y1 - 5),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)

    # 计算并显示 FPS
    current_time = time.time()
    fps = 1 / (current_time - prev_time)
    prev_time = current_time
    cv2.putText(frame, f"FPS: {fps:.1f}", (10, 30),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

    # 显示画面
    cv2.imshow("YOLO Detection", frame)

    # 按 'q' 退出
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

这段代码大概 40 行,跑起来你就能看到实时检测效果了。

4.7 性能分析:瓶颈在哪?

跑起来之后,你可能会发现 FPS 不高。我用一张表总结一下各环节的耗时占比:

环节 耗时占比 说明
读取帧 ~5% 摄像头读取很快,基本不耗时
模型检测 ~85% 这是最大的瓶颈,尤其是大模型
绘制边界框 ~5% 框多了会稍微慢一点
显示画面 ~5% imshow 本身有开销

看到了吧?模型检测占了绝大部分时间。这就是为什么后面我们要用多线程——把检测放到单独的线程里,让主线程专心读取和显示。

核心结论: 单线程版本是功能验证,不是性能方案。它能跑,但跑不快。下一章开始,我们就要对检测这个瓶颈动手了。

嗯,这一章的内容就到这。代码你可以直接复制跑跑看,感受一下单线程的“卡顿感”。有了这个基础,后面优化起来你才知道到底优化了什么。