Hi Devpost! We are team blueberry jam and this is our submission to topic #5.
We built Quorum, a multi-model, consensus-based approach to robust hybrid AI image detection.
Briefly: Quorum uses several small specialised models to match and even beat a single large one, while holding on to robustness (performance after degradation), accuracy, and precision when deciding whether an image is AI-generated or real.
Try out the Demo (CPU only so might be slow - please run locally for GPU performance): https://huggingface.co/spaces/adrieljss/quorum-demo-cpu-only
Introduction and How Quorum Works (briefly)
AI-generated images are everywhere now, especially on social media, and the technology behind them keeps advancing. So a detector has to handle several very different kinds of fake at once (fully synthetic, tampered/inpainted, deepfakes), and it has to keep working after the image has been through a platform's compression, resizing and re-encoding.
Quorum was built on the fact that we wanted to try another approach to a long-standing and still-advancing problem: AI image detection and classification. This problem has been around for years and is well tried-and-tested, with no real breakthrough.
Sure, you can always add more datasets and make the model bigger (vertical scaling), but is there another approach?
So we wanted to tackle it from another point of view. What happens if you scale models horizontally, on the architecture side, instead of vertically by adding parameters?
Quorum uses a "voting system", where small specialised models, each trained for one specific type of AI image, can each cast a vote and control the final verdict. Firstly, we want to state the fact that detecting an AI image is actually very complex: it has many types, e.g. fully synthetic, tampered with/inpainted, deepfake. And so, each of them requires a different approach instead of one big model. The current version of Quorum has three voters:
| branch | input | params | the question it answers |
|---|---|---|---|
general |
768-d CLIP embedding | 769 | is this fully synthetic? |
tampered |
768-d CLIP embedding | 769 | does it contain an AI-edited region? |
face |
769-d = CLIP(aligned face crop) + standardised log_2(face px) | 772 | is any face in it synthetic? |
Every image is embedded once by a frozen CLIP ViT-L/14-quickgelu, which we never fine-tune. All three voters are linear probes on that same vector, so the table above is the entire trained model.
The Voting System
$$\mathrm{pred} = \max\Big(\ \sigma\big(\max(z_{\text{general}},\ 1.25\,z_{\text{tampered}}) - \text{SHIFT}\big),\ \ \max_i p_{\text{face},i}\ \Big)$$
maxis really just an OR, and that is the point. "AI touched this image" means fully synthetic or locally edited, which are opposite problems. Ourgeneralprobe scores below chance on edited photographs, and it is right to, because an edited photo is still globally authentic.- We built the learned combiner first. It lost to a plain
max, because it is forced into a single trade-off across two detectors that disagree by design. - The
SHIFTis not cosmetic. Our threshold used to be 0.5, the sigmoid's default, meaning nobody had actually chosen it — and it flagged far too many ordinary photographs as AI. We now shift the score so 0.5 lands on a threshold cross-validated over generator families, each fold scored on the family it never saw. The shift is monotone, so AUROC does not move, but precision jumps.
Datasets
Ten sources. The line between what we train on and what we score on is the thing we were most careful about — an "unseen generator" claim is only worth something if the generator is really unseen.
| dataset | role | n | |
|---|---|---|---|
| Train | SID_Set | real vs fully synthetic, plus face crops | 16,000 |
| SID_Set tampered | the tampered probe — AI-inpainted real photos |
3,949 | |
| WildFake (Midjourney) | generator diversity for general |
1,500 | |
So-Fake-OOD calib_ood carve |
calibration + threshold, 4-of-5-family rotation | 2,044 | |
| Eval | So-Fake-OOD test_ood |
the headline — 10 generator families absent from training | 4,198 |
| Organizer validation (COCO val2017 + WildFake DALL·E) | the brief's benchmark, quarantined | 8,719 | |
| SID_Set / So-Fake tampered eval | edited photos, same corpus and foreign corpus | 4,499 | |
| LAION-5B real holdout | false accusations on a corpus we never touched | 2,000 |
We read all of it exactly once into a cached set of embeddings, so the whole project fits on one laptop and a new experiment takes minutes instead of a day. Our manifest builder then asserts that no image and no generator family crosses the train/eval line, so a contaminated split fails the build. We split by family rather than by generator on purpose: putting e.g. Ideogram2 and Ideogram3 on opposite sides would let us call a sibling model "unseen".
How we maintain robustness
This is the part we like most: robustness needed no architectural change at all. Same frozen CLIP, same linear probes. It comes entirely from what the probe is shown while being trained.
We built a degradation grid out of the transformations the problem statement asks about — JPEG, blur, downscaling, noise, cropping and colour jitter — seeded on each image's content hash so it reproduces exactly. Every training image contributes its clean version plus a few random degradations, and every eval image is scored on all of them. The probe never gets to assume a clean input, so it never learns a feature that a platform's encoder would have destroyed anyway.
| general | face | spectral | tampered | |
|---|---|---|---|---|
| clean | 0.9844 | 0.9520 | 0.6341 | 0.9069 |
| blur05 | 0.9888 | 0.9541 | 0.6074 | 0.9110 |
| blur10 | 0.9907 | 0.9401 | 0.6303 | 0.8847 |
| blur20 | 0.9889 | 0.8887 | 0.5605 | 0.7893 |
| crop08 | 0.9723 | 0.9341 | 0.5376 | 0.8464 |
| jitter02 | 0.9828 | 0.9485 | 0.6230 | 0.8848 |
| jpeg30 | 0.9859 | 0.9283 | 0.6706 | 0.9473 |
| jpeg50 | 0.9896 | 0.9323 | 0.6863 | 0.9448 |
| jpeg70 | 0.9916 | 0.9432 | 0.7311 | 0.9278 |
| jpeg90 | 0.9852 | 0.9557 | 0.6073 | 0.9024 |
| noise002 | 0.9843 | 0.9366 | 0.6289 | 0.8023 |
| noise005 | 0.9821 | 0.9424 | 0.5948 | 0.7709 |
| noise01 | 0.9871 | 0.9254 | 0.5591 | 0.7286 |
| resize025 | 0.9890 | 0.9109 | 0.5199 | 0.9002 |
| resize05 | 0.9876 | 0.9372 | 0.6170 | 0.8740 |
| worst-case drop | 0.0121 | 0.0633 | 0.1143 | 0.1782 |
- JPEG does not hurt us at all — heavy compression scores slightly above clean. CLIP's own pretraining data is already full of recompressed web images, so this is simply what the backbone expects to see.
- Noise is the only transformation that really hurts, and it hurts
tamperedmost. No amount of probe tuning recovers information that has already been destroyed.
We also composed every pair of degradations against each other, in case stacking two of them compounds. It does not.
Results and Performance
Everything we trained comes to 2,310 parameters, 11 KB across three .npz files, plus a 227 KB off-the-shelf face detector. But we want to be upfront about the rest of it: underneath those sits frozen CLIP, 304M parameters and a 1.7 GB download. We did not train it and do not count it as ours, but it is the bulk of what actually runs. So this is not a "sub-1000-parameter detector", and we are not going to claim it is.
The claim that does survive is the one that matters in production. On an RTX 4060 Laptop the backbone runs at roughly 72 images per second and our heads add microseconds on top — so anywhere CLIP embeddings already exist, e.g. search, recommendation or moderation, adding detection costs microseconds and an 11 KB file. Not cheap. Free.
| eval set | n | AUROC | ACC | PREC | RECALL | F1 |
|---|---|---|---|---|---|---|
| So-Fake-OOD clean (headline — 10 unseen generator families) | 4,198 | 0.9265 | 0.8380 | 0.9021 | 0.7588 | 0.8243 |
| So-Fake-OOD, all 15 variants | 62,970 | 0.9206 | 0.8221 | 0.9076 | 0.7177 | 0.8016 |
| Organizer set, clean | 8,719 | 0.9722 | 0.9185 | 0.8872 | 0.9266 | 0.9065 |
| Organizer set, all 15 variants | 130,785 | 0.9594 | 0.8989 | 0.8675 | 0.9005 | 0.8837 |
| SID_Set tampered, clean | 3,595 | 0.9120 | 0.8473 | 0.8665 | 0.7492 | 0.8036 |
| Foreign tampered, clean | 5,096 | 0.7260 | 0.5608 | 0.8464 | 0.3103 | 0.4541 |
The organizer row is the friendlier number and the brief says it will not count toward scoring, so our honest headline is the So-Fake-OOD row. The bottom row is our tampered branch tested on somebody else's edits — we report it rather than only the good one.
Against two published detectors. We ran CNNDetection (CVPR'20) and FatFormer (CVPR'24) at their released weights, here, on exactly the same pixels we score, instead of copying numbers out of their papers.
| detector | clean | jpeg30 | blur20 | resize025 | noise005 | mean AUROC | drop | clean ACC |
|---|---|---|---|---|---|---|---|---|
| Quorum | 0.977 | 0.983 | 0.953 | 0.974 | 0.947 | 0.967 | 0.030 | 93.4% |
| FatFormer | 0.646 | 0.883 | 0.252 | 0.302 | 0.515 | 0.520 | 0.394 | 50.3% |
| CNNDetection (crop) | 0.586 | 0.527 | 0.505 | 0.506 | 0.521 | 0.529 | 0.081 | 49.9% |
| CNNDetection (resize) | 0.390 | 0.378 | 0.324 | 0.314 | 0.451 | 0.371 | 0.076 | 49.9% |
Accuracy is not comparable across detectors sitting at their own operating points, so read the AUROC columns. FatFormer under blur goes inverted — it calls blurred AI images real more often than it calls real photographs real — and its JPEG column is not even monotone. A detector whose ranking reverses under a transformation cannot be given a threshold at all.
And where we still fail, on generators none of the three were trained on:
| generator | quorum | fatformer | cnnd (crop) | cnnd (resize) |
|---|---|---|---|---|
| ADM (guided diffusion) | 0.483 | 0.749 | 0.618 | 0.763 |
| DF-GAN | 0.716 | 0.995 | 0.927 | 0.966 |
| GigaGAN | 0.770 | 0.711 | 0.679 | 0.725 |
| VQ-VAE FFHQ | 0.224 | 0.784 | 0.571 | 0.810 |
| GPT-Image-2 | 0.791 | 0.766 | 0.501 | 0.519 |
| Nano Banana | 0.800 | 0.749 | 0.511 | 0.501 |
| — averaged — | ||||
| all six, clean | 0.631 | 0.792 | 0.635 | 0.714 |
| all six, mean over the grid | 0.695 | 0.786 | 0.630 | 0.713 |
| modern only (GPT-Image-2, Nano Banana) | 0.795 | 0.757 | 0.506 | 0.510 |
| legacy only (ADM, DF-GAN, GigaGAN, VQ-VAE) | 0.548 | 0.810 | 0.699 | 0.816 |
The short version: we beat both detectors on the modern generators, even though our own training data comes from considerably older ones. We lose on the legacy GAN families, which are exactly the ones they were built for.
What we have tried and failed
The resulting Quorum model does not come from a single-shot hypothesis, but from rigorous experiments. There are some things that we have tried and we want to say, such as:
- Spectral branch (detects frequency-domain artifacts). Discontinued — modern generators barely leave that fingerprint any more, so it sat near chance and added false detections. It survives in the demo as a display signal, never in the score.
- Text branch (detects deformed text). Discontinued — modern generators render near-perfect text now. Our OCR features transferred below chance and mostly flipped sign between datasets. CLIP on the text crops did work, but was worth almost nothing.
- A nonlinear head (MLP). Did not beat the plain linear one, and doubled our false positives on real photographs. One linear boundary was never the bottleneck; data breadth is.
- Per-generator specialists, one probe each, combined with
max. Lost to a single pooled probe, and lost most on the exact generator it was meant to help. - Training on the newest generators, aimed straight at our worst failure. Worse overall, worse on false accusations, and worse the more we added. Giving it its own branch was twice as bad. The lesson:
maxcan only ever raise a score, so a branch with poor precision adds false positives that nothing can overrule. - Hard-negative mining. Cut false positives on the corpus we mined, barely moved one we had not. It learned that those watermarks are real, not that watermarks are real.
One pattern we did not expect: six branches have now lost their bid for a seat in max, and every one of them read the same CLIP embedding. The only one that ever won a seat, face, brought a genuinely different feature. That is now how we decide what is even worth trying.
How we leveraged agenting coding to build
Quorum was built in ~7 days, and within that time we needed to perform lots of research, training and testing. And so, we leveraged AI coding agents (e.g. Claude Code, Codex) to get the velocity we wanted to be production-ready, while keeping five people's work parallel and in sync. Here is how we do it:
- Inside the code repository you will find the
docs/folder, which contains our "AI context database / memory bank". Here we pass context around and update it as needed so that it matches the current state of Quorum after changes. - At every step that we take, we write tests —
python scripts/selfcheck.pyruns every check in the repo under one command and one exit code. This is what makes the AI results reliable and usable, because an agent cannot quietly break the scorer. - We also have TODOs, which keep the agentic coding tools in line with the goal.
Lastly, while we rely heavily on these coding tools, we also want to emphasise the fact that the ideas and hypotheses come from us. We use AI to quickly iterate through them, test if they work, and see what additional recommendations it may give. And so, with this system, we hope to maintain codebase quality and idea quality while not hampering our velocity.
Closing Note
Next up is patch-level self-consistency — scoring patches of an image against each other, instead of against a global idea of what real photography looks like. It is the proper fix for our biggest weakness, needs no new parameters, and gives us a heat map, which is the explainability we currently lack. After that: retraining tampered on two corpora so it learns editing rather than one dataset's editing, and validating C2PA signatures properly.
We also want more branches, e.g. for reconstructed and upscaled images, screenshots, documents, or video frames. Three is where we shipped, not a limit, and each new one costs a few hundred parameters.
But the finding we would most like to leave behind is the cheap one. You do not need a bigger model. Three linear probes and a few kilobytes, on a backbone a platform is very likely already running, beat two published detectors on the set that decides this and gave up almost nothing across the whole degradation grid. Horizontal scaling worked, and we would like to see how far it goes.
Thanks for reading!


Log in or sign up for Devpost to join the conversation.