Tools,

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 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.

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

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.