Android实时取色器开发:CameraX实时帧处理与色彩校准实践
Real Life Color Picker 这个项目说白了就是一个摄像头版 Color Picker把手机对准现实世界的任何一个物体屏幕中间实时显示它的颜色值按下按钮存进色板。听起来不复杂但当我真正开始做的时候才发现从摄像头画面里取出真实的颜色这件事比在屏幕上吸一个已经渲染好的颜色要麻烦得多。事情起因是我在做设计配色时的一个坏习惯看到喜欢的物体先拍照回电脑再用取色器一点点吸。问题在于照片里的颜色和肉眼看到的经常差得十万八千里——室内暖光下发黄阳光下过曝同一个红色袋子换个角度就变成橘红色。来回折腾几次后我决定自己写一个实时取色工具。如果你也想做类似工具或者想了解摄像头取色的底层细节这篇文章应该对你有帮助。1. 为什么现实取色比想象中麻烦一个量级1.1 拍照再取色这条路为什么天然走不通先说说先拍照再吸色为什么在原理上就有问题。手机相机不是一台颜色测量仪它是一台尽量拍出讨人喜欢照片的设备。按下快门之后感光元件的数据已经被 ISP 做了一堆处理自动白平衡、自动曝光、降噪、锐化、饱和度增强、色调映射每一步都在把传感器数据往好看而不是真实的方向推。等你把照片放进设计软件里吸色吸到的其实是相机对颜色的艺术加工结果。这不是吸色工具不准而是颜色在源头就被污染了。所以我意识到想要从现实世界拿颜色必须绕过拍照片这一环直接对摄像头的实时帧下手。也只有实时能看到颜色变化用户才能一边移动手机一边对比屏幕上的色值和实物这是静态照片永远给不了的体验。1.2 市面上取色工具的三个硬伤动手之前我也用过不少 Color Picker 类的 App基本分成三类问题。第一类是先拍照再取色型上面已经说过颜色不准。第二类做得比较专业要求先用实体色卡比如色卡护照贴着物体再扫描成本高而且出门不一定随身带。第三类功能倒是有但要联网、有广告取色结果还只能复制成 HEX 一种格式设计师想要的 RGB、HSL、导出色卡全都得自己二次加工。我一度以为这只是我没找到好工具后来在几个设计群里问了一圈发现大家的使用习惯出奇一致要么用系统自带截图的吸管要么在线转换网站上手动输入照片里的颜色没人觉得手机上能有专业好用的取色工具。需求在这里供给缺位我决定自己来。1.3 项目边界只做好取色这一件事自己动手之前我给自己划了几条边界防止项目失控。功能上只做实时取色、中心点取样、多格式复制、本地色板管理、离线可用、导出通用色卡文件。不做滤镜不做修图不做社区不碰社交功能只把取到真实的颜色这一件事做到位。目标用户想得很清楚设计师看到实物要配色方案前端开发者看到一个实物的颜色想要十六进制值做手账和画水彩的人需要把树叶、布料、染料的颜色记录下来家里刷墙选乳胶漆的人可以对着色卡和墙面比一比。我的核心设计理念是打开应用对准物体屏幕显示颜色点击保存结束整个过程不超过五秒。2. 摄像头实时取色的核心链路从一帧图像到一个色值2.1 整体架构与选型技术栈我选了 Android 原生 Kotlin CameraX Room没有引入重量级视觉库。CameraX 的 ImageAnalysis 用例专门用于逐帧分析颜色换算自己写不过度依赖第三方色板数据用 Room 存结构化记录。为什么不直接上 OpenCV因为基础取色并不需要。OpenCV 的价值体现在色卡识别和几何校准这种高级功能上早期先把管线跑通更重要。管线流程是CameraX 分析用例每帧回调 - 从 YUV 帧中采样中心区域像素 - YUV 转 RGB - 色彩校准 - 色值格式换算 - 更新 UI 和色板。2.2 CameraX 分析用例拿到实时帧CameraX 的分析用例把相机预览和分析绑定在一起省去了手动处理设备旋转、生命周期和厂商兼容的麻烦。我的核心配置长这样val analysisUseCase ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() analysisUseCase.setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy - processFrame(imageProxy) imageProxy.close() }关于两个关键参数说几句。STRATEGY_KEEP_ONLY_LATEST表示处理不过来的时候丢弃旧帧只保留最新帧这很符合实时取色的需求——我永远关心的是手机现在对准的颜色而不是一百毫秒前的。OUTPUT_IMAGE_FORMAT_YUV_420_888是相机输出的原生格式比 RGBA_8888 更贴近传感器数据但代价是后续要自己处理 YUV 到 RGB 的转换。这里有一个一开始容易忽略的细节imageProxy.close()必须在每一次分析回调里调用否则缓冲区不会释放几分钟后应用就会因为内存占用过高被系统杀掉。我第一版测试时只打开了三分钟系统直接把进程回收就是这个原因。2.3 YUV 到 RGB最容易出错的第一个环节YUV 可能让很多人陌生。简单理解摄像头为了压缩存储空间把每个像素拆成了亮度和颜色差异两部分。Y 代表亮度U也叫 Cb和 V也叫 Cr代表蓝色和红色相对亮度的偏移。人眼对亮度敏感、对颜色细节不敏感所以 U、V 可以用比 Y 低的分辨率存储。在 YUV_420_888 格式里每 4 个 Y 像素共享一组 UV这也是 420 的含义。屏幕显示需要的是红绿蓝三原色所以把 YUV 还原成 RGB 是取色的第一道关口。我用的是 BT.601 标准的近似公式fun yuvToRgb(y: Int, u: Int, v: Int): TripleInt, Int, Int { val r (y 1.402 * (v - 128)).toInt().coerceIn(0, 255) val g (y - 0.344136 * (u - 128) - 0.714136 * (v - 128)).toInt().coerceIn(0, 255) val b (y 1.772 * (u - 128)).toInt().coerceIn(0, 255) return Triple(r, g, b) }这个转换本身不难坑出现在怎么从 YUV 帧里取到正确的字节。Y 平面每个像素一个字节U 和 V 平面因为分辨率减半需要按行列步进去取。我在定位中心像素的时候直接写了一个按坐标采样的函数fun sampleYuvPixel(image: ImageProxy, tx: Int, ty: Int): TripleInt, Int, Int { val yPlane image.planes[0] val uPlane image.planes[1] val vPlane image.planes[2] val yBuffer yPlane.buffer val uBuffer uPlane.buffer val vBuffer vPlane.buffer val yIndex ty * yPlane.rowStride tx * yPlane.pixelStride val uvX tx / 2 val uvY ty / 2 val uvIndex uvY * uPlane.rowStride uvX * uPlane.pixelStride val y yBuffer.get(yIndex).toInt() and 0xff val u uBuffer.get(uvIndex).toInt() and 0xff val v vBuffer.get(uvIndex).toInt() and 0xff return yuvToRgb(y, u, v) }这里有个容易忽略的细节Y 平面的rowStride往往不等于图像宽度因为底层要对齐内存U、V 平面的pixelStride可能是 1 也可能是 2取决于硬件。直接按 width * y 去算索引在部分设备上会拿到错位的颜色。所以用rowStride和pixelStride而不是裸索引是我踩过坑之后换来的习惯。2.4 色彩空间换算HEX、RGB、HSL 一条龙拿到 RGB 之后下一步是生成用户真正要的东西。设计师在工具里最常用的是 HEX也就是十六进制前端同学经常需要rgb(12,34,56)调色板排序时 HSV 和 HSL 更友好因为 H 分量独立表示色相。RGB 转 HEX 很简单fun rgbToHex(r: Int, g: Int, b: Int): String String.format(#%02X%02X%02X, r, g, b)RGB 转 HSV 需要一点计算。HSV 把颜色拆成色相H、饱和度S、明度V三个值色相是一个 0 到 360 度的角度fun rgbToHsv(r: Int, g: Int, b: Int): FloatArray { val rf r / 255f val gf g / 255f val bf b / 255f val max maxOf(rf, gf, bf) val min minOf(rf, gf, bf) val delta max - min var h 0f if (delta ! 0f) { h when (max) { rf - 60f * (((gf - bf) / delta) % 6f) gf - 60f * (((bf - rf) / delta) 2f) bf - 60f * (((rf - gf) / delta) 4f) else - 0f } if (h 0f) h 360f } val s if (max 0f) 0f else delta / max return floatArrayOf(h, s, max) }HSV 的饱和度、明度这里用 0~1 比例表示UI 层展示时再转成百分比。HSL 和 HSV 的区别在于 L 通道用亮度而不是明度。功能上没必要两个都实现但如果你想把色板按彩虹顺序排布HSL 里的 L 会让排序结果看起来更均匀。我最终两个都做了复制按钮弹窗里提供 HEX、RGB、HSL、HSV 四种格式一次满足不同工具的需求。2.5 取样区域与预览对齐中心点不是只要取一个像素看到这里你可能会问取色不就应该取中心那一个像素吗实际上不行。传感器每个像素都有噪声同一个颜色在两三帧里会跳动好几个色阶。我实测用单像素取样表面颜色会像呼吸灯一样不停闪根本没法看。我的做法是取中心 5x5 个像素做平均private fun sampleRegion(image: ImageProxy, radius: Int 2): TripleInt, Int, Int { val cx image.width / 2 val cy image.height / 2 var sumR 0L; var sumG 0L; var sumB 0L; var count 0 for (x in cx - radius..cx radius) { for (y in cy - radius..cy radius) { val (r, g, b) sampleYuvPixel(image, x, y) sumR r; sumG g; sumB b; count } } return Triple( (sumR / count).toInt(), (sumG / count).toInt(), (sumB / count).toInt() ) }为什么是 5x5 而不是 10x10因为半径越大混入边缘其他颜色的概率越高。取色器对准的是物体细节时一个大面积平均反而会把目标颜色和背景缝在一起。5x5 在抗噪和精细之间比较平衡。预览对齐的问题稍微绕一点。分析帧的宽高和预览画面上显示的尺寸往往不一样前者是相机传感器的原始比例后者经过 UI 裁剪。不过只要取景器用居中裁剪画面中心在两种坐标系里都还是中心所以直接取分析帧中心即可。真正需要处理的是旋转角度imageProxy.imageInfo.rotationDegrees会告诉你相机传感器方向和屏幕方向的夹角。因为我是按中心采样旋转不影响中心点坐标但如果以后改成区域取色就必须用CoordinateTransform做一次完整映射否则用户框住的地方和实际取样区域会错位。3. 颜色校准为什么同一件衣服白天和晚上取出来完全是两个颜色3.1 自动白平衡是在讨好眼睛而不是还原颜色实时帧的管线打通后我拿着应用对着一件军绿色外套测试结果不满意在室内白炽灯下取出来偏黄到户外阴天又偏灰蓝。问题不在取色代码而在相机的自动白平衡AWB。AWB 做的事情是把整个画面的色温往中性方向拉。比如暖色灯光下的白色墙面相机会把墙往白里调于是画面整体偏冷一点点让照片看起来自然。但对取色来说这会直接改变物体颜色一个本来略偏橙红的物体经过 AWB 拉拽之后取出来变成了一个不伦不类的中间色。更麻烦的是AWB 是实时变化的。手机只要稍微移动一下取景画面里的色温就会变取出的颜色一直在漂移。我试过用 Camera2Interop 去锁定 AWB 模式在部分设备上有效但很多厂商的相机管线根本不给你这种底层控制权。3.2 白卡校准一张白纸就够用的修正方案最终我选择了更可控的方案白卡校准。原理不复杂。颜色传感器测量的是一束光的反射强度理想的白色表面应该均匀反射所有波长所以白纸的颜色值就是整条颜色链路的基准。操作流程是用户拿一张纯白的 A4 纸放在镜头前占满中心区域点击校准。应用取这个区域的 RGB 均值假设它代表纯白然后计算每个通道的修正系数val gainR 255f / avgR val gainG 255f / avgG val gainB 255f / avgB之后每次取样都把这组系数乘上去fun calibrate(rgb: TripleInt, Int, Int, gains: TripleFloat, Float, Float): TripleInt, Int, Int { val r (rgb.first * gains.first).toInt().coerceIn(0, 255) val g (rgb.second * gains.second).toInt().coerceIn(0, 255) val b (rgb.third * gains.third).toInt().coerceIn(0, 255) return Triple(r, g, b) }绝大部分场景下三通道增益修正已经能纠正掉 70% 的偏色。比如暖黄灯光下那个偏橘的红色校准前取出来是 #C8754F校准后接近 #B3402A肉眼对比已经比较接近实物了。但要提醒一句白卡校准挡不住拍摄角度变化带来的反光。如果用户拿着手机绕着物体转表面高光会把颜色直接洗白这是物理现象任何软件都救不回来。我做了个反光提示器根据取色区域亮度过高给出警告建议用户稍微调整角度。3.3 伽马线性化直接乘增益为什么会在暗部翻车白卡校准第一版有个隐藏问题暗部颜色偏得厉害。比如深蓝色表面校准后变成了紫色。排查后发现问题出在在线性空间乘增益这一步。相机的 8 位 RGB 输出是经过伽马编码的 sRGB 值它和实际的光线强度不是线性关系。简单说颜色值 128 对应的物理亮度不是颜色值 64 的两倍。如果直接把两个伽马编码的值相乘等于在对数空间做乘法暗部的偏差会被放大所以阴影部分会莫名其妙偏色。正确做法是先把 sRGB 值解码到线性空间乘增益再重新编码回 sRGBfun srgbToLinear(c: Int): Float { val v c / 255f return if (v 0.04045f) v / 12.92f else Math.pow(((v 0.055f) / 1.055f).toDouble(), 2.4).toFloat() } fun linearToSrgb(c: Float): Int { val v if (c 0.0031308f) c * 12.92f else 1.055f * Math.pow(c.toDouble(), 1.0 / 2.4).toDouble().toFloat() - 0.055f return (v * 255f).toInt().coerceIn(0, 255) }在校准函数里先做srgbToLinear乘增益再做linearToSrgb。改完之后暗部偏色的问题基本消失。这个细节很容易被忽略我建议所有做图像处理的朋友都把这个流程内置进工具函数而不是每次现算。3.4 多帧平滑让颜色停止跳动即使固定了白平衡和校准实时取色时颜色还是会轻微抖动。原因有两个传感器的高频噪声以及手持手机时的微小晃动让取样区域边缘混入其他颜色。我最初试过对连续若干帧取均值效果不错但手一停取到的颜色会有明显的滞后感因为均值窗口让旧帧的权重没有快速衰减。后来改用指数平滑val smoothedR prevR * 0.65f currentR * 0.35f val smoothedG prevG * 0.65f currentG * 0.35f val smoothedB prevB * 0.65f currentB * 0.35f为什么用 0.35 而不是 0.5系数越大跟随新帧越快但抗噪能力越差系数越小画面越稳定但改变目标物体时颜色要半秒才跟得过来。实测 0.3 到 0.4 之间手感最好我最后固定在 0.35。这个参数没有标准答案跟每台设备的帧率和使用习惯有关做成可调更靠谱。4. 色板管理与导出取到颜色之后还要能顺利交给设计工具4.1 本地色板的数据结构取色只是第一步颜色存下来才是资产。我用 Room 设计了两张表色板表和色块表。色板代表一个项目色块属于某个色板。色块表的核心字段包括名称、十六进制值、RGB 字符串、HSV 字符串、创建时间、所属色板 ID。存多种格式字符串看似冗余但免去了每次读取时重新计算的成本而且导出时可以直出逻辑更简单。Entity(tableName swatch) data class SwatchEntity( PrimaryKey(autoGenerate true) val id: Long 0, val paletteId: Long, val name: String, val hex: String, val rgb: String, val hsv: String, val hsl: String, val createdAt: Long System.currentTimeMillis() )名称自动生成规则很简单根据 HSV 的 H 值判断色相区间例如暖橙-02同色板内自动递增编号避免出现一长串没意义的数字。4.2 一键复制多格式输出是设计工具的刚需保存下来的色块在列表里点击可以弹出复制菜单。多数设计师工作流里HEX 是通用语言但也有人用rgb(255, 87, 51)这种语法还有人做 CSS 时需要hsl(12, 100%, 60%)。我做了四选一HEX、RGB、HSL、HSV。实测最常被用到的是 HEX 和 HSLRGB 主要用于对接一些自动生成代码的平台。这个功能不复杂但是很提效率省去了每次在颜色转换网站之间跳来跳去。4.3 导出 ASE 色卡文件让修图软件直接识别ASEAdobe Swatch Exchange是 Photoshop、Illustrator、Affinity 等设计软件通用的色板文件格式直接双击就能载入色板。所以保存到色板里的颜色松手就能送到设计软件里继续用这个能力直接决定工具是否进入专业工作流。ASE 是二进制大端格式核心结构是文件头用ASEF标识跟着版本号和块数量每个色块包括名称、颜色模型RGB以及三个 0 到 1 之间的浮点分量。用 Kotlin 的ByteBuffer设置BIG_ENDIAN就能写出来。一个重要细节是名称用 UTF-16BE 编码而且长度单位是字符数不是字节数我第一次写的时候在这里踩了坑生成的色卡在 Photoshop 里名称乱码。这个导出功能上线后配合设计软件整个取色-归档-使用的闭环就完整了。很多设计师朋友反馈ASE 导出是他们愿意每天打开这个应用的理由。除了 ASE我还补了 JSON 和 CSV 导出。JSON 用于自己写脚本做批量处理CSV 可以在表格软件里直接打开按颜色名称排序、筛选、统计都方便。这套组合让色板不再绑死在应用内部所有权属于用户自己。4.4 一个简单有效的自动去重机制色板变多以后重复颜色就成了麻烦。为此我在保存时增加了一个近似度判断新颜色和色板里已有颜色之间的色差超过阈值才允许保存。色差不直接用 RGB 的欧氏距离因为 RGB 空间里人眼对蓝色的感知差异和绿色的感知差异不一致。我用的是简化版 Lab 空间 ΔE 近似虽然不如 CIEDE2000 精细但已经能避免手动去重。阈值我设成 ΔE 小于 4 就算重复因为人眼在绝大多数情况下分辨不出这个级别的色差。5. 真机测出来的几个翻车现场与优化方案5.1 同一个红色从紫色变成绿色U/V 顺序的坑第一次在真机上跑通时我对着一块标准红色色卡取色屏幕显示竟然是紫色。第一反应是校准函数写错了把校准关掉后依然偏紫于是转向检查 YUV 转换。排查链路是这样的先固定手机不动在代码里分别打印取样点的 Y、U、V 原始值和从 RGB 反推的预期值对比。结果发现 U 和 V 的值像是被交换了红色区域应该 V 偏高实际打印结果却是 U 偏高。也就是说这台设备上planes[1]里装的其实是 Vplanes[2]里装的是 U。虽然 Android 的YUV_420_888文档里通常规定 plane 0 是 Y、plane 1 是 U、plane 2 是 V但厂商实现并不总能保证这在老设备和部分国产设备上尤其明显。我的解决方式不是去猜而是做了一个初始化检测用户在取色界面对着一块纯色物体时程序根据如果 U/V 反了红变紫、绿变色的特征自动检测并交换 U/V 平面然后在日志里记录当前设备用了哪种布局。这个经验总结下来就一句话凡是涉及多平面像素格式的代码不要假设平面顺序要么兼容检测要么留出开关。5.2 掉帧和发热全分辨率 YUV 转换不可取项目初期我在分析器里把整帧 YUV 全量转成 Bitmap然后getPixel取中心颜色。结果热门机型跑起来掉帧明显手机背板发烫。原因很简单2592x1944 的帧每帧要做约 500 万次像素转换每个像素内还涉及三次乘加和多次边界处理。优化分三步。第一步分析器的目标分辨率降到 640x480这个精度对于取中心颜色完全够用。第二步不再整帧转换而是直接按坐标采样中心 5x5 区域每次只转 25 个像素几乎不占 CPU。第三步加帧率闸门两次处理间隔小于 100 毫秒的直接丢弃if (now - lastProcessedAt 100) { imageProxy.close() return }这三步走完CPU 占用从肉眼可见的卡顿降到几乎无感日常使用也不发烫。取色是一个持续性场景用户可能举着手机在房间里扫来扫去所以性能必须按长期运行来设计而不是按截一张图来设计。5.3 厂商 ISP 的美颜数据源本身就带了滤镜优化完性能之后又遇到一个更头疼的问题某些手机拍出来的颜色过分鲜艳。我拿两台手机同时对着同一个物体取出来的颜色差异很大一台偏真实另一台饱和度明显被拉高。原因是厂商的相机 ISP 在输出图像前加了饱和度增强、锐化和色调映射它们在硬件层面就把颜色改了。对于普通拍照这算优化对取色就是造假。我尝试用 Camera2 的 CaptureRequest 去关闭部分 ISP 处理但在多数消费级设备上并不能完全生效厂商没有暴露这个开关。那段时间我想了不少办法最终选择在应用里加一个手动修正面板色相、饱和度、明度三个滑块用户可以自己微调。说是妥协其实也是改进——因为不同用户对颜色是否准的标准不统一把手动微调变成工具的一部分反而更实用。后来我还加了一个最近颜色记录区把前后两个色值放在一起对比肉眼判断偏差后手动拉回来效率比反复截图要高得多。5.4 关于屏幕色域的一个提醒最后补一句容易忽略的事即使取到的色值完全正确手机上最终显示出来的颜色和设计软件里最终渲染出来的颜色仍然可能不一样。因为手机屏幕可能是 P3 色域而设计文件通常是 sRGB。同一串 #FF5733在两种色域下显示的鲜艳程度不同。这个我在应用内部解决不了也不是取色应用应该解决的。但我在设置页加了一行提醒如果发现取到的颜色在电脑上和手机上看有差异先检查两边的色彩配置文件是不是一致。这个提醒帮我挡掉了不少应用颜色不准的误报。6. 后续可以怎么扩展个人经验中的几个方向6.1 值得做的进化方向这个项目做到现在核心链路稳定但我自己心里清楚它还只是能用的阶段离好用还有距离。我列了几个我觉得值得继续做的方向。一是色卡自动识别。用 OpenCV 识别画面里的标准色卡色块自动算出一个 3x3 的色彩校正矩阵替代现在手动白卡校准的方式。这是专业取色工具的标准做法也是我下一步最想做的。二是区域取色。现在只支持中心点取样但很多场景用户想框住一个范围取平均色甚至排除高光区域。这个功能需要精确的坐标映射前面提到的CoordinateTransform会派上用场。三是色差比对。比如你有一块旧布料的颜色想找到最接近的新布料直接扫描并和色板里的旧色值算 ΔE按接近程度排序对家装、染布这类需求很有用。6.2 我踩过坑之后的三条原则如果这篇文章只留三句话我希望是这三句。第一取色工具的核心是校准能力不是界面好不好看。先把同一物体在不同光线下取到接近颜色这件事做稳再去美化 UI。第二拿到一帧数据先做最小可用的颜色管线不要一上来就引入一堆图像库。我的项目前两周就是纯 YUV 采样加一个 TextView跑通了才慢慢加的 Room 和导出。第三保持单一职责。取色工具就取色不要试图变成全能修图软件功能越多用户对你的信任越分散。我自己在项目做完之后最大的感受是以前总觉得取色是个再简单不过的功能现在才明白每一条色值背后都经过了一整套物理、光学和色彩管理学的噪声。好在这些噪声都是可以被设计、被校准、被修复的。如果你也在做类似工具欢迎从白卡校准和线性化开始先把颜色底子打正其他的都会顺起来。