Spaces:
Runtime error
Runtime error
Upload folder using huggingface_hub
Browse files- Dockerfile +10 -0
- README.md +14 -4
- __pycache__/detect.cpython-313.pyc +0 -0
- app.py +29 -0
- detect.py +133 -0
- requirements.txt +4 -0
Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
|
| 7 |
+
COPY . .
|
| 8 |
+
|
| 9 |
+
EXPOSE 7860
|
| 10 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PDF Field Detector
|
| 3 |
+
emoji: π
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# PDF Field Detector
|
| 11 |
+
|
| 12 |
+
FastAPI service that detects form fields in PDFs.
|
| 13 |
+
|
| 14 |
+
- **AcroForm PDFs**: extracts native widget positions
|
| 15 |
+
- **Flat vector PDFs**: mines drawing layer for checkboxes, underlines, rectangles
|
| 16 |
+
- **Scanned/image PDFs**: returns `needs_ml` signal for FFDNet fallback
|
| 17 |
+
|
| 18 |
+
## API
|
| 19 |
+
|
| 20 |
+
`POST /detect` β multipart form upload, returns JSON with field boxes as fractions 0..1.
|
__pycache__/detect.cpython-313.pyc
ADDED
|
Binary file (5.91 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from detect import detect_pdf
|
| 4 |
+
|
| 5 |
+
app = FastAPI(title="PDF Field Detector")
|
| 6 |
+
|
| 7 |
+
app.add_middleware(
|
| 8 |
+
CORSMiddleware,
|
| 9 |
+
allow_origins=["*"],
|
| 10 |
+
allow_methods=["POST", "GET"],
|
| 11 |
+
allow_headers=["*"],
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
@app.get("/health")
|
| 15 |
+
def health():
|
| 16 |
+
return {"ok": True}
|
| 17 |
+
|
| 18 |
+
@app.post("/detect")
|
| 19 |
+
async def detect(file: UploadFile = File(...)):
|
| 20 |
+
if not file.filename.lower().endswith(".pdf"):
|
| 21 |
+
raise HTTPException(400, "Only PDF files accepted")
|
| 22 |
+
pdf_bytes = await file.read()
|
| 23 |
+
if len(pdf_bytes) > 50 * 1024 * 1024:
|
| 24 |
+
raise HTTPException(413, "PDF too large (max 50MB)")
|
| 25 |
+
try:
|
| 26 |
+
pages = detect_pdf(pdf_bytes)
|
| 27 |
+
return {"pages": pages}
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise HTTPException(500, str(e))
|
detect.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core PDF field detection logic.
|
| 3 |
+
Handles three cases:
|
| 4 |
+
1. AcroForm PDF β extract native widgets
|
| 5 |
+
2. Flat vector PDF β extract from drawings layer (rectangles, lines)
|
| 6 |
+
3. Image/scanned PDF β return 'needs_ml' signal for FFDNet fallback
|
| 7 |
+
"""
|
| 8 |
+
import fitz
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
FieldType = Literal['checkbox', 'text', 'signature']
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _label_near(x0, y0, x1, y1, text_spans):
|
| 16 |
+
"""Find the nearest text label above or left of a rect."""
|
| 17 |
+
best, best_dist = '', 1e9
|
| 18 |
+
for span in text_spans:
|
| 19 |
+
sx0, sy0, sx1, sy1 = span['bbox']
|
| 20 |
+
# Candidate: text ends to the left, or is above (within 20pt)
|
| 21 |
+
if sx1 <= x0 + 5 and abs((sy0 + sy1) / 2 - (y0 + y1) / 2) < 20:
|
| 22 |
+
dist = x0 - sx1
|
| 23 |
+
if 0 <= dist < best_dist:
|
| 24 |
+
best, best_dist = span['text'], dist
|
| 25 |
+
elif sy1 <= y0 + 2 and sx0 >= x0 - 5 and sx1 <= x1 + 5:
|
| 26 |
+
dist = y0 - sy1
|
| 27 |
+
if 0 <= dist < best_dist:
|
| 28 |
+
best, best_dist = span['text'], dist
|
| 29 |
+
return best.strip()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def detect_page(page) -> dict:
|
| 33 |
+
pw, ph = page.rect.width, page.rect.height
|
| 34 |
+
widgets = list(page.widgets())
|
| 35 |
+
drawings = page.get_drawings()
|
| 36 |
+
images = page.get_images(full=False)
|
| 37 |
+
|
| 38 |
+
# ββ Case 1: AcroForm ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
if widgets:
|
| 40 |
+
boxes = []
|
| 41 |
+
for w in widgets:
|
| 42 |
+
r = w.rect
|
| 43 |
+
ftype: FieldType
|
| 44 |
+
if w.field_type in (fitz.PDF_WIDGET_TYPE_CHECKBOX, fitz.PDF_WIDGET_TYPE_RADIOBUTTON):
|
| 45 |
+
ftype = 'checkbox'
|
| 46 |
+
elif w.field_type == fitz.PDF_WIDGET_TYPE_SIGNATURE:
|
| 47 |
+
ftype = 'signature'
|
| 48 |
+
else:
|
| 49 |
+
ftype = 'text'
|
| 50 |
+
boxes.append({
|
| 51 |
+
'type': ftype,
|
| 52 |
+
'x': r.x0 / pw, 'y': (ph - r.y1) / ph,
|
| 53 |
+
'w': r.width / pw, 'h': r.height / ph,
|
| 54 |
+
'label': w.field_name or '',
|
| 55 |
+
'source': 'acroform',
|
| 56 |
+
})
|
| 57 |
+
return {'source': 'acroform', 'boxes': boxes}
|
| 58 |
+
|
| 59 |
+
# ββ Case 3: Image/scanned β no vector data to mine ββββββββββββββββββββ
|
| 60 |
+
if not drawings:
|
| 61 |
+
return {'source': 'needs_ml', 'boxes': []}
|
| 62 |
+
|
| 63 |
+
# ββ Case 2: Flat vector PDF ββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
text_spans = []
|
| 65 |
+
for block in page.get_text('dict', flags=fitz.TEXT_INHIBIT_SPACES).get('blocks', []):
|
| 66 |
+
for line in block.get('lines', []):
|
| 67 |
+
for span in line.get('spans', []):
|
| 68 |
+
t = span.get('text', '').strip()
|
| 69 |
+
if t:
|
| 70 |
+
text_spans.append({'text': t, 'bbox': span['bbox']})
|
| 71 |
+
|
| 72 |
+
boxes = []
|
| 73 |
+
seen = set()
|
| 74 |
+
|
| 75 |
+
for d in drawings:
|
| 76 |
+
r = d['rect']
|
| 77 |
+
w, h = r.width, r.height
|
| 78 |
+
if w < 1 or h < 1:
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
key = (round(r.x0), round(r.y0))
|
| 82 |
+
if key in seen:
|
| 83 |
+
continue
|
| 84 |
+
seen.add(key)
|
| 85 |
+
|
| 86 |
+
x_frac = r.x0 / pw
|
| 87 |
+
y_frac = r.y0 / ph
|
| 88 |
+
w_frac = w / pw
|
| 89 |
+
h_frac = h / ph
|
| 90 |
+
|
| 91 |
+
# Small square β checkbox / radio
|
| 92 |
+
if abs(w - h) < w * 0.35 and 3 < w < 22:
|
| 93 |
+
label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
|
| 94 |
+
boxes.append({
|
| 95 |
+
'type': 'checkbox', 'source': 'vector',
|
| 96 |
+
'x': x_frac, 'y': y_frac, 'w': w_frac, 'h': h_frac,
|
| 97 |
+
'label': label,
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
# Thin horizontal line β text underline input
|
| 101 |
+
elif h < 2.5 and w > 20:
|
| 102 |
+
label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
|
| 103 |
+
pad = min(14 / ph, 0.02)
|
| 104 |
+
boxes.append({
|
| 105 |
+
'type': 'text', 'source': 'vector_line',
|
| 106 |
+
'x': x_frac, 'y': max(0, y_frac - pad),
|
| 107 |
+
'w': w_frac, 'h': pad + h_frac + 1 / ph,
|
| 108 |
+
'label': label,
|
| 109 |
+
})
|
| 110 |
+
|
| 111 |
+
# Rectangular box wider than tall β text input field
|
| 112 |
+
elif w > h * 1.2 and h > 6 and w < pw * 0.95:
|
| 113 |
+
label = _label_near(r.x0, r.y0, r.x1, r.y1, text_spans)
|
| 114 |
+
boxes.append({
|
| 115 |
+
'type': 'text', 'source': 'vector_rect',
|
| 116 |
+
'x': x_frac, 'y': y_frac, 'w': w_frac, 'h': h_frac,
|
| 117 |
+
'label': label,
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
return {'source': 'vector', 'boxes': boxes}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def detect_pdf(pdf_bytes: bytes) -> list[dict]:
|
| 124 |
+
doc = fitz.open(stream=pdf_bytes, filetype='pdf')
|
| 125 |
+
pages = []
|
| 126 |
+
for page_num, page in enumerate(doc):
|
| 127 |
+
result = detect_page(page)
|
| 128 |
+
result['page'] = page_num
|
| 129 |
+
result['width'] = page.rect.width
|
| 130 |
+
result['height'] = page.rect.height
|
| 131 |
+
pages.append(result)
|
| 132 |
+
doc.close()
|
| 133 |
+
return pages
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn==0.30.6
|
| 3 |
+
python-multipart==0.0.9
|
| 4 |
+
pymupdf==1.24.10
|