简介:用于用OpenCV实现Photoshop曲线调整算法的完整C++源码包,面向具备一定C++基础、正在学习OpenCV图像处理或需要为程序加入曲线调色功能的开发者。压缩包共4个文件,其中2个cpp分别负责曲线算法核心与示例演示,1个hpp声明Curves类接口,1张jpg作为测试图像,整体大小仅113KB,轻量易用。目前已有263人学习下载。资源围绕Photoshop曲线调整这一高频图像处理需求,提供了可复用的完整实现,代码结构清晰、集成方便,适合图像增强、美颜算法、照片编辑工具等开发场景。通过阅读源码,读者能够理解曲线调整的数学映射原理,学习如何用查找表或分段插值实现阴影、中间调、高光的独立控制,并快速将算法移植到自己的OpenCV工程中。
1. Photoshop 的曲线调整,本质是一张查找表,OpenCV 是生成它的最佳载体
Photoshop 里的“曲线调整”看起来是个交互工具:拖动几个控制点,亮度、对比度和色调就跟着变。剥开交互外壳,曲线调整的输出其实是一份映射表:把输入亮度 0~255 映射到新的亮度 0~255。因为像素值范围固定,所以这张表可以预先算好,运行时只做一次批量替换。OpenCV 的cv::LUT天生就是干这个的,这让 C++ 实现曲线调整比想象中简单得多:控制点插值生成查找表,再用 LUT 映射整个图像。与其到处找现成滤镜源码,不如自己写一个可复用的曲线调整组件,把插值、通道策略和掩膜混合都握在自己手里。下面按工程实现顺序走一遍这条路,新手可以直接抄代码,熟手可以重点看插值选型和浮点边界。
2. 曲线调整的数学骨架:用 C++ 从控制点生成 256 项查找表
2.1 控制点插值与曲线调整的三种选型
Photoshop 曲线面板上,你给出的其实是一组离散控制点,比如(0, 0)、(96, 128)、(255, 255)。要得到任意输入亮度对应的输出亮度,必须在这几个点之间做插值。C++ 实现里最常碰到的选型有三种:线性插值、Catmull-Rom 样条、单调三次 Hermite(PCHIP)。
| 插值方式 | 计算量 | 曲线平滑度 | 是否存在过冲 | 实现成本 |
|---|---|---|---|---|
| 线性插值 | 最低 | 控制点处有折角 | 否 | 最低 |
| Catmull-Rom 样条 | 低 | 平滑连续 | 是 | 中等 |
| 单调三次 Hermite | 中等 | 平滑连续 | 否 | 中等偏高 |
Photoshop 默认曲线的观感介于线性和样条之间,但不会让曲线明显超出相邻控制点形成的区间,所以最接近的其实是 PCHIP。不过第一版实现我建议先用线性插值,逻辑简单,出问题一眼能看出来。等交互工具需要“曲线手感”了,再替换成样条不迟。
2.2 线性插值生成查找表:最小可用的 C++ 代码
下面的函数把控制点数组转换成 256 项查找表。控制点用cv::Point2d,输入坐标和输出坐标都限制在 0~255 之间。
#include <vector> #include <algorithm> #include <opencv2/core.hpp> // points: 曲线控制点,x 与 y 都在 [0, 255] // 返回 1x256 的 CV_8U 查找表 cv::Mat generateLUT(const std::vector<cv::Point2d>& points) { cv::Mat lut(1, 256, CV_8U); uchar* ptr = lut.ptr<uchar>(); if (points.empty()) { for (int i = 0; i < 256; ++i) ptr[i] = (uchar)i; return lut; } std::vector<cv::Point2d> pts = points; std::sort(pts.begin(), pts.end(), [](const cv::Point2d& a, const cv::Point2d& b) { return a.x < b.x; }); // 第一个控制点之前,输出保持第一个点的 y 值 int firstX = std::clamp(cvRound(pts.front().x), 0, 255); for (int x = 0; x < firstX; ++x) { ptr[x] = cv::saturate_cast<uchar>(pts.front().y); } // 相邻控制点之间做线性插值 for (size_t i = 0; i + 1 < pts.size(); ++i) { double x1 = pts[i].x, y1 = pts[i].y; double x2 = pts[i + 1].x, y2 = pts[i + 1].y; int start = cvRound(x1); int end = cvRound(x2); if (end <= start) continue; for (int x = start; x <= end; ++x) { double t = (x - x1) / (x2 - x1); double value = (1.0 - t) * y1 + t * y2; ptr[x] = cv::saturate_cast<uchar>(value); } } // 最后一个控制点之后,输出保持最后一个点的 y 值 int lastX = std::clamp(cvRound(pts.back().x), 0, 255); for (int x = lastX; x < 256; ++x) { ptr[x] = cv::saturate_cast<uchar>(pts.back().y); } return lut; }代码先对控制点按 x 排序,避免调用方乱序传入导致查表结果错乱。首尾补段逻辑很关键:如果控制点只落在 64 到 192 之间,那么 0~63 的输出会等于第一个点的 y 值,193~255 会等于最后一个点的 y 值,这正好对应 Photoshop 里控制点之外的区域保持端点输出不动的行为。saturate_cast比直接static_cast<uchar>更安全,浮点越界时会自动截断到 0 或 255,不会产生负数回绕。
线性插值有个容易被忽略的坑:当两个控制点 x 坐标非常接近且落在同一像素整数位时,区间长度会变成 0。上面的if (end <= start) continue;就是为这个加的,否则除零会出现非法浮点值。
2.3 样条平滑与防过冲的取舍
想要更接近 Photoshop 的平滑曲线,常见做法是换 Catmull-Rom 样条。它的核心是一段三次多项式:对每个控制点区间,用前后相邻的两个点推算出端点切线,再做插值。
// t 在 [0,1] 之间,p0~p3 是相邻 4 个控制点的 y 值 double catmullRom(double t, double p0, double p1, double p2, double p3) { double t2 = t * t; double t3 = t2 * t; return 0.5 * (2.0 * p1 + (-p0 + p2) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); }把当前区间的两个端点设为p1、p2,再把前一个点取为p0、后一个点取为p3,对区间内每个输入亮度计算归一化坐标t就可以得到平滑输出。代价是可能过冲:如果p1是 100、p2是 200,但p0只有 0,起点附近的曲线可能会冲到 120,肉眼表现为暗部被无故提亮。解决过冲的办法是改用 PCHIP,它会保证曲线在每个区间的值都不超过两端控制点给出的值域。实际工程里,如果只是做离线调色滤镜,PCHIP 是性价比最高的选择;做交互式拖动控制点的预览工具,则先用 Catmull-Rom 加一个 overshoot clamp 更合理。
3. 用 OpenCV 的 LUT 应用曲线调整:写成一个可复用的 C++ 滤镜
3.1 为什么是cv::LUT而不是 for 循环
查找表生成后,下一步是把映射作用到图像上。最容易想到的写法是两层 for 循环,遍历每个像素然后取ptr[x]替换。但对 1920×1080 的彩色图,这意味着 600 多万次内存访问,性能取决于编译器能不能自动向量化,很难保证稳定性。OpenCV 的cv::LUT在内部做了并行处理,对小尺寸查找表还有特殊优化,三通道图一次性映射完,性能通常比手写循环高一截,而且代码更短。OpenCV 文档里明确写了 8 位输入的 LUT 是专门优化过的路径,所以曲线调整这类 256 项查表操作应该优先用它。
这里有个容易踩的坑:cv::LUT要求查找表和输入图像匹配。单通道CV_8U图配 1×256 查找表没问题;三通道图可以直接配同样 1×256 的表,意思是三个通道共用同一张表。但如果你希望 R、G、B 各用各的曲线,就必须给cv::LUT传入一张 1×256 的三通道查找表,否则结果不是报错,而是三个通道全部套同一张表。
3.2 完整可编译的 CurveFilter 类
把上一节的 LUT 生成和映射封装成一个类,调用方只需要关心控制点。代码可以直接存成.cpp编译。
#include <opencv2/core.hpp> #include <opencv2/imgproc.hpp> #include <vector> #include <algorithm> class CurveFilter { public: // 用一组控制点生成曲线,并保存为查找表 void setCurve(const std::vector<cv::Point2d>& points) { lut_ = generateLUT(points); } // 直接设置外部准备好的查找表 void setLUT(const cv::Mat& lut) { CV_Assert(lut.type() == CV_8U && lut.total() == 256); lut_ = lut; } // 应用曲线调整,支持 8 位单通道、三通道以及浮点图 cv::Mat apply(const cv::Mat& src) const { cv::Mat dst; if (src.empty()) return dst; if (src.type() == CV_8UC1) { cv::LUT(src, lut_, dst); } else if (src.type() == CV_8UC3) { // 三通道共用一条曲线时,直接把一张表扩展为三通道 cv::Mat lut3; cv::merge(std::vector<cv::Mat>{lut_, lut_, lut_}, lut3); cv::LUT(src, lut3, dst); } else { // 浮点图先缩放到 0~255,映射后再还原 cv::Mat tmp; src.convertTo(tmp, CV_8U, 255.0); cv::LUT(tmp, lut_, dst); dst.convertTo(dst, src.type(), 1.0 / 255.0); } return dst; } private: cv::Mat lut_; static cv::Mat generateLUT(const std::vector<cv::Point2d>& points) { cv::Mat lut(1, 256, CV_8U); uchar* ptr = lut.ptr<uchar>(); if (points.empty()) { for (int i = 0; i < 256; ++i) ptr[i] = (uchar)i; return lut; } std::vector<cv::Point2d> pts = points; std::sort(pts.begin(), pts.end(), [](const cv::Point2d& a, const cv::Point2d& b) { return a.x < b.x; }); int firstX = std::clamp(cvRound(pts.front().x), 0, 255); for (int x = 0; x < firstX; ++x) { ptr[x] = cv::saturate_cast<uchar>(pts.front().y); } for (size_t i = 0; i + 1 < pts.size(); ++i) { double x1 = pts[i].x, y1 = pts[i].y; double x2 = pts[i + 1].x, y2 = pts[i + 1].y; int start = cvRound(x1); int end = cvRound(x2); if (end <= start) continue; for (int x = start; x <= end; ++x) { double t = (x - x1) / (x2 - x1); ptr[x] = cv::saturate_cast<uchar>((1.0 - t) * y1 + t * y2); } } int lastX = std::clamp(cvRound(pts.back().x), 0, 255); for (int x = lastX; x < 256; ++x) { ptr[x] = cv::saturate_cast<uchar>(pts.back().y); } return lut; } };调用方不需要关心查找表大小和 type,setCurve内部会处理。apply函数特别处理了浮点图,因为cv::LUT直接接受CV_32F输入时会抛异常。先把浮点图乘 255 转成 8 位,查完表再除回来,虽然会有量化损失,但对曲线调整这种色调映射场景足够。如果真要高精度,就得自己用浮点控制点做逐像素映射,避免 8 位查表的信息丢失。
3.3 编译、调用与参数表
把下面的调用代码追加到类后面,编译运行:
g++ -std=c++17 -O2 curve_demo.cpp -o curve_demo \ $(pkg-config --cflags --libs opencv4)调用端非常短,一组控制点定义一个曲线形态:
cv::Mat img = cv::imread("input.jpg"); CurveFilter cf; cf.setCurve({{0, 0}, {64, 40}, {128, 128}, {192, 216}, {255, 255}}); cv::Mat out = cf.apply(img); cv::imwrite("output.jpg", out);上面 5 个控制点是一个典型的 S 形曲线:阴影被压暗,高光被提亮,中间调保持不变,整体对比度增强。参数含义如下:
| 参数 | 类型 | 取值范围 | 说明 |
|---|---|---|---|
points | std::vector<cv::Point2d> | x、y ∈ [0,255] | 定义曲线形状,x 必须递增 |
lut_ | cv::Mat | CV_8U,1×256 | 输入亮度到输出亮度的映射表 |
src | cv::Mat | CV_8UC1/CV_8UC3/ 浮点 | 输入图像,三通道默认共用同一曲线 |
lut3 | cv::Mat | CV_8UC3,1×256 | 三通道不同曲线时必须组成这种结构 |
这套结构对 OpenCV 版本几乎无依赖,从 OpenCV 3 到 OpenCV 4 都能直接用。拿到压缩包源码后,优先确认里面generateLUT的首尾补段逻辑,很多网上流传的版本只做中间插值,导致首尾控制点不在 0 和 255 时边缘像素被错误置零。
提示:
cv::LUT的三通道查找表逻辑是“每个通道各取对应行/列”,所以你想让 R 通道用一条曲线、G 通道用另一条,只需要把两个不同的 1×256 表和一张恒等表合并成CV_8UC3,不需要改动类结构。
4. 曲线调整的分通道与局部应用:从全局滤镜变成调色组件
4.1 RGB 通道曲线与明度曲线怎么选
曲线调整不只能改变灰度,还能做颜色分级。Photoshop 里曲线的通道下拉框提供了 RGB、红、绿、蓝四类。工程实现上要区分两种逻辑:直接改 RGB 三通道,和只改感知亮度。两种做法对色彩的影响差别很大。
| 调整目标 | 使用的色彩空间 | 主要效果 | 常见用途 |
|---|---|---|---|
| RGB 通道独立曲线 | 原始 BGR/RGB | 改变色相和饱和度,偏色明显 | 风格化调色、模拟胶片 |
| 感知亮度曲线 | Lab 或 HSV | 亮度改变,色相基本不变 | 提亮暗部、增强对比度 |
| 混合模式 | Lab L + RGB 通道 | 局部可控,效果复杂 | 人像精修、商业修图 |
4.2 用 split/merge 实现三通道独立曲线
如果要让红、绿、蓝三个通道分别使用不同曲线,最直接的方式是cv::split拆分通道,每通道单独套一个CurveFilter,最后再cv::merge合并。这种写法比构造三通道查找表更直观,也方便单独调试某个通道的曲线参数。
cv::Mat src = cv::imread("portrait.jpg"); CurveFilter cfR, cfG, cfB; cfR.setCurve({{0, 0}, {128, 140}, {255, 255}}); // 红通道中间调提亮,偏暖 cfG.setCurve({{0, 0}, {128, 120}, {255, 255}}); // 绿通道中间调压暗 cfB.setCurve({{0, 10}, {128, 128}, {255, 245}}); // 蓝通道暗部提亮 std::vector<cv::Mat> bgr; cv::split(src, bgr); bgr[0] = cfB.apply(bgr[0]); bgr[1] = cfG.apply(bgr[1]); bgr[2] = cfR.apply(bgr[2]); cv::Mat dst; cv::merge(bgr, dst);这段代码实现了一个冷暖分离调色:蓝通道暗部提亮,阴影偏冷;红通道中间调提亮,高光和肤色偏暖。参数上注意,单通道应用时CurveFilter::apply走的是CV_8UC1分支,性能比三通道合并后的整体 LUT 略低,但换来的是每个通道的曲线都能单独调试。如果最终效果固定,可以把三条曲线合并成一张三通道查找表,运行时只调一次cv::LUT,性能最优。
这里有个工程上容易犯的错:图是 BGR 存储时,cv::split出来的顺序是蓝、绿、红,不要下意识把第一个结果当成 R 通道。调色参数写反,成品图会偏色到完全没法看。
4.3 给曲线调整加掩膜:局部提亮不破坏整体
全局曲线调整在某些场景下会有副作用:提亮阴影时,背景天空也跟着变亮。解决办法是加掩膜,把曲线调整结果和原图按权重混合。OpenCV 里最常见的方法是先算出曲线调整结果,再用掩膜做 alpha blend,比直接逐像素if判断快得多。
cv::Mat src = cv::imread("portrait.jpg"); CurveFilter cf; cf.setCurve({{0, 20}, {128, 128}, {255, 255}}); // 只提亮阴影 cv::Mat curveImg = cf.apply(src); // 掩膜:白色表示完全用曲线调整结果,黑色保留原图 // 这里用阈值分割示例,实际可从深度学习分割模型得到 cv::Mat mask = cv::Mat::zeros(src.size(), CV_8UC1); cv::rectangle(mask, cv::Rect(100, 80, 600, 400), cv::Scalar(255), cv::FILLED); cv::Mat floatMask; mask.convertTo(floatMask, CV_32F, 1.0 / 255.0); std::vector<cv::Mat> curCh, srcCh, outCh; cv::split(curveImg, curCh); cv::split(src, srcCh); for (int i = 0; i < 3; ++i) { cv::Mat blended = curCh[i].mul(floatMask) + srcCh[i].mul(1.0 - floatMask); blended.convertTo(blended, CV_8U); outCh.push_back(blended); } cv::Mat result; cv::merge(outCh, result);floatMask的值域被缩放到了 0.0~1.0,mul是逐像素乘法,直接让每个通道按同一权重融合。掩膜边缘如果有锯齿,结果图和原图之间会出现生硬分界线。解决方式是在mask上做一次高斯模糊,让亮度在边缘平滑过渡,cv::GaussianBlur(mask, mask, cv::Size(31, 31), 0)是顺手就能加的细节。如果要实时预览曲线调整的局部效果,可以把掩膜分辨率缩到和图像一致,再用cv::resize做预处理,避免每帧都在高分辨率图上计算。
5. 曲线调整的三个验证技巧与浮点边界
5.1 用渐变图和直方图验证 LUT
曲线调整写完后,第一件事不是放到真实照片上,而是用一张从 0 到 255 线性渐变图验证。输入是线性渐变,输出应该严格等于查找表的值,如果中间出现断层或跳变,说明插值或边界处理有 bug。OpenCV 里用cv::Mat::zeros加cv::line就能构造渐变图,也可以用 Python 一行生成后转成 8 位图像。
python3 -c " import cv2, numpy as np img = np.arange(256, dtype=np.uint8).reshape(1, 256) lut = np.array([(i * 128 // 255) for i in range(256)], dtype=np.uint8) out = cv2.LUT(img, lut) print(out[0, :32]) "输出应该是 0 到 15 的递增值,如果发现连续多个相同值,说明查找表生成时区间计算存在问题。再用cv::calcHist对比原图和输出图的直方图,S 形曲线应该让直方图两端密度增加、中间略微下降,这比肉眼观察更严格。
5.2 浮点图和 16 位图的边界处理
cv::LUT对 8 位图的支持最完善,16 位和浮点图直接查表会出问题。常见做法是把浮点图缩放到 0~255 再查表,查完转回来。这种做法在暗部可能产生条带,因为浮点图的小数部分被截断了。更稳妥的方案是把查找表换成CV_32F,然后用cv::remap做映射,或者在浮点空间逐像素线性插值。曲线调整在 HDR 图像上时,建议直接把查找表设计成浮点版本,控制点数量多一点,避免 8 位量化影响渐变平滑度。
5.3 用线性曲线替代 addWeighted 做对比度调节
一个容易被忽略的技巧是:线性曲线本身就能替代cv::addWeighted做对比度和亮度调整。比如要提升对比度 20%,可以设控制点(0, -25)、(255, 280),生成 LUT 后再用saturate_cast截断越界部分,效果和addWeighted一样,但代码统一到曲线调整框架里,后续叠加其他曲线时不需要额外分支。验证时注意 LUT 两端必须超出 0~255,否则输出范围不够,对比度会被压缩而不是增强。拿到源码包后,先确认这份 LUT 是否从(0, y)和(255, y)之外的可调范围开始,再看浮点分支是否做了 scale 还原,这两处都通了,曲线调整组件才算真正可用。
本文还有配套的精品资源,点击获取