Spaces:
Running on Zero
Running on Zero
Update app_v2.py
Browse files
app_v2.py
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
==========================================
|
| 3 |
-
|
| 4 |
==========================================
|
| 5 |
"""
|
| 6 |
|
| 7 |
import base64
|
| 8 |
import functools
|
| 9 |
import io
|
| 10 |
-
import
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
-
from fastapi import File, UploadFile
|
| 15 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
|
|
|
| 16 |
from PIL import Image
|
| 17 |
|
| 18 |
from app import (
|
|
@@ -56,46 +56,11 @@ async def homepage():
|
|
| 56 |
return FRONTEND_HTML
|
| 57 |
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
try:
|
| 65 |
-
img_bytes = await file.read()
|
| 66 |
-
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
| 67 |
-
except Exception as e:
|
| 68 |
-
return JSONResponse({"error": f"Could not read image: {e}"}, 400)
|
| 69 |
-
|
| 70 |
-
ocr = ocr_image(img)
|
| 71 |
-
if not ocr["text"].strip():
|
| 72 |
-
return JSONResponse({"error": "No text detected in the image."}, 400)
|
| 73 |
-
|
| 74 |
-
try:
|
| 75 |
-
source_text, spans = run_pii_analysis(ocr["text"])
|
| 76 |
-
except Exception as e:
|
| 77 |
-
return JSONResponse({"error": f"PII analysis failed: {e}"}, 500)
|
| 78 |
-
|
| 79 |
-
if source_text != ocr["text"]:
|
| 80 |
-
spans = [s for s in spans if s["end"] <= len(ocr["text"])]
|
| 81 |
-
|
| 82 |
-
boxes = map_spans_to_boxes(ocr["words"], spans)
|
| 83 |
-
|
| 84 |
-
buf = io.BytesIO(); img.save(buf, format="PNG")
|
| 85 |
-
data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 86 |
-
|
| 87 |
-
return JSONResponse({
|
| 88 |
-
"filename": file.filename,
|
| 89 |
-
"image": data_url,
|
| 90 |
-
"width": img.width, "height": img.height,
|
| 91 |
-
"boxes": boxes,
|
| 92 |
-
"text": ocr["text"],
|
| 93 |
-
"spans": spans,
|
| 94 |
-
"categories_meta": {k: {"color": v["color"], "label": v["label"]}
|
| 95 |
-
for k, v in CATEGORIES_META.items()},
|
| 96 |
-
})
|
| 97 |
-
|
| 98 |
-
|
| 99 |
@server.get("/api/examples")
|
| 100 |
async def api_examples():
|
| 101 |
return JSONResponse({"examples": _list_examples()})
|
|
@@ -118,37 +83,51 @@ async def get_example(name: str, thumb: int = 0):
|
|
| 118 |
return FileResponse(path, headers={"Cache-Control": "public, max-age=3600"})
|
| 119 |
|
| 120 |
|
|
|
|
|
|
|
|
|
|
| 121 |
@server.api(name="anonymize_screenshot")
|
| 122 |
-
def anonymize_screenshot_api(
|
| 123 |
-
"""
|
| 124 |
-
returns detected redaction boxes as JSON.
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
| 128 |
"""
|
| 129 |
-
import traceback
|
| 130 |
try:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
return json.dumps({"error": f"expected image path, got {type(image_path).__name__}"})
|
| 135 |
|
| 136 |
-
img = Image.open(
|
| 137 |
ocr = ocr_image(img)
|
| 138 |
if not ocr["text"].strip():
|
| 139 |
-
return
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
boxes = map_spans_to_boxes(ocr["words"], spans)
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
"width": img.width, "height": img.height,
|
| 147 |
-
"boxes": boxes,
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
except Exception as e:
|
| 150 |
traceback.print_exc()
|
| 151 |
-
return
|
| 152 |
|
| 153 |
|
| 154 |
# =====================================================================
|
|
@@ -160,7 +139,7 @@ FRONTEND_HTML = r"""<!DOCTYPE html>
|
|
| 160 |
<head>
|
| 161 |
<meta charset="UTF-8">
|
| 162 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 163 |
-
<title>
|
| 164 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 165 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 166 |
<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">
|
|
@@ -316,7 +295,7 @@ svg{display:block;flex-shrink:0}
|
|
| 316 |
|
| 317 |
.landing-foot{
|
| 318 |
max-width:720px;margin:.5rem auto 2rem;padding:0 1.5rem;
|
| 319 |
-
font-family:var(--mono);font-size:
|
| 320 |
}
|
| 321 |
.landing-foot a{color:var(--text2);text-decoration:none;border-bottom:.5px dotted var(--text3)}
|
| 322 |
.landing-foot a:hover{color:var(--text)}
|
|
@@ -543,7 +522,7 @@ svg{display:block;flex-shrink:0}
|
|
| 543 |
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
|
| 544 |
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
|
| 545 |
</svg>
|
| 546 |
-
<span class="wordmark">
|
| 547 |
<span class="version">v0.3 · beta</span>
|
| 548 |
</div>
|
| 549 |
<div class="spacer"></div>
|
|
@@ -551,8 +530,8 @@ svg{display:block;flex-shrink:0}
|
|
| 551 |
</header>
|
| 552 |
|
| 553 |
<div class="landing-content">
|
| 554 |
-
<h1 class="headline">Redact
|
| 555 |
-
<p class="subtitle">
|
| 556 |
|
| 557 |
<label class="dropzone" id="dropzone">
|
| 558 |
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp,image/bmp,image/tiff">
|
|
@@ -561,13 +540,13 @@ svg{display:block;flex-shrink:0}
|
|
| 561 |
<rect x="2" y="12" width="38" height="5" rx="1" fill="currentColor"/>
|
| 562 |
<rect x="2" y="21" width="18" height="5" rx="1" fill="currentColor"/>
|
| 563 |
</svg>
|
| 564 |
-
<div class="dz-text">Drop
|
| 565 |
<div class="dz-hint">png · jpg · webp · bmp · tiff</div>
|
| 566 |
</label>
|
| 567 |
|
| 568 |
<div class="example-wrap">
|
| 569 |
<div class="example-head">
|
| 570 |
-
<span class="title">try an example · click to load
|
| 571 |
<span class="note">all content is fictitious · mock data for testing only</span>
|
| 572 |
</div>
|
| 573 |
<div class="example-scroll" id="example-scroll">
|
|
@@ -577,8 +556,8 @@ svg{display:block;flex-shrink:0}
|
|
| 577 |
</div>
|
| 578 |
|
| 579 |
<div class="landing-foot">
|
| 580 |
-
|
| 581 |
-
|
| 582 |
</div>
|
| 583 |
</div>
|
| 584 |
|
|
@@ -591,11 +570,11 @@ svg{display:block;flex-shrink:0}
|
|
| 591 |
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
|
| 592 |
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
|
| 593 |
</svg>
|
| 594 |
-
<span class="wordmark">
|
| 595 |
</div>
|
| 596 |
<span class="version" id="meta-info">—</span>
|
| 597 |
<div class="spacer"></div>
|
| 598 |
-
<button class="btn-link" onclick="resetView()">new
|
| 599 |
</header>
|
| 600 |
|
| 601 |
<div class="error-banner" id="error-banner"></div>
|
|
@@ -689,7 +668,17 @@ svg{display:block;flex-shrink:0}
|
|
| 689 |
<div id="loading"><div class="spinner"></div><p>ocr → privacy filter → map to pixels</p></div>
|
| 690 |
<div class="toast" id="toast"></div>
|
| 691 |
|
| 692 |
-
<script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
// ══════════════════════════════════════════════════════════════════
|
| 694 |
// State
|
| 695 |
// ══════════════════════════════════════════════════════════════════
|
|
@@ -778,14 +767,20 @@ async function uploadFile(file) {
|
|
| 778 |
if (!file.type || !file.type.startsWith('image/')) { showError('please drop an image file.'); return; }
|
| 779 |
document.getElementById('loading').style.display = 'flex';
|
| 780 |
document.getElementById('landing').style.display = 'none';
|
| 781 |
-
const form = new FormData(); form.append('file', file);
|
| 782 |
try {
|
| 783 |
-
const
|
| 784 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
if (d.error) { showError(d.error); return; }
|
|
|
|
|
|
|
|
|
|
| 786 |
await initEditor(d);
|
| 787 |
} catch (e) {
|
| 788 |
-
showError('analysis failed: ' + e.message);
|
| 789 |
} finally {
|
| 790 |
document.getElementById('loading').style.display = 'none';
|
| 791 |
}
|
|
@@ -1291,6 +1286,14 @@ function toast(msg) {
|
|
| 1291 |
clearTimeout(toastTimer);
|
| 1292 |
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
|
| 1293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
</script>
|
| 1295 |
</body>
|
| 1296 |
</html>"""
|
|
|
|
| 1 |
"""
|
| 2 |
==========================================
|
| 3 |
+
Screenshot Anonymizer — v2 (tool revision)
|
| 4 |
==========================================
|
| 5 |
"""
|
| 6 |
|
| 7 |
import base64
|
| 8 |
import functools
|
| 9 |
import io
|
| 10 |
+
import traceback
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import gradio as gr
|
|
|
|
| 14 |
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
| 15 |
+
from gradio.data_classes import FileData
|
| 16 |
from PIL import Image
|
| 17 |
|
| 18 |
from app import (
|
|
|
|
| 56 |
return FRONTEND_HTML
|
| 57 |
|
| 58 |
|
| 59 |
+
# The /api/examples and /examples/{name} routes below serve static
|
| 60 |
+
# example thumbnails and originals from disk. They are plain FastAPI
|
| 61 |
+
# routes because they do no GPU / queued compute — they just read
|
| 62 |
+
# files, which is exactly the pattern the gradio.Server blog
|
| 63 |
+
# recommends plain @server.get for.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
@server.get("/api/examples")
|
| 65 |
async def api_examples():
|
| 66 |
return JSONResponse({"examples": _list_examples()})
|
|
|
|
| 83 |
return FileResponse(path, headers={"Cache-Control": "public, max-age=3600"})
|
| 84 |
|
| 85 |
|
| 86 |
+
# The compute endpoint: goes through Gradio's queue, plays nicely with
|
| 87 |
+
# @spaces.GPU on ZeroGPU, and is callable by both the browser via the
|
| 88 |
+
# @gradio/client JS client AND by Python users via gradio_client.
|
| 89 |
@server.api(name="anonymize_screenshot")
|
| 90 |
+
def anonymize_screenshot_api(image: FileData) -> dict:
|
| 91 |
+
"""OCR + PII-filter an uploaded image.
|
|
|
|
| 92 |
|
| 93 |
+
Input: FileData from `handle_file(file)` (JS client) or
|
| 94 |
+
`gradio_client.handle_file(path)` (Python client).
|
| 95 |
+
Output: dict with the base-64 image, OCR text, detected spans,
|
| 96 |
+
per-span pixel boxes, and the category color/label table.
|
| 97 |
"""
|
|
|
|
| 98 |
try:
|
| 99 |
+
path = image.get("path") or image.get("url") or ""
|
| 100 |
+
if not path:
|
| 101 |
+
return {"error": "expected an image file"}
|
|
|
|
| 102 |
|
| 103 |
+
img = Image.open(path).convert("RGB")
|
| 104 |
ocr = ocr_image(img)
|
| 105 |
if not ocr["text"].strip():
|
| 106 |
+
return {"error": "No text detected in the image."}
|
| 107 |
+
|
| 108 |
+
source_text, spans = run_pii_analysis(ocr["text"])
|
| 109 |
+
if source_text != ocr["text"]:
|
| 110 |
+
spans = [s for s in spans if s["end"] <= len(ocr["text"])]
|
| 111 |
boxes = map_spans_to_boxes(ocr["words"], spans)
|
| 112 |
+
|
| 113 |
+
buf = io.BytesIO(); img.save(buf, format="PNG")
|
| 114 |
+
data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"filename": Path(path).name,
|
| 118 |
+
"image": data_url,
|
| 119 |
"width": img.width, "height": img.height,
|
| 120 |
+
"boxes": boxes,
|
| 121 |
+
"text": ocr["text"],
|
| 122 |
+
"spans": spans,
|
| 123 |
+
"categories_meta": {
|
| 124 |
+
k: {"color": v["color"], "label": v["label"]}
|
| 125 |
+
for k, v in CATEGORIES_META.items()
|
| 126 |
+
},
|
| 127 |
+
}
|
| 128 |
except Exception as e:
|
| 129 |
traceback.print_exc()
|
| 130 |
+
return {"error": f"{type(e).__name__}: {e}"}
|
| 131 |
|
| 132 |
|
| 133 |
# =====================================================================
|
|
|
|
| 139 |
<head>
|
| 140 |
<meta charset="UTF-8">
|
| 141 |
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 142 |
+
<title>Screenshot Anonymizer</title>
|
| 143 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 144 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 145 |
<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">
|
|
|
|
| 295 |
|
| 296 |
.landing-foot{
|
| 297 |
max-width:720px;margin:.5rem auto 2rem;padding:0 1.5rem;
|
| 298 |
+
font-family:var(--mono);font-size:10.5px;color:var(--text3);line-height:1.7;
|
| 299 |
}
|
| 300 |
.landing-foot a{color:var(--text2);text-decoration:none;border-bottom:.5px dotted var(--text3)}
|
| 301 |
.landing-foot a:hover{color:var(--text)}
|
|
|
|
| 522 |
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
|
| 523 |
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
|
| 524 |
</svg>
|
| 525 |
+
<span class="wordmark">Screenshot Anonymizer</span>
|
| 526 |
<span class="version">v0.3 · beta</span>
|
| 527 |
</div>
|
| 528 |
<div class="spacer"></div>
|
|
|
|
| 530 |
</header>
|
| 531 |
|
| 532 |
<div class="landing-content">
|
| 533 |
+
<h1 class="headline">Redact screenshots before you post them.</h1>
|
| 534 |
+
<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>
|
| 535 |
|
| 536 |
<label class="dropzone" id="dropzone">
|
| 537 |
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp,image/bmp,image/tiff">
|
|
|
|
| 540 |
<rect x="2" y="12" width="38" height="5" rx="1" fill="currentColor"/>
|
| 541 |
<rect x="2" y="21" width="18" height="5" rx="1" fill="currentColor"/>
|
| 542 |
</svg>
|
| 543 |
+
<div class="dz-text">Drop a screenshot, paste from clipboard, or click to browse</div>
|
| 544 |
<div class="dz-hint">png · jpg · webp · bmp · tiff</div>
|
| 545 |
</label>
|
| 546 |
|
| 547 |
<div class="example-wrap">
|
| 548 |
<div class="example-head">
|
| 549 |
+
<span class="title">try an example · click to load</span>
|
| 550 |
<span class="note">all content is fictitious · mock data for testing only</span>
|
| 551 |
</div>
|
| 552 |
<div class="example-scroll" id="example-scroll">
|
|
|
|
| 556 |
</div>
|
| 557 |
|
| 558 |
<div class="landing-foot">
|
| 559 |
+
pii: openai/privacy-filter · 1.5b params, 50m active · apache 2.0<br>
|
| 560 |
+
ocr: rednote-hilab/dots.ocr · 3b vlm, top-3 on olmocr-bench · edits stay in your browser
|
| 561 |
</div>
|
| 562 |
</div>
|
| 563 |
|
|
|
|
| 570 |
<rect x="2" y="9" width="16" height="3" rx="0.5" fill="currentColor"/>
|
| 571 |
<rect x="2" y="14" width="8" height="3" rx="0.5" fill="currentColor"/>
|
| 572 |
</svg>
|
| 573 |
+
<span class="wordmark">Screenshot Anonymizer</span>
|
| 574 |
</div>
|
| 575 |
<span class="version" id="meta-info">—</span>
|
| 576 |
<div class="spacer"></div>
|
| 577 |
+
<button class="btn-link" onclick="resetView()">new screenshot</button>
|
| 578 |
</header>
|
| 579 |
|
| 580 |
<div class="error-banner" id="error-banner"></div>
|
|
|
|
| 668 |
<div id="loading"><div class="spinner"></div><p>ocr → privacy filter → map to pixels</p></div>
|
| 669 |
<div class="toast" id="toast"></div>
|
| 670 |
|
| 671 |
+
<script type="module">
|
| 672 |
+
// ═══════════════════════════════════���══════════════════════════════
|
| 673 |
+
// Gradio JS client — talks to the queued @server.api routes so that
|
| 674 |
+
// requests are serialized, progress is tracked, and ZeroGPU's
|
| 675 |
+
// @spaces.GPU allocator gets invoked correctly. A plain fetch() to a
|
| 676 |
+
// FastAPI route would bypass all of that.
|
| 677 |
+
// ══════════════════════════════════════════════════════════════════
|
| 678 |
+
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 679 |
+
|
| 680 |
+
const clientPromise = Client.connect(window.location.origin);
|
| 681 |
+
|
| 682 |
// ══════════════════════════════════════════════════════════════════
|
| 683 |
// State
|
| 684 |
// ══════════════════════════════════════════════════════════════════
|
|
|
|
| 767 |
if (!file.type || !file.type.startsWith('image/')) { showError('please drop an image file.'); return; }
|
| 768 |
document.getElementById('loading').style.display = 'flex';
|
| 769 |
document.getElementById('landing').style.display = 'none';
|
|
|
|
| 770 |
try {
|
| 771 |
+
const client = await clientPromise;
|
| 772 |
+
const result = await client.predict("/anonymize_screenshot", {
|
| 773 |
+
image: handle_file(file),
|
| 774 |
+
});
|
| 775 |
+
// @server.api returns a dict; the client wraps outputs in result.data[]
|
| 776 |
+
const d = result.data[0] || {};
|
| 777 |
if (d.error) { showError(d.error); return; }
|
| 778 |
+
// The server returns Path(path).name as `filename`; fall back to the
|
| 779 |
+
// original File.name for display continuity.
|
| 780 |
+
if (!d.filename) d.filename = file.name;
|
| 781 |
await initEditor(d);
|
| 782 |
} catch (e) {
|
| 783 |
+
showError('analysis failed: ' + (e && e.message ? e.message : e));
|
| 784 |
} finally {
|
| 785 |
document.getElementById('loading').style.display = 'none';
|
| 786 |
}
|
|
|
|
| 1286 |
clearTimeout(toastTimer);
|
| 1287 |
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
|
| 1288 |
}
|
| 1289 |
+
|
| 1290 |
+
// Module scripts don't expose top-level names to the global scope, but
|
| 1291 |
+
// several buttons in the HTML use inline onclick="foo()" handlers —
|
| 1292 |
+
// bridge them explicitly.
|
| 1293 |
+
Object.assign(window, {
|
| 1294 |
+
resetView, zoomStep, zoomFit, zoomReset,
|
| 1295 |
+
downloadImage, copyToClipboard, exportText,
|
| 1296 |
+
});
|
| 1297 |
</script>
|
| 1298 |
</body>
|
| 1299 |
</html>"""
|