惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Security Latest
Security Latest
Recorded Future
Recorded Future
人人都是产品经理
人人都是产品经理
S
SegmentFault 最新的问题
Hacker News - Newest:
Hacker News - Newest: "LLM"
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园 - 三生石上(FineUI控件)
博客园 - 聂微东
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
Know Your Adversary
Know Your Adversary
Spread Privacy
Spread Privacy
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
量子位
L
LINUX DO - 热门话题
L
Lohrmann on Cybersecurity
博客园 - Franky
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tor Project blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
雷峰网
雷峰网
阮一峰的网络日志
阮一峰的网络日志
V
Visual Studio Blog
T
Threatpost
T
Tenable Blog
有赞技术团队
有赞技术团队
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
C
Cisco Blogs
H
Heimdal Security Blog
Attack and Defense Labs
Attack and Defense Labs
A
About on SuperTechFans
Last Week in AI
Last Week in AI
N
News and Events Feed by Topic
T
Threat Research - Cisco Blogs
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
I
Intezer
V
V2EX
Cyberwarzone
Cyberwarzone
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
B
Blog RSS Feed
V
Vulnerabilities – Threatpost
N
Netflix TechBlog - Medium
T
The Blog of Author Tim Ferriss
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
U
Unit 42
PCI Perspectives
PCI Perspectives
P
Privacy International News Feed
D
Docker

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
🛡️ How I Built a NSFWJS Alternative That's 2.5 Lighter (~1 MB Gzipped) and Runs in ~65 ms (WASM)
Matias Affolter · 2026-06-04 · via DEV Community

A field report on shipping a binary SFW/NSFW image classifier for pixagram that runs **entirely in the browser, tuned for the one thing every off-the-shelf model quietly gets wrong: pixel art.


🎯 The itch

I run a Web3 platform for pixel art. People upload sprites, mint them, share them. And the moment you let strangers upload images, you inherit a very old problem: some of those images shouldn't be shown to everyone.

The obvious move is NSFWJS. It's excellent, it's battle-tested, and it runs in the browser. I tried it. Then I looked at what it was doing on my content and realized I was using a sledgehammer to crack a very specific nut.

"I didn't need a model that knows a thousand things about a photograph. I needed one that knows exactly two things about a sprite."

So I built my own. This is the story of how — the good decisions, the one that almost shipped a broken model, and the numbers I ended up with. I've written it so a junior dev can follow the whole thing and a senior one still finds the war stories useful. Where I use jargon, there's a 🔰 In plain terms box right after.


🤔 Why not just use NSFWJS?

NSFWJS is built around a MobileNetV2 backbone, trained on a large corpus of photographic and drawn web imagery, and it returns five categories (Drawing, Hentai, Neutral, Porn, Sexy). That's a fantastic general tool. But three things didn't fit me:

  • It's photographic. My content is pixel art — hard edges, tiny palettes, no anti-aliasing. A model that learned from photos has never really seen a 160×160 sprite.
  • It's bigger than my whole problem. Five classes and a ~3.5M-parameter backbone is a multi-megabyte download for a yes/no question.
  • I only ever ask one question. "Is this safe to show or not?" That's a single threshold, not a five-way softmax I have to interpret.

I wanted something I could embed in my bundle, that ran on-device (no upload, no server round-trip, no privacy headache), and that was trained to understand the medium I actually serve.


📋 What I actually needed

Before writing a line of code, I wrote the spec. It fit on a sticky note:

  • Binary. sfw vs nsfw, one probability, one threshold.
  • Tiny. Small enough to base64-embed in an npm package — ideally ~1 MB.
  • Fast. Real-time-ish on a mid laptop without a GPU. Target: well under 100 ms.
  • On-device. Runs in a Web Worker via WebAssembly. Nothing leaves the browser.
  • Pixel-art-aware. The preprocessing has to respect the pixel grid, not smear it.
  • Self-contained. npm install, import, call classify(). No model-hosting step for the consumer.

Everything below is downstream of that list.


🧠 Choosing a brain that fits in a tweet

I picked MobileNetV4-conv-small-050 from timm. It's ~0.96M parameters — roughly 2.5× lighter than the MobileNetV2 that NSFWJS leans on — and it's one of the strongest architectures per parameter you can pull off the shelf with ImageNet weights.

Then I made it cheaper still: I trained and ran it at 160×160 instead of the usual 224. For a convolutional net, compute scales with the pixel area, so dropping from 224 to 160 cuts the work to roughly half — landing the model around ~65 MFLOPs per image. For a binary decision on a sprite, that resolution is plenty.

🔰 In plain terms: the "backbone" is the pretrained image-understanding part of the model. Starting from one that already learned general vision on millions of photos (ImageNet) means I only have to teach it the last mile — "of the things you already see, which are NSFW?" — instead of teaching it to see from scratch.


🔧 The pipeline (the boring part that matters)

The whole thing is four steps, and each one hands its output to the next:

  1. Train in PyTorch/timm — fine-tune the backbone on a two-folder dataset (nsfw/, sfw/).
  2. Export to ONNX — a portable model format the browser can run.
  3. Quantize — shrink the weights from 32-bit floats to 8-bit integers.
  4. Embed — base64 the quantized model straight into the JS bundle, so there's no separate fetch.

At runtime it's onnxruntime-web, which can run the same model on WebGPU when it's available and fall back to WebAssembly (WASM) on the CPU otherwise.

🔰 In plain terms: WASM lets the browser run compiled code at near-native speed. It's why a neural network can run client-side without melting the tab.

The thing I want to stress for anyone building one of these: the four steps are one chain, and the weakest link decides your accuracy. I learned that the hard way twice — once on quantization, once on preprocessing.


🐇 The quantization rabbit hole (where I almost outsmarted myself)

My first instinct was to go aggressive: if 8-bit is good, 4-bit (Q4) must be better, right? Half the size, the blogs promise 2× speedups, everyone's quantizing LLMs to 4-bit. I almost spent a weekend on it.

Then I actually checked what 4-bit means in ONNX Runtime, and on my own model. Two facts stopped me cold:

  • ONNX Runtime's 4-bit path is weight-only and only touches MatMul operations (it rewrites them into MatMulNBits). It's a tool built for transformers.
  • My model, when I counted its operations, is 46 convolutions, one tiny classifier head, and zero MatMuls. A MobileNet is almost pure convolution.

So I ran the 4-bit quantizer on it anyway, just to be sure. It dutifully skipped every single node and handed me back a file that was 100% of the original size. Nothing happened, because there was nothing it knew how to touch.

"Halving a file that already fits in cache doesn't make it faster — it just makes it smaller. Those are not the same win. The 4-bit magic is real; it's just real for a different kind of model than mine."

The memory-bandwidth argument behind 4-bit assumes a huge model whose weights can't fit in fast memory. Mine is ~1 MB — it lives in CPU cache comfortably. So I dropped the fantasy and used uint8, which is exactly what ONNX Runtime recommends for the WASM/CPU path.

🔰 In plain terms: quantization stores each weight in fewer bits (32-bit float → 8-bit int) to shrink and speed up the model. Less precision, smaller file. The trick is doing it without changing the model's answers — which is exactly where I got burned later.


🎨 Pixel art breaks all the rules

Here's the thing nobody warns you about when your domain is pixel art: how you resize the image matters more than almost anything else.

Every model needs a fixed input size, so every image gets resized. For photos, the default — bilinear interpolation, which smoothly blends pixels — is fine. For pixel art, it's destructive: it takes crisp, intentional blocks and blurs them into mush. The thing that makes a sprite a sprite is the first thing bilinear throws away.

The right filter for upscaling pixel art is nearest-neighbor ("pixelated"): it keeps every block sharp.

But here's the trap, and it's the one that bites everyone: the resize at training time and the resize at serving time have to be identical. If you train on crisp nearest-neighbor images and then serve on blurred bilinear ones, the model is seeing a different distribution than it learned. You didn't just lose a little accuracy — you're running a different model than the one you trained.

"A model is only as honest as its preprocessing. Train on crisp pixels, serve on blurred ones, and you've quietly shipped a model you never actually tested."

So I made the resize filter a single source of truth: you choose it once at training time (--interp nearest), it gets stamped into the model checkpoint, the exporter reads it back and bakes it into the preprocessing config, and the browser reads that and resizes the same way. One decision, threaded through the entire chain, impossible to desync.

(One sharp edge worth flagging: in the browser, the cleaner-looking createImageBitmap API can do the resize, but its quality setting is browser-dependent — Firefox has historically ignored the "pixelated" hint. For guaranteed nearest-neighbor everywhere, an old-fashioned <canvas> with image smoothing turned off is the reliable tool.)


⚡ Making it fast (and invisible) in the browser

A classifier that freezes the UI is a classifier nobody ships. So the runtime does a few things to stay out of the way:

  • 🧵 Web Worker by default. Inference runs on a background thread, with an automatic fall back to the main thread where workers aren't available. Same API either way.
  • 📦 Batching. Multiple classify() calls within a few milliseconds get coalesced into a single inference, so the fixed per-call overhead is paid once for the whole batch.
  • 🎒 The model travels with the code. It's base64-embedded in the bundle — one fewer network request, and it works offline.
  • 🖥️ GPU when present, CPU when not. The runtime tries WebGPU and gracefully falls back to WASM, so a uint8 model still reaches the GPU on machines that have one.

The result of all this — small backbone, 160px, uint8, off-thread — is an inference that lands around ~65 ms on a CPU. Fast enough to check an image the moment it's dropped in.


🐛 The bug that almost broke me: "it never says NSFW"

This is the part I almost left out, because it's embarrassing. But it's also the most useful thing in this whole post, so here it is.

Everything was wired up. I deployed it. And it never flagged anything as NSFW. Not once. Clearly-NSFW pixel art sailed right through as "safe."

My first assumption was the worst-case one: the model never learned, or my training data (photographic) didn't transfer to pixel art at all. A wasted model. But before re-training anything, I wrote a tiny sanity-check script — the single most valuable hour I spent on the project. All it does is run the full-precision model and the quantized model on the same image, with the exact preprocessing, in plain Python. No browser, no canvas, no worker. Just: where, precisely, does the answer go wrong?

I ran it on one NSFW sprite. Here's what it told me:

nsfw_pixelart.png
  FP32 : P(nsfw)=0.973   ← the real model is CONFIDENTLY correct
  UINT8: P(nsfw)=0.106   ← the SHIPPED model is confidently WRONG

Enter fullscreen mode Exit fullscreen mode

There it was. The full-precision model was right — 97% sure it was NSFW. The quantized model I'd actually deployed was 89% sure it was safe. Quantization had flipped the answer.

"FP32 said 0.97. The version I shipped said 0.11. The model wasn't wrong — the model I deployed was a different model than the one I trained."

The cause is a classic, and it's specific to this family of networks. MobileNets are built from depthwise convolutions, where each channel has its own little filter and the weight magnitudes vary wildly from channel to channel. I'd quantized with a single scale for each whole weight tensor (per-tensor), which crushed the small-magnitude channels to nothing. The fix is per-channel quantization — give every channel its own scale — which is a single flag (--wasm-quant u8s8, per-channel int8 weights).

🔰 In plain terms: imagine compressing a choir by setting one volume knob for everyone. The loud singers are fine; the quiet ones vanish. Per-channel quantization gives each singer their own knob. For MobileNets, that's the difference between a working model and a broken one.

The deeper lesson wasn't even about quantization. It was that the model you train and the model you deploy are not automatically the same model — export and quantization are transformations that can silently change behavior. A 30-line script that compares them at the boundary is worth more than any amount of staring at training curves.


🏆 The result

Where it all landed:

NSFWJS (typical) This
Backbone MobileNetV2 (~3.5M params) MobileNetV4-conv-small-050 (~0.96M)
Output 5 categories binary sfw / nsfw
Domain photographic / drawn pixel art
Size several MB ~1 MB gzipped
Inference ~65 ms on CPU (WASM)
Runs in browser in browser, in a worker, offline

Roughly 2.5× lighter, single-purpose, on-device, and — critically — trained and served with preprocessing that respects the medium. It ships as a self-contained npm package: npm install, import { classify }, done.


💡 What I'd tell my past self

The reusable lessons, stripped of my specific stack:

  • 🎯 Match the model to the question. A binary problem doesn't need a five-class model; a sprite doesn't need a photo-scale backbone. Smaller-but-fitted beats bigger-but-generic.
  • 🧮 "Smaller file" and "faster" are different wins. 4-bit shrinks giant models that are memory-bound. A 1-MB CNN is compute-bound and already cache-resident — different problem, different tool.
  • 🔗 Preprocessing is part of the model. Train and serve through the identical pipeline, or you're testing one thing and shipping another. Make it a single source of truth.
  • 🧪 Verify at every boundary. Export and quantization can flip your answers. A tiny full-precision-vs-quantized diff script will save you a day of guessing.
  • 🐛 When it "never fires," suspect the transform, not the model. My model was perfect. The 8-bit copy of it wasn't.

🚀 Try it

This powers content moderation on my pixel-art platform, and it's open. If you're building anything that takes user images in the browser — especially in a niche domain where the big general models don't quite fit — I'd genuinely encourage you to consider training a small, fitted model instead of reaching for the default. It's less work than it sounds, and the result is something you actually understand top to bottom.

Build the thing that knows exactly what it needs to know. 🎨🛡️

Happy shipping.