简介这是一份基于 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 推理线程池别让主线程卡成ANRTensorFlow推理属于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 集成到相册和拍照流程里接入层可以做成一个透明的工具类调用方拿图拿到分数然后自己做业务判断。以一个典型流程为例用户点击“选择图片”调起系统相册。拿到Uri通过ContentResolver打开输入流。先做EXIF方向校正再降采样解码。把Bitmap传给NsfwAnalyzer.predict。主线程收到回调根据阈值决定展示、隐藏或者上报。有一个容易被忽略的小坑第三方文件管理器返回的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入手先把链路跑通再谈优化和改造。最后再分享一个细节上线之前拿几百张真实业务图片跑一遍回归测试比什么理论都管用模型效果的底线全在这一步里。本文还有配套的精品资源点击获取