nsfw_detect
Privacy-friendly NSFW detection for Flutter apps. On-device, no telemetry, no media uploads.
import 'package:nsfw_detect/nsfw_detect.dart';
// Works for images, videos, GIFs — same call, same result shape.
final result = await NsfwDetector.instance.scanFile('/path/to/file.jpg');
if (result.isNsfw) {
// Blur, block, or route to review — your choice.
}
That's the whole API for the most common case. No init, no permission for files on disk. Add more entry points as you need them.
Detection is probabilistic. Use it as a local moderation signal and one layer in a broader safety workflow.
Install
dependencies:
nsfw_detect: ^2.6.0
flutter pub get
| Platform | Minimum |
|---|---|
| iOS | 16.0+ |
| Android | API 24 / Android 7.0+ |
| Web | one-shot APIs only — see below |
| Flutter | 3.22+ |
| Dart | 3.4+ |
| Xcode | 15+ |
⚠️ REQUIRED PLATFORM SETUP — read this or your app WILL crash
Do NOT skip this section. Every API that touches media — including
pickAndScan,pickMedia,scanAsset,startScan,startCameraScan— requires the matching usage strings / permissions in your host app. iOS terminates your process withSIGABRTthe moment the system reads a missing key. There is no graceful runtime error. This is the #1 reported integration issue.
iOS — ios/Runner/Info.plist
Add these keys before calling any media API. pickAndScan and pickMedia are NOT exempt: even though PHPickerViewController grants per-item access without a runtime prompt, the plugin still resolves PHAsset identifiers behind the scenes, which iOS gates on NSPhotoLibraryUsageDescription. Missing this key = instant crash on first scan.
<!-- Required for pickAndScan / pickMedia / scanAsset / startScan -->
<key>NSPhotoLibraryUsageDescription</key>
<string>We scan selected media on-device to flag NSFW content.</string>
<!-- Required for startCameraScan -->
<key>NSCameraUsageDescription</key>
<string>We analyze camera frames on-device to flag NSFW content.</string>
<!-- Required if you record video with audio for analysis -->
<key>NSMicrophoneUsageDescription</key>
<string>Used when recording video with audio for moderation.</string>
Android — android/app/src/main/AndroidManifest.xml
<!-- API 33+ — pickAndScan / scanAsset / startScan for images & videos -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- API ≤ 32 fallback -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<!-- Required for startCameraScan -->
<uses-permission android:name="android.permission.CAMERA"/>
Quick verify
If your test app crashes on the first tap of "scan", grep your iOS log for this exact string:
This app has crashed because it attempted to access privacy-sensitive data
without a usage description. The app's Info.plist must contain an
NSPhotoLibraryUsageDescription key...
That is not a plugin bug — it's a missing Info.plist key. Add it, rebuild (flutter clean && flutter run), and the crash is gone.
Programmatic preflight (recommended)
Since v2.6.3 the plugin can tell you which keys are missing before you hit any media API. The check reads Info.plist (iOS) / PackageInfo.requestedPermissions (Android) only — it never triggers the OS permission layer, so it's safe to call at startup:
final setup = await NsfwDetector.instance.checkPlatformSetup();
if (!setup.isComplete) {
// Show your own setup-guide UI, or just log it during development.
debugPrint('nsfw_detect: missing platform keys → ${setup.missingKeys}');
// missingKeys e.g. ['NSPhotoLibraryUsageDescription']
}
When a key is missing, the corresponding native APIs (pickAndScan, pickMedia, startScan, scanAsset, requestPermission, startCameraScan, requestCameraPermission) now return FlutterError(code: "MISSING_USAGE_DESCRIPTION", …) instead of crashing the host. Catch it on the Dart side and route the user to your setup screen.
Web
The web platform runs the one-shot scan APIs in the browser — scanBytes,
scanFile (a blob:/http(s): URL), pickMedia, and detection-mode scans.
Inference runs client-side: classification on nsfwjs
(TensorFlow.js), detection on NudeNet
via onnxruntime-web. The JS runtimes load on demand from a CDN — no index.html
edits required.
// Detection-mode scans need a NudeNet model — point this at a
// CORS-reachable .onnx URL once, before the first scan.
NsfwWebConfig.nudeNetModelUrl = 'https://your-host.example/nudenet_320n.onnx';
final result = await NsfwDetector.instance.scanBytes(bytes);
Not available on web: photo-library scanning (startScan), camera
scanning, and background sweep — they have no browser equivalent and throw
UnimplementedError. nsfwjs has no dedicated nudity class, so the web
classifier reports explicitNudity rather than nudity, and its confidence
scores are not numerically comparable to the native OpenNSFW2 classifier.
What you can scan
| Source | API | Permission |
|---|---|---|
| Image file on disk | scanFile · isNsfwFile |
none |
| Video file on disk (mp4, mov, …) | scanFile · isNsfwFile |
none |
| Animated image (gif, apng, webp) | scanFile |
none |
| Bytes in memory | scanBytes · isNsfwBytes |
none |
Flutter ImageProvider |
scanImageProvider |
none |
| Remote URL (image or video) | scanUrl |
none (network) |
| Photo-library asset (image or video) | scanAsset · isNsfwAsset |
photo library |
| System picker (image or video) | pickMedia · pickAndScan |
NSPhotoLibraryUsageDescription required on iOS (see Platform Setup) |
| Whole library (photos + videos) | startScan |
photo library |
| Live camera | startCameraScan |
camera |
| Mixed batch | scanPaths(['file://…', 'https://…', '/abs/path', 'asset-id']) |
per-source |
Videos are first-class. scanFile auto-detects the container and samples frames at a configurable interval. No separate API or model is needed.
Each headless API returns a ScanResult (full label list + detections) or a shortcut Future<bool> via the isNsfw* variants.
Common patterns
Gate an image before display
NsfwModerationGate.file(
'/path/to/upload.jpg',
child: Image.file(File('/path/to/upload.jpg')),
)
Constructors: .bytes(...), .file(...), .asset(...). Optional confidenceFloor adds a manual-review band; pass nsfwBuilder / uncertainBuilder / errorBuilder for custom UI.
Pick + scan in one call
final session = await NsfwDetector.instance.pickAndScan(maxItems: 5);
await for (final r in session.results) {
if (r.isNsfw) { /* … */ }
}
pickMedia (returns the picked items without scanning) is the other half of that API.
Scan a URL before showing it
final r = await NsfwDetector.instance.scanUrl(
Uri.parse('https://cdn.example.com/avatar.jpg'),
timeout: const Duration(seconds: 8),
);
if (r.isNsfw) /* hide / report */
Hard-capped at 32 MB by default to keep a malicious server from OOM-ing you. Override via maxBytes.
Find perceptual duplicates
final clusters = await NsfwDetector.instance.findDuplicates(
items, // List<MediaItem>
loadBytes: (id) async => await myStorage.read(id),
);
// clusters: List<List<MediaItem>> — each cluster ≥ 2 visually-identical items.
dHash + LRU cache; the detector decouples from your storage layer via loadBytes.
Redact detector boxes in place
final redacted = await NsfwDetector.instance.redactBytes(
bytes,
result,
mode: RedactionMode.blur, // or .pixelate, .blackBox
intensity: 0.8,
);
When result.detections is non-empty, only the per-detection boxes are redacted. Falls back to whole-image redaction for classifier-only results.
Scan a video file
final result = await NsfwDetector.instance.scanFile('/path/to/clip.mp4');
if (result.isNsfw) {
// result.topCategory, result.topConfidence — same shape as image scans.
}
The same API works for .mov, .gif, .apng, and .webp. The plugin samples frames automatically and aggregates them into one ScanResult. Control the sampling with ScanConfiguration:
final result = await NsfwDetector.instance.scanFile(
'/path/to/clip.mp4',
configuration: const ScanConfiguration(
maxVideoFrames: 12, // default 8 — more frames, more accurate
videoFrameInterval: 1.0, // default 2.0 s — sample every second
),
);
Whole-library scan with progress
final session = await NsfwDetector.instance.requestPermissionAndStartScan(
// includeVideos: true is the default — shown explicitly for clarity.
const ScanConfiguration.strict(includeVideos: true),
);
if (session == null) return; // User denied — show your permission UI.
session.results.listen((r) { if (r.isNsfw) /* … */ });
session.progress.listen((p) => print('${p.scannedCount}/${p.totalCount}'));
final summary = await session.done;
Presets: .strict() (threshold 0.85), .moderate() (0.7), .permissive() (0.5), .fastScan() (concurrency 8). Pass includeVideos: false to skip video assets and scan images only.
Pre-warm models on splash
await NsfwDetector.instance.init(const NsfwInitOptions(
preloadModels: [
ModelIds.openNsfw2, // fast, default classifier (~11 MB)
ModelIds.falconsai, // ViT classifier (~75 MB)
ModelIds.adamcodd, // ViT classifier, 384px (~75 MB)
ModelDescriptor.nudenet, // body-part detector (~46 MB)
],
downloadIfMissing: [
ModelIds.falconsai,
ModelIds.adamcodd,
ModelDescriptor.nudenet,
],
enableNativeLogging: false,
));
Preload multiple classifiers when you want to ensemble them — the more agreement across independent architectures (CNN + two ViTs), the lower the false-positive rate. See Higher accuracy via ensemble.
Skipping init is fine — the plugin lazy-loads on first use. Use NsfwInitOptions.lazy() / .debug() / .production() for typical shapes.
Higher accuracy via ensemble
final config = ScanConfiguration.strict().copyWith(
ensemble: MajorityEnsemble(
modelIds: [ModelIds.openNsfw2, ModelIds.falconsai, ModelIds.adamcodd],
),
);
final result = await NsfwDetector.instance.scanFile(path, configuration: config);
MajorityEnsemble runs all three classifiers and takes the consensus, with
borderline scores (~0.45 .. 0.55) abstaining so a single uncertain model can't
flip the verdict. WeightedEnsemble averages per-category confidences with
configurable per-model weights. Cost scales linearly with model count —
preload them via NsfwInitOptions.preloadModels so the first ensemble scan
is warm.
Drop-in permissions UI
NsfwPermissionsView(
kinds: const [PermissionKind.photoLibrary, PermissionKind.camera],
onOpenSettings: () => /* host opens system Settings */,
)
The plugin doesn't pull in permission_handler or app_settings; pass onOpenSettings to wire your preferred deep-link package.
Per-category thresholds
final config = ScanConfiguration.moderate().copyWith(
thresholdsByCategory: {
NsfwCategory.explicitNudity: 0.5, // flag aggressively
NsfwCategory.suggestive: 0.95, // tolerate
},
);
Overrides the scalar confidenceThreshold per category; unmapped categories fall back to it. ScanResult.withThresholds(...) re-evaluates a persisted result without re-running inference.
Remember moderator decisions
NsfwDetector.instance.useDecisionStore(SharedPreferencesDecisionStore());
await NsfwDetector.instance.decisions.mark('asset-id', ScanDecision.allow);
// Later scans of that asset come back with `userDecision` applied —
// .allow forces isNsfw=false, .block forces isNsfw=true.
InMemoryDecisionStore is the dependency-free default; SharedPreferencesDecisionStore persists across cold starts.
Detect, then classify each region
final r = await NsfwDetector.instance.scanFileDetectThenClassify(
'/path/to/image.jpg',
detectorModelId: ModelDescriptor.nudenet,
);
// r.detections[i].labels — per-region NSFW classification, stronger than
// detector-only (graded confidence) or classifier-only (per-region attribution).
Telemetry hooks
NsfwDetector.instance.onTelemetryEvent = (e) => myAnalytics.log(e);
Structured scanCompleted / modelLoaded / downloadFinished / … events with timing and a PII-free confidence decile. localId only attaches when includeLocalIdsInTelemetry is set. The plugin itself sends nothing — this is a local callback.
Localize plugin strings
NsfwLocalizations.current = const NsfwLocalizationsDe();
Bundled EN/DE/ES/FR/JA cover category names, permission hints, confidence buckets, and widget button labels. NsfwLocalizations.resolve('es-MX') picks a bundle by BCP-47 tag.
What's new
2.5.x — platform reach + polish
- Localization —
NsfwLocalizationsplain-Dart bundle (EN/DE/ES/FR/JA), no new deps. Global override viaNsfwLocalizations.current. - Accessibility — Semantics pass over the surfaced widgets; WCAG-AA badge contrast via
NsfwGalleryTheme.readableForeground.
2.4.0 — architectural moves
- Detect-then-classify pipeline —
ScanMode.detectThenClassify,scanBytesDetectThenClassify/scanFileDetectThenClassify; per-region labels onBodyPartDetection.labels. - Per-category thresholds —
ScanConfiguration.thresholdsByCategory,ScanResult.withThresholds. - Persistent decision store —
DecisionStore(InMemory*/SharedPreferences*),NsfwDetector.decisions,ScanResult.userDecision. - Telemetry hooks —
NsfwDetector.onTelemetryEvent, PII-free by default. - Evaluation harness —
tools/eval/precision / recall / F1 reporting + a false-positive regression suite.
2.3.0 — headless inputs + redaction
scanUrl,scanImageProvider,scanPaths(auto-routing batch);findDuplicates+PerceptualHashJSON.- Native redaction —
redactBytes/redactFilewithRedactionMode.blur/.pixelate/.blackBox. prefetchAssets,cachedResult+cacheUpdates,NsfwSafetyProfile.evaluate.- Background sweep scheduling, multi-model ensemble voting, runtime custom model registration.
Full list in CHANGELOG.md.
Result shape
class ScanResult {
final MediaItem item;
final ScanStatus status; // completed | failed | skipped
final DateTime scannedAt;
final List<NsfwLabel> labels; // sorted: NSFW labels first, then by confidence
final List<BodyPartDetection> detections; // detector-mode only
final ScanDecision? userDecision; // from the DecisionStore, if any
// … convenience getters: isNsfw, topCategory, topConfidence,
// hasNudity, hasExplicitContent, isSuggestive, hasDetections,
// confidenceDescription
}
| Category | isNsfw |
Typical handling |
|---|---|---|
safe |
false | allow |
suggestive |
false | optional warning |
nudity |
true | block or blur |
explicitNudity |
true | block / route to review |
unknown |
false | apply your fallback policy |
result.isNsfw is true only when the scan completed AND the top category is NSFW AND confidence ≥ the threshold.
ScanResult.toJson() / fromJson(...) round-trip preserves the threshold so isNsfw is stable across persistence.
Models
Four models ship out of the box — preload one for the lightest footprint, or
several to ensemble for higher accuracy. None of them is bundled in the binary;
each downloads on first use (or eagerly via NsfwInitOptions.downloadIfMissing).
| Id | Shape | Size | Strength |
|---|---|---|---|
ModelIds.openNsfw2 |
classifier, 224 (CNN) | ~11 MB | default — small + fast, good baseline accuracy |
ModelIds.falconsai |
classifier, 224 (ViT) | ~75 MB | ViT — different errors than openNsfw2, great ensemble partner |
ModelIds.adamcodd |
classifier, 384 (ViT) | ~75 MB | higher-resolution ViT — best single-model accuracy |
ModelDescriptor.nudenet |
detector, 640 (YOLOv8m body-parts) | ~46 MB | spatial — per-region boxes, drives redaction + detect-then-classify |
Pick a single classifier via ScanConfiguration.modelId, or combine multiple
classifiers via ScanConfiguration.ensemble (example above).
Set a custom mirror URL with setModelUrl(modelId, url). The model archive's
SHA-256 is verified before extraction when pinned on the descriptor. Manage
downloads / preloads via NsfwDetector.instance.models (NsfwModelManager).
Permissions
| Workflow | iOS | Android |
|---|---|---|
scanFile · scanBytes · scanUrl · scanImageProvider |
none | none |
pickMedia · pickAndScan |
NSPhotoLibraryUsageDescription (plugin resolves PHAsset after the picker — missing key = crash) |
none |
scanAsset · startScan |
NSPhotoLibraryUsageDescription |
READ_MEDIA_IMAGES + READ_MEDIA_VIDEO (API 33+) / READ_EXTERNAL_STORAGE (≤32) |
startCameraScan |
NSCameraUsageDescription |
CAMERA |
⚠️ Earlier docs said
pickAndScanneeded no iOS permission — that was wrong.PHPickerViewControlleritself grants per-item access without a prompt, but the plugin then reads thePHAssetto pull bytes/metadata, and iOS gates that path onNSPhotoLibraryUsageDescription. The key must be present inios/Runner/Info.plistbefore the first call. See Required Platform Setup for the exact snippets.
The plugin requests at runtime via requestPermission / requestCameraPermission. NsfwPermissionsView is a drop-in panel showing live status with a Request button.
Documentation
- Getting started
- Cookbook — common patterns
- Permissions
- Media precheck
- Picker workflows
- Library scanning
- Camera scanning
- Configuration
- Models
- Platform gotchas (iOS / Android)
- Performance tuning
- False positives FAQ
- Privacy and limitations
- Troubleshooting
API reference on pub.dev.
Example app
git clone https://github.com/nexas105/flutter_nsfw_scaner.git
cd flutter_nsfw_scaner/example
flutter pub get
flutter run
A real device is recommended for photo-library and camera workflows — the iOS simulator has no camera, and emulator photo libraries are usually empty. The example covers the gallery view, picker flow, camera scanner, result detail, moderation gate, and model selection.
Privacy
- Inference runs on-device on Core ML (iOS) and TFLite (Android). The plugin sends no analytics and performs no telemetry network egress.
onTelemetryEventis a local callback — it hands scan events to your code; nothing leaves the device unless you forward it.- Picker-based scanning avoids full photo-library permission — the system picker grants per-item access.
scanUrlis the only Dart-initiated network egress the plugin performs; everything else is local. Model downloads are explicit calls or the auto-download path the host opts into viaNsfwInitOptions.downloadIfMissing.
Your app remains responsible for explaining permissions, handling results, storing any moderation state, and complying with platform / privacy / safety requirements.
Limitations
NSFW detection is probabilistic. Expect false positives and false negatives on unusual lighting, partial visibility, illustrations, screenshots, low-resolution media, compressed video, or ambiguous content.
Tune confidenceThreshold for your product risk. For sensitive workflows, combine on-device detection with user reporting, human review, policy-specific rules, or additional moderation layers.
Links
License
MIT. See LICENSE.
Libraries
- nsfw_detect
- Privacy-friendly, on-device NSFW detection for Flutter apps.
- nsfw_detect_method_channel
- nsfw_detect_platform_interface