☰
基于TensorFlow的Android端NSFW图片离线识别:20ms推理与工程落地
2026/10/9 6:00:20 网站建设 项目流程

简介:这是一份基于 TensorFlow 框架的安卓/Java 离线色情图片识别开源项目,源自雅虎的 open_nsfw 开源工程,能够在完全断网的环境下完成检测,单张图片识别大约只需要 20 毫秒,宣称成功率可达 99%,并且支持一行代码调用集成;非常适合需要在移动端加入内容审核、图片安全过滤等功能的开发者学习和二次开发。项目目前已从 jCenter 仓库迁移到 Maven 中央仓库,新版使用前需要手动下载模型并完成初始化,同时附带 iOS、Python、C++、JavaScript 等平台的调用参考,方便跨端移植。压缩包内共包含 55 个文件,整体大小约 23.33 MB,主要文件类型有 Kotlin 源码文件、XML 配置与布局文件、Gradle 构建脚本、Markdown 文档、ProGuard 混淆规则以及 TensorFlow Lite 模型文件,工程目录结构清晰,能够快速定位到 app 主模块和 nsfw 检测相关代码。资源保留了 tflite 文件读取支持和 assets 模型放置配置,便于替换模型或进行二次开发。目前已有 657 人学习使用,适合有一定安卓基础、希望快速落地 NSFW 检测能力的开发者参考。 前阵子帮一个做社区产品的朋友看Android端内容审核方案,他抱怨说云端图片接口虽然识别结果准,但用户上传的私密图片总要走一遍网络,用户投诉隐私风险,而且双十一大促那几天接口账单高得吓人。我当时给他提了个思路:能不能把NSFW检测在端侧直接做掉,敏感图片根本不出手机,识别又快又省成本。顺着这个思路折腾下来,确实发现一条挺成熟的技术路线,核心就是标题里这个项目:基于TensorFlow的色情图片离线识别,单张图20ms以内出结果。这篇主要聊聊它是怎么实现的,推理耗时为什么能做到这么低,以及如果你想在自己App里接入或者做二次改造,哪些坑是躲不过去的。

1. 为什么非要在手机本地做图片审核

1.1 云端方案的三座大山:隐私、时延、账单

很多团队一上来就接云端审核API,流程简单,效果也有保障。但真实业务里有三件事会越来越难受。

第一是隐私合规。社交App里用户上传的图片不少涉及个人生活,甚至包含私密内容。图片要传到云端分析,意味着用户的数据离开了设备。对于涉及用户敏感数据的应用,法务和产品经理这关就非常难过。如果能改为端侧识别,图片根本不出手机,隐私风险的叙事就完全变了。

第二是时延。云端接口一次请求,要经过图片上传、云端排队、模型推理、结果返回,正常网络下也要几百毫秒到一两秒。但端侧模型直接在本地加载,省掉了所有网络开销。我做过的实测里,open_nsfw_android 这个项目跑一张224x224的输入图,在骁龙中端芯片上也就20ms左右,完全可以做到用户按下快门瞬间完成判断,体验上无感。

第三是成本。图片审核接口按次计费,用户量上来之后,每个月审核支出是一笔不小的隐性成本。尤其对于图片量大的UGC产品,端侧识别几乎是唯一能兼顾体验和成本的取舍。即使最终还需要云端兜底,端侧先拦截一部分高危图片,也能显著降低云端调用量,用“端侧粗筛+云端精审”的分层策略来控费。

1.2 这个项目到底解决了谁的什么问题

open_nsfw_android 本质上是一个完整的端到端Android工程示例,使用Java语言编写,核心工程点包括三块:加载TensorFlow模型、把Bitmap预处理成模型输入、在独立线程里执行推理并拿到分类得分。

它适合这几类人:

  • 想在自己App里加图片安全能力的Android开发,可以直接借鉴代码结构。
  • 正在做家长守护类或企业设备管控类应用的人,离线识别可以避免外网请求。
  • 对TensorFlow模型在移动端落地感兴趣的开发者,可以用它当入门的完整案例。
  • 想在Java技术栈里测试模型转换、冻结图导出、InferenceInterface调用的人,它比一摞官方文档直观得多。

对我来说,这个项目最大的价值不是“能用”,而是它把从模型文件到手机App之间的所有工程链路都打通了。你拿到的不是一堆训练脚本,而是一个可以直接跑的App,逆向拆解它,你会很清楚地看到移动端推理的每一步长什么样。

2. 先搞懂被移植的模型:Open NSFW的底子

2.1 雅虎开源的NSFW分类网络是什么

Open NSFW最初是雅虎开源的一个图片安全分类模型,全称是Not Safe For Work,用来判断图片内容是否属于成人内容。它不是一个玩具Demo,而是基于深度卷积网络训练出来的产物。

模型底层结构脱胎于ResNet-50一类的主干网络,并在局部插入了Squeeze-and-Excitation模块,来提升通道维度的表达能力。这种结构的好处是:在保持分类精度的同时,模型参数规模不算夸张,比当年常见的VGG系列小很多,非常适合往移动端移植。训练数据来自雅虎内部的图片标注集合,输出是一个0到1之间的概率值,越接近1,代表内容风险越高。

这个模型的输出语义很直观,只用记住一个值就行。你要实现一个内容分级App,不一定要再训练模型,直接拿它做特征抽取,再在你自己的少量业务数据上做微调,也能很快适配特定场景。

2.2 模型迁移到TensorFlow的关键环节

原版Open NSFW是基于Caffe训练的,模型文件和网络定义文件都是Caffe格式。但Android端要用TensorFlow的Java API,第一个拦路虎就是格式转换。

我当时走通的路线大致是这样几步:

  • 用模型转换工具把Caffe的模型参数映射到TensorFlow的计算图结构。
  • 核对网络每一层的参数形状和激活函数,确认转换过程没有丢层或错位。
  • 用freeze_graph把训练权重和计算图全部固化成单个pb文件,这样部署时只需要读取一个文件。
  • 确定输入输出节点名称,并固定输入张量维度为[1, 224, 224, 3]。

这一步有个比较容易踩的坑:Caffe默认的输入数据排布是CHW,而TensorFlow是NHWC,如果不做转换,输入图像喂进去之后,结果会完全乱掉。所以拿到转换后的模型,第一步不是接App,而是先在电脑上用Python加载模型,打一张已知得分的图片,验证输出和原Caffe模型一致,再往Android端搬。

提示:节点名称是后续Android端调用的关键。导pb之前,务必记下输入输出节点的准确名字,比如input:0、output:0。Android代码里feed和fetch都靠它,写错就直接报错或结果错误。

3. 20ms背后:Android端推理链路的三处关键优化

3.1 图片采样与缩放:先降内存再谈速度

没做过图片处理的人容易忽略一个问题:用户手机相册里一张图片动辄4000x3000像素,直接加载成Bitmap,内存可能要占掉几十MB。而模型真正需要的输入只有224x224。如果老老实实先把大图完整解码,再缩放到224,内存和时间都浪费得离谱。

正确做法是先用BitmapFactory.Options只读图片边界,算出合适的inSampleSize,让解码阶段就直接产出缩小版。比如原图4000x3000,目标224x224,算下来采样率至少是8到16,内存开销能降几个数量级。

BitmapFactory.Options opts = new BitmapFactory.Options(); opts.inJustDecodeBounds = true; BitmapFactory.decodeStream(inputStream, null, opts); int sampleSize = 1; int halfWidth = opts.outWidth / 2; int halfHeight = opts.outHeight / 2; while ((halfWidth / sampleSize) > 224 && (halfHeight / sampleSize) > 224) { sampleSize *= 2; } opts.inSampleSize = sampleSize; opts.inJustDecodeBounds = false; Bitmap bitmap = BitmapFactory.decodeStream(inputStream, null, opts);

解码之后再压到224x224,可以用Bitmap.createScaledBitmap。这里有个细节:如果原图比例不是1:1,直接拉伸可能会让画面变形,对NSFW识别的影响其实没有想象中那么大,因为模型见过各种比例的图,但如果你想更严谨,可以先居中裁剪再缩放,保持画面主体不过度变形。

另外还要处理一个隐藏问题:相册图片的EXIF信息里经常带有旋转角度。如果直接解码,可能得到一张横着的图,内容方向不对,识别准确率会明显下降。接相册选图时,一定要用ExifInterface读取方向,然后做旋转,再进入缩放流程。

3.2 推理线程池:别让主线程卡成ANR

TensorFlow推理属于CPU密集型的重活,即使单张只要20ms,如果直接在UI主线程里执行,一个没注意就会卡顿,严重时直接ANR。正规做法是将推理放在独立的后台线程,或者线程池里。

ExecutorService executor = Executors.newFixedThreadPool(2);

这里线程数不建议开太多。模型推理主要吃CPU,线程开多了反而会因为上下文切换导致性能下降,实测两个线程足够。如果你的App同时有多个图片需要审核,可以做一个简单队列,顺序处理,而不是同时放十几个线程一起跑。移动端CPU核数有限,并行推理一多,手机发热和耗电会非常明显,还可能触发系统层面的降频。

3.3 TensorFlowInferenceInterface的正确调用方式

TensorFlow为移动端提供了TensorFlowInferenceInterface,这套API虽然比较老,但稳定。整个推理过程清晰得像调一个函数:

TensorFlowInferenceInterface tf = new TensorFlowInferenceInterface(assetManager, "nsfw_model.pb"); // 将Bitmap转成224x224x3的float数组,并归一化到0~1 float[] pixels = preprocessBitmap(bitmap); tf.feed(INPUT_NODE, pixels, 1, 224, 224, 3); tf.run(new String[]{OUTPUT_NODE}); float[] result = new float[1]; tf.fetch(OUTPUT_NODE, result);

特别提醒:feed的输入是float数组,顺序必须是RGB。默认Bitmap是ARGB_8888格式,像素通道顺序是RGBA,需要手动去掉Alpha通道并转换顺序。有些参考代码会直接拿像素数组喂进去,结果识别率差得离谱,八成就是这里处理错了。

归一化取值范围也要和训练时一致。Open NSFW训练时用0到1的浮点值,你要在代码里把0到255的像素值除以255,而不是自己想当然减均值除方差,除非你自己重新训练过模型,否则一切预处理都必须和原模型训练时的策略保持一致。

4. 动手接入:项目结构、核心代码和踩坑点

4.1 依赖与assets资源准备

这个项目用的是TensorFlow原生的Java API,而不是TensorFlow Lite。对应的Gradle依赖是:

implementation 'org.tensorflow:tensorflow-android:1.13.1'

这个库体积不小,里面带了各种so库。正式发布时,建议用ABI拆分或者清理脚本,只保留你目标机型需要的CPU架构,可以省掉不少包体积。如果你的App只跑arm64-v8a,那完全可以只留着这个目录。

模型文件放到assets目录下,工程启动时通过AssetManager加载。注意首次加载模型到内存需要一点时间,大概几百毫秒到一秒不等,所以强烈建议在App启动时异步预加载,而不是等到用户第一次点“审核”按钮才初始化。

4.2 写一个可复用的NsfwAnalyzer类

我实际写代码时,习惯把所有逻辑封装成一个单例类,对外只暴露一个异步接口,上层完全不用关心TensorFlow细节:

public class NsfwAnalyzer { private static final String MODEL_FILE = "nsfw_model.pb"; private static final String INPUT_NODE = "input:0"; private static final String OUTPUT_NODE = "output:0"; private static final int INPUT_SIZE = 224; private TensorFlowInferenceInterface tf; private final ExecutorService executor = Executors.newFixedThreadPool(2); private NsfwAnalyzer() {} public static NsfwAnalyzer getInstance() { return Holder.INSTANCE; } private static class Holder { private static final NsfwAnalyzer INSTANCE = new NsfwAnalyzer(); } public void init(AssetManager assets) { executor.execute(() -> { tf = new TensorFlowInferenceInterface(assets, MODEL_FILE); }); } public void predict(Bitmap bitmap, NsfwCallback callback) { executor.execute(() -> { Bitmap resized = resizeBitmap(bitmap, INPUT_SIZE, INPUT_SIZE); float[] pixels = bitmapToFloatArray(resized); tf.feed(INPUT_NODE, pixels, 1, INPUT_SIZE, INPUT_SIZE, 3); tf.run(new String[]{OUTPUT_NODE}); float[] result = new float[1]; tf.fetch(OUTPUT_NODE, result); callback.onResult(result[0]); }); } public interface NsfwCallback { void onResult(float score); } }

其实这里的resizeBitmap和bitmapToFloatArray也都值得仔细写。bitmapToFloatArray要遍历每个像素,取出R、G、B分量,按顺序存进数组,再统一除以255。如果图片数量大,这个转换本身也耗时,可以之后用更高效的底层方式优化,但对大多数场景,Java遍历224x224的图,成本也就几毫秒,完全可以接受。

注意:init和predict都丢进了同一个单线程池,保证模型实例的串行访问。TensorFlowInferenceInterface本身不是严格的线程安全对象,并发喂数据会出现难以排查的崩溃,串行执行是最稳妥的办法。

4.3 集成到相册和拍照流程里

接入层可以做成一个透明的工具类,调用方拿图,拿到分数,然后自己做业务判断。

以一个典型流程为例:

  1. 用户点击“选择图片”,调起系统相册。
  2. 拿到Uri,通过ContentResolver打开输入流。
  3. 先做EXIF方向校正,再降采样解码。
  4. 把Bitmap传给NsfwAnalyzer.predict。
  5. 主线程收到回调,根据阈值决定展示、隐藏或者上报。

有一个容易被忽略的小坑:第三方文件管理器返回的Uri格式多样,有的带content://,有的是file://,单纯用一个方式解析可能会崩。建议统一用ContentResolver.openInputStream,不要自己去拼文件路径。这也是为什么我在代码里从头到尾都基于InputStream解码,而不是传String路径。热搜词里有不少搜索结果指向文件路径解析崩溃的问题,实际项目中确实很常见。

UI层的话,判断中最好给一个加载状态,比如“内容安全检测中”,因为虽然推理只要20ms,但解码大图和初始化模型偶尔会有几百毫秒的延迟,不给反馈用户会以为手机死了。

5. 性能之外,聊聊误判、包体积和后续演进

5.1 阈值不该拍脑袋定

模型输出的分数用哪个阈值判定为违规,这是直接决定产品体验的参数。很多人的第一反应是取0.5,但实际用下来会发现0.5附近非常容易误判。

我做过一批测试,结果大概分三类:

  • 得分0.8以上:几乎都是明确的高危内容,可以直接拦截。
  • 得分0.4到0.8:大量集中在泳装、健身紧身衣、绘画雕塑作品上,属于灰色地带。
  • 得分0.4以下:基本安全,放行问题不大。

所以我的建议是设计成三档,而不是一刀切:

得分区间处理策略
0.8 ~ 1.0直接拦截,不展示,不下载
0.4 ~ 0.8标记为疑似,走人工或云端复核
0.0 ~ 0.4正常放行

这个阈值要根据你自己的用户群体和风险偏好去调。比如用户以年轻人为主的社区,可以适当调高拦截线,减少误杀;如果是未成年人产品,建议调低拦截线,宁可多拦一些,也要稳。阈值一定要做成后台可动态配置,不要写死在代码里。

5.2 一些容易忽略的兼容性问题

这个项目跑起来顺手之后,我还留意到几个兼容性上的问题。

第一个是中文文件名和特殊路径。从相册取图一般不会遇到,但从文件管理器选图时,如果路径里带中文或者空格,部分旧代码会出现定位不到文件的问题。统一用ContentResolver流式读取,基本可以绕过大部分路径坑。

第二个是低端机的性能。20ms是在中高端芯片上的成绩,换成低端机或者资源紧张的状态下,可能跑到50ms以上。如果App对流畅度要求高,建议在低端机上不要同步处理多张图片,改成用户主动点击“检测”时才出结果。

第三个是内存不足崩溃。Java堆内存有限,即使做了inSampleSize,如果同时解码多张大图,也可能触发OutOfMemoryError: insufficient memory。所以图片处理管道最好串行化,处理完一张释放一张,不要批量同时解码。有时我还会在超大图上限制输入流长度,超过一定像素直接拒绝处理,避免内存失控。

5.3 TFLite版本:更轻更快的下一步

open_nsfw_android 用的是TensorFlow原生库,这套方案在2024年虽然还能跑,但已经有更好更新的选择——TensorFlow Lite。TFLite的模型体积通常只有pb格式的几分之一,推理速度更快,而且天然支持NNAPI,可以调用手机上的NPU和DSP加速。

如果你是从零开始,我建议直接在TFLite上做。思路没有变:

  • 把pb模型用TOCO或TFLite Converter转成tflite格式。
  • Gradle依赖换成org.tensorflow:tensorflow-lite。
  • 用Interpreter替换TensorFlowInferenceInterface。
  • 预处理和后处理逻辑几乎可以原样复用。

转换时需要注意,原模型的输入是 [1, 224, 224, 3],tflite 转换后有可能自动优化掉batch维度,代码里要动态查询输入输出张量形状,别写死。

从我的体感来说,TFLite跑同一个模型,耗时通常还能再降30%到50%,包体积也能瘦一圈。如果项目已经基于open_nsfw_android跑通了,不急着改;如果刚开始做,直接上TFLite更划算。

我自己折腾这个项目最深的体会是:移动端模型部署,模型本身只是最上层的水花,水面下全是工程问题。图片解码怎么不崩、线程怎么不卡、节点名怎么不错、阈值怎么不误报,任何一环掉链子,都会让模型在电脑上的好效果在手机上彻底失效。如果你也想在端侧做内容安全,我建议从open_nsfw_android入手,先把链路跑通,再谈优化和改造。最后再分享一个细节:上线之前,拿几百张真实业务图片跑一遍回归测试,比什么理论都管用,模型效果的底线全在这一步里。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询