Skip to content

Commit 220b267

Browse files
authored
Fix #33: missed signature field promotion for signature-labeled forms (#35)
* Fix missed signature field promotion * Address review feedback and fix FFDetr device handling
1 parent 1dc9092 commit 220b267

3 files changed

Lines changed: 216 additions & 9 deletions

File tree

commonforms/inference.py

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
from huggingface_hub import hf_hub_download
55
from rfdetr import RFDETRNano, RFDETRBase, RFDETRMedium, RFDETRLarge
66

7-
from commonforms.utils import BoundingBox, Page, Widget
7+
from commonforms.utils import BoundingBox, Page, TextFragment, Widget
88
from commonforms.form_creator import PyPdfFormCreator
99
from commonforms.exceptions import EncryptedPdfError
1010

11-
import formalpdf
1211
import pypdfium2
1312
import logging
1413
import PIL
@@ -38,7 +37,9 @@ def batch(lst: list, n: int = 8):
3837
class FFDetrDetector:
3938
def __init__(self, model_or_path: str, device: int | str = "cpu") -> None:
4039
self.device = device
41-
self.model = RFDETRMedium(pretrain_weights=self.get_model_path(model_or_path))
40+
self.model = RFDETRMedium(
41+
pretrain_weights=self.get_model_path(model_or_path), device=device
42+
)
4243

4344
self.id_to_cls = {0: "TextBox", 1: "ChoiceButton", 2: "Signature"}
4445

@@ -73,7 +74,9 @@ def extract_widgets(
7374
image_size = 1024
7475
results = []
7576
for b in batch([p.image for p in pages], n=batch_size):
76-
predictions = self.model.predict(b, threshold=confidence)
77+
predictions = self.model.predict(
78+
b, threshold=confidence, device=self.device
79+
)
7780
if isinstance(predictions, list):
7881
results.extend(predictions)
7982
else:
@@ -229,16 +232,137 @@ def sort_widgets(widgets: list[Widget]) -> list[Widget]:
229232
return [widget for line in lines for widget in line]
230233

231234

235+
def extract_text_fragments(page: pypdfium2.PdfPage) -> list[TextFragment]:
236+
textpage = page.get_textpage()
237+
try:
238+
fragments = []
239+
for term in textpage.get_text_range().splitlines():
240+
text = term.strip()
241+
if not text:
242+
continue
243+
244+
searcher = textpage.search(term, match_case=False, consecutive=True)
245+
try:
246+
match = searcher.get_next()
247+
finally:
248+
searcher.close()
249+
250+
if match is None:
251+
continue
252+
253+
index, count = match
254+
rect_count = textpage.count_rects(index, count)
255+
rects = [textpage.get_rect(i) for i in range(rect_count)]
256+
if not rects:
257+
continue
258+
259+
left = min(rect[0] for rect in rects)
260+
top = max(rect[3] for rect in rects)
261+
fragments.append(
262+
TextFragment(
263+
text=text,
264+
x0=left / page.get_width(),
265+
y0=1 - (top / page.get_height()),
266+
)
267+
)
268+
269+
return fragments
270+
finally:
271+
textpage.close()
272+
273+
232274
def render_pdf(pdf_path: str) -> list[Page]:
233275
pages = []
234-
doc = formalpdf.open(pdf_path)
276+
doc = pypdfium2.PdfDocument(pdf_path)
235277
try:
236278
for page in doc:
237-
image = page.render(dpi=144)
238-
pages.append(Page(image=image, width=image.width, height=image.height))
279+
image = page.render(scale=2).to_pil()
280+
pages.append(
281+
Page(
282+
image=image,
283+
width=image.width,
284+
height=image.height,
285+
text_fragments=extract_text_fragments(page),
286+
)
287+
)
239288
return pages
240289
finally:
241-
doc.document.close()
290+
doc.close()
291+
292+
293+
def group_widget_rows(
294+
widgets: list[Widget], y_threshold: float = 0.015
295+
) -> list[list[Widget]]:
296+
rows: list[list[Widget]] = []
297+
for widget in sorted(widgets, key=lambda item: item.bounding_box.y0):
298+
if (
299+
rows
300+
and abs(widget.bounding_box.y0 - rows[-1][0].bounding_box.y0) <= y_threshold
301+
):
302+
rows[-1].append(widget)
303+
else:
304+
rows.append([widget])
305+
return rows
306+
307+
308+
def promote_signature_widgets(
309+
pages: list[Page],
310+
results: dict[int, list[Widget]],
311+
signature_label_terms: tuple[str, ...] = ("signature",),
312+
) -> dict[int, list[Widget]]:
313+
"""Promote likely signature fields by matching signature labels to nearby rows."""
314+
normalized_terms = tuple(term.lower() for term in signature_label_terms)
315+
316+
for page_ix, widgets in results.items():
317+
if any(widget.widget_type == "Signature" for widget in widgets):
318+
continue
319+
320+
signature_labels = [
321+
fragment
322+
for fragment in pages[page_ix].text_fragments
323+
if any(term in fragment.text.lower() for term in normalized_terms)
324+
]
325+
if not signature_labels:
326+
continue
327+
328+
textbox_rows = group_widget_rows(
329+
[widget for widget in widgets if widget.widget_type == "TextBox"]
330+
)
331+
if not textbox_rows:
332+
continue
333+
334+
scored_rows = []
335+
for row in textbox_rows:
336+
row_left = min(widget.bounding_box.x0 for widget in row)
337+
row_right = max(widget.bounding_box.x1 for widget in row)
338+
row_y = sum(widget.bounding_box.y0 for widget in row) / len(row)
339+
row_width = row_right - row_left
340+
341+
for label in signature_labels:
342+
horizontal_penalty = 0.0
343+
if label.x0 < row_left:
344+
horizontal_penalty = row_left - label.x0
345+
elif label.x0 > row_right:
346+
horizontal_penalty = label.x0 - row_right
347+
348+
score = (
349+
horizontal_penalty,
350+
abs(row_y - label.y0),
351+
abs(row_left - label.x0),
352+
-row_width,
353+
-row_y,
354+
)
355+
scored_rows.append((score, row))
356+
357+
if not scored_rows:
358+
continue
359+
360+
best_row = min(scored_rows, key=lambda item: item[0])[1]
361+
candidate = min(best_row, key=lambda widget: widget.bounding_box.x0)
362+
widget_ix = widgets.index(candidate)
363+
widgets[widget_ix] = candidate.model_copy(update={"widget_type": "Signature"})
364+
365+
return results
242366

243367

244368
def prepare_form(
@@ -254,11 +378,12 @@ def prepare_form(
254378
fast: bool = False,
255379
multiline: bool = False,
256380
batch_size: int = 4,
381+
signature_label_terms: tuple[str, ...] = ("signature",),
257382
):
258383
if "FFDNET" in model_or_path.upper():
259384
detector = FFDNetDetector(model_or_path, device=device, fast=fast)
260385
else:
261-
detector = FFDetrDetector(model_or_path)
386+
detector = FFDetrDetector(model_or_path, device=device)
262387

263388
try:
264389
pages = render_pdf(input_path)
@@ -274,6 +399,11 @@ def prepare_form(
274399
pages, confidence=confidence, image_size=image_size
275400
)
276401

402+
if use_signature_fields:
403+
results = promote_signature_widgets(
404+
pages, results, signature_label_terms=signature_label_terms
405+
)
406+
277407
writer = PyPdfFormCreator(input_path)
278408
if not keep_existing_fields:
279409
writer.clear_existing_fields()

commonforms/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,15 @@ class Widget(BaseModel):
2626
page: int
2727

2828

29+
class TextFragment(BaseModel):
30+
text: str
31+
x0: float
32+
y0: float
33+
34+
2935
@dataclass
3036
class Page:
3137
image: Image.Image
3238
width: float
3339
height: float
40+
text_fragments: list[TextFragment]

tests/inference_test.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
import formalpdf
55
import pytest
6+
from PIL import Image
7+
8+
from commonforms.inference import promote_signature_widgets
9+
from commonforms.utils import BoundingBox, Page, TextFragment, Widget
610

711

812
def test_inference(tmp_path):
@@ -67,6 +71,72 @@ def test_inference_ffdetr(tmp_path):
6771
doc.document.close()
6872

6973

74+
def test_promote_signature_widgets_uses_signature_label_on_test_pdf():
75+
pages = [
76+
Page(
77+
image=Image.new("RGB", (1, 1)),
78+
width=1,
79+
height=1,
80+
text_fragments=[],
81+
),
82+
Page(
83+
image=Image.new("RGB", (1, 1)),
84+
width=1,
85+
height=1,
86+
text_fragments=[
87+
TextFragment(
88+
text="POLICYHOLDER/PATIENT SIGNATURE FAMILY RELATIONSHIP, IF NOT POLICYHOLDER DATE",
89+
x0=0.37,
90+
y0=0.61,
91+
)
92+
],
93+
),
94+
]
95+
results = {
96+
1: [
97+
Widget(
98+
widget_type="TextBox",
99+
bounding_box=BoundingBox(x0=0.089, y0=0.857, x1=0.384, y1=0.895),
100+
page=1,
101+
),
102+
Widget(
103+
widget_type="TextBox",
104+
bounding_box=BoundingBox(x0=0.752, y0=0.859, x1=0.927, y1=0.896),
105+
page=1,
106+
),
107+
]
108+
}
109+
110+
promoted = promote_signature_widgets(pages, results)
111+
112+
assert promoted[1][0].widget_type == "Signature"
113+
assert promoted[1][1].widget_type == "TextBox"
114+
115+
116+
def test_promote_signature_widgets_skips_pages_without_signature_label():
117+
pages = [
118+
Page(
119+
image=Image.new("RGB", (1, 1)),
120+
width=1,
121+
height=1,
122+
text_fragments=[TextFragment(text="General contact information", x0=0.1, y0=0.2)],
123+
)
124+
]
125+
results = {
126+
0: [
127+
Widget(
128+
widget_type="TextBox",
129+
bounding_box=BoundingBox(x0=0.1, y0=0.8, x1=0.3, y1=0.84),
130+
page=0,
131+
)
132+
]
133+
}
134+
135+
promoted = promote_signature_widgets(pages, results)
136+
137+
assert promoted[0][0].widget_type == "TextBox"
138+
139+
70140
# TODO(joe): future tests around handling encrypted PDFs
71141
# 1. add a --password flag and test that inference doesn't fail
72142
# 2. if a password is provided, ensure that the _output_ PDF remains encrpyted

0 commit comments

Comments
 (0)