简介:基于libjpeg实现YUV转JPEG的C语言示例包,面向视频编解码、图像采集及嵌入式开发工程师,用于解决YUV原始帧数据快速压缩为JPEG图片的实际问题,适合有一定C语言基础、希望绕过繁琐官方文档直接查看编码实现的读者。资源以yuvtojpeg.c为主程序,演示libjpeg的完整编码链路:创建压缩对象、设置输出文件或内存缓冲区、完成YUV到RGB色彩矩阵换算,再调用jpeg_write_scanlines逐行写入扫描线,最后以jpeg_finish_compress收尾并释放对象。压缩包精简为8个文件、约463KB,除核心源码外还提供libjpeg.a静态库,以及jpeglib.h、jconfig.h、jerror.h、jmorecfg.h等接口与配置头文件,覆盖错误处理、宏配置与默认采样参数,配合libjpeg.la可直接参与本地链接、编译运行与调试。已有1230人学习过这份代码包,适合希望快速上手JPEG编码器或排查转换细节的中级开发者,也常用作课程设计与项目实践的参考。通过示例代码不仅能看清YUV与RGB空间变换、压缩质量参数对输出大小和清晰度的影响,还能理解libjpeg内部缓冲区管理与错误处理方式,稍加改动即可复用到视频帧提取、监控抓图等真实业务场景。
1. 基于libjpeg 的 yuv 转成 jpg:为什么这步操作卡住了不少人
做了多年的图像采集,有个绕不过去的操作:把相机或视频流吐出来的 YUV 帧落成 JPG。很多人觉得简单,直接叫 libjpeg 一压就行,结果第一次在 YUV 上翻车——颜色花、尺寸歪、内存崩,网上搜“libjpeg yuv转成jpg”大多只给了半段示例,照着敲完还是一头雾水。这份工程其实不复杂:它把 YUV420/NV12 的原始帧,通过 libjpeg 的 raw_data_in 通道直接编码成 JPG 文件,避开中间转 RGB 的额外开销,代码里已经把尺寸对齐、采样格式、质量参数都封装好了。适合刚接手视频采集的嵌入式工程师,也适合要临时把一帧 YUV 存成图片的桌面开发者。
2. YUV 布局与 libjpeg 编码前提:先分清 I420 和 NV12,否则全是无头苍蝇
2.1 三种常见 YUV 布局与对齐陷阱
YUV 在相机、编码器、图像库里出现的格式远比想象中多,先把最常见的三种摆上台面。I420(也叫 YUV420p)是最标准的平面格式,一帧数据依次为亮度平面 Y、蓝色差平面 U、红色差平面 V,三个平面连续存放;NV12 则是 Y 平面后跟一个交错的 UV 平面,每两个像素共用一对色度;I422 的色度在水平方向不降采样,垂直方向保持完整,占用比 420 多三分之一。它们的字节数差异很大,直接决定了后面分配缓冲区的大小。
| 格式 | 平面布局 | 一帧字节数(宽 W 高 H) | 常用场景 |
|---|---|---|---|
| I420 | Y | U | V 三分平面 |
| NV12 | Y | UV 交错 | W×H × 3 / 2 |
| I422 | Y | U | V,色度仅水平降采样 |
对齐是一个容易忽略的硬门槛。YUV 文件里每个平面按行连续存放,但行的字节数不一定等于图像宽度——底层 DMA 或硬件编码器经常把每一行对齐到 16、32 或 64 字节。举个例子,一张 1920 宽的画面如果 32 字节对齐,行字节数刚好是 1920;但若是 1922 宽,行字节数就会变成 1936,多出来的 14 字节是填充。读文件时按宽度读、按宽度移动指针,后面的像素全都会错位,出来的 JPG 就是歪的。所以一开始就要把「图像宽高」和「内存 stride」分开记,凡是和行偏移相关的计算一律用 stride。
2.2 libjpeg 编码时究竟怎么处理 YUV
libjpeg 内部的目标色空间是 JCS_YCbCr,也就是 JPEG 标准的编码空间。平时最常见的用法是给它喂 RGB,它会自己完成 RGB 到 YCbCr 的转换;但当我们手上本来就是 YUV 数据时,再走 RGB 这条路就是白白浪费 CPU,还会引入一次有损的颜色转换。正确做法是让 libjpeg 进入原始数据模式,这个开关就是 jpeg_compress_struct 里的 raw_data_in 字段,置为 TRUE 后,压缩器不再做颜色转换,直接读取 Y、Cb、Cr 三个分量。
JPEG 压缩是按 8×8 的 DCT 块进行的,采样格式决定了每次能喂多少行。4:2:0 采样下,亮度一个 MCU(最小编码单元)在垂直方向占 16 行,两个色度平面各占 8 行;4:2:2 下亮度占 8 行。这意味着调用 jpeg_write_raw_data 时必须攒够一个 MCU 高度的数据再写一次,不像普通的 jpeg_write_scanlines 那样一行一行写。这个特性让一些人以为 libjpeg 不接受 YUV,其实是没弄对调用节奏。
主要字段对应关系如下:
| 字段 | 值 | 含义 |
|---|---|---|
| image_width / image_height | 像素宽高 | 必须是原始帧尺寸,不是 stride |
| input_components | 3 | Y、Cb、Cr 三个分量 |
| in_color_space | JCS_YCbCr | 告诉压缩器输入已经是 YCbCr |
| raw_data_in | TRUE | 跳过颜色转换,直接写原始分量 |
2.3 为什么不能假装它是 RGB
最常看到的一个误用是:in_color_space 保持默认的 JCS_RGB,然后直接把 YUV 数据按顺序丢进 jpeg_write_scanlines。表面上看程序没报错,但输出 JPG 会非常怪——因为 YUV 的第一个字节是亮度,第二个字节是色差,libjpeg 会把它读成 RGB 的 R 分量,又做一次 RGB 到 YCbCr 的转换,本质上等于对完全无关的数据做了两遍变换。结果是色彩完全错乱,画面偏绿或者偏紫,而且这类 bug 非常难通过看代码发现,因为宽高都对,数据量也对,问题出在语义上。
判断是否走了错误的颜色转换,有个简单办法:查看压缩后 JPG 的文件大小。YUV420 按 4:2:0 编码,压缩率和直接 RGB 编码有明显差距;如果一帧 1080p 的 YUV420 压出来的 JPG 偏大,多半是 libjpeg 把数据当成了 RGB,编码时把 Y、U、V 交错解释成了三个颜色分量。要避免这个问题,记住一个原则:凡是从摄像头或解码器直接拿到的 YUV,一律走 raw_data_in 通道;只有拿到的确实是 RGB24,才用默认的 RGB 路径。
3. 两条编码路径怎么选:raw_data_in 原始通道与 turbojpeg 封装
3.1 jpeg_write_raw_data:自己搬砖但控制力最强
纯 libjpeg 的 raw 通道适合对内存布局有完全控制权的场景。整个调用链是:创建压缩对象 → 设置宽高和色空间 → jpeg_set_defaults 拉默认参数 → jpeg_set_quality 设质量 → 打开压缩 → 循环写 MCU 数据 → 收尾。关键点在于最后的一步,先看一个最小可运行的片段:
#include <stdio.h> #include <jpeglib.h> int minimal_raw_compress(unsigned char *y, unsigned char *u, unsigned char *v, int width, int height, int quality, const char *out_path) { FILE *out = fopen(out_path, "wb"); if (!out) return -1; struct jpeg_compress_struct cinfo; struct jpeg_error_mgr jerr; cinfo.err = jpeg_std_error(&jerr); jpeg_create_compress(&cinfo); jpeg_stdio_dest(&cinfo, out); cinfo.image_width = width; cinfo.image_height = height; cinfo.input_components = 3; cinfo.in_color_space = JCS_YCbCr; jpeg_set_defaults(&cinfo); jpeg_set_quality(&cinfo, quality, TRUE); /* 关键:必须在 set_defaults 之后再覆盖为 TRUE */ cinfo.raw_data_in = TRUE; jpeg_start_compress(&cinfo, TRUE); int mcu_height = 16; /* 4:2:0 采样时一个 MCU 块的高度 */ JSAMPIMAGE planes = malloc(sizeof(JSAMPROW) * 3); while (cinfo.next_scanline < cinfo.image_height) { /* 根据当前扫描行,从 y/u/v 中取 mcu_height 行指针 */ planes[0] = &y[cinfo.next_scanline * width]; planes[1] = &u[(cinfo.next_scanline / 2) * (width / 2)]; planes[2] = &v[(cinfo.next_scanline / 2) * (width / 2)]; jpeg_write_raw_data(&cinfo, planes, mcu_height); } jpeg_finish_compress(&cinfo); jpeg_destroy_compress(&cinfo); fclose(out); free(planes); return 0; }代码里最需要注意的其实是两处。第一处是 jpeg_set_defaults 和 raw_data_in 的先后顺序,jpeg_set_defaults 会把 raw_data_in 重置为 FALSE,所以先调用 defaults、再手动置 TRUE 才有效,顺序颠倒等于白设。第二处是 planes 数组的构造,JSAMPIMAGE 是 JSAMPROW 的数组,JSAMPROW 又是一行像素的指针,在 raw 模式下它必须指向 Y、U、V 三个平面的整行起始位置;每次循环的 next_scanline 表示已经处理了多少亮度行,色度平面因为垂直降采样,行号要除以 2。
3.2 turbojpeg 封装:一行函数解决 80% 需求
如果你的环境允许引入 libjpeg 的加速分支 turbojpeg,YUV 转 JPG 会简单得多。turbojpeg 把 MCU 分块、stride 补偿、平面指针构造全部封装进了 tjCompressFromYUV,一个函数就能完成压缩。这里以 NV12 输入为例子:
#include <turbojpeg.h> int nv12_to_jpg(unsigned char *nv12, int width, int height, int quality, const char *out_path) { tjhandle handle = tjInitCompress(); if (!handle) return -1; unsigned long jpeg_size = 0; unsigned char *jpeg_buf = NULL; /* NV12 每行亮度 pitch 等于 width,总大小是 w*h*3/2 */ int pitch = width; if (tjCompressFromYUV(handle, nv12, width, pitch, height, TJPF_NV12, &jpeg_buf, &jpeg_size, TJSS_420, quality, TJFLAG_FASTDCT) != 0) { tjDestroy(handle); return -1; } FILE *out = fopen(out_path, "wb"); if (!out) return -1; fwrite(jpeg_buf, 1, jpeg_size, out); fclose(out); tjFree(jpeg_buf); tjDestroy(handle); return 0; }tjCompressFromYUV 的参数列表看着长,实际拆开就三类:输入缓冲和尺寸、输出缓冲、编码选项。第二个参数是 YUV 数据指针,第三个是宽度,第四个是每行 pitch——这里传 width 是因为 NV12 亮度行长和宽度相等;第五个是高度。TJPF_NV12 表明输入是 NV12 布局,TJSS_420 是目标采样格式。quality 和纯 libjpeg 的语义一致,范围 1 到 100。TJFLAG_FASTDCT 是加速开关,牺牲一点点压缩率换取编码速度,嵌入式上通常值得开。
用它最大的好处是不用自己管理 MCU 行缓冲,也不用管 U、V 平面的指针偏移,适合快速把功能跑通。但代价是可控性下降:如果想在编码前精确控制每一行的 stride,turbojpeg 接口给 pitch 参数留了口子,却不再暴露 DCT 表和采样内部细节。
3.3 输出目标:jpeg_stdio_dest 与 tjCompressToFile
压缩完成后数据落在哪里,两条路径各有写法。纯 libjpeg 用 jpeg_stdio_dest 绑定一个已经打开的 FILE*,之后 jpeg_start_compress 把编码结果不断写入这个句柄;turbojpeg 则可以直接用 tjCompressToFile 省掉手动 fwrite。实际工程里我更常用前面那种,因为输出文件往往还要叠加 EXIF、权限设置等额外处理,FILE* 接口更灵活。
两种写文件的代码模式对比:
| 方式 | 核心接口 | 适合场景 |
|---|---|---|
| 纯 libjpeg | jpeg_stdio_dest + jpeg_start_compress | 需要自定义表、需要精确控制输出过程 |
| turbojpeg | tjCompressToFile | 快速实现、批量转换、对速度敏感 |
无论哪种方式,都要记得在压缩完成后关闭文件句柄。特别提醒一句:纯 libjpeg 路径下 jpeg_finish_compress 必须在 fclose 之前调用,否则尾部数据可能没刷进文件,得到一个能打开但缺尾巴的 JPG。反过来,turbojpeg 路径则是先 fwrite 完再从 handle 释放,顺序反了也会有类似问题。
4. 落地实战:I420 转 JPG 的完整函数与参数解释
4.1 从 YUV 文件到平面缓存:别忘了文件可能带填充
真实工程项目里,YUV 帧不一定来自一个干净的内存段,很多是从文件读入的。读取时要考虑到文件内部可能存在的行填充,先看一个带 stride 处理的读取套路:
static unsigned char *read_yuv420(const char *path, int width, int height, int *out_size) { FILE *fp = fopen(path, "rb"); if (!fp) return NULL; int stride = ((width + 31) / 32) * 32; /* 32 字节对齐 */ int y_size = stride * height; int uv_size = (stride / 2) * (height / 2); *out_size = y_size + uv_size * 2; unsigned char *buf = malloc(*out_size); if (!buf) return NULL; if (fread(buf, 1, *out_size, fp) != (size_t)*out_size) { free(buf); fclose(fp); return NULL; } fclose(fp); return buf; }这段代码里 stride 的计算用的是常见的 32 字节对齐,实际对齐数应与采集设备保持一致,有的硬件是 16,有的是 64。关键点在于 y_size 和 uv_size 都用 stride 计算,而不是 width。如果文件确实没有填充,stride 等于 width,结果不受影响;如果文件有填充而代码按 width 分配,fread 会读不满或者多读,后续压缩读取时内存越界就等着崩溃吧。
4.2 构造带 stride 偏移的 MCU 平面指针
I420 的原始数据读进来后,Y、U、V 三块是连续排列的。通过 buf 基址加偏移就可以拿到各平面首地址:Y 从 0 开始,U 在 y_size 处,V 在 y_size + uv_size 处。压缩循环里每次构造 MCU 行指针时,行偏移要用 stride 而不是 width,否则图像宽度和内存步长不一致,输出画面会出现左移或斜切现象。
static JSAMPIMAGE build_mcu_planes(unsigned char *buf, int stride, int width, int height, int scanline) { int y_size = stride * height; int uv_size = (stride / 2) * (height / 2); unsigned char *y = buf; unsigned char *u = buf + y_size; unsigned char *v = buf + y_size + uv_size; JSAMPIMAGE planes = malloc(sizeof(JSAMPROW) * 3); planes[0] = malloc(sizeof(JSAMPROW) * 16); planes[1] = malloc(sizeof(JSAMPROW) * 8); planes[2] = malloc(sizeof(JSAMPROW) * 8); for (int i = 0; i < 16; i++) { planes[0][i] = y + (scanline + i) * stride; } for (int i = 0; i < 8; i++) { planes[1][i] = u + ((scanline + i * 2) / 2) * (stride / 2); planes[2][i] = v + ((scanline + i * 2) / 2) * (stride / 2); } return planes; }注意这里构造指针时亮度行逐行递增,色度行则按行号除以 2 取整:因为在 4:2:0 中每两行亮度对应一行色度。当 scanline 是奇数时,色度行的起始边界并不在 MCU 边界上,但这不影响正确性,因为 JPEG 编码要求每个 MCU 的对齐是相对于图像原点,而不是相对于当前 scanline。
4.3 完整压缩循环:把 MCU 高度和 next_scanline 咬合
有了平面指针构造函数,主循环就很清晰了。每轮循环向 jpeg_write_raw_data 喂一个 MCU 高度的数据,它会自己更新 next_scanline。循环退出的条件就是 next_scanline 到达图像高度。如果图像高度不是 16 的倍数,最后一块数据会不足 16 行,libjpeg 会自行补足,这一点由内部逻辑保证,不需要手动填零:
int yuv420_file_to_jpg(const char *yuv_path, const char *jpg_path, int width, int height, int quality) { int file_size = 0; unsigned char *raw = read_yuv420(yuv_path, width, height, &file_size); if (!raw) return -1; FILE *out = fopen(jpg_path, "wb"); if (!out) { free(raw); return -1; } struct jpeg_compress_struct cinfo; struct jpeg_error_mgr jerr; cinfo.err = jpeg_std_error(&jerr); jpeg_create_compress(&cinfo); jpeg_stdio_dest(&cinfo, out); cinfo.image_width = width; cinfo.image_height = height; cinfo.input_components = 3; cinfo.in_color_space = JCS_YCbCr; jpeg_set_defaults(&cinfo); jpeg_set_quality(&cinfo, quality, TRUE); cinfo.raw_data_in = TRUE; jpeg_start_compress(&cinfo, TRUE); while (cinfo.next_scanline < cinfo.image_height) { JSAMPIMAGE planes = build_mcu_planes(raw, stride, width, height, cinfo.next_scanline); jpeg_write_raw_data(&cinfo, planes, 16); /* 释放本轮分配的平面指针数组 */ } jpeg_finish_compress(&cinfo); jpeg_destroy_compress(&cinfo); fclose(out); free(raw); return 0; }循环体的设计有一个很实用的原则:不要在循环外一次性构建所有平面的行指针,因为 JPG 编码按 MCU 行进,行指针数组每轮都要重建。每次循环后检查 cinfo.next_scanline 是否增长,如果循环卡死,先排查是不是 raw_data_in 在 set_defaults 之后又被重置了;如果是这个原因,读到的 next_scanline 会一直保持 0,程序进入死循环。
4.4 分辨率与 quality 参数怎么设:不是拍脑袋的事
width 和 height 必须和实际画面尺寸完全一致,这点没有商量余地。很多摄像头输出是 1920×1080,但传感器裁切后可能变成 1920×1076,这类非标准高度在 4:2:0 下最后几行尤其容易出问题。quality 参数直接影响文件大小和画质,JPEG 的 quality 在 libjpeg 里是一个系数表系数,不是简单的百分比。工程上我一般摄影像类图片用 90,普通监控截图用 75,超过 95 的文件体积暴涨但视觉增益很小,低于 60 开始出现明显的块效应。
| quality 区间 | 视觉表现 | 典型用途 |
|---|---|---|
| 90–95 | 肉眼几乎无损 | 取证截图、医疗影像、原始图备份 |
| 75–85 | 轻微压缩痕迹 | 监控抓拍、即时分享 |
| 50–65 | 可见块状噪声 | 缩略图、临时缓存 |
另外,jpeg_set_quality 的第三个参数 force_baseline 设为 TRUE 时,会限制量化表使输出兼容所有 JPEG 解码器;设为 FALSE 则允许更激进的量化表,文件更小但老设备可能解不出来。我在需要兼容多端播放时固定用 TRUE,吃不准的场景也在 TRUE,稳妥比省那一点空间重要。
5. 避坑:YUV 转 JPG 最容易翻车的四个细节与修复
5.1 现象:输出图片整体发绿或者发紫
原因:in_color_space 没有设置成 JCS_YCbCr,默认按 JCS_RGB 处理。YUV 数据被当成 RGB 读入,随后 libjpeg 又执行了一次 RGB 到 YCbCr 的转换,两次错误叠加导致 Hue 完全错乱。
解决:把 cinfo.in_color_space = JCS_YCbCr 放在 jpeg_set_defaults 之前或之后都行,只要在 jpeg_start_compress 前生效即可;同时确认 cinfo.input_components = 3。如果是 NV12 等交错格式,还要确认使用的是对应 turbojpeg 输入格式,TJPF_NV12 不能错填成 TJPF_YUV420。
5.2 现象:JPG 能打开,但画面倾斜或明显错位
原因:读文件时按 width 计算行偏移,忽略了 YUV 文件内部的行填充。如果图像宽度不是对齐基数的整数倍,后半帧每一行都比预期偏移几个字节,最终呈现为人眼可见的斜切。
解决:为输入文件单独计算 stride,所有行偏移用 stride 代替 width。建议在读取函数里加一段自检:读取完成后比对实际文件大小与按 stride 算出的理论大小,不一致时直接报错,能提前拦截大部分文件格式异常。
5.3 现象:raw_data_in 明明设了 TRUE 却不生效,程序卡在循环里
原因:jpeg_set_defaults 会重新初始化 raw_data_in,把它置回 FALSE。如果先设置 raw_data_in=TRUE 再调用 jpeg_set_defaults,最后生效的值就是 FALSE,jpeg_write_raw_data 行为异常,next_scanline 不推进导致死循环。
解决:记住固定顺序:先 jpeg_set_defaults,后 cinfo.raw_data_in = TRUE。最好在代码注释里标清楚这条不要乱调。这也是我踩过最深的一个坑,一度以为是 libjpeg 版本问题,最后单步调试才发现是赋值顺序作祟。
5.4 现象:颜色正常但整体发白或者发灰,对比度不对
原因:YUV 数据的取值范围不一致。视频采集设备常见 TV range(亮度 16–235,色度 16–240),而 JPEG 内部假定全部范围 0–255。输入是 TV range 却按 0–255 处理,黑色不黑、白色不白,画面整体灰蒙蒙的。
解决:在进入编码前把 YUV 从 TV range 拉伸到 0–255,或者另一种方法是写一个归一化表在读取时映射。拉伸公式按 YUV 到 RGB 的系数方向还原即可,注意 U、V 需要做 128 中心偏置后再缩放,直接线性缩放会导致色偏反过来加重。
6. 收尾技巧:用 PSNR 给转换结果照个 X 光,防患未然
代码移植到新平台或新版本 libjpeg 后,光看输出图觉得“好像没问题”是不够的,肉眼对暗部细节和整体色偏的敏感度波动很大。我用一个简单的 PSNR(峰值信噪比)自检流程,把所有 YUV 转 JPG 的改动都先压上一测,数值说话比感觉可靠得多。做法是把 YUV 帧用写好的转换函数压成 JPG,再用 libjpeg 的 JPEG 解码路径读回 YUV,和原始 YUV 逐像素比较——正常情况下 JPG 有损压缩后 PSNR 应该落在 38–45 dB 之间,低于 35 dB 就要怀疑颜色空间或采样设置出了问题。
import numpy as np def psnr(orig_y, decoded_y): orig_y = orig_y.astype(np.float32) decoded_y = decoded_y.astype(np.float32) mse = np.mean((orig_y - decoded_y) ** 2) if mse == 0: return 99.0 return 10.0 * np.log10((255.0 ** 2) / mse) # 读入 Y 平面数据后调用即可 # 连续测试 10 帧,如果各帧 PSNR 波动超过 1.5 dB,优先查 YUV 源文件的行填充写这个自检代码时有个习惯沿用了很久:只比较亮度平面 Y,不比较色度平面 U、V。原因很简单,不同解码器对色度抽样的位置处理有差异,CbCr 的微小差别不代表编码错误,而亮度差异直接反映 DCT 和量化路径是否正常。测试时固定质量参数 85,连续跑 10 帧以上,PSNR 保持稳定在 40 dB 左右,才敢继续调其他参数。
从那以后,我每次更换采集设备或升级图像库版本,都会强制走一遍这个 PSNR 自检流程,先确认 YUV 源正常,再调质量参数,最后才做视觉效果微调。这套流程帮我在一台输出 stride 异常的采集板上提前抓到了错位问题,省掉了一次现场排查的折腾。希望帮到你。
本文还有配套的精品资源,点击获取