8000 Add live, fail-closed image search over Wikimedia Commons by blueheron786 · Pull Request #607 · mwmbl/mwmbl · GitHub
[go: up one dir, main page]

Skip to content

Add live, fail-closed image search over Wikimedia Commons - #607

Closed
blueheron786 wants to merge 13 commits into
mainfrom
wikicommons_image_search
Closed

blueheron786 wants to merge 13 commits into
mainfrom
wikicommons_image_search

Conversation

@blueheron786
@blueheron786 blueheron786 commented Nov 7, 2026 •
Copy link
Copy Markdown
Collaborator

What

image

Live image search over Wikimedia Commons, with a fail-closed safety pipeline.

  • GET /api/v2/image-search/?q=&limit= returns approved images only.
  • GET /api/v2/image-search/top/?q= returns one approved image for a strong title match, for showing beside web results, or null.

Nothing from Commons is stored. Approved results sit in the cache for 10 minutes, and rejected metadata is never kept. Thumbnails are loaded by the browser straight from Wikimedia; Mwmbl never proxies or stores them.

Safety layers

An image is returned only if every layer says SAFE. Anything uncertain, missing or failing is excluded.

  1. Query gate: deny-term rules, then the text model refuses a confident NSFW query. (It does not refuse "uncertain": the model scores bare words like "cat" as uncertain, which would blank innocent searches.)
  2. Deterministic rules o 8000 ver title, description and categories (hidden categories included). Folds accents, width variants and invisible characters.
  3. Text classifier: eliasalbouzidi/distilbert-nsfw-text-classifier (Apache-2.0), exported to ONNX and run in-process with the already-present onnxruntime and tokenizers. Long texts are judged chunk by chunk; texts too long to check are uncertain.
  4. Pixel check: NudeNet's detector (MIT) on a thumbnail fetched into memory, scored and discarded. Thumbnails are only fetched from upload.wikimedia.org / thumb.wikimedia.org, with no redirects and a size cap.

A Commons outage or rate limit is a 503, never an empty or unchecked list. It is not cached.

Wikimedia etiquette

Identifying User-Agent, bounded concurrency, and 429/503 + Retry-After honoured through a shared cooldown in the cache. Ranking uses the profile and filters MediaSearch uses (filetype:bitmap -fileres:0, classic_noboostlinks) and falls back to plain file search. CirrusSearch syntax in the user's query is stripped.

Deployment: models are required

The models are not in the repo or the Docker image. Build them once with scripts/export_image_safety_models.py (needs torch; the server does not) and point IMAGE_SEARCH_MODEL_DIR at the output (default devdata/image_safety). Without them the endpoints fail loudly with a 500; they never return images.

Limits, stated plainly

  • No layer detects CSAM. NudeNet finds exposed body parts; the text checks read uploader-written metadata. Child safety rests on Commons' own moderation and the no-retention design, not on this pipeline.
  • Thumbnail URLs are not tied to a file revision, so a file replaced after approval could be served under the same URL (for up to the cache TTL, and in any case once the browser loads it).
  • The text model is English-only, and the deny list is English.
  • Painting/sculpture nudity is mostly neither tagged nor detected by NudeNet.
  • Uncached search takes ~4-8 s (up to 30 thumbnails are fetched and scored). The front end streams it in.
  • Public, unauthenticated endpoints that fan out to Wikimedia and run CPU inference: no per-client rate limit yet.

Verification

  • make check passes; make test: 1847 passed (the one failure, test_openapi's exact v2 path list, is updated here).
  • test/test_image_search.py (74 tests) covers each layer vetoing, fail-closed paths (thumbnail fetch failure, outage, rate limit, not-cached), URL/host validation, markup injection, rate-limit cooldown, and chunked text classification. The real-model tests run when the models are built.
  • Live: red panda returns images; nude woman returns none; sunbathing photos are removed by rules and by pixels; Michelangelo's David is removed by pixels alone.

Reviewed by a code-review, security and trust-and-safety pass; their findings are fixed in the second commit.

Front end: mwmbl/front-end (see the linked PR there).

@codecov
codecov Bot commented Nov 7, 2026 •
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@blueheron786
Copy link
Copy Markdown
Collaborator Author

Coverage: the missing lines were all in the model wrappers, which only ran in tests that are skipped without the real models. They now run in CI around a tiny tokenizer and fake ONNX sessions (test/test_image_search_models.py), plus tests for a successful thumbnail fetch, top_image from cache and the /top/ 503. mwmbl/imagesearch is 100% covered without the models, codecov/patch passes, and the Docker build job (which builds the models stage) passes.

blueheron786 and others added 6 commits November 7, 2026 21:51
GET /api/v2/image-search/ searches Commons live and returns only images that pass
every safety layer: a query gate, deterministic deny rules over title, description and
categories, a self-hosted DistilBERT text classifier, and NudeNet run on a thumbnail
that is fetched, scored and discarded in memory. Anything uncertain, missing or failing
is excluded; a Commons outage is a 503, never an unchecked list. Nothing is stored
except approved results in the cache for ten minutes.

GET /api/v2/image-search/top/ returns one approved image for a strong title match,
for showing alongside web results.

Wikimedia's 429/503 Retry-After is honoured through a shared back-off, requests carry
an identifying User-Agent, and concurrency is bounded.

Models are built by scripts/export_image_safety_models.py into IMAGE_SEARCH_MODEL_DIR.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- A thumbnail fetch that fails because Wikimedia is unreachable or rate-limiting now
  raises instead of excluding the image, so it becomes a 503 and is never cached as a
  final empty result.
- The plain-search fallback also triggers on an API error body; a failing fallback or a
  non-JSON body is ImageSourceUnavailable, not a 500.
- Entity-encoded markup in Commons metadata is decoded before tags are stripped, so it
  cannot be revived; only https licence links and commons.wikimedia.org page links pass.
- The text classifier judges long texts chunk by chunk (worst chunk wins) and treats
  texts too long to check, and empty texts, as uncertain, so categories are no longer
  beyond its 128-token window.
- Deny rules fold accents, width variants and invisible characters.
- CirrusSearch syntax in the query is removed before it reaches Commons.
- The query gate refuses only a confident NSFW verdict: the model scores bare words such
  as "cat" as uncertain, which blanked innocent searches.
- Document that no layer detects CSAM and that approved thumbnail URLs are mutable.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A separate stage exports the text model to ONNX with CPU torch and fetches NudeNet's
detector; the final stage copies only the exported files and sets IMAGE_SEARCH_MODEL_DIR,
so the image ships onnxruntime and not torch. The stage depends on nothing but the export
script, so it stays cached until the script changes.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CI has no models, so the wrappers' own code only ran in tests that are skipped there. Run it
in CI around a tiny tokenizer and fake ONNX sessions: tokenizing, chunking, overflow,
softmax, preprocessing, thresholds and undecodable or oversized images. Also cover a
successful thumbnail fetch, top_image served from the cache, and the /top/ 503.

mwmbl/imagesearch is now fully covered without the models.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The text model is trained on sentences and on a bare word it is noise: it calls "gorilla",
"monkey" and "ape" NSFW, so those searches returned nothing. Judge the query framed as a
caption instead, and add the explicit terms the deny rules lacked (onlyfans, camgirl,
busty, horny, milf and similar), since the rules, not the model, are what catch explicit
queries.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An uncached image search fetches up to thirty thumbnails and runs two models, and the
endpoints are public. Limit each client network (the proxy-seen address, IPv6 grouped by
/64, as search_callers does) to IMAGE_SEARCH_REQUESTS_PER_MINUTE across both endpoints,
with a 429 before any work is done. One client's burst would otherwise also trip
Wikimedia's own limit and pause image search for everyone.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@blueheron786
blueheron786 force-pushed the wikicommons_image_search branch from 5893b23 to e18d7ac Compare November 8, 2026 02:51
blueheron786 and others added 2 commits November 8, 2026 19:47
One search ran the text model over about 60 chunks and took 10-23s of CPU
(2-6s of wall time) on a 12-core machine. Two things were wasting it:

- Every chunk was padded to 128 tokens, though the average real length is
  about 72. The text model now runs in batches of 8 sorted by length, each
  trimmed to its longest chunk. Padding is masked, so a chunk's probability
  is unchanged: on 539 real chunks all verdicts were identical.
- ONNX Runtime started a spinning thread per core for each model. Sessions
  now get a configured thread count (2 for text, 1 for NudeNet, which already
  runs in parallel across the thumbnail workers) and idle threads sleep.

Measured per search, CPU time, before -> after:
  text model   10.4 / 16.4 / 23.5s -> 4.3 / 4.0 / 6.2s (wall time no worse)
  NudeNet      ~1.1-1.7s per 12 images -> ~0.3s
  query gate   0.04-0.23s -> 0.015s

Dynamic int8 quantization of the text model was also tried: about 2x faster
again, but it moved verdicts (several chunks swung by 0.5+ in NSFW
probability), so it is left for a labelled evaluation.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The same Commons images come up in many queries, but only whole result lists
were cached (for 10 minutes), so every new query re-ran the text model and
re-fetched and re-scored every thumbnail. Verdicts are now cached per image:

- text verdicts by a hash of the text, so an edited description is rejudged;
- pixel verdicts by the file's Commons SHA-1 and thumbnail width, so a
  replaced file is rejudged even though its thumbnail URL is unchanged.

Keys also carry a fingerprint of the model files and the thresholds and
chunking that decide a verdict, so retraining or retuning invalidates them.
Only verdicts are stored, under hashed keys, never text or images.

What is deliberately not cached: an UNCERTAIN pixel verdict (it may only mean
a thumbnail failed to download or decode), and any pixel verdict for a file
whose current version is under 24 hours old, because Wikimedia can keep
serving the replaced file's thumbnail for a while and its verdict must not be
filed under the new SHA-1. A SHA-1 collision between a harmless and an
explicit file is an accepted risk, documented in verdict_cache.

Redis rather than SQL: the verdicts are cheap to recompute, the deployed Redis
is already a bounded allkeys-lru cache, and a Redis flush costs only a
re-check. SQL would add a migration, model-version invalidation, and make
Commons-derived data permanent; it is worth revisiting if images are indexed.

Measured end to end against live Commons with the real models (CPU time per
search): cold 4-7s; the same images under another query string 0.1-0.4s; a
repeat once the result cache has expired 0.04s, with no thumbnail fetches.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@blueheron786
Copy link
Copy Markdown
Collaborator Author

Following up on the server-load discussion. I pushed two independent commits to this branch, one for each question: can we cut CPU per search, and should we cache the expensive part. The numbers below are from real searches against live Commons with the real models. The short answers:

  1. Yes. Model CPU per search drops about 2.5-5x with identical verdicts.
  2. Yes, in Redis. Images that come up again skip the models and the thumbnail fetch. I'd hold off on permanent SQL until we're indexing images.

Where the CPU was going

A search runs the DistilBERT text model over about 60 chunks (each image's title, description and categories), then NudeNet on up to 30 thumbnails. I measured CPU time against wall time, and the CPU was far higher than the wall time. Two causes:

  • Padding. Every chunk was padded to 128 tokens, but the average real length is about 72. The model's cost grows with padded length, so more than a third of the work was on padding.
  • Thread spinning. ONNX Runtime starts one thread per core per model, and idle threads spin instead of sleeping. On my 12-core machine that burned about 5x as much CPU as the work needed. A busy server with fewer cores would feel this as contention rather than raw CPU.

I measured on a 12-core machine and only the model scoring stages, so it won't match the 0.8s Blue measured. The relative improvement is what carries over.

Commit 1: a32999a cut the CPU

  • The text model now runs in batches of 8, sorted by length and trimmed to each batch's longest chunk. Padding is masked, so a chunk's probability doesn't change. I checked on 539 real chunks (from queries including bikini, nude beach, breast cancer, venus de milo): probability difference 0.0 and 539/539 identical verdicts.
  • ONNX sessions get an explicit thread count (IMAGE_SEARCH_TEXT_THREADS = 2, IMAGE_SEARCH_PIXEL_THREADS = 1) and idle threads sleep. NudeNet gets one thread because it already runs in parallel across the thumbnail workers. Both are settings you can tune for the real server.
  • Batch size 8 beat 16 and 64 when I measured. Attention cost grows with the square of the length, and a bigger batch has more length spread to pad.
CPU time per search Before After
Text model (cat / mount fuji / paris) 10.4 / 16.4 / 23.5s 4.3 / 4.0 / 6.2s
NudeNet, per 12 images about 1.1-1.7s about 0.3s
Query gate 0.04-0.23s 0.015s

Wall time for the text model is no worse (1.8-5.8s before across my runs, 2.0-3.2s after). Wall time is noisy because it depends on the machine and what else is running, which is why I quote CPU. The saving is in CPU, not latency, which is what matters for server load.

What I tried and did not ship: int8 quantization. It was about another 2x faster and the model file is 4x smaller (268MB to 67MB). It changed verdicts, though. On the same 539 chunks, 18 moved category. Individual chunks swung by 0.5 or more in NSFW probability (0.96 to 0.08 on a "beaches" text, and 0.04 to 0.90 on "Featured pictures of Assisi"). For a safety filter I don't think we should accept that without a labelled evaluation, so it's still available as a later step.

Commit 2: c0bea96 cache the verdicts

At the moment we only cache whole result lists for 10 minutes. The same images appear in many different queries (red panda, red pandas, panda, giant panda), and each new query re-ran the text model and re-fetched and re-scored every thumbnail. Now each image's verdict is cached.

What the keys are:

  • Text verdicts: a hash of the text. If someone edits a description or category on Commons, it's a new key and gets judged again.
  • Pixel verdicts: the file's Commons SHA-1 plus thumbnail width. A file replaced after approval gets a fresh check even though its thumbnail URL is unchanged. That is stronger than the current URL-based behaviour.
  • Both keys include a fingerprint of the model files plus the thresholds and chunking settings. Retraining a model or moving a threshold makes every old entry unreachable, so a cached verdict can't go stale. The v1 prefix in the key is for a code change that alters verdicts.
  • Only verdicts are stored, under hashed keys. No text or images.

What is deliberately not cached:

  • An UNCERTAIN pixel verdict. It may only mean a thumbnail failed to download or decode this time, so caching it would turn a transient failure into a permanent exclusion.
  • Any pixel verdict for a file whose current version is under 24 hours old (IMAGE_SEARCH_VERDICT_MIN_FILE_AGE_SECONDS). I got this from a review: Wikimedia can keep serving the old file's thumbnail for a while after a replacement. Without the gate, a SAFE for the old image could be filed under the new file's SHA-1 for 30 days. The gate means fresh files are simply re-checked each time.
  • Rate-limit or outage errors raise before anything is written, so nothing is stored when a batch fails part-way. This is tested.

Measured end to end (live Commons, real models, CPU per search):

Search CPU
Cold, red panda 4.2s
Same images under another query string (red pandas, panda red) 0.44s, 0.10s
Overlapping topic (giant panda, then panda) 3.5s, then 0.9s
Cold, eiffel tower 7.2s
eiffel tower again once the 10-minute result cache had expired 0.04s, no thumbnail fetches

This also speeds up the web results top-image lookup, since it goes through the same checks.

Why Redis and not permanent SQL

  • The verdicts are cheap to recompute. A lost entry costs one re-check, never a wrong answer.
  • Production Redis is already a bounded 2GB allkeys-lru cache, so it evicts the least-used entries without any extra bookkeeping.
  • SQL would need a migration, invalidation when the model changes (which the fingerprint gives us for free here), and it would make Commons-derived data permanent.
  • Where SQL becomes the right answer: Daoud's idea of storing images locally and building up an index. At that point we want durable per-image records, and it's worth designing together with the index. This commit doesn't block that, and the key design (content hash plus model fingerprint) carries over.

The cache only helps repeat images. Cold queries for new topics still cost the full model time (the first column of the table above), so the load is bounded by how many new images people search for, not by search volume. Warm-up will be slow on a fresh deploy.

Trade-offs and risks to be aware of

  • SHA-1 collision. An attacker who could craft a SHA-1 collision between a harmless and an explicit file, and get both onto Commons, could get the explicit one approved from the cache. That takes real resources, and I've documented it as an accepted risk in verdict_cache.py. If you'd rather close it, the alternative is keying on a hash of the thumbnail bytes, but that means fetching every thumbnail and loses most of the saving.
  • Memory. Each image can add two entries (text and pixel), each a short hash key plus a one-word value. My 6 test searches created 183 entries. I haven't measured the per-entry size in Redis, so treat any total as unknown, but LRU eviction under the existing 2GB cap bounds it either way.
  • Cache poisoning. The cache can only contain verdicts our own models produced for the exact content, under keys that include the model fingerprint. I couldn't find a way for a user to influence a verdict for content they don't control.
  • It doesn't fix the cold-search cost. For lo 8000 gged-in-only image search or more compute (as Daoud and Blue discussed), these changes just mean we need less of both.

Not changed

  • Verdict logic, thresholds and the fail-closed behaviour are unchanged.
  • No API changes. The one new request field is sha1|timestamp on the Commons call, which I checked the live API returns.
  • Tests: 1907 passed in the full suite, and make check passes. New tests cover batching and ordering, the fingerprints, cache hits and misses, replaced files, fresh files, mixed cached/uncached batches and partial failures.
  • Both commits touch the shared settings, so they carry No functional changes to the crawler.

Next steps, if useful

  1. Set IMAGE_SEARCH_TEXT_THREADS and IMAGE_SEARCH_PIXEL_THREADS against the real server's core count and the load we see.
  2. Evaluate int8 quantization on the labelled queries. It's the biggest remaining CPU win.
  3. Blue's idea of replacing DistilBERT with a lighter text model would multiply with all of this, and the fingerprint means a model swap invalidates the cache cleanly.

blueheron786 and others added 5 commits November 8, 2026 22:38
Compares 15 image models and the production text model against hand-verified
art nudes and Commons photographs, in the production ONNX configuration.
The pixel layer misses 71% of art nudes and 27% of nude photographs; zero-shot
SigLIP2 with art-aware prompts is the only candidate that fixes this, and the
per-image DistilBERT check adds no measurable recall. The report ends with
prioritised, actionable recommendations and the limits of the evidence.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Blue-Eye (DINOv3 ViT-L at 512 px) is precise but narrow: 0 of 238 verified-safe
images excluded at its default and 74% of nude photographs caught, yet it
labels 42 of 52 art nudes safe (its card counts non-sexual art as safe) and
costs 7.5 s per image under ONNX Runtime on one thread. Reaching 95% art recall
would exclude about half of safe images. It is rejected; the recommendations
are unchanged.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Measured against 52 hand-verified nudes (docs/image-search-safety-benchmark.md),
the per-image DistilBERT check caught none of the 22 that the deny rules miss,
excluded 4-8% of safe images, and was the largest CPU cost of a cold search
(about 5 s of about 6 s of model time).

An image's text is now judged by the deny rules alone. The classifier stays
for the query gate, which runs it on one short caption. Its verdict cache and
fingerprint, which only served the per-image check, go with it.

Recall is unchanged; safe images excluded fall from about 8% to 4%.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Against 52 hand-verified art nudes NudeNet catches 29% and Falconsai 2%:
dedicated NSFW classifiers are trained on porn and hentai, so a statue or a
painting is outside what they learned. SigLIP2 scored against fixed prompts
that name the art forms catches 96% of the same nudes, and 94% of 331 nude
photographs with thresholds fitted on the art alone (see
docs/image-search-safety-benchmark.md for method and limits).

The layer is off by default (IMAGE_SEARCH_ZERO_SHOT = "off"); "enforce" makes
an image need SAFE from every pixel layer. The threshold is the 99% art-recall
point, 0.386, fitted down to 0.38.

- ZeroShotSafetyClassifier runs the exported image tower under onnxruntime and
  compares the embedding with precomputed prompt embeddings, so the server
  needs no tokenizer or torch. Checked on 80 real thumbnails against the
  Hugging Face reference: max probability difference 0.015, no decision flips
  at 0.38.
- scripts/export_image_safety_models.py exports the fp32 image tower (int8
  cut recall from 94% to 75%), the prompt embeddings and the temperature.
  The Docker export stage runs it, so the image grows by about 370 MB.
- Each pixel layer's verdicts are cached on their own, keyed by file and by
  that layer's fingerprint, so turning a layer on or off or retuning it leaves
  the other's cached verdicts valid, and the thumbnail is fetched once for all
  layers. NudeNet's cache keys are unchanged.

Known limit, recorded in the report: blank, flat or abstract images score high
(solid black 0.86) because none of the safe prompts describes them. A prompt
for them is tuning against the evaluation and needs a held-out re-test.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
IMAGE_SEARCH_ZERO_SHOT now defaults to "enforce", so an image must be called
SAFE by both NudeNet and the SigLIP2 zero-shot layer. Tests keep it "off"
(the model is not built in CI) and switch it on with a fake model where they
exercise it. "off" restores the previous behaviour.

Needs the exported zero_shot model: the Docker export stage builds it, and a
local IMAGE_SEARCH_MODEL_DIR that predates it needs
scripts/export_image_safety_models.py re-run, or image search fails loudly
rather than returning unchecked images.

Live run with the real models against Commons: "greek statue" returns 8 images
instead of 23, ordinary queries lose 2-3 of 30 ("red panda" 30 -> 28, "eiffel
tower" 30 -> 27). A cold search costs about 12 s of CPU instead of 1 s at one
thread (cached verdicts make repeat images free); the text classifier's
removal in the earlier commit already cut about 5 s from the previous total.

No functional changes to the crawler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@blueheron786
Copy link
Copy Markdown
Collaborator Author

Image search safety: what changed since the load discussion, and what to review

You were worried about server load. Benchmarking the models for that turned up a bigger problem: the pixel layer misses most art nudity and a quarter of nude photographs. I pushed 5 commits. The full report is docs/image-search-safety-benchmark.md (about 80 lines, with prioritised recommendations).

The evidence, in four numbers

Measured on 52 hand-verified art nudes and 331 Commons nude photographs, 16 image models, same images for all (Blue-Eye on a 550-image subset):

art nudes caught nude photographs caught CPU per image
NudeNet (what we had) 29% 73% 30 ms
Falconsai / Blue-Eye (the pre-built ones) 2% / 19% 43% / 74% 327 ms / 7.5 s
SigLIP2 zero-shot (now added) 96% 94% 340 ms

Pre-built NSFW classifiers are trained on porn vs. everyday photos, so statues and paintings are out-of-distribution. The per-image DistilBERT text check caught 0 of the 22 nudes that the keyword rules miss.

Commits

  1. 8ffb012, a36d445: the benchmark report.
  2. b7c9284: stop running DistilBERT on every image's text (same recall, safe images wrongly excluded 8% to 4%, about 5 s less CPU per cold search). It stays as the query gate.
  3. ba37c15: add the SigLIP2 zero-shot layer behind IMAGE_SEARCH_ZERO_SHOT.
  4. 2643112: turn it on by default. "off" is the kill switch.

What you're signing up for

  • CPU: a cold search now costs about 12 s of CPU at one thread (measured live). That is about 2x the ~6 s it cost before commit 2 (DistilBERT + NudeNet), because SigLIP2 is ~11x NudeNet; NudeNet alone is ~1 s. Repeat images are free via the 30-day verdict cache, and wall time is similar (SigLIP2 over 4 thumbnail workers replaces DistilBERT's 2–3 s).
  • More images excluded: live, ordinary queries lose 2–3 of 30 (red panda 30 to 28); greek statue goes 23 to 8, which is the intent. At the 0.38 threshold about 17–19% of safe test images are excluded; 0.68 would halve that for ~95% instead of ~99% recall.
  • Known weakness: blank, flat or abstract images score high (solid black 0.86) because no prompt describes them. Documented in the report. A fix is a held-out re-test, not a quiet tweak.
  • Deploy: Docker image grows ~370 MB, and the build downloads the model from Hugging Face. A local IMAGE_SEARCH_MODEL_DIR needs scripts/export_image_safety_models.py re-run, otherwise image search fails loudly (not unchecked images).

Where to look

  • mwmbl/imagesearch/zero_shot_classifier.py (new) and pipeline.pass_pixel_checks: each pixel layer has its own cached verdicts, so toggling one never invalidates the other; the thumbnail is fetched once for all layers.
  • Verified: make check, full suite (1925 passed), runtime vs. the Hugging Face reference on 80 real thumbnails (max difference 0.015, no decision flips), and a live run with the real models.

Limits and open questions

  • Ground truth is 52 images I labelled by looking, plus unreviewed category-labelled photographs. Zero misses can't be proven; keep a report/takedown path.
  • I skipped the planned shadow-mode rollout (a decision to enable directly), so this enforces immediately.
  • For you: is +370 MB and ~2x model CPU acceptable? Threshold 0.38 or 0.68? Next on the list is moving scoring off the request path (batch + index), your idea.

@blueheron786
Copy link
Copy Markdown
Collaborator Author

Performance after the SigLIP2 layer, and the case for scoring offline

Where the load stands. Model CPU for a cold search of about 28 images (one thread per session, measured):

State CPU per cold search
Before this PR's performance work about 12–25 s
After batching + thread fixes (a32999a) about 5–7 s
After dropping per-image DistilBERT (b7c9284) about 1 s
Now, with SigLIP2 enforced (2643112) about 12 s (live run)

So the safety layer costs about 2x what we had just before it, and roughly the same as where we started. Wall time is similar (SigLIP2 over 4 thumbnail workers replaces DistilBERT's 2–3 s). Per image it is about 0.37 s of CPU (NudeNet 30 ms + SigLIP2 340 ms). Repeat images are free through the 30-day verdict cache, but we haven't measured the production hit rate, and that decides the real steady-state cost.

Why scoring should move offline. The cost is per image, not per search, and the same images recur across queries. The cheapest step is not a bulk crawl: keep scoring images as searches surface them (Daoud's incremental idea), but make the result permanent.

  1. Persist the verdicts in Postgres instead of 30-day Redis, keyed by file SHA-1 and thumbnail width as now.
  2. Store the raw scores (SigLIP2 nude probability, NudeNet exposed/covered maxima) plus the model fingerprint, and derive the verdict at read time. Retuning a threshold, such as the 0.38 vs 0.68 question, then needs no rescoring; only a model or prompt change does.
  3. Add a cache hit/miss counter first, so we know how much this saves before building more.

A real bulk index of Commons is a different decision. SigLIP2 alone is about 94 CPU-hours per million images, and scoring ~15M files means fetching ~15M thumbnails from Wikimedia (a rough estimate of hundreds of GB). That should be coordinated with them, and I'd only do it on a GPU.

Cheaper wins that don't need new infrastructure (not implemented):

  • Stop scoring once limit images are approved, instead of scoring all candidates: about 20% less CPU.
  • Skip SigLIP2 on images NudeNet already excluded: about 5%.
  • If load becomes the priority, CLIP-B/32 costs 92 ms per image (about 3.7x cheaper) but excludes about 20% of safe images instead of 7%.
  • Longer term, a small student model trained on SigLIP2's labels (15–30 ms per image).

Open decisions for review: what to do with an image that has no stored verdict yet (score inline, as now, or exclude until scored), and whether to move to Postgres now or after the hit-rate numbers.

@blueheron786
Copy link
Copy Markdown
Collaborator Author

Closing as discussed. Lots of good learning lessons. We're going to go with Jev instead, because it is quite performant and quite good at filtering.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0