
简介本资源是一个面向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 上用 PILnumpy 模拟 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 # 关键minSdkVersion21ndk.abiFilters[arm64-v8a] ├── docs/ │ └── preprocessing_notes.md # 详细记录 RGB 均值/标准差、resize 插值方式、归一化公式 └── requirements.txt # pip install -r 一行搞定 Python 依赖tf2.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.soarm64-v8a仅 2.1MB初始化解释器内存占用稳定在 8~12MB。精度-速度平衡点MobileNetV2 INT8 量化后模型大小从 13.8MBFP32 SavedModel压缩到 472KBTop-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.3Android Gradle Plugin8.1.0适配 AGP 8.x 的android.useAndroidXtrue默认行为避免android.support.*包冲突tensorflow-lite(AAR)2.13.0必须与 Python 端 TF 版本严格一致否则createFromAsset()会静默失败无异常但getInputTensorCount()0NDK25.1.8937393支持-marcharmv8.2-afp16保障 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_topFalse, weightsimagenet ) base_model.trainable False model tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense(10, activationsoftmax) # 假设你的数据集有10类 ]) # 关键编译时指定 input_signature否则转换会丢失 shape 信息 tf.function(input_signature[ tf.TensorSpec(shape[None, 224, 224, 3], dtypetf.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 步骤二转换为 TFLiteINT8 量化是性能分水岭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), channels3) 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, axis0)] 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_typetf.int8强制输入输出 tensor 为 int8这是减小模型体积和加速推理的关键representative_dataset必须提供真实数据不能用随机噪声否则量化参数scale/zero_point会严重失真导致 Android 端输出全为 0 或 NaNOpsSet.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_pathmodel.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, dtypenp.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, axis0) # 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❌ 错误2declaredLength用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_SIZE224必须和模型训练时的输入尺寸严格一致否则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但偶发会飙到 200msCPU 频率降频。因此MainActivity.java中必须用HandlerThread或ExecutorService将其移出主线程否则runInference()在onImageAvailable()里直接调用会导致 UI 卡顿甚至 ANR。4.4getSortedResult(FloatBuffer labelProbArray)输出解析的边界安全// ImageClassifier.java private ListRecognition 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); PriorityQueueRecognition 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 ArrayListRecognition 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 维ImageNetlabels.get(i)会在i10时抛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.1SDK 25——等等25 23应该能跑错TF Lite 的libtensorflowlite_jni.so在minSdkVersion23时会链接libc_shared.so的 Android 8.0 版本符号导致 Android 7.x 加载失败System.loadLibrary(tensorflowlite)静默失败后续new Interpreter()抛UnsatisfiedLinkError但被try-catch吞掉了。解决将minSdkVersion明确设为21Android 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()的第三个参数filtertrue开启了双线性插值而训练时用的是tf.image.resize(..., methodbilinear)—— 听起来一样不。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中显式设置targetSdkVersion33Android 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/val100 epochbatch_size32 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% 的真机调试时间也再没遇到过“模型明明对了但手机上就是不准”的玄学问题。希望帮到你。本文还有配套的精品资源点击获取