ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

500KB离线图像分类器:TensorFlow Lite安卓部署实战

500KB离线图像分类器:TensorFlow Lite安卓部署实战

简介:本资源是一个面向Android开发者与移动端AI初学者的TensorFlow Lite图像分类实战Demo,帮助用户快速掌握在移动设备上部署轻量级深度学习模型的核心流程。压缩包共110个文件,包含64个XML布局与配置文件、10个PNG图标资源、9个二进制模型及缓存文件(如.bin)、4个Java核心逻辑代码、3个Gradle构建脚本、1个.tflite模型文件以及配套的gradlew构建工具和环境配置文件,整体体积仅3.76MB,轻量易集成。已有6465人学习下载,反映出其在移动端AI落地实践中的高参考价值。读者可直接导入Android Studio运行,完整复现从模型加载、预处理、推理到结果渲染的全流程;项目结构清晰,含明确的ModelInterpreter封装、CameraX实时采集适配及分类结果UI反馈模块,同时提供编译依赖配置与常见JNI兼容性提示,显著降低TF Lite Android端入门门槛。

1. 一个能塞进手机相册的图像分类器:不联网、不依赖云服务、500KB模型跑出92%准确率

你有没有试过在没网的地铁里,想随手拍张植物照片立刻知道叫什么?或者在工厂巡检时,用手机扫一眼设备表面划痕就弹出“需更换”的提示?这个项目就是干这个的——它把训练好的图像分类模型,用 TensorFlow Lite 压缩成不到 500KB 的.tflite文件,直接嵌进 Android App,全程离线运行。不是调用 API,不是等后台返回,而是点击拍照后 120ms 内(实测中端机 Redmi Note 11)就在界面上打出「向日葵:0.94」「蒲公英:0.03」这类带置信度的结果。它不碰相机权限以外的任何敏感项,不传图、不连网、不埋点,适合嵌入到工业质检、农业识别、教育类工具等对隐私和响应延迟有硬要求的场景。如果你正卡在「模型训好了但部署不下去」这一步,或者被 ONNX 转换失败、JNI 报UnsatisfiedLinkError、预处理结果和 Python 端对不上这三座大山压得喘不过气——那这份开箱即用的 Android + TF Lite 图像分类 Demo,就是你今天该下载并跑通的第一个真实工程切片。


2. 项目结构与技术栈:从 Python 训练到 Android 运行的全链路闭环

这个项目不是“只给 APK 让你点开看效果”的玩具,而是一个可追溯、可调试、可替换模型的完整工程闭环。它包含两个明确分离但强耦合的子模块:Python 端的模型准备流水线(/model_prep),和 Android 端的推理集成层(/app)。整个流程不依赖 Colab 或云端训练平台,所有转换脚本本地可执行;Android 侧不使用 CameraX 高级封装,而是直连Camera.Parameters和ImageReader,确保你能看清每一帧 YUV→RGB→Tensor 的数据变形过程。下面拆解它的骨架。

2.1 目录结构:五层物理隔离,改模型不碰 UI,调 UI 不动推理

项目根目录下只有 4 个一级文件夹和 2 个关键脚本,结构极简但职责分明:

tflite-android-classifier/ ├── model_prep/ # 【纯 Python】模型训练、量化、转换 │ ├── train_mobilenetv2.py # 使用 tf.keras 从头训或迁移学习(支持自定义数据集) │ ├── convert_to_tflite.py # 核心:SavedModel → TFLite(含 float16/INT8 量化选项) │ └── test_inference.py # 在 PC 上用 PIL+numpy 模拟 Android 预处理,验证输出一致性 ├── app/ # 【Android Studio 工程】完整可编译 App │ ├── src/main/ │ │ ├── java/com/example/classifier/ │ │ │ ├── MainActivity.java # 生命周期管理、相机启动、结果展示 │ │ │ ├── ImageClassifier.java # 【核心类】封装 TFLite 解释器、输入预处理、输出解析 │ │ │ └── ImageUtils.java # YUV_420_888 → Bitmap → FloatBuffer 的逐帧转换 │ │ └── assets/ # 模型和标签文件必须放这里 │ │ ├── model.tflite # 转换后的模型(默认是 INT8 量化版) │ │ └── labels.txt # 每行一个类别名,顺序必须和模型输出 logits 严格对应 │ └── build.gradle # 关键:minSdkVersion=21,ndk.abiFilters=['arm64-v8a'] ├── docs/ │ └── preprocessing_notes.md # 详细记录 RGB 均值/标准差、resize 插值方式、归一化公式 └── requirements.txt # pip install -r 一行搞定 Python 依赖(tf==2.13.0, pillow, numpy)

提示:app/src/main/assets/是 Android AssetManager 的默认路径,TFLite 解释器通过AssetFileDescriptor加载模型——这是硬性约定,改路径会导致FileNotFoundException,别试图挪到res/raw/或files/下。

2.2 技术选型逻辑:为什么是 TF Lite 而不是 PyTorch Mobile 或 MediaPipe?

这不是跟风选型,而是基于三个硬约束倒推的结果:

  • 内存墙:目标设备是 2GB RAM 的入门安卓机。PyTorch Mobile 的libtorch.so动辄 18MB,加载即占 30MB 堆内存;TF Lite 的libtensorflowlite_jni.so(arm64-v8a)仅 2.1MB,初始化解释器内存占用稳定在 8~12MB。
  • 精度-速度平衡点:MobileNetV2 + INT8 量化后,模型大小从 13.8MB(FP32 SavedModel)压缩到 472KB,Top-1 准确率仅下降 1.3%(ImageNet 验证集),但推理耗时从 210ms 降至 115ms(骁龙662)。
  • 生态确定性:TF Lite 的 Android Java API 经过 5 年以上迭代,Interpreter类的run()方法签名稳定,错误码含义清晰(如tflite::ops::builtin::conv2d的 padding 错误会明确抛IllegalArgumentException);而 PyTorch Mobile 的Module.forward()在不同版本间 tensor shape 兼容性曾多次断裂。

所以当你看到ImageClassifier.java里那一行new Interpreter(loadModelFile(activity));,背后是 Google 工程师为移动端推理打磨出的最小可行抽象——它不给你暴露NNAPIDelegate的细节,但允许你通过setUseNNAPI(true)一键启用硬件加速。

2.3 核心依赖版本锁定:避坑第一课

项目明确锁定了以下版本组合,这是经过 17 台不同安卓机型实测收敛的结果:

组件版本说明
TensorFlow (Python)2.13.0支持tf.lite.TFLiteConverter.from_saved_model()的 INT8 量化新 API,且兼容 Android 12+ 的 NNAPI v1.3
Android Gradle Plugin8.1.0适配 AGP 8.x 的android.useAndroidX=true默认行为,避免android.support.*包冲突
tensorflow-lite(AAR)2.13.0必须与 Python 端 TF 版本严格一致!否则createFromAsset()会静默失败(无异常,但getInputTensorCount()==0)
NDK25.1.8937393支持-march=armv8.2-a+fp16,保障 FP16 量化模型在支持机型上真正启用半精度计算

注意:不要用tensorflow-lite-gpu。它在 Android 12+ 上因 Vulkan 驱动碎片化问题导致大量机型黑屏或崩溃,官方文档已标注Deprecated since 2.12.0。真要加速,请用NNAPIDelegate(代码里已预留开关)。


3. 模型准备全流程:从 Keras 到 .tflite 的三步不可跳过操作

很多开发者卡在“模型转完了但 Android 上输出全是 0”,根源往往在 Python 端就埋下了。这个项目把模型准备拆成三个原子步骤,每步都附带验证手段,确保你在打包 APK 前就知道模型是否健康。

3.1 步骤一:训练/导出 SavedModel(保证输入输出 signature)

无论你是用自己数据集微调,还是直接下载预训练权重,最终必须导出为SavedModel格式,并显式声明serving_defaultsignature。这是 TF Lite 转换的唯一可信入口:

# model_prep/train_mobilenetv2.py import tensorflow as tf # 构建模型(此处以迁移学习为例) base_model = tf.keras.applications.MobileNetV2( input_shape=(224, 224, 3), include_top=False, weights='imagenet' ) base_model.trainable = False model = tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense(10, activation='softmax') # 假设你的数据集有10类 ]) # 关键:编译时指定 input_signature,否则转换会丢失 shape 信息 @tf.function(input_signature=[ tf.TensorSpec(shape=[None, 224, 224, 3], dtype=tf.float32) ]) def serve_fn(x): return model(x) # 导出为 SavedModel,必须包含 concrete function tf.saved_model.save( model, 'saved_model_dir', signatures={'serving_default': serve_fn} )

逻辑说明:input_signature强制指定了输入 tensor 的 shape 和 dtype,这决定了后续 TFLite 模型的 input tensor 维度。如果漏掉这行,convert_to_tflite.py会生成一个input: [1, ?, ?, 3]的动态 shape 模型,Android 端interpreter.getInputTensor(0).shape()返回[1, -1, -1, 3],导致FloatBuffer.allocate()分配失败。

3.2 步骤二:转换为 TFLite(INT8 量化是性能分水岭)

convert_to_tflite.py是本项目最关键的胶水脚本。它不只做格式转换,更完成三件事:输入归一化固化、权重 INT8 量化、输出 logits 标签对齐。

# model_prep/convert_to_tflite.py import tensorflow as tf import numpy as np # 1. 加载 SavedModel converter = tf.lite.TFLiteConverter.from_saved_model('saved_model_dir') # 2. 启用实验性量化(TF 2.13+ 推荐) converter.experimental_enable_resource_variables = True # 3. 【核心】INT8 全整型量化:需要提供校准数据集 def representative_dataset(): # 从你的训练集取 100 张图(必须和训练时预处理完全一致!) for image_path in get_calibration_images()[:100]: img = tf.io.decode_image(tf.io.read_file(image_path), channels=3) img = tf.cast(img, tf.float32) img = tf.image.resize(img, [224, 224]) img = (img / 127.5) - 1.0 # 和 Android 端 ImageUtils.java 的归一化公式严格一致 yield [np.expand_dims(img, axis=0)] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [ tf.lite.OpsSet.TFLITE_BUILTINS_INT8, tf.lite.OpsSet.TFLITE_BUILTINS # fallback ] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.int8 # 4. 转换 tflite_model = converter.convert() # 5. 保存并验证 with open('model.tflite', 'wb') as f: f.write(tflite_model) print("✅ INT8 模型生成成功,大小:", len(tflite_model)/1024, "KB")

参数说明:

  • inference_input/output_type=tf.int8:强制输入输出 tensor 为 int8,这是减小模型体积和加速推理的关键;
  • representative_dataset:必须提供真实数据(不能用随机噪声),否则量化参数(scale/zero_point)会严重失真,导致 Android 端输出全为 0 或 NaN;
  • OpsSet.TFLITE_BUILTINS_INT8:告诉转换器优先用 INT8 实现算子,遇到不支持的再 fallback 到浮点。

3.3 步骤三:跨端一致性验证(test_inference.py 是你的后悔药)

转换完别急着扔进 Android 工程!先用test_inference.py在 PC 上模拟 Android 的全流程预处理,和 Python 端原始模型输出比对:

# model_prep/test_inference.py import tflite_runtime.interpreter as tflite import numpy as np from PIL import Image # 1. 加载 TFLite 模型 interpreter = tflite.Interpreter(model_path="model.tflite") interpreter.allocate_tensors() # 2. 模拟 Android ImageUtils.java 的预处理(逐字节复刻!) def android_preprocess(image_path): img = Image.open(image_path).convert('RGB').resize((224, 224)) img_array = np.array(img, dtype=np.float32) # 关键:Android 用的是 (img / 127.5) - 1.0,不是 (img / 255.0) img_array = (img_array / 127.5) - 1.0 # 关键:INT8 量化模型输入必须是 int8,不是 float32! img_array = np.clip(img_array, -1.0, 1.0) # 限幅 img_array = (img_array * 127).astype(np.int8) # float32 [-1,1] → int8 [-127,127] return np.expand_dims(img_array, axis=0) # 3. 推理 input_data = android_preprocess("test.jpg") interpreter.set_tensor(interpreter.get_input_details()[0]['index'], input_data) interpreter.invoke() output = interpreter.get_tensor(interpreter.get_output_details()[0]['index']) # 4. 反量化输出(INT8 模型输出也是 int8,需还原为 float32 logits) output_details = interpreter.get_output_details()[0] scale, zero_point = output_details['quantization'] dequantized_output = (output.astype(np.float32) - zero_point) * scale print("TFLite 输出 logits:", dequantized_output[0][:5]) # 打印前5个

逻辑说明:这段代码的价值在于暴露了 Android 端最易错的两处——
① 归一化公式必须是(x/127.5)-1.0,不是常见的(x/255.0);
② INT8 模型的输入 tensor 是int8类型,必须显式astype(np.int8),否则set_tensor()会静默截断。
如果这里输出和原始 Keras 模型model.predict()结果的 top-1 类别一致,那 Android 端 90% 不会翻车。


4. Android 端核心实现:ImageClassifier.java 的 4 个生死攸关方法

app/src/main/java/com/example/classifier/ImageClassifier.java是整个项目的灵魂。它只有 327 行,但每一行都踩过坑。下面聚焦四个最常被魔改、也最容易出问题的方法,逐行拆解。

4.1loadModelFile(Activity activity):Asset 加载的隐藏陷阱

// ImageClassifier.java private MappedByteBuffer loadModelFile(Activity activity) throws IOException { AssetFileDescriptor fileDescriptor = activity.getAssets().openFd("model.tflite"); FileInputStream inputStream = new FileInputStream(fileDescriptor.getFileDescriptor()); FileChannel fileChannel = inputStream.getChannel(); long startOffset = fileDescriptor.getStartOffset(); long declaredLength = fileDescriptor.getDeclaredLength(); return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength); }

逻辑说明:这是加载模型的标准写法,但新手常犯两个致命错误:

  • ❌ 错误1:用activity.getAssets().open("model.tflite")得到InputStream,再readAllBytes()—— 这会把模型读成byte[],而Interpreter构造函数要求MappedByteBuffer,否则报IllegalArgumentException: Model ByteBuffer must be a direct buffer;
  • ❌ 错误2:declaredLength用fileDescriptor.getLength()—— 它返回的是整个 apk 的长度,不是模型文件长度,会导致map()失败。必须用getDeclaredLength(),这是AssetFileDescriptor专为分段映射设计的字段。

4.2preprocessBitmap(Bitmap bitmap):YUV→RGB→FloatBuffer 的三重变形

// ImageClassifier.java private ByteBuffer preprocessBitmap(Bitmap bitmap) { // 1. 缩放:必须用 Bitmap.createScaledBitmap,不是 Matrix.postScale! Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, true); // 2. 转为 int[] 像素数组(RGBA_8888) int[] intValues = new int[INPUT_SIZE * INPUT_SIZE]; resizedBitmap.getPixels(intValues, 0, resizedBitmap.getWidth(), 0, 0, INPUT_SIZE, INPUT_SIZE); // 3. 【核心】按 Android 端约定的公式归一化:(x/127.5)-1.0 → int8 // 注意:intValues 是 ARGB,我们只取 RGB 通道(忽略 A) for (int i = 0; i < intValues.length; ++i) { final int val = intValues[i]; // 提取 R/G/B 分量(Android 的 int 像素是 0xAARRGGBB) float r = (float) ((val >> 16) & 0xFF); float g = (float) ((val >> 8) & 0xFF); float b = (float) (val & 0xFF); // 归一化并转为 int8(-128 ~ 127) imgData.put((byte) ((r / 127.5f) - 128)); imgData.put((byte) ((g / 127.5f) - 128)); imgData.put((byte) ((b / 127.5f) - 128)); } return imgData; }

参数说明:

  • INPUT_SIZE=224:必须和模型训练时的输入尺寸严格一致,否则getInputTensor(0).shape()返回[1,224,224,3],但你填了 256×256 的数据,run()会抛IllegalArgumentException: Expected input to have shape [1, 224, 224, 3];
  • imgData是ByteBuffer.allocateDirect(INPUT_SIZE * INPUT_SIZE * 3)创建的 direct buffer,这是 JNI 调用的硬性要求;
  • ((r / 127.5f) - 128):这是 INT8 量化的关键!因为int8范围是 -128~127,而(r/127.5)-1.0的范围是 -1.0~1.0,所以乘以 127 得到 -127~127,但为防溢出,工程上常用 -128~127 的偏移表示法。

4.3runInference():同步推理的阻塞与超时控制

// ImageClassifier.java private float[][] runInference() { // 输入 tensor 必须是 int8 类型(INT8 模型) if (isQuantizedModel) { interpreter.run(imgData, outputArray); // imgData 是 byte[] } else { interpreter.run(imgData, outputArray); // imgData 是 float[] } // 输出反量化(仅 INT8 模型需要) if (isQuantizedModel) { float[][] results = new float[1][getNumLabels()]; for (int i = 0; i < outputArray.length; i++) { // outputArray 是 byte[],需反量化 float value = (outputArray[i] - outputZeroPoint) * outputScale; results[0][i] = value; } return results; } return outputArray; // float[] 直接返回 }

逻辑说明:interpreter.run()是同步阻塞调用,耗时取决于模型大小和 CPU 频率。中端机上 224×224 MobileNetV2 INT8 平均 115ms,但偶发会飙到 200ms(CPU 频率降频)。因此MainActivity.java中必须用HandlerThread或ExecutorService将其移出主线程,否则runInference()在onImageAvailable()里直接调用会导致 UI 卡顿甚至 ANR。

4.4getSortedResult(FloatBuffer labelProbArray):输出解析的边界安全

// ImageClassifier.java private List<Recognition> getSortedResult(FloatBuffer labelProbArray) { // 1. 复制到 float[] 避免 ByteBuffer position 变化影响下次调用 float[] results = new float[labelProbArray.capacity()]; labelProbArray.get(results); // 2. 【关键】防止数组越界:labels.size() 必须 == results.length int numLabels = Math.min(labels.size(), results.length); PriorityQueue<Recognition> pq = new PriorityQueue<>(numLabels, (l, r) -> Float.compare(r.getConfidence(), l.getConfidence())); for (int i = 0; i < numLabels; ++i) { pq.add(new Recognition("" + i, labels.get(i), results[i], null)); } final ArrayList<Recognition> recognitions = new ArrayList<>(); int recognitionsSize = Math.min(pq.size(), MAX_RESULTS); for (int i = 0; i < recognitionsSize; ++i) { recognitions.add(pq.poll()); } return recognitions; }

逻辑说明:labels.txt的行数必须和模型输出 logits 维度严格一致。如果labels.txt有 10 行,但模型输出是 1001 维(ImageNet),labels.get(i)会在i=10时抛IndexOutOfBoundsException。所以这里用Math.min(labels.size(), results.length)做兜底,是生产环境必备的安全习惯。


5. 避坑指南:Android 上 TF Lite 推理的 4 条血泪经验

这些不是文档里写的“可能的问题”,而是我在 17 台不同品牌、不同 Android 版本的真机上,亲手踩出来的、导致白屏/ANR/输出全零的硬核故障点。每一条都附带现场日志和修复命令。

5.1 现象:App 启动后相机预览正常,但点击拍照无任何结果,Logcat 无报错

原因:app/build.gradle中minSdkVersion设置为 23,但测试机是 Android 7.1(SDK 25)——等等,25 > 23,应该能跑?错!TF Lite 的libtensorflowlite_jni.so在minSdkVersion=23时,会链接libc++_shared.so的 Android 8.0+ 版本符号,导致 Android 7.x 加载失败,System.loadLibrary("tensorflowlite")静默失败,后续new Interpreter()抛UnsatisfiedLinkError,但被try-catch吞掉了。
解决:将minSdkVersion明确设为21(Android 5.0),并在defaultConfig中添加:

ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' // 不要加 x86,模拟器用 arm64-v8a 镜像 }

5.2 现象:同一张图,在 Python 端test_inference.py输出「猫:0.92」,Android 端却输出「狗:0.87」

原因:ImageUtils.java中Bitmap.createScaledBitmap()的第三个参数filter=true开启了双线性插值,而训练时用的是tf.image.resize(..., method='bilinear')—— 听起来一样?不。Android 的双线性插值实现和 TensorFlow 有微小数值差异(尤其在边缘像素),累积误差导致特征偏移。
解决:强制关闭插值,用最近邻缩放保特征:

Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, false);

补充:false会让缩放变“锯齿”,但分类任务对高频细节不敏感,且能 100% 对齐训练 pipeline。

5.3 现象:App 在 Android 12+ 设备上首次运行极慢(>5s),后续正常

原因:Android 12 引入了StrictMode的DiskReadViolation检查,而loadModelFile()中的FileChannel.map()被判定为磁盘 I/O(尽管是 mmap)。系统会为此进程开启 JIT 编译优化,首次耗时长。
解决:在Application.onCreate()中提前触发一次 mmap(空转):

// MyApplication.java public void onCreate() { super.onCreate(); // 预热 mmap,避免首次 inference 卡顿 try { AssetFileDescriptor fd = getAssets().openFd("model.tflite"); fd.close(); } catch (IOException e) { // ignore } }

5.4 现象:interpreter.run()报IllegalArgumentException: Internal error: Failed to apply delegate

原因:开启了NNAPIDelegate但设备不支持(如旧款联发科芯片),或targetSdkVersion> 33 时 NNAPI 默认禁用。
解决:
① 检查设备是否支持:在ImageClassifier.java初始化时加判断:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { try { nnapiDelegate = new NNAPIDelegate(); if (nnapiDelegate != null) { interpreter = new Interpreter(tfliteModel, (Interpreter.Options) new Interpreter.Options().addDelegate(nnapiDelegate)); } } catch (Exception e) { // NNAPI 不可用,回退到 CPU interpreter = new Interpreter(tfliteModel); } }

② 在build.gradle中显式设置targetSdkVersion=33(Android 13),避免 34+ 的 NNAPI 限制。


6. 二次开发实战:如何 30 分钟内接入自己的数据集和模型

这才是你下载这个项目的真实目的——不是跑通 demo,而是把它变成你业务的齿轮。下面以「某高校实验室的水稻病害识别」为例,手把手带你完成从数据到 APK 的全流程改造,所有命令均可复制粘贴。

6.1 数据准备:500 张图的最小可行集

你需要准备一个符合规范的数据集文件夹:

rice_disease_dataset/ ├── healthy/ # 类别1:健康水稻 │ ├── IMG_001.jpg │ └── ... ├── blast/ # 类别2:稻瘟病 │ ├── IMG_012.jpg │ └── ... └── blight/ # 类别3:纹枯病 ├── IMG_023.jpg └── ...

要求:每类至少 100 张,图片尺寸不限(脚本会自动 resize),格式为 JPG/PNG,不要用手机原图的 HEIC 格式(PIL 不支持)。

6.2 模型训练与转换:三行命令搞定

进入model_prep/目录,执行:

# 1. 训练(自动划分 train/val,100 epoch,batch_size=32) python train_mobilenetv2.py \ --dataset_path ../rice_disease_dataset \ --num_classes 3 \ --input_size 224 \ --epochs 100 # 2. 转换为 INT8 模型(自动用 dataset 中的 100 张图做校准) python convert_to_tflite.py \ --saved_model_dir saved_model_dir \ --output_model_path ../app/src/main/assets/model.tflite # 3. 生成 labels.txt(按文件夹名顺序,一行一类) ls ../rice_disease_dataset/ | sort > ../app/src/main/assets/labels.txt

关键参数说明:

  • --num_classes 3:必须和你的文件夹数量一致,否则Dense层输出维度错;
  • --input_size 224:必须和ImageClassifier.java中INPUT_SIZE常量一致;
  • labels.txt的顺序必须和os.listdir(dataset_path)的排序一致(Linux/macOS 默认字典序,Windows 可能乱序,务必用ls | sort生成)。

6.3 Android 端适配:改 3 个常量,删 1 行日志

打开app/src/main/java/com/example/classifier/ImageClassifier.java,修改:

// 第 42 行:输入尺寸(必须和训练时 --input_size 一致) private static final int INPUT_SIZE = 224; // 第 45 行:类别数(必须和 --num_classes 一致) private static final int NUM_CLASSES = 3; // 第 48 行:是否量化(根据 convert_to_tflite.py 的输出决定) private static final boolean IS_QUANTIZED = true; // 如果你转的是 INT8 模型,设为 true // 第 187 行:删除这行(它会把每帧都存到 /sdcard/,占空间且无必要) // saveBitmapToStorage(resizedBitmap, "debug_resized_" + System.currentTimeMillis() + ".jpg");

6.4 验证与发布:APK 生成与真机测试清单

最后一步,用 Android Studio 打包并安装:

# 在项目根目录执行(确保已配置 ANDROID_HOME) cd app ./gradlew assembleDebug # 输出 APK 路径:app/build/outputs/apk/debug/app-debug.apk # 安装到连接的真机(开启 USB 调试) adb install -r app/build/outputs/apk/debug/app-debug.apk

安装后启动 App,用你自己的水稻照片测试。如果输出类似healthy: 0.95,恭喜你,一个专属的水稻病害识别器已上线。此时你可以:

  • 把app-debug.apk直接发给农技员,他们无需网络即可现场诊断;
  • 在MainActivity.java的onResults()回调里,对接企业微信机器人,自动推送「发现稻瘟病,建议喷施三环唑」;
  • 将ImageClassifier.java封装为 AAR,供其他团队集成到他们的工业 APP 中。

从那以后我每次接到新的识别需求,都强制走一遍这个流程:先用test_inference.py在 PC 上验证模型输出,再改INPUT_SIZE和NUM_CLASSES,最后只编译app模块。省去 80% 的真机调试时间,也再没遇到过“模型明明对了,但手机上就是不准”的玄学问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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