linux驱动移植-USB摄像头采集图像实时显示(v4l2应用)
一、V4L2
1.1 介绍
V4L2是video for Linux 2的缩写,是一套Linux内核视频设备的驱动框架,该驱动框架为应用层提供一套统一的操作接口(一系列的ioctl)。
V4L2在设计时,是要支持很多广泛的设备的,它们之中只有一部分在本质上是真正的视频设备,可以支持多种设备,它可以有以下几种接口。
- video capture interface:视频捕获接口,这种接口应用于摄像头,V4L2在最初设计的时候就是应用于这种功能;
- video output interface:视频输出接口,将静止图像或图像序列编码为模拟视频信号,通过此接口,应用程序可以控制编码过程并将图像从用户空间移动到驱动程序;
- video overlay interface:视频直接传输接口,可以将采集到的视频数据直接传输到显示设备,不需要cpu参与,这种方式的显示图像的效率比其他方式高得多;
1.2 V4L2应用程序框架
在linux下,一切设备皆是是文件,可以像访问普通文件一样对其进行读写。一般来说,采用V4L2驱动的摄像头设备文件是/dev/video0。
V4L2支持两种方式来采集图像:内存映射方式(mmap)和直接读取方式(read)。
V4L2在include/linux/videodev.h文件中定义了一些重要的数据结构,在采集图像的过程中,就是通过对这些数据的操作来获取最终的图像数据,Linux系统V4L2的能力可在Linux内核编译阶段配置,默认情况下都有此开发接口。
而摄像头所用的主要是capature了,视频的捕获,具体linux的调用可以参考下图:
1.3 V4L2采集视频步骤
V4L2支持内存映射方式(mmap)和直接读取方式(read)来采集数据,前者一般用于连续视频数据的采集,后者常用于静态图片数据的采集,本文重点讨论内存映射方式的视频采集。
应用程序通过V4L2接口采集视频数据分为以下几个步骤:
- 打开视频设备文件;
- 查询设备驱动的功能;
- 枚举输入设备,并设置输入设备;
- 获取视频采集设备支持的视频格式;
- 设置视频设备的视频数据格式,通过V4L2接口设置视频图像的采集窗口、采集的点阵大小和格式等;
- 申请若干视频采集的帧缓冲区,并将这些帧缓冲区从内核空间映射到用户空间,便于应用程序读取/处理视频数据;
- 将申请到的帧缓冲区在视频采集输入队列排队,并启动视频采集;
- 驱动开始视频数据的采集,应用程序从视频采集输出队列取出帧缓冲区;
- 这里我们拿到帧缓冲区的图像数据,我们就可以进行一些想要的操作,比如保存图片,实时显示等;
- 将帧缓冲区重新放入视频采集输入队列,循环往复采集连续的视频数据;
- 停止视频采集;
二、ioctl API介绍
其中V4L2大多数操作都是通过应用层调用ioctl实现的,可以将这些ioctl分为若干类。
2.1 查询设备的功能
由于V4L2涵盖了各种各样的设备,因此并非API的所有方面都适用于所有类型的设备,在使用V4L2设备时,必须调用此API,获得设备支持的功能(capture、output、overlay…)
ID | 描述 |
---|---|
VIDIOC_QUERYCAP | 查询设备功能 |
参数类型为V4L2的能力描述类型struct v4l2_capability:
struct v4l2_capability { __u8 driver[16]; /* i.e. "bttv" */ //驱动名称, __u8 card[32]; /* i.e. "Hauppauge WinTV" */ // __u8 bus_info[32]; /* "PCI:" + pci_name(pci_dev) */ //PCI总线信息 __u32 version; /* should use KERNEL_VERSION() */ __u32 capabilities; /* Device capabilities */ //设备能力 __u32 reserved[4]; };
返回值说明: 执行成功时,函数返回值为 0;
函数执行成功后,struct v4l2_capability 结构体变量中的返回当前视频设备所支持的功能;例如支持视频捕获功能V4L2_CAP_VIDEO_CAPTURE、 V4L2_CAP_STREAMING等。
使用举例:
/* 获取驱动信息:获取设备具有的能力 */ int v4l2_querycap(int fd, struct v4l2_capability* cap) { if (ioctl(fd, VIDIOC_QUERYCAP, cap) < 0) { printf("ERR(%s):VIDIOC_QUERYCAP failed\n", __func__); return -1; } return 0; }
执行完VIDIOC_QUERYCAP命令后,cap变量中包含了该视频设备的能力信息:其中最重要的是capabilities字段,这个字段标记着V4L2设备的功能,capabilities有以下部分标记位:
ID | 描述符 |
---|---|
V4L2_CAP_VIDEO_CAPTURE | 设备支持捕获功能 |
V4L2_CAP_VIDEO_OUTPUT | 设备支持输出功能 |
V4L2_CAP_VIDEO_OVERLAY | 设备支持预览功能 |
V4L2_CAP_STREAMING | 设备支持流读写 |
V4L2_CAP_READWRITE | 设备支持read、write方式读写 |
程序中通过检查cap中的设备能力信息来判断设备是否支持某项功能。
// 查询设备驱动的功能 ret = v4l2_querycap(fd, &cap); if(ret < 0) goto err; printf("Driver Name:%s\n Card Name:%s\n Bus info:%s\n\n",cap.driver,cap.card,cap.bus_info); if(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) printf("dev support capture\n"); if(cap.capabilities & V4L2_CAP_VIDEO_OUTPUT) printf("dev support output\n"); if(cap.capabilities & V4L2_CAP_VIDEO_OVERLAY) printf("dev support overlay\n"); if(cap.capabilities & V4L2_CAP_STREAMING) printf("dev support streaming\n"); if(cap.capabilities & V4L2_CAP_READWRITE) printf("dev support read write\n");
这里我们需要检查一下是否是为视频采集设备V4L2_CAP_VIDEO_CAPTURE以及是否支持流IO操作V4L2_CAP_STREAMING。
2.2 应用优先级
当多个应用程序共享设备时,可能需要为它们分配不同的优先级。视频录制应用程序可以例如阻止其他应用程序改变视频控制或切换当前的电视频道。
另一个目标是允许在后台工作的低优先级应用程序,这些应用程序可以被用户控制的应用程序抢占,并在以后自动重新获得对设备的控制
ID | 描述 |
---|---|
VIDIOC_G_PRIORITY | 获取优先级 |
VIDIOC_S_PRIORITY | 设置优先级 |
2.3 输入和输出设备
ID | 描述 |
---|---|
VIDIOC_ENUMINPUT | 枚举视频输入设备 |
VIDIOC_G_INPUT | 获取当前的视频输入设备 |
VIDIOC_S_INPUT | 设置视频输入设备 |
VIDIOC_ENUMOUTPUT | 枚举视频输出设备 |
VIDIOC_G_OUTPUT | 获取当前视频输出设备 |
VIDIOC_S_OUTPUT | 设置视频输出设备 |
VIDIOC_ENUMAUDIO | 枚举音频输入设备 |
VIDIOC_G_AUDIO | 获取当前音频输入设备 |
VIDIOC_S_AUDIO | 设置音频输入设备 |
VIDIOC_ENUMAUDOUT | 枚举音频输出设备 |
VIDIOC_G_OUTPUT | 获取音频输出设备 |
VIDIOC_S_AUDOUT | 设置音频输出设备 |
2.3.1 VIDIOC_ENUMINPUT
一个设备可能有多个输入,比如:在芯片上,摄像头控制器和摄像头接口是分离的,需要选择哪一个摄像头接口作为摄像头控制器的输入源。
当然,并不是所有的设备都需要设置输入,比如:uvc摄像头,一般只有一个输入,默认就会选择,不需要设置。
视频捕获的应用首先要通过VIDIOC_ENUMINPUT命令来枚举所有可用的输入。在V4L2层,这个调用会转换成调用一个驱动中对应的回调函数:
int (*vidioc_enum_input)(struct file *file, void *private_data, struct v4l2_input *input);
在这个调用中,file 对就的是打开的视频设备。private_data是驱动的私有字段,input字段是真正的传递的信息:
struct v4l2_input { __u32 index; /* Which input */ __u8 name[32]; /* Label */ __u32 type; /* Type of input */ __u32 audioset; /* Associated audios (bitfield) */ __u32 tuner; /* Associated tuner */ v4l2_std_id std; __u32 status; __u32 reserved[4]; };
它有如下几个值得关注的字段:
- index:应用关注的输入的索引号;这是唯一一个用户空间设定的字段。驱动要分配索引号给输入,从0开始,依次往上增加。应用想要知道所有可用的输入时,要调用VIDIOC_ENUMINPUT 控制,调用索引号从0开始,并开始递增。 一旦返回EINVAL,应用就知道,输入己经遍历结束了,只要有输入,输入索引号0就一定要存在的;
- name::输入的名字,由驱动设定。简单起见,可以设为”Camera”,诸如此类;如果卡上有多个输入,名称就要与接口的打印相符合;
- type:输入的类型,现在只有两个值可选:V4L2_INPUT_TYPE_TUNER和V4L2_INPUT_TYPE_CAMERA;
- audioset:描述哪个音频输入可以与些视频输入相关联音频输入与视频输入一样通过索引号枚举 ,但并非所以的音频与视频的组合都是可用的,这个字段是一个掩码,代表对于当前枚举出的视频而言,哪些音频输入是可以与 之关联的.如果没有音频输入可以与之关联,或是只有一个可选,那么就可以简单地把这个字段置0;
- tuner: 如果输入是一个调谐器 (type字段置为V4L2_INPUT_TYPE_TUNER),,这个字段就是会包含一个相应的调谐设备的索引号;
- std: 描述设备支持哪个或哪些视频标准.;
- status::给出输入的状态.,简而言之,status中设置的每一位都代表一个问题。这些问题包括没有电源,没有信号,没有同频锁等;
- reserved:保留字段,驱动应该将其置0。
通常驱动会设置上面所以的字段,并返回0。如果索引值超出支持的输入范围,应该返回-EINVAL,这个调用里可能出现的错误不多。
应用程序使用举例:
int v4l2_enuminput(int fd, int index, char* name) { struct v4l2_input input; int found = 0; input.index = 0; while(!ioctl(fd, VIDIOC_ENUMINPUT, &input)) { printf("input:%s : std: 0x%08x\n", input.name, input.std); if(input.index == index) { found = 1; strcpy(name, input.name); } ++input.index; } if(!found) { printf("%s:can't find input dev\n", __func__); return -1; } return 0; }
执行完该函数,就可以获取输入的名字:
ret = v4l2_enuminput(fd, 0, name); if(ret < 0) goto err; printf("input device name:%s\n", name);
2.3.2 VIDIOC_S_INPUT
当应用想改变当前的输入时,驱动会收到一个对回调函数vidioc_s_input()的调用。
int (*vidioc_s_input) (struct file *file, void *private_data, unsigned int index);
其中index用来确定那个输入是应用想要的,驱动要对硬件进行设置,选择那个输入并返回0。也有可能要返回-EINVAL(索引号不正确时) 或-EIO(硬件有问题).,即使只有一路输入,驱动也要实现这个回调函数。
应用程序使用举例:
int v4l2_s_input(int fd, int index) { struct v4l2_input input; // 指定输入编号 input.index = index; if (ioctl(fd, VIDIOC_S_INPUT, &input) < 0) { printf("ERR(%s):VIDIOC_S_INPUT failed\n", __func__); return -1; } return 0; }
确定了输入编号后,执行如下代码,设定输入为0:
ret = v4l2_s_input(fd, 0); if(ret < 0) goto err;
2.4 视频标准
ID | 描述 |
---|---|
VIDIOC_ENUMSTD | 枚举设备支持的所有标准 |
VIDIOC_G_STD | 获取当前正在使用的标准 |
VIDIOC_S_STD | 设置视频标准 |
VIDIOC_QUERYSTD | 有的设备支持自动侦测输入源的视频标准,此ioctl获取检测到的标准 |
2.5 控制属性
ID | 描述 |
---|---|
VIDIOC_QUERYCTRL | 查询指定的control详细信息 |
VIDIOC_QUERYMENU | 查询menu |
VIDIOC_G_CTRL | 获取设备指定control的当前信息 |
VIDIOC_S_CTRL | 设置设备指定的control |
VIDIOC_STREAMON
|
开始视频采集 |
VIDIOC_STREAMOFF
|
结束视频采集 |
2.5.1 VIDIOC_STREAMON
VIDIOC_STREAMON用于启动视频采集命令,应用程序调用VIDIOC_STREAMON启动视频采集命令后,视频设备驱动程序开始采集视频数据,并把采集到的视频数据保存到视频驱动的视频缓冲区中。
参数类型为V4L2的视频缓冲区类型 enum v4l2_buf_type ;
enum v4l2_buf_type { V4L2_BUF_TYPE_VIDEO_CAPTURE = 1, V4L2_BUF_TYPE_VIDEO_OUTPUT = 2, V4L2_BUF_TYPE_VIDEO_OVERLAY = 3, V4L2_BUF_TYPE_VBI_CAPTURE = 4, V4L2_BUF_TYPE_VBI_OUTPUT = 5, V4L2_BUF_TYPE_SLICED_VBI_CAPTURE = 6, V4L2_BUF_TYPE_SLICED_VBI_OUTPUT = 7, #if 1 /* Experimental */ V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY = 8, #endif V4L2_BUF_TYPE_PRIVATE = 0x80, };
返回值说明: 执行成功时,函数返回值为 0;函数执行成功后,视频设备驱动程序开始采集视频数据,此时应用程序一般通过调用select函数来判断一帧视频数据是否采集完成。
当视频设备驱动完成一帧视频数据采集并保存到视频缓冲区中时,select函数返回,应用程序接着可以读取视频数据;否则select函数阻塞直到视频数据采集完成。
应用程序使用举例:
/* 开始采集 */ int v4l2_streamon(int fd) { enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 启动视频采集命令,应用程序调用VIDIOC_STREAMON启动视频采集命令后,视频设备驱动程序开始采集视频数据, // 并把采集到的视频数据保存到视频驱动的视频缓冲区中 if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) { printf("ERR(%s):VIDIOC_STREAMON failed\n", __func__); return -1; } // 视频设备驱动程序开始采集视频数据,此时应用程序一般通过调用select函数来判断一帧视频数据是否采集完成 // 当视频设备驱动完成一帧视频数据采集并保存到视频缓冲区中时,select函数返回,应用程序接着可以读取视频数据 // 否则select函数阻塞直到视频数据采集完成 if(v4l2_poll(fd) < 0) return -1; return 0; } /* 等待一帧数据采集完成 */ int v4l2_poll(int fd) { int ret; struct pollfd poll_fds[1]; poll_fds[0].fd = fd; poll_fds[0].events = POLLIN; // 等待一帧数据采集完成 ret = poll(poll_fds, 1, 10000); if (ret < 0) { printf("ERR(%s):poll error\n", __func__); return -1; } if (ret == 0) { printf("ERR(%s):No data in 10 secs..\n", __func__); return -1; } return 0; }
当视频缓冲都如队列后,执行如下代码,开始视频图像采集:
ret = v4l2_streamon(fd); if(ret < 0) goto err;
2.5.2 VIDIOC_STREAMOFF
/* 停止视频采集 */ int v4l2_streamoff(int fd) { enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 停止视频采集命令,应用程序调用VIDIOC_ STREAMOFF停止视频采集命令后,视频设备驱动程序不再采集视频数据 if (ioctl(fd, VIDIOC_STREAMOFF, &type) < 0) { printf("ERR(%s):VIDIOC_STREAMOFF failed\n", __func__); return -1; } return 0; }
完成视频采集后,一般执行如下代码,停止视频采集工作:
ret = v4l2_streamoff(fd); if(ret < 0) goto err;
2.6 图像格式
图像由多种格式YUV和RGB还有压缩格式等等,其中每种格式又分有多种格式,比如RGB:RGB565、RGB888…
所以在使用设备时,需要对格式进行设置
ID | 描述 |
---|---|
VIDIOC_ENUM_FMT | 枚举设备支持的图像格式 |
VIDIOC_G_FMT | 获取当前设备的图像格式 |
VIDIOC_S_FMT | 设置图像格式 |
VIDIOC_TRY_FMT | 测试设备是否支持此格式 |
2.6.1 VIDIOC_ENUM_FMT
VIDIOC_ENUM_FMT用于 获取当前视频设备支持的视频格式 。参数类型为V4L2的视频格式描述符类型 struct v4l2_fmtdesc:
struct v4l2_fmtdesc { __u32 index; /* Format number */ enum v4l2_buf_type type; /* buffer type */ __u32 flags; __u8 description[32]; /* Description string */ __u32 pixelformat; /* Format fourcc */ __u32 reserved[4]; };
返回值说明: 执行成功时,函数返回值为0;
struct v4l2_fmtdesc 结构体中的.pixelformat和 description 成员返回当前视频设备所支持的视频格式;这里很关键,因为不同的摄像头可能支持的格式不一样。V4L2可以支持的格式很多,/usr/include/linux/videodev2.h文件中可以看到,比如:
V4L2_PIX_FMT_RGB565
V4L2_PIX_FMT_RGB32
V4L2_PIX_FMT_YUYV
V4L2_PIX_FMT_UYVY
V4L2_PIX_FMT_VYUY
V4L2_PIX_FMT_YVYU
V4L2_PIX_FMT_YUV422P
V4L2_PIX_FMT_NV12
V4L2_PIX_FMT_NV12T
V4L2_PIX_FMT_NV21
V4L2_PIX_FMT_NV16
V4L2_PIX_FMT_NV61
V4L2_PIX_FMT_YUV420
V4L2_PIX_FMT_JPEG
应用程序使用举例:
int v4l2_enum_fmt(int fd,unsigned int fmt,enum v4l2_buf_type type) { struct v4l2_fmtdesc fmt_desc; int found = 0; memset(&fmt_desc, 0, sizeof(fmt_desc)); fmt_desc.type = type; fmt_desc.index = 0; while (!ioctl(fd, VIDIOC_ENUM_FMT, &fmt_desc)) { printf("pixelformat = %c%c%c%c, description = %s\n", fmt_desc.pixelformat & 0xFF, (fmt_desc.pixelformat >> 8) & 0xFF, (fmt_desc.pixelformat >> 16) & 0xFF, (fmt_desc.pixelformat >> 24) & 0xFF, fmt_desc.description); if (fmt_desc.pixelformat == fmt) { found = 1; } fmt_desc.index++; } if (!found) { printf("%s:unsupported pixel format\n", __func__); return -1; } return 0; }
我采用的usb摄像头只支持V4L2_PIX_FMT_YUYV,一般的USB摄像头都会支持YUYV,有些还支持其他的格式。这里我们判断一下我们的摄像头是否支持V4L2_PIX_FMT_YUYV:
ret = v4l2_enum_fmt(fd,V4L2_PIX_FMT_YUYV, V4L2_BUF_TYPE_VIDEO_CAPTURE); if(ret < 0) goto err;
2.6.2 VIDIOC_S_FMT
既然我使用的摄像头只支持V4L2_PIX_FMT_YUYV,因此我们需要通过命令VIDIOC_S_FMT设置图像格式,参数类型为V4L2的视频数据格式类型 struct v4l2_format:
struct v4l2_format { enum v4l2_buf_type type; //数据流类型,必须永远是V4L2_BUF_TYPE_VIDEO_CAPTURE union { struct v4l2_pix_format pix; /* V4L2_BUF_TYPE_VIDEO_CAPTURE */ struct v4l2_window win; /* V4L2_BUF_TYPE_VIDEO_OVERLAY */ struct v4l2_vbi_format vbi; /* V4L2_BUF_TYPE_VBI_CAPTURE */ struct v4l2_sliced_vbi_format sliced; /* V4L2_BUF_TYPE_SLICED_VBI_CAPTURE */ __u8 raw_data[200]; /* user-defined */ } fmt; };
v4l2_format中的fmt是一个union,其中哪个成员有效取决于type的取值,一般较常用的是取类型type为 V4L2_BUF_TYPE_VIDEO_CAPTURE,此时pix生效。该成员的详细内部细节如下:
struct v4l2_pix_format { __u32 width; // 宽,必须是16的倍数 __u32 height; // 高,必须是16的倍数 __u32 pixelformat; // 视频数据存储类型,例如是YUV4:2:2还是RGB enum v4l2_field field; __u32 bytesperline; __u32 sizeimage; enum v4l2_colorspace colorspace; __u32 priv; };
这里我们需要设置usb摄像头的相关参数,比如宽度我这里的是320,高度是240,pixelformat为V4L2_PIX_FMT_YUYV。这几个参数尽量要设置正确,不然可能无法采集图像。
返回值说明: 执行成功时,函数返回值为 0;
注意:如果该视频设备驱动不支持你所设定的图像格式,视频驱动会重新修改struct v4l2_format结构体变量的值为该视频设备所支持的图像格式,所以在程序设计中,设定完所有的视频格式后,要获取实际的视频格式,要重新读取 struct v4l2_format结构体变量。
应用程序使用举例:
int v4l2_s_fmt(int fd, int* width, int* height, unsigned int fmt, enum v4l2_buf_type type) { struct v4l2_format v4l2_fmt; struct v4l2_pix_format pixfmt; memset(&v4l2_fmt, 0, sizeof(struct v4l2_format)); v4l2_fmt.type = type; memset(&pixfmt, 0, sizeof(pixfmt)); // 图像宽,必须是16的倍数 pixfmt.width = *width; // 图像高,必须是16的倍数 pixfmt.height = *height; // 视频数据存储类型,例如是YUV4:2:2还是RGB pixfmt.pixelformat = fmt; // 获取图像所占字节 pixfmt.sizeimage = (*width * *height * get_pixel_depth(fmt)) / 8; pixfmt.field = V4L2_FIELD_ANY; v4l2_fmt.fmt.pix = pixfmt; // 如果该视频设备驱动不支持你所设定的图像格式,视频驱动会重新修改struct v4l2_format结构体变量的值为该视频设备所支持的图像格式 if (ioctl(fd, VIDIOC_S_FMT, &v4l2_fmt) < 0) { printf("ERR(%s):VIDIOC_S_FMT failed\n", __func__); return -1; } *width = v4l2_fmt.fmt.pix.width; *height = v4l2_fmt.fmt.pix.height; return 0; }
这里我们设置我们的图像格式,设置成功后,输出图像的宽度,高度:
ret = v4l2_s_fmt(fd, &width, &height, V4L2_PIX_FMT_YUYV, V4L2_BUF_TYPE_VIDEO_CAPTURE); if(ret < 0) goto err; printf("image width:%d\n", width); printf("image height:%d\n", height);
2.7 图像裁切、插入与缩放
ID | 描述 |
---|---|
VIDIOC_CROPCAP | 获取图像裁剪缩放能力 |
VIDIOC_G_CROP | 获取当前的裁剪矩阵 |
VIDIOC_S_CROP | 设置裁剪矩阵 |
2.8 数据的输入与输出
内核中使用缓存队列对图像数据进行管理,用户空间获取图像数据有两种方式:
- 直接读取方式:通过read、write方式读取内核空间的缓存;
- 内存映射方式:将内核空间的缓存映射到用户空间;
在操作V4L2设备时,通过VIDIOC_QUERYCAP获取设备支持哪种方式:
ID | 描述 |
---|---|
VIDIOC_REQBUFS | 申请缓存 |
VIDIOC_QUERYBUF | 获取缓存信息 |
VIDIOC_QBUF | 将缓存放入队列中 |
VIDIOC_DQBUF | 将缓存从队列中取出 |
2.8.1 VIDIOC_REQBUFS
VIDIOC_REQBUFS用于请求V4L2驱动分配视频缓冲区(申请V4L2视频驱动分配内存),V4L2是视频设备的驱动层,位于内核空间,所以通过VIDIOC_REQBUFS控制命令字申请的内存位于内核空间,应用程序不能直接访问,需要通过调用mmap内存映射函数把内核空间内存映射到用户空间后,应用程序通过访问用户空间地址来访问内核空间。
struct v4l2_requestbuffers { u32 count; //缓存数量,也就是说在缓存队列里保持多少张照片 enum v4l2_buf_type type; //数据流类型,必须永远是V4L2_BUF_TYPE_VIDEO_CAPTURE enum v4l2_memory memory; //V4L2_MEMORY_MMAP或V4L2_MEMORY_USERPTR u32 reserved[2]; };
返回值说明: 执行成功时,函数返回值为 0,V4L2驱动层分配好了视频缓冲区;
应用程序使用举例:
struct v4l2_buf* v4l2_reqbufs(int fd, enum v4l2_buf_type type, int nr_bufs) { struct v4l2_requestbuffers req; struct v4l2_buf* v4l2_buf; int i; // 缓存数量,也就是说在缓存队列里保持多少张照片 req.count = nr_bufs; // 数据流类型, req.type = type; // V4L2_MEMORY_MMAP或V4L2_MEMORY_USERPTR req.memory = V4L2_MEMORY_MMAP; // 请求V4L2驱动分配视频缓冲区(申请V4L2视频驱动分配内存),V4L2是视频设备的驱动层,位于内核空间,所以通过 // VIDIOC_REQBUFS控制命令字申请的内存位于内核空间,应用程序不能直接访问,需要通过调用mmap内存映射函数把 // 内核空间内存映射到用户空间后,应用程序通过访问用户空间地址来访问内核空间。 if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { printf("ERR(%s):VIDIOC_REQBUFS failed\n", __func__); return NULL; } // 动态分配内存 v4l2_buf = (struct v4l2_buf*)malloc(sizeof(struct v4l2_buf)); // VIDIOC_REQBUFS会修改req的count值,req的count值返回实际申请成功的视频缓冲区数目; v4l2_buf->nr_bufs = req.count; // 动态分配内存 v4l2_buf->buf = (struct v4l2_buf_unit*)malloc(sizeof(struct v4l2_buf_unit) * v4l2_buf->nr_bufs); v4l2_buf->type = type; // 填充0 memset(v4l2_buf->buf, 0, sizeof(struct v4l2_buf_unit) * v4l2_buf->nr_bufs); return v4l2_buf; }
注意:VIDIOC_REQBUFS会修改v4l2_requestbuffers 的count值,req的count值返回实际申请成功的视频缓冲区数目;
上面代码中struct v4l2_buf为自定义的数据类型,用于保存从内核空间申请到的视频缓冲区数据,其中变量v4l2_buf为指针类型,指向一个动态分配的内存空间,其成员buf为struct v4l2_buf_unit *类型,保存内核申请到的每一个视频缓冲区信息:
/* 保存内核空间申请到的每一个视频缓冲区信息 */ struct v4l2_buf_unit { int index; // 当前索引 void* start; // 映射到用户空间起始地址 uint32_t length; // 缓冲区大小 uint32_t offset; // 内核空间起始地址 }; /* 保存视频缓冲区信息 */ struct v4l2_buf { struct v4l2_buf_unit* buf; int nr_bufs; enum v4l2_buf_type type; };
执行如下代码:
v4l2_buf = v4l2_reqbufs(fd, V4L2_BUF_TYPE_VIDEO_CAPTURE, nr_bufs); // nr_bufs这里初始化为4 if(!v4l2_buf) goto err;
此时v4l2_buf结构如下:
2.8.2 VIDIOC_QUERYBUF
VIDIOC_QUERYBUF用于查询已经分配的V4L2的视频缓冲区的相关信息,包括视频缓冲区的使用状态、在内核空间的偏移地址、缓冲区长度等。在应用程序设计中通过调 VIDIOC_QUERYBUF来获取内核空间的视频缓冲区信息,然后调用函数mmap把内核空间地址映射到用户空间,这样应用程序才能够访问位于内核空间的视频缓冲区。
struct v4l2_buffer { __u32 index; enum v4l2_buf_type type; __u32 bytesused; __u32 flags; enum v4l2_field field; struct timeval timestamp; struct v4l2_timecode timecode; __u32 sequence; /* memory location */ enum v4l2_memory memory; union { __u32 offset; unsigned long userptr; } m; __u32 length; __u32 input; __u32 reserved; };
返回值说明: 执行成功时,函数返回值为 0;
struct v4l2_buffer结构体变量中保存了指令的缓冲区的相关信息;一般情况下,应用程序中调用VIDIOC_QUERYBUF取得了内核缓冲区信息后,紧接着调用mmap函数把内核空间地址。映射到用户空间,方便用户空间应用程序的访问。
为什么要映射缓存?
因为如果使用read方式读取的话,图像数据是从内核空间拷贝会应用空间,而一副图像的数据一般来讲是比较大的,所以效率会比较低。而如果使用映射的方式,讲内核空间的内存应用到用户空间,那么用户空间读取数据就想在操作内存一样,不需要经过内核空间到用户空间的拷贝,大大提高效率。
映射缓存需要先查询缓存信息,然后再使用缓存信息进行映射,应用程序使用举例:
/* 查询缓存信息 */ int v4l2_querybuf(int fd, struct v4l2_buf* v4l2_buf) { struct v4l2_buffer buf; int i; // 遍历视频缓冲区 for(i = 0; i < v4l2_buf->nr_bufs; ++i) { buf.type = v4l2_buf->type; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; // 查询已经分配的V4L2的视频缓冲区的相关信息,包括视频缓冲区的使用状态、在内核空间的偏移地址、缓冲区长度等 if (ioctl(fd , VIDIOC_QUERYBUF, &buf) < 0) { printf("ERR(%s):VIDIOC_QUERYBUF failed\n", __func__); return -1; } v4l2_buf->buf[i].index = i; // 内核空间地址 v4l2_buf->buf[i].offset = buf.m.offset; // 长度 v4l2_buf->buf[i].length = buf.length; v4l2_buf->buf[i].start = NULL; } return 0; } /* 把内核空间缓冲区映射到用户空间 */ int v4l2_mmap(int fd, struct v4l2_buf* v4l2_buf) { int i; // 遍历视频缓冲区 for(i = 0; i < v4l2_buf->nr_bufs; ++i) { // 映射到用户空间的起始地址 v4l2_buf->buf[i].start = mmap(0, v4l2_buf->buf[i].length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, v4l2_buf->buf[i].offset); if(v4l2_buf->buf[i].start < 0) { printf("ERR(%s):v4l2_mmap failed\n", __func__); goto err; } } return 0; err: while(--i >= 0) { munmap(v4l2_buf->buf[i].start, v4l2_buf->buf[i].length); v4l2_buf->buf[i].start = NULL; } return -1; }
执行如下代码:
// 查询缓存信息 ret = v4l2_querybuf(fd, v4l2_buf); if(ret < 0) goto err; // 把内核空间缓冲区映射到用户空间 ret = v4l2_mmap(fd, v4l2_buf); if(ret < 0) goto err;
2.8.3 VIDIOC_QBUF
VIDIOC_QBUF用于 投放一个空的视频缓冲区到视频缓冲区输入队列中 ;参数类型为V4L2缓冲区数据结构类型 struct v4l2_buffer;
返回值说明: 执行成功时,函数返回值为 0;
函数执行成功后,指令(指定)的视频缓冲区进入视频输入队列,在启动视频设备拍摄图像时,相应的视频数据被保存到视频输入队列相应的视频缓冲区中。
应用程序使用举例:
/* 投放一个空的视频缓冲区到视频缓冲区输入队列中 */ int v4l2_qbuf(int fd, struct v4l2_buf_unit* buf) { struct v4l2_buffer v4l2_buf; memset(&v4l2_buf, 0, sizeof(struct v4l2_buf)); v4l2_buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; v4l2_buf.memory = V4L2_MEMORY_MMAP; // 指令(指定)要投放到视频输入队列中的内核空间视频缓冲区的编号 v4l2_buf.index = buf->index; if (ioctl(fd, VIDIOC_QBUF, &v4l2_buf) < 0) { printf("ERR(%s):VIDIOC_QBUF failed\n", __func__); return -1; } return 0; } /* 所有缓存入队列 */ int v4l2_qbuf_all(int fd, struct v4l2_buf* v4l2_buf) { int i; // 遍历视频缓冲区 for(i = 0; i < v4l2_buf->nr_bufs; ++i) { if(v4l2_qbuf(fd, &v4l2_buf->buf[i])) return -1; } return 0; }
完成缓存区信息查询以及内核空间到用户空间的映射后,执行如下代码,将所有视频缓存入队列:
/* 所有缓存入队列 */ ret = v4l2_qbuf_all(fd, v4l2_buf); if(ret < 0) goto err;
2.8.4 VIDIOC_DQBUF
VIDIOC_DQBUF用于从视频缓冲区的输出队列中取得一个已经保存有一帧视频数据的视频缓冲区;
参数类型为V4L2缓冲区数据结构类型 struct v4l2_buffer;
执行成功时,函数返回值为 0;函数执行成功后,相应的内核视频缓冲区中保存有当前拍摄到的视频数据,应用程序可以通过访问用户空间来读取该视频数据。(前面已经通过调用函数 mmap做了用户空间和内核空间的内存映射)。
应用程序使用举例:
/* 缓存出队列 */ struct v4l2_buf_unit* v4l2_dqbuf(int fd, struct v4l2_buf* v4l2_buf) { struct v4l2_buffer buffer; buffer.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buffer.memory = V4L2_MEMORY_MMAP; // 从视频缓冲区的输出队列中取得一个已经保存有一帧视频数据的视频缓冲区 if (ioctl(fd, VIDIOC_DQBUF, &buffer) < 0) { printf("ERR(%s):VIDIOC_DQBUF failed, dropped frame\n", __func__); return NULL; } return &v4l2_buf->buf[buffer.index]; }
VIDIOC_DQBUF命令结果, 使从队列删除的缓冲帧信息传给了此buffer。v4l2_buffer结构体的作用就相当于申请的缓冲帧的代理,找缓冲帧的都要先问问它,通过它来联系缓冲帧,起了中间桥梁的作用。
一帧数据采集完成后, 执行如下代码,视频缓存出队列:
v4l2_buf_unit = v4l2_dqbuf(fd, v4l2_buf); if(!v4l2_buf_unit) goto err;
ioctl API就先介绍到这里,还有非常多的接口这里就不一一介绍了,具体可以查看V4L2 Function Reference。
三、V4L2设备操作流程
V4L2支持多种接口:capture(捕获)、output(输出)、overlay(预览)等等。其中视屏捕获的流程如下:
3.1 打开设备
在Linux中,视频设备节点为/dev/videox,使用open函数将其打开:
int fd = open("/dev/video0", O_RDWR); if(fd < 0) { printf("ERR(%s):failed to open %s\n", __func__, name); return -1; } return fd;
3.2 查询设备功能
ret = v4l2_querycap(fd, &cap); if(ret < 0) goto err; printf("Driver Name:%s\n Card Name:%s\n Bus info:%s\n\n",cap.driver,cap.card,cap.bus_info); if(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) printf("dev support capture\n"); if(cap.capabilities & V4L2_CAP_VIDEO_OUTPUT) printf("dev support output\n"); if(cap.capabilities & V4L2_CAP_VIDEO_OVERLAY) printf("dev support overlay\n"); if(cap.capabilities & V4L2_CAP_STREAMING) printf("dev support streaming\n"); if(cap.capabilities & V4L2_CAP_READWRITE) printf("dev support read write\n");
3.3 设置输入设备
3.3.1 枚举输入设备
ret = v4l2_enuminput(fd, 0, name); if(ret < 0) goto err; printf("input device name:%s\n", name);
3.3.2 设置输入设备
ret = v4l2_s_input(fd, 0); if(ret < 0) goto err;
3.4 设置图像格式
3.4.1 枚举支持的像素格式
ret = v4l2_enum_fmt(fd,V4L2_PIX_FMT_YUYV, V4L2_BUF_TYPE_VIDEO_CAPTURE); if(ret < 0) goto err;
3.4.2 设置像素格式
ret = v4l2_s_fmt(fd, &width, &height, V4L2_PIX_FMT_YUYV, V4L2_BUF_TYPE_VIDEO_CAPTURE); if(ret < 0) goto err; printf("image width:%d\n", width); printf("image height:%d\n", height);
3.5 设置缓存
V4L2设备读取数据的方式有两种,一种是read方式,一种是streaming方式,具体需要看设备驱动的能力是V4L2_CAP_READWRITE还是V4L2_CAP_STREAMING。
read方式很容易理解,就是通过read函数读取,那么streaming是什么意思呢?
streaming就是在内核空间中维护一个缓存队列,然后将内存映射到用户空间,应用读取图像数据就是一个不断地出队列和入队列的过程,如下图所示:
3.5.1 申请缓存
v4l2_buf = v4l2_reqbufs(fd, V4L2_BUF_TYPE_VIDEO_CAPTURE, nr_bufs); if(!v4l2_buf) goto err;
3.5.2 映射缓存
ret = v4l2_querybuf(fd, v4l2_buf); if(ret < 0) goto err; ret = v4l2_mmap(fd, v4l2_buf); if(ret < 0) goto err;
3.5.3 将所有的缓存放入队列
ret = v4l2_qbuf_all(fd, v4l2_buf); if(ret < 0) goto err;
3.6 开始视频采集
ret = v4l2_streamon(fd); if(ret < 0) goto err;
3.7 读取数据
获取图像数据其实就是一个不断地入队列和出队列地过程,在出队列前要调用poll等待数据准备完成。
3.7.1 poll
ret = v4l2_poll(fd); if(ret < 0) goto err;
3.7.2 出队列
v4l2_buf_unit = v4l2_dqbuf(fd, v4l2_buf); if(!v4l2_buf_unit) goto err;
3.7.3 图像处理
采集完一帧图像之后,我们就可以对图像处理,无论是保存,还是在lcd设备上显示都是可以的,不过这里有一点需要注意的是,一般我们usb设备采集到图像格式都是YUYV 4:2:2,因此我们需要将其转换为RGB格式,具体是RGB32还是RGB565等格式,取决于你的fb设备支持哪一种。关于图像的转换我们后面单独介绍。
3.7.4 入队列
再数据读取完成后,要将buf重新放入队列中:
ret = v4l2_qbuf(fd, v4l2_buf_unit); if(ret < 0) goto err;
3.8 关闭设备
3.8.1 关闭设备
ret = v4l2_streamoff(fd); if(ret < 0) goto err;
3.8.2 取消映射
ret = v4l2_munmap(fd, v4l2_buf); if(ret < 0) goto err;
3.8.3 关闭文件描述符
if(close(fd)) { printf("ERR(%s):failed to close v4l2 dev\n", __func__); return -1; }
四、YUV转RGB
4.1 YUV介绍
在 YUV 空间中,每一个颜色有一个亮度信号 Y,和两个色度信号 U 和 V。亮度信号是强度的感觉,它和色度信号断开,这样的话强度就可以在不影响颜色的情况下改变,占用的存储空间对于RGB也较小。
常见YUV图像格式是由采样格式+存储格式共同决定的,如NV21、YV12、I420、YUY2等。
4.1.1 采样格式
人眼对亮度(Y分量)的敏感度远远大于颜色(UV分量)的敏感度,所以丢弃某些UV分量,图像在肉眼中的感觉不会起太大的变化,主流的采样方式有四种,分别为YUV4:4:4,YUV4:2:2,YUV4:1:1,YUV4:2:0(常用常见)。
4.1.2 存储格式
Planar平面格式:指先连续存储所有像素点的 Y 分量,然后存储 U 、V 分量。Packed打包格式:指每个像素点的 Y、U、V 分量是连续交替存储的。
I420(YU12)和YV12都是YUV420P类型即先存储Y分量,再存储 U、V 分量,区别在于:I420 是先 Y 再 U 后 V,而 YV12 是先 Y 再 V 后 U 。
NV21、NV12 属于 YUV420SP 类型(IOS中有的类型)。即先存储了Y分量,再UV 分量交替存储。区别在于:NV12 是UV交替,而 NV21 是VU交替。
4.1.3 YUV4:4:4
YUV三个信道的抽样率相同,每个像素点的三个分量信息完整,每个分量用8bit表示,平均一个像素点占用3个字节。
Y:实心圆、U:空心圆 、V:交叉线.
假如图像像素为:[Y0 U0 V0]、[Y1 U1 V1]、[Y2 U2 V2]、[Y3 U3 V3]。
那么采样的码流为:Y0 U0 V0 Y1 U1 V1 Y2 U2 V2 Y3 U3 V3,最后映射出的像素点依旧为:[Y0 U0 V0]、[Y1 U1 V1]、[Y2 U2 V2]、[Y3 U3 V3]。
4.1.4 YUV4:2:2
U、V分量是Y分量的抽样率一半,每个像素点对应一个Y分量,水平方向每两个相邻像素点,抽取第一个像素点的U分量,抽取第二个像素点的V分量,则平均一个像素点占用2个字节。
Y:实心圆、U:空心圆 、V:交叉线。
假如图像像素为:[Y0 U0 V0]、[Y1 U1 V1]、[Y2 U2 V2]、[Y3 U3 V3]。
那么采样的码流为:Y0 U0 Y1 V1 Y2 U2 Y3 V3,其中,每采样过一个像素点,都会采样其 Y 分量,而 U、V 分量就会间隔一个采集一个最后映射出的像素点为[Y0 U0 V1]、[Y1 U0 V1]、[Y2 U2 V3]、[Y3 U2 V3]。
4.1.5 YUV4:1:1
U、V分量是Y分量的抽样率1/4,每个像素点对应一个Y分量,水平方向每四个相邻像素点,抽取第一个像素点的U分量,抽取第三个像素点的V分量,则平均一个像素点占用1.5个字节。
Y:实心圆、U:空心圆 、V:交叉线。
假如图像像素为:[Y0 U0 V0]、[Y1 U1 V1]、[Y2 U2 V2]、[Y3 U3 V3]。
那么采样的码流为:Y0 U0 Y1 Y2 V2 Y3;最后映射出的像素点为 :[Y0 U0 V2]、 [Y1 U0 V2] 、[Y2 U0 V2]、 [Y3 U0 V2]。
4.1.6 YUV4:2:0
U、V分量是Y分量的抽样率1/4,每个像素点对应一个Y分量,按行扫描,相邻两行中第一行每2个相邻像素点抽取第一个像素点的U分量,第二行每2个相邻像素点抽取第一个像素点的V分量,则平均一个像素点占用1.5个字节。
Y:实心圆、U:空心圆 、V:交叉线。
假设图像像素为:
[Y0 U0 V0]、[Y1 U1 V1]、 [Y2 U2 V2]、 [Y3 U3 V3]
[Y4 U4 V4]、[Y5 U5 V5]、[Y6 U6 V6]、 [Y7 U7 V7]
那么采样的码流为:Y0 U0 Y1 Y2 U2 Y3 Y4 V4 Y5 Y6 V6 Y7;其中,每采样过一个像素点,都会采样其 Y 分量,而 U、V 分量就会间隔一行按照 2 : 1 进行采样。
最后映射出的像素点为:
[Y0 U0 V5]、[Y1 U0 V5]、[Y2 U2 V7]、[Y3 U2 V7]
[Y5 U0 V5]、[Y6 U0 V5]、[Y7 U2 V7]、[Y8 U2 V7]
4.2 RGB
RGB是红、绿、蓝三原色,任何颜色都可以通过这三原色按不同比例混合出来。但在科学研究一般不采用RGB颜色空间,因为它的细节难以进行数字化的调整。它将亮度、色调、饱和度三个量放在一起表示,很难分开。
RGB常见种类包含:RGB_555、RGB_565、RGB_8888、RGB_4444。
4.2.1 RGB_555
用16个bit表示一个像素,最高位不用,5个bit表示R(红色),5个bit表示G(绿色),5个bit表示B(蓝色),那么一个屏幕像素点占1+5+5+5=16位,共2个字节。
4.2.2 RGB565
用16个bit表示一个像素,5个bit表示R(红色),6个bit表示G(绿色),5个bit表示B(蓝色),那么一个屏幕像素点占5+6+5=16位,共2个字节。
4.2.3 RGB_8888
用32个bit表示一个像素,8个bit表示A(透明度), 8个bit表示R(红色),8个bit表示G(绿色),8个bit表示B(蓝色) ,那么一个屏幕像素点占8+8+8+8=32位,共4个字节,注意ARGB的排列顺序是BGRA在解析数据时要注意。
4.2.4 ARGB_4444
用16个bit表示一个像素,4个bit表示A(透明度), 4个bit表示R(红色),4个bit表示G(绿色),4个bit表示B(蓝色) ,那么一个屏幕像素点占4+4+4+4=16位,共2个字节。
4.3 保存YUV格式图片
在一帧图像采集完成后,我们可以将其保存为yuv格式文件,这里我们定义一个函数用来保存图像:
/* * 函数名称:save_yuv_image * 功能描述:保存一帧图像,格式为yuv文件 * 输入参数:start - 采集的一帧图像的缓冲区的起始地址 * 输入参数:length - 采集的一帧图像长度 单位字节 * 输出参数:无 * 返 回 值:失败返回-1;成功返回0 */ int save_yuv_image(unsigned char * start, int length);
/* 保存一帧图像,格式为yuv文件 */ int save_yuv_image(unsigned char * start, int length) { FILE* fp = fopen("pic.yuv", "w"); if(!fp) { printf("failed to open picture\n"); return -1; } fwrite(start, 1, length, fp); fclose(fp); return 0; }
然后在一帧图像采集到之后,我们就可以保存该图像到文件:
v4l2_buf_unit = v4l2_dqbuf(fd, v4l2_buf); if(!v4l2_buf_unit) goto err; save_yuv_image(v4l2_buf_unit->start,v4l2_buf_unit->length);
需要注意的是采集到的图像为yuv格式,我们一般是打不开的,如果需要打开该文件可以使用ffplay工具,这个在后面介绍。
亲爱的读者和支持者们,自动博客加入了打赏功能,陆陆续续收到了各位老铁的打赏。在此,我想由衷地感谢每一位对我们博客的支持和打赏。你们的慷慨与支持,是我们前行的动力与源泉。
日期 | 姓名 | 金额 |
---|---|---|
2023-09-06 | *源 | 19 |
2023-09-11 | *朝科 | 88 |
2023-09-21 | *号 | 5 |
2023-09-16 | *真 | 60 |
2023-10-26 | *通 | 9.9 |
2023-11-04 | *慎 | 0.66 |
2023-11-24 | *恩 | 0.01 |
2023-12-30 | I*B | 1 |
2024-01-28 | *兴 | 20 |
2024-02-01 | QYing | 20 |
2024-02-11 | *督 | 6 |
2024-02-18 | 一*x | 1 |
2024-02-20 | c*l | 18.88 |
2024-01-01 | *I | 5 |
2024-04-08 | *程 | 150 |
2024-04-18 | *超 | 20 |
2024-04-26 | .*V | 30 |
2024-05-08 | D*W | 5 |
2024-05-29 | *辉 | 20 |
2024-05-30 | *雄 | 10 |
2024-06-08 | *: | 10 |
2024-06-23 | 小狮子 | 666 |
2024-06-28 | *s | 6.66 |
2024-06-29 | *炼 | 1 |
2024-06-30 | *! | 1 |
2024-07-08 | *方 | 20 |
2024-07-18 | A*1 | 6.66 |
2024-07-31 | *北 | 12 |
2024-08-13 | *基 | 1 |
2024-08-23 | n*s | 2 |
2024-09-02 | *源 | 50 |
2024-09-04 | *J | 2 |
2024-09-06 | *强 | 8.8 |
2024-09-09 | *波 | 1 |
2024-09-10 | *口 | 1 |
2024-09-10 | *波 | 1 |
2024-09-12 | *波 | 10 |
2024-09-18 | *明 | 1.68 |
2024-09-26 | B*h | 10 |
2024-09-30 | 岁 | 10 |
2024-10-02 | M*i | 1 |
2024-10-14 | *朋 | 10 |
2024-10-22 | *海 | 10 |
2024-10-23 | *南 | 10 |
2024-10-26 | *节 | 6.66 |
2024-10-27 | *o | 5 |
2024-10-28 | W*F | 6.66 |
2024-10-29 | R*n | 6.66 |
2024-11-02 | *球 | 6 |
2024-11-021 | *鑫 | 6.66 |
2024-11-25 | *沙 | 5 |
2024-11-29 | C*n | 2.88 |

【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· 2 本地部署DeepSeek模型构建本地知识库+联网搜索详细步骤
2018-08-24 第十三节、SURF特征提取算法