15、图像分类实战(二):后处理(Softmax、Top-K)、结果显示与UI绑定、性能优化
上一章我们把模型跑起来了,拿到了原始的推理结果。嗯,那个结果其实是一堆浮点数,看着像天书。这一章,我们就来聊聊怎么把这堆数字变成人能看懂的东西——说白了,就是后处理。
我个人习惯把后处理分成三步:Softmax归一化、Top-K筛选、UI绑定展示。每一步都有坑,我踩过不少,今天一并分享给你。
15.1 Softmax:把分数变成概率
模型输出的原始值,可能是正数也可能是负数,范围也不固定。比如某个类别得分是 2.5,另一个是 1.2,这能直接比吗?其实不能。因为不同模型的输出尺度不一样,2.5 在模型A里可能很高,在模型B里可能只是中等。
Softmax 的作用,就是把任意实数向量,映射成 [0,1] 之间的概率分布,并且所有概率之和为 1。公式很简单:
softmax(x_i) = exp(x_i) / sum(exp(x_j)) for j in 1..N
我在项目中遇到过一个问题:当某个 x_i 特别大时,exp(x_i) 会溢出。比如 x_i = 100,exp(100) 直接爆 float 上限。解决办法是:先减去最大值,再做 exp。这样既保证数值稳定,又不改变相对大小。
来看代码实现:
public float[] softmax(float[] input) {
float max = Float.NEGATIVE_INFINITY;
for (float v : input) {
if (v > max) max = v;
}
float sum = 0f;
float[] output = new float[input.length];
for (int i = 0; i < input.length; i++) {
output[i] = (float) Math.exp(input[i] - max);
sum += output[i];
}
for (int i = 0; i < output.length; i++) {
output[i] /= sum;
}
return output;
}
15.2 Top-K:只取最可能的几个
ImageNet 有 1000 个类别,你不可能全显示给用户。用户只关心「最像的那几个」。Top-K 就是干这个的。
K 一般取 1、3 或 5。我个人习惯取 5,因为有时候模型的第一名是错的,但第二名或第三名是对的——尤其是当类别很相似时,比如「哈士奇」和「阿拉斯加」。
实现思路:维护一个大小为 K 的最小堆,遍历所有概率,堆顶是当前第 K 大的值。如果新概率比堆顶大,就替换掉堆顶,重新调整堆。这样时间复杂度是 O(N log K),比全排序快很多。
public List<TopKResult> topK(float[] probabilities, int k) {
PriorityQueue<TopKResult> heap = new PriorityQueue<>(k, (a, b) -> Float.compare(a.probability, b.probability));
for (int i = 0; i < probabilities.length; i++) {
if (heap.size() < k) {
heap.offer(new TopKResult(i, probabilities[i]));
} else if (probabilities[i] > heap.peek().probability) {
heap.poll();
heap.offer(new TopKResult(i, probabilities[i]));
}
}
List<TopKResult> results = new ArrayList<>(heap);
results.sort((a, b) -> Float.compare(b.probability, a.probability));
return results;
}
public static class TopKResult {
public int index;
public float probability;
public TopKResult(int index, float probability) {
this.index = index;
this.probability = probability;
}
}
15.3 结果显示与UI绑定
拿到 Top-K 结果后,需要把类别索引映射成人类可读的标签。ImageNet 的标签文件是一个文本文件,每行一个类别名。我建议在应用启动时一次性加载到内存,避免每次推理都读文件。
public class LabelLoader {
private List<String> labels;
public LabelLoader(Context context, int rawResId) {
labels = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(context.getResources().openRawResource(rawResId)))) {
String line;
while ((line = reader.readLine()) != null) {
labels.add(line.trim());
}
} catch (IOException e) {
e.printStackTrace();
}
}
public String getLabel(int index) {
if (index >= 0 && index < labels.size()) {
return labels.get(index);
}
return "unknown";
}
}
UI 绑定我推荐用 RecyclerView,每个 item 显示排名、标签名和概率条。概率条可以用一个水平 ProgressBar 或者自定义 View,宽度按概率比例缩放。这样用户一眼就能看出哪个类别置信度高。
嗯,这里要注意:UI 更新必须在主线程。推理是在子线程做的,所以拿到结果后要 post 到主线程。我习惯用 Handler 或者 LiveData。
// 在子线程推理完成后
runOnUiThread(() -> {
adapter.setResults(topKResults);
adapter.notifyDataSetChanged();
});
15.4 性能优化:让推理更快更稳
图像分类的实时性很重要,尤其是做视频流分类时。我总结了几条优化经验:
- 减少内存分配:每次推理都 new 数组?别。提前分配好 float[] 和 ByteBuffer,重复使用。GC 抖动会严重影响帧率。
- 输入图像预处理优化:Bitmap 缩放用 Matrix 或者 RenderScript,别用 Canvas.drawBitmap 逐像素操作,太慢。
- 使用 NNAPI 的加速模式:ANEURALNETWORKS_PREFER_SUSTAINED_SPEED 适合长时间运行,ANEURALNETWORKS_PREFER_FAST_SINGLE_ANSWER 适合单次推理。根据场景选。
- 异步推理:别阻塞 UI 线程。用 NNAPI 的异步执行接口,或者自己维护一个推理线程池。
- 模型量化:如果精度允许,用 INT8 量化模型。推理速度能快 2-3 倍,内存占用也小。
15.5 知识体系总览
下面这张图概括了本章的核心流程,从原始输出到用户看到结果,每一步都环环相扣:
15.6 避坑指南
最后,分享几个我踩过的坑:
- 标签文件索引从 0 开始还是从 1 开始? 大部分模型输出索引从 0 开始,但有些从 1 开始。我曾经没注意,结果「猫」显示成了「狗」,被测试骂惨了。建议先打印几个已知图片的索引,人工核对一下。
- 概率显示精度:别显示太多小数位。用户看到「98.7654%」会觉得奇怪,显示「98.8%」就够了。我一般保留一位小数。
- 空结果处理:如果所有概率都低于某个阈值(比如 0.1),说明模型不确定。这时候别硬显示第一名,直接提示「无法识别」更诚实。
- 多线程竞争:如果多个线程同时推理,后处理的数据要加锁或者用 ThreadLocal。我遇到过概率数组被覆盖的 bug,排查了两天才发现。
好了,这一章的内容就到这里。后处理虽然看起来简单,但细节很多。把每一步都做扎实了,你的图像分类 App 才能真正好用。