Created
July 18, 2026 16:38
-
-
Save Earu/90dbc8e3748ffde0cb56d6f3b613fa52 to your computer and use it in GitHub Desktop.
A PoC that aims at classifying images on whether they contain NSFW / gore material.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --[[----------------------------------------------------------------------------- | |
| Client-side NSFW / gore image classifier for Garry's Mod | |
| Runs an ONNX image-safety model locally inside a hidden DHTML via onnxruntime-web. | |
| `Classify(url, cb)` -> cb(status), where status is: | |
| STATUS_SAFE safe | |
| STATUS_UNCERTAIN borderline, or we couldn't classify it | |
| STATUS_NSFW nudity or gore | |
| Model: OwenElliott/image-safety-classifier-xs | |
| Things that make this work on gmod's ancient CEF 86: | |
| 1. Its simd/threaded wasm builds don't compile there -> force plain build (`wasm.simd = false`, `numThreads = 1`). | |
| 2. ort 1.14 (last version that loads on 86) caps at onnx IR 8, the model is IR 10 -> patch that one varint (byte 1) from 10 to 8 before creating the session. | |
| We classify one image at a time, concurrent wasm inferences crash 86. | |
| ]]------------------------------------------------------------------------------- | |
| if not CLIENT then return end | |
| local STATUS_SAFE = "STATUS_SAFE" | |
| local STATUS_UNCERTAIN = "STATUS_UNCERTAIN" | |
| local STATUS_NSFW = "STATUS_NSFW" | |
| local NSFW_THRESHOLD = 0.6 -- >= this -> STATUS_NSFW | |
| local UNCERTAIN_THRESHOLD = 0.4 -- >= this (and < nsfw) -> STATUS_UNCERTAIN | |
| local CLASSIFY_TIMEOUT = 20 -- report STATUS_UNCERTAIN if the browser never answers | |
| local ORT_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.14.0/dist/ort.min.js" | |
| local ORT_DIST_URL = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.14.0/dist/" | |
| local MODEL_URL = "https://huggingface.co/OwenElliott/image-safety-classifier-xs/resolve/main/onnx/image-safety-classifier-xs.onnx" | |
| local panel -- lazily created hidden classifier browser | |
| local queue = {} -- { { id, url }, ... } waiting to be picked up by the browser | |
| local pending = {} -- url -> { cb, ... } callbacks waiting on a status | |
| local id_to_url = {} -- classify id -> url | |
| local statuses = {} -- url -> status, cached | |
| local id_counter = 0 | |
| local runtime_failed = false | |
| local function build_page() | |
| return ([==[<html><head></head><body> | |
| <script src="%s"></script> | |
| <script> | |
| var sessionPromise = null; | |
| function getSession() { | |
| if (!sessionPromise) { | |
| ort.env.wasm.simd = false; // chromium 86 can't compile the simd wasm build | |
| ort.env.wasm.numThreads = 1; // no threaded build (no SharedArrayBuffer in DHTML) | |
| ort.env.wasm.proxy = false; | |
| ort.env.wasm.wasmPaths = "%s"; | |
| sessionPromise = fetch("%s").then(function(r) { return r.arrayBuffer(); }).then(function(buf) { | |
| var bytes = new Uint8Array(buf); | |
| if (bytes[0] === 8 && bytes[1] === 10) { bytes[1] = 8; } // onnx ir_version 10 -> 8 | |
| return ort.InferenceSession.create(bytes); | |
| }); | |
| } | |
| return sessionPromise; | |
| } | |
| var busy = false; // one inference, concurrent wasm inferences crash chromium 86 | |
| function classify(id, url) { | |
| var img = new Image(); | |
| img.crossOrigin = "anonymous"; | |
| function finish(nsfw, gore) { NSFW.Result(id, nsfw, gore); busy = false; pump(); } | |
| img.onload = function() { | |
| getSession().then(function(sess) { | |
| var c = document.createElement("canvas"); c.width = 224; c.height = 224; | |
| var ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0, 224, 224); | |
| var d = ctx.getImageData(0, 0, 224, 224).data; // rgba | |
| var n = 224 * 224, f = new Float32Array(3 * n); | |
| for (var i = 0; i < n; i++) { f[i] = d[i*4]; f[n+i] = d[i*4+1]; f[2*n+i] = d[i*4+2]; } | |
| return sess.run({ image: new ort.Tensor("float32", f, [1,3,224,224]) }).then(function(out) { | |
| var pr = out[sess.outputNames[0]].data; // [NSFL, NSFW, SFW] | |
| finish(pr[1], pr[0]); | |
| }); | |
| }).catch(function() { finish(-1, -1); }); | |
| }; | |
| img.onerror = function() { finish(-1, -1); }; | |
| img.src = url; | |
| } | |
| function pump() { | |
| if (busy) return; | |
| NSFW.Next(function(id, url) { if (id > 0) { busy = true; classify(id, url); } }); | |
| } | |
| if (typeof ort !== "undefined") { NSFW.OnReady(); pump(); } | |
| else { NSFW.OnError(); } | |
| </script> | |
| </body></html>]==]):format(ORT_SCRIPT_URL, ORT_DIST_URL, MODEL_URL) | |
| end | |
| local function settle(url, status) | |
| statuses[url] = status | |
| local waiters = pending[url] | |
| pending[url] = nil | |
| if waiters then | |
| for _, cb in ipairs(waiters) do cb(status) end | |
| end | |
| end | |
| local function ensure_panel() | |
| if IsValid(panel) then return end | |
| panel = vgui.Create("DHTML") | |
| panel:SetSize(16, 16) | |
| panel:SetPos(-10000, -10000) -- offscreen | |
| panel:SetAllowLua(false) | |
| panel:AddFunction("NSFW", "Next", function() | |
| local req = table.remove(queue, 1) | |
| if not req then return 0, "" end | |
| return req.id, req.url | |
| end) | |
| -- runtime couldn't load -> uncertain | |
| panel:AddFunction("NSFW", "OnError", function() | |
| runtime_failed = true | |
| for url in pairs(pending) do settle(url, STATUS_UNCERTAIN) end | |
| end) | |
| panel:AddFunction("NSFW", "OnReady", function() end) | |
| panel:AddFunction("NSFW", "Result", function(id, nsfw, gore) | |
| local url = id_to_url[id] | |
| if not url then return end | |
| id_to_url[id] = nil | |
| -- a negative score means we couldn't classify it -> uncertain | |
| local status = STATUS_UNCERTAIN | |
| if nsfw >= 0 then | |
| local worst = math.max(nsfw, gore) | |
| if worst >= NSFW_THRESHOLD then status = STATUS_NSFW | |
| elseif worst < UNCERTAIN_THRESHOLD then status = STATUS_SAFE end | |
| end | |
| settle(url, status) | |
| end) | |
| panel:SetHTML(build_page()) | |
| end | |
| function Classify(url, cb) | |
| if runtime_failed then cb(STATUS_UNCERTAIN) return end | |
| local cached = statuses[url] | |
| if cached ~= nil then cb(cached) return end | |
| if pending[url] then -- already processing | |
| pending[url][#pending[url] + 1] = cb | |
| return | |
| end | |
| pending[url] = { cb } | |
| id_counter = id_counter + 1 | |
| id_to_url[id_counter] = url | |
| queue[#queue + 1] = { id = id_counter, url = url } | |
| ensure_panel() | |
| panel:QueueJavascript("if (typeof pump === 'function') pump();") | |
| timer.Simple(CLASSIFY_TIMEOUT, function() | |
| if pending[url] then settle(url, STATUS_UNCERTAIN) end -- never answered in time | |
| end) | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment