Run OCR on a scanned table and you get the words back. You do not get the table
back, and the difference is where the bugs live.
Here is a scanned page:
Item Jan Feb Mar
Widgets 12 31
Gadgets 44 51
Doohickeys 7 9
Here is what a normal OCR pass returns:
Item Jan Feb Mar Widgets 12 31 Gadgets 44 51 Doohickeys 7 9
Is 31 a February number or a March number? There is no way to tell any more.
The blank cells left no trace, so every value after the first gap has quietly
shifted one column left. Nothing errored. Nothing looked wrong. The numbers are
all still there, in the wrong places.
The bit that is worse than that
I hit something nastier while testing this. On a page I had deliberately
roughed up with scan noise, Tesseract read a printed 7 as the letter Z,
with confidence 0.
Any sensible pipeline drops confidence-0 junk. Mine did too. And the moment it
did, that cell became empty — indistinguishable from a cell that genuinely had
nothing in it.
So the output said "no charge in January" when the truth was "the January
figure was too blurry to read".
That is not a rounding error. On an invoice or a bank statement, those are
different facts, and only one of them is a reason to call someone.
The fix is not a better filter. The fix is to stop throwing away the position:
{
"rows": [["Doohickeys", null, "9", null]],
"emptyCellCount": 1,
"unreadableCellCount": 1,
"unreadableCells": [[0, 1]]
}
Both cells are null, because in both cases you have no value. But [0, 1]
says there was ink here and I failed, and the other null says this was
blank. The caller can decide what to do with each. That is the whole idea.
I ended up packaging this as an Apify Actor —
scanned-pdf-ocr
(Python)
— but the technique below is the interesting part and you can build it yourself
in an afternoon.
Keep the coordinates
Tesseract will hand you word boxes if you ask for TSV instead of text:
tesseract page.png stdout --psm 6 -c preserve_interword_spaces=1 tsv
You get one row per word with left, top, width, height, conf. That
geometry is the only place the table structure still exists, so everything from
here is clustering.
Rows. Words sitting on the same baseline belong together. Median glyph
height is a good yardstick:
height = statistics.median(w.h for w in words)
rows = [[ordered[0]]]
for word in ordered[1:]:
if abs(word.middle - rows[-1][-1].middle) <= height * 0.7:
rows[-1].append(word)
else:
rows.append([word])
Cells. Inside a row, split wherever the gap is wider than a word space —
about 1.4 × the glyph height works across most scans.
Columns. Now the part people get wrong. Do not infer columns from every
row. A title line or a paragraph will happily drag a column out of alignment.
Only let rows that already look tabular vote:
anchors = sorted(
cell["x"] for cells in row_cells if len(cells) >= min_columns
for cell in cells
)
Cluster those anchors, take the median of each cluster, and you have your column
positions. Assign each cell to the nearest one. Any column with no cell in a
given row is null — and that is how empty cells survive.
Refusing to invent a table
Run a column finder over a page of prose and it will cheerfully carve the
sentences into a dozen ragged columns and hand you a "table".
I know because mine did. On a scanned 1960s government memo it produced a
confident 26 × 14 grid with 296 empty cells — 81% holes. It looked like
data. It was a paragraph.
Two cheap guards fixed it:
marked = filled + len(unreadable)
if capacity and marked / capacity < min_filled: # mostly holes
return None
if marked and filled / marked < 0.5: # mostly unreadable
return None
A real table is mostly full. And if most of the ink on a page defeated the OCR,
returning a tidy-looking grid with the values missing is worse than returning
nothing and saying so.
The thing that actually made it usable
I want to flag this because I nearly fixed the wrong problem.
My first real run timed out. Three pages, five minutes, dead. The obvious move
was to drop the render resolution from 300 DPI to 200 and take the accuracy
hit.
The obvious move was wrong. I doubled the container memory from 1 GB to 2 GB
and the same job finished in 46 seconds. A 300 DPI page is tens of megabytes
as a bitmap before Tesseract even looks at it; at 1 GB the process spends its
life swapping.
If your OCR is slow, check memory before you touch DPI. Six times faster, same
image quality, one config line.
What this still cannot do
Being honest about the edges, because a table extractor that overpromises is
exactly the problem I started with:
- Rotated and vertically-written tables are not handled.
- Cells merged across rows get reported in the first row they occupy.
- Photographs of pages taken at an angle read badly. Scan flat.
- OCR is a guess. Clean 300 DPI print reads at high confidence; faint photocopies and dot-matrix print do not, and no amount of clustering fixes a page you cannot read.
That last point is the reason for the confidence score on every row and the
unreadableCells list. The tool's job is not to be certain. It is to be clear
about where it isn't.
If your PDF already has a text layer
Do not OCR it. The text is already in the file, exact and free, and running
recognition over it only introduces mistakes that were not there. Check first:
import pdfplumber
with pdfplumber.open(path) as pdf:
chars = sum(len((p.extract_text() or "").strip()) for p in pdf.pages)
If that comes back with a real number, read the text layer and skip the OCR
entirely. (I do the same column-geometry trick on born-digital PDFs in
pdf-table-extractor,
where the word coordinates come from the file instead of from Tesseract.)
Judge it by density, not presence — plenty of scans carry a stamped header or a
single recognised word and nothing else, and "has some text" will send those
down the wrong path.
The general lesson, if there is one: when a pipeline discards something, ask
what the discard looks like downstream. A dropped word and an empty cell are
the same shape in the output. They are not the same fact.
Top comments (0)