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;
}
小技巧:如果模型输出已经是概率(比如用了 Softmax 层),就别再算一次了。我见过有人重复 Softmax,结果概率全变成接近 0,排查了半天。

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;
    }
}
注意:K 不要设太大。我曾经为了展示全面设了 K=20,结果 UI 列表太长,用户反而看不清。移动端屏幕有限,5 个足够了。

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 倍,内存占用也小。
核心思路:后处理不是简单的「算一下概率」,而是连接模型和用户的桥梁。做得好,用户觉得 App 聪明;做得差,模型再准也没用。

15.5 知识体系总览

下面这张图概括了本章的核心流程,从原始输出到用户看到结果,每一步都环环相扣:

图像分类后处理流程 原始推理输出 float[] 原始分数 Softmax 归一化 映射为概率分布 Top-K 筛选 取前 K 个最高概率 标签映射 索引 → 类别名称 UI 绑定展示 RecyclerView 列表 性能优化 内存复用 / 量化 / 异步 整个流程从模型输出到用户可见,每一步都影响最终体验 后处理做得好,用户觉得 App 聪明;做得差,模型再准也没用

15.6 避坑指南

最后,分享几个我踩过的坑:

  • 标签文件索引从 0 开始还是从 1 开始? 大部分模型输出索引从 0 开始,但有些从 1 开始。我曾经没注意,结果「猫」显示成了「狗」,被测试骂惨了。建议先打印几个已知图片的索引,人工核对一下。
  • 概率显示精度:别显示太多小数位。用户看到「98.7654%」会觉得奇怪,显示「98.8%」就够了。我一般保留一位小数。
  • 空结果处理:如果所有概率都低于某个阈值(比如 0.1),说明模型不确定。这时候别硬显示第一名,直接提示「无法识别」更诚实。
  • 多线程竞争:如果多个线程同时推理,后处理的数据要加锁或者用 ThreadLocal。我遇到过概率数组被覆盖的 bug,排查了两天才发现。
我的习惯:在开发阶段,我会把 Softmax 后的概率全部打印到 logcat,用表格形式排列。这样一眼就能看出模型对哪些类别「犹豫不决」,方便调优。

好了,这一章的内容就到这里。后处理虽然看起来简单,但细节很多。把每一步都做扎实了,你的图像分类 App 才能真正好用。


公众号:蓝海资料掘金营,微信deep3321