-
Notifications
You must be signed in to change notification settings - Fork 0
docs: add bench/ and docs/evaluations/, with the ICDAR 2013 result #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| # Benchmarks | ||
|
|
||
| Accuracy benchmarks against public datasets with published ground truth. | ||
|
|
||
| These are **not** `go test` benchmarks (those live next to the code as | ||
| `*_bench_test.go` and measure speed). These measure **correctness** against | ||
| an external reference, and they are deliberately outside the library's Go | ||
| module so their dependencies never reach a consumer of `pdftable`. | ||
|
|
||
| Datasets are **not committed** — they are large and separately licensed. | ||
| Each harness downloads what it needs into a scratch directory. | ||
|
|
||
| | Benchmark | Dataset | Measures | Report | | ||
| | --- | --- | --- | --- | | ||
| | [`icdar2013/`](icdar2013/) | ICDAR 2013 Table Competition (125 PDFs) | table detection + structure | [2026-08-02](../docs/evaluations/2026-08-02-icdar2013-table-structure.md) | | ||
|
|
||
| ## Why the numbers live in `docs/evaluations/` | ||
|
|
||
| A benchmark result is only meaningful with its date, the code version it | ||
| ran against, and the caveats on the metric. A bare number in a README rots | ||
| the moment either side changes. Every run gets a dated report; the table | ||
| above links to the most recent. | ||
|
|
||
| ## Adding a benchmark | ||
|
|
||
| 1. `bench/<name>/` with its own `go.mod` if it needs Go. | ||
| 2. A `run.py` that fetches the dataset and prints a result table. | ||
| 3. A dated report in `docs/evaluations/`, and a row in the table above. | ||
|
|
||
| State the metric precisely, and say what it is **not** comparable to. | ||
| Published table-extraction scores in particular are usually | ||
| structure-only — the system is handed the table location — and are not | ||
| comparable to an end-to-end number. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| // Separate module on purpose: the benchmark harness must never add a | ||
| // dependency to the library it measures. Nested modules are excluded from | ||
| // the parent's ./... , so go build ./... at the repo root ignores this | ||
| // entirely and a consumer of pdftable never fetches it. | ||
| module github.com/hallelx2/pdftable/bench | ||
|
|
||
| go 1.25.0 | ||
|
|
||
| require github.com/hallelx2/pdftable v0.0.0 | ||
|
|
||
| replace github.com/hallelx2/pdftable => ../ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # ICDAR 2013 Table Competition benchmark | ||
|
|
||
| Measures **table detection + structure recognition** end to end against | ||
| 125 born-digital PDFs from EU and US government sources, with per-cell | ||
| ground truth. | ||
|
|
||
| ```sh | ||
| pip install pdfplumber | ||
| python bench/icdar2013/run.py # full run, ~10 min | ||
| python bench/icdar2013/run.py --limit 5 # quick check while iterating | ||
| python bench/icdar2013/run.py --diag # detection vs structure breakdown | ||
| ``` | ||
|
|
||
| The dataset (~12 MB) downloads to `~/.cache/pdftable-bench` (override with | ||
| `PDFTABLE_BENCH_DIR`). It is not committed. | ||
|
|
||
| ## Metric: adjacency relations | ||
|
|
||
| From Göbel et al., the metric the competition itself used. For every | ||
| non-empty cell, take its nearest non-empty neighbour to the right and | ||
| below; each pair is a relation `(text_a, text_b, direction)`. Score the | ||
| detected multiset against ground truth. | ||
|
|
||
| Cell-by-cell grid comparison would be the obvious alternative and is | ||
| worse: two tools can grid the same table differently — one emits a spacer | ||
| column, the other does not — and still convey identical structure. A grid | ||
| diff calls that a failure. Adjacency asks the question a reader actually | ||
| cares about: *is this value next to that label?* It is unforgiving about | ||
| genuinely wrong structure and forgiving about harmless disagreements. | ||
|
|
||
| Documents are scored whole, with relations unioned across pages and | ||
| tables. That sidesteps matching detected tables to ground-truth tables, a | ||
| step which would need its own arbitrary thresholds and would make the | ||
| number depend on them. | ||
|
|
||
| ## Reading the result | ||
|
|
||
| **This is an end-to-end number and is NOT comparable to published ICDAR | ||
| 2013 scores of 0.85–0.95.** Those evaluate structure recognition with the | ||
| table region *given*; this has to find the table first. Compare runs of | ||
| this harness to each other, and to the pdfplumber column, not to papers. | ||
|
|
||
| `--diag` splits the two failure modes, which need opposite work: | ||
|
|
||
| - **the table was never detected** — a detection problem | ||
| - **it was detected but gridded wrongly** — a structure problem | ||
|
|
||
| Latest results: [`docs/evaluations/`](../../docs/evaluations/). | ||
|
|
||
| ## Files | ||
|
|
||
| | | | | ||
| | --- | --- | | ||
| | `run.py` | fetches the dataset, builds the extractor, runs everything | | ||
| | `extract.go` | dumps every table pdftable finds as JSON; built as its own module so the benchmark adds no dependency to the library | | ||
| | `score.py` | parses ground-truth XML, computes precision/recall/F1 | | ||
| | `diag.py` | separates detection failures from structure failures | | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,86 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Separate the two ways table extraction can fail on ICDAR 2013. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Low recall has two very different causes and the headline F1 cannot tell | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| them apart: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (a) the table was never detected at all — a detection problem | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (b) it was detected but gridded wrongly — a structure problem | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (a) is fixed by better "is this a table?" logic; (b) by better cell | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| geometry. They need opposite work, so it is worth knowing which one we | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| have before touching anything. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import subprocess | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from collections import Counter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from score import gt_relations, norm, relations_from_grid, score # noqa: E402 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def main() -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| root, exe = sys.argv[1], sys.argv[2] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pairs = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for dirpath, _, files in os.walk(root): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for f in sorted(files): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if f.endswith("-str.xml"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pdf = os.path.join(dirpath, f.replace("-str.xml", ".pdf")) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if os.path.exists(pdf): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pairs.append((pdf, os.path.join(dirpath, f))) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pairs.sort() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| zero_detect = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gt_regions_total = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| detected_tables_total = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pages_total = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| per_doc = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for pdf, xml in pairs: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gt = gt_relations(xml) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| out = subprocess.run( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [exe, "-strategy", "lines", pdf], capture_output=True, timeout=120 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).stdout | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tables = json.loads(out or b"[]") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| detected_tables_total += len(tables) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rels: Counter = Counter() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for t in tables: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rels += relations_from_grid([[norm(c) for c in r] for r in t["rows"]]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| c, nd, ng = score(gt, rels) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if nd == 0: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| zero_detect += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gt_regions_total += 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| per_doc.append((os.path.basename(pdf), ng, nd, c, len(tables))) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+43
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win One slow or failing PDF aborts the entire diagnostic run. Unlike 🔧 Proposed fix for pdf, xml in pairs:
gt = gt_relations(xml)
- out = subprocess.run(
- [exe, "-strategy", "lines", pdf], capture_output=True, timeout=120
- ).stdout
- tables = json.loads(out or b"[]")
+ try:
+ out = subprocess.run(
+ [exe, "-strategy", "lines", pdf], capture_output=True, timeout=120
+ ).stdout
+ tables = json.loads(out or b"[]")
+ except Exception as e:
+ print(f"warning: extraction failed for {pdf}: {e}", file=sys.stderr)
+ tables = []
detected_tables_total += len(tables)📝 Committable suggestion
Suggested change
🧰 Tools🪛 ast-grep (0.45.0)[error] 44-46: Command coming from incoming request (subprocess-from-request) 🪛 Ruff (0.16.0)[error] 45-45: (S603) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(f"documents : {len(pairs)}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(f"documents where we found NO table: {zero_detect}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f" ({100*zero_detect/len(pairs):.0f}%)") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(f"tables detected (total) : {detected_tables_total}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Guard against division by zero when no documents are found. If 🛡️ Proposed fix pairs.sort()
+ if not pairs:
+ print(f"no documents found under {root}")
+ return 1
+
zero_detect = 0🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Restrict to documents where we DID detect something: if structure is | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # good, precision AND recall should both be high on this subset. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tot = [0, 0, 0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for _, ng, nd, c, _ in per_doc: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if nd: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tot[0] += c | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tot[1] += nd | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tot[2] += ng | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| p = tot[0] / tot[1] if tot[1] else 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| r = tot[0] / tot[2] if tot[2] else 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f = 2 * p * r / (p + r) if (p + r) else 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print("--- documents where a table WAS detected ---") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(f" precision {p:.3f} recall {r:.3f} F1 {f:.3f}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print("worst 8 documents by missed relations:") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for name, ng, nd, c, nt in sorted(per_doc, key=lambda x: x[1] - x[3])[-8:]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print(f" {name:<18} gt={ng:<6} detected={nd:<6} correct={c:<6} tables={nt}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sys.exit(main()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // Emit every table pdftable finds in a PDF, as JSON, for benchmarking. | ||
| package main | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "flag" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/hallelx2/pdftable" | ||
| ) | ||
|
|
||
| type tableOut struct { | ||
| Page int `json:"page"` | ||
| Rows [][]string `json:"rows"` | ||
| } | ||
|
|
||
| func main() { | ||
| strategy := flag.String("strategy", "lines", "lines | text | fallback") | ||
| merge := flag.Bool("merge", false, "TableSettings.MergeSplitTokens") | ||
| flag.Parse() | ||
|
|
||
| doc, err := pdftable.OpenFile(flag.Arg(0)) | ||
| if err != nil { | ||
| // A file we cannot open scores zero rather than aborting the run; | ||
| // the harness needs a result for every document. | ||
| json.NewEncoder(os.Stdout).Encode([]tableOut{}) | ||
| fmt.Fprintln(os.Stderr, "open:", err) | ||
| return | ||
| } | ||
| defer doc.Close() | ||
|
|
||
| mk := func(v, h pdftable.TableStrategy) pdftable.TableSettings { | ||
| s := pdftable.DefaultTableSettings() | ||
| s.VerticalStrategy, s.HorizontalStrategy = v, h | ||
| s.MergeSplitTokens = *merge | ||
| return s | ||
| } | ||
| lines := mk(pdftable.StrategyLines, pdftable.StrategyLines) | ||
| text := mk(pdftable.StrategyText, pdftable.StrategyText) | ||
|
|
||
| var attempts []pdftable.TableSettings | ||
| switch *strategy { | ||
| case "lines": | ||
| attempts = []pdftable.TableSettings{lines} | ||
| case "text": | ||
| attempts = []pdftable.TableSettings{text} | ||
| default: // fallback: ruled cells first, whitespace alignment if none | ||
| attempts = []pdftable.TableSettings{lines, text} | ||
| } | ||
|
|
||
| out := []tableOut{} | ||
| for i := 1; i <= doc.NumPages(); i++ { | ||
| p, err := doc.Page(i) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| for _, s := range attempts { | ||
| tables, err := p.ExtractTables(s) | ||
| if err != nil || len(tables) == 0 { | ||
| continue | ||
| } | ||
| for _, t := range tables { | ||
| out = append(out, tableOut{Page: i, Rows: t.Rows}) | ||
| } | ||
| break | ||
| } | ||
| } | ||
| json.NewEncoder(os.Stdout).Encode(out) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: hallelx2/pdftable
Length of output: 288
🏁 Script executed:
Repository: hallelx2/pdftable
Length of output: 39482
Pin
pdfplumberfor the ICDAR2013 benchmark.The ICDAR2013 evaluation reports the reference oracle as
pdfplumber 0.11.9, butpip install pdfplumbercan install another version. Pin the dependency in the README/run instructions or document a lockfile so later runs remain comparable.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents