""" ========================================== Screenshot Anonymizer — v2 (tool revision) ========================================== """ import base64 import functools import io import traceback from pathlib import Path import gradio as gr from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from gradio.data_classes import FileData from PIL import Image from app import ( CATEGORIES_META, map_spans_to_boxes, ocr_image, run_pii_analysis, ) EXAMPLES_DIR = Path(__file__).parent / "example-images" EXAMPLE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} def _list_examples() -> list[str]: if not EXAMPLES_DIR.is_dir(): return [] return sorted(p.name for p in EXAMPLES_DIR.iterdir() if p.is_file() and p.suffix.lower() in EXAMPLE_EXTS) @functools.lru_cache(maxsize=64) def _example_thumbnail(name: str) -> bytes: """Downscaled preview for the landing grid. Cached after first hit.""" path = EXAMPLES_DIR / name img = Image.open(path).convert("RGB") img.thumbnail((480, 480)) buf = io.BytesIO() img.save(buf, format="JPEG", quality=82, optimize=True) return buf.getvalue() # ===================================================================== # SERVER # ===================================================================== server = gr.Server() @server.get("/", response_class=HTMLResponse) async def homepage(): return FRONTEND_HTML # The /api/examples and /examples/{name} routes below serve static # example thumbnails and originals from disk. They are plain FastAPI # routes because they do no GPU / queued compute — they just read # files, which is exactly the pattern the gradio.Server blog # recommends plain @server.get for. @server.get("/api/examples") async def api_examples(): return JSONResponse({"examples": _list_examples()}) @server.get("/examples/{name}") async def get_example(name: str, thumb: int = 0): safe = Path(name).name if Path(safe).suffix.lower() not in EXAMPLE_EXTS: return JSONResponse({"error": "invalid file type"}, 400) path = EXAMPLES_DIR / safe if not path.is_file(): return JSONResponse({"error": "not found"}, 404) if thumb: return Response( content=_example_thumbnail(safe), media_type="image/jpeg", headers={"Cache-Control": "public, max-age=86400"}, ) return FileResponse(path, headers={"Cache-Control": "public, max-age=3600"}) # The compute endpoint: goes through Gradio's queue, plays nicely with # @spaces.GPU on ZeroGPU, and is callable by both the browser via the # @gradio/client JS client AND by Python users via gradio_client. @server.api(name="anonymize_screenshot") def anonymize_screenshot_api(image: FileData) -> dict: """OCR + PII-filter an uploaded image. Input: FileData from `handle_file(file)` (JS client) or `gradio_client.handle_file(path)` (Python client). Output: dict with the base-64 image, OCR text, detected spans, per-span pixel boxes, and the category color/label table. """ try: path = image.get("path") or image.get("url") or "" if not path: return {"error": "expected an image file"} img = Image.open(path).convert("RGB") ocr = ocr_image(img) if not ocr["text"].strip(): return {"error": "No text detected in the image."} source_text, spans = run_pii_analysis(ocr["text"]) if source_text != ocr["text"]: spans = [s for s in spans if s["end"] <= len(ocr["text"])] boxes = map_spans_to_boxes(ocr["words"], spans) buf = io.BytesIO(); img.save(buf, format="PNG") data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() return { "filename": Path(path).name, "image": data_url, "width": img.width, "height": img.height, "boxes": boxes, "text": ocr["text"], "spans": spans, "categories_meta": { k: {"color": v["color"], "label": v["label"]} for k, v in CATEGORIES_META.items() }, } except Exception as e: traceback.print_exc() return {"error": f"{type(e).__name__}: {e}"} # ===================================================================== # FRONTEND # ===================================================================== FRONTEND_HTML = r""" Screenshot Anonymizer
Screenshot Anonymizer v0.3 · beta
⌘V paste · ⌘O open

Redact screenshots before you post them.

OCR finds the text. The privacy filter marks names, emails, phones, addresses, dates, URLs, accounts, and secrets. You approve what gets hidden before it ever leaves the page.

try an example · click to load all content is fictitious · mock data for testing only
loading examples…
pii: openai/privacy-filter · 1.5b params, 50m active · apache 2.0
ocr: rednote-hilab/dots.ocr · 3b vlm, top-3 on olmocr-bench · edits stay in your browser
Screenshot Anonymizer
100%
zoom 100% · cursor · selection select

ocr → privacy filter → map to pixels

""" if __name__ == "__main__": server.launch(server_name="0.0.0.0", server_port=7860, show_error=True)