ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

OpenCvSharp滤镜实现:C#调整饱和度、对比度与锐化的完整指南

OpenCvSharp滤镜实现:C#调整饱和度、对比度与锐化的完整指南 简介一份面向C#开发者的OpenCvSharp图像滤镜Demo演示如何通过OpenCV的.NET封装实现饱和度、明度、对比度、锐化、阴影、高光及色温等常见图像处理效果。资源以完整Visual Studio解决方案形式提供包含核心窗体代码、界面设计文件和项目配置打开即可编译运行适合正在学习OpenCvSharp或需要快速实现图像特效的开发者参考。压缩包共72个文件主要包含11个C#源文件、9个DLL运行库、8张JPEG测试图片以及配置文件与构建产物整体大小35.6MB。源码将每种滤镜封装为独立操作并配有测试图片便于直观对比处理前后差异。目前已吸引386人学习下载可作为入门OpenCvSharp滤镜编程的实用样例尤其对理解色彩空间调整、边缘增强和色温补偿等底层逻辑有直接帮助。1. 用 OpenCvSharp 调出照片滤镜这个 Demo 到底解决了什么如果你下载过一个叫“OpenCvSharp Demo 饱和度、明度、对比度、锐化、阴影、高光、色温实现滤镜效果.rar”的压缩包八成是在找一套能直接落在 C# 工程里的图像调色方案。这个 Demo 用 OpenCvSharp 框架把 7 种后期滤镜做成了可执行示例核心价值是你不需要把图片丢进 PhotoShop 手动拉曲线也不用为了一个滤镜引入 Python 和 OpenCV而是在 WPF、WinForm 或控制台程序里用 C# 的语法直接调用 OpenCV 的算法把它变成产品功能。和很多人的第一印象相反OpenCV 里并没有一个叫“滤镜”的万能函数这 7 个效果本质是靠颜色空间转换、卷积和通道加权组合出来的。这篇文章会顺着这个标题把每个效果背后的数学拆开给你可直接抄的代码再说清参数调到什么值最容易翻车。适合想自己做批量调色工具、桌面端图片处理软件或者已经在用 OpenCvSharp 但只会读图写图的开发者。2. 先搞懂这几个参数在 OpenCvSharp 里的实际含义从像素到矩阵2.1 饱和度与明度HSV 空间里做文章饱和度不是用 RGB 直接算的。OpenCvSharp 默认读进来的图片是三通道 BGR每个通道取值 0 到 255。要调整某个像素“颜色浓不浓”人类直觉上是把红色变得更红、把蓝色变得更蓝但 RGB 三个通道是交织变化的直接乘通道系数容易让画面整体偏移甚至出现色罩。常见做法是把图片转成 HSV 颜色空间这里的 H 是色相、S 是饱和度、V 是明度三个分量相对独立。改变 S 通道就相当于往颜色里掺白或者掺色改变 V 通道就相当于给整个画面打灯或压暗。OpenCvSharp 里用Cv2.CvtColor就能把 BGR 转成 HSV。注意 OpenCV 的 H 范围是 0 到 180S 和 V 范围是 0 到 255和你在图像编辑软件里看到的百分比不一样。写这个 Demo 时最容易犯的错误是拿到一张 8 位三通道图直接把 S 通道值乘 1.5结果像过曝一样一片白。原因就是没做截断。正确写法是把 S 通道先转成浮点乘系数再用Cv2.Min或Cv2.Max夹到 0 到 255最后用.ConvertTo把精度转回字节。明度同理调整 V 通道比调整亮度更安全不会破坏色相和饱和度。using OpenCvSharp; public Mat AdjustSaturationAndBrightness(Mat src, double satFactor, double brightOffset) { if (src.Empty()) throw new OpenCvSharpException(输入图像为空); Mat hsv new Mat(); Cv2.CvtColor(src, hsv, ColorConversionCodes.BGR2HSV); // 分离通道HSV 是 3 通道的单图拆开改起来更直观 Mat[] hsvChannels Cv2.Split(hsv); Mat h hsvChannels[0]; Mat s hsvChannels[1]; Mat v hsvChannels[2]; // 饱和度浮点乘法结果越界会破坏后续颜色所以必须夹到 [0, 255] Mat sFloat new Mat(); s.ConvertTo(sFloat, MatType.CV_32FC1); Cv2.Multiply(sFloat, satFactor, sFloat); Cv2.Threshold(sFloat, sFloat, 255f, 255f, ThresholdTypes.Trunc); Cv2.Max(sFloat, 0, sFloat); Mat sFinal new Mat(); sFloat.ConvertTo(sFinal, MatType.CV_8UC1); // 明度偏移量可正可负用加偏置的方式比乘法更符合“提亮/压暗”的直觉 Mat vFloat new Mat(); v.ConvertTo(vFloat, MatType.CV_32FC1); Cv2.Add(vFloat, brightOffset, vFloat); Cv2.Threshold(vFloat, vFloat, 255f, 255f, ThresholdTypes.Trunc); Cv2.Max(vFloat, 0, vFloat); Mat vFinal new Mat(); vFloat.ConvertTo(vFinal, MatType.CV_8UC1); // 合并后转回 BGR交给上层显示或保存 Mat[] merged new Mat[] { h, sFinal, vFinal }; Mat hsvFinal new Mat(); Cv2.Merge(merged, hsvFinal); Mat dst new Mat(); Cv2.CvtColor(hsvFinal, dst, ColorConversionCodes.HSV2BGR); h.Dispose(); s.Dispose(); v.Dispose(); sFloat.Dispose(); vFloat.Dispose(); hsv.Dispose(); hsvFinal.Dispose(); return dst; }这段代码里satFactor是饱和度乘法系数0.0 得到纯灰度1.0 保持原样超过 1.0 就增强浓艳感brightOffset是明度偏移-255 到 255习惯上用 -50 到 50 之间调节。为什么不用 HSV 转回来导致颜色断层因为 8 位精度在多次转换时会有舍入误差所以中间计算我习惯转到CV_32F最后再转回 8 位。如果只是做实时预览可以保留浮点 Mat 多转几次但要是保存成 JPG建议最后再转否则会出现可见色阶。2.2 对比度与锐化线性变换和卷积核对比度调整在图像处理里属于像素级线性变换公式是dst (src - mean) * contrast mean brightness。OpenCvSharp 的Mat.ConvertTo正好提供了这种带缩放和偏置的通道映射。内部实现其实就是dst(x,y) src(x,y) * alpha beta但要注意这个公式是围绕 0 点做的直接对 8 位图做会产生死黑或者死白。常见做法是把图片转成CV_32F先减 128或者减均值乘系数再加 128。系数alpha 1时亮的地方更亮暗的地方更暗对比度就上来了alpha 1则画面发灰。锐化则完全是另一套逻辑。锐化的本质是突出边缘而边缘对应像素值的突变数学上可以用二阶差分来描述。OpenCV 里的Cv2.Filter2D允许你传入一个 3x3 卷积核最典型的锐化核是[0,-1,0; -1,5,-1; 0,-1,0]它相当于原图减去上下左右四个方向的拉普拉斯边缘。这个核中心权重的 5 不是随便定的核内所有权重之和应该是 1这样图像平均亮度不改变。如果把中心的 5 改成更大的值锐化会越来越重边缘会出现白色光晕改成小于 4核和不是 1图像整体变灰。这个就是后面避坑要说的“参数边界”。public Mat AdjustContrastAndSharpen(Mat src, double contrastAlpha, double brightnessBeta, int sharpenAmount) { // 对比度部分 Mat floatImg new Mat(); src.ConvertTo(floatImg, MatType.CV_32FC3); Mat contrastImg floatImg.Clone(); Cv2.Multiply(contrastImg, contrastAlpha, contrastImg); Cv2.Add(contrastImg, brightnessBeta, contrastImg); Mat contrast8U new Mat(); contrastImg.ConvertTo(contrast8U, MatType.CV_8UC3, 1.0, 0); // 锐化部分建议在原图的基础上叠加边缘方便控制强度 float[] kernelData new float[] { 0f, -1f, 0f, -1f, (4f sharpenAmount), -1f, 0f, -1f, 0f }; using Mat kernel new Mat(3, 3, MatType.CV_32F, kernelData); Mat sharpened new Mat(); Cv2.Filter2D(contrast8U, sharpened, contrast8U.Type(), kernel); floatImg.Dispose(); contrastImg.Dispose(); contrast8U.Dispose(); return sharpened; }这段代码把对比度参数contrastAlpha的典型取值范围放在 0.5 到 2.0 之间1.0 表示不变brightnessBeta取值范围在 -80 到 80 之间通常配合对比度一起用防止整体被提亮后头发等暗部失去层次。sharpenAmount是锐化强度我一般控制在 0 到 2 之间超过 2 就开始出现白边。代码用了using Mat kernel因为卷积核 Mat 如果不释放也会占内存。需要注意的是Filter2D的边界处理默认是反射也就是BorderTypes.Default如果你想要黑边效果可以显式传BorderTypes.Constant但做滤镜一般用默认即可。2.3 阴影、高光和色温没有现成 API 时的组合拳OpenCV 里没有AdjustShadow或者AdjustHighlight这样的函数所以这俩效果在很多 Demo 里其实是自己拼出来的。阴影对应一张图片里亮度值比较低的区域高光对应亮度值比较高的区域。做法是先对原图转灰度然后用灰度值作为权重生成一个阴影掩膜或高光掩膜。阴影提亮的操作可以理解为对整张图做一次均匀提亮再把这个提亮的效果乘上阴影掩膜这样只有暗部被提亮亮部几乎不变。高光压暗同理只是掩膜要取反。色温调整就更加直白它本质是改变 R、G、B 三个通道的增益。色温偏暖意味着红色通道增强、蓝色通道减弱色温偏冷则反过来。这里有个容易踩坑的地方通道增强后数值超过 255 的部分会被Cv2.ConvertTo直接截断导致高光区变成纯白色死白一片。所以处理色温时要先对浮点图做线性缩放再做软裁剪。3. 把 Demo 跑起来OpenCvSharp 环境搭建与项目加载3.1 选对 NuGet 包OpenCvSharp4 和 OpenCvSharp4.Windows 的区别开箱要用 OpenCvSharp绕不开选包问题。最常见的方案是装OpenCvSharp4.Windows这个包自带 OpenCV 运行库解压之后就能运行不需要你再配置 DLL 路径只装OpenCvSharp4的话需要手动拷贝OpenCvSharp.dll和 OpenCV 的 native 库。区别在于前者把原生依赖打包进去了安装成本低。如果你是给客户做桌面软件我更建议安装OpenCvSharp4.Windows因为它的 runtime 是和托管程序集一起发布的部署时少一个环节出错。版本上目前 4.x 系列都支持.NET Framework 4.7.2和.NET 6/8。老项目用.NET Framework可以选OpenCvSharp4.Windows的 4.5 版本新项目直接用 .NET 8 也不会有兼容问题。需要注意的是 Windows 下必须装 VC Redistributable否则加载时直接弹找不到opencv_world450.dll。这算是这个框架最常见的第一道坑。// Visual Studio 的 Package Manager Console 里执行 Install-Package OpenCvSharp4.Windows -Version 4.8.0.20230708也可以直接在 NuGet 界面搜索OpenCvSharp4.Windows挑最新稳定版点击安装。如果你用 .NET CLI则执行dotnet add package OpenCvSharp4.Windows。装完之后看一下项目的bin目录里面应该有OpenCvSharp.dll和OpenCvSharpExtern.dll后者是 C 桥接层缺失会导致所有Cv2静态方法报DllNotFoundException。3.2 从 rar 到跑起来的最小步骤打开工程、改图片路径、按需删减下载回来的 rar 解压后一般是一个 Visual Studio 解决方案。常见的 Demo 工程结构是Program.cs或者纯窗口工程。第一件事不是急着看代码而是确认它有没有被压缩软件拦掉 OpenCvSharp 的 Bin 目录。我遇到过不下三次作者顺手把bin夹排除在压缩包外你打开工程发现几十个编译错误实际上全是找不到OpenCv.Core命名空间。这时候只需要在解决方案里重新安装一次 NuGet 包就好。跑通最小 Demo 前建议先写一个只读图、显示图、保存图的程序验证环境本身没问题。下面是控制台程序读图并弹出的最小代码using OpenCvSharp; class Program { static void Main() { // 路径里避免中文和空格这不是玄学是 OpenCV 的文件读取经常在带空格路径上翻车 using Mat src Cv2.ImRead(C:\temp\test.jpg, ImreadModes.Color); if (src.Empty()) { System.Console.WriteLine(图片没读进来检查路径); return; } // 显示原图等待任意键关闭 Cv2.ImShow(origin, src); Cv2.WaitKey(0); Cv2.DestroyAllWindows(); } }逻辑说明ImRead第二参数指定读入模式ImreadModes.Color表示强制转为三通道 BGRsrc.Empty()是判断是否成功的关键很多新手在这漏掉。ImShow只能显示到一个 OpenCV 自带的窗口如果你的程序还要和 WPF 窗体互动这个窗口会是独立的不是嵌入的。WaitKey(0)是让程序阻塞直到按键不然窗口一闪而过。3.3 加载图片后立刻保存再继续处理一句排查金句拿到 Demo 第一步如果原图能显示下一步就点用Cv2.ImWrite(output.jpg, dst)保存一张。这个操作成本很低但能立刻验证你的滤镜代码里有没有产生异常。因为ImWrite会真正执行编码如果 Mat 的通道数、深度不对它是会报错的。大多数 Demo 跑不通不是算法不对而是前面的 Mat 类型没转好。保存成功一次再开始调参数才安心。4. 滤镜实现饱和度、明度、对比度、锐化、阴影、高光、色温的代码与参数调优4.1 阴影和高光用亮度掩膜保护原色阴影和高光调整是这 7 个效果里最考验手感的。很多 Demo 直接把灰度图作为权重去乘原图结果饱和度被连带拉低画面发灰。行之有效的方法是做一个边缘平滑的亮度掩膜然后用这个掩膜去控制“影响像素的多少”。我这里用Gamma 曲线的思路阴影掩膜shadowMask 1 - grayFloat假设灰度归一化到 0-1然后用Cv2.Pow调整曲线形状让掩膜在暗部保留更多权值。高光掩膜就是grayFloat本身。public Mat AdjustShadowHighlight(Mat src, double shadowAmount, double highlightAmount) { // 浮点归一化方便计算掩膜 Mat floatImg new Mat(); src.ConvertTo(floatImg, MatType.CV_32FC3, 1.0 / 255.0); // 灰度图作为亮度参考 Mat gray new Mat(); Cv2.CvtColor(floatImg, gray, ColorConversionCodes.BGR2GRAY); // shadowAmount: 正数提亮阴影负数压暗阴影 double shadowWeight shadowAmount / 50.0; Mat shadowMask Mat.Ones(gray.Size(), MatType.CV_32FC1); Cv2.Subtract(shadowMask, gray, shadowMask); Cv2.Pow(shadowMask, 1.2, shadowMask); float shadowScalar (float)(shadowWeight * 0.6); Cv2.Multiply(shadowMask, shadowScalar, shadowMask); // 高光掩膜从灰度图直接取用乘法控制强度 double highlightWeight highlightAmount / 50.0; Mat highlightMask gray.Clone(); Cv2.Pow(highlightMask, 1.5, highlightMask); float highlightScalar (float)(highlightWeight * 0.8); Cv2.Multiply(highlightMask, highlightScalar, highlightMask); // 在原图上叠加补偿量原图变亮或变暗 Mat shadowBoost new Mat(); Cv2.Multiply(shadowMask, 1.0, shadowBoost); Mat highlightDamp new Mat(); Cv2.Multiply(highlightMask, 1.0, highlightDamp); Mat dst floatImg.Clone(); // 阴影补偿加到暗部高光补偿加负号因为我们需要压暗高光 Cv2.Add(dst, shadowScalar, dst, shadowMask); Cv2.Subtract(dst, highlightScalar, dst, highlightMask); dst.ConvertTo(dst, MatType.CV_8UC3, 255.0, 0); floatImg.Dispose(); gray.Dispose(); shadowMask.Dispose(); highlightMask.Dispose(); return dst; }参数上shadowAmount和highlightAmount我习惯用 -100 到 100 的范围0 表示不调整。提阴影到 40暗部细节能出来但再往上到 70暗部开始产生明显噪点和色块。压高光同理50 以内比较安全超过 60 天空会发灰。掩膜的Pow值也很关键Pow小于 1 时掩膜对比度降低过渡更柔和大于 1 时对比度更陡只有极暗/极亮区域被影响。实际调参时一般是shadowAmount先给 30然后看头发和阴影里的椅子纹路如果背景也跟着变灰说明shadowMask太“软”。4.2 色温调节通道增益的对称偏移色温是一个色彩氛围滤镜。调整时直接修改 R 和 B 通道比例G 通道通常保持不动。但要避免把通道值乘到溢出后又被截断否则照片高光瞬间变死白。稳妥的写法是先转到CV_32FC3然后做 0 到 1 的归一化接着让 R 通道乘以(1 tempFactor)、B 通道乘以(1 - tempFactor)最后做平滑截断。tempFactor大于 0 是暖色调小于 0 是冷色调。public Mat AdjustColorTemperature(Mat src, double tempFactor) { Mat floatImg new Mat(); src.ConvertTo(floatImg, MatType.CV_32FC3, 1.0 / 255.0); Mat[] channels Cv2.Split(floatImg); Mat b channels[0]; Mat g channels[1]; Mat r channels[2]; // tempFactor 0 时暖增强 R减少 B double rGain 1.0 Math.Min(tempFactor, 0.5); double bGain 1.0 - Math.Max(tempFactor, -0.5); Cv2.Multiply(r, rGain, r); Cv2.Multiply(b, bGain, b); // 软裁剪超过 1 的值缓慢趋近 1而不是直接砍掉 Cv2.Threshold(r, r, 1.0, 1.0, ThresholdTypes.Trunc); Cv2.Threshold(b, b, 1.0, 1.0, ThresholdTypes.Trunc); Mat[] merged new Mat[] { b, g, r }; Mat hsvFinal new Mat(); Cv2.Merge(merged, hsvFinal); Mat dst new Mat(); hsvFinal.ConvertTo(dst, MatType.CV_8UC3, 255.0, 0); floatImg.Dispose(); b.Dispose(); g.Dispose(); r.Dispose(); hsvFinal.Dispose(); return dst; }参数说明tempFactor的推荐范围是 -0.5 到 0.5。0.2 就是比较明显的暖黄0.3 以上肤色会偏橙红拍人像时一般不太敢超过 0.3。冷色调负方向敏感度略低-0.2 就能看出清冷感-0.4 之后阴影里容易出现蓝紫色噪点。这里Threshold(..., Trunc)是“超过 1 就压回 1”比直接min更平滑。如果你不想要这种软裁剪可以再加一层Cv2.GaussianBlur让色彩过渡更自然但会降低清晰度适合氛围感滤镜。4.3 把这 7 种效果串成一个滤镜链实际滤镜 Demo 不会只调用一个函数而是顺序执行饱和度、明度、对比度、锐化、阴影、高光、色温。执行顺序是会影响结果的常见的顺序是先颜色空间调整再亮度对比度再高频锐化。如果先锐化再提饱和度锐化产生的白边会被饱和度放大非常难看。我自己推荐的顺序色温 → 阴影高光 → 饱和度明度 → 对比度 → 锐化。原因很简单色温和阴影会改变整体亮度分布先做可以让后续饱和度感知更稳定锐化放最后让边缘在最终亮度下才被增强避免纹理被前几步弄糊。参数组合上不要一次性把 7 个效果全拉到极端。这类 Demo 的坏习惯是演示时想要每个效果肉眼可见于是从饱和度 1.5、对比度 1.3、锐化 2.0 全部拉满成品像劣质 HDR。我一般会给三套预设自然温 0.05饱和 0.1对比 1.05锐化 0.5通透温 -0.1饱和 0.15对比 1.1阴影 20人像温 0.15饱和 -0.05阴影 30高光 -20。新手照着这三个预设去试能更快建立参数和视觉印象的对应关系。5. 避坑指南OpenCvSharp 滤镜开发中常见的翻车点与排查方法5.1 Mat 内存泄漏滤镜链跑着跑着内存爆掉现象在循环里对一批图片做滤镜第 200 张时内存涨到几个 GB程序卡死。原因OpenCvSharp 的Mat是对非托管内存的封装只靠垃圾回收不会及时释放。每次Cv2.CvtColor、Split、Merge都会新建 Mat如果没用using或Dispose原生内存一直被占用。解决所有临时 Mat 都用using包裹或者在函数开头用using var结束时让代码块自动释放。循环内调用滤镜函数时函数内部创建的 Mat只要调用过Dispose就不会累积。另外Cv2.ImShow打开的窗口在关闭前会持续保存引用循环里别反复创建同名窗口。5.2 颜色空间转换后通道顺序搞反现象对图片做了饱和度调整结果保存出来的图红蓝对调人在照片里像中了毒。原因OpenCvSharp 默认 BGR而很多 C# 位图控件期望 RGB你在拆分通道时拿到的是B,G,R合回去如果写成R,G,B就会通道错乱。解决打开任何 Mat 前先明确它的通道顺序。src[0]是蓝、src[1]是绿、src[2]是红。如果要在System.Drawing.Bitmap里显示需要先用Cv2.CvtColor转成ColorConversionCodes.BGR2BGRA再拷贝像素。这点在写 Demo 时不是问题但一旦接入界面几乎每个人都会中招。5.3 锐化强度参数失控导致白边噪声现象把锐化核中心参数从 5 改成 10图片的确“清晰”了但物体边缘出现一圈白色描边。原因锐化本质是增强边缘的高频分量中心权重越大边缘两侧的差越大如果核内权重和大于 1还会整体增亮。解决让核内权重和保持为 1即中心权重 4 边界权重之和。实际参数中sharpenAmount对应额外权重0 表示核为[0,-1,0;-1,4,-1;0,-1,0]这是拉普拉斯强化边缘的常规值。建议sharpenAmount不超过 2同时配合高斯模糊预处理降噪。5.4 色温调节后高光溢出成死白现象把色温往暖色调 0.4天空和白色衣服全部变成白板看不到纹理。原因对 8 位图直接乘通道增益超过 255 的值被硬截断丢失高光细节。解决切换到浮点 Mat归一化到 0-1 后再调增益如果希望细节保留可以对高光区域做Cv2.Threshold的截断加上一点Cv2.GaussianBlur过渡。我在调色温时始终保留一个highlightProtection参数对原图亮度取灰度图再用灰度图做权重把通道增益压回一点。例如在天空区域把暖色增益减半。5.5 实时调参卡成幻灯片现象在 WPF 里做几个滑块拖动 BglLightness 滑块时画面一帧要 300 毫秒完全没法看。原因每拖动一次就从Mat重新加载一张原始图然后从头跑完整的滤波链其中既有颜色空间转换又有卷积开销巨大。解决不要每次都重新读图把原图Mat缓存在内存里拖动时只对缓存的 Mat 做增量处理。第二个办法是把预览分辨率降成原图一半滑块事件变化过程中做小图最后松手时再全精度出图。第三个办法是把饱和度和色温合并到一个 HSV 通道操作里减少CvtColor的次数。实时滤镜里CvtColor是最贵的函数之一能少调就少调。6. 把滤镜效果调出质感验证效果与参数组合的进阶技巧滤镜 Demo 跑通和滤镜效果好是两码事。我自己的习惯是调完一组参数立刻把结果保存成 JPG放到看图软件里和原图叠加对比。不要只在 OpenCV 窗口里看那个窗口不做色彩管理显示效果略“闷”。更好的验证办法是输出三张图原图、效果图、效果图减原图的差分图。差分图能一眼看出哪里被改动最多如果阴影提亮把天空也改了说明掩膜没做对。进阶玩法是给每个参数配一个键盘快捷键用Cv2.WaitKey的返回值来调参。比如A/Z提高或降低饱和度S/X调整明度。这样你能在一分钟之内从 0 试到 100找到参数边界。我最早写这个 Demo 时就是从这儿开始的后来加了滑块又加了保存预设到 JSON 文件才变成能给别人用的工具。还有一个值得养成的习惯每次调出满意的效果记录参数组合和原始图片名。同一个参数在夜景、人像、风景里的最优值差异极大靠眼睛记住是不可能的。最后再分享一个参数组合的私藏打法如果一张图片发灰先提阴影 20再降色温 -0.05然后把饱和度放到 1.15。这个组合会让暗部有细节、高光不过曝整体颜色偏冷静适合大多数阴天拍的照片。如果是暖调氛围色温 0.12、对比度 1.08、锐化 0.8配合高光 -30效果通常能打。说到底色彩调整没有完美的公式但 OpenCvSharp 让你能在一个 C# 工程里反复实验所有变量。希望你从这套 Demo 出发能构建出自己的滤镜工具箱早日调出不用加滤镜的照片。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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