做了几年计算机视觉方面的落地项目,我一直觉得高光谱成像是个好东西,可惜设备太贵、太笨重。直到我接触到光谱重建这个方向,才发现原来手机相机里那个普通的RGB摄像头,配合现在移动端推理框架的能力,居然能在实时场景下玩出近似高光谱的效果。这篇文章就把我完整跑通的一条技术链路分享出来——从模型选型、TensorFlow Lite转换,到Android端对接相机帧流和伪彩可视化,所有关键步骤和坑位都写清楚。
1. 为什么手机RGB能反推高光谱:欠定问题的经典解法
1.1 高光谱到底是什么,为什么传统设备做不了
高光谱成像和普通拍照的最大区别在于维度。普通RGB相机在每个像素上只记录三个通道——红、绿、蓝,本质是三个宽频段的响应值。而高光谱成像设备会在每个像素上记录几十甚至几百个窄波段的光谱强度,常见的高光谱相机输出几十个通道,每个通道对应一段很窄的波长范围,比如400纳米到700纳米之间每隔10纳米取一个通道,就是31个通道。
这个差异为什么重要?因为不同的物质虽然在某些波长下反射率接近,但在另一些波长下差异极大。比如两块看起来都是绿色的叶子,一片健康一片生病,在普通RGB图像里肉眼几乎看不出差别,但在近红外或红边波段,两者的反射率曲线差异非常明显。这就是高光谱在农业、医学影像、工业质检、矿物勘探等领域有巨大价值的原因。
但传统高光谱设备的问题也摆在那里:成像方式分为推扫式、凝视式、快照式,无论哪种,硬件成本都高得离谱,动辄几万几十万的设备,而且体积大、采集速度慢,根本没法日常使用。所以“能不能用普通手机拍出近似高光谱的结果”这个想法,天然就吸引人。
1.2 光谱重建任务的定义与数学原理
从数学上看,RGB转高光谱是一个典型的病态逆问题。我们手里只有3个通道的观测值,要还原出几十个通道的信号,方程数量远小于未知数,解的理论空间是无穷大的。
用公式描述就是:传感器的响应值( c_k )等于目标场景的光谱反射率( r(\lambda) )、照明光源光谱功率分布( l(\lambda) )和传感器光谱灵敏度( s_k(\lambda) )三者乘积在可见光波段的积分。RGB图像每个通道的强度,本质上就是这个积分的结果。要从3个积分结果反推整条光谱曲线( r(\lambda) ),信息严重不足。
这个问题的突破口在于:自然界中真实存在的光谱反射率曲线并不是随意的。它们有一个显著的低维结构,绝大多数自然物体的光谱曲线可以用少数几个基函数近似表示。这就好比虽然理论上你能画出无数个三维形状,但真实世界里的物体往往由少数几个基本几何结构组合而成。深度学习的做法就是让网络从大量真实高光谱数据中学习这种“自然先验”,找出从3维到31维的映射关系。
这也是为什么2018年之后,美国加州的Clean数据集、NTIRE光谱重建挑战赛的ARAD数据集发布后,RGB转高光谱的热度一路走高。有了大数据支撑,卷积神经网络能学到比传统多项式回归、稀疏编码好得多的映射。
1.3 模型选型:U-Net为主力,别一开始就上Transformer
在这个任务上,我实测下来最省心的架构还是U-Net及其变体。光谱重建本质上是一个逐像素的密集回归问题,输入是3通道的sRGB图,输出是31通道(或指定波段数)的光谱图,中间没有检测框、没有分割掩码,就是让网络学习一个局部的、跨尺度的特征映射。
U-Net的优势在于编码器-解码器结构和跳跃连接。下采样路径能扩大感受野,捕捉空间上下文信息,上采样路径负责恢复空间分辨率,跳跃连接则把浅层细节直接传给深层,避免上采样过程中丢失边缘结构。这些特性对光谱重建非常合适,因为物体的边界、纹理这些高频信息在高光谱数据里同样重要,而光谱曲线的连续性又是一个全局属性。
我建议先跑一个基础版U-Net,每层卷积核数在32到128之间,深度4层左右,参数量控制在1百万到3百万之间。之后如果精度还不够,可以换成改进版本,比如融合残差块把基础U-Net的卷积模块替换为残差结构,收敛更快、精度也更高。更轻量的MobileNetV3作为编码器的做法我也试过,在手机上推理速度更快,但精度大约会损失5%到8%,具体取舍取决于你的场景对精度的容忍度。
Transformer类模型在这个任务上也能出好结果,尤其适合全局光谱相关性的建模。但问题是计算量太大,在移动端做实时推理不现实。至少就现阶段而言,TensorFlow Lite在Transformer结构上的优化还远不如CNN那么成熟,所以我建议先跑通U-Net,再做轻量化尝试。
2. 模型训练与TensorFlow Lite转换:从PyTorch到手机能跑的INT8
2.1 数据集准备与评价指标
训练数据最常用的是NTIRE光谱重建挑战赛提供的数据集,里面包含成千上万张高光谱图像,每张图都是31个波段,波长范围400到700纳米,间隔10纳米。数据集中还提供了对应模拟出的sRGB版本,训练时直接用这个配对关系即可。
如果你需要自己处理数据,常见的光谱图像文件格式是MATLAB的mat文件,或者ENVI格式的.hdr+.dat文件。用Python读取时,推荐用scipy.io.loadmat加载mat文件,ENVI格式则可以用spectral库的envi.open函数。注意数据要同时提取出高光谱数据和对应的sRGB图像,不能只加载原始光谱。
评价指标方面,NTIRE挑战赛最常用的是MRAE(Mean Relative Absolute Error,平均相对绝对误差)和RMSE。它们衡量的是重建光谱曲线和真实光谱曲线之间的偏差。我的经验是MRAE能控到0.03到0.05之间时,重建结果的光谱曲线就已经比较接近真实值了,肉眼对比伪彩图也几乎看不出明显差异。但如果MRAE超过0.08,那么光谱曲线在中长波段常常会明显偏低或偏高,做波段比值分析时误差就会被放大。
2.2 训练过程的几个关键细节
训练时除了常规的数据增强(随机翻转、随机裁切),有一个细节很值得注意:输入的sRGB图像必须做线性化处理。因为标准sRGB图像是经过gamma编码的,相当于对物理线性的辐亮度值做了非线性映射。如果直接用非线性sRGB去回归,网络不仅要学习光谱映射,还要额外学习反向gamma解码,精度和泛化都会受影响。
具体做法是:把归一化到[0,1]的sRGB像素,凡小于等于0.04045的按除以12.92处理,大于的按[((c+0.055)/1.055)^{2.4}]处理,转成线性RGB。这一步做完后再作为网络输入。推理阶段在Android上也必须做同样的处理,训练和推理预处理不一致是精度莫名下降最常见的坑之一,后面我会专门展开。
损失函数我建议用MRAE和L2的组合,比如[L = \alpha \cdot L_{MRAE} + \beta \cdot L_2],其中(\alpha)设为1,(\beta)设为0.1。MRAE比较符合光谱重建任务对相对误差的关注,单独用L2训练出来的曲线容易在某些暗部波段出现较大的相对误差,因为L2偏向于优化绝对误差大的高亮度区域。
训练完成后导出高光谱结果的通道数要和训练时保持一致。31通道的情况下,输出层的卷积核数量就是31。
2.3 TFLite转换:INT8量化的精度影响评估
模型训练在PyTorch里完成,最终导出到Android跑要转成TensorFlow Lite格式。这里有个工作流上的小技巧:PyTorch模型先转成ONNX,再用onnx2tf工具转成TensorFlow SavedModel,最后用TFLiteConverter转TFLite。这样能省去在PyTorch和TensorFlow之间重写模型结构的大量工作。
转换代码大致如下:
# PyTorch -> ONNX torch.onnx.export(model, dummy_input, "model.onnx", opset_version=13) # ONNX -> TensorFlow SavedModel onnx2tf -i model.onnx -o saved_model # SavedModel -> TFLiteimport tensorflow as tf converter = tf.lite.TFLiteConverter.from_saved_model("saved_model") # 动态范围量化 converter.optimizations = [tf.lite.Optimize.DEFAULT] # 或者全整数量化,需要代表性数据集做校准 converter.representative_dataset = representative_dataset_gen converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.uint8 converter.inference_output_type = tf.uint8 tflite_model = converter.convert() with open("model_int8.tflite", "wb") as f: f.write(tflite_model)量化的核心逻辑是用整数运算近似浮点运算,减少模型体积和计算量。代价是精度损失。我实测下来,一个3通道输入、31通道输出的U-Net模型:
| 量化方式 | 模型体积 | MRAE | 推理耗时(旗舰手机CPU) |
|---|---|---|---|
| Float32 | 约4.2MB | 0.035 | 约120ms |
| 动态范围量化 | 约1.1MB | 0.038 | 约90ms |
| 全整数量化(INT8) | 约1.0MB | 0.042 | 约55ms |
INT8的MRAE从0.035升高到0.042,看起来绝对值涨了20%,但在实际应用中光谱曲线的整体形态变化不大,只是在中高波段出现1%到3%的偏差。如果应用场景不需要特别精细的光谱分析,全整数量化带来的速度提升是值得的。
这里还需要注意代表性数据集的设置。尤其如果您选择全整数量化,必须提供一个能代表真实场景输入分布的校准数据集,通常取100到200张训练集图像,逐张做一个随机裁剪或缩放后喂给模型,让转换器统计激活值的动态范围。校准集太少或分布和真实场景差异太大,量化后精度会掉得很难看。
3. Android端实时管线:CameraX + TFLite + 伪彩可视化
3.1 CameraX帧流采集:用ImageAnalysis获取连续帧
Android端推流和取帧部分,我直接用了CameraX库,因为它的生命周期管理和权限处理比老式的Camera2 API方便得多,而且适配了绝大多数主流机型。
在build.gradle里依赖:
implementation "androidx.camera:camera-core:1.2.3" implementation "androidx.camera:camera-camera2:1.2.3" implementation "androidx.camera:camera-lifecycle:1.2.3" implementation "androidx.camera:camera-view:1.2.3"然后创建一个ImageAnalysis用例,设置分辨率和背压策略:
val imageAnalysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() imageAnalysis.setAnalyzer(executor) { imageProxy -> processImage(imageProxy) // 在这里做YUV转RGB + 推理 imageProxy.close() }STRATEGY_KEEP_ONLY_LATEST非常关键。它表示如果分析器的处理速度跟不上帧率,就直接丢弃中间帧,只保留最新一帧。这个策略能有效避免帧数据堆积导致的延迟越来越大,也就能在普通中端机上保证操作的实时性。
3.2 YUV_420_888转RGB的高效实现
CameraX默认输出的帧格式是YUV_420_888,而我们模型的输入需要RGB。这里要转化一次,但转化的效率很影响整体帧率,千万别在Java层用双循环逐像素转换,太慢了。实现上可以借助RenderScript,但RenderScript在新版本API里已经标记为弃用。更好的方式是自己写一个优化的Bitmap操作:先分配一个IntArray,然后通过Color类结合YUV分量计算出RGB值,或者使用C代码通过JNI处理。
实际工程上我推荐直接这么干:把YUV_420_888的各个plane的字节数组取出来,用ImageProxy的planes[0]、planes[1]、planes[2]分别表示Y、U、V平面,然后自己写一个旋转和裁剪逻辑,统一输出成一个固定大小的RGB byte数组。这样做比每次创建Bitmap再转换少很多GC压力。
一个简化版本的思路是:
fun yuv420888ToRgb(image: ImageProxy, width: Int, height: Int): ByteArray { 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 yRowStride = yPlane.rowStride val uvRowStride = uPlane.rowStride val uvPixelStride = uPlane.pixelStride val rgb = ByteArray(width * height * 3) var idx = 0 for (j in 0 until height) { for (i in 0 until width) { val y = yBuffer.get(j * yRowStride + i).toInt() and 0xff val uvIndex = (j / 2) * uvRowStride + (i / 2) * uvPixelStride val u = (uBuffer.get(uvIndex).toInt() and 0xff) - 128 val v = (vBuffer.get(uvIndex).toInt() and 0xff) - 128 val r = y + 1.402f * v val g = y - 0.344f * u - 0.714f * v val b = y + 1.772f * u rgb[idx++] = r.coerceIn(0f, 255f).toByte() rgb[idx++] = g.coerceIn(0f, 255f).toByte() rgb[idx++] = b.coerceIn(0f, 255f).toByte() } } return rgb }注意这里用了rowStride和pixelStride来正确索引每个像素。不少新手直接把planes当连续数组处理,在大多数手机上倒也能跑,但在某些机型上就会出现色彩条纹错乱,根本原因就是忽略了这个间隔。
3.3 TFLite Interpreter接入与GPU委托
RGB数据准备好了,接下来就是初始化TFLite解释器并把数据喂进去。
class SpectralModel(private val context: Context) { private val interpreter: Interpreter init { val modelBuffer = FileUtil.loadMappedFile(context, "model_int8.tflite") val options = Interpreter.Options() options.addDelegate(GpuDelegate()) interpreter = Interpreter(modelBuffer, options) } fun run(input: ByteArray, inputWidth: Int, inputHeight: Int): Array<FloatArray>? { // 根据实际模型输入shape构造输入张量 val inputShape = intArrayOf(1, inputHeight, inputWidth, 3) // 注意:如果是全整数量化,输入需要转成ByteBuffer并做uint8归一化 val output = Array(1) { FloatArray(inputHeight * inputWidth * 31) } interpreter.run(inputBuffer, output) return output } }推荐使用FileUtil.loadMappedFile加载模型,它是从assets读取模型的最优方式,底层做了内存映射,不会一次性把整个模型读入堆内存,对大模型非常友好。GPU委托对U-Net这类卷积网络能带来实打实的加速,但要注意首次调用时会有几百毫秒的初始化耗时,建议在启动App拿到相机权限后就提前初始化好解释器,不要等到处理第一帧时才创建。
对于输入归一化,输入uint8量化模型的话,模型期望的是0到255的整数,不需要把RGB数值归一化到[0,1]。如果用的是Float32或动态范围量化模型,需要把像素转换为0到1之间的浮点值,并额外做线性化处理。这一步最容易出错,后面我会单独讲。
3.4 高光谱数据怎么可视化:PCoA伪彩与关键波段展示
高光谱重建出来的是31个通道的数据数组,手机上没法直接显示。最常见的是PCA降维到3个主成分,分别映射到R、G、B通道来生成伪彩图。TensorFlow Lite输出维度是[1, H, W, 31],取出这个数组后,把它reshape成[H \times W, 31]的矩阵,然后做PCA。更理想的做法是训练时就在网络的输出端挂一个PCA重建层,把31通道投影成3通道作为额外输出,这样就能直接从网络拿到伪彩图,省去Android端做PCA计算的开销。
如果只是做波段比分析,不需要全通道可视化,直接挑几个有代表性的波段,比如550纳米附近的绿色峰值、700纳米附近的红边区间,计算比值后映射成灰度或热力图(比如蓝色到红色渐变)展示即可。可视化逻辑用Bitmap的setPixel逐像素生成就行,在全分辨率下会稍微慢一些,但控制在每帧几毫秒,可接受。
4. 性能优化与踩坑经验:帧率、色彩偏差和内存抖动
4.1 帧率到不了实时的罪魁祸首:反复分配对象
实测时最容易遇到的性能瓶颈不是模型计算,而是每帧处理中反复创建临时对象造成的GC(垃圾回收)风暴。Kotlin在函数里频繁创建ByteArray、FloatArray、Bitmap,会导致GC频繁执行,GC一跑,主线程就被卡顿,帧率肉眼可见地下降。
解决办法是把所有中间缓冲区在初始化阶段一次性分配好,之后每帧直接复用。我的做法是把YUV转RGB的ByteArray、模型输入的ByteBuffer、模型输出的FloatArray都做成类成员,Process帧的方法里只填充数据,不分配新对象。经过这个优化,中端机型上的帧率能提升一倍以上,这个提升幅度比换任何量化方案都明显。
另一个关键点是:CameraX的ImageAnalysis回调其实是在单独的Handler线程上执行的,分析器内部不要做任何UI操作,否则会阻塞主线程。结果可视化部分用ImageReader或者直接在分析器线程里把Bitmap生成好,再通过runOnUiThread丢给ImageView刷新。
4.2 色彩偏差排查:预处理一致性比模型本身更影响精度
这是我踩过最大的一个坑。训练时数据先做了线性化,但部署到Android上时忘了做sRGB到线性RGB的解码,直接拿非线性RGB送进模型。结果重建出来的光谱曲线整体偏低,尤其在高光区域,绿色波段的强度被大幅削弱,伪彩图看起来像蒙了一层旧照片的滤镜。
这个问题在开发调试阶段非常隐蔽,因为视觉上看整体颜色“似乎差不多”,但如果你把重建出来的光谱曲线和真实光谱曲线放在一起对比,就会发现系统性偏差。所以务必在Android端做和训练时完全一致的预处理:
fun srgbToLinear(rgb: ByteArray): FloatArray { val out = FloatArray(rgb.size) for (i in rgb.indices) { val c = (rgb[i].toInt() and 0xff) / 255.0f out[i] = if (c <= 0.04045f) { c / 12.92f } else { Math.pow(((c + 0.055f) / 1.055f).toDouble(), 2.4).toFloat() } } return out }另外,白平衡也很影响重建精度。手机默认的白平衡会自动调整场景色温,导致输入图像的颜色分布不稳定。最好把CameraX的WhiteBalanceControl固定到某个色温,或者在相机预览里锁定白平衡,否则同一个场景在不同光线下重建出的光谱曲线不一致。我用固定色温5000K测试过,比自动白平衡的场景一致性高很多。
4.3 相机传感器的动态范围限制:过曝区域光谱信息丢失
手机传感器的动态范围远比高光谱相机窄。高光谱设备每个波段曝光可调,能把暗部和亮部细节都记录下来,但手机一张图过大,高光区域比如天空、白墙、金属反光会直接过曝,像素值拥挤在255附近,这部分区域的信息已经丢失,再强的模型也无法恢复原光谱。同样的,暗部区域噪声大,重建误差也大。
所以实际部署时要注意两点:一是引导用户拍摄时光线要均匀,尽量避免正对强光源和强反光面;二是在App的原生相机参数里适当降低曝光补偿,让高光区域不过曝,即使暗部稍微偏暗也没关系,等重建后亮度能由RGB通道向光谱映射时补回来一点。我实测降低0.7EV曝光后,高光区域的光谱重建误差能下降近一半。
这也决定了应用体验的边界。指望手机RGB重建高光谱在强逆光下还保持高精度,目前不太现实,物理信息已经丢失了。在合理拍摄条件下把误差控住,才是我们能做好的事。
5. 实测效果与后续扩展思路
5.1 实测效果:什么样的场景重建得准
在室内均匀光照、室外阴天和晴天背光三种场景下,我拿骁龙中端芯片的手机跑INT8量化模型,帧率稳定在18到22FPS,算子推理单帧耗时约40到55毫秒,加上YUV转换和可视化,整体延迟在90毫秒上下,对着场景移动时能看到伪彩图的连续刷新,基本达到可交互程度。
从重建精度上看,MRAE在测试集上为0.042,实际手持拍摄时由于镜头、白平衡、噪声等因素干扰,估计MRAE在0.06到0.09之间。这个量级做定性分析完全够用,比如区分不同成熟度的植物叶片、识别不同材质的面料、检测食品表面的局部变化,但当需要精确到1%以内反射率差别的定量分析时,还达不到替代专业高光谱仪的水平。
神经网络在可见光波段中段(490到650纳米)重建精度最高,因为RGB相机在这段信息最丰富。而在400到450纳米的蓝紫波段和700纳米以上的红边区域,由于相机灵敏度下降,重建误差明显增大。做应用开发时,如果想判断指标变化,尽量选在中段波段的特征。
5.2 做一个能上手的波段分析工具
顺带提一个很有用的思路:重建出31通道高光谱数据后,可以在App里做一个波段比分析的小工具。用户拍照或实时预览时,选定两个波段,App实时计算比值并映射为热力图叠加到画面上。这种功能在植物健康检测里是经典应用,健康植被在红光波段反射率低、近红外波段反射率高,波段比值能直观标出每片叶子的活性差异。
实现上就是定义一个分辨率与预览图像一致的二维数组,每帧计算后映射到Color,再用半透明的方式叠加到预览画面之上。整个计算量其实很小,主要开销在Canvas绘制。这样可以做成一个用户可交互的“光谱分析工作台”,比单独展示一张伪彩图更有实用价值。
5.3 后面的扩展方向:多帧融合和小模型蒸馏
接下来值得尝试的方向有两个。第一是多帧融合:同一场景连续拍多帧,利用对齐和平均来抑制传感器噪声,特别是暗部区域,能显著提升重建质量。毕竟实时预览只是满足了“能调准角度”的需求,真要分析数据,拍三到五帧叠加一分钟,效果比单帧好不少。
第二个方向是模型蒸馏:先用大模型(比如深度残差网络变种)在大量数据上训练出一个精度高的老师模型,然后把预测结果当作软标签,训练一个小学生模型(比如MobileNetV2编码器的U-Net),让它维持较大模型的大部分精度,参数却少了数倍。这样在手机上的实时性能还能再提升,甚至可以腾出算力去跑更高分辨率的输入。如果想把这个项目产品化,这个方向值得投入时间。
我自己在这条链路上的体会是:光谱重建在移动端落地没有那么遥不可及,但也不能把期望定得太高。它更像是在手机有限的感知通道里,利用先验知识“脑补”出一部分光谱信息,而这个脑补的置信度在不同光照、不同材质下差异很大。把重建模型、预处理链路、可视化方案作为一个整体去调优,比单独追求模型的MRAE更有实际意义。如果你也想试,建议先拿公开数据集跑通一次完整的离线重建流程,再上Android端,分阶段排查问题的难度会小很多。