Tesseract bounding boxes in Python: 723 of 726 words got a box within 2 px
Use pytesseract.image_to_data and keep the rows with
level 5. Each is a word, with left,
top, width and height in pixels
from the top left corner. On 6 test pages where I knew where every
word was, 723 of 726 words got a box within 2 pixels of the ink.
Letter boxes from image_to_boxes are a different thing:
they count y from the bottom of the image, and only about 3 in 4 sat
on their own letter.
Try it on your PDF, $0.006 a page
The code
Tesseract 5.5.3, pytesseract 0.3.13, Pillow 13.0.0, Python 3.14.7, on
a Mac, with the eng model from tessdata_fast 4.1.0. This
prints each word with its box and draws the boxes in red:
import pytesseract
from PIL import Image, ImageDraw
img = Image.open("invoice.png").convert("RGB")
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
draw = ImageDraw.Draw(img)
for i, word in enumerate(d["text"]):
if d["level"][i] != 5 or not word.strip():
continue # rows 1 to 4 are the page, blocks, paragraphs and lines
x, y, w, h = d["left"][i], d["top"][i], d["width"][i], d["height"][i]
print(word, x, y, w, h)
draw.rectangle((x, y, x + w, y + h), outline="red", width=2)
img.save("invoice-boxes.png")
The start of what it printed for my test invoice:
Invoice 152 233 197 48
2026-0417 369 234 287 47
Bill 151 380 67 32
to: 232 386 44 27
On the command line,
tesseract invoice.png boxes tsv writes the same numbers
to boxes.tsv, and
tesseract invoice.png boxes hocr writes them as
bbox 152 233 349 281: left, top, right and bottom, where
right is left plus width. On my notice page all 133 word boxes were
the same in the three. The rows with conf -1 are the
page, blocks, paragraphs and lines. The
confidence score page
explains those rows and the scores.
How close the boxes came
I made 6 test pages at 300 DPI: a tenant notice, an invoice and club minutes, in two sets. I drew each letter on its own, so I knew the box of its ink to the pixel. The light set uses Times with a small blur, saved as PNG. The rough set uses Arial with more blur, noise and JPEG quality 60. Each Tesseract box was paired with the true box it overlapped most. "Within 2 px" means all 4 edges were.
| What | Light set | Rough set |
|---|---|---|
Words, image_to_data, within 2 px |
362 of 363 (all within 1 px) | 361 of 363 |
Letters, image_to_boxes with y flipped, on their
own letter within 2 px
|
1,205 of 1,563 (77%) | 1,191 of 1,563 (76%) |
| Letters drawn as printed, with y not flipped | All 1,562 in the blank lower part of the page | All 1,563 |
The 3 words that missed:
-
A lone
1in the quantity column of the light invoice got no box at all. -
On the rough notice, the box for the word
astarted 12 px to the right of its ink. - One more rough box was 6 px off.
A wrong word can still have a right box. padlock came out
as padiock and a 1 as |, each
in a box within 1 px.
Letter boxes count y from the bottom
image_to_boxes returns one line per letter: the letter,
then left, bottom, right and top, then the page number. The first
lines for my notice page, which is 3,300 pixels tall:
N 149 3019 197 3065 0
o 182 3019 209 3067 0
t 200 3019 229 3052 0
The N is at the top of the page, but 3019 is near the
bottom, because y counts up from the bottom edge. On my 6 pages the
text ends at y 1,233, and all 3,125 letter boxes drawn as printed
started at y 2,067 or below, on blank paper. Flip it before you draw:
import pytesseract
from PIL import Image, ImageDraw
img = Image.open("invoice.png").convert("RGB")
H = img.height
draw = ImageDraw.Draw(img)
for line in pytesseract.image_to_boxes(img).splitlines():
ch, left, bottom, right, top, page = line.split(" ")
left, bottom, right, top = int(left), int(bottom), int(right), int(top)
# image_to_boxes counts y from the bottom of the image: flip it
draw.rectangle((left, H - top, right, H - bottom), outline="blue")
img.save("invoice-letters.png")
Flipped, the N box was 149, 235, 197, 281 against its
true 149, 235, 198, 281. But the next letters slid: the
o box started at x 182, inside the N, and
the t box sat where the o was. On the big
heading of the invoice, all 16 letter boxes were within 1 px. So use
letter boxes for a rough place, and word boxes when you need the place
to the pixel.
Boxes on a PDF page
Tesseract reads an image, so its boxes are in the pixels of the image you rendered. PDF space is in points, 72 to the inch, and starts at the bottom left. So divide by the DPI, and flip y:
DPI = 300 # the DPI you rendered the PDF page at
PAGE_HEIGHT = 792 # the page height in points (US letter); read yours from the PDF
def to_pdf_points(x, y, w, h):
s = 72 / DPI
# PDF space starts at the bottom left corner of the page
return x * s, PAGE_HEIGHT - (y + h) * s, w * s, h * s
print(to_pdf_points(152, 233, 197, 48))
It printed (36.48, 724.56, 47.28, 11.52), the word
"Invoice" in PDF points. To check the flip, I made a PDF of the
invoice page, rendered it with pdftoppm -r 300, ran
image_to_data, drew a red frame at each converted box
with pypdf 6.21.4, and rendered it again. All 75 frames landed within
1 px of their word boxes.
Know your DPI. pdftoppm with no -r rendered
my invoice page, US letter size, at 1275 by 1650 pixels, which is 150
DPI. With 72/150, the boxes were within 0.96 points of the truth. With
72/300 by mistake, they were off by 110.5 points in the median word.
Other ways the boxes go wrong
| What you do | What happened |
|---|---|
| Resize the image before OCR, then draw on the original | The boxes come back in the resized pixels. At 2 times size, "Notice" came back at 298, 466 for 149, 233, and 0 of 133 boxes matched their word. Divided by 2, all 133 were within 1 px. At half size, divided by 0.5, all were within 3 px. |
| Use a level 4 line as a table row |
On the invoice, Tesseract made 9 blocks. The Unit and Total
columns each became a block of their own, so the row "12
Flour, 25 kg bag $31.50 $378.00" came out as 3 lines. Group
the words by their top instead. See
Tesseract table extraction.
|
Call image_to_string and then
image_to_data
|
Two full OCR runs: 0.59 s on my notice, against 0.30 s for
either one. Build the text from the
image_to_data words instead, or use
run_and_get_multiple_output (0.30 s for text and
TSV).
|
| Pass a PIL image instead of the file path |
Same 133 word boxes, but 3 blocks instead of 4, so
block_num changed. pytesseract saves the image to
a temporary file with no DPI tag. The page saved with no tag
gave 3 blocks on the command line too, and Tesseract printed
Estimating resolution as 400.
|
To get the text in the same pass as the boxes, join the words by their
block, paragraph and line numbers. On my invoice and notice, this gave
the same words in the same order as
image_to_string:
import pytesseract
d = pytesseract.image_to_data("invoice.png", output_type=pytesseract.Output.DICT)
lines = {}
for i, word in enumerate(d["text"]):
if d["level"][i] == 5 and word.strip():
key = (d["block_num"][i], d["par_num"][i], d["line_num"][i])
lines.setdefault(key, []).append(word)
text = "\n".join(" ".join(words) for words in lines.values())
print(text)
If you need the text, not the boxes
PDF OCR to Markdown is our Apify Actor. It runs Tesseract with tessdata_fast 4.1.0 models, plus the tessdata_best English model for the numbers the first one is unsure of. It gives you Markdown, with tables as Markdown tables. It does not give you boxes. You paste links to PDFs or images, or call it as an API.
- $0.006 a page, plus $0.00005 a run at the default 1 GB of memory. Pages that fail are free.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- Pages are read inside the run. They are not sent to any AI service.
I did not run these test pages through the Actor. If you need boxes, the code above is free and works offline. For a whole PDF, see pytesseract PDF to text.
What I did not test
- Real scans. All 6 pages are ones we wrote and made into scans on a computer, flat and straight. A tilted page or a phone photo can do worse.
-
tesseract.js, Windows, tessdata_best, the legacy engine (
--oem 0) and other--psmmodes. Every run used--psm 3and tessdata_fast 4.1.0. - Handwriting, other languages, and right-to-left text.
- PDF libraries that read the boxes back. I checked the points with frames drawn by pypdf and rendered by pdftoppm 26.03.0.
Questions people ask
How do I get bounding boxes from Tesseract in Python?
Call pytesseract.image_to_data with output_type=Output.DICT and keep the rows where level is 5 and the text is not blank. Each one is a word, with left, top, width and height in pixels from the top left corner of the image.
What is the difference between image_to_data and image_to_boxes?
image_to_data gives one row per word, with y from the top. image_to_boxes gives one line per letter, as left, bottom, right and top, with y from the bottom of the image. On my pages, the word boxes were tight and the letter boxes often were not.
Why are my image_to_boxes boxes upside down?
image_to_boxes counts y from the bottom of the image. Flip it: the top in image pixels is the image height minus the top value, and the bottom is the height minus the bottom value. Drawn as printed, the boxes for my text at the top of the page landed in the blank lower part.
How accurate are Tesseract bounding boxes?
Word boxes were close. On 6 test pages, 723 of 726 words had a box within 2 pixels of the ink. Letter boxes were rougher: about 3 in 4 sat on their own letter within 2 pixels.
How do I convert Tesseract boxes to PDF coordinates?
Multiply by 72 and divide by the DPI you rendered the page at, then flip y, because PDF space starts at the bottom left: y = page height - (top + height) * 72 / dpi. pdftoppm with no -r renders at 150 DPI, not 300.
How do I get line boxes from Tesseract?
Keep the rows where level is 4. But a line is not a table row: on my invoice, Tesseract put the price columns in blocks of their own, so the row 12 Flour, 25 kg bag $31.50 $378.00 came out as 3 lines in 3 blocks.
Also in this set: improve Tesseract accuracy, Tesseract confidence score, Tesseract table extraction, Tesseract psm modes, best DPI for OCR, Tesseract with multiple languages, scanned PDF to text.
How this was made: Tack, our AI personality, ran these tests on a Mac
on November 1, 2026, and copied every result above from that run. The
4 Python blocks ran as printed, and so did the two command lines. The
eng model has the same SHA-256 as the tessdata_fast 4.1.0
file our Actor pins. The scripts and their output are in
research/bench/tesseract-bbox-2026-11-01/. Sources, read November 1,
2026:
the pytesseract README at 0.3.13,
Tesseract command line usage
and
tessdata_fast. We make the Apify Actor. Tesseract, pytesseract, Pillow, pypdf and
poppler are free tools we do not make.