4、图像采集基础:使用v4l2采集单帧图像、OpenCV读取摄像头、帧率控制

好,咱们正式开始搞图像采集。

这一章是实战的敲门砖。你想想看,后面所有的算法、模型、显示,都得先拿到图像数据。如果这一步没搞对,后面全是白搭。我个人习惯把图像采集分成两个层面:一个是底层驱动层面,用v4l2直接跟硬件打交道;另一个是应用层面,用OpenCV快速上手。今天咱们两个都讲透。

4.1 先聊聊v4l2——摄像头驱动的灵魂

v4l2,全称Video for Linux 2。说白了,它就是Linux系统下摄像头设备的统一接口标准。不管你用的是USB摄像头,还是CSI接口的摄像头,只要驱动写好了,v4l2都能管。

我在项目中遇到过一个问题:同一款摄像头,在x86的Ubuntu上跑得好好的,换到Jetson上就死活打不开。查了半天,发现是v4l2的设备节点路径不一样。x86上通常是/dev/video0,但Jetson上可能是/dev/video1甚至/dev/video2。嗯,这里要注意,先ls /dev/video*看一眼。

4.2 用v4l2采集单帧图像

为什么要学v4l2?因为有时候OpenCV不够灵活。比如你想设置某个特定的曝光时间,或者想用YUV格式直接处理,OpenCV的封装就不够用了。这时候就得自己写v4l2代码。

下面这个例子,是我常用的一个单帧采集函数。它做的事情很简单:打开设备、设置格式、申请缓冲区、开始采集、取一帧、保存。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/videodev2.h>

int capture_one_frame(const char *dev_path, const char *output_file) {
    int fd = open(dev_path, O_RDWR);
    if (fd < 0) {
        perror("open device failed");
        return -1;
    }

    // 设置格式:640x480,YUV420
    struct v4l2_format fmt = {0};
    fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    fmt.fmt.pix.width = 640;
    fmt.fmt.pix.height = 480;
    fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
    fmt.fmt.pix.field = V4L2_FIELD_INTERLACED;
    if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) {
        perror("set format failed");
        close(fd);
        return -1;
    }

    // 申请缓冲区
    struct v4l2_requestbuffers req = {0};
    req.count = 1;
    req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    req.memory = V4L2_MEMORY_MMAP;
    if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
        perror("request buffers failed");
        close(fd);
        return -1;
    }

    // 映射缓冲区
    struct v4l2_buffer buf = {0};
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    buf.index = 0;
    if (ioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) {
        perror("query buffer failed");
        close(fd);
        return -1;
    }

    unsigned char *buffer = (unsigned char *)mmap(
        NULL, buf.length, PROT_READ | PROT_WRITE,
        MAP_SHARED, fd, buf.m.offset
    );

    // 入队并开始采集
    if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) {
        perror("queue buffer failed");
        close(fd);
        return -1;
    }

    int type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) {
        perror("stream on failed");
        close(fd);
        return -1;
    }

    // 等待一帧
    fd_set fds;
    FD_ZERO(&fds);
    FD_SET(fd, &fds);
    struct timeval tv = {2, 0}; // 2秒超时
    int r = select(fd + 1, &fds, NULL, NULL, &tv);
    if (r <= 0) {
        perror("select timeout or error");
        close(fd);
        return -1;
    }

    // 出队获取数据
    if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) {
        perror("dequeue buffer failed");
        close(fd);
        return -1;
    }

    // 保存为原始YUV文件
    FILE *fp = fopen(output_file, "wb");
    if (fp) {
        fwrite(buffer, 1, buf.bytesused, fp);
        fclose(fp);
        printf("Saved %d bytes to %s\n", buf.bytesused, output_file);
    }

    // 清理
    munmap(buffer, buf.length);
    close(fd);
    return 0;
}
我的小技巧: 调试v4l2时,先用v4l2-ctl --list-formats看看摄像头支持哪些格式。我曾经在Jetson Nano上踩过坑,摄像头明明支持MJPEG,但用YUYV格式去打开,结果帧率只有5fps。后来改成MJPEG格式,直接飙到30fps。

4.3 OpenCV读取摄像头——更省心的方式

说实话,大部分时候我们不需要自己写v4l2代码。OpenCV的VideoCapture封装得很好,几行代码就能搞定。

import cv2

# 打开摄像头,0表示第一个摄像头设备
cap = cv2.VideoCapture(0)

if not cap.isOpened():
    print("摄像头打开失败")
    exit()

# 设置分辨率
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)

# 读取一帧
ret, frame = cap.read()
if ret:
    cv2.imwrite("frame.jpg", frame)
    print("图像已保存")

cap.release()

你看,是不是简单很多?但这里有个坑——OpenCV默认用的是v4l2后端,但在Jetson上,有时候它会自动选择GStreamer后端。为什么会这样?因为Jetson的硬件加速编码器需要GStreamer来驱动。

注意: 在Jetson上,如果你发现cap.read()返回的图像是空的或者花屏,试试强制指定后端:cap = cv2.VideoCapture(0, cv2.CAP_V4L2)。我曾经在Jetson Xavier上调试了整整一天,最后发现是后端选择的问题。

4.4 帧率控制——别让摄像头跑飞了

帧率控制是个容易被忽视的问题。你想想看,如果你的算法处理一帧需要50ms,但摄像头以30fps(约33ms一帧)的速度往缓冲区塞数据,缓冲区很快就会满,然后丢帧。

我常用的帧率控制方法有三种:

方法 原理 适用场景
硬件帧率设置 通过v4l2或OpenCV直接设置摄像头帧率 摄像头支持固定帧率输出
软件帧率限制 在读取循环中加入延时 算法处理时间不稳定
时间戳同步 根据上一帧的处理时间动态调整 需要精确控制处理节奏

先看硬件帧率设置。用OpenCV的话,很简单:

cap.set(cv2.CAP_PROP_FPS, 15)  # 设置为15fps

但说实话,这个方法不是所有摄像头都支持。我遇到过一些USB摄像头,你设了15fps,它还是按30fps跑。这时候就得用软件方法了。

软件帧率限制,说白了就是加个延时:

import cv2
import time

cap = cv2.VideoCapture(0)
target_fps = 15
frame_time = 1.0 / target_fps

while True:
    start_time = time.time()
    
    ret, frame = cap.read()
    if not ret:
        break
    
    # 处理图像...
    cv2.imshow("frame", frame)
    
    # 计算需要等待的时间
    elapsed = time.time() - start_time
    wait_time = frame_time - elapsed
    if wait_time > 0:
        time.sleep(wait_time)
    
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

cap.release()
核心要点: 帧率控制不是越精确越好。我个人的经验是,在嵌入式设备上,用软件延时控制帧率时,time.sleep()的精度大约在10ms左右。如果你需要更精确的控制,可以考虑用nanosleep()或者硬件定时器。

4.5 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 设备节点冲突: Jetson上可能有多个/dev/video*节点,有的是摄像头,有的是ISP(图像信号处理器)。我曾经误用了ISP节点,结果读出来的图像全是黑的。用v4l2-ctl --list-devices可以查看每个节点的具体信息。
  • 缓冲区大小: 用v4l2采集高分辨率图像时,默认的缓冲区可能不够。我遇到过1920x1080的YUV图像,一帧大约3MB,但缓冲区只分配了1MB,结果图像被截断了。记得根据分辨率计算缓冲区大小。
  • OpenCV的imshow延迟: 在Jetson上,cv2.imshow()的显示延迟可能高达100ms。如果你在做实时处理,建议用cv2.imwrite()保存到文件,或者用GStreamer直接推流显示。

嗯,这一章的内容就到这里。图像采集看起来简单,但里面的细节不少。下一章我们会讲图像预处理,到时候会用到今天采集的图像数据。记得把代码跑一遍,有问题随时翻回来看看。