Skip to content

Instantly share code, notes, and snippets.

@Earu
Created July 18, 2026 16:38
Show Gist options
  • Select an option

  • Save Earu/90dbc8e3748ffde0cb56d6f3b613fa52 to your computer and use it in GitHub Desktop.

Select an option

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.
--[[-----------------------------------------------------------------------------
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