做压缩算法评估的同学,对“率失真曲线图”肯定不陌生。横轴是码率,纵轴是失真或质量,散点是编码器在不同配置下的实测工作点,干这行的人每天都得盯着它看。但真正画过的人都知道,直接拿这些散点连线,出来的图基本没法用——不是锯齿明显,就是曲线回弹,甚至出现码率高了画质反而变差的荒谬结果。如果再加上多条算法对比、不同码率范围参差不齐、还要导出印刷级矢量图,那这事儿就得好好梳理一遍了。
这篇文章把我自己在Matlab和Python两套环境下的完整绘图经验写出来,重点解决三个问题:散点怎么连才平滑、多条曲线怎么对比才公平、参数怎么选才能保证“既好看又不失真”。内容适合正在写论文、做汇报、准备技术评审的同行参考,基础是中等偏上,但我会把每一步的原理和代码都写透,确保拿来就能用。
1. 率失真曲线的本质需求与常见误区
1.1 率失真曲线到底在表达什么
率失真曲线的理论源头是信息论里的率失真函数,描述的是在给定失真上限的条件下,信源压缩所需的最小码率。落到工程实践里,我们通常不是算那条理想边界,而是拿一套编码器在不同量化参数下跑实验,得到一组码率和质量配对的工作点。质量指标常用PSNR、SSIM,码率就是输出比特流的大小除以时长。
举个例子,某实验室的A同学做视频编码优化,他设定的量化参数从20到45,每5取一个档位,就能得到6个(码率,PSNR)点。把这6个点画出来,用一条平滑曲线连起来,就是这条编码配置的率失真曲线。横轴可以是kbps,纵轴是dB,曲线越靠左上,说明用更小的码率换到了更高的质量,算法性能越好。
这个图的用途很广:
- 横向对比多个编码方案,看谁在同等码率下质量更高;
- 验证优化方向是否有效,比如改进后的曲线是否整体左移;
- 为后面的BD-rate计算提供数据基础。
但恰恰是“把点连成曲线”这个动作,最容易翻车。
1.2 为什么直接连线会翻车
很多同学拿到数据后第一件事就是用plot把点连起来,然后发现结果惨不忍睹。原因主要有三个。
第一,工作点太少。编码实验通常只跑5到8个QP点,直接线性连接得到的是折线,看起来像心电图,完全没有科研论文里那种丝滑的曲线感。如果你用三次样条硬插,点太少还会出现剧烈的过冲振荡。
第二,数据不单调。率失真理论保证最优编码器下,质量应该随码率单调递增,但实测数据因为编码器实现、内容特性、统计误差等原因,经常出现局部抖动。直接连接会导致曲线来回扭曲,审稿人一眼就能看出问题。
第三,横坐标的分布不均匀。码率在低QP段可能跳得很快,比如从200跳到800,而在高QP段则挤在一起。这种非均匀分布会让线性插值在曲率变化大的地方严重失真。
1.3 插值、拟合和平滑,三件事别混为一谈
画率失真曲线时,有一个基本功必须清楚:插值、拟合和平滑是完全不同的三件事,混用是新手最常见的错误。
- 插值:让曲线精确穿过每一个已知点,不改变原始数据的位置。常用PCHIP、Cubic Spline。适合工作点本身可靠、误差小的情况。
- 拟合:用一个带参数的模型去逼近数据,不要求过点,比如多项式拟合、指数拟合。适合数据噪声大、需要提炼趋势的场景。
- 平滑:对已有序列做滤波,比如移动平均、Savitzky-Golay、LOWESS。它的目标是去掉局部抖动,保留整体形状。
率失真曲线图的正确打开方式一般是用“插值”,而不是拟合或平滑。因为你的工作点是编码器实测出来的,本身就代表真实性能,不能随意篡改数值;但相邻点之间可以用单调插值补出平滑的形状。只有当你的数据是从大批随机样本统计得到、点位严重噪声时,才考虑平滑处理。
2. 数据准备与坐标体系的处理逻辑
2.1 实验数据怎么整理最省心
科研绘图最费时间的永远不是画图本身,而是数据整理。率失真曲线的输入数据一般长这样:
| 配置 | 量化参数 | 码率(kbps) | PSNR(dB) |
|---|---|---|---|
| 方案A | 45 | 152 | 31.26 |
| 方案A | 40 | 318 | 34.41 |
| 方案A | 35 | 561 | 36.84 |
| 方案A | 30 | 932 | 38.67 |
| 方案A | 25 | 1680 | 40.23 |
| 方案A | 20 | 2860 | 41.35 |
我自己惯用的方式是导出一份CSV,列分别是method, qp, rate, metric。然后在Matlab或Python里按方法分组,再按rate排序。不要直接在画图代码里硬编码数据,因为一张图里通常有多条曲线,硬编码改起来非常痛苦。
排序问题尤其要留意。如果不排序,直接传给插值函数,结果会是一团乱麻。因为插值函数默认横坐标是升序的,而实验日志里的顺序往往是按配置顺序记录的,不一定和码率大小一致。
2.2 横轴要不要取对数
率失真曲线的横轴经常跨度非常大。比如码率从150kbps到3000kbps,差了20倍,如果直接用线性坐标,低码率端的点会被压缩到左侧一小块区域,曲线形状根本看不清楚。
这种时候通常有两种选择:
- 横轴取对数:
semilogx在Matlab里一行搞定,Python里ax.set_xscale('log'); - 纵轴取对数:通常不需要,因为PSNR的单位是dB,本身就是对数域的量;
- 双对数:只在极少见的长尾数据里使用,常规RD曲线不推荐。
我的建议是,只要码率跨度超过一个数量级,就优先考虑横轴取对数。而且取对数之后,插值也应该在对数域里进行,或者至少意识到:垂直方向上的差距在不同横轴位置上的视觉权重会变化。实际操作中,把码率取log后再插值,曲线会更平滑,因为码率轴变得均匀了。
2.3 排序、去重与异常点剔除
数据预处理有三个简单但关键的步骤,顺序不能乱。
第一步,按横轴升序排序。Python里用np.argsort(rate)拿到索引,Matlab里用sort(rate)。排序之后,后面的插值函数才可能正常工作。
第二步,去重。如果两个工作点码率相同但PSNR不同,插值函数会报错或者产生NaN。这种情况虽然少见,但在多路并行实验汇总时会出现。简单做法是保留平均值,或者保留更优的那个点。
第三步,剔除肉眼可见的异常点。比如码率突然从800跳到2200而PSNR只涨了0.1dB,这类点大多是实验配置出错所致,留着会让曲线在局部出现明显凹陷。你可以用差分比值定位异常:
delta_psnr / delta_rate如果这个比值突然掉到周围数值的十分之一以下,就要检查原始日志了。
3. Matlab实现:从散点到平滑曲线的完整方案
3.1 基础版本:PCHIP插值画单条曲线
Matlab里画率失真曲线,最顺手的方式是用pchip做插值。PCHIP全称是分段三次Hermite插值多项式,它的核心优势是“保单调”:只要你的原始数据是单调的,插值结果就不会出现多余的振荡。这一点对率失真曲线特别重要,因为理论上码率增加,质量不应下降。
先上代码:
% 原始工作点数据 rate = [152, 318, 561, 932, 1680, 2860]; psnr = [31.26, 34.41, 36.84, 38.67, 40.23, 41.35]; % 按横轴排序 [rate_sorted, idx] = sort(rate); psnr_sorted = psnr(idx); % 生成密集的横坐标点 rate_fine = linspace(min(rate_sorted), max(rate_sorted), 500); % PCHIP插值 psnr_fine = pchip(rate_sorted, psnr_sorted, rate_fine); % 绘图 figure('Color', 'w'); plot(rate_fine, psnr_fine, '-', 'LineWidth', 2, 'Color', [0 0.4470 0.7410]); hold on; plot(rate_sorted, psnr_sorted, 'o', 'MarkerSize', 8, ... 'MarkerFaceColor', [0 0.4470 0.7410], 'MarkerEdgeColor', 'w'); xlabel('Bitrate (kbps)', 'FontSize', 12); ylabel('PSNR (dB)', 'FontSize', 12); grid on; legend('方案A', '实测点', 'Location', 'southeast');这里有一点值得展开:为什么用linspace生成500个点而不是直接用原始点连折线?因为插值的目的是让曲线在视觉上平滑连续,500个点在绝大多数屏幕上已经足够密,再多也没意义,反而让文件体积变大。如果后续要做BD-rate计算,这500个点可以作为积分基础,精度已经够用。
PCHIP的“保单调”特性不是空话。你可以拿同样的数据去跑spline,会发现spline曲线在码率900到1600之间容易出现一个明显的波浪,看起来似乎编码器在某段码率上性能“回退”了,而这实际是插值方法带来的假象。
3.2 进阶:多条曲线对比与工作点标注
真实论文里很少只画一条曲线,至少是三五种方案叠在一起。Matlab的多曲线绘图要特别注意两个方面:颜色和图例。
我推荐用Matlab默认的lines色图,但不直接调用,而是手动指定一组高对比且适合打印的颜色。比如:
method_colors = [0 0.4470 0.7410; 0.8500 0.3250 0.0980; ... 0.4940 0.1840 0.5560; 0.4660 0.6740 0.1880]; marker_styles = {'o', 's', 'd', '^'};这样四条曲线排出来,不至于出现两条深蓝和两条浅蓝分不清的情况。每条曲线的工作点用不同marker标注,曲线本身用实线不同颜色,配合图例放在右下角或右上角。图例位置用Location指定后,还要检查是否遮挡了曲线,遮挡时可以改用'Location', 'best'或手动调整。
对于工作点标注,我建议只标marker,不标数值。数值标上去会让图非常拥挤,审稿时看不清。如果确实需要展示具体工作点数值,可以用表格放在正文或者附录里,图面保持简洁。
多曲线对比时还有一个细节:各条曲线的横坐标范围可能不一致。比如方案A的码率范围是150到2800,方案B只跑到2200,如果直接全部绘制,方案B的曲线会在右侧缺一大截,看上去对比不充分。处理方式是,在同一个图里,所有曲线只绘制公共码率区间内的部分。比如找到所有方法的最小码率最大值和最大码率最小值,把它们作为统一的横轴边界。
Mtlab里可以用xlim配合intersect逻辑实现,但最简单的做法是先对每条曲线各自插值到密集点,再统一用xlim([common_min, common_max])截断显示范围。
3.3 Matlab里平滑参数怎么选
如果实验数据噪声比较大,PCHIP插值出来的曲线仍会有点“毛”。这时候可以用moothdata系列函数。Matlab提供了smoothdata,支持的平滑方法包括'movmean'、'lowess'、'sgolay'等。
实际经验是:
- 只缺几个毛刺时,用
'sgolay',窗口长度取5或7,注意窗口必须是奇数; - 曲线整体抖动幅度较大时,用
'lowess',span取0.1到0.25之间,比例太大容易把真实起伏也削平; - 只在视觉上“圆润”一下,用
'movmean',窗口取9到15。
但这里必须提醒:平滑会把原始工作点的位置改变,如果一场图里同时展示实测散点和平滑曲线,两者会错开,不懂的读者会以为数据对不上。所以我的习惯是:只对插值后的密集点做适当平滑,而原始工作点永远保持原样绘制。这样既保留实测数据的权威性,又让曲线看起来更舒服。
4. Python实现:Matplotlib + SciPy的等效方案
4.1 核心代码:PCHIP插值加散点叠加
Python生态里的等效方案是基于Matplotlib绘图,加上SciPy里的PchipInterpolator。代码写出来和Matlab很像,但有一些细节值得注意。
import numpy as np import matplotlib.pyplot as plt from scipy.interpolate import PchipInterpolator # 原始工作点数据 rate = np.array([152, 318, 561, 932, 1680, 2860]) psnr = np.array([31.26, 34.41, 36.84, 38.67, 40.23, 41.35]) # 排序 order = np.argsort(rate) rate_sorted = rate[order] psnr_sorted = psnr[order] # PCHIP插值 pchip = PchipInterpolator(rate_sorted, psnr_sorted) rate_fine = np.linspace(rate_sorted.min(), rate_sorted.max(), 500) psnr_fine = pchip(rate_fine) # 绘图 fig, ax = plt.subplots(figsize=(7, 5)) ax.plot(rate_fine, psnr_fine, linewidth=2, color='C0', label='方案A') ax.plot(rate_sorted, psnr_sorted, 'o', markersize=7, color='C0', markeredgecolor='white', markeredgewidth=1.2, label='实测点') ax.set_xlabel('Bitrate (kbps)') ax.set_ylabel('PSNR (dB)') ax.grid(True, linestyle='--', alpha=0.6) ax.legend(loc='lower right') plt.tight_layout() plt.show()这里有一个和Matlab不同的地方:Matlab的pchip直接接受原始点数组返回插值结果,而Python需要先创建PchipInterpolator对象再调用。本质上都是分段三次Hermite插值,单调性保证是一致的。
如果实际数据和Matlab一致,那两条曲线的结果应该完全重合,只是渲染风格略有区别。这说明方案之间可以无缝迁移。
4.2 用LOWESS处理噪声偏大的实测数据
当你的工作点来自多次实验的平均值、或者参数扫描不是严格对齐时,PCHIP也不一定稳。更合适的方案是LOWESS局部加权回归。它比多项式拟合灵活,不需要预设模型,还能保留局部趋势。
statsmodels里直接有实现:
from statsmodels.nonparametric.smoothers_lowess import lowess # 对排序后的数据做LOWESS平滑 smoothed = lowess(psnr_sorted, rate_sorted, frac=0.4) rate_smooth = smoothed[:, 0] psnr_smooth = smoothed[:, 1] plt.plot(rate_smooth, psnr_smooth, linewidth=2, label='LOWESS') plt.plot(rate_sorted, psnr_sorted, 'o', markersize=7, label='实测点')frac参数是核心,它控制每个局部窗口覆盖多少比例的数据点。frac越大,曲线越光滑,但容易丢失细节;frac越小,曲线越贴合原始数据,但噪声也越大。我的建议是先从0.4起步,如果你有7到10个工作点,这个比例大概让每个窗口覆盖3到4个点,属于比较均衡的选择。
LOWESS和PCHIP的取舍在于:PCHIP是插值,必须经过每个点;LOWESS是平滑,不要求过点。如果你的实验点每个都是确定性的编码结果,用PCHIP;如果每个数据本身带有明显统计波动,用LOWESS。
4.3 Savitzky-Golay滤波的使用时机
还有一种常用的平滑手段是Savitzky-Golay滤波,用scipy.signal.savgol_filter实现。它的优点是能够在平滑的同时保持峰谷形状,不会像移动平均那样把拐角都抹平。
但用SG滤波有一个隐藏坑:它要求输入是等间距的序列。而你的实测码率点几乎不可能等间距,所以不能直接对原始散点做SG滤波。正确做法是先PCHIP插值到等间距密集点,再用savgol_filter做平滑。
from scipy.signal import savgol_filter # 先插值到等间距点 pchip = PchipInterpolator(rate_sorted, psnr_sorted) rate_dense = np.linspace(rate_sorted.min(), rate_sorted.max(), 300) psnr_dense = pchip(rate_dense) # 再对插值序列做SG平滑 psnr_sg = savgol_filter(psnr_dense, window_length=15, polyorder=3) plt.plot(rate_dense, psnr_dense, alpha=0.4, label='PCHIP') plt.plot(rate_dense, psnr_sg, linewidth=2, label='PCHIP+SG')window_length和polyorder的选择逻辑:窗口长度越大越平滑,但太长会削掉真实弯曲;多项式阶数通常取2到3,阶数太高反而会跟着噪声一起抖动。对于率失真曲线这种总体单调、局部轻微弯曲的形状,我实测window_length=15、polyorder=3的组合效果很好。如果你只有5到6个原始点,窗口长度可以缩到9,因为插值密集点本身已经比较平滑。
最后提一个细节:无论用哪种平滑,都要注意“插值→平滑→绘图”的链条里,排序必须在最开始完成。否则横坐标乱序会让后续所有步骤失真。
5. 实操中跑不掉的坑与排查清单
5.1 插值后曲线出现非单调回弹
这个现象我用图表形式总结一下,方便随手排查:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 曲线局部出现凹坑,码率增加PSNR反而下降 | 用spline插值,过冲振荡 | 换成PCHIP插值 |
| 曲线整体太平滑,现场数据对不上 | 平滑过度,工作点位置被篡改 | 只做插值,对散点平滑参数调小 |
| 曲线起始端剧烈弯曲 | 端点数据点较少,插值自由度大 | 增加起始端工作点,或用LOWESS截断处理 |
| 多条曲线右端长短不一 | 各方案码率范围不一致 | 取公共区间再对比 |
| 横轴取log后曲线变得奇怪 | 插值在线性域进行,横轴却被log显示 | 先对log(rate)做插值再转回 |
非单调回弹是论文返修的高频理由。你有没有想过,为什么审稿人一眼就能看出曲线不对劲?因为在率失真框架下,任何“码率增加、质量下降”都是违背理论最优性的表现,曲线一旦回弹,说明你的对比方案存在本质上无法解释的退化。
假如你确实遇到非单调的原始数据怎么办?两个选择:
- 如果你确信是某个异常点,剔掉它,并保证你有原始日志作为依据;
- 如果这个现象反复出现,说明编码器在某些配置下出现了异常降级,这本身就是值得写成问题的发现,不要用平滑去掩盖。
5.2 对比曲线如何保证公平
多方案对比最常见的不公平是横坐标范围不一致。比如方案A标称最小码率100kbps,方案B标称300kbps,那方案A曲线在左侧多出来一段,视觉上似乎“性能更好”。实际上这可能只是方案B没有跑更极端的配置而已。
我的做法是先计算公共区间:
common_min = max(np.min(rate_a), np.min(rate_b), np.min(rate_c)) common_max = min(np.max(rate_a), np.max(rate_b), np.max(rate_c)) ax.set_xlim(common_min, common_max)然后用numpy.interp或PCHIP把各条曲线重采样到公共区间内的同一组横坐标上,这样后续做面积对比、BD-rate计算时才是公平的。
还有一个容易踩的坑:如果方案A的实测点明显比方案B多,A的曲线辨识度更高,读者会下意识觉得A更可靠。解决方式是统一marker大小和线条宽度,甚至可以用不同线型来降低点密度差异造成的影响。
5.3 导出矢量图与字号设置
论文级别的科研图,优先导出矢量格式。Matlab里用:
exportgraphics(gcf, 'rd_curve.pdf', 'ContentType', 'vector');Python里用:
fig.savefig('rd_curve.pdf', format='pdf', dpi=600, bbox_inches='tight')只要涉及PDF或SVG,图里的文字就会保留为矢量信息,放大到多大都不虚。位图的话PNG至少要300dpi,600dpi更稳妥。
字号方面,我的默认习惯是:图里正文文本不小于9pt,坐标轴标签不小于11pt。很多期刊要求图内文字在缩放后不低于6pt,所以如果你事先不确定缩放比例,宁可设得大一点。字太小是审稿意见里非常常见的一条,而且改起来特别烦。
配色上,黑白打印的论文不要用太浅的颜色。像黄色的曲线在灰度打印后会变成浅灰色,几乎看不到。我一般用深蓝、深红、深绿、紫色这四类,既能满足彩色阅读,灰度打印也分得清。
6. 个人实操体会与扩展建议
画率失真曲线这件事,看起来是“随手一画”的小工作,实际做起来恰恰是最锤炼基本功的地方。我自己踩过几次坑之后,现在的流程基本固定为:原始数据CSV化、排序去重、PCHIP插值、多方案公共区间对齐、导出PDF矢量图。这套流程在好几个模拟项目和跨平台系统里都验证过,效果稳妥。
有一个小技巧顺便分享:当你需要把30张以上的RD曲线批量生成时,不要每张图都手动调参数。把颜色、线宽、marker、图例字体封装成一个绘图配置函数,改一处全程生效。我在某次对比实验里就是用这个思路,把之前两天的出图工作量压缩到了半小时。
后续如果你想进一步量化算法性能,插值后的密集点已经为BD-rate计算铺好了路。计算两条曲线之间的面积差,或者用分段积分求平均码率节省比例,都可以直接在这些密集点上做数值积分,误差会比用原始稀疏点小很多。我自己常用的做法是在公共区间内对两条曲线的差值做梯形积分,代码不超过十行。等哪天需要细讲BD-rate的数值计算,我再开一篇单独展开。