All projects

Agent P-DF

A table extractor for scanned documents. It finds tables, rebuilds rows and columns, reads the text, and opens the result in an editor before export.

Context
STP MACHATHON 7.0, table extraction competition (1st place)
Role
Sole developer: fine-tuning, the label-repair tool, the pipeline and the studio
Stack
PyTorch, Table Transformer, PaddleOCR v5 (ONNX), FastAPI, React
Year
2026
Outcome
1st place; runs on a CPU; Docker build for Hugging Face Spaces
Mohamed Sameh smiling in front of a fountain and palm trees, holding a large board that reads: Machathon 7.0, table extraction competition, congratulations to Agent P-DF for getting the 1st place, prize 8000.

Scanned page in, editable table out.

Agent P-DF finds tables in scanned documents and PDFs, rebuilds their rows, columns and merged cells, reads the text, and opens the result in a split-screen editor. You fix what the models got wrong, then export JSON, CSV, HTML or Excel.

I built it for STP MACHATHON 7.0, a table extraction competition, where it took first place. The rule behind it: the models will sometimes be wrong, so the editor has to be exact. Models propose geometry, plain code turns geometry and OCR text into cells, and the studio handles what is left.

The project in 35 seconds, built from its own data: the boxes, the OCR and the cells are the real extraction of one page, and the studio is a screen recording.
A 48-second screen recording with no sound: the landing page, an upload, the five processing phases, then the studio with a table selected.

Three parts, each checkable alone.

A page goes through two Table Transformer (TATR) models. One finds the tables; the other finds the rows, columns, headers and merged cells inside each one. PaddleOCR v5 models, run through ONNX Runtime, find and read the text. Python code then assigns every piece of text to a cell and builds the table.

Keeping the parts separate meant I could measure each one and replace the one that failed. I fine-tuned the two TATR models; I used the PaddleOCR models as released, because retraining them was not needed.

Agent P-DF: from page image to editable table A page image or PDF goes to a Table Transformer that finds tables, then to a second Table Transformer that finds rows, columns, headers and merged cells. The table crop also goes to PaddleOCR text detection and then text recognition, which produce text boxes with coordinates and recognised text. Python code assigns each text box to a cell, rebuilds rows and applies header rules to give an editable grid. The grid opens in the studio, which exports JSON, CSV, HTML or XLSX; a dashed edge shows the studio re-running structure recognition or text recognition on a region the user draws. From page image to editable table Three parts that can each be checked alone, then an editor for what is left. Find the structure (PyTorch) Page image or PDF up to 30 MB TATR table detection finds each table table region TATR structure recognition reads the grid inside it Read the text (ONNX Runtime) PaddleOCR v5 detection finds the text lines text boxes with coordinates PaddleOCR v5 recognition reads each line table crop Build the table (Python) Assign text to cells IoA, IoU and centre test Row rebuild, header rules full-width text marks a header Editable grid cells, spans and text recognised text tables, rows, columns, headers, merged cells Studio edit cells, re-run a region export JSON, CSV, HTML, XLSX re-run on a drawn region
Whole pipeline: about 60M parameters, runs on a CPU. Solid arrows are the normal path. The dashed arrows are the studio re-running a step on a region the user draws.

Making training data from nothing.

Public table datasets are clean, and real scans are not. Before training anything, I built a dataset generator. It wrote tables and table-like pages from pools of scientific and financial words, many fonts, colours, sizes and table types, compiled them with LaTeX and rasterised them with PyMuPDF. It produced 16,000 pixel-precise images, with exact labels for both detection and structure.

They were too perfect: crisp, high-DPI renders that no real scan looks like, so I lowered the DPI to match real pages. The part that lasted is the adversarial synthesizer. It generates pages that look like tables but are not, in four tiers from a table of contents to a grid of sub-figures, and saves each one with an empty label file. A model trained only on real tables learns to say yes to every grid; these pages teach it what plain background is. In the final detection run, 100 of them went into the mix beside the public sets and the noisy Phase 3 pages.

Agent P-DF: the adversarial document synthesizer Word pools, font and style profiles and a cache of non-table figures feed a tier weighting engine. It dispatches four tiers of pages that look like tables but are not: 12 percent sparse grid-like pages such as tables of contents, 23 percent two- or three-column lists, 38 percent pages heavy with rules, and 27 percent sub-figure grids and comparisons. Each page is written into a LaTeX template, compiled with pdfTeX or XeTeX to a PDF and rasterised with PyMuPDF at the same DPI as the real data. Every image is saved with an empty label file, which teaches the detector pure background, then split into training and validation sets for the TATR table detector. The adversarial document synthesizer It makes pages that look like tables and are not, each saved with an empty label file. 1 Sourcing Word pools scientific, financial Fonts and styles sizes, colours, rules Figure cache charts and diagrams that are not tables Tier weights 2 Four tiers of non-tables Sparse grid-like tables of contents, indexes 12% Table-looking lists two or three columns 23% Rules, no table ruled multi-column text 38% Adversarial nightmare sub-figure grids 27% 3 Render LaTeX template pdfTeX / XeTeX PDF PyMuPDF, same DPI 4 Into training page.png looks like a table + page.txt empty: no boxes at all Train / val split TATR table detector An empty label file means “nothing here is a table”, which is how the detector learns pure background.
The synthesizer, as documented in the project's notes: sourcing, a weighted tier dispatcher, LaTeX rendering and empty labels.
Deck slide: high-quality datasets were too beautiful; the model needed hard negatives with intentionally empty label files. A table of four tiers with sample thumbnails: 12% sparse grid-like structures such as tables of contents with dotfills, 23% two- or three-column lists, 38% heavy rules without tabular meaning, 27% grid-arranged sub-figures and side-by-side comparisons.
The four tiers, from the competition deck, with a sample of each.

A detector that can say "not a table".

My first detector was YOLO. It drew boxes around repeated words instead of following the page layout, and its GPL licence did not fit a product I wanted to ship. I moved to the Table Transformer family.

Trained on clean public tables, the TATR detector scored AP 0.93 but also drew tables on flowcharts and multi-column text. I added Phase 3, a noisy set of 422 real pages, and generated hard negatives: pages that look like tables but are not, such as tables of contents, figure grids and ruled text. The score dropped to AP 0.89. I kept that model because the deck's comparison rates its false positives on real pages as low instead of high. I did not measure a false-positive rate.

The final run trained for 40 epochs on 5,272 images and validated on 932. Loss fell from 0.61 to 0.16, and the last epoch scored AP 0.896 and AP50 0.971. I froze most of the ResNet backbone and trained its last stage at a lower learning rate than the transformer, so the pretrained model was adjusted, not overwritten.

The dataset was the bug.

The structure model started at AP 0.477 (AP50 0.658) on my training data. I looked at the pages. On more than 60 images of the Phase 3 set, the "projected row header" label sat on almost every cell. Column headers were missing and merged cells were inconsistent.

A quick test confirmed it: dropping that one class from the training config lifted AP50 from about 0.75 to about 0.91 at once. Then I wrote an editor for the annotations and bulk-deleted 5,353 wrong boxes in one pass, on pages 82 to 154. With the class dropped and the wrong boxes gone, the same TATR model reached AP50 of about 0.95 and AP75 of about 0.90.

I did not change the architecture. I changed the data.

How to read these numbers. AP is COCO box AP on the validation split of my own training mix, and the two runs used different data mixes, so the pair shows a direction and not a controlled experiment. A later run saved in the repo notebook ended at AP 0.785 and AP50 0.939.

Letting code decide what a header is.

The TATR decoder predicts at most 125 objects per table. My diagnosis was that large tables ran out of those queries when the model also had to guess which rows were projected headers. I removed that class so the model only outputs a dense grid of rows and columns, then added a rule: if OCR finds one text block that spans the table's full width, that row is a projected header.

The pattern repeats through the project. The model finds geometry, and plain code handles the part where the answer has to be predictable.

Slide from the project deck: a perspective drawing of table cells from the structure model above, raw OCR text pieces below, joined by lines. Underneath: the scoring equation, the acceptance thresholds, the fallback rule and the row rebuild step.
The deck's illustration: cells from the structure model on top, raw OCR text below, joined by the scoring rule.

Putting each word in a cell.

Matching each OCR box to the nearest cell centre fails when text crosses a boundary or a cell spans columns. So each box is scored against every cell with IoA + 0.20 × IoU + 0.35 × (centre inside). IoA is the overlap as a share of the text box's area; IoU is overlap over union.

I accept a match at IoA 0.35 or IoU 0.02. Otherwise the box falls back to the nearest cell centre, within a distance limit. When the model's rows disagree with the text, a rebuild step places row boundaries in the gaps between text lines.

All of this is deterministic, so a wrong cell can be traced to the rule that put the text there.

Fixing the last errors fast.

The studio shows the page and the table side by side. The video above is a walkthrough. It is a single-process FastAPI service with a React front end, and on a CPU it runs two jobs at a time. A health page shows uptime, success rate and p50 and p95 latency.

View
The page and the table side by side, with overlays for the detected grid, the OCR boxes or a confidence heat map.
Correct
Edit cell text; merge, unmerge, add or delete cells; find and replace. Edits save after a short pause, with undo and redo.
Re-run
Draw a box to run structure recognition and OCR again on just that region.
Export
JSON, CSV, HTML or Excel. In Excel each table is its own sheet and merged cells stay merged.
Five training charts over 27 epochs for TableFormerV2: total loss, sequence loss, bounding box loss, token accuracy and bounding box IoU. Validation box IoU rises from about 0.44 to about 0.79.
TableFormerV2 side experiment, 27 epochs. The last panel is validation cell-box IoU.

What I tried and did not ship.

TableFormerV2 has 52M parameters. Its training code expects clean, text-annotated boxes, and my Phase 3 data was noisy. I fine-tuned it anyway as an experiment: the vision parts frozen, 26.4M of the 52.2M parameters trained, one head for structure tokens and one for cell boxes.

Validation cell-box IoU rose from 0.44 to about 0.79 over 27 epochs. That is the mean overlap of predicted cell boxes with the true ones when the correct tokens are fed in, so it is not an end-to-end score. TATR stayed the main pipeline, and TableFormerV2 is not wired into the shipped code.

The notebook in the repo is a shorter, weaker run (20 epochs, IoU 0.38 to 0.45). The 0.79 figure comes from the 27-epoch curve shown here.

Read the deck for the long version.