""" ========================================== 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"""
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.
ocr → privacy filter → map to pixels