Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten
- **Manifest V3.** `onnxruntime-web` in an offscreen document. WebGPU with WASM fallback (probe the adapter; do not latch a WebGPU error).
- **Auto-scan ordinary webpages.** Confidence on every badge: AI / OK / uncertain.
- **Hybrid is allowed:** neural + C2PA + EXIF/XMP + PNG/JPEG comments + weak URL hints. A URL hint alone must not cross 0.65.
- **Current fusion: CF-primary, three tiers.** CommunityForensics TTA takes the maximum raw sigmoid from inspected views. CF is authoritative at >= 0.65. Sub-floor tier: below CF 0.03, rescue only when CF >= 0.005 (not flatlined) AND DINO >= 0.999; a CF hard zero is never overridden. Strong tier: CF in [0.03, 0.30) needs DINO >= 0.96. Normal tier: CF in [0.30, 0.65) needs DINO >= 0.70. The flat-graphic gate blocks every rescue tier on catalog art and UI-like images. Bands were fit under a hard stress-set constraint (240 stock/catalog/product reals, zero new FPs allowed). **Do not restore raw max(CF, DINO), remove the graphic gate, or loosen these bands without rerunning the bench plus the stress set and live-site checks.**
- **Current fusion: CF-primary, three tiers.** CommunityForensics TTA takes the maximum raw sigmoid from inspected views. CF is authoritative at >= 0.65. Sub-floor tier: below CF 0.02, rescue only when CF >= 0.0005 (not flatlined) AND DINO >= 0.995; a CF hard zero is never overridden. Strong tier: CF in [0.02, 0.10) needs DINO >= 0.90. Normal tier: CF in [0.10, 0.65) needs DINO >= 0.70. The flat-graphic gate blocks every rescue tier on catalog art and UI-like images. Bands were re-derived for the hard-negative probe under a hard stress-set constraint (240 full-resolution stock/catalog/product reals; no DINO-attributable stress FP allowed), choosing a conservative near-optimum over the grid maximum. **Do not restore raw max(CF, DINO), remove the graphic gate, or loosen these bands without rerunning the bench plus the stress set and live-site checks.**
- **Overlay:** badge store must be an iterable `Map`, not a `WeakMap`. Reposition on scroll / resize / `visualViewport` / mutations. Never wrap images.
- **Load-unpacked users do not auto-update.** GitHub Releases zip + popup banner is the update path. Do not assume CWS.
- **Cross-device:** macOS (owner), Windows, Linux. WASM must work when WebGPU has no adapter.
Expand Down Expand Up @@ -65,7 +65,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten

| File | Role |
|---|---|
| `src/fuse.js` | `DEFAULT_THRESHOLD` 0.65, `DINO_CF_FLOOR` 0.03, `DINO_STRONG_RESCUE_FLOOR` 0.30, `DINO_STRONG_RESCUE_MIN` 0.96, `DINO_RESCUE_MIN` 0.70, `DINO_SUBFLOOR_CF_MIN` 0.005, `DINO_SUBFLOOR_MIN` 0.999, CF-primary `fuseNeuralScores` |
| `src/fuse.js` | `DEFAULT_THRESHOLD` 0.65, `DINO_CF_FLOOR` 0.02, `DINO_STRONG_RESCUE_FLOOR` 0.10, `DINO_STRONG_RESCUE_MIN` 0.90, `DINO_RESCUE_MIN` 0.70, `DINO_SUBFLOOR_CF_MIN` 0.0005, `DINO_SUBFLOOR_MIN` 0.995, CF-primary `fuseNeuralScores` |
| `src/graphic-gate.js` | Shared flat-graphic policy for browser and Node evaluation |
| `src/pixel-resize.js` | Pillow-exact bicubic resize shared by the extension and the Node harness for the CF path (byte-exact vs Pillow 12.3.0 goldens) |
| `src/offscreen.js` | ORT WebGPU/WASM, DINO 224 then CF TTA |
Expand All @@ -76,7 +76,7 @@ Win POIDH Arbitrum bounty 323 by shipping a privacy-first local MV3 Chrome exten
| `src/c2pa-reader.js` | C2PA reader |

- Issue 23 (fixed in PR 24 / v1.0.7): overlay WeakMap drift + DINO max false positives.
- The public 893-image fixture on the Pillow-exact preprocess and three-tier policy: 87.7% BA, 75.8% TPR, 99.6% TNR (harness-verified, not simulated). Stress set (240 stock/catalog/product reals): 2 FPs, identical images to the prior policy. Browser vs Node parity: 16/16 decisions, max per-view CF delta 0.0005. The DINO path still uses canvas/RawImage resize (probe trained against it); retraining the probe on Pillow-preprocessed features is the documented follow-up. Live-smoke checks still remain required before any claim.
- The public 893-image fixture with the hard-negative probe and re-derived bands: 90.5% BA, 81.2% TPR, 99.8% TNR (harness-verified, not simulated). Stress set (240 full-resolution stock/catalog/product reals): 4 FPs, all CF-driven at >= 0.65 where CF is authoritative by design; zero DINO-attributable stress FPs (shipped v1.1.0 measured 7 on identical bytes). The DINO probe is trained on features from the shared Pillow-exact path plus 1,915 hard-negative reals (stock, catalog, product, interiors; rows disjoint from the stress set). Full-resolution professional stock photos can spike CF itself; check exactly that during live smoke. Live-smoke checks still remain required before any claim.

---

Expand Down
15 changes: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ Maintainers cut a release with `git tag v1.0.0 && git push origin v1.0.0`. That
Two independent neural heads cover complementary failure modes, plus deterministic metadata:

1. **CommunityForensics head:** ViT-Small official FP32 ONNX (CLIP 384). `p(AI) = sigmoid(logit)`. Near-zero false positives on real photos, but under-scores several modern generators (Flux, GPT-4o-image, photoreal DALL-E 3).
2. **DINOv2 probe head:** frozen DINOv2-small backbone (224 center view) with a transparent logistic head over CLS+mean-pooled features (`models/probe/dino-probe.json`: plain standardize/weights/bias, no lookup tables). Trained on ~9.6k images from public datasets across Flux, SD3.5, SDXL-era, Midjourney, DALL-E 3, GPT-4o-image and diverse real photos, with web-realistic JPEG/resize augmentation. This head carries the modern generators.
3. **Neural fusion:** CF-primary with three rescue tiers. When CommunityForensics is confident AI (`>= 0.65`), its score wins. Between `0.03` and `0.30`, DINO can only rescue if it is near-saturated (`p(AI) >= 0.96`); between `0.30` and `0.65`, DINO can lift at `p(AI) >= 0.70`. Below the `0.03` floor a rescue additionally requires CF to be at least faintly awake (`>= 0.005`) and DINO to be saturated (`>= 0.999`): CF emits hard zeros on real photos it is certain about, while AI images in its blind spots still elicit a faint response, so a flatlined CF is itself evidence of a real photo and is never overridden. On flat graphics and catalog art (low palette / high flat-run pixels), a graphic gate suppresses every DINO rescue tier when CF stays below `0.65`, so icons and UI shots do not mass-label AI 100%. CF-confident AI illustrations (`>= 0.65`) are unchanged. Displayed confidence is this raw fused probability; the AI verdict stays at raw `>= 0.65` with no remapping and no logit bias. The rescue bands were fit on the public bench below under a hard constraint measured on a separate 240-image stock, catalog, and product photo stress set: zero new false positives allowed relative to the previous policy.
2. **DINOv2 probe head:** frozen DINOv2-small backbone (224 center view) with a transparent logistic head over CLS+mean-pooled features (`models/probe/dino-probe.json`: plain standardize/weights/bias, no lookup tables). Trained on ~11.4k images from public datasets across Flux, SD3.5, SDXL-era, Midjourney, DALL-E 3, GPT-4o-image and diverse real photos, including ~1.9k hard-negative reals (stock photography, product catalogs, interiors, high-saturation nature) that teach the head not to fire on professional real photos, with web-realistic JPEG/resize augmentation. Features are extracted through the same Pillow-exact resize the extension ships, so training matches serving exactly. This head carries the modern generators.
3. **Neural fusion:** CF-primary with three rescue tiers. When CommunityForensics is confident AI (`>= 0.65`), its score wins. Between `0.02` and `0.10`, DINO can only rescue if it is highly confident (`p(AI) >= 0.90`); between `0.10` and `0.65`, DINO can lift at `p(AI) >= 0.70`. Below the `0.02` floor a rescue additionally requires CF to be at least faintly awake (`>= 0.0005`) and DINO to be saturated (`>= 0.995`): CF emits hard zeros on real photos it is certain about, while AI images in its blind spots still elicit a faint response, so a flatlined CF is itself evidence of a real photo and is never overridden. On flat graphics and catalog art (low palette / high flat-run pixels), a graphic gate suppresses every DINO rescue tier when CF stays below `0.65`, so icons and UI shots do not mass-label AI 100%. CF-confident AI illustrations (`>= 0.65`) are unchanged. Displayed confidence is this raw fused probability; the AI verdict stays at raw `>= 0.65` with no remapping and no logit bias. The rescue bands were re-derived for the current probe under a hard constraint measured on a separate 240-image full-resolution stock, catalog, and product photo stress set (no DINO-attributable false positives allowed), and a deliberately conservative near-optimum was chosen over the grid maximum.
4. **Adaptive TTA:** the DINO pass and the official 440 center crop always run. Extra CommunityForensics views (440 corners + 512 center) run only when a head is at least mildly suspicious (CF center or DINO in `[0.15, 0.65)`), so confident reals cost two passes total. `Math.max` of sigmoids, early exit at `>= 0.9`. Under heavy queue load (more than 12 pending) CF drops to center-only; the DINO pass still runs.
5. **Metadata:** C2PA, EXIF/XMP/IPTC, generator text in PNG/JPEG, weak URL hints. Strong metadata forces 0.95-0.99; a URL hint alone cannot cross 65%.

Expand All @@ -77,13 +77,12 @@ Eval harness: `npm run eval -- ./path/to/labeled-folder` after `npm run fetch-mo
|---|---:|---:|---:|
| CommunityForensics center crop only | 66.4% | 32.8% | 100% |
| CommunityForensics adaptive max diagnostic | 73.4% | 47.2% | 99.6% |
| DINOv2 probe only | 93.0% | 89.7% | 96.3% |
| Legacy raw max ensemble (not shipped) | 96.1% | 96.1% | 96.1% |
| Prior policy, prior Node resize (historical) | 85.0% | 70.4% | 99.6% |
| Prior policy on the Pillow-exact preprocess | 82.9% | 66.3% | 99.6% |
| Production three-tier policy, Pillow-exact preprocess | 87.7% | 75.8% | 99.6% |
| v1.1.0 policy and probe, Pillow-exact preprocess | 87.7% | 75.8% | 99.6% |
| Production: hard-negative probe, re-derived bands | 90.5% | 81.2% | 99.8% |

The legacy raw max result is included to make the tradeoff visible, not as a product claim. It caused unacceptable false positives on live stock and catalog images, so production keeps the CF guard. The historical 85.0% row was measured through a Node resize the extension never ran; the Pillow-exact rows are computed by the same resize the extension ships, byte for byte, and browser versus Node decisions agree 16/16 on a stratified parity sample. The production policy also holds 2 false positives in 240 on a stock, catalog, and product photo stress set, identical images to the prior policy. Public fixtures are directional only and are not a claim about Kenny's private held-out set.
The legacy raw max result is included to make the tradeoff visible, not as a product claim. It caused unacceptable false positives on live stock and catalog images, so production keeps the CF guard. The historical 85.0% row was measured through a Node resize the extension never ran; later rows are computed by the same Pillow-exact resize the extension ships, byte for byte. On a 240-image full-resolution stock, catalog, and product photo stress set the production policy shows 4 false positives, all driven by CommunityForensics alone scoring `>= 0.65` (where it is authoritative by design), and zero attributable to a DINO rescue; the v1.1.0 configuration measured 7 on identical bytes. Public fixtures are directional only and are not a claim about Kenny's private held-out set.

## Limitations

Expand All @@ -105,9 +104,9 @@ See [PRIVACY.md](PRIVACY.md) and [docs/privacy.html](docs/privacy.html). Images
## Reproducing the probe head

```bash
node eval/fetch-train.mjs /tmp/train # ~9.6k images from public HF datasets
node eval/fetch-train.mjs /tmp/train # ~11.4k images from public HF datasets (incl. hard-negative reals)
node eval/extract-features.mjs /tmp/train models/Xenova/dinov2-small/onnx/model.onnx /tmp/feat-train --augment
node eval/train-probe.mjs /tmp/feat-train models/probe/dino-probe.json
```

The head is a linear probe (768 weights + bias + feature mean/std) over frozen DINOv2 features; the JSON is human-auditable. No benchmark images, hashes, or lookup tables are involved.
The head is a linear probe (768 weights + bias + feature mean/std) over frozen DINOv2 features; the JSON is human-auditable. No benchmark images, hashes, or lookup tables are involved. Fetching pulls live public datasets, so counts can drift by a few images between runs; an authenticated Hugging Face token (`HF_TOKEN` or the CLI cache) raises the datasets-server rate limit and is picked up automatically.
34 changes: 6 additions & 28 deletions eval/extract-features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,34 +107,12 @@ async function degradeBytes(buffer, rand) {
return { buffer: outBuf, aug: parts.join('+') };
}

function toCHWDino(data, channels, size) {
const plane = size * size;
const out = new Float32Array(3 * plane);
for (let i = 0; i < plane; i++) {
const base = i * channels;
out[i] = (data[base] / 255 - DINO_MEAN[0]) / DINO_STD[0];
out[i + plane] = (data[base + 1] / 255 - DINO_MEAN[1]) / DINO_STD[1];
out[i + 2 * plane] = (data[base + 2] / 255 - DINO_MEAN[2]) / DINO_STD[2];
}
return out;
}

export async function dinoPreprocess(rawImage) {
const { width, height } = rawImage;
let rw, rh;
if (width < height) {
rw = DINO_SHORTEST;
rh = Math.round((height * DINO_SHORTEST) / width);
} else {
rh = DINO_SHORTEST;
rw = Math.round((width * DINO_SHORTEST) / height);
}
const resized = await rawImage.resize(rw, rh);
const sx = Math.floor((rw - DINO_CROP) / 2);
const sy = Math.floor((rh - DINO_CROP) / 2);
const cropped = await resized.crop([sx, sy, sx + DINO_CROP - 1, sy + DINO_CROP - 1]);
return toCHWDino(cropped.data, cropped.channels, DINO_CROP);
}
// Preprocess comes from the shipped module so probe training, the Node
// harness, and the extension all compute identical DINO inputs (Pillow-
// exact resize, guarded grayscale handling). A local duplicate here once
// drifted from production; do not reintroduce one.
import { dinoPreprocessRawImage as dinoPreprocess } from '../src/dino.js';
export { dinoPreprocess };

/** CLS + mean(patch tokens) from last_hidden_state [1, T, H]. */
export function poolFeatures(hidden, tokens, hiddenSize) {
Expand Down
Loading