ysharma's picture
ysharma HF Staff
Update app_v2.py
5021071 verified
"""
==========================================
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Screenshot Anonymizer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Lora:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
color-scheme:dark light;
--bg:#0d0d0f;
--surface:#131316;
--surface2:#1a1a1e;
--surface3:#222227;
--border:rgba(255,255,255,.06);
--border-strong:rgba(255,255,255,.14);
--text:#e9e9ea;
--text2:#9a9a9f;
--text3:#65656c;
--accent:#818cf8;
--accent-dim:rgba(129,140,248,.12);
--danger:#f87171;
--mono:ui-monospace,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace;
--serif:'Lora',Georgia,'Times New Roman',serif;
--sans:'Inter',system-ui,-apple-system,sans-serif;
}
@media (prefers-color-scheme: light){
:root{
--bg:#fafaf8;
--surface:#ffffff;
--surface2:#f4f4f2;
--surface3:#eeeeec;
--border:rgba(0,0,0,.07);
--border-strong:rgba(0,0,0,.16);
--text:#1c1c1e;
--text2:#515156;
--text3:#86868b;
--accent:#4f46e5;
--accent-dim:rgba(79,70,229,.08);
}
}
html,body{height:100%}
body{
font-family:var(--sans);font-size:13.5px;line-height:1.5;
color:var(--text);background:var(--bg);
font-feature-settings:'ss01','cv11';
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
overflow:hidden;
}
button{font:inherit;color:inherit;background:none;border:none;padding:0;cursor:pointer}
svg{display:block;flex-shrink:0}
/* ── shared chrome ─────────────────────────────────────────────── */
.app-bar{
display:flex;align-items:center;gap:.9rem;
padding:.55rem 1rem;
border-bottom:.5px solid var(--border);
background:var(--bg);
flex-shrink:0;
height:40px;
}
.brand{display:flex;align-items:center;gap:.55rem}
.brand-icon{color:var(--text);width:18px;height:18px}
.wordmark{
font-family:var(--sans);font-weight:500;font-size:13.5px;
color:var(--text);letter-spacing:-.005em;
}
.version{
font-family:var(--mono);font-size:11px;color:var(--text3);
padding:1px 6px;border:.5px solid var(--border);border-radius:3px;
margin-left:.25rem;
}
.app-bar .spacer{flex:1}
.kbd-hints{font-family:var(--mono);font-size:11px;color:var(--text3)}
.kbd-hints kbd{
font-family:var(--mono);color:var(--text2);
padding:1px 4px;border:.5px solid var(--border);border-radius:3px;
background:var(--surface);font-size:10.5px;
}
.btn-link{
font-family:var(--sans);font-size:12.5px;font-weight:500;
color:var(--text2);padding:.3rem .55rem;
border:.5px solid var(--border);border-radius:5px;
transition:color .12s, border-color .12s, background .12s;
}
.btn-link:hover{color:var(--text);border-color:var(--border-strong);background:var(--surface2)}
/* ── landing view ──────────────────────────────────────────────── */
#landing{display:flex;flex-direction:column;height:100vh;overflow-y:auto}
.landing-content{
max-width:720px;width:100%;
margin:0 auto;padding:3.5rem 1.5rem 2rem;
display:flex;flex-direction:column;gap:1.5rem;
}
.headline{
font-family:var(--serif);font-weight:400;
font-size:30px;line-height:1.15;letter-spacing:-.01em;
color:var(--text);
}
.subtitle{
font-family:var(--sans);font-size:14px;color:var(--text2);
max-width:52ch;
}
.dropzone{
position:relative;
border:.5px dashed var(--border-strong);
border-radius:8px;
background:var(--surface);
aspect-ratio:3/1;min-height:140px;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;
cursor:pointer;transition:border-color .15s, background .15s;
}
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--accent-dim)}
.dropzone input{position:absolute;inset:0;opacity:0;cursor:pointer}
.dz-icon{color:var(--text2);margin-bottom:.1rem}
.dz-text{font-size:13.5px;color:var(--text);font-weight:500}
.dz-hint{font-family:var(--mono);font-size:11px;color:var(--text3)}
/* example strip */
.example-wrap{
border:.5px solid var(--border);border-radius:8px;
background:var(--surface);overflow:hidden;
}
.example-head{
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
padding:.5rem .8rem;border-bottom:.5px solid var(--border);
font-family:var(--mono);font-size:10.5px;color:var(--text3);
}
.example-head .title{color:var(--text2)}
.example-head .note{color:var(--text3)}
.example-scroll{
display:flex;gap:8px;padding:10px;overflow-x:auto;
scrollbar-width:thin;scrollbar-color:var(--border-strong) transparent;
}
.example-scroll::-webkit-scrollbar{height:6px}
.example-scroll::-webkit-scrollbar-track{background:transparent}
.example-scroll::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:3px}
.ex-thumb{
flex-shrink:0;width:108px;height:108px;
border:.5px solid var(--border);border-radius:5px;overflow:hidden;
background:var(--surface2);cursor:pointer;position:relative;
transition:border-color .12s, transform .12s;
}
.ex-thumb:hover{border-color:var(--accent);transform:translateY(-1px)}
.ex-thumb img{width:100%;height:100%;object-fit:cover;display:block}
.ex-thumb .ex-idx{
position:absolute;top:4px;left:5px;
font-family:var(--mono);font-size:9.5px;color:#fff;
background:rgba(0,0,0,.55);padding:1px 4px;border-radius:2px;
letter-spacing:.02em;
}
.ex-empty{
padding:1rem;font-family:var(--mono);font-size:11px;color:var(--text3);
}
.landing-foot{
max-width:720px;margin:.5rem auto 2rem;padding:0 1.5rem;
font-family:var(--mono);font-size:10.5px;color:var(--text3);line-height:1.7;
}
.landing-foot a{color:var(--text2);text-decoration:none;border-bottom:.5px dotted var(--text3)}
.landing-foot a:hover{color:var(--text)}
/* ── editor view ───────────────────────────────────────────────── */
#editor{display:none;flex-direction:column;height:100vh}
.editor-body{flex:1;display:flex;min-height:0}
.canvas-area{
flex:1;position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;
background:var(--bg);
}
.canvas-scroll{
flex:1;overflow:auto;position:relative;
background:
linear-gradient(45deg,var(--surface2) 25%,transparent 25%),
linear-gradient(-45deg,var(--surface2) 25%,transparent 25%),
linear-gradient(45deg,transparent 75%,var(--surface2) 75%),
linear-gradient(-45deg,transparent 75%,var(--surface2) 75%);
background-color:var(--bg);
background-size:16px 16px;
background-position:0 0,0 8px,8px -8px,8px 0;
}
.canvas-inner{
position:relative;padding:28px 24px 24px 36px;
display:flex;align-items:flex-start;justify-content:flex-start;
min-width:100%;min-height:100%;
}
.canvas-wrap{position:relative;cursor:crosshair;flex-shrink:0}
.canvas-wrap.mode-select{cursor:default}
.canvas-wrap.mode-select.over-box{cursor:move}
.canvas-wrap.mode-select.dragging{cursor:grabbing}
.canvas-wrap canvas{display:block}
/* rulers */
.ruler-top,.ruler-left{position:absolute;background:var(--surface);pointer-events:none}
.ruler-top{top:4px;left:36px;height:20px}
.ruler-left{left:4px;top:28px;width:24px}
.ruler-corner{
position:absolute;top:4px;left:4px;
width:24px;height:20px;background:var(--surface);
border-right:.5px solid var(--border);border-bottom:.5px solid var(--border);
}
/* status bar */
.status-bar{
display:flex;align-items:center;gap:.75rem;
padding:.35rem .75rem;flex-shrink:0;
border-top:.5px solid var(--border);
font-family:var(--mono);font-size:11px;color:var(--text3);
background:var(--surface);
height:26px;
}
.status-bar .sep{color:var(--text3);opacity:.4}
.status-bar .k{color:var(--text3)}
.status-bar .v{color:var(--text2)}
.status-bar .spacer{flex:1}
/* zoom toolbar */
.zoom-tools{
position:absolute;bottom:38px;right:14px;z-index:5;
display:flex;align-items:center;gap:0;
background:var(--surface);border:.5px solid var(--border);border-radius:6px;
padding:2px;
}
.zoom-tools button, .zoom-tools .zoom-pct{
font-family:var(--mono);font-size:11px;color:var(--text2);
padding:4px 8px;border-radius:4px;min-width:22px;text-align:center;
}
.zoom-tools button:hover{background:var(--surface2);color:var(--text)}
.zoom-tools .zoom-pct{color:var(--text)}
.zoom-tools .sep{width:.5px;background:var(--border);align-self:stretch;margin:2px 0}
/* ── right panel ───────────────────────────────────────────────── */
.panel{
width:272px;flex-shrink:0;
background:var(--surface);border-left:.5px solid var(--border);
overflow-y:auto;display:flex;flex-direction:column;
}
.p-section{padding:.85rem 1rem;border-bottom:.5px solid var(--border)}
.p-section:last-child{border-bottom:none}
.p-label{
font-family:var(--sans);font-size:10.5px;font-weight:500;
color:var(--text3);text-transform:uppercase;letter-spacing:.08em;
margin-bottom:.55rem;
}
/* tool row */
.tools{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin-bottom:.7rem}
.tool{
display:flex;align-items:center;gap:.55rem;
padding:10px 11px;
border:.5px solid var(--border);border-radius:6px;
color:var(--text2);
transition:color .12s, background .12s, border-color .12s;
}
.tool:hover{color:var(--text);border-color:var(--border-strong)}
.tool.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.tool svg{width:18px;height:18px}
.tool .name{
flex:1;text-align:left;
font-family:var(--sans);font-size:13px;font-weight:500;letter-spacing:-.005em;
}
.tool .sc{
font-family:var(--mono);font-size:10.5px;color:var(--text3);
padding:1px 5px;border:.5px solid var(--border);border-radius:3px;
background:var(--bg);
}
.tool.active .sc{color:var(--accent);border-color:var(--accent);background:transparent}
/* contextual tool options */
.tool-opts{
font-size:12px;color:var(--text2);line-height:1.5;
padding:.5rem .65rem;background:var(--surface2);border-radius:5px;
border:.5px solid var(--border);
}
.tool-opts .hint{display:flex;align-items:flex-start;gap:.4rem;font-family:var(--sans)}
.tool-opts .hint svg{color:var(--text3);margin-top:1px}
.tool-opts .kb{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.45rem}
.tool-opts kbd{
font-family:var(--mono);font-size:10px;color:var(--text2);
padding:1px 4px;border:.5px solid var(--border);border-radius:3px;
background:var(--bg);
}
.tool-opts .sep{color:var(--text3);margin:0 .25rem}
/* detected summary */
.summary{
font-family:var(--mono);font-size:11.5px;color:var(--text2);
margin-bottom:.5rem;
}
.summary .num{color:var(--text)}
.dist-bar{
height:4px;background:var(--surface2);border-radius:2px;overflow:hidden;
display:flex;
}
.dist-seg{height:100%;transition:width .4s ease}
/* category pills */
.pills{display:flex;flex-direction:column;gap:2px}
.pill{
display:flex;align-items:center;gap:.55rem;
padding:.4rem .5rem;
border:.5px solid transparent;border-radius:5px;
cursor:pointer;user-select:none;
transition:background .1s, border-color .1s;
}
.pill:hover{background:var(--surface2)}
.pill.active{background:var(--surface2);border-color:var(--border)}
.pill.inactive{opacity:.42}
.pill .swatch{
width:3px;height:14px;border-radius:1.5px;flex-shrink:0;
}
.pill .name{flex:1;font-size:12.5px;color:var(--text);font-weight:400}
.pill.inactive .name{color:var(--text2)}
.pill .count{font-family:var(--mono);font-size:11px;color:var(--text3)}
.pills-empty{
font-size:12px;color:var(--text3);font-style:italic;padding:.25rem 0;
}
/* export row */
.export-row{display:flex;flex-direction:column;gap:4px}
.export-btn{
display:flex;align-items:center;justify-content:space-between;
padding:.5rem .65rem;
border:.5px solid var(--border);border-radius:5px;
color:var(--text);font-size:12.5px;font-weight:500;
transition:background .1s, border-color .1s;
}
.export-btn:hover{background:var(--surface2);border-color:var(--border-strong)}
.export-btn.primary{border-color:var(--border-strong)}
.export-btn.primary:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.export-btn .sc{font-family:var(--mono);font-size:10.5px;color:var(--text3)}
.export-btn.primary:hover .sc{color:var(--accent)}
.export-text-link{
font-size:11.5px;color:var(--text3);font-family:var(--sans);
padding:.3rem .1rem 0;text-align:left;
transition:color .1s;
}
.export-text-link:hover{color:var(--text2)}
/* ── loading + toast ───────────────────────────────────────────── */
#loading{
position:fixed;inset:0;background:rgba(13,13,15,.85);backdrop-filter:blur(6px);
display:none;flex-direction:column;align-items:center;justify-content:center;z-index:9999;
gap:.9rem;
}
@media(prefers-color-scheme:light){ #loading{background:rgba(250,250,248,.85)} }
.spinner{
width:18px;height:18px;border:1.5px solid var(--border);border-top-color:var(--accent);
border-radius:50%;animation:spin .75s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
#loading p{font-family:var(--mono);font-size:11px;color:var(--text2);letter-spacing:.02em}
.error-banner{
margin:.75rem 1rem 0;padding:.55rem .8rem;
border:.5px solid rgba(248,113,113,.3);background:rgba(248,113,113,.08);
color:var(--danger);font-size:12px;border-radius:5px;display:none;font-family:var(--mono);
}
.toast{
position:fixed;bottom:1rem;left:50%;transform:translateX(-50%) translateY(60px);
background:var(--surface);border:.5px solid var(--border-strong);color:var(--text);
padding:.45rem .8rem;border-radius:5px;font-size:12px;font-family:var(--mono);
transition:transform .2s ease;z-index:9998;
}
.toast.show{transform:translateX(-50%) translateY(0)}
@media (max-width: 820px){
.editor-body{flex-direction:column}
.panel{width:100%;max-height:44vh;border-left:none;border-top:.5px solid var(--border)}
}
</style>
</head>
<body>
<!-- ═══════════════════════ LANDING ═══════════════════════ -->
<div id="landing">
<header class="app-bar">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true">
<rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/>
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
</svg>
<span class="wordmark">Screenshot Anonymizer</span>
<span class="version">v0.3 Β· beta</span>
</div>
<div class="spacer"></div>
<div class="kbd-hints"><kbd>⌘V</kbd> paste <span style="margin:0 .35rem;opacity:.4">·</span> <kbd>⌘O</kbd> open</div>
</header>
<div class="landing-content">
<h1 class="headline">Redact screenshots before you post them.</h1>
<p class="subtitle">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.</p>
<label class="dropzone" id="dropzone">
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp,image/bmp,image/tiff">
<svg class="dz-icon" width="42" height="30" viewBox="0 0 42 30" aria-hidden="true">
<rect x="2" y="3" width="26" height="5" rx="1" fill="currentColor"/>
<rect x="2" y="12" width="38" height="5" rx="1" fill="currentColor"/>
<rect x="2" y="21" width="18" height="5" rx="1" fill="currentColor"/>
</svg>
<div class="dz-text">Drop a screenshot, paste from clipboard, or click to browse</div>
<div class="dz-hint">png Β· jpg Β· webp Β· bmp Β· tiff</div>
</label>
<div class="example-wrap">
<div class="example-head">
<span class="title">try an example Β· click to load</span>
<span class="note">all content is fictitious Β· mock data for testing only</span>
</div>
<div class="example-scroll" id="example-scroll">
<div class="ex-empty">loading examples…</div>
</div>
</div>
</div>
<div class="landing-foot">
pii: openai/privacy-filter Β· 1.5b params, 50m active Β· apache 2.0<br>
ocr: rednote-hilab/dots.ocr Β· 3b vlm, top-3 on olmocr-bench Β· edits stay in your browser
</div>
</div>
<!-- ═══════════════════════ EDITOR ═══════════════════════ -->
<div id="editor">
<header class="app-bar">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 20 20" aria-hidden="true">
<rect x="2" y="4" width="12" height="3" rx="0.5" fill="currentColor"/>
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
</svg>
<span class="wordmark">Screenshot Anonymizer</span>
</div>
<span class="version" id="meta-info">β€”</span>
<div class="spacer"></div>
<button class="btn-link" onclick="resetView()">new screenshot</button>
</header>
<div class="error-banner" id="error-banner"></div>
<div class="editor-body">
<div class="canvas-area">
<div class="canvas-scroll" id="canvas-scroll">
<div class="canvas-inner" id="canvas-inner">
<div class="ruler-corner"></div>
<canvas class="ruler-top" id="ruler-top"></canvas>
<canvas class="ruler-left" id="ruler-left"></canvas>
<div class="canvas-wrap mode-select" id="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
</div>
</div>
<div class="zoom-tools">
<button title="Zoom out (βˆ’)" onclick="zoomStep(-1)">βˆ’</button>
<div class="sep"></div>
<span class="zoom-pct" id="zoom-pct">100%</span>
<div class="sep"></div>
<button title="Zoom in (+)" onclick="zoomStep(1)">+</button>
<div class="sep"></div>
<button title="Fit (F)" onclick="zoomFit()">fit</button>
<button title="100% (0)" onclick="zoomReset()">1:1</button>
</div>
<div class="status-bar">
<span class="k">zoom</span> <span class="v" id="status-zoom">100%</span>
<span class="sep">Β·</span>
<span class="k">cursor</span> <span class="v" id="status-cursor">β€”</span>
<span class="sep">Β·</span>
<span class="k">selection</span> <span class="v" id="status-sel">β€”</span>
<span class="spacer"></span>
<span class="k" id="status-mode">select</span>
</div>
</div>
<aside class="panel">
<section class="p-section">
<div class="p-label">Tool</div>
<div class="tools">
<button class="tool active" data-mode="select" title="Select (V)">
<svg viewBox="0 0 18 18"><path fill="currentColor" d="M3 2l11 6-5 1.6L7.5 15z"/></svg>
<span class="name">Select</span>
<span class="sc">V</span>
</button>
<button class="tool" data-mode="draw" title="Draw bar (B)">
<svg viewBox="0 0 18 18"><rect x="2" y="8" width="14" height="2.5" rx=".5" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="1.5 1.5" d="M2 5h14M2 13h14"/></svg>
<span class="name">Draw</span>
<span class="sc">B</span>
</button>
</div>
<div class="tool-opts" id="tool-opts"></div>
</section>
<section class="p-section">
<div class="p-label">Detected</div>
<div class="summary" id="summary"><span class="num">0</span> bars across <span class="num">0</span> categories</div>
<div class="dist-bar" id="dist-bar"></div>
</section>
<section class="p-section">
<div class="p-label">Categories</div>
<div class="pills" id="pills">
<div class="pills-empty">no pii detected</div>
</div>
</section>
<section class="p-section">
<div class="p-label">Export</div>
<div class="export-row">
<button class="export-btn primary" onclick="downloadImage()">
<span>Download PNG</span>
<span class="sc"><kbd>⌘S</kbd></span>
</button>
<button class="export-btn" onclick="copyToClipboard()">
<span>Copy to clipboard</span>
<span class="sc"><kbd>βŒ˜β‡§C</kbd></span>
</button>
<button class="export-text-link" onclick="exportText()">Export sanitized text only β†’</button>
</div>
</section>
</aside>
</div>
</div>
<div id="loading"><div class="spinner"></div><p>ocr β†’ privacy filter β†’ map to pixels</p></div>
<div class="toast" id="toast"></div>
<script type="module">
// ══════════════════════════════════════════════════════════════════
// Gradio JS client β€” talks to the queued @server.api routes so that
// requests are serialized, progress is tracked, and ZeroGPU's
// @spaces.GPU allocator gets invoked correctly. A plain fetch() to a
// FastAPI route would bypass all of that.
// ══════════════════════════════════════════════════════════════════
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const clientPromise = Client.connect(window.location.origin);
// ══════════════════════════════════════════════════════════════════
// State
// ══════════════════════════════════════════════════════════════════
const S = {
img: null, width: 0, height: 0,
boxes: [], // {id, x, y, w, h, label, text, enabled, custom}
nextId: 1,
activeCats: new Set(),
catMeta: {},
catCounts: {},
sourceText: '',
spans: [],
mode: 'select', // 'select' | 'draw'
selected: null,
drag: null, // {type:'draw'|'move', startX, startY, origBox?, newBox?, boxId?}
scale: 1,
filename: '',
};
const LABELS = {private_person:'Person',private_address:'Address',private_email:'Email',private_phone:'Phone',private_url:'URL',private_date:'Date',account_number:'Account',secret:'Secret',custom:'Custom'};
const CUSTOM_COLOR = '#9ca3af';
// ══════════════════════════════════════════════════════════════════
// Upload
// ══════════════════════════════════════════════════════════════════
const dz = document.getElementById('dropzone');
const fi = document.getElementById('file-input');
['dragenter','dragover'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.add('dragover'); }));
['dragleave','drop'].forEach(e => dz.addEventListener(e, ev => { ev.preventDefault(); dz.classList.remove('dragover'); }));
dz.addEventListener('drop', ev => { const f = ev.dataTransfer.files[0]; if (f) uploadFile(f); });
fi.addEventListener('change', ev => { const f = ev.target.files[0]; if (f) uploadFile(f); });
document.addEventListener('paste', ev => {
if (document.getElementById('landing').style.display === 'none') return;
const items = ev.clipboardData && ev.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) { uploadFile(f); ev.preventDefault(); return; }
}
}
});
async function loadExamples() {
const scroll = document.getElementById('example-scroll');
try {
const r = await fetch('/api/examples');
const d = await r.json();
const list = d.examples || [];
if (!list.length) {
scroll.innerHTML = '<div class="ex-empty">no examples available</div>';
return;
}
scroll.innerHTML = '';
list.forEach((name, i) => {
const el = document.createElement('div');
el.className = 'ex-thumb';
el.title = 'load ' + name;
el.innerHTML = `<span class="ex-idx">${String(i + 1).padStart(2, '0')}</span>
<img loading="lazy" src="/examples/${encodeURIComponent(name)}?thumb=1" alt="">`;
el.addEventListener('click', () => loadExample(name));
scroll.appendChild(el);
});
} catch (e) {
scroll.innerHTML = '<div class="ex-empty">could not load examples</div>';
}
}
async function loadExample(name) {
try {
const r = await fetch('/examples/' + encodeURIComponent(name));
if (!r.ok) throw new Error('fetch failed');
const blob = await r.blob();
const type = blob.type || 'image/png';
const file = new File([blob], name, { type });
uploadFile(file);
} catch (e) {
showError('could not load example: ' + e.message);
}
}
loadExamples();
async function uploadFile(file) {
if (!file.type || !file.type.startsWith('image/')) { showError('please drop an image file.'); return; }
document.getElementById('loading').style.display = 'flex';
document.getElementById('landing').style.display = 'none';
try {
const client = await clientPromise;
const result = await client.predict("/anonymize_screenshot", {
image: handle_file(file),
});
// @server.api returns a dict; the client wraps outputs in result.data[]
const d = result.data[0] || {};
if (d.error) { showError(d.error); return; }
// The server returns Path(path).name as `filename`; fall back to the
// original File.name for display continuity.
if (!d.filename) d.filename = file.name;
await initEditor(d);
} catch (e) {
showError('analysis failed: ' + (e && e.message ? e.message : e));
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function showError(msg) {
document.getElementById('loading').style.display = 'none';
document.getElementById('editor').style.display = 'flex';
const b = document.getElementById('error-banner');
b.textContent = msg; b.style.display = 'block';
}
function resetView() {
document.getElementById('editor').style.display = 'none';
document.getElementById('landing').style.display = 'flex';
document.getElementById('error-banner').style.display = 'none';
fi.value = '';
S.boxes = []; S.selected = null; S.img = null; S.drag = null;
}
// ══════════════════════════════════════════════════════════════════
// Editor init
// ══════════════════════════════════════════════════════════════════
async function initEditor(d) {
document.getElementById('editor').style.display = 'flex';
document.getElementById('error-banner').style.display = 'none';
S.filename = d.filename || 'screenshot.png';
S.width = d.width; S.height = d.height;
S.catMeta = d.categories_meta || {};
S.sourceText = d.text || '';
S.spans = d.spans || [];
S.catCounts = {};
S.boxes = (d.boxes || []).map(b => {
S.catCounts[b.label] = (S.catCounts[b.label] || 0) + 1;
return { id: S.nextId++, x: b.x, y: b.y, w: b.w, h: b.h,
label: b.label, text: b.text, enabled: true, custom: false };
});
S.activeCats = new Set(Object.keys(S.catCounts));
const img = new Image();
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = d.image; });
S.img = img;
setMode('select', true);
zoomFit();
renderPills();
renderToolOpts();
renderSummary();
updateMeta();
draw();
}
// ══════════════════════════════════════════════════════════════════
// Zoom + layout
// ══════════════════════════════════════════════════════════════════
function applyScale() {
const cv = document.getElementById('canvas');
cv.width = S.width; cv.height = S.height;
cv.style.width = (S.width * S.scale) + 'px';
cv.style.height = (S.height * S.scale) + 'px';
drawRulers();
document.getElementById('zoom-pct').textContent = Math.round(S.scale * 100) + '%';
document.getElementById('status-zoom').textContent = Math.round(S.scale * 100) + '%';
}
function zoomFit() {
const sc = document.getElementById('canvas-scroll');
const pad = 72;
const maxW = sc.clientWidth - pad, maxH = sc.clientHeight - pad;
const s = Math.min(1, maxW / S.width, maxH / S.height);
S.scale = Math.max(0.1, s);
applyScale(); draw();
}
function zoomReset() { S.scale = 1; applyScale(); draw(); }
function zoomStep(dir) {
const steps = [0.1,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,3,4];
let i = steps.findIndex(s => s >= S.scale - 0.001);
if (i < 0) i = 0;
i = Math.max(0, Math.min(steps.length - 1, i + dir));
S.scale = steps[i]; applyScale(); draw();
}
window.addEventListener('resize', () => { if (S.img) { applyScale(); draw(); } });
// ══════════════════════════════════════════════════════════════════
// Rulers
// ══════════════════════════════════════════════════════════════════
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function drawRulers() {
const tc = document.getElementById('ruler-top');
const lc = document.getElementById('ruler-left');
const w = Math.ceil(S.width * S.scale);
const h = Math.ceil(S.height * S.scale);
const dpr = window.devicePixelRatio || 1;
const color = cssVar('--text3') || '#65656c';
const colorStrong = cssVar('--text2') || '#9a9a9f';
const border = cssVar('--border') || 'rgba(255,255,255,.06)';
// Top
tc.style.width = w + 'px'; tc.style.height = '20px';
tc.width = w * dpr; tc.height = 20 * dpr;
const tx = tc.getContext('2d'); tx.scale(dpr, dpr);
tx.clearRect(0, 0, w, 20);
tx.fillStyle = 'transparent';
tx.strokeStyle = border; tx.lineWidth = 1;
tx.beginPath(); tx.moveTo(0, 19.5); tx.lineTo(w, 19.5); tx.stroke();
tx.fillStyle = color;
tx.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace');
tx.textBaseline = 'middle';
const step = pickRulerStep(S.scale);
for (let x = 0; x <= S.width; x += step.minor) {
const sx = Math.round(x * S.scale) + 0.5;
const th = x % step.major === 0 ? 8 : x % (step.minor * 5) === 0 ? 5 : 3;
tx.fillStyle = x % step.major === 0 ? colorStrong : color;
tx.fillRect(sx, 20 - th, 1, th);
if (x % step.major === 0 && x > 0 && sx + 30 < w) {
tx.fillStyle = colorStrong;
tx.fillText(String(x), sx + 2, 7);
}
}
// Left
lc.style.width = '24px'; lc.style.height = h + 'px';
lc.width = 24 * dpr; lc.height = h * dpr;
const ly = lc.getContext('2d'); ly.scale(dpr, dpr);
ly.clearRect(0, 0, 24, h);
ly.strokeStyle = border; ly.lineWidth = 1;
ly.beginPath(); ly.moveTo(23.5, 0); ly.lineTo(23.5, h); ly.stroke();
ly.font = '10px ' + (cssVar('--mono') || 'ui-monospace,monospace');
ly.textBaseline = 'middle';
for (let y = 0; y <= S.height; y += step.minor) {
const sy = Math.round(y * S.scale) + 0.5;
const tw = y % step.major === 0 ? 8 : y % (step.minor * 5) === 0 ? 5 : 3;
ly.fillStyle = y % step.major === 0 ? colorStrong : color;
ly.fillRect(24 - tw, sy, tw, 1);
if (y % step.major === 0 && y > 0 && sy + 20 < h) {
ly.save();
ly.fillStyle = colorStrong;
ly.translate(10, sy + 2);
ly.rotate(-Math.PI / 2);
ly.fillText(String(y), 0, 0);
ly.restore();
}
}
}
function pickRulerStep(scale) {
// pick minor tick spacing so adjacent ticks are roughly 8-15 screen px apart
const candidates = [5, 10, 20, 50, 100, 200, 500];
let minor = 10;
for (const c of candidates) {
if (c * scale >= 8) { minor = c; break; }
}
const major = minor * 10;
return { minor, major };
}
// ══════════════════════════════════════════════════════════════════
// Draw
// ══════════════════════════════════════════════════════════════════
function isVisible(b) {
if (!b.enabled) return false;
if (!b.custom && !S.activeCats.has(b.label)) return false;
return true;
}
function draw() {
if (!S.img) return;
const cv = document.getElementById('canvas');
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(S.img, 0, 0);
for (const b of S.boxes) {
if (!isVisible(b)) continue;
ctx.fillStyle = '#000';
ctx.fillRect(b.x, b.y, b.w, b.h);
}
const sel = selectedBox();
if (sel) {
ctx.save();
ctx.strokeStyle = cssVar('--accent') || '#818cf8';
ctx.lineWidth = Math.max(1, 1.5 / S.scale);
ctx.setLineDash([5 / S.scale, 3 / S.scale]);
ctx.strokeRect(sel.x - 1, sel.y - 1, sel.w + 2, sel.h + 2);
ctx.restore();
}
if (S.drag && S.drag.type === 'draw' && S.drag.newBox) {
const b = S.drag.newBox;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,.7)';
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = cssVar('--accent') || '#818cf8';
ctx.lineWidth = Math.max(1, 1 / S.scale);
ctx.strokeRect(b.x, b.y, b.w, b.h);
ctx.restore();
}
}
function selectedBox() {
return S.selected == null ? null : (S.boxes.find(b => b.id === S.selected) || null);
}
// ══════════════════════════════════════════════════════════════════
// Interaction
// ══════════════════════════════════════════════════════════════════
const wrap = document.getElementById('canvas-wrap');
function canvasXY(ev) {
const rect = wrap.getBoundingClientRect();
return {
x: (ev.clientX - rect.left) / S.scale,
y: (ev.clientY - rect.top) / S.scale,
};
}
function hitTest(x, y) {
for (let i = S.boxes.length - 1; i >= 0; i--) {
const b = S.boxes[i];
if (!isVisible(b)) continue;
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return b;
}
return null;
}
wrap.addEventListener('mousedown', ev => {
if (ev.button !== 0) return;
ev.preventDefault();
const { x, y } = canvasXY(ev);
const hit = hitTest(x, y);
if (S.mode === 'draw' && !hit) {
S.selected = null;
S.drag = { type: 'draw', startX: x, startY: y, newBox: { x, y, w: 0, h: 0 } };
} else if (hit) {
S.selected = hit.id;
S.drag = { type: 'move', startX: x, startY: y,
origBox: { x: hit.x, y: hit.y, w: hit.w, h: hit.h }, boxId: hit.id };
wrap.classList.add('dragging');
} else {
S.selected = null; S.drag = null;
}
updateStatus(x, y);
draw();
});
window.addEventListener('mousemove', ev => {
if (S.img && document.getElementById('editor').style.display === 'flex') {
const rect = wrap.getBoundingClientRect();
if (ev.clientX >= rect.left && ev.clientX <= rect.right &&
ev.clientY >= rect.top && ev.clientY <= rect.bottom) {
const x = Math.round((ev.clientX - rect.left) / S.scale);
const y = Math.round((ev.clientY - rect.top) / S.scale);
updateStatus(x, y);
if (!S.drag) {
const hit = hitTest(x, y);
wrap.classList.toggle('over-box', !!hit);
}
}
}
if (!S.drag) return;
const { x, y } = canvasXY(ev);
if (S.drag.type === 'draw') {
const sx = S.drag.startX, sy = S.drag.startY;
S.drag.newBox = {
x: Math.max(0, Math.min(S.width, Math.min(sx, x))),
y: Math.max(0, Math.min(S.height, Math.min(sy, y))),
w: Math.min(Math.abs(x - sx), S.width),
h: Math.min(Math.abs(y - sy), S.height),
};
} else if (S.drag.type === 'move') {
const dx = x - S.drag.startX, dy = y - S.drag.startY;
const b = S.boxes.find(b => b.id === S.drag.boxId);
if (b) {
const o = S.drag.origBox;
b.x = Math.max(0, Math.min(S.width - o.w, Math.round(o.x + dx)));
b.y = Math.max(0, Math.min(S.height - o.h, Math.round(o.y + dy)));
}
}
draw();
});
window.addEventListener('mouseup', () => {
if (!S.drag) return;
wrap.classList.remove('dragging');
if (S.drag.type === 'draw') {
const b = S.drag.newBox;
if (b.w > 3 && b.h > 3) {
const nb = { id: S.nextId++, x: Math.round(b.x), y: Math.round(b.y),
w: Math.round(b.w), h: Math.round(b.h),
label: 'custom', text: '', enabled: true, custom: true };
S.boxes.push(nb);
S.selected = nb.id;
renderSummary();
}
}
S.drag = null;
draw();
});
// Keyboard
window.addEventListener('keydown', ev => {
if (document.getElementById('editor').style.display !== 'flex') return;
const tgt = ev.target;
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA')) return;
const cmd = ev.metaKey || ev.ctrlKey;
if (cmd && ev.key.toLowerCase() === 's') { ev.preventDefault(); downloadImage(); return; }
if (cmd && ev.shiftKey && ev.key.toLowerCase() === 'c') { ev.preventDefault(); copyToClipboard(); return; }
if (ev.key === 'Delete' || ev.key === 'Backspace') {
if (S.selected != null) {
S.boxes = S.boxes.filter(b => b.id !== S.selected);
S.selected = null;
renderSummary(); draw();
ev.preventDefault();
}
} else if (ev.key === 'Escape') {
S.selected = null; S.drag = null; draw();
} else if (ev.key === 'v' || ev.key === 'V') setMode('select');
else if (ev.key === 'b' || ev.key === 'B') setMode('draw');
else if (ev.key === 'f' || ev.key === 'F') zoomFit();
else if (ev.key === '0') zoomReset();
else if (ev.key === '+' || ev.key === '=') zoomStep(1);
else if (ev.key === '-' || ev.key === '_') zoomStep(-1);
});
function updateStatus(x, y) {
document.getElementById('status-cursor').textContent = `${x}, ${y}`;
const sel = selectedBox();
document.getElementById('status-sel').textContent = sel ? `${sel.w}Γ—${sel.h}` : 'β€”';
}
// ══════════════════════════════════════════════════════════════════
// Tool mode
// ══════════════════════════════════════════════════════════════════
document.querySelectorAll('.tool').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.disabled) return;
setMode(btn.dataset.mode);
});
});
function setMode(m, silent) {
S.mode = m;
document.querySelectorAll('.tool').forEach(b => b.classList.toggle('active', b.dataset.mode === m));
wrap.classList.toggle('mode-select', m === 'select');
wrap.classList.toggle('mode-draw', m === 'draw');
document.getElementById('status-mode').textContent = m;
renderToolOpts();
if (!silent) updateStatus(0, 0);
}
function renderToolOpts() {
const el = document.getElementById('tool-opts');
if (S.mode === 'select') {
el.innerHTML = `
<div class="hint">Click a bar to select. Drag to move. Press <kbd style="font-family:var(--mono);padding:1px 4px;border:.5px solid var(--border);border-radius:3px;background:var(--bg)">Del</kbd> to remove.</div>
<div class="kb"><kbd>V</kbd> select <span class="sep">Β·</span> <kbd>B</kbd> draw <span class="sep">Β·</span> <kbd>Esc</kbd> clear</div>`;
} else if (S.mode === 'draw') {
el.innerHTML = `
<div class="hint">Drag on empty canvas to add a black bar. Release to confirm.</div>
<div class="kb"><kbd>B</kbd> draw <span class="sep">Β·</span> <kbd>V</kbd> select <span class="sep">Β·</span> <kbd>F</kbd> fit <span class="sep">Β·</span> <kbd>0</kbd> 1:1</div>`;
} else {
el.innerHTML = `<div class="hint">coming soon.</div>`;
}
}
// ══════════════════════════════════════════════════════════════════
// Side panel rendering
// ══════════════════════════════════════════════════════════════════
function updateMeta() {
const parts = [S.filename, `${S.width}Γ—${S.height}`];
document.getElementById('meta-info').textContent = parts.join(' Β· ');
}
function renderSummary() {
const visible = S.boxes.filter(isVisible);
const catsActive = new Set();
for (const b of visible) catsActive.add(b.custom ? 'custom' : b.label);
document.getElementById('summary').innerHTML =
`<span class="num">${visible.length}</span> bar${visible.length === 1 ? '' : 's'} across <span class="num">${catsActive.size}</span> categor${catsActive.size === 1 ? 'y' : 'ies'}`;
const dist = document.getElementById('dist-bar');
dist.innerHTML = '';
if (!visible.length) return;
const counts = {};
for (const b of visible) {
const k = b.custom ? 'custom' : b.label;
counts[k] = (counts[k] || 0) + 1;
}
const total = visible.length;
for (const [k, n] of Object.entries(counts)) {
const seg = document.createElement('div');
seg.className = 'dist-seg';
seg.style.width = (n / total * 100) + '%';
seg.style.background = (k === 'custom' ? CUSTOM_COLOR : (S.catMeta[k] && S.catMeta[k].color)) || '#888';
dist.appendChild(seg);
}
}
function renderPills() {
const container = document.getElementById('pills');
const cats = Object.keys(S.catCounts);
if (!cats.length) {
container.innerHTML = '<div class="pills-empty">no pii detected</div>';
return;
}
container.innerHTML = '';
for (const cat of cats) {
const meta = S.catMeta[cat] || { color: '#888', label: cat };
const active = S.activeCats.has(cat);
const pill = document.createElement('div');
pill.className = 'pill ' + (active ? 'active' : 'inactive');
pill.innerHTML = `
<span class="swatch" style="background:${meta.color}"></span>
<span class="name">${meta.label}</span>
<span class="count">${S.catCounts[cat]}</span>`;
pill.addEventListener('click', () => {
if (S.activeCats.has(cat)) S.activeCats.delete(cat);
else S.activeCats.add(cat);
renderPills(); renderSummary(); draw();
});
container.appendChild(pill);
}
}
// ══════════════════════════════════════════════════════════════════
// Export
// ══════════════════════════════════════════════════════════════════
function renderExportCanvas() {
const ec = document.createElement('canvas');
ec.width = S.width; ec.height = S.height;
const ctx = ec.getContext('2d');
ctx.drawImage(S.img, 0, 0);
ctx.fillStyle = '#000';
for (const b of S.boxes) if (isVisible(b)) ctx.fillRect(b.x, b.y, b.w, b.h);
return ec;
}
function downloadImage() {
const ec = renderExportCanvas();
ec.toBlob(blob => {
const a = document.createElement('a');
const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, '');
a.download = base + '-redacted.png';
a.href = URL.createObjectURL(blob);
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast('saved ' + a.download);
}, 'image/png');
}
async function copyToClipboard() {
const ec = renderExportCanvas();
try {
await new Promise((res, rej) => {
ec.toBlob(async blob => {
try {
if (!navigator.clipboard || !window.ClipboardItem) { rej(new Error('clipboard api not supported')); return; }
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
res();
} catch (e) { rej(e); }
}, 'image/png');
});
toast('copied to clipboard');
} catch (e) {
toast('copy failed Β· ' + e.message);
}
}
function exportText() {
// Build redacted plaintext by replacing detected spans with [REDACTED label]
let out = '', pos = 0;
const spans = [...S.spans].filter(s => S.activeCats.has(s.label)).sort((a, b) => a.start - b.start);
for (const sp of spans) {
if (sp.start < pos) continue;
out += S.sourceText.slice(pos, sp.start) + `[${(LABELS[sp.label] || sp.label).toLowerCase()}]`;
pos = sp.end;
}
out += S.sourceText.slice(pos);
const blob = new Blob([out], { type: 'text/plain' });
const a = document.createElement('a');
const base = (S.filename || 'screenshot').replace(/\.[^/.]+$/, '');
a.download = base + '-redacted.txt';
a.href = URL.createObjectURL(blob);
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast('saved ' + a.download);
}
let toastTimer = null;
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
}
// Module scripts don't expose top-level names to the global scope, but
// several buttons in the HTML use inline onclick="foo()" handlers β€”
// bridge them explicitly.
Object.assign(window, {
resetView, zoomStep, zoomFit, zoomReset,
downloadImage, copyToClipboard, exportText,
});
</script>
</body>
</html>"""
if __name__ == "__main__":
server.launch(server_name="0.0.0.0", server_port=7860, show_error=True)