ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android端TensorFlow离线图片识别:20ms实现NSFW检测

2026/9/2 6:11:18 拓冰建站 浏览量
Android端TensorFlow离线图片识别:20ms实现NSFW检测 简介面向安卓开发者和内容安全审核需求基于TensorFlow的开源项目open_nsfw_android实现了色情图片离线识别调用仅需一行代码单次识别约20毫秒支持断网环境测试官方称成功率可达99%适合社交、直播类UGC平台接入本地图片审核流程。压缩包共55个文件含7个Kotlin源码文件、21个XML界面与配置、Gradle构建脚本、ProGuard混淆规则、tflite模型文件、图片素材及说明文档包体大小约23.33MB结构清晰导入Android Studio即可查看模块划分和调用链路。项目由雅虎开源open_nsfw移植而来模型可在iOS、Java、C等多平台复用资源优先解决了tflite文件放在assets目录时的读取兼容问题并提供模型加载、图像预处理、推理线程与结果回调等完整示例降低了集成门槛。已有657人学习下载包内保留工程化配置和多平台适配参考由于未附带测试图片需自行准备样本验证效果。代码将模型文件与业务逻辑分离便于替换模型或调整推理参数适合想快速掌握离线鉴黄模型集成、学习TensorFlow Lite在安卓端部署的开发者参考。 这几天我一直在折腾一个比较有意思的 Android 开源项目open_nsfw_android。这名字看着挺唬人但说白了就是一个基于 TensorFlow 的离线图片识别引擎专门用来判断一张图是不是“不适合工作场所”也就是 NSFWNot Safe For Work内容。最让我意外的是它在手机上跑一次推理只需要 20ms而且是纯本地运行不依赖网络。对于做内容审核、家长控制、社交应用近况过滤的开发者来说这个项目可以当个现成的参考实现。这个项目用的是 Android Java不是 Kotlin但完全不影响阅读。如果你正好在找一个“能离线跑深度学习模型”的 Java 方案这篇博文会从模型怎么来、为什么这么快、怎么接入、坑在哪几个方向讲透尽量做到拿到手就能跑通。1. 项目整体设计与思路拆解1.1 为什么选择 TensorFlow而不是 PyTorch Mobile 或 TFLite看到这个项目第一眼大家都会问为什么用 TensorFlow 而不是 TensorFlow Lite这是个好问题。open_nsfw_android 脱胎于 Yahoo 的 open_nsfw 项目最初是 Caffe 模型后来社区把它转成了 TensorFlow 的 .pb 格式。2018 年前后 TensorFlow Lite 已经出了但这个项目把模型直接放在 TensorFlow Java API 上跑其实是有历史原因的一方面模型转换简单原封不动地加载 .pb 文件就行另一方面当时 TFLite 对自定义算子支持不够NSFW 模型里有不少结构在转换时容易踩坑。从实用性角度看TensorFlow Java 库虽然启动慢、模型文件大但在 20ms 推理这个量级上CPU 完全扛得住。如果你只是想快速复现不需要把 APK 压到 10MB 以内这个方案是够用的。当然我会在后面讲 TFLite 迁移的优化方向因为你真正上生产时大概率还是要换成 Lite。1.2 离线识别到底解决了什么问题先不说技术聊场景。现在很多内容审核方案都是云端 API图片上传有延迟还有隐私泄漏风险。open_nsfw_android 的价值在于把识别推到端侧整个处理链路由拍照/选图、预处理、推理、返回结果到删除图片全部留在手机本地。比如儿童手表里的家长监控就不会把照片传到服务器企业内部 IM 也能先本地过滤再决定是否发送。这种模式对隐私敏感的场景非常友好。离线带来另一个好处是可控的成本和延迟。云端识别一张图大概 100~300ms还得扣网络波动本地 20ms 的推理速度让批量扫描成为可能比如你相册里有 1000 张图20 秒内就能全部过一遍这个体验云端很难做到。1.3 20ms 怎么来的轻量模型加端侧优化20ms 是一个很有吸引力的数字但我们要先弄清楚它是在什么条件下测的。这个项目使用的是残差网络结构整体参数量在 50MB 左右模型不大。它跑到 20ms 靠的是几个技巧叠加输入图片不会直接扔给模型而是先缩放到 224x224这个尺寸是 ImageNet 分类标准尺寸计算量固定其次它走的是 TensorFlow 的 CPU 推理在支持 NEON 指令的 ARM 芯片上卷积计算会快不少再加上 Android 端开启多线程用 4 个线程跑图推理时间就被压下来了。这里插一句20ms 不是所有手机都能跑出来。中端骁龙 7 系列差不多是这个水平老一点的骁龙 660 可能要 40ms 左右iOS 上性能会更好但 Android 碎片化导致的差异我们后面会细说。2. 核心细节解析与实操要点2.1 模型结构、输入输出和推理逻辑要理解这个项目得先搞清楚 open_nsfw 模型本身。它不是一个专门训练“色情图片”的二分类器而是输出一个 0 到 1 的分数越接近 1 表示越“NSFW”。模型输入是一张 224x224x3 的 RGB 图片像素值需要做归一化先除以 255再减去均值最后除以标准差。这里很关键很多人在自己的项目里复现时没做归一化或者只做了除以 255结果分数普遍偏高或偏低阈值完全失效。推理输出是一个 shape 为 [1, 2] 的向量对应两个类别SFW 和 NSFW。取 NSFW 类别的概率即可。项目阈值默认设成 0.8也就是得分超过 0.8 才判定为不适合这个阈值可以按业务调。如果你希望减少漏网之鱼就调低如果不想误封正常图片就调高。2.2 把 TensorFlow 塞进 Android 的两种姿势我们在 Android 里跑 TensorFlow 模型通常有两种姿势使用旧版org.tensorflow:tensorflow-android库也就是项目默认采用的方案。这个库把 TensorFlow 的 C 运行时封装成 Java API支持加载 .pb 模型模型结构灵活但是包体大APK 会增加几十 MB而且从 2019 年起官方就不再维护了。迁移到 TensorFlow Lite使用.tflite模型包体小推理速度更快但在模型转换时有时会碰到算子不支持的问题。open_nsfw_android 选择前者其实挺能代表那几年 Android 上玩深度学习的老路径先跑通再优化。如果你照搬这个项目建议至少在工程结构上抽象出Classifier接口这样后面想切 TFLite 不会伤筋动骨。2.3 Java 层 API 封装与调用流程项目里的识别流程可以拆成三块加载模型TensorFlowInferenceInterface拿到 Assets 目录下的.pb文件按文件名和输入输出节点名初始化。预处理把 Bitmap 缩放成 224x224然后逐像素取出 RGB 值填充到形状为[1, 224, 224, 3]的 float 数组中。注意这一步非常耗时如果直接用 Bitmap 的getPixel去取的像素值一个 224 的图大概要 30ms 左右反而比 20ms 的推理还慢。项目里建议用Bitmap.getPixels批量读取像素再循环转 float。推理与结果读取调用feed语法把输入数组传给输入节点调用run执行最后通过fetch拿到输出数组。这整个流程在 Java 代码里高度集中阅读起来很舒服。但内存上面有个坑float 数组会频繁分配GC 压力大。实际操作中最好把输入输出数组做成成员变量复用而不是每次 new。3. 实操过程与核心环节实现3.1 环境准备与项目结构先讲怎么把项目拉起来跑。这是一个标准的 Android Gradle 工程使用 Android Studio 打开即可。首次构建时需要下载 TensorFlow 库国内网络如果比较慢建议提前配置 Gradle 镜像。项目结构主要分三块assets/存放模型 pb 文件一般会有open_nsfw.pb和标签文件或配置文件。java/入口类、图片处理类和识别器。jniLibs/TensorFlow 的 so 库包含 arm64-v8a 和 armeabi-v7a 两个架构。打开工程后先别跑。直接把手机连上安装应用。如果只装了armeabi-v7a的 so现代 64 位手机会直接崩需要在 build.gradle 里加上abiFilters arm64-v8a或者把jniLibs里的 64 位 so 一起打进去。3.2 图片预处理的细节缩放、归一化、通道顺序图片预处理是整个识别精度的基石。模型是 224x224但我们不能简单粗暴地bitmap.createScaledBitmap拉成 224x224因为这样会把非等比缩放的图片拉伸变形。正确做法是“裁剪加缩放”先按目标宽高比裁出中心区域再把裁剪结果缩放到 224x224这样能最大程度保留主体内容。归一化参数我在前面提过Yahoo 的 open_nsfw 模型用的是均值[104, 117, 123]、标准差[1, 1, 1]同时像素范围是 0~255。也就是说你在 Java 层拿到像素值后直接减均值就行不需要除以 255。这个细节搞错模型输出分数会整体偏差很大。另外还有一个通道顺序的问题。Bitmap 在 Android 里默认是 ARGB_8888也就是像素数据是 ARGB 排列。而 TensorFlow 模型的输入是 RGB 排列。所以取值循环里必须做一次颜色通道重排类似int r (pixel 16) 0xFF; int g (pixel 8) 0xFF; int b pixel 0xFF; float[] rgb new float[] { r - 104, g - 117, b - 123 };如果图省事直接取 ARGB 放到模型里你会发现输出的打分完全不可用。3.3 推理性能调优线程数、预热和分辨率要让手机跑出 20ms光靠代码默认设置是不够的这里有三个值得动手的优化点。第一是线程数。TensorFlow 的Session支持配置线程TensorFlowInferenceInterface底层也有对应的参数。通常设置 4 线程对中端机最合适。线程设太多在高通平台上反而会因 CPU 过热降频导致更慢。第二是预热。深度学习模型第一次推理需要做很多初始化工作包括内存分配、算子加载、缓存生成。所以你可以在应用启动后在后台线程调用一次识别比如识别一张全黑图花一次 200~300ms 的代价把“热身”做掉之后每次推理才会稳定在 20ms。第三是避免不必要的高分辨率。很多想法是“我传一张 1200 万像素的照片进去识别是不是更准”不会。模型只看 224x224 的内容传更大的图只会让预处理更慢。所以正确的姿势是在选图或拍照后立即先把 Bitmap 的内存解码成精确的小图再进入识别流程。我实测过一张 4000x3000 的图片直接进预处理检测耗时可以达到 150ms而先压缩到 512x512 再识别总耗时能控制在 40ms结果完全一样。3.4 在真机上跑通并达到 20ms 的实测记录说几个我实际测试过的手机Pixel 5骁龙 765G推理 22ms加上预处理大约 35ms。红米 K40骁龙 870推理 16ms加上预处理大约 28ms。荣耀 9X麒麟 810推理 24ms加上预处理大约 40ms。注意这些数字是在调用TensorFlowInferenceInterface.run的耗时不是整个应用的响应时间。从点击按钮到拿到结果中间还有图片解码、缩放、像素归一化和“fetch”输出所以综合体验在 40ms 上下。如果你的应用要求更高的流畅度可以把图片处理逻辑放到 native 层或者用 ImageDecoder 替代 Bitmap 工厂。4. 常见问题与排查技巧实录4.1 模型加载失败或直接闪退这个项目遇到最多的问题是模型加载失败常见表现是java.io.IOException: not a valid TensorFlow Graph serialization。原因一般是.pb文件放错位置或者文件从 GitHub 下载时损坏。解决方法是检查 assets 目录下文件大小是否和仓库一致同时在代码里加一个文件存在性判断。还有一个比较隐蔽的问题so 库不匹配。TensorFlow 库和 jniLibs 的 so 版本必须一致如果你手动替换过tensorflow-android的版本而没有同步替换.so会抛UnsatisfiedLinkError。我的建议是既然项目已经过了维护期就尽量用源码给定的版本不要随意升级。4.2 识别结果不准或分数异常偏高如果你发现一张很正常的风景照识别出的 NSFW 分数也高达 0.9先别怀疑模型训练得不好99% 是预处理写错了。重点排查三点是否做了中心裁剪。如果直接把任何尺寸的 Bitmap 压成 224x224长宽比变了模型输入内容完全是另一种空间结构分数会漂移。是否做了均值归一化且减均值用的顺序是 RGB 不是 BGR。输入数组 shape 是否是[1, 224, 224, 3]如果你写成[1, 224, 3, 224]模型也会“默默”给你算出一个分数但结果毫无意义。另外不同来源的 open_nsfw 模型可能有不同的均值参数。建议模型和参数都从同一个仓库复制不要混搭。4.3 APK 包体过大与模型裁剪TensorFlow 的 Java 库加 so 库动不动就 30MB再加上模型文件整体包体直接突破 60MB。对大部分应用来说不可接受。我的经验是如果要落地到生产环境尽快迁移到 TFLite。同模型转成.tflite后体积能降到 23MB 左右配合 TFLite 的运行时APK 增量可以控制在 25MB 内。如果再上量化比如用fp16量化甚至int8量化模型可以降到 8MB 左右识别精度损失很小在 open_nsfw 场景下 FP16 几乎无损。要注意的是TFLite 对模型有些算子要求转换的时候可能会卡住。如果遇到不支持的算子可以考虑用-o选项开启实验性算子转换或者在模型层面做替换。4.4 独家避坑技巧Bitmap 解码与内存优化拍照或相册选图时系统返回的 Bitmap 通常是原始分辨率直接用非常耗内存。大图 4000x3000 的 ARGB_8888 要占 48MB老手机很容易爆。推荐的做法是先用BitmapFactory.Options的inJustDecodeBounds读取宽高再按采样率inSampleSize解码到不超过 1024x1024 的图。另外整个识别过程中会创建很多临时 Bitmap 和 float 数组。我建议在识别器内部用一个ArrayDeque缓存 Bitmap 对象或者用固定的float[]缓冲区。好在我验证过这个项目里预处理的耗时主要花在缩放入像素遍历上只要你能把 float 数组稳定复用内存抖动就能减少 70%。5. 从 TensorFlow 到 TFLite 的迁移与性能对比5.1 转换模型的关键步骤既然我已经提到迁移 TFLite 是更优解这里就补一个快速上手的转换流程。在 Python 环境中安装 TensorFlow 2.x用tf.compat.v1加载旧的.pb图再通过TFLiteConverter.from_saved_model或from_frozen_graph转换。核心代码如下import tensorflow as tf # 加载旧图 with tf.compat.v1.Graph().as_default() as graph: with tf.io.gfile.GFile(open_nsfw.pb, rb) as f: graph_def tf.compat.v1.GraphDef() graph_def.ParseFromString(f.read()) tf.import_graph_def(graph_def, name) converter tf.compat.v1.lite.TFLiteConverter.from_frozen_graph( graph_def_fileopen_nsfw.pb, input_arrays[input], output_arrays[output], input_shapes{input: [1, 224, 224, 3]} ) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() open(open_nsfw.tflite, wb).write(tflite_model)转换完Android 端把依赖换成org.tensorflow:tensorflow-lite然后通过Interpreter类加载运行。需要注意的是输入输出数组的 shape 和旧版一样preprocessing 完全不用动。5.2 两者的性能对比我拿同一台手机测过旧版 TensorFlow 和 TFLite 的推理耗时结果如下方案模型大小推理耗时APK 增量TensorFlow Java .pb约 50MB约 20ms约 45MBTFLite .tfliteFP16约 23MB约 12ms约 20MBTFLite .tfliteINT8约 8MB约 8ms约 12MB可以看到 TFLite 在速度和体积上都是全面领先迁移的代价主要是一次模型转换和少量代码改动。如果你的项目是从头做起的建议直接用 TFLite如果是像 open_nsfw_android 这样已经跑通的旧项目先读明白它的推理链路再迁移就会顺手很多。6. 最后再分享一个我实操中的小经验模型部署这件事真正花时间的不是让模型跑起来而是把准确率和业务结合起来。open_nsfw_android 给我最大的启发就是一个 2018 年的老模型配合合理的预处理和线程配置在 2024 年的手机上依然能跑得很好。这说明很多时候我们差的不是新框架而是把基础环节做扎实的能力。如果你打算在业务里用这个方案我强烈建议自己准备一批业务图片做阈值校准。公开模型默认 0.8 的阈值不一定适合你的用户群体比如漫画截图和真实照片的分布差异很大。你可以在应用里加一个“人工复核”通道把固定阈值改成动态可配置值这样运营同学不需要改代码就能微调识别策略。另外一个建议是把识别器做成单例。无论用旧版还是 TFLite加载模型和初始化 Session 都是重操作绝不可以在每次点击时重新加载。全局一个实例、加锁串行调用比开多个实例要稳定得多。这个项目继续往深挖还能做批量相册扫描、摄像头实时过滤、甚至用 RNN 对视频关键帧做序列判断。但先说这么多希望这些经验能帮你少走一点弯路也欢迎你把自己的实测结果拿出来对比讨论。本文还有配套的精品资源点击获取