> ## Documentation Index
> Fetch the complete documentation index at: https://runcrate.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Image moderation with blur and an audit log

> Check uploads with a safety model, blur faces, plates, IDs and screens a vision model finds, and keep an append-only audit log.

User uploads go live faster when nobody has to look at each one, but you still need two things: no unsafe image published, and no stranger's face or plate published by accident. This pipeline asks a safety model first, then asks a vision model for boxes around anything that identifies a person, blurs them with ffmpeg, and writes one line per decision to an append-only log with both hashes, so you can show later what was published and why. It costs about a twentieth of a cent an image.

```mermaid theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
flowchart LR
  A[upload] --> B{Safety<br/>Nemotron Content Safety}
  B -- unsafe --> C[reject + signed webhook]
  B -- safe --> D[Find faces, plates, IDs, screens<br/>Qwen3-VL 30B, JSON boxes]
  D --> E[Blur boxes<br/>ffmpeg]
  E --> F[publish]
  C & F --> G[audit.jsonl]
```

## Build it in your project

Paste this line into your agent (Claude Code, Codex, Cursor, OpenCode, Cline). It sets up your key, then builds the pipeline in your project:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Add Runcrate's Qwen3 VL 30B A3B to this project: run curl -fsSL "https://runcrate.ai/agents.md?model=Qwen%2FQwen3-VL-30B-A3B-Instruct&intent=build" and follow it. Then build: a script that moderates a folder of uploads: Nemotron Content Safety 3.5 (Llama Guard 4 as fallback) for a verdict, Qwen3-VL 30B boxes for faces, license plates, ID documents and screens, ffmpeg blur, an append-only audit.jsonl and a signed webhook on every reject.
```

## The code

One file per language. Both read `RUNCRATE_API_KEY` from the environment; nothing secret is in the code.

```python image_moderation.py theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
"""Moderate uploads before they go live: a safety check, privacy redaction (faces, plates, IDs, screens), and an audit log.

pip install openai requests      (and ffmpeg on PATH: it does the blurring)
RUNCRATE_API_KEY=... python image_moderation.py ./uploads
Optional: WEBHOOK_URL and WEBHOOK_SECRET get a signed POST for every rejected image.
"""
import base64, datetime as dt, hashlib, hmac, json, os, re, subprocess, sys, time
from pathlib import Path

import requests
from openai import OpenAI, RateLimitError

client = OpenAI(base_url="https://api.runcrate.ai/v1", api_key=os.environ["RUNCRATE_API_KEY"], max_retries=4, timeout=120)
SAFETY = ["nvidia/Nemotron-Content-Safety-3.5", "meta-llama/Llama-Guard-4-12B"]  # second is the fallback when the first is busy
REDACT = "Qwen/Qwen3-VL-30B-A3B-Instruct"
OUT, AUDIT = Path("out"), Path("audit.jsonl")
OUT.mkdir(exist_ok=True)

PROMPT = ("Redact this image for privacy. Return a box for EACH of these you can see: every human face (including small or partial faces, "
          "and faces in photos or on screens), every vehicle license plate, every ID card or passport, every screen showing readable personal text. "
          "Count the people first. Boxes are [x1,y1,x2,y2] on a 0-1000 grid of the whole image.")
SCHEMA = {"type": "object", "additionalProperties": False, "required": ["people_visible", "regions"], "properties": {
    "people_visible": {"type": "integer"},
    "regions": {"type": "array", "items": {"type": "object", "additionalProperties": False, "required": ["kind", "box"], "properties": {
        "kind": {"type": "string", "enum": ["face", "license_plate", "id_document", "screen_text", "other_personal"]},
        "box": {"type": "array", "items": {"type": "integer"}}}}}}}

def data_url(b: bytes) -> str:
    mime = "image/png" if b[:4] == b"\x89PNG" else "image/webp" if b[8:12] == b"WEBP" else "image/jpeg"
    return f"data:{mime};base64,{base64.b64encode(b).decode()}"

def safety(img: bytes) -> tuple[str, str, str, float]:
    """Returns (verdict, categories, model, cost). Tries each safety model in turn."""
    for model in SAFETY:
        try:
            raw = client.chat.completions.with_raw_response.create(model=model, max_tokens=60, messages=[{"role": "user", "content": [
                {"type": "image_url", "image_url": {"url": data_url(img)}}, {"type": "text", "text": "Is this image safe to publish?"}]}])
        except RateLimitError:
            continue
        text = raw.parse().choices[0].message.content or ""
        verdict = "unsafe" if re.search(r"\bunsafe\b", text, re.I) else "safe"
        cats = (re.search(r"(?:Categories|S\d+).*", text) or [""])[0]
        return verdict, cats, model, float(raw.headers.get("x-llm-cost", 0))
    raise RuntimeError("every safety model is busy; retry the batch later")

def blur(src: Path, dst: Path, regions: list[dict]) -> None:
    w, h = map(int, subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", str(src)],
                                   capture_output=True, text=True).stdout.strip().split(","))
    graph, last = [f"[0]split={len(regions) + 1}[base]" + "".join(f"[c{i}]" for i in range(len(regions)))], "base"
    for i, r in enumerate(regions):
        x1, y1, x2, y2 = r["box"]
        pad = 0.1 * max(x2 - x1, y2 - y1)  # 10% margin around each box
        x, y = int(max(0, x1 - pad) * w / 1000), int(max(0, y1 - pad) * h / 1000)
        bw, bh = max(8, int(min(1000, x2 + pad) * w / 1000) - x), max(8, int(min(1000, y2 + pad) * h / 1000) - y)
        graph.append(f"[c{i}]crop={bw}:{bh}:{x}:{y},boxblur={max(4, min(bw, bh) // 6)}:2[b{i}]")
        graph.append(f"[{last}][b{i}]overlay={x}:{y}[o{i}]"); last = f"o{i}"
    subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(src), "-filter_complex", ";".join(graph), "-map", f"[{last}]", "-q:v", "3", str(dst)], check=True)

def reject_webhook(entry: dict) -> int:
    if not os.environ.get("WEBHOOK_URL"):
        return 0
    body = json.dumps(entry).encode()
    sig = hmac.new(os.environ.get("WEBHOOK_SECRET", "").encode(), body, hashlib.sha256).hexdigest()
    return requests.post(os.environ["WEBHOOK_URL"], data=body, timeout=10, headers={"Content-Type": "application/json", "X-Signature-SHA256": sig}).status_code

def main(folder: str) -> None:
    rows = []
    for src in sorted(p for p in Path(folder).iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}):
        t0, img = time.time(), src.read_bytes()
        verdict, cats, safety_model, cost = safety(img)
        entry = {"at": dt.datetime.now(dt.timezone.utc).isoformat(), "file": src.name, "sha256": hashlib.sha256(img).hexdigest(),
                 "safety": {"model": safety_model, "verdict": verdict, "categories": cats}}
        if verdict == "unsafe":
            entry["decision"] = "reject"
            entry["webhook"] = reject_webhook(entry)
        else:
            raw = client.chat.completions.with_raw_response.create(model=REDACT, temperature=0, messages=[{"role": "user", "content": [
                {"type": "image_url", "image_url": {"url": data_url(img)}}, {"type": "text", "text": PROMPT}]}],
                response_format={"type": "json_schema", "json_schema": {"name": "redact", "strict": True, "schema": SCHEMA}})
            cost += float(raw.headers.get("x-llm-cost", 0))
            found = json.loads(raw.parse().choices[0].message.content)
            regions = [r for r in found["regions"] if len(r["box"]) == 4 and r["box"][2] > r["box"][0] and r["box"][3] > r["box"][1]]
            dst = OUT / f"{src.stem}.jpg"
            if regions:
                blur(src, dst, regions)
            else:
                dst.write_bytes(img)
            entry |= {"decision": "publish_redacted" if regions else "publish", "redact_model": REDACT, "people_visible": found["people_visible"],
                      "regions": regions, "output": str(dst), "output_sha256": hashlib.sha256(dst.read_bytes()).hexdigest()}
        entry |= {"seconds": round(time.time() - t0, 2), "cost_usd": cost}
        with AUDIT.open("a") as f:  # append-only: one line per decision
            f.write(json.dumps(entry) + "\n")
        rows.append(entry)
        kinds = ", ".join(sorted({r["kind"] for r in entry.get("regions", [])})) or "-"
        print(f"{src.name}: {entry['decision']:<17} safety {verdict:<6} ({safety_model.split('/')[1]})  redacted: {kinds}  {entry['seconds']}s  ${cost:.6f}")
    Path("run.json").write_text(json.dumps({"images": rows, "total_usd": sum(r["cost_usd"] for r in rows)}, indent=2))
    print(f"total ${sum(r['cost_usd'] for r in rows):.6f}")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "uploads")
```

```typescript image-moderation.ts theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
// Moderate uploads before they go live: a safety check, privacy redaction (faces, plates, IDs, screens), and an audit log.
//
// npm install openai      (and ffmpeg on PATH: it does the blurring)
// RUNCRATE_API_KEY=... npx tsx image-moderation.ts ./uploads
// Optional: WEBHOOK_URL and WEBHOOK_SECRET get a signed POST for every rejected image.
import { execFileSync } from "node:child_process";
import { createHash, createHmac } from "node:crypto";
import { appendFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { extname, join, parse } from "node:path";
import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api.runcrate.ai/v1", apiKey: process.env.RUNCRATE_API_KEY, maxRetries: 4, timeout: 120_000 });
const SAFETY = ["nvidia/Nemotron-Content-Safety-3.5", "meta-llama/Llama-Guard-4-12B"]; // second is the fallback when the first is busy
const REDACT = "Qwen/Qwen3-VL-30B-A3B-Instruct";
mkdirSync("out", { recursive: true });

const PROMPT =
  "Redact this image for privacy. Return a box for EACH of these you can see: every human face (including small or partial faces, " +
  "and faces in photos or on screens), every vehicle license plate, every ID card or passport, every screen showing readable personal text. " +
  "Count the people first. Boxes are [x1,y1,x2,y2] on a 0-1000 grid of the whole image.";
const SCHEMA = {
  type: "object",
  additionalProperties: false,
  required: ["people_visible", "regions"],
  properties: {
    people_visible: { type: "integer" },
    regions: {
      type: "array",
      items: {
        type: "object",
        additionalProperties: false,
        required: ["kind", "box"],
        properties: {
          kind: { type: "string", enum: ["face", "license_plate", "id_document", "screen_text", "other_personal"] },
          box: { type: "array", items: { type: "integer" } },
        },
      },
    },
  },
};
type Region = { kind: string; box: number[] };

function dataUrl(b: Buffer): string {
  const mime = b.subarray(0, 4).toString("latin1") === "\x89PNG" ? "image/png" : b.subarray(8, 12).toString() === "WEBP" ? "image/webp" : "image/jpeg";
  return `data:${mime};base64,${b.toString("base64")}`;
}

/** Returns [verdict, categories, model, cost]. Tries each safety model in turn. */
async function safety(img: Buffer): Promise<[string, string, string, number]> {
  for (const model of SAFETY) {
    try {
      const { data, response } = await client.chat.completions
        .create({
          model,
          max_tokens: 60,
          messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl(img) } }, { type: "text", text: "Is this image safe to publish?" }] }],
        })
        .withResponse();
      const text = data.choices[0].message.content ?? "";
      return [/\bunsafe\b/i.test(text) ? "unsafe" : "safe", text.match(/(?:Categories|S\d+).*/)?.[0] ?? "", model, Number(response.headers.get("x-llm-cost") ?? 0)];
    } catch (e) {
      if (!(e instanceof OpenAI.RateLimitError)) throw e;
    }
  }
  throw new Error("every safety model is busy; retry the batch later");
}

function blur(src: string, dst: string, regions: Region[]) {
  const [w, h] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", src])
    .toString().trim().split(",").map(Number);
  const graph = [`[0]split=${regions.length + 1}[base]${regions.map((_, i) => `[c${i}]`).join("")}`];
  let last = "base";
  regions.forEach((r, i) => {
    const [x1, y1, x2, y2] = r.box;
    const pad = 0.1 * Math.max(x2 - x1, y2 - y1); // 10% margin around each box
    const x = Math.trunc((Math.max(0, x1 - pad) * w) / 1000);
    const y = Math.trunc((Math.max(0, y1 - pad) * h) / 1000);
    const bw = Math.max(8, Math.trunc((Math.min(1000, x2 + pad) * w) / 1000) - x);
    const bh = Math.max(8, Math.trunc((Math.min(1000, y2 + pad) * h) / 1000) - y);
    graph.push(`[c${i}]crop=${bw}:${bh}:${x}:${y},boxblur=${Math.max(4, Math.trunc(Math.min(bw, bh) / 6))}:2[b${i}]`);
    graph.push(`[${last}][b${i}]overlay=${x}:${y}[o${i}]`);
    last = `o${i}`;
  });
  execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-i", src, "-filter_complex", graph.join(";"), "-map", `[${last}]`, "-q:v", "3", dst]);
}

async function rejectWebhook(entry: object): Promise<number> {
  if (!process.env.WEBHOOK_URL) return 0;
  const body = JSON.stringify(entry);
  const sig = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "").update(body).digest("hex");
  return (await fetch(process.env.WEBHOOK_URL, { method: "POST", body, headers: { "Content-Type": "application/json", "X-Signature-SHA256": sig } })).status;
}

const folder = process.argv[2] ?? "uploads";
const rows: Record<string, unknown>[] = [];
let total = 0;
for (const name of readdirSync(folder).filter((f) => [".jpg", ".jpeg", ".png", ".webp"].includes(extname(f).toLowerCase())).sort()) {
  const t0 = Date.now();
  const src = join(folder, name);
  const img = readFileSync(src);
  const [verdict, categories, safetyModel, safetyCost] = await safety(img);
  let cost = safetyCost;
  const entry: Record<string, unknown> = {
    at: new Date().toISOString(),
    file: name,
    sha256: createHash("sha256").update(img).digest("hex"),
    safety: { model: safetyModel, verdict, categories },
  };
  let regions: Region[] = [];
  if (verdict === "unsafe") {
    entry.decision = "reject";
    entry.webhook = await rejectWebhook(entry);
  } else {
    const { data, response } = await client.chat.completions
      .create({
        model: REDACT,
        temperature: 0,
        messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl(img) } }, { type: "text", text: PROMPT }] }],
        response_format: { type: "json_schema", json_schema: { name: "redact", strict: true, schema: SCHEMA } },
      })
      .withResponse();
    cost += Number(response.headers.get("x-llm-cost") ?? 0);
    const found: { people_visible: number; regions: Region[] } = JSON.parse(data.choices[0].message.content ?? "{}");
    regions = found.regions.filter((r) => r.box.length === 4 && r.box[2] > r.box[0] && r.box[3] > r.box[1]);
    const dst = join("out", `${parse(name).name}.jpg`);
    if (regions.length) blur(src, dst, regions);
    else writeFileSync(dst, img);
    Object.assign(entry, {
      decision: regions.length ? "publish_redacted" : "publish",
      redact_model: REDACT,
      people_visible: found.people_visible,
      regions,
      output: dst,
      output_sha256: createHash("sha256").update(readFileSync(dst)).digest("hex"),
    });
  }
  Object.assign(entry, { seconds: (Date.now() - t0) / 1000, cost_usd: cost });
  appendFileSync("audit.jsonl", `${JSON.stringify(entry)}\n`); // append-only: one line per decision
  rows.push(entry);
  total += cost;
  const kinds = [...new Set(regions.map((r) => r.kind))].sort().join(", ") || "-";
  console.log(`${name}: ${String(entry.decision).padEnd(17)} safety ${verdict.padEnd(6)} (${safetyModel.split("/")[1]})  redacted: ${kinds}  ${entry.seconds}s  $${cost.toFixed(6)}`);
}
writeFileSync("run.json", JSON.stringify({ images: rows, total_usd: total }, null, 2));
console.log(`total $${total.toFixed(6)}`);
```

## What the proof run cost

The Python file on three generated test uploads: a man beside a parked car, a living room with a laptop and a passport, and a pair of shoes. Measured on 2026-10-23 against `api.runcrate.ai`; costs are what the account was billed, read from each response's `X-LLM-Cost` header or `usage.cost`.

| Step | Model or tool | Time | Cost |
| - | - | - | - |
| Street photo: safe, blurred face, plates, screen text | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 6.1 s | \$0.000431 |
| Living room: safe, blurred face, passport, screen | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 19.1 s | \$0.000393 |
| Shoes: safe, nothing to blur | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 1.8 s | \$0.000337 |
| Total, 3 images | | 28 s | \$0.001160 |

The TypeScript file on the same three: the same decisions and \$0.001160, in 34 s. No test image was unsafe, so the reject branch and its webhook did not fire in the proof.

## Scale it

* Run it where uploads land: call it from your upload handler, or have a [schedule](https://runcrate.ai/docs/schedules/overview.md) sweep a bucket.
* Cost per image is fixed: two small vision calls. A million images is about \$390 at the proof's rate.
* Keep the audit log on a [volume](https://runcrate.ai/docs/storage/overview.md) when it runs on a job, so it outlives the machine.

## Gotchas found in the proof run

* A short prompt ("find regions that identify a person") missed the main face in the street photo. Listing every kind, asking to count the people first, and naming small, partial and on-screen faces fixed it on both Qwen3-VL models.
* `meta-llama/Llama-Guard-4-12B` answered 429 "busy" on all three images on the proof day; Nemotron Content Safety 3.5 answered at once. The script tries one, then the other.
* Qwen3-VL gives boxes on a 0 to 1000 grid of the image, whatever its size; scale them to pixels before you crop. The script also pads each box by 10% so the blur covers the edge of a face.
* The 30B Qwen3-VL found the passport that the 235B one missed on the same prompt. Bigger was not better here: test on your own images.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.