├── .all-contributorsrc
├── .circleci
└── config.yml
├── .editorconfig
├── .github
└── dependabot.yml
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── __tests__
├── __snapshots__
│ └── regressionCheck.ts.snap
├── index.ts
└── regressionCheck.ts
├── _art
├── nsfw_demo.gif
├── nsfwjs-mobile.jpg
├── nsfwjs_logo.jpg
└── red_mockup_top.jpg
├── examples
├── manual_testing
│ ├── README.md
│ ├── data
│ │ ├── animations
│ │ │ └── smile.webp
│ │ ├── images
│ │ │ ├── cat.svg
│ │ │ ├── glasses.png
│ │ │ ├── house.webp
│ │ │ └── smile.jpg
│ │ └── index.html
│ ├── default.conf
│ └── docker-compose.yml
├── minimal_demo
│ ├── README.md
│ └── index.html
├── node_demo
│ └── server.js
└── nsfw_demo
│ ├── .env
│ ├── .gitignore
│ ├── README.md
│ ├── config-overrides.js
│ ├── netlify
│ └── edge-functions
│ │ └── country-block.tsx
│ ├── package.json
│ ├── public
│ ├── android-chrome-192x192.png
│ ├── android-chrome-256x256.png
│ ├── apple-touch-icon.png
│ ├── browserconfig.xml
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ ├── index.html
│ ├── manifest.json
│ ├── mstile-150x150.png
│ ├── safari-pinned-tab.svg
│ └── site.webmanifest
│ ├── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── components
│ │ ├── Footer.js
│ │ ├── Header.js
│ │ ├── Loading.js
│ │ ├── Results.js
│ │ └── Underdrop.js
│ ├── index.css
│ ├── index.js
│ ├── ir.svg
│ ├── logo.svg
│ ├── serviceWorker.js
│ └── tflogo.jpg
│ └── yarn.lock
├── jest.config.js
├── models
├── inception_v3
│ ├── group1-shard1of6
│ ├── group1-shard2of6
│ ├── group1-shard3of6
│ ├── group1-shard4of6
│ ├── group1-shard5of6
│ ├── group1-shard6of6
│ └── model.json
├── mobilenet_v2
│ ├── group1-shard1of1
│ └── model.json
└── mobilenet_v2_mid
│ ├── group1-shard1of2
│ ├── group1-shard2of2
│ └── model.json
├── netlify.toml
├── package.json
├── scripts
├── add-extensions.mjs
├── add-nested-package-json.mjs
├── bundle-model.mjs
├── post-test.mjs
└── pre-test.mjs
├── src
├── index.ts
├── model_imports
│ ├── inception_v3.ts
│ ├── mobilenet_v2.ts
│ └── mobilenet_v2_mid.ts
├── nsfw_classes.ts
└── nsfwjs.ts
├── tsconfig.cjs.json
├── tsconfig.esm.json
├── tsconfig.json
└── yarn.lock
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "nsfwjs",
3 | "projectOwner": "infinitered",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 100,
10 | "commit": true,
11 | "contributors": [
12 | {
13 | "login": "GantMan",
14 | "name": "Gant Laborde",
15 | "avatar_url": "https://avatars0.githubusercontent.com/u/997157?v=4",
16 | "profile": "http://gantlaborde.com/",
17 | "contributions": [
18 | "question",
19 | "blog",
20 | "code",
21 | "example",
22 | "ideas",
23 | "infra",
24 | "review",
25 | "test"
26 | ]
27 | },
28 | {
29 | "login": "jamonholmgren",
30 | "name": "Jamon Holmgren",
31 | "avatar_url": "https://avatars3.githubusercontent.com/u/1479215?v=4",
32 | "profile": "https://jamonholmgren.com",
33 | "contributions": [
34 | "doc",
35 | "ideas",
36 | "code",
37 | "content"
38 | ]
39 | },
40 | {
41 | "login": "mazenchami",
42 | "name": "Mazen Chami",
43 | "avatar_url": "https://avatars.githubusercontent.com/u/9324607?v=4",
44 | "profile": "https://github.com/mazenchami",
45 | "contributions": [
46 | "doc",
47 | "code",
48 | "review",
49 | "test"
50 | ]
51 | },
52 | {
53 | "login": "jstudenski",
54 | "name": "Jeff Studenski",
55 | "avatar_url": "https://avatars0.githubusercontent.com/u/7350279?v=4",
56 | "profile": "https://github.com/jstudenski",
57 | "contributions": [
58 | "design"
59 | ]
60 | },
61 | {
62 | "login": "fvonhoven",
63 | "name": "Frank von Hoven III",
64 | "avatar_url": "https://avatars2.githubusercontent.com/u/10098988?v=4",
65 | "profile": "https://github.com/fvonhoven",
66 | "contributions": [
67 | "doc",
68 | "ideas"
69 | ]
70 | },
71 | {
72 | "login": "sandeshsoni",
73 | "name": "Sandesh Soni",
74 | "avatar_url": "https://avatars3.githubusercontent.com/u/3761745?v=4",
75 | "profile": "https://github.com/sandeshsoni",
76 | "contributions": [
77 | "code"
78 | ]
79 | },
80 | {
81 | "login": "seannam1218",
82 | "name": "Sean Nam",
83 | "avatar_url": "https://avatars1.githubusercontent.com/u/24437898?v=4",
84 | "profile": "https://github.com/seannam1218",
85 | "contributions": [
86 | "doc"
87 | ]
88 | },
89 | {
90 | "login": "emer7",
91 | "name": "Gilbert Emerson",
92 | "avatar_url": "https://avatars1.githubusercontent.com/u/21377166?v=4",
93 | "profile": "https://github.com/emer7",
94 | "contributions": [
95 | "code"
96 | ]
97 | },
98 | {
99 | "login": "xilaraux",
100 | "name": "Oleksandr Kozlov",
101 | "avatar_url": "https://avatars2.githubusercontent.com/u/17703730?v=4",
102 | "profile": "https://github.com/xilaraux",
103 | "contributions": [
104 | "infra",
105 | "test",
106 | "code"
107 | ]
108 | },
109 | {
110 | "login": "mlaco",
111 | "name": "Morgan",
112 | "avatar_url": "https://avatars2.githubusercontent.com/u/4466642?v=4",
113 | "profile": "http://morganlaco.com",
114 | "contributions": [
115 | "code",
116 | "ideas"
117 | ]
118 | },
119 | {
120 | "login": "mycaule",
121 | "name": "Michel Hua",
122 | "avatar_url": "https://avatars2.githubusercontent.com/u/6161385?v=4",
123 | "profile": "http://mycaule.github.io/",
124 | "contributions": [
125 | "code",
126 | "doc"
127 | ]
128 | },
129 | {
130 | "login": "kevinvangelder",
131 | "name": "Kevin VanGelder",
132 | "avatar_url": "https://avatars2.githubusercontent.com/u/1771152?v=4",
133 | "profile": "https://www.infinite.red",
134 | "contributions": [
135 | "code",
136 | "doc"
137 | ]
138 | },
139 | {
140 | "login": "TechnikEmpire",
141 | "name": "Jesse Nicholson",
142 | "avatar_url": "https://avatars2.githubusercontent.com/u/11234763?v=4",
143 | "profile": "http://technikempire.com",
144 | "contributions": [
145 | "data",
146 | "ideas"
147 | ]
148 | },
149 | {
150 | "login": "camhart",
151 | "name": "camhart",
152 | "avatar_url": "https://avatars0.githubusercontent.com/u/3038809?v=4",
153 | "profile": "https://github.com/camhart",
154 | "contributions": [
155 | "doc"
156 | ]
157 | },
158 | {
159 | "login": "Cameron-Burkholder",
160 | "name": "Cameron Burkholder",
161 | "avatar_url": "https://avatars2.githubusercontent.com/u/13265710?v=4",
162 | "profile": "https://github.com/Cameron-Burkholder",
163 | "contributions": [
164 | "design"
165 | ]
166 | },
167 | {
168 | "login": "qwertyforce",
169 | "name": "qwertyforce",
170 | "avatar_url": "https://avatars0.githubusercontent.com/u/44163887?v=4",
171 | "profile": "https://qwertyforce.ru",
172 | "contributions": [
173 | "doc"
174 | ]
175 | },
176 | {
177 | "login": "YegorZaremba",
178 | "name": "Yegor <3",
179 | "avatar_url": "https://avatars3.githubusercontent.com/u/31797554?v=4",
180 | "profile": "https://github.com/YegorZaremba",
181 | "contributions": [
182 | "code",
183 | "test"
184 | ]
185 | },
186 | {
187 | "login": "navendu-pottekkat",
188 | "name": "Navendu Pottekkat",
189 | "avatar_url": "https://avatars1.githubusercontent.com/u/49474499?v=4",
190 | "profile": "http://navendu.me",
191 | "contributions": [
192 | "doc"
193 | ]
194 | },
195 | {
196 | "login": "VladStepanov",
197 | "name": "Vladislav",
198 | "avatar_url": "https://avatars0.githubusercontent.com/u/49880862?v=4",
199 | "profile": "https://github.com/VladStepanov",
200 | "contributions": [
201 | "code",
202 | "doc"
203 | ]
204 | },
205 | {
206 | "login": "nacht42",
207 | "name": "Nacht",
208 | "avatar_url": "https://avatars1.githubusercontent.com/u/37903575?v=4",
209 | "profile": "https://github.com/nacht42",
210 | "contributions": [
211 | "code"
212 | ]
213 | },
214 | {
215 | "login": "kateinkim",
216 | "name": "kateinkim",
217 | "avatar_url": "https://avatars.githubusercontent.com/u/53795920?v=4",
218 | "profile": "https://github.com/kateinkim",
219 | "contributions": [
220 | "code",
221 | "doc"
222 | ]
223 | },
224 | {
225 | "login": "JanPoonthong",
226 | "name": "jan",
227 | "avatar_url": "https://avatars.githubusercontent.com/u/56725335?v=4",
228 | "profile": "https://janpoonthong.github.io/portfolio/",
229 | "contributions": [
230 | "doc"
231 | ]
232 | },
233 | {
234 | "login": "roerohan",
235 | "name": "Rohan Mukherjee",
236 | "avatar_url": "https://avatars.githubusercontent.com/u/42958812?v=4",
237 | "profile": "https://github.com/roerohan",
238 | "contributions": [
239 | "question",
240 | "infra",
241 | "maintenance",
242 | "code"
243 | ]
244 | },
245 | {
246 | "login": "haZya",
247 | "name": "Hasitha Wickramasinghe",
248 | "avatar_url": "https://avatars.githubusercontent.com/u/63403456?v=4",
249 | "profile": "https://hazya.dev",
250 | "contributions": [
251 | "code",
252 | "doc",
253 | "example",
254 | "ideas",
255 | "infra",
256 | "test"
257 | ]
258 | }
259 | ],
260 | "contributorsPerLine": 7,
261 | "commitConvention": "none"
262 | }
263 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | jobs:
4 | lint:
5 | docker:
6 | - image: cimg/node:20.9.0
7 | steps:
8 | - checkout
9 | - restore_cache:
10 | name: Restore node modules
11 | keys:
12 | - v1-dependencies-{{ checksum "package.json" }}-{{ arch }}
13 | # fallback to using the latest cache if no exact match is found
14 | - v1-dependencies-
15 | - run:
16 | name: Install dependencies
17 | command: yarn install
18 | - save_cache:
19 | name: Save node modules
20 | paths:
21 | - node_modules
22 | key: v1-dependencies-{{ checksum "package.json" }}-{{ arch }}
23 | - run:
24 | name: Lint
25 | command: yarn lint
26 | test:
27 | docker:
28 | - image: cimg/node:20.9.0
29 | steps:
30 | - checkout
31 | - restore_cache:
32 | name: Restore node modules
33 | keys:
34 | - v1-dependencies-{{ checksum "package.json" }}-{{ arch }}
35 | # fallback to using the latest cache if no exact match is found
36 | - v1-dependencies-
37 | - run:
38 | name: Install dependencies
39 | command: yarn install
40 | - save_cache:
41 | name: Save node modules
42 | paths:
43 | - node_modules
44 | key: v1-dependencies-{{ checksum "package.json" }}-{{ arch }}
45 | - run:
46 | name: Test
47 | command: yarn test
48 |
49 | workflows:
50 | nsfw-ci:
51 | jobs:
52 | - lint
53 | - test
54 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # top-most EditorConfig file
2 | root = true
3 |
4 | # all files
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | indent_style = space
9 | indent_size = 2
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 | max_line_length = 80
13 | quote_type = double
14 |
15 | [*.{js,ts}]
16 | quote_type = double
17 | curly_bracket_next_line = false
18 | spaces_around_brackets = inside
19 | indent_brace_style = BSD KNF
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "13:00"
8 | open-pull-requests-limit: 10
9 | ignore:
10 | - dependency-name: np
11 | versions:
12 | - 7.3.0
13 | - 7.4.0
14 | - 7.5.0
15 | - dependency-name: "@types/jest"
16 | versions:
17 | - 26.0.23
18 | - dependency-name: "@tensorflow/tfjs"
19 | versions:
20 | - 3.0.0
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | yarn-error.log
4 | .vscode
5 | .DS_Store
6 |
7 | examples/manual-testing/data/*
8 | !examples/manual-testing/data/index.html
9 | !examples/manual-testing/data/animations/
10 | !examples/manual-testing/data/images/
11 | !examples/manual-testing/data/videos/
12 |
13 | /models/**/*.js
14 | /models/**/*.min.js
15 |
16 | # Local Netlify folder
17 | .netlify
18 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | .rpt2_cache/
3 | demos/
4 | scripts/
5 | src/
6 | coverage/
7 | node_modules/
8 | karma.conf.js
9 | *.tgz
10 | dist/**/*.js.map
11 | .travis.yml
12 | .npmignore
13 | tslint.json
14 | yarn.lock
15 | examples/
16 | _art/
17 | yarn-error.log
18 | bundle.js
19 | .DS_Store
20 | .all-contributorsrc
21 | .editorconfig
22 | .gitignore
23 | jest.config.js
24 | tsconfig.cjs.json
25 | tsconfig.esm.json
26 | tsconfig.json
27 | .circleci/
28 | .github/
29 | __tests__/
30 |
31 | # Models
32 | /models/
33 |
34 | # Ignore intermediate browserify files
35 | # Mountpoint
36 | dist/cjs/nsfwjs.d.ts
37 | dist/cjs/nsfwjs.js
38 | dist/esm/nsfwjs.d.ts
39 | dist/esm/nsfwjs.js
40 | # Unminified bundle
41 | dist/browser/bundle.js
42 |
43 | netlify.toml
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Infinite Red, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Client-side indecent content checking
4 |
5 |
6 | [](#contributors)
7 | [](https://dl.circleci.com/status-badge/redirect/gh/infinitered/nsfwjs/tree/master)
8 | [](https://app.netlify.com/sites/nsfwjs/deploys)
9 |
10 | A simple JavaScript library to help you quickly identify unseemly images; all in the client's browser. NSFWJS isn't perfect, but it's pretty accurate (~90% with small and ~93% with midsized model)... and it's getting more accurate all the time.
11 |
12 | Why would this be useful? [Check out the announcement blog post](https://shift.infinite.red/avoid-nightmares-nsfw-js-ab7b176978b1).
13 |
14 |
15 |
16 |
17 |
18 | ## NOTE
19 |
20 | If you're trying to access the Cloudfront hosted model and are running into an error, it's likely due to the fact that the model has been moved to a new location. Please take a look at our [Host your own model](#host-your-own-model) section. We will be returning the model after some hotlinkers have been dealt with.
21 |
22 | ## **Table of Contents**
23 |
24 |
25 |
26 |
27 | - [QUICK: How to use the module](#quick-how-to-use-the-module)
28 | - [Library API](#library-api)
29 | - [`load` the model](#load-the-model)
30 | - [Caching](#caching)
31 | - [`classify` an image](#classify-an-image)
32 | - [Production](#production)
33 | - [Install](#install)
34 | - [Host your own model](#host-your-own-model)
35 | - [Run the Examples](#run-the-examples)
36 | - [Tensorflow.js in the browser](#tensorflowjs-in-the-browser)
37 | - [Browserify](#browserify)
38 | - [React Native](#react-native)
39 | - [Node JS App](#node-js-app)
40 | - [NSFW Filter](#nsfw-filter)
41 | - [Learn TensorFlow.js](#learn-tensorflowjs)
42 | - [More!](#more)
43 | - [Open Source](#open-source)
44 | - [Need the experts? Hire Infinite Red for your next project](#need-the-experts-hire-infinite-red-for-your-next-project)
45 | - [Contributors](#contributors)
46 |
47 |
48 |
49 | The library categorizes image probabilities in the following 5 classes:
50 |
51 | - `Drawing` - safe for work drawings (including anime)
52 | - `Hentai` - hentai and pornographic drawings
53 | - `Neutral` - safe for work neutral images
54 | - `Porn` - pornographic images, sexual acts
55 | - `Sexy` - sexually explicit images, not pornography
56 |
57 | > _The demo is a continuous deployment source - Give it a go: http://nsfwjs.com_
58 |
59 | ## QUICK: How to use the module
60 |
61 | With `async/await` support:
62 |
63 | ```js
64 | import * as nsfwjs from "nsfwjs";
65 |
66 | const img = document.getElementById("img");
67 |
68 | // If you want to host models on your own or use different model from the ones available, see the section "Host your own model".
69 | const model = await nsfwjs.load();
70 |
71 | // Classify the image
72 | const predictions = await model.classify(img);
73 | console.log("Predictions: ", predictions);
74 | ```
75 |
76 | Without `async/await` support:
77 |
78 | ```js
79 | import * as nsfwjs from "nsfwjs";
80 |
81 | const img = document.getElementById("img");
82 |
83 | // If you want to host models on your own or use different model from the ones available, see the section "Host your own model".
84 | nsfwjs
85 | .load()
86 | .then(function (model) {
87 | // Classify the image
88 | return model.classify(img);
89 | })
90 | .then(function (predictions) {
91 | console.log("Predictions: ", predictions);
92 | });
93 | ```
94 |
95 | ## Library API
96 |
97 | ### `load` the model
98 |
99 | Before you can classify any image, you'll need to load the model.
100 |
101 | ```js
102 | const model = nsfwjs.load(); // Default: "MobileNetV2"
103 | ```
104 |
105 | You can use the optional first parameter to specify which model you want to use from the three that are bundled together. Defaults to: `"MobileNetV2"`. This supports tree-shaking on supported bundlers like Webpack, so you will only be loading the model you are using.
106 |
107 | ```js
108 | const model = nsfwjs.load("MobileNetV2Mid"); // "MobileNetV2" | "MobileNetV2Mid" | "InceptionV3"
109 | ```
110 |
111 | You can also use same parameter and load the model from your website/server, as explained in the [Host your own model](#host-your-own-model) section. Doing so could reduce the bundle size for loading the model by approximately 1.33 times (33%) since you can directly use the binary files instead of the base64 that are bundled with the package. i.e. The `"MobileNetV2"` model bundled into the package is 3.5MB instead of 2.6MB for hosted binary files. This would only make a difference if you are loading the model every time (without [Caching](#caching)) on the client-side browser since on the server-side, you'd only be loading the model once at the server start.
112 |
113 | Model MobileNetV2 - [224x224](https://github.com/infinitered/nsfwjs/blob/master/models/mobilenet_v2/)
114 |
115 | ```js
116 | const model = nsfwjs.load("/path/to/mobilenet_v2/");
117 | ```
118 |
119 | If you're using a model that needs an image of dimension other than 224x224, you can pass the size in the options parameter.
120 |
121 | Model MobileNetV2Mid - [Graph](https://github.com/infinitered/nsfwjs/tree/master/models/mobilenet_v2_mid)
122 |
123 | ```js
124 | /* You may need to load this model with graph type */
125 | const model = nsfwjs.load("/path/to/mobilenet_v2_mid/", { type: 'graph' });
126 | ```
127 |
128 | If you're using a graph model, you cannot use the infer method, and you'll need to tell model load that you're dealing with a graph model in options.
129 |
130 | Model InceptionV3 - [299x299](https://github.com/infinitered/nsfwjs/tree/master/models/inception_v3)
131 |
132 | ```js
133 | const model = nsfwjs.load("/path/to/inception_v3/", { size: 299 });
134 | ```
135 |
136 | ### Caching
137 |
138 | If you're using in the browser and you'd like to subsequently load from indexed db or local storage (NOTE: model size may be too large for local storage!) you can save the underlying model using the appropriate scheme and load from there.
139 |
140 | ```js
141 | const initialLoad = await nsfwjs.load(
142 | "/path/to/different/model/" /*, { ...options }*/
143 | );
144 | await initialLoad.model.save("indexeddb://exampleModel");
145 | const model = await nsfwjs.load("indexeddb://exampleModel" /*, { ...options }*/);
146 | ```
147 |
148 | **Parameters**
149 |
150 | Initial Load:
151 | 1. URL or path to folder containing `model.json`.
152 | 2. Optional object with size or type property that your model expects.
153 |
154 | Subsequent Load:
155 | 1. IndexedDB path.
156 | 2. Optional object with size or type property that your model expects.
157 |
158 |
159 | **Returns**
160 |
161 | - Ready to use NSFWJS model object
162 |
163 |
164 | **Troubleshooting**
165 |
166 | - On the tab where the model is being loaded, inspect element and navigate to the the "Application" tab. On the left pane under the "Storage" section, there is a subsection named "IndexedDB". Here you can view if the model is being saved.
167 |
168 |
169 | ### `classify` an image
170 |
171 | This function can take any browser-based image elements (` `, ``, ``) and returns an array of most likely predictions and their confidence levels.
172 |
173 | ```js
174 | // Return top 3 guesses (instead of all 5)
175 | const predictions = await model.classify(img, 3);
176 | ```
177 |
178 | **Parameters**
179 |
180 | - Tensor, Image data, Image element, video element, or canvas element to check
181 | - Number of results to return (default all 5)
182 |
183 | **Returns**
184 |
185 | - Array of objects that contain `className` and `probability`. Array size is determined by the second parameter in the `classify` function.
186 |
187 | ## Production
188 |
189 | Tensorflow.js offers two flags, `enableProdMode` and `enableDebugMode`. If you're going to use NSFWJS in production, be sure to enable prod mode before loading the NSFWJS model.
190 |
191 | ```js
192 | import * as tf from "@tensorflow/tfjs";
193 | import * as nsfwjs from "nsfwjs";
194 | tf.enableProdMode();
195 | //...
196 | let model = await nsfwjs.load(`${urlToNSFWJSModel}`);
197 | ```
198 |
199 | **NOTE:** Consider downloading and hosting the model yourself before moving to production as explained in the [Host your own model](#host-your-own-model) section. This could potentially improve the initial load time of the model. Furthermore, consider [Caching](#caching) the model, if you are using it in the browser.
200 |
201 | ## Install
202 |
203 | NSFWJS is powered by TensorFlow.js as a peer dependency. If your project does not already have TFJS you'll need to add it.
204 |
205 | ```bash
206 | # peer dependency
207 | $ yarn add @tensorflow/tfjs
208 | # install NSFWJS
209 | $ yarn add nsfwjs
210 | ```
211 |
212 | For script tags include all the bundles as shown [here](#browserify). Then simply access the nsfwjs global variable. This requires that you've already imported TensorFlow.js as well.
213 |
214 | ### Host your own model
215 |
216 | The magic that powers NSFWJS is the [NSFW detection model](https://github.com/gantman/nsfw_model). By default, the models are bundled into this package. But you may want to host the models on your own server to reduce bundle size by loading them as raw binary files or to host your own custom model. If you want to host your own version of [the model files](https://github.com/infinitered/nsfwjs/tree/master/models), you can do so by following the steps below. You can then pass the relative URL to your hosted files in the `load` function along with the `options` if necessary.
217 |
218 | Here is how to install the default model on a website:
219 |
220 | 1. Download the project by either downloading as zip or cloning `git clone https://github.com/infinitered/nsfwjs.git`. **_If downloading as zip does not work use cloning._**
221 | 2. Extract the `models` folder from the root of the project and drop it in the `public` directory of your web application to serve them as static files along side your website. (You can host it anywhere such as on a s3 bucket as long as you can access it via URL).
222 | 3. Retrieve the URL and put it into `nsfwjs.load()`. For example: `nsfwjs.load(https://yourwebsite.com/models/mobilenet_v2/model.json)`.
223 |
224 | ## Run the Examples
225 |
226 | ### Tensorflow.js in the browser
227 |
228 | The demo that powers https://nsfwjs.com/ is available in the [`examples/nsfw_demo`](https://github.com/infinitered/nsfwjs/tree/master/examples/nsfw_demo) folder.
229 |
230 | To run the demo, run `yarn prep` which will copy the latest code into the demo. After that's done, you can `cd` into the demo folder and run with `yarn start`.
231 |
232 | ### Browserify
233 |
234 | A browserified version using nothing but promises and script tags is available in the [`minimal_demo`](https://github.com/infinitered/nsfwjs/tree/master/examples/minimal_demo) folder.
235 |
236 | ```js
237 |
238 |
239 |
240 |
241 | ```
242 |
243 | You should host the `nsfwjs.min.js` file and all the model bundles that you want to use alongside your project, and reference them using the `src` attribute in the script tags.
244 |
245 | ### React Native
246 |
247 | The [NSFWJS React Native app](https://github.com/infinitered/nsfwjs-mobile)
248 | 
249 |
250 | Loads a local copy of the model to reduce network load and utilizes TFJS-React-Native. [Blog Post](https://shift.infinite.red/nsfw-js-for-react-native-a37c9ba45fe9)
251 |
252 | ### Node JS App
253 |
254 | Using NPM, you can also use the model on the server side.
255 |
256 | ```bash
257 | $ npm install nsfwjs
258 | $ npm install @tensorflow/tfjs-node
259 | ```
260 |
261 | ```javascript
262 | const axios = require("axios"); //you can use any http client
263 | const tf = require("@tensorflow/tfjs-node");
264 | const nsfw = require("nsfwjs");
265 | async function fn() {
266 | const pic = await axios.get(`link-to-picture`, {
267 | responseType: "arraybuffer",
268 | });
269 | const model = await nsfw.load(); // To load a local model, nsfw.load('file://./path/to/model/')
270 | // Image must be in tf.tensor3d format
271 | // you can convert image to tf.tensor3d with tf.node.decodeImage(Uint8Array,channels)
272 | const image = await tf.node.decodeImage(pic.data, 3);
273 | const predictions = await model.classify(image);
274 | image.dispose(); // Tensor memory must be managed explicitly (it is not sufficient to let a tf.Tensor go out of scope for its memory to be released).
275 | console.log(predictions);
276 | }
277 | fn();
278 | ```
279 |
280 | Here is another full example of a [multipart/form-data POST using Express](examples/node_demo), supposing you are using JPG format.
281 |
282 | ```javascript
283 | const express = require("express");
284 | const multer = require("multer");
285 | const jpeg = require("jpeg-js");
286 |
287 | const tf = require("@tensorflow/tfjs-node");
288 | const nsfw = require("nsfwjs");
289 |
290 | const app = express();
291 | const upload = multer();
292 |
293 | let _model;
294 |
295 | const convert = async (img) => {
296 | // Decoded image in UInt8 Byte array
297 | const image = await jpeg.decode(img, { useTArray: true });
298 |
299 | const numChannels = 3;
300 | const numPixels = image.width * image.height;
301 | const values = new Int32Array(numPixels * numChannels);
302 |
303 | for (let i = 0; i < numPixels; i++)
304 | for (let c = 0; c < numChannels; ++c)
305 | values[i * numChannels + c] = image.data[i * 4 + c];
306 |
307 | return tf.tensor3d(values, [image.height, image.width, numChannels], "int32");
308 | };
309 |
310 | app.post("/nsfw", upload.single("image"), async (req, res) => {
311 | if (!req.file) res.status(400).send("Missing image multipart/form-data");
312 | else {
313 | const image = await convert(req.file.buffer);
314 | const predictions = await _model.classify(image);
315 | image.dispose();
316 | res.json(predictions);
317 | }
318 | });
319 |
320 | const load_model = async () => {
321 | _model = await nsfw.load();
322 | };
323 |
324 | // Keep the model in memory, make sure it's loaded only once
325 | load_model().then(() => app.listen(8080));
326 |
327 | // curl --request POST localhost:8080/nsfw --header 'Content-Type: multipart/form-data' --data-binary 'image=@/full/path/to/picture.jpg'
328 | ```
329 |
330 | You can also use [`lovell/sharp`](https://github.com/lovell/sharp) for preprocessing tasks and more file formats.
331 |
332 | ### NSFW Filter
333 |
334 | [**NSFW Filter**](https://github.com/navendu-pottekkat/nsfw-filter) is a web extension that uses NSFWJS for filtering out NSFW images from your browser.
335 |
336 | It is currently available for Chrome and Firefox and is completely open-source.
337 |
338 | Check out the project [here](https://github.com/navendu-pottekkat/nsfw-filter).
339 |
340 | ## Learn TensorFlow.js
341 |
342 | Learn how to write your own library like NSFWJS with my O'Reilly book "Learning TensorFlow.js" available on [O'Reilly](https://learning.oreilly.com/library/view/learning-tensorflowjs/9781492090786/) and [Amazon](https://amzn.to/3dR3vpY).
343 |
344 | [](https://amzn.to/3dR3vpY)
345 |
346 | ## More!
347 |
348 | An [FAQ](https://github.com/infinitered/nsfwjs/wiki/FAQ:-NSFW-JS) page is available.
349 |
350 | More about NSFWJS and TensorFlow.js - https://youtu.be/uzQwmZwy3yw
351 |
352 | The [model was trained in Keras over several days](https://medium.freecodecamp.org/how-to-set-up-nsfw-content-detection-with-machine-learning-229a9725829c) and 60+ Gigs of data. Be sure to [check out the model code](https://github.com/GantMan/nsfw_model) which was trained on data provided by [Alexander Kim's](https://github.com/alexkimxyz) [nsfw_data_scraper](https://github.com/alexkimxyz/nsfw_data_scraper).
353 |
354 | ### Open Source
355 |
356 | NSFWJS, as open source, is free to use and always will be :heart:. It's MIT licensed, and we'll always do our best to help and quickly answer issues. If you'd like to get a hold of us, join our [community slack](http://community.infinite.red).
357 |
358 | ### Need the experts? Hire Infinite Red for your next project
359 |
360 | If your project's calling for the experts in all things React Native, Infinite Red’s here to help! Our experienced team of software engineers have worked with companies like Microsoft, Zoom, and Mercari to bring even some of the most complex projects to life.
361 |
362 | Whether it’s running a full project or training a team on React Native, we can help you solve your company’s toughest engineering challenges – and make it a great experience at the same time.
363 | Ready to see how we can work together? [Send us a message](mailto:hello@infinite.red)
364 |
365 | ## Contributors
366 |
367 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
368 |
369 |
370 |
371 |
372 |
408 |
409 |
410 |
411 |
412 |
413 |
414 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
415 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/regressionCheck.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Snapshots 1`] = `
4 | Array [
5 | Object {
6 | "className": "Drawing",
7 | "probability": 0.6877415776252747,
8 | },
9 | Object {
10 | "className": "Neutral",
11 | "probability": 0.31009718775749207,
12 | },
13 | Object {
14 | "className": "Hentai",
15 | "probability": 0.00210934947244823,
16 | },
17 | Object {
18 | "className": "Porn",
19 | "probability": 0.00003717914660228416,
20 | },
21 | Object {
22 | "className": "Sexy",
23 | "probability": 0.000014760436897631735,
24 | },
25 | ]
26 | `;
27 |
--------------------------------------------------------------------------------
/__tests__/index.ts:
--------------------------------------------------------------------------------
1 | import * as tf from "@tensorflow/tfjs";
2 | import { ModelName, load } from "../src/index";
3 |
4 | const timeoutMS = 10000;
5 |
6 | it(
7 | "NSFWJS classify doesn't leak",
8 | async () => {
9 | const model = await load();
10 | const x = tf.zeros([224, 224, 3]) as tf.Tensor3D;
11 | const numTensorsBefore = tf.memory().numTensors;
12 | await model.classify(x);
13 | expect(tf.memory().numTensors).toBe(numTensorsBefore);
14 | },
15 | timeoutMS
16 | );
17 |
18 | it(
19 | "NSFWJS infer doesn't leak",
20 | async () => {
21 | const model = await load();
22 | const x = tf.zeros([224, 224, 3]) as tf.Tensor3D;
23 | const numTensorsBefore = tf.memory().numTensors;
24 | model.infer(x);
25 | expect(tf.memory().numTensors).toBe(numTensorsBefore + 1);
26 | },
27 | timeoutMS
28 | );
29 |
30 | it(
31 | "NSFWJS console informs user if no 'modelOrUrl' parameter is passed",
32 | async () => {
33 | const consoleInfoSpy = jest.spyOn(console, "info");
34 | await load();
35 | expect(consoleInfoSpy).toBeCalledWith(
36 | `%cBy not specifying 'modelOrUrl' parameter, you're using the default model: 'MobileNetV2'. See NSFWJS docs for instructions on hosting your own model (https://github.com/infinitered/nsfwjs?tab=readme-ov-file#host-your-own-model).`,
37 | "color: lightblue"
38 | );
39 | },
40 | timeoutMS
41 | );
42 |
43 | it(
44 | "NSFWJS console informs user if no hosted model is passed",
45 | async () => {
46 | const modelOrUrl: ModelName = "MobileNetV2";
47 | const consoleInfoSpy = jest.spyOn(console, "info");
48 | await load(modelOrUrl);
49 | expect(consoleInfoSpy).toBeCalledWith(
50 | `%cYou're using the model: '${modelOrUrl}'. See NSFWJS docs for instructions on hosting your own model (https://github.com/infinitered/nsfwjs?tab=readme-ov-file#host-your-own-model).`,
51 | "color: lightblue"
52 | );
53 | },
54 | timeoutMS
55 | );
56 |
--------------------------------------------------------------------------------
/__tests__/regressionCheck.ts:
--------------------------------------------------------------------------------
1 | import * as tf from "@tensorflow/tfjs";
2 | import { exec } from "child_process";
3 | import fs from "fs";
4 | import jpeg from "jpeg-js";
5 | import { load } from "../src/index";
6 |
7 | // Fix for JEST
8 | const timeoutMS = 10000;
9 | const NUMBER_OF_CHANNELS = 3;
10 |
11 | const readImage = (path: string) => {
12 | const buf = fs.readFileSync(path);
13 | const pixels = jpeg.decode(buf, { useTArray: true });
14 | return pixels;
15 | };
16 |
17 | const imageByteArray = (
18 | image: ReturnType,
19 | numChannels: number
20 | ) => {
21 | const pixels = image.data;
22 | const numPixels = image.width * image.height;
23 | const values = new Int32Array(numPixels * numChannels);
24 |
25 | for (let i = 0; i < numPixels; i++) {
26 | for (let channel = 0; channel < numChannels; ++channel) {
27 | values[i * numChannels + channel] = pixels[i * 4 + channel];
28 | }
29 | }
30 |
31 | return values;
32 | };
33 |
34 | const imageToInput = (
35 | image: ReturnType,
36 | numChannels: number
37 | ) => {
38 | const values = imageByteArray(image, numChannels);
39 | const outShape = [image.height, image.width, numChannels] as [
40 | number,
41 | number,
42 | number
43 | ];
44 | const input = tf.tensor3d(values, outShape, "int32");
45 |
46 | return input;
47 | };
48 |
49 | it(
50 | "Snapshots",
51 | async () => {
52 | const model = await load();
53 | const logo = readImage(`${__dirname}/../_art/nsfwjs_logo.jpg`);
54 | const input = imageToInput(logo, NUMBER_OF_CHANNELS);
55 | const predictions = await model.classify(input);
56 | expect(predictions).toMatchSnapshot();
57 | },
58 | timeoutMS
59 | );
60 |
61 | it(
62 | "Builds, bundles and minifies",
63 | (done) => {
64 | const cmd = "yarn bundle";
65 | exec(cmd, (err) => {
66 | if (err) done.fail("Failed to build, bundle and minify");
67 | // All good!
68 | done();
69 | });
70 | },
71 | timeoutMS * 6
72 | );
73 |
--------------------------------------------------------------------------------
/_art/nsfw_demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/_art/nsfw_demo.gif
--------------------------------------------------------------------------------
/_art/nsfwjs-mobile.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/_art/nsfwjs-mobile.jpg
--------------------------------------------------------------------------------
/_art/nsfwjs_logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/_art/nsfwjs_logo.jpg
--------------------------------------------------------------------------------
/_art/red_mockup_top.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/_art/red_mockup_top.jpg
--------------------------------------------------------------------------------
/examples/manual_testing/README.md:
--------------------------------------------------------------------------------
1 | We setup our own `nginx` server to avoid CORS issues
2 |
3 | ```sh
4 | docker-compose up -d
5 |
6 | firefox $PWD/data/index.html
7 | ```
8 |
--------------------------------------------------------------------------------
/examples/manual_testing/data/animations/smile.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/manual_testing/data/animations/smile.webp
--------------------------------------------------------------------------------
/examples/manual_testing/data/images/cat.svg:
--------------------------------------------------------------------------------
1 |
2 |
13 |
15 |
17 |
18 |
20 | image/svg+xml
21 |
23 |
24 |
25 |
26 |
27 |
30 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/examples/manual_testing/data/images/glasses.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/manual_testing/data/images/glasses.png
--------------------------------------------------------------------------------
/examples/manual_testing/data/images/house.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/manual_testing/data/images/house.webp
--------------------------------------------------------------------------------
/examples/manual_testing/data/images/smile.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/manual_testing/data/images/smile.jpg
--------------------------------------------------------------------------------
/examples/manual_testing/data/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Manual testing
6 |
7 |
8 |
9 |
Please wait ~10 secs and checkout console.log output for results
10 |
11 |
12 |
Images
13 |
14 |
15 |
JPG image
16 |
23 |
24 |
25 |
26 |
PNG image
27 |
34 |
35 |
36 |
37 |
WebP image
38 |
45 |
46 |
47 |
48 |
SVG image
49 |
56 |
57 |
58 |
59 |
60 |
61 |
Animations
62 |
63 |
64 |
65 |
WebP animation
66 |
67 |
68 |
69 |
70 |
71 |
72 |
88 |
89 |
93 |
94 |
95 |
96 |
97 |
98 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/examples/manual_testing/default.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | listen [::]:80;
4 | server_name localhost;
5 |
6 | #charset koi8-r;
7 | #access_log /var/log/nginx/host.access.log main;
8 |
9 | location /data {
10 | root /usr/share/nginx/html;
11 | index index.html index.htm;
12 |
13 | if ($request_method = 'GET') {
14 | add_header 'Access-Control-Allow-Origin' '*';
15 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
16 | add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
17 | add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
18 | }
19 | }
20 |
21 | #error_page 404 /404.html;
22 |
23 | # redirect server error pages to the static page /50x.html
24 | #
25 | error_page 500 502 503 504 /50x.html;
26 | location = /50x.html {
27 | root /usr/share/nginx/html;
28 | }
29 |
30 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80
31 | #
32 | #location ~ \.php$ {
33 | # proxy_pass http://127.0.0.1;
34 | #}
35 |
36 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
37 | #
38 | #location ~ \.php$ {
39 | # root html;
40 | # fastcgi_pass 127.0.0.1:9000;
41 | # fastcgi_index index.php;
42 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
43 | # include fastcgi_params;
44 | #}
45 |
46 | # deny access to .htaccess files, if Apache's document root
47 | # concurs with nginx's one
48 | #
49 | #location ~ /\.ht {
50 | # deny all;
51 | #}
52 | }
53 |
--------------------------------------------------------------------------------
/examples/manual_testing/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | nginx:
4 | image: nginx:1.19.1
5 | container_name: nginx
6 | volumes:
7 | - ./default.conf:/etc/nginx/conf.d/default.conf
8 | - ./data:/usr/share/nginx/html/data
9 | - ../../dist:/usr/share/nginx/html/data/dist
10 | ports:
11 | - 80:80
12 | - 443:443
13 |
--------------------------------------------------------------------------------
/examples/minimal_demo/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [Simplest example of NSFWJS possible](#simplest-example-of-nsfwjs-possible)
6 |
7 |
8 |
9 | # Simplest example of NSFWJS possible
10 |
11 | Please do not use this code in your app. It depends on our S3. You can download the needed files and set this up yourself so that you host them on your site, but for simplicity's sake, I wanted to make a very simple and basic version to demo.
12 |
13 | Simply double click `index.html` and view your console log for a result.
--------------------------------------------------------------------------------
/examples/minimal_demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
12 |
16 |
20 |
21 |
22 |
41 | Checkout console.log output for results!
42 |
--------------------------------------------------------------------------------
/examples/node_demo/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const multer = require('multer')
3 | const jpeg = require('jpeg-js')
4 |
5 | const tf = require('@tensorflow/tfjs-node')
6 | const nsfw = require('../../dist')
7 |
8 | const app = express()
9 | const upload = multer()
10 |
11 | let _model
12 |
13 | const convert = async (img) => {
14 | // Decoded image in UInt8 Byte array
15 | const image = await jpeg.decode(img, true)
16 |
17 | const numChannels = 3
18 | const numPixels = image.width * image.height
19 | const values = new Int32Array(numPixels * numChannels)
20 |
21 | for (let i = 0; i < numPixels; i++)
22 | for (let c = 0; c < numChannels; ++c)
23 | values[i * numChannels + c] = image.data[i * 4 + c]
24 |
25 | return tf.tensor3d(values, [image.height, image.width, numChannels], 'int32')
26 | }
27 |
28 | app.post('/nsfw', upload.single("image"), async (req, res) => {
29 | if (!req.file)
30 | res.status(400).send("Missing image multipart/form-data")
31 | else {
32 | const image = await convert(req.file.buffer)
33 | const predictions = await _model.classify(image)
34 | image.dispose()
35 | res.json(predictions)
36 | }
37 | })
38 |
39 | const load_model = async () => {
40 | _model = await nsfw.load()
41 | }
42 |
43 | // Keep the model in memory, make sure it's loaded only once
44 | load_model().then(() => app.listen(8080))
45 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
2 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [Available Scripts](#available-scripts)
6 | - [`npm start`](#npm-start)
7 | - [`npm test`](#npm-test)
8 | - [`npm run build`](#npm-run-build)
9 | - [`npm run eject`](#npm-run-eject)
10 | - [Learn More](#learn-more)
11 | - [Code Splitting](#code-splitting)
12 | - [Analyzing the Bundle Size](#analyzing-the-bundle-size)
13 | - [Making a Progressive Web App](#making-a-progressive-web-app)
14 | - [Advanced Configuration](#advanced-configuration)
15 | - [Deployment](#deployment)
16 | - [`npm run build` fails to minify](#npm-run-build-fails-to-minify)
17 |
18 |
19 |
20 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
21 |
22 | ## Available Scripts
23 |
24 | In the project directory, you can run:
25 |
26 | ### `npm start`
27 |
28 | Runs the app in the development mode.
29 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
30 |
31 | The page will reload if you make edits.
32 | You will also see any lint errors in the console.
33 |
34 | ### `npm test`
35 |
36 | Launches the test runner in the interactive watch mode.
37 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
38 |
39 | ### `npm run build`
40 |
41 | Builds the app for production to the `build` folder.
42 | It correctly bundles React in production mode and optimizes the build for the best performance.
43 |
44 | The build is minified and the filenames include the hashes.
45 | Your app is ready to be deployed!
46 |
47 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
48 |
49 | ### `npm run eject`
50 |
51 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
52 |
53 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
54 |
55 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
56 |
57 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
58 |
59 | ## Learn More
60 |
61 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
62 |
63 | To learn React, check out the [React documentation](https://reactjs.org/).
64 |
65 | ### Code Splitting
66 |
67 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
68 |
69 | ### Analyzing the Bundle Size
70 |
71 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
72 |
73 | ### Making a Progressive Web App
74 |
75 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
76 |
77 | ### Advanced Configuration
78 |
79 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
80 |
81 | ### Deployment
82 |
83 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
84 |
85 | ### `npm run build` fails to minify
86 |
87 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
88 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/config-overrides.js:
--------------------------------------------------------------------------------
1 |
2 | const webpack = require('webpack');
3 | module.exports = function override(config) {
4 | const fallback = config.resolve.fallback || {};
5 | Object.assign(fallback, {
6 | "stream": require.resolve("stream-browserify"),
7 | "path": require.resolve("path-browserify"),
8 | "zlib": require.resolve("browserify-zlib"),
9 |
10 | })
11 | config.resolve.fallback = fallback;
12 | config.plugins = (config.plugins || []).concat([
13 | new webpack.ProvidePlugin({
14 | process: 'process/browser',
15 | Buffer: ['buffer', 'Buffer']
16 | })
17 | ])
18 | return config; }
19 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/netlify/edge-functions/country-block.tsx:
--------------------------------------------------------------------------------
1 | import type { Context, Config } from "@netlify/edge-functions";
2 |
3 | export default async (request: Request, context: Context) => {
4 | const BLOCKED_COUNTRY_CODE = "RU";
5 | const countryCode = context.geo?.country?.code ?? "US";
6 | const countryName = context.geo?.country?.name ?? "United States of America";
7 |
8 | if (countryCode === BLOCKED_COUNTRY_CODE) {
9 | return new Response(
10 | `We're sorry, you can't access our content from ${countryName}!`,
11 | {
12 | headers: { "content-type": "text/html" },
13 | status: 451,
14 | }
15 | );
16 | }
17 | };
18 |
19 | export const config: Config = {
20 | path: "/*",
21 | };
22 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nsfw_demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@netlify/edge-functions": "^2.3.1",
7 | "@tensorflow/tfjs": "^2.6.0",
8 | "browserify-zlib": "^0.2.0",
9 | "nsfwjs": "../../",
10 | "path-browserify": "^1.0.1",
11 | "react": "^16.8.1",
12 | "react-dom": "^16.8.1",
13 | "react-dropdown": "^1.6.4",
14 | "react-dropzone": "^7.0.1",
15 | "react-scripts": "5.0.0",
16 | "react-spinkit": "^3.0.0",
17 | "react-switch": "^4.1.0",
18 | "react-webcam": "^1.1.0",
19 | "seedrandom": "^3.0.1",
20 | "stream-browserify": "^3.0.0",
21 | "tether-drop": "^1.4.2"
22 | },
23 | "scripts": {
24 | "start": "react-app-rewired start",
25 | "build": "react-app-rewired build",
26 | "test": "react-app-rewired test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": "react-app"
31 | },
32 | "browserslist": [
33 | ">0.2%",
34 | "not dead",
35 | "not ie <= 11",
36 | "not op_mini all"
37 | ],
38 | "devDependencies": {
39 | "react-app-rewired": "^2.2.1"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/android-chrome-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/android-chrome-256x256.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #ffc40d
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/favicon-16x16.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/favicon-32x32.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/favicon.ico
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
18 |
24 |
25 |
30 |
31 |
32 |
36 |
37 |
41 |
42 |
43 |
52 | NSFW JS
53 |
54 |
55 | You need to enable JavaScript to run this app.
56 |
57 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/public/mstile-150x150.png
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #282c34;
3 | }
4 |
5 | div#root {
6 | max-width: 100vw;
7 | overflow: hidden;
8 | }
9 |
10 | .App {
11 | display: flex;
12 | flex-direction: column;
13 | font-family: Arial, Helvetica, sans-serif;
14 | min-height: 100vh;
15 | text-align: center;
16 | }
17 |
18 | header {
19 | display: flex;
20 | background-color: #000;
21 | color: #fff;
22 | align-items: center;
23 | }
24 |
25 | header h1 {
26 | margin-left: 3%;
27 | font-style: italic;
28 | font-size: 1.5em;
29 | justify-self: flex-end;
30 | }
31 |
32 | .App-logo {
33 | width: 30vw;
34 | max-width: 375px;
35 | min-width: 100px;
36 | pointer-events: none;
37 | padding-left: 3%;
38 | }
39 |
40 | main {
41 | background-color: #282c34;
42 | display: flex;
43 | flex-direction: column;
44 | align-items: center;
45 | justify-content: center;
46 | font-size: calc(10px + 2vmin);
47 | color: white;
48 | flex: 1 0 auto;
49 | }
50 |
51 | #spinContainer {
52 | position: absolute;
53 | padding-bottom: 20px;
54 | }
55 |
56 | #processCube {
57 | width: 200px;
58 | height: 200px;
59 | }
60 |
61 | .VictoryContainer {
62 | margin-top: -70px;
63 | }
64 |
65 | #results {
66 | width: 100%;
67 | }
68 |
69 | #results p {
70 | font-weight: bold;
71 | margin: 8px 0 15px 0;
72 | border-bottom: 1px solid;
73 | display: inline-block;
74 | }
75 |
76 | #camBlock {
77 | padding: 0 20px;
78 | }
79 |
80 | .snippet {
81 | display: flex;
82 | flex-direction: column;
83 | margin-right: 2%;
84 | flex: 1;
85 | justify-content: flex-end;
86 | color: #e79f23;
87 | text-align: right;
88 | font-weight: bold;
89 | }
90 |
91 | .snippet p {
92 | margin-right: 30px;
93 | font-size: 12pt;
94 | }
95 |
96 | .snippet a {
97 | display: flex;
98 | justify-content: flex-end;
99 | }
100 |
101 | #tflogo {
102 | width: 25vw;
103 | max-width: 140px;
104 | }
105 |
106 | .App-link {
107 | color: #e79f23;
108 | }
109 |
110 | #fpInfo {
111 | background-color: rgba(0, 0, 0, 0.9);
112 | color: #fff;
113 | border-radius: 15px;
114 | max-width: 400px;
115 | padding: 20px;
116 | font-size: 1.2em;
117 | border: 2px solid rgba(255, 255, 255, 0.8);
118 | }
119 |
120 | #fpInfo h2 {
121 | border-bottom: 1px solid;
122 | color: #fff;
123 | padding-left: 5px;
124 | }
125 |
126 | #fpInfo h3 {
127 | color: #fff;
128 | }
129 |
130 | #fpInfo a {
131 | color: #e79f23;
132 | }
133 |
134 | #fpInfo li {
135 | padding: 0 0 20px 0;
136 | }
137 |
138 | .clickTarget {
139 | cursor: pointer;
140 | }
141 |
142 | #overDrop {
143 | display: flex;
144 | flex-direction: row;
145 | justify-content: center;
146 | }
147 |
148 | #underDrop {
149 | display: flex;
150 | flex-direction: row;
151 | justify-content: space-between;
152 | align-items: center;
153 | font-size: 0.7em;
154 | background-color: rgba(0, 0, 0, 0.2);
155 | border-radius: 0 0 15px 15px;
156 | padding: 0 15px;
157 | }
158 |
159 | .switchStation {
160 | display: flex;
161 | flex-direction: row;
162 | align-items: center;
163 | float: right;
164 | }
165 |
166 | .switchStation p {
167 | padding-right: 10px;
168 | }
169 |
170 | .photo-box {
171 | padding: 20px;
172 | border-width: 5px;
173 | border-color: #02bbd7;
174 | border-style: dashed;
175 | }
176 |
177 | .dropped-photo {
178 | height: 400px;
179 | max-width: 100%;
180 | }
181 |
182 | #predictions {
183 | min-height: 100px;
184 | margin-bottom: 20px;
185 | }
186 |
187 | #predictions ul {
188 | list-style-type: none;
189 | margin-top: -1%;
190 | margin-left: -2%;
191 | }
192 |
193 | #predictions ul li {
194 | font-size: 0.8em;
195 | color: #e79f23;
196 | }
197 |
198 | footer {
199 | align-items: center;
200 | background: #000;
201 | bottom: 0;
202 | color: #fff;
203 | display: flex;
204 | flex-wrap: wrap;
205 | font-size: 0.875em;
206 | font-weight: 600;
207 | justify-content: center;
208 | position: relative;
209 | text-transform: uppercase;
210 | width: 100vw;
211 | }
212 |
213 | .modelPicker {
214 | padding-top: 20px;
215 | display: flex;
216 | justify-content: center;
217 | color: white;
218 | font-size: 1.2em;
219 | align-items: center;
220 | padding-bottom: 100px;
221 | }
222 |
223 | .modelPicker p {
224 | font-size: 1.4em;
225 | padding-right: 7px;
226 | }
227 |
228 | footer div {
229 | display: inline-block;
230 | margin: 15px 30px;
231 | }
232 |
233 | footer a {
234 | color: #fff;
235 | text-decoration: none;
236 | }
237 | footer a:hover {
238 | color: #e79f23;
239 | }
240 |
241 | @media screen and (max-width: 600px) {
242 | header {
243 | flex-direction: column;
244 | }
245 | header h1 {
246 | font-size: 1.5em;
247 | }
248 | .snippet {
249 | visibility: hidden;
250 | display: none;
251 | }
252 |
253 | .VictoryContainer {
254 | margin-top: -40px;
255 | }
256 |
257 | #camDescription span {
258 | display: none;
259 | }
260 |
261 | #camDescription:after {
262 | content: 'Cam';
263 | }
264 |
265 | #blurDescription span {
266 | display: none;
267 | }
268 |
269 | #blurDescription:after {
270 | content: 'Protection';
271 | }
272 |
273 | #switchStation {
274 | padding-right: 5px;
275 | }
276 |
277 | #topMessage:after {
278 | content: ' - On mobile tap to add';
279 | }
280 |
281 | #predictions {
282 | padding-top: 10px;
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/App.js:
--------------------------------------------------------------------------------
1 | import * as nsfwjs from 'nsfwjs'
2 | import React, { Component } from 'react'
3 | import Dropzone from 'react-dropzone'
4 | import Webcam from 'react-webcam'
5 | import './App.css'
6 | import logo from './logo.svg'
7 |
8 | // components
9 | import Footer from './components/Footer'
10 | import Header from './components/Header'
11 | import Loading from './components/Loading'
12 | import Results from './components/Results'
13 | import Underdrop from './components/Underdrop'
14 |
15 | const blurred = { filter: 'blur(30px)', WebkitFilter: 'blur(30px)' }
16 | const clean = {}
17 | const loadingMessage = 'Loading NSFWJS Model'
18 | const dragMessage = 'Drag and drop an image to check'
19 | const camMessage = 'Cam active'
20 | const DETECTION_PERIOD = 1000
21 |
22 | class App extends Component {
23 | state = {
24 | model: null,
25 | graphic: logo,
26 | titleMessage: 'Please hold, the model is loading...',
27 | message: loadingMessage,
28 | predictions: [],
29 | droppedImageStyle: { opacity: 0.4 },
30 | blurNSFW: true,
31 | enableWebcam: false,
32 | loading: true,
33 | fileType: null,
34 | hardReset: false,
35 | currentModelName: 'MobileNetV2Mid',
36 | }
37 |
38 | componentDidMount() {
39 | this._loadModel()
40 | }
41 |
42 | _onChange = ({ value }) => {
43 | this.setState({ currentModelName: value }, this._loadModel)
44 | }
45 |
46 | _loadModel = async () => {
47 | this.setState({
48 | titleMessage: 'Please hold, the model is loading...',
49 | message: loadingMessage,
50 | droppedImageStyle: { opacity: 0.4 },
51 | graphic: logo,
52 | hardReset: true,
53 | predictions: [],
54 | loading: true,
55 | })
56 | // Load model from public folder
57 | await nsfwjs
58 | .load(this.state.currentModelName)
59 | .then((model) => {
60 | this.setState({
61 | model,
62 | titleMessage: this.state.enableWebcam ? camMessage : dragMessage,
63 | message: 'Ready to Classify',
64 | loading: false,
65 | })
66 | })
67 | }
68 |
69 | _refWeb = (webcam) => {
70 | this.webcam = webcam
71 | }
72 |
73 | // terrible race condition fix :'(
74 | sleep(ms) {
75 | return new Promise((resolve) => setTimeout(resolve, ms))
76 | }
77 |
78 | detectBlurStatus = (predictions, blurNSFW = this.state.blurNSFW) => {
79 | let droppedImageStyle = clean
80 | if (blurNSFW) {
81 | switch (predictions[0].className) {
82 | case 'Hentai':
83 | case 'Porn':
84 | case 'Sexy':
85 | droppedImageStyle = blurred
86 | break
87 | default:
88 | break
89 | }
90 | }
91 | return droppedImageStyle
92 | }
93 |
94 | checkContent = async () => {
95 | // Sleep bc it's grabbing image before it's rendered
96 | // Not really a problem of this library
97 | await this.sleep(100)
98 | const img = this.refs.dropped
99 | const predictions = await this.state.model.classify(img)
100 | let droppedImageStyle = this.detectBlurStatus(predictions)
101 | this.setState({
102 | message: `Identified as ${predictions[0].className}`,
103 | predictions,
104 | droppedImageStyle,
105 | })
106 | }
107 |
108 | setFile = (file) => {
109 | // drag and dropped
110 | const reader = new FileReader()
111 | reader.onload = (e) => {
112 | this.setState(
113 | { graphic: e.target.result, fileType: file.type },
114 | this.checkContent
115 | )
116 | }
117 |
118 | reader.readAsDataURL(file)
119 | }
120 |
121 | onDrop = (accepted, rejected) => {
122 | if (rejected.length > 0) {
123 | window.alert('JPG, PNG, WEBP only plz')
124 | } else {
125 | let droppedImageStyle = this.state.blurNSFW ? blurred : clean
126 | this.setState({
127 | message: 'Processing...',
128 | droppedImageStyle,
129 | hardReset: true,
130 | })
131 | this.setFile(accepted[0])
132 | }
133 | }
134 |
135 | detectWebcam = async () => {
136 | await this.sleep(100)
137 |
138 | const video = document.querySelectorAll('.captureCam')
139 | // assure video is still shown
140 | if (video[0]) {
141 | const predictions = await this.state.model.classify(video[0])
142 | let droppedImageStyle = this.detectBlurStatus(predictions)
143 | this.setState({
144 | message: `Identified as ${predictions[0].className}`,
145 | predictions,
146 | graphic: logo,
147 | droppedImageStyle,
148 | })
149 | setTimeout(this.detectWebcam, DETECTION_PERIOD)
150 | }
151 | }
152 |
153 | blurChange = (checked) => {
154 | // Check on blurring
155 | let droppedImageStyle = clean
156 | if (this.state.predictions.length > 0) {
157 | droppedImageStyle = this.detectBlurStatus(this.state.predictions, checked)
158 | }
159 |
160 | this.setState({
161 | blurNSFW: checked,
162 | droppedImageStyle,
163 | })
164 | }
165 |
166 | _renderInterface = () => {
167 | const maxWidth = window.innerWidth
168 | const maxHeight = window.innerHeight
169 |
170 | const videoConstraints = {
171 | width: { ideal: maxWidth, max: maxWidth },
172 | height: { ideal: maxHeight, max: maxHeight },
173 | facingMode: 'environment',
174 | }
175 | if (this.state.enableWebcam) {
176 | return (
177 |
186 | )
187 | } else {
188 | // Only way I can seem to revive it is
189 | // to force a full re-render of Drop area
190 | if (this.state.hardReset) {
191 | this.setState({ hardReset: false })
192 | return null
193 | }
194 | return (
195 |
201 |
202 |
208 |
209 |
210 | )
211 | }
212 | }
213 |
214 | _camChange = (e) => {
215 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
216 | window.alert("Sorry, your browser doesn't seem to support camera access.")
217 | return
218 | }
219 | this.detectWebcam()
220 | this.setState({
221 | enableWebcam: !this.state.enableWebcam,
222 | message: 'Ready',
223 | predictions: [],
224 | droppedImageStyle: {},
225 | fileType: null,
226 | titleMessage: this.state.enableWebcam ? dragMessage : camMessage,
227 | })
228 | }
229 |
230 | render() {
231 | return (
232 |
233 |
234 |
235 |
236 |
237 |
{this.state.titleMessage}
238 |
239 | {this._renderInterface()}
240 |
246 |
247 |
248 |
252 |
253 |
254 |
255 | )
256 | }
257 | }
258 |
259 | export default App
260 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render( , div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Dropdown from 'react-dropdown'
3 | import 'react-dropdown/style.css'
4 | import ir from '../ir.svg'
5 | const options = [
6 | {
7 | type: 'group',
8 | name: 'Mobilenet v2 Model',
9 | items: [
10 | {
11 | value: 'MobileNetV2',
12 | label: '90% Accurate - 2.6MB',
13 | },
14 | {
15 | value: 'MobileNetV2Mid',
16 | label: '93% Accurate - 4.2MB',
17 | },
18 | ],
19 | },
20 | {
21 | type: 'group',
22 | name: 'Inception v3 Model',
23 | items: [
24 | {
25 | value: 'InceptionV3',
26 | label: '93% Accurate - Huge!',
27 | },
28 | ],
29 | },
30 | ]
31 |
32 | const Footer = (props) => (
33 |
34 |
35 |
Currently Using:
36 |
41 |
42 |
67 |
68 | )
69 |
70 | export default Footer
71 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import tflogo from '../tflogo.jpg'
3 | import logo from '../logo.svg'
4 |
5 | const Header = () => (
6 |
7 |
8 | Client-side indecent content checking
9 |
15 |
16 | )
17 |
18 | export default Header
19 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import * as Spinner from 'react-spinkit'
3 |
4 | const Loading = ({ showLoading }) => {
5 | if (showLoading) {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
13 | return null
14 | }
15 |
16 | export default Loading
17 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/components/Results.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | // Render Text Prediction
4 | const renderPredictions = props => {
5 | // only render if predictions is in singular format
6 | if (props.predictions[0] && props.predictions[0].className) {
7 | return (
8 |
9 |
10 | {props.predictions.map(prediction => (
11 |
12 | {prediction.className} -{' '}
13 | {(prediction.probability * 100).toFixed(2)}%
14 |
15 | ))}
16 |
17 |
18 | )
19 | }
20 | }
21 |
22 | const Results = props => (
23 |
24 |
{props.message}
25 | {renderPredictions(props)}
26 |
27 | )
28 |
29 | export default Results
30 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/components/Underdrop.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Switch from 'react-switch'
3 | import Drop from 'tether-drop'
4 |
5 | export default class Underdrop extends Component {
6 | componentDidMount() {
7 | // hovercard
8 | this.drop = new Drop({
9 | target: this.hoverTarget,
10 | content: this.hoverContent,
11 | position: 'bottom left',
12 | openOn: 'click',
13 | constrainToWindow: true,
14 | constrainToScrollParent: true,
15 | remove: true
16 | })
17 | }
18 |
19 | _refTarget = ref => {
20 | this.hoverTarget = ref
21 | }
22 |
23 | _refContent = ref => {
24 | this.hoverContent = ref
25 | }
26 |
27 | render() {
28 | return (
29 |
30 |
31 | False Positive?
32 |
33 |
34 |
+ False Positives +
35 |
36 | Humans are amazing at visual identification. NSFW tries to error
37 | more on the side of things being dirty than clean. It's part of
38 | what makes failures on NSFW JS entertaining as well as
39 | practical. This algorithm for NSFW JS is constantly getting
40 | improved, and you can help!
41 |
42 |
Ways to Help!
43 |
79 |
80 |
81 |
82 |
83 |
84 | Camera
85 |
86 |
92 |
93 |
94 |
95 | Blur Protection
96 |
97 |
103 |
104 |
105 | )
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render( , document.getElementById('root'));
8 |
9 | // If you want your app to work offline and load faster, you can change
10 | // unregister() to register() below. Note this comes with some pitfalls.
11 | // Learn more about service workers: http://bit.ly/CRA-PWA
12 | serviceWorker.unregister();
13 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/ir.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Infinite Red Logo Light
4 | Created with Sketch.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read http://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit http://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/examples/nsfw_demo/src/tflogo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/examples/nsfw_demo/src/tflogo.jpg
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "node",
4 | testPathIgnorePatterns: ["__mocks__", "examples"],
5 | };
6 |
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard1of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard1of6
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard2of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard2of6
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard3of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard3of6
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard4of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard4of6
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard5of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard5of6
--------------------------------------------------------------------------------
/models/inception_v3/group1-shard6of6:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/inception_v3/group1-shard6of6
--------------------------------------------------------------------------------
/models/mobilenet_v2/group1-shard1of1:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/mobilenet_v2/group1-shard1of1
--------------------------------------------------------------------------------
/models/mobilenet_v2_mid/group1-shard1of2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/mobilenet_v2_mid/group1-shard1of2
--------------------------------------------------------------------------------
/models/mobilenet_v2_mid/group1-shard2of2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/infinitered/nsfwjs/836ff6b3e8bcbceb27f18aa43d5866f80cfe068c/models/mobilenet_v2_mid/group1-shard2of2
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | base = "/"
3 | command = "npm run build && cd examples/nsfw_demo && npm i && npm run build"
4 | publish = "examples/nsfw_demo/build"
5 | edge_functions = "examples/nsfw_demo/netlify/edge-functions"
6 |
7 | [[edge_functions]]
8 | function = "country-block"
9 | path = "/*"
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nsfwjs",
3 | "version": "4.2.1",
4 | "unpkg": "dist/browser/nsfwjs.min.js",
5 | "description": "Detect NSFW content client-side",
6 | "type": "commonjs",
7 | "module": "dist/esm/index.js",
8 | "main": "dist/cjs/index.js",
9 | "exports": {
10 | "import": "./dist/esm/index.js",
11 | "require": "./dist/cjs/index.js"
12 | },
13 | "scripts": {
14 | "lint": "tslint -p . -t verbose",
15 | "build:esm": "tsc -p ./tsconfig.esm.json && node ./scripts/add-extensions.mjs",
16 | "build:cjs": "tsc -p ./tsconfig.cjs.json",
17 | "build": "yarn build:esm && yarn build:cjs && node ./scripts/add-nested-package-json.mjs",
18 | "bundle:mobilenet_v2": "node ./scripts/bundle-model.mjs mobilenet_v2 1",
19 | "bundle:mobilenet_v2_mid": "node ./scripts/bundle-model.mjs mobilenet_v2_mid 2",
20 | "bundle:inception_v3": "node ./scripts/bundle-model.mjs inception_v3 6",
21 | "bundle:models": "yarn bundle:mobilenet_v2 && yarn bundle:mobilenet_v2_mid && yarn bundle:inception_v3",
22 | "postbuild": "yarn bundle:models",
23 | "pretest": "yarn build && node ./scripts/pre-test.mjs",
24 | "test": "jest",
25 | "posttest": "node ./scripts/post-test.mjs",
26 | "toc": "doctoc .",
27 | "contributors:add": "all-contributors add",
28 | "contributors:generate": "all-contributors generate",
29 | "prep": "yarn && yarn build && cd examples/nsfw_demo/ && yarn add ../../ && cd -",
30 | "scriptbundle": "browserify ./dist/cjs/nsfwjs.js -s nsfwjs -o ./dist/browser/bundle.js",
31 | "minbundle": "terser dist/browser/bundle.js -c -m -o dist/browser/nsfwjs.min.js",
32 | "bundle": "yarn build && yarn scriptbundle && yarn minbundle",
33 | "shipit:precheck": "if [ \"$npm_config_user_agent\" != \"${npm_config_user_agent#*yarn}\" ]; then echo 'Use `npm run shipit` instead of yarn.' && exit 1; fi",
34 | "shipit": "npm run shipit:precheck && yarn bundle && np"
35 | },
36 | "repository": {
37 | "type": "git",
38 | "url": "git+https://github.com/infinitered/nsfwjs.git"
39 | },
40 | "peerDependencies": {
41 | "@tensorflow/tfjs": "^4.0.0",
42 | "buffer": "^6.0.3"
43 | },
44 | "devDependencies": {
45 | "@tensorflow/tfjs": "^4.0.0",
46 | "@types/jest": "^28.1.6",
47 | "all-contributors-cli": "^6.20.0",
48 | "babel-core": "^6.26.3",
49 | "babel-plugin-transform-runtime": "~6.23.0",
50 | "browserify": "^17.0.0",
51 | "buffer": "^6.0.3",
52 | "doctoc": "^2.2.0",
53 | "esbuild": "^0.24.0",
54 | "jest": "^28.1.3",
55 | "jpeg-js": "^0.4.4",
56 | "np": "^10.0.0",
57 | "seedrandom": "~3.0.5",
58 | "terser": "^5.14.2",
59 | "ts-jest": "^28.0.7",
60 | "ts-node": "~10.9.1",
61 | "tslint": "~6.1.3",
62 | "typescript": "5.6.2"
63 | },
64 | "packageManager": "yarn@1.22.19",
65 | "keywords": [
66 | "NSFW",
67 | "JavaScript",
68 | "ML",
69 | "Machine",
70 | "Learning",
71 | "Tensorflow",
72 | "JS"
73 | ],
74 | "author": "Gant Laborde",
75 | "license": "MIT",
76 | "bugs": {
77 | "url": "https://github.com/infinitered/nsfwjs/issues"
78 | },
79 | "homepage": "https://nsfwjs.com",
80 | "dependencies": {}
81 | }
82 |
--------------------------------------------------------------------------------
/scripts/add-extensions.mjs:
--------------------------------------------------------------------------------
1 | import { readdirSync, readFileSync, statSync, writeFileSync } from "fs";
2 | import { join } from "path";
3 |
4 | const directory = "./dist/esm"; // Target directory
5 | const extension = ".js";
6 |
7 | function addExtensions(dir) {
8 | const files = readdirSync(dir);
9 |
10 | files.forEach((file) => {
11 | const fullPath = join(dir, file);
12 |
13 | if (statSync(fullPath).isDirectory()) {
14 | addExtensions(fullPath);
15 | } else if (fullPath.endsWith(".js")) {
16 | let content = readFileSync(fullPath, "utf8");
17 |
18 | // Add .js extension to static imports (from './path')
19 | content = content.replace(/(from\s+['"]\.\/[^'"]+)/g, (match) => {
20 | if (!match.endsWith(extension)) {
21 | return `${match}${extension}`;
22 | }
23 | return match;
24 | });
25 |
26 | // Add .js extension to dynamic imports (import('./path'))
27 | content = content.replace(/(import\(['"]\.\/[^'"]+)/g, (match) => {
28 | if (!match.endsWith(extension)) {
29 | return `${match}${extension}`;
30 | }
31 | return match;
32 | });
33 |
34 | writeFileSync(fullPath, content);
35 | }
36 | });
37 | }
38 |
39 | // Call the function on the target directory
40 | addExtensions(directory);
41 |
--------------------------------------------------------------------------------
/scripts/add-nested-package-json.mjs:
--------------------------------------------------------------------------------
1 | import { writeFileSync } from "fs";
2 | import { join } from "path";
3 |
4 | function addPackageJson(dir, type) {
5 | const packageJsonContent = JSON.stringify({ type }, null, 2);
6 | const filePath = join(dir, "package.json");
7 | writeFileSync(filePath, packageJsonContent);
8 | console.log(`Created package.json in ${dir}`);
9 | }
10 |
11 | // Add package.json for ESM and CJS builds
12 | addPackageJson("./dist/esm", "module");
13 | addPackageJson("./dist/cjs", "commonjs");
14 |
--------------------------------------------------------------------------------
/scripts/bundle-model.mjs:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import {
3 | copyFileSync,
4 | mkdirSync,
5 | readFileSync,
6 | unlinkSync,
7 | writeFileSync,
8 | } from "fs";
9 | import { promisify } from "util";
10 |
11 | const execPromise = promisify(exec);
12 | const model = process.argv[2];
13 | const numShards = +process.argv[3];
14 | const filenames = [
15 | "model",
16 | ...Array.from(
17 | { length: numShards },
18 | (_, i) => `group1-shard${i + 1}of${numShards}`
19 | ),
20 | ];
21 |
22 | const BASE_MODEL_PATH = "./models/";
23 | const BASE_DIST_PATH = "./dist/";
24 | const JSON_EXT = ".json";
25 | const MIN_JS_EXT = ".min.js";
26 |
27 | const binToJson = (sourcePath, outputPath) => {
28 | const binFile = readFileSync(`${sourcePath}`);
29 | const base64String = binFile.toString("base64");
30 | writeFileSync(`${outputPath}.json`, JSON.stringify(base64String));
31 | };
32 |
33 | const runCommand = async (command) => {
34 | try {
35 | const { stdout, stderr } = await execPromise(command);
36 | if (stderr) console.error(`stderr: ${stderr}`);
37 | if (stdout) console.log(`stdout: ${stdout}`);
38 | } catch (error) {
39 | console.error(`Error: ${error.message}`);
40 | }
41 | };
42 |
43 | const generateUmd = async (outputPath, filename) => {
44 | const identifier = filename.replace(/-/g, "_");
45 | await runCommand(
46 | `browserify ${outputPath}.json -s ${identifier} -o ${outputPath}.js`
47 | );
48 | await runCommand(
49 | `terser ${outputPath}.js -c -m -o ${outputPath}${MIN_JS_EXT}`
50 | );
51 | unlinkSync(`${outputPath}.js`);
52 | unlinkSync(`${outputPath}.json`);
53 | };
54 |
55 | const generateEsm = async (outputPath) => {
56 | await runCommand(
57 | `esbuild ${outputPath}.json --bundle --format=esm --minify --outfile=${outputPath}${MIN_JS_EXT} --log-level=error`
58 | );
59 | unlinkSync(`${outputPath}.json`);
60 | };
61 |
62 | const processBundle = async (filename, type) => {
63 | const sourcePath = `${BASE_MODEL_PATH}${model}/${filename}`;
64 | const outputDir = `${BASE_DIST_PATH}${type}/models/${model}`;
65 | const outputPath = `${outputDir}/${filename}`;
66 |
67 | mkdirSync(outputDir, { recursive: true });
68 |
69 | if (filename === "model") {
70 | copyFileSync(`${sourcePath}${JSON_EXT}`, `${outputPath}${JSON_EXT}`);
71 | } else {
72 | binToJson(sourcePath, outputPath);
73 | }
74 |
75 | if (type === "cjs") {
76 | await generateUmd(outputPath, filename);
77 | } else if (type === "esm") {
78 | await generateEsm(outputPath);
79 | }
80 | };
81 |
82 | const bundleAll = async () => {
83 | await Promise.all(
84 | filenames.map(async (filename) => {
85 | await processBundle(filename, "cjs");
86 | await processBundle(filename, "esm");
87 | })
88 | );
89 | };
90 |
91 | bundleAll().catch((err) => console.error(err));
92 |
--------------------------------------------------------------------------------
/scripts/post-test.mjs:
--------------------------------------------------------------------------------
1 | import { rmSync } from "fs";
2 |
3 | const dir = "./src/models";
4 |
5 | rmSync(dir, { recursive: true, force: true });
6 |
--------------------------------------------------------------------------------
/scripts/pre-test.mjs:
--------------------------------------------------------------------------------
1 | import { copyFileSync, mkdirSync, readdirSync } from "fs";
2 | import { join } from "path";
3 |
4 | const srcDir = "./dist/cjs/models";
5 | const destDir = "./src/models";
6 |
7 | // Get the names of all subfolders in the srcDir
8 | const models = readdirSync(srcDir, { withFileTypes: true })
9 | .filter((dirent) => dirent.isDirectory())
10 | .map((dirent) => dirent.name);
11 |
12 | models.forEach((model) => {
13 | const modelSrcDir = join(srcDir, model);
14 | const modelDestDir = join(destDir, model);
15 |
16 | mkdirSync(modelDestDir, { recursive: true });
17 |
18 | readdirSync(modelSrcDir, { withFileTypes: true })
19 | .filter((dirent) => dirent.isFile() && dirent.name.endsWith(".min.js"))
20 | .forEach((dirent) => {
21 | const srcFile = join(modelSrcDir, dirent.name);
22 | const destFile = join(modelDestDir, dirent.name);
23 | copyFileSync(srcFile, destFile);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as tf from "@tensorflow/tfjs";
2 | // Remove the trailing slash below if this Bun issue gets fixed: https://github.com/oven-sh/bun/issues/8683
3 | import { Buffer } from "buffer/";
4 | import { NSFW_CLASSES } from "./nsfw_classes";
5 |
6 | declare global {
7 | namespace NodeJS {
8 | interface Global {
9 | [x: string]: any;
10 | }
11 | }
12 | interface Window {
13 | [x: string]: any;
14 | }
15 | }
16 |
17 | type IOHandler = tf.io.IOHandler;
18 | type ModelJSON = tf.io.ModelJSON;
19 | type ModelArtifacts = tf.io.ModelArtifacts;
20 | type WeightDataBase64 = { [x: string]: string };
21 |
22 | export type FrameResult = {
23 | index: number;
24 | totalFrames: number;
25 | predictions: Array;
26 | image: HTMLCanvasElement | ImageData;
27 | };
28 |
29 | export type ClassifyConfig = {
30 | topk?: number;
31 | fps?: number;
32 | onFrame?: (result: FrameResult) => any;
33 | };
34 |
35 | interface NSFWJSOptions {
36 | size?: number;
37 | type?: string;
38 | }
39 |
40 | export type PredictionType = {
41 | className: (typeof NSFW_CLASSES)[keyof typeof NSFW_CLASSES];
42 | probability: number;
43 | };
44 |
45 | export type ModelName = "MobileNetV2" | "MobileNetV2Mid" | "InceptionV3";
46 |
47 | type ModelConfig = {
48 | [key in ModelName]: {
49 | numOfWeightBundles: number;
50 | options?: NSFWJSOptions;
51 | };
52 | };
53 |
54 | const availableModels: ModelConfig = {
55 | MobileNetV2: { numOfWeightBundles: 1 },
56 | MobileNetV2Mid: {
57 | numOfWeightBundles: 2,
58 | options: { type: "graph" },
59 | },
60 | InceptionV3: {
61 | numOfWeightBundles: 6,
62 | options: { size: 299 },
63 | },
64 | };
65 |
66 | const DEFAULT_MODEL_NAME: ModelName = "MobileNetV2";
67 | const IMAGE_SIZE = 224; // default to Mobilenet v2
68 |
69 | const getGlobal = () => {
70 | if (typeof globalThis !== "undefined")
71 | return globalThis as typeof globalThis & NodeJS.Global & Window;
72 | if (typeof global !== "undefined")
73 | return global as typeof globalThis & NodeJS.Global & Window;
74 | if (typeof window !== "undefined")
75 | return window as typeof globalThis & NodeJS.Global & Window;
76 | if (typeof self !== "undefined")
77 | return self as typeof globalThis & NodeJS.Global & Window;
78 | throw new Error("Unable to locate global object");
79 | };
80 |
81 | function isModelName(name?: string): name is ModelName {
82 | return !!name && name in availableModels;
83 | }
84 |
85 | const getModelJson = async (modelName: ModelName) => {
86 | const globalModel = getGlobal().model;
87 |
88 | if (globalModel) {
89 | // If the model is available globally (UMD via script tag), return it
90 | return globalModel;
91 | }
92 |
93 | let modelJson;
94 |
95 | if (modelName === "MobileNetV2")
96 | ({ modelJson } = await import("./model_imports/mobilenet_v2"));
97 | else if (modelName === "MobileNetV2Mid")
98 | ({ modelJson } = await import("./model_imports/mobilenet_v2_mid"));
99 | else if (modelName === "InceptionV3")
100 | ({ modelJson } = await import("./model_imports/inception_v3"));
101 |
102 | return (await modelJson()).default;
103 | };
104 |
105 | const getWeightData = async (
106 | modelName: ModelName
107 | ): Promise => {
108 | const { numOfWeightBundles } = availableModels[modelName];
109 | const bundles: WeightDataBase64[] = [];
110 |
111 | for (let i = 0; i < numOfWeightBundles; i++) {
112 | const bundleName = `group1-shard${i + 1}of${numOfWeightBundles}`;
113 | const identifier = bundleName.replace(/-/g, "_");
114 |
115 | const globalWeight = getGlobal()[identifier];
116 | if (globalWeight) {
117 | // If the weight data bundle is available globally (UMD via script tag), use it
118 | bundles.push({ [bundleName]: globalWeight });
119 | } else {
120 | let weightBundles;
121 |
122 | if (modelName === "MobileNetV2")
123 | ({ weightBundles } = await import("./model_imports/mobilenet_v2"));
124 | else if (modelName === "MobileNetV2Mid")
125 | ({ weightBundles } = await import("./model_imports/mobilenet_v2_mid"));
126 | else if (modelName === "InceptionV3")
127 | ({ weightBundles } = await import("./model_imports/inception_v3"));
128 |
129 | const weight = (await weightBundles[i]()).default;
130 | bundles.push({ [bundleName]: weight });
131 | }
132 | }
133 |
134 | return Object.assign({}, ...bundles);
135 | };
136 |
137 | async function loadWeights(modelName: ModelName): Promise {
138 | try {
139 | const weightDataBundles = await getWeightData(modelName);
140 | return weightDataBundles;
141 | } catch {
142 | throw new Error(
143 | `Could not load the weight data. Make sure you are importing the correct shard files from the models directory. Ref: https://github.com/infinitered/nsfwjs?tab=readme-ov-file#browserify`
144 | );
145 | }
146 | }
147 |
148 | async function loadModel(modelName: ModelName | string) {
149 | if (!isModelName(modelName)) return modelName; // Custom url for the model provided
150 |
151 | try {
152 | const modelJson = await getModelJson(modelName);
153 | const weightData = await loadWeights(modelName);
154 | const handler = new JSONHandler(modelJson, weightData);
155 | return handler;
156 | } catch {
157 | throw new Error(
158 | `Could not load the model. Make sure you are importing the model.min.js bundle. Ref: https://github.com/infinitered/nsfwjs?tab=readme-ov-file#browserify`
159 | );
160 | }
161 | }
162 |
163 | export async function load(modelOrUrl?: ModelName): Promise;
164 |
165 | export async function load(
166 | modelOrUrl?: string,
167 | options?: NSFWJSOptions
168 | ): Promise;
169 |
170 | export async function load(
171 | modelOrUrl?: string,
172 | options: NSFWJSOptions = { size: IMAGE_SIZE }
173 | ) {
174 | if (tf == null) {
175 | throw new Error(
176 | `Cannot find TensorFlow.js. If you are using a