mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
docs: add spot redaction implementation plan
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
1 parent
25f3705820
commit
6e8933f0f4
1 file changed
+543
@@ -0,0 +1,543 @@
|
||||
# Spot Redaction Detection Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace auto-detection with user-guided spot detection via double-click, using connected-component flood fill.
|
||||
|
||||
**Architecture:** New `spot_redaction()` function uses OpenCV connected components to find the dark region at a click point. New `/api/redaction/spot` endpoint chains this with existing analysis logic. Frontend replaces double-click-to-zoom with spot detection, remaps shift+drag from manual marking to gap width adjustment.
|
||||
|
||||
**Tech Stack:** Python/FastAPI (backend), OpenCV (connected components), vanilla JS (frontend)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Add `spot_redaction()` function
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/pipeline/detect_redactions.py`
|
||||
|
||||
**Step 1: Add spot_redaction function after the existing detect_redactions function**
|
||||
|
||||
Add at the end of `unredact/pipeline/detect_redactions.py` (after line 74):
|
||||
|
||||
```python
|
||||
def spot_redaction(image: Image.Image, click_x: int, click_y: int) -> Redaction | None:
|
||||
"""Find a redaction box at a specific click point using connected components.
|
||||
|
||||
Args:
|
||||
image: PIL Image of a document page.
|
||||
click_x: X coordinate of the click in page-image pixels.
|
||||
click_y: Y coordinate of the click in page-image pixels.
|
||||
|
||||
Returns:
|
||||
Redaction object if a dark region is found, None otherwise.
|
||||
"""
|
||||
arr = np.array(image.convert("RGB"))
|
||||
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
|
||||
_, binary = cv2.threshold(gray, 40, 255, cv2.THRESH_BINARY_INV)
|
||||
|
||||
num_labels, labels = cv2.connectedComponents(binary)
|
||||
|
||||
if click_y < 0 or click_y >= labels.shape[0] or click_x < 0 or click_x >= labels.shape[1]:
|
||||
return None
|
||||
|
||||
label = int(labels[click_y, click_x])
|
||||
if label == 0:
|
||||
return None
|
||||
|
||||
coords = cv2.findNonZero((labels == label).astype(np.uint8))
|
||||
x, y, w, h = cv2.boundingRect(coords)
|
||||
|
||||
if w * h < 100:
|
||||
return None
|
||||
|
||||
return Redaction(id=uuid.uuid4().hex[:8], x=int(x), y=int(y), w=int(w), h=int(h))
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/pipeline/detect_redactions.py
|
||||
git commit -m "feat: add spot_redaction() using connected components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — Extract analysis helper and add `/api/redaction/spot` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/app.py:19-21` (imports)
|
||||
- Modify: `unredact/app.py:150-230` (extract helper from analyze endpoint)
|
||||
- Add new endpoint after the analyze endpoint
|
||||
|
||||
**Step 1: Update imports in `app.py` (line 20)**
|
||||
|
||||
Change:
|
||||
```python
|
||||
from unredact.pipeline.detect_redactions import detect_redactions
|
||||
```
|
||||
To:
|
||||
```python
|
||||
from unredact.pipeline.detect_redactions import detect_redactions, spot_redaction
|
||||
```
|
||||
|
||||
**Step 2: Extract analysis logic into helper function**
|
||||
|
||||
Insert before the `@app.post("/api/redaction/analyze")` decorator (before line 150):
|
||||
|
||||
```python
|
||||
def _run_analysis(page_img: Image.Image, rx: int, ry: int, rw: int, rh: int) -> dict | None:
|
||||
"""Run OCR + font detection for a redaction box. Returns analysis dict or None."""
|
||||
pad_y = rh
|
||||
crop_y1 = max(0, ry - pad_y)
|
||||
crop_y2 = min(page_img.height, ry + rh + pad_y)
|
||||
line_crop = page_img.crop((0, crop_y1, page_img.width, crop_y2))
|
||||
|
||||
lines = ocr_page(line_crop)
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
redaction_cy = (ry - crop_y1) + rh / 2
|
||||
best_line = min(lines, key=lambda l: abs((l.y + l.h / 2) - redaction_cy))
|
||||
|
||||
font_match = detect_font_for_line(best_line)
|
||||
|
||||
segments = []
|
||||
gap = {"x": rx, "w": rw}
|
||||
|
||||
left_chars = [c for c in best_line.chars if c.x + c.w <= rx]
|
||||
right_chars = [c for c in best_line.chars if c.x >= rx + rw]
|
||||
|
||||
left_text = "".join(c.text for c in left_chars).rstrip()
|
||||
right_text = "".join(c.text for c in right_chars).lstrip()
|
||||
|
||||
pil_font = font_match.to_pil_font()
|
||||
if left_text:
|
||||
left_rendered_width = pil_font.getlength(left_text)
|
||||
offset_x = float(rx - left_rendered_width - best_line.x)
|
||||
else:
|
||||
offset_x = 0.0
|
||||
offset_y = 0.0
|
||||
|
||||
if left_text:
|
||||
lx = left_chars[0].x
|
||||
lw = (left_chars[-1].x + left_chars[-1].w) - lx
|
||||
segments.append({"text": left_text, "x": lx, "w": lw})
|
||||
if right_text:
|
||||
rx2 = right_chars[0].x
|
||||
rw2 = (right_chars[-1].x + right_chars[-1].w) - rx2
|
||||
segments.append({"text": right_text, "x": rx2, "w": rw2})
|
||||
|
||||
chars_json = [
|
||||
{"text": c.text, "x": c.x, "y": c.y + crop_y1, "w": c.w, "h": c.h, "conf": c.conf}
|
||||
for c in best_line.chars
|
||||
]
|
||||
|
||||
return {
|
||||
"segments": segments,
|
||||
"gap": gap,
|
||||
"font": {
|
||||
"name": font_match.font_name,
|
||||
"id": _make_font_id(font_match.font_name),
|
||||
"size": font_match.font_size,
|
||||
"score": font_match.score,
|
||||
},
|
||||
"line": {
|
||||
"x": best_line.x,
|
||||
"y": best_line.y + crop_y1,
|
||||
"w": best_line.w,
|
||||
"h": best_line.h,
|
||||
"text": best_line.text,
|
||||
},
|
||||
"chars": chars_json,
|
||||
"offset_x": round(offset_x, 1),
|
||||
"offset_y": round(offset_y, 1),
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Simplify existing analyze endpoint to use helper**
|
||||
|
||||
Replace the body of the `analyze_redaction` function (lines 152-230) with:
|
||||
|
||||
```python
|
||||
@app.post("/api/redaction/analyze")
|
||||
async def analyze_redaction(req: AnalyzeRequest):
|
||||
doc = _docs.get(req.doc_id)
|
||||
if not doc or req.page not in doc["pages"]:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
page_img = doc["pages"][req.page]["original"]
|
||||
rx, ry, rw, rh = req.redaction["x"], req.redaction["y"], req.redaction["w"], req.redaction["h"]
|
||||
|
||||
result = _run_analysis(page_img, rx, ry, rw, rh)
|
||||
if result is None:
|
||||
return JSONResponse({"error": "no text detected near redaction"}, status_code=422)
|
||||
return result
|
||||
```
|
||||
|
||||
**Step 4: Add SpotRequest model and endpoint**
|
||||
|
||||
Add after the analyze endpoint:
|
||||
|
||||
```python
|
||||
class SpotRequest(BaseModel):
|
||||
doc_id: str
|
||||
page: int
|
||||
click_x: int
|
||||
click_y: int
|
||||
|
||||
|
||||
@app.post("/api/redaction/spot")
|
||||
async def spot_redaction_endpoint(req: SpotRequest):
|
||||
doc = _docs.get(req.doc_id)
|
||||
if not doc or req.page not in doc["pages"]:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
page_img = doc["pages"][req.page]["original"]
|
||||
redaction = spot_redaction(page_img, req.click_x, req.click_y)
|
||||
if redaction is None:
|
||||
return JSONResponse({"error": "no_redaction_found"}, status_code=422)
|
||||
|
||||
result = _run_analysis(page_img, redaction.x, redaction.y, redaction.w, redaction.h)
|
||||
response = {"box": {"x": redaction.x, "y": redaction.y, "w": redaction.w, "h": redaction.h}}
|
||||
if result:
|
||||
response.update(result)
|
||||
return response
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/app.py
|
||||
git commit -m "feat: add /api/redaction/spot endpoint with analysis helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — Remove auto-detection from upload
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/app.py:79-105` (upload_pdf function)
|
||||
|
||||
**Step 1: Remove detect_redactions call from upload_pdf**
|
||||
|
||||
Replace lines 91-97 of `upload_pdf`:
|
||||
|
||||
```python
|
||||
page_data = {}
|
||||
for i, page_img in enumerate(pages, start=1):
|
||||
redactions = detect_redactions(page_img)
|
||||
page_data[i] = {
|
||||
"original": page_img,
|
||||
"redactions": redactions,
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```python
|
||||
page_data = {}
|
||||
for i, page_img in enumerate(pages, start=1):
|
||||
page_data[i] = {
|
||||
"original": page_img,
|
||||
"redactions": [],
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/app.py
|
||||
git commit -m "feat: remove auto-detection from upload pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Add toast notification system
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/static/index.html` (add toast container)
|
||||
- Modify: `unredact/static/style.css` (add toast styles)
|
||||
- Modify: `unredact/static/app.js` (add showToast function)
|
||||
|
||||
**Step 1: Add toast container to HTML**
|
||||
|
||||
In `index.html`, add before the closing `</body>` tag (before line 150):
|
||||
|
||||
```html
|
||||
<div id="toast-container"></div>
|
||||
```
|
||||
|
||||
**Step 2: Add toast CSS**
|
||||
|
||||
Append to `style.css`:
|
||||
|
||||
```css
|
||||
/* Toast notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: rgba(30, 30, 50, 0.95);
|
||||
color: #e0e0e0;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
font-size: 0.85rem;
|
||||
backdrop-filter: blur(6px);
|
||||
animation: toast-in 0.2s ease-out, toast-out 0.3s ease-in 2.5s forwards;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: #d32f2f;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add showToast function to app.js**
|
||||
|
||||
Add near the top of `app.js`, after the DOM element declarations (after line 53):
|
||||
|
||||
```javascript
|
||||
const toastContainer = document.getElementById("toast-container");
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${type}`;
|
||||
el.textContent = message;
|
||||
toastContainer.appendChild(el);
|
||||
setTimeout(() => el.remove(), 3000);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/static/index.html unredact/static/style.css unredact/static/app.js
|
||||
git commit -m "feat: add toast notification system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Replace double-click-to-zoom with spot redaction
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/static/app.js:870-877` (dblclick handler)
|
||||
|
||||
**Step 1: Replace the double-click handler**
|
||||
|
||||
Replace the existing dblclick handler (lines 870-877):
|
||||
|
||||
```javascript
|
||||
// Double-click to zoom in
|
||||
rightPanel.addEventListener("dblclick", (e) => {
|
||||
if (popover.contains(e.target) || fontToolbar.contains(e.target) || textEditBar.contains(e.target)) return;
|
||||
const rect = rightPanel.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
zoomTo(state.zoom * 2, sx, sy, true);
|
||||
});
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
// Double-click to spot-detect a redaction
|
||||
rightPanel.addEventListener("dblclick", async (e) => {
|
||||
if (popover.contains(e.target) || fontToolbar.contains(e.target) || textEditBar.contains(e.target)) return;
|
||||
if (!state.docId) return;
|
||||
|
||||
const rect = rightPanel.getBoundingClientRect();
|
||||
const sx = e.clientX - rect.left;
|
||||
const sy = e.clientY - rect.top;
|
||||
const doc = screenToDoc(sx, sy);
|
||||
|
||||
// If clicking on an existing redaction, just activate it (handled by canvas mousedown)
|
||||
const hit = hitTestRedaction(doc.x, doc.y);
|
||||
if (hit) return;
|
||||
|
||||
const clickX = Math.round(doc.x);
|
||||
const clickY = Math.round(doc.y);
|
||||
|
||||
showToast("Detecting redaction...");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/redaction/spot", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
doc_id: state.docId,
|
||||
page: state.currentPage,
|
||||
click_x: clickX,
|
||||
click_y: clickY,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
showToast(err.error === "no_redaction_found" ? "No redaction found at click point" : "Detection failed", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const box = data.box;
|
||||
const id = "m" + Date.now().toString(36);
|
||||
|
||||
state.redactions[id] = {
|
||||
id,
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
page: state.currentPage,
|
||||
status: data.segments ? "analyzed" : "unanalyzed",
|
||||
analysis: data.segments ? data : null,
|
||||
solution: null,
|
||||
preview: null,
|
||||
};
|
||||
|
||||
if (data.segments) {
|
||||
state.redactions[id].overrides = {
|
||||
fontId: data.font.id,
|
||||
fontSize: data.font.size,
|
||||
offsetX: data.offset_x || 0,
|
||||
offsetY: data.offset_y || 0,
|
||||
gapWidth: data.gap.w,
|
||||
leftText: data.segments.length > 0 ? data.segments[0].text : "",
|
||||
rightText: data.segments.length > 1 ? data.segments[1].text : "",
|
||||
};
|
||||
}
|
||||
|
||||
renderRedactionList();
|
||||
renderCanvas();
|
||||
activateRedaction(id);
|
||||
} catch (e) {
|
||||
console.error("Spot detection error:", e);
|
||||
showToast("Detection failed: " + e.message, "error");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/static/app.js
|
||||
git commit -m "feat: replace double-click zoom with spot redaction detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — Remove shift+drag manual marking, remap shift+drag to gap width
|
||||
|
||||
**Files:**
|
||||
- Modify: `unredact/static/app.js:1299-1372` (remove shift+drag marking)
|
||||
- Modify: `unredact/static/app.js:1374-1416` (remap ctrl+shift to shift for gap width)
|
||||
|
||||
**Step 1: Remove the entire shift+drag manual marking block**
|
||||
|
||||
Delete lines 1299-1372 (from `// ── Manual redaction marking (Shift+drag) ──` through the final `mouseup` handler for `drawDrag`). This includes the `drawDrag` variable and all three event listeners.
|
||||
|
||||
**Step 2: Remap the modifier drag handlers**
|
||||
|
||||
Replace the existing ctrl+drag block (lines 1374-1416, now renumbered after deletion):
|
||||
|
||||
```javascript
|
||||
// ── Ctrl+drag offset / Ctrl+Shift+drag gap width ──
|
||||
|
||||
let ctrlDrag = null;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
if (!e.ctrlKey || e.button !== 0) return;
|
||||
const r = state.redactions[state.activeRedaction];
|
||||
if (!r?.overrides) return;
|
||||
|
||||
ctrlDrag = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startOffsetX: r.overrides.offsetX,
|
||||
startOffsetY: r.overrides.offsetY,
|
||||
startGapWidth: r.overrides.gapWidth,
|
||||
widthMode: e.shiftKey,
|
||||
};
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}, { capture: true });
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
// ── Ctrl+drag offset / Shift+drag gap width ──
|
||||
|
||||
let modDrag = null;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
if ((!e.ctrlKey && !e.shiftKey) || e.button !== 0) return;
|
||||
const r = state.redactions[state.activeRedaction];
|
||||
if (!r?.overrides) return;
|
||||
|
||||
modDrag = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startOffsetX: r.overrides.offsetX,
|
||||
startOffsetY: r.overrides.offsetY,
|
||||
startGapWidth: r.overrides.gapWidth,
|
||||
widthMode: e.shiftKey && !e.ctrlKey,
|
||||
};
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}, { capture: true });
|
||||
```
|
||||
|
||||
And update the mousemove and mouseup handlers to use `modDrag` instead of `ctrlDrag`:
|
||||
|
||||
```javascript
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!modDrag) return;
|
||||
const r = state.redactions[state.activeRedaction];
|
||||
if (!r?.overrides) return;
|
||||
|
||||
const dx = (e.clientX - modDrag.startX) / state.zoom;
|
||||
const dy = (e.clientY - modDrag.startY) / state.zoom;
|
||||
|
||||
if (modDrag.widthMode) {
|
||||
r.overrides.gapWidth = Math.max(1, modDrag.startGapWidth + dx);
|
||||
gapValue.textContent = Math.round(r.overrides.gapWidth);
|
||||
} else {
|
||||
r.overrides.offsetX = modDrag.startOffsetX + dx;
|
||||
r.overrides.offsetY = modDrag.startOffsetY + dy;
|
||||
updatePosDisplay();
|
||||
}
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
if (modDrag) modDrag = null;
|
||||
});
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add unredact/static/app.js
|
||||
git commit -m "feat: remap shift+drag to gap width, remove manual marking"
|
||||
```
|
||||
Reference in new issue
Block a user