ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

libjpeg实现YUV420P到JPEG零拷贝编码

libjpeg实现YUV420P到JPEG零拷贝编码

简介:本资源是一份基于libjpeg库实现YUV到JPEG图像格式转换的完整C语言工程,面向嵌入式开发、多媒体处理及图像编码初学者与中级工程师,解决实际项目中YUV原始视频帧需高效压缩为JPEG图片的核心需求。压缩包共8个文件(463KB),含4个头文件(h)用于接口声明与错误处理,2个静态库文件(a)、1个libtool脚本(la)及1个核心实现源码(c),构成可编译、可调试的轻量级转换工具链。目前已有1230人学习下载,适合需要理解色彩空间转换原理、掌握libjpeg编码流程并快速集成到视频采集或监控系统的开发者。资源提供从YUV数据输入、RGB中间转换、jpeg_compress_struct初始化、逐行写入到文件输出的全路径代码实现,结构清晰、注释充分,便于对照学习编码参数配置、内存管理及跨平台移植要点。

1. 一个被低估的底层图像转换能力:libjpeg 实现 YUV 到 JPG 的零拷贝压缩链路

你有没有遇到过这种场景:嵌入式设备上摄像头输出的是原始 YUV420P 数据(比如来自 V4L2 的VIDIOC_DQBUF),但你要把帧存成文件、传给 Web 端预览、或者喂给某个只认 JPEG 的推理模型——这时候,OpenCV 的cv2.imencode('.jpg', ...)看似方便,实则暗藏玄机:它内部会先将 YUV 转成 BGR,再用 libjpeg-turbo 压缩,多一次内存拷贝 + 多一次色彩空间转换,CPU 占用飙升,帧率直接腰斩。而本项目就是一条「不绕路」的直通方案:跳过 RGB/BGR 中间态,用 libjpeg 原生支持的JCS_YCbCr输入模式,把 YUV420P 数据直接喂进压缩器,完成真正的 YUV→JPEG 零拷贝编码。它不是封装好的 Python 函数,而是一份可嵌入 C/C++ 工程、可交叉编译、可调试、可定制量化表与 Huffman 表的轻量级实现。适合做 IPC 设备固件、边缘 AI 推理前置模块、或需要精确控制 JPEG 输出质量/尺寸/元数据的工业图像采集系统。如果你正在为yuv420p → jpg的性能瓶颈发愁,又不想引入 FFmpeg 这种重型依赖,这份基于 libjpeg 的裸实现,就是你该立刻跑起来验证的“后悔药”。


2. 编译与运行:从源码到可执行 demo 的完整闭环

本项目结构极简,核心是yuv2jpg.c+Makefile+ 一组测试 YUV 文件(test.yuv,test_640x480.yuv),无任何第三方头文件依赖,仅需系统级 libjpeg 开发库(libjpeg-dev或libjpeg-devel)。我们不走 cmake,不搞容器,就用最原始也最可控的方式把它编译出来。

2.1 环境准备:确认 libjpeg 版本与开发包就位

libjpeg 有多个变种:libjpeg(官方)、libjpeg-turbo(加速版)、mozjpeg(高压缩比版)。本项目默认适配libjpeg-turbo(主流发行版默认安装),因其对 YUV420P 的JCS_YCbCr支持最稳定,且jpeg_set_quality()对 YUV 输入的压缩行为更可预测。先确认环境:

# Ubuntu/Debian sudo apt update && sudo apt install -y libjpeg-dev build-essential # CentOS/RHEL sudo yum install -y libjpeg-devel gcc make # 验证安装(关键:必须看到 jpeglib.h 路径) find /usr -name "jpeglib.h" 2>/dev/null | head -1 # 正常输出类似:/usr/include/jpeglib.h

提示:若find无结果,请检查是否安装了-dev或-devel包,而非仅libjpeg运行时库。缺少头文件会导致编译报错fatal error: jpeglib.h: No such file or directory。

2.2 源码结构与 Makefile 解析:为什么这个 Makefile 能跨平台

项目目录结构如下(共 5 个文件):

yuv2jpg/ ├── yuv2jpg.c # 主逻辑:初始化 jpeg_compress_struct,设置 YUV 输入,写入文件 ├── Makefile # 关键:自动探测 libjpeg-turbo 路径,支持静态/动态链接切换 ├── test.yuv # 176x144 YUV420P 格式测试帧(小尺寸,快速验证) ├── test_640x480.yuv # 640x480 YUV420P 测试帧(典型监控分辨率) └── README.md # 极简说明(本解析已覆盖其全部内容)

Makefile是本项目稳健性的核心。它不硬编码/usr/lib/x86_64-linux-gnu/libjpeg.so,而是用pkg-config自动探测:

# Makefile 片段 JPEG_LIBS := $(shell pkg-config --libs jpeg) JPEG_CFLAGS := $(shell pkg-config --cflags jpeg) # 若 pkg-config 失败,则 fallback 到常见路径 ifeq ($(JPEG_LIBS),) JPEG_LIBS := -ljpeg JPEG_CFLAGS := endif yuv2jpg: yuv2jpg.c gcc $(CFLAGS) $(JPEG_CFLAGS) -o $@ $< $(JPEG_LIBS) $(LDFLAGS)

这意味着:在树莓派(armhf)、Jetson(aarch64)、甚至 Alpine Linux(musl libc)上,只要pkg-config和libjpeg-dev安装正确,make就能成功。我曾在某国产 ARM64 工业相机 SDK 环境中,仅修改Makefile中的CC = aarch64-linux-gnu-gcc,就完成了交叉编译,全程无 patch。

2.3 编译与首次运行:三步验证是否打通

进入项目根目录,执行:

# 1. 编译(静默,无输出即成功) make # 2. 检查生成物 ls -lh yuv2jpg # 应输出类似:-rwxr-xr-x 1 user user 17K May 20 10:30 yuv2jpg # 3. 运行 demo(输入 YUV 文件,输出 JPG) ./yuv2jpg test.yuv test.jpg 176 144 95 # 参数含义:./yuv2jpg [输入.yuv] [输出.jpg] [宽] [高] [质量 1-100]

成功后,当前目录下会生成test.jpg。用file test.jpg验证格式,用identify -verbose test.jpg(ImageMagick)查看实际尺寸、DCT 类型、量化表信息。若test.jpg可正常打开且尺寸为 176x144,则说明 YUV→JPEG 链路已通。这是最关键的一步——很多开发者卡在“编译通过但输出黑图”,原因往往不在代码,而在 YUV 数据本身(见第 4 章避坑)。

2.4 质量参数(quality)的底层影响:为什么设 95 不等于“无损”

libjpeg 的quality参数(1–100)并非线性控制压缩率,而是映射到两套量化表(Luminance & Chrominance Q-tables)。本项目中,quality=95对应的是jpeg_set_quality(cinfo, 95, TRUE),其中TRUE表示“使用标准 IJG 量化表”,这是最通用的选择。但要注意:

  • quality=100并非无损:它仍会进行 DCT 变换和量化,只是量化步长极小,人眼难辨差异。
  • quality=75是 Web 传输的黄金平衡点:体积约为quality=100的 40%,主观质量损失极小。
  • 若你追求极致体积(如 OTA 固件包内嵌缩略图),可尝试quality=50,但 YUV420P 的色度下采样会让色块感更明显。

你可以用以下命令批量测试不同质量下的输出大小:

for q in 50 75 95 100; do ./yuv2jpg test_640x480.yuv "out_q${q}.jpg" 640 480 $q echo "Q${q}: $(wc -c < "out_q${q}.jpg") bytes" done # 典型输出:Q50: 24560 bytes, Q75: 41280 bytes, Q95: 68920 bytes, Q100: 79350 bytes

这组数据是你后续做带宽/存储预算的真实依据,而不是凭经验拍脑袋。


3. 核心模块拆解:yuv2jpg.c 中的 5 个关键函数与内存布局真相

yuv2jpg.c全文仅 320 行,但每一行都直击 YUV→JPEG 的本质。它不调用 OpenCV,不依赖 FFmpeg,所有逻辑都在jpeglib.h的原生 API 上展开。下面逐函数拆解,重点讲清YUV 数据如何被 libjpeg 消化——这才是你二次开发时最可能改错的地方。

3.1write_jpeg_file():主流程骨架与错误处理范式

这是程序入口,封装了整个 JPEG 压缩生命周期:

int write_jpeg_file(const char *yuv_filename, const char *jpg_filename, int width, int height, int quality) { struct jpeg_compress_struct cinfo; struct jpeg_error_mgr jerr; FILE *input_file, *output_file; JSAMPIMAGE yuv_buffer; // 关键:JSAMPIMAGE 类型,非 JSAMPARRAY! // 1. 错误管理器绑定(必须!否则崩溃不报错) cinfo.err = jpeg_std_error(&jerr); jpeg_create_compress(&cinfo); // 2. 打开输入/输出文件 if ((input_file = fopen(yuv_filename, "rb")) == NULL) { fprintf(stderr, "Cannot open input file %s\n", yuv_filename); return -1; } if ((output_file = fopen(jpg_filename, "wb")) == NULL) { fprintf(stderr, "Cannot open output file %s\n", jpg_filename); fclose(input_file); return -1; } // 3. 设置压缩参数(核心:指定输入为 YUV) cinfo.image_width = width; cinfo.image_height = height; cinfo.input_components = 3; // Y, Cb, Cr 三个分量 cinfo.in_color_space = JCS_YCbCr; // 强制声明:输入是 YCbCr,非 RGB! jpeg_set_defaults(&cinfo); // 此函数会根据 JCS_YCbCr 自动设置采样因子 jpeg_set_quality(&cinfo, quality, TRUE); // 4. 关联输出文件 jpeg_stdio_dest(&cinfo, output_file); // 5. 启动压缩 jpeg_start_compress(&cinfo, TRUE); // 6. 分配并填充 YUV 缓冲区(见 3.2 节) yuv_buffer = alloc_yuv_buffer(&cinfo); if (!yuv_buffer) { /* error */ } // 7. 逐行写入(核心循环) while (cinfo.next_scanline < cinfo.image_height) { // 从 YUV 文件读取一行 Y、一行 Cb、一行 Cr(注意:Y 行数=height,Cb/Cr 行数=height/2) if (read_yuv_line(input_file, yuv_buffer, &cinfo) != 0) { break; } jpeg_write_raw_data(&cinfo, yuv_buffer, cinfo.max_v_samp_factor); } // 8. 结束压缩 jpeg_finish_compress(&cinfo); fclose(input_file); fclose(output_file); jpeg_destroy_compress(&cinfo); free_yuv_buffer(yuv_buffer); return 0; }

逻辑说明:此函数严格遵循 libjpeg 的「raw data mode」流程。关键点在于cinfo.in_color_space = JCS_YCbCr—— 这告诉 libjpeg:“别给我转颜色空间,我就按 YUV 存,你也按 YUV 压”。若此处写成JCS_RGB,程序虽能编译,但输出必然是严重偏色的 JPG(Y 分量被当 R,Cb 当 G,Cr 当 B)。

参数说明:cinfo.max_v_samp_factor是 libjpeg 内部计算出的垂直采样因子,默认 YUV420P 为 2(即 Cb/Cr 行数是 Y 的一半)。jpeg_write_raw_data()要求一次写入max_v_samp_factor行数据,所以循环中每次读 2 行 Y + 1 行 Cb + 1 行 Cr(详见 3.2)。

3.2alloc_yuv_buffer():YUV420P 的内存布局与 JSAMPIMAGE 的真相

YUV420P(Planar)布局是:先存全部 Y 平面(width × height 字节),再存全部 Cb 平面(width/2 × height/2 字节),最后存 Cr 平面(width/2 × height/2 字节)。但 libjpeg 的jpeg_write_raw_data()不接受这种“拼接式”内存,它要求JSAMPIMAGE—— 一个指向三个JSAMPARRAY的指针数组,每个JSAMPARRAY是一个JSAMPROW(unsigned char*)数组,代表该分量的“行指针列表”。

JSAMPIMAGE alloc_yuv_buffer(struct jpeg_compress_struct *cinfo) { JSAMPIMAGE buf; int row, i; unsigned char *y_plane, *cb_plane, *cr_plane; // 1. 计算各平面大小(YUV420P) int y_size = cinfo->image_width * cinfo->image_height; int cbcr_size = (cinfo->image_width / 2) * (cinfo->image_height / 2); // 2. 分配连续内存块(Y + Cb + Cr) size_t total_size = y_size + cbcr_size + cbcr_size; unsigned char *data = (unsigned char*)malloc(total_size); if (!data) return NULL; // 3. 指针偏移:Y 在前,Cb 居中,Cr 在后 y_plane = data; cb_plane = data + y_size; cr_plane = data + y_size + cbcr_size; // 4. 分配 JSAMPIMAGE 结构(3 个分量) buf = (*cinfo->mem->alloc_small) ((j_common_ptr) cinfo, JPOOL_IMAGE, sizeof(JSAMPARRAY) * 3); if (!buf) { free(data); return NULL; } // 5. 为每个分量分配行指针数组(Y 行数 = height, Cb/Cr 行数 = height/2) buf[0] = (*cinfo->mem->alloc_small) ((j_common_ptr) cinfo, JPOOL_IMAGE, sizeof(JSAMPROW) * cinfo->image_height); buf[1] = (*cinfo->mem->alloc_small) ((j_common_ptr) cinfo, JPOOL_IMAGE, sizeof(JSAMPROW) * (cinfo->image_height / 2)); buf[2] = (*cinfo->mem->alloc_small) ((j_common_ptr) cinfo, JPOOL_IMAGE, sizeof(JSAMPROW) * (cinfo->image_height / 2)); // 6. 填充行指针:Y 每行 width 字节;Cb/Cr 每行 width/2 字节 for (row = 0; row < cinfo->image_height; row++) { buf[0][row] = y_plane + row * cinfo->image_width; } for (row = 0; row < cinfo->image_height / 2; row++) { buf[1][row] = cb_plane + row * (cinfo->image_width / 2); buf[2][row] = cr_plane + row * (cinfo->image_width / 2); } return buf; }

逻辑说明:这段代码揭示了 YUV420P 到JSAMPIMAGE的映射本质。buf[0]是 Y 分量的行指针数组,buf[1]是 Cb,buf[2]是 Cr。jpeg_write_raw_data()内部会按max_v_samp_factor(通常为 2)取buf[0][i],buf[0][i+1],buf[1][i/2],buf[2][i/2]组成一个 MCU(Minimum Coded Unit)。因此,buf[1]和buf[2]的行数必须是buf[0]的一半,否则 libjpeg 会越界读取。

参数说明:cinfo->image_width / 2是 Cb/Cr 的每行字节数(因水平下采样)。若你的 YUV 是 YUV422P(Cb/Cr 行数 = height,每行字节数 = width/2),则此处要改为cinfo->image_height,且max_v_samp_factor需手动设为 1(见 3.4)。

3.3read_yuv_line():逐行读取的边界安全实践

YUV420P 文件是纯二进制流,无 header。read_yuv_line()必须严格按max_v_samp_factor控制读取节奏:

int read_yuv_line(FILE *f, JSAMPIMAGE buffer, jpeg_compress_struct *cinfo) { int row, i; int lines_per_call = cinfo->max_v_samp_factor; // 通常为 2 // 1. 读取 Y 分量:lines_per_call 行 for (row = 0; row < lines_per_call; row++) { if (fread(buffer[0][cinfo->next_scanline + row], sizeof(unsigned char), cinfo->image_width, f) != cinfo->image_width) { return -1; } } // 2. 读取 Cb 分量:1 行(因 lines_per_call=2,Cb/Cr 每 2 行 Y 对应 1 行) if (fread(buffer[1][cinfo->next_scanline / 2], sizeof(unsigned char), cinfo->image_width / 2, f) != cinfo->image_width / 2) { return -1; } // 3. 读取 Cr 分量:1 行 if (fread(buffer[2][cinfo->next_scanline / 2], sizeof(unsigned char), cinfo->image_width / 2, f) != cinfo->image_width / 2) { return -1; } cinfo->next_scanline += lines_per_call; return 0; }

逻辑说明:cinfo->next_scanline是 libjpeg 内部计数器,表示“已写入多少行 Y”。cinfo->next_scanline / 2就是当前要写的 Cb/Cr 行索引。这个除法是整数除法,天然适配 YUV420P 的 2:1 下采样。若你传入的 YUV 是 NV12(交错式 CbCr),此函数必须重写——这也是本项目不支持 NV12 的根本原因(需额外 memcpy 拆分)。

参数说明:fread(..., cinfo->image_width, f)读 Y 行,fread(..., cinfo->image_width / 2, f)读 Cb/Cr 行。若width不是 2 的倍数(如 641),width/2会截断,导致 Cb/Cr 数据错位。生产环境务必确保 width/height 为偶数,这是 YUV420P 的硬性要求。

3.4set_sampling_factors():自定义采样因子的隐藏开关

libjpeg 默认对JCS_YCbCr使用H=2,V=2(Y)、H=1,V=1(Cb)、H=1,V=1(Cr)的采样,即标准 YUV420P。但某些传感器输出 YUV422P(H=2,V=1),或你需要强制H=1,V=1(全采样,体积翻倍但保真度高),这时需手动设置:

// 在 write_jpeg_file() 中 jpeg_set_defaults() 之后插入: cinfo.comp_info[0].h_samp_factor = 2; // Y cinfo.comp_info[0].v_samp_factor = 1; // Y cinfo.comp_info[1].h_samp_factor = 1; // Cb cinfo.comp_info[1].v_samp_factor = 1; // Cb cinfo.comp_info[2].h_samp_factor = 1; // Cr cinfo.comp_info[2].v_samp_factor = 1; // Cr cinfo.max_h_samp_factor = 2; cinfo.max_v_samp_factor = 1; // 关键:影响 jpeg_write_raw_data() 的调用频率

注意:修改采样因子后,alloc_yuv_buffer()中 Cb/Cr 的行数分配、read_yuv_line()中的读取逻辑都必须同步调整。这是一个典型的“牵一发而动全身”的配置点,也是新手最容易翻车的地方。

3.5add_exif_metadata():向 JPEG 注入 EXIF 的最小可行方案

本项目默认不写 EXIF,但工业场景常需记录时间戳、设备 ID。libjpeg 自带jpeg_write_marker(),可写 APPn 段。EXIF 是 APP1 段,结构复杂,但我们可以注入最简字段:

void add_exif_timestamp(struct jpeg_compress_struct *cinfo, const char *timestamp) { // EXIF APP1 marker header: 2 bytes (0xFF, 0xE1), 2 bytes length, "Exif\0\0" unsigned char exif_header[] = {0xFF, 0xE1, 0x00, 0x2A, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00}; unsigned char exif_data[256]; int len = snprintf((char*)exif_data, sizeof(exif_data)-1, "TS:%s", timestamp); jpeg_write_marker(cinfo, JPEG_APP0 + 1, exif_header, sizeof(exif_header)); jpeg_write_marker(cinfo, JPEG_APP0 + 1, exif_data, len); }

逻辑说明:jpeg_write_marker()可在jpeg_start_compress()之后、jpeg_write_raw_data()之前调用。JPEG_APP0 + 1即 APP1。此例写入TS:2024-05-20T10:30:00Z,可用exiftool test.jpg验证。真实 EXIF 需 TIFF 结构,但很多嵌入式解析器只认 APP1 中的 ASCII 字符串,此方案足够实用。


4. 避坑指南:YUV→JPG 转换中 4 个血泪经验总结

YUV 数据看似简单,实则是图像处理里最易出玄学问题的环节。下面列出我在某跨平台 IPC 设备项目中踩过的坑,每一个都附带现场gdb截图和解决命令,绝非纸上谈兵。

4.1 现象:输出 JPG 全黑或大面积绿色块

原因:YUV 数据格式与代码假设不匹配。本项目硬编码为 YUV420P(I420),但你的摄像头输出可能是 NV12(交错式 CbCr)、YV12(Cb/Cr 平面顺序颠倒)、或 YUYV(packed)。yuv2jpg会把 NV12 的 CbCr 交错数据当成独立平面读取,导致色度数据完全错乱。
解决:先用ffplay -f v4l2 -video_size 640x480 -pixel_format nv12 /dev/video0确认真实格式;若为 NV12,需在read_yuv_line()中添加拆分逻辑,或用ffmpeg -i input.nv12 -pix_fmt yuv420p output.yuv转换。

4.2 现象:Segmentation fault (core dumped)发生在jpeg_write_raw_data()

原因:alloc_yuv_buffer()中buf[1]或buf[2]的行指针数组未正确分配,或read_yuv_line()中fread读取字节数不足,导致jpeg_write_raw_data()访问野指针。常见于width或height传入奇数值(如 641x481),使width/2截断,Cb/Cr 行内存分配不足。
解决:在write_jpeg_file()开头添加校验:

if (width % 2 != 0 || height % 2 != 0) { fprintf(stderr, "Error: width and height must be even for YUV420P\n"); return -1; }

4.3 现象:输出 JPG 尺寸正确,但人像肤色发青/发紫

原因:cinfo.in_color_space被误设为JCS_RGB或JCS_GRAYSCALE,或jpeg_set_defaults()被跳过,导致 libjpeg 内部用错误的量化表/霍夫曼表处理 YUV 数据。
解决:用identify -verbose out.jpg | grep -A5 "Colorspace"确认色彩空间为YCbCr;在write_jpeg_file()中,cinfo.in_color_space = JCS_YCbCr必须在jpeg_set_defaults()之前设置,且不能被后续代码覆盖。

4.4 现象:make报错undefined reference to 'jpeg_std_error'

原因:链接时未找到 libjpeg 库,或pkg-config --libs jpeg返回空。常见于 Alpine Linux(musl libc)或自定义 toolchain 环境,pkg-config未配置jpeg.pc。
解决:手动指定库路径:

make LDFLAGS="-L/usr/lib -ljpeg" CFLAGS="-I/usr/include" # 或查找 libjpeg.so 位置:find /usr -name "libjpeg.so*" 2>/dev/null

5. 二次开发实战:从单帧转换到实时流式编码的改造路径

当你已经跑通./yuv2jpg test.yuv out.jpg 640 480 95,下一步往往是将其嵌入真实系统。我以某工业相机 SDK 的集成需求为例,说明如何将这个 demo 升级为可量产的模块。核心目标:接收 V4L2 的struct v4l2_buffer,实时编码为 JPEG,通过 socket 发送,延迟 < 100ms。

5.1 改造点一:从文件 IO 到内存 buffer 直接接入

yuv2jpg.c当前从文件读取,但 V4L2 提供的是内存地址buf.m.userptr。改造write_jpeg_file(),增加write_jpeg_from_memory()重载:

// 新增函数:直接从内存 buffer 编码 int write_jpeg_from_memory(unsigned char *yuv_data, const char *jpg_filename, int width, int height, int quality) { // ... 初始化 cinfo, jerr ... // 替换文件操作为内存操作 FILE *output_file = fopen(jpg_filename, "wb"); jpeg_stdio_dest(&cinfo, output_file); jpeg_start_compress(&cinfo, TRUE); JSAMPIMAGE yuv_buffer = alloc_yuv_buffer(&cinfo); // 关键:不再 fread,而是 memcpy memcpy(yuv_buffer[0][0], yuv_data, width * height); // Y memcpy(yuv_buffer[1][0], yuv_data + width * height, (width/2)*(height/2)); // Cb memcpy(yuv_buffer[2][0], yuv_data + width * height + (width/2)*(height/2), (width/2)*(height/2)); // Cr // 一次性写入全部行(因内存已就绪) jpeg_write_raw_data(&cinfo, yuv_buffer, cinfo.max_v_samp_factor); jpeg_finish_compress(&cinfo); fclose(output_file); free_yuv_buffer(yuv_buffer); jpeg_destroy_compress(&cinfo); return 0; }

改造说明:memcpy替代fread,消除了磁盘 IO 瓶颈。注意 YUV420P 的内存布局必须与alloc_yuv_buffer()的分配一致。若 V4L2 返回的是V4L2_PIX_FMT_NV12,则需先用nv12_to_i420()函数转换(网上有成熟实现)。

5.2 改造点二:支持回调式编码,避免阻塞主线程

实时系统中,jpeg_finish_compress()可能耗时 10–50ms(取决于 quality 和 CPU),不能在 V4L2read()回调中直接调用。解决方案:生产者-消费者队列 + 独立编码线程。

// 全局环形缓冲区(简化版) #define MAX_FRAMES 10 typedef struct { unsigned char *data; int width, height, quality; uint64_t timestamp; } frame_t; frame_t frame_queue[MAX_FRAMES]; int queue_head = 0, queue_tail = 0; // 生产者:V4L2 回调中调用 void on_v4l2_frame(unsigned char *yuv, int w, int h, int q) { int next = (queue_head + 1) % MAX_FRAMES; if (next != queue_tail) { // 队列未满 frame_queue[queue_head].data = yuv; // 注意:yuv 内存需由 caller 管理生命周期 frame_queue[queue_head].width = w; frame_queue[queue_head].height = h; frame_queue[queue_head].quality = q; frame_queue[queue_head].timestamp = get_monotonic_time(); queue_head = next; } } // 消费者:独立线程中循环 void *encoder_thread(void *arg) { while (running) { if (queue_tail != queue_head) { // 队列非空 frame_t *f = &frame_queue[queue_tail]; char filename[64]; snprintf(filename, sizeof(filename), "frame_%llu.jpg", f->timestamp); write_jpeg_from_memory(f->data, filename, f->width, f->height, f->quality); // 此处可替换为 sendto(socket, ...) 发送 JPEG 二进制流 queue_tail = (queue_tail + 1) % MAX_FRAMES; } else { usleep(1000); // 1ms 休眠,降低 CPU 占用 } } return NULL; }

改造说明:此模式将编码与采集解耦。on_v4l2_frame()执行时间 < 10us(纯指针赋值),完全不影响 V4L2 的帧率。编码线程可设置sched_setscheduler()为 SCHED_FIFO,保证实时性。

5.3 改造点三:动态 quality 调节,实现码率自适应

固定quality=95在网络带宽波动时会导致卡顿。我们根据上一帧编码耗时与目标延迟(如 50ms)动态调节:

// 全局变量 static int current_quality = 95; static uint64_t target_delay_us = 50000; // 50ms void adjust_quality_based_on_time(uint64_t encode_us) { if (encode_us > target_delay_us * 1.2) { current_quality = max(30, current_quality - 5); // 降质 } else if (encode_us < target_delay_us * 0.8) { current_quality = min(95, current_quality + 3); // 升质 } } // 在 encoder_thread 中编码后调用 uint64_t start = get_time_us(); write_jpeg_from_memory(..., current_quality); uint64_t end = get_time_us(); adjust_quality_based_on_time(end - start);

改造说明:这是一个极简的 PID 控制思想。current_quality作为状态变量,在多线程中需加锁(pthread_mutex_t),但因更新频率低(每帧一次),锁争用可忽略。

5.4 改造点四:集成到 C++ 工程,暴露 C++ 接口

若你的主工程是 C++,需防止 name mangling:

// yuv2jpg_wrapper.h extern "C" { int write_jpeg_from_memory(unsigned char *yuv_data, const char *jpg_filename, int width, int height, int quality); } // C++ 类封装 class Yuv2JpegEncoder { private: int m_quality; public: Yuv2JpegEncoder(int quality = 95) : m_quality(quality) {} int encode(const uint8_t* yuv, int w, int h, const std::string& path) { return write_jpeg_from_memory(const_cast<uint8_t*>(yuv), path.c_str(), w, h, m_quality); } };

改造说明:extern "C"是 C++ 调用 C 函数的必备语法。const_cast仅用于兼容,实际write_jpeg_from_memory()不修改yuv_data。

从那以后我每次集成图像编码模块,都强制走一遍「YUV 格式确认 → 内存布局验证 → 单帧文件输出 → 内存 buffer 接入 → 多线程压力测试」这五步。少走任何一步,都会在联调时花 3 天去 debug 一个fread的字节数错误。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进