Detect checkboxes in a PDF with Python (digital and scanned forms)
Direct answer: There are three distinct checkbox problems in PDF, and they need three different techniques. Digital AcroForm checkboxes (the field is still live) — read field.value with pypdf, no image processing needed. Checkbox glyphs in the text layer (☐/☑ characters) — extract text and check the Unicode codepoint. Flattened or scanned checkboxes (just lines and marks, no field, no glyph) — detect square contours with OpenCV, then measure dark-pixel density inside each box to decide checked vs unchecked. Try them in that order; each is cheaper and more reliable than the next, and most real documents only need the first or third.
Why this isn’t one problem
A PDF checkbox that looks identical on screen can be represented three completely different ways under the hood, and the technique that works for one does nothing for the others.
- Live AcroForm field. The checkbox is a form widget with a name and a value (
/Offor an “on” state like/Yes). This is what you get from a form someone filled out in Acrobat, a browser PDF form, or most e-signature tools before flattening. - Unicode glyph in the text layer. Some form generators — especially ones built from HTML/Markdown, like many government and insurance forms — draw the checkbox as a character: ☐ (U+2610) for unchecked, ☑ (U+2611) or ☒ (U+2612) for checked. The state is text, not a form field or a drawn shape.
- Flattened or scanned mark. The form was printed, physically checked, and scanned — or a digital form was “flattened” (fields converted to static content) before distribution. All you have is a small square outline and, possibly, an X or tick mark drawn or photographed inside it. No field, no glyph — just pixels.
Guessing wrong costs you a silent failure, not an error: pypdf.get_fields() on a flattened or scanned form returns {} with no exception, the same failure mode covered for other form types in extracting data from fillable PDF forms. Check which case you’re in before writing extraction code, not after it silently returns nothing.
Case 1: Live AcroForm checkboxes
pip install pypdf
from pypdf import PdfReader
def read_checkboxes(pdf_path: str) -> dict[str, bool]:
reader = PdfReader(pdf_path)
fields = reader.get_fields()
if not fields:
return {}
checkboxes = {}
for name, field in fields.items():
if field.field_type != "/Btn":
continue
# Checkbox value is the on-state name (e.g. "/Yes") or "/Off".
checkboxes[name] = field.value not in (None, "/Off")
return checkboxes
result = read_checkboxes("intake-form.pdf")
print(result)
# {'agree_terms[0]': True, 'opt_in_marketing[0]': False, ...}
/Btn covers checkboxes, radio buttons, and push buttons in the PDF form spec, so filter further if a form mixes them — field.value for a radio group returns the selected option’s name, not a boolean, which is a different parsing step. This is the fastest and most reliable of the three cases: no image processing, no threshold tuning, exact values every time.
Case 2: Unicode checkbox glyphs
import pdfplumber
CHECKED = {"☑", "☒"} # ☑ ☒
UNCHECKED = {"☐"} # ☐
def find_checkbox_glyphs(pdf_path: str) -> list[dict]:
results = []
with pdfplumber.open(pdf_path) as pdf:
for page_num, page in enumerate(pdf.pages, start=1):
text = page.extract_text() or ""
for ch in text:
if ch in CHECKED or ch in UNCHECKED:
results.append({
"page": page_num,
"checked": ch in CHECKED,
"glyph": ch,
})
return results
This only tells you that a checkbox glyph exists and its state — not which label it belongs to. For that, pull the surrounding text on the same line (page.extract_words() with x0/x1 positions in pdfplumber gives you enough to associate the glyph with the nearest label to its right). Cheap, exact, and easy to miss if you don’t think to check for it — a form that looks scanned in a PDF viewer can still have a real, searchable text layer with these characters.
Case 3: Flattened or scanned checkboxes
No field, no glyph — just a drawn or photographed box. This is a computer-vision problem: find small square shapes, then measure how much of the interior is dark.
pip install pdf2image opencv-python-headless numpy
import cv2
import numpy as np
from pdf2image import convert_from_path
def detect_checkboxes(pdf_path: str, page_num: int = 1) -> list[dict]:
pages = convert_from_path(pdf_path, dpi=300, first_page=page_num, last_page=page_num)
img = np.array(pages[0].convert("L")) # grayscale
# Binarize: dark marks/lines become white on a black background for contour detection.
_, binary = cv2.threshold(img, 200, 255, cv2.THRESH_BINARY_INV)
contours, _ = cv2.findContours(binary, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
boxes = []
for cnt in contours:
x, y, w, h = cv2.boundingRect(cnt)
aspect = w / h if h else 0
# Checkboxes are small and roughly square. Tune the size range to your
# scan DPI — at 300 DPI, a typical checkbox is 15-35px per side.
if not (15 <= w <= 35 and 15 <= h <= 35 and 0.8 <= aspect <= 1.2):
continue
# Measure fill density strictly inside the box, away from the border itself.
pad = 3
interior = binary[y + pad:y + h - pad, x + pad:x + w - pad]
fill_ratio = float(np.mean(interior > 0)) if interior.size else 0.0
boxes.append({
"x": x, "y": y, "width": w, "height": h,
"fill_ratio": round(fill_ratio, 3),
"checked": fill_ratio > 0.12, # tune against your own sample
})
return boxes
checkboxes = detect_checkboxes("scanned-application.pdf", page_num=1)
for box in checkboxes:
print(f"({box['x']}, {box['y']}) fill={box['fill_ratio']} checked={box['checked']}")
Three things determine whether this holds up in production:
- DPI consistency. The
15-35pxsize window is calibrated to 300 DPI. Render at a different DPI, or accept scans at mixed DPIs, and the window needs to scale with it — compute it as a fraction of page width instead of a fixed pixel range if your inputs vary. - The fill-ratio threshold is a starting point, not a constant. An X mark, a tick, and a solid fill-in cover very different fractions of the box interior. Run this against 30-50 real examples from your document population and plot the fill-ratio distribution before trusting a single cutoff — a bimodal distribution with a clear gap means your threshold is safe; a smeared one means you need a better feature than raw fill ratio (contour count inside the box is a reasonable second signal).
- Skew and rotation break the aspect-ratio filter. A scan rotated even a few degrees turns a square bounding box into a rectangle that fails the
0.8 <= aspect <= 1.2check. Deskew first — OpenCV’scv2.minAreaRecton the largest text-block contour, or a dedicated deskew library, run once per page before detection.
Associating a detected box with its label works the same way as the glyph case: find the nearest text (usually to the right, sometimes above) using word-level bounding boxes from pdfplumber or pytesseract’s image_to_data output on the same rendered page.
When pixel detection isn’t worth it: vision-LLM fallback
Skewed scans, photographed (not flatbed-scanned) forms, or checkbox styles that don’t reduce cleanly to “small square, measure fill” — circles instead of boxes, checkmarks that bleed outside the border — push the false-positive rate on the OpenCV approach up fast. At that point, a vision-LLM call with a schema is more reliable and requires no threshold tuning, at the cost of per-page pricing instead of free local compute:
from pydantic import BaseModel
class CheckboxState(BaseModel):
label: str
checked: bool
class FormResult(BaseModel):
checkboxes: list[CheckboxState]
# Using pdfmux's BYOK vision-LLM backend — no built-in checkbox preset,
# same as its financial-statement handling: define the schema, pdfmux routes
# the page to the configured provider and returns matching JSON.
from pdfmux import process
result = process(
"photographed-form.pdf",
quality="standard",
output_format="json",
schema="checkbox_state.json", # JSON Schema mirroring FormResult above
llm_provider="gemini",
)
This is the same “no preset, write your own schema” pattern used for financial statement extraction — pdfmux doesn’t special-case checkboxes, but its schema-driven LLM routing handles them the same way it handles any structured field. For the cost and behavior differences between vision-LLM backends, see Gemini vs Mistral OCR — Mistral OCR alone won’t do this, since it transcribes rather than reasons about a schema.
Choosing an approach
| Situation | Method | Reliability | Cost |
|---|---|---|---|
| Live AcroForm field | pypdf field value | Exact | Free |
| Unicode glyph in text layer | Codepoint check | Exact | Free |
| Clean flatbed scan, consistent DPI | OpenCV contour + fill ratio | High, needs threshold tuning | Free |
| Photographed / skewed / unusual mark style | Vision LLM with schema | High, no tuning | Per-page fee |
Check which case applies before writing extraction code — the field/glyph checks are near-instant to try and rule out, and skipping straight to computer vision on a form that actually has a live field or Unicode glyph is wasted engineering effort on a problem that a two-line check would have solved.
Production checklist
- Try
pypdf.get_fields()first — if it returns non-empty/Btnfields, you’re done - Check the text layer for ☐/☑/☒ glyphs before assuming a scan
- For pixel detection: calibrate the box size window to your actual render DPI
- Deskew before contour detection if any input scans are rotated
- Plot the fill-ratio distribution on a real sample before fixing a threshold
- Associate each detected box with its nearest label text, not just its coordinates
- Fall back to a vision-LLM schema for photographed or non-standard checkbox styles
Keep reading
- How to extract data from fillable PDF forms in Python — AcroForm, XFA, and scanned forms beyond checkboxes
- Detect whether a PDF is scanned in Python — the check to run before deciding which checkbox path applies
- Gemini vs Mistral OCR for PDF extraction — choosing a vision-LLM backend for the schema-based fallback
- PDF extraction without a GPU — running OpenCV/OCR-based detection on CPU only
Last updated: September 2026.
Frequently asked questions
Why does pypdf return an empty dict for a form I can see checkboxes on?
If the checkboxes were flattened at export time — common when a form is scanned, printed and re-scanned, or exported 'flat' from tools like DocuSign — there is no AcroForm field left to read. pypdf sees a page with lines and marks, not form data. That's the pixel-based path below, not a pypdf bug.
How do I tell a checked box from an unchecked one in a scan?
Compute the fraction of dark pixels inside each detected box's bounding region. An empty checkbox is mostly white background with a thin border; a checked one has an X, tick, or fill covering a meaningful share of the interior. A threshold around 10-15% dark-pixel coverage separates the two reliably on clean scans — tune it against your own sample.
Do I need OpenCV, or can pdfmux do this?
pdfmux has no built-in checkbox preset — the same honest gap as its financial-statement schema. For flattened or scanned checkboxes it routes hard pages to a vision-LLM backend, which you can prompt with a schema asking for checkbox states directly, skipping the OpenCV step entirely at a higher per-page cost.
What about checkboxes drawn as Unicode characters (☐ / ☑) in the text layer?
Some PDF generators write the checkbox glyph directly into the text layer instead of drawing it or using a form field. Extract text normally and check for U+2610 (☐, unchecked) vs U+2611/U+2612 (☑/☒, checked) — no image processing needed. Check for this first; it's the cheapest case when it applies.