Recover analytical formulas from charts in PDF files - deterministic, no neural networks (C++, MuPDF, Ceres, OpenCV/Tesseract)
0
stars
1
commits
C++
primary language
Sep 13, 2026
updated
Finds charts in PDF files and recovers the analytical formula of every curve on them.
No neural networks anywhere: the whole pipeline is deterministic, reproducible and explainable — every number in the output can be traced back to a specific geometric feature of the page.
$ analyze_pdf paper.pdf
Page 1 — source: vector PDF graphics
Chart detected, confidence 0.96.
X axis: "X", linear scale, range 0…10, 6 ticks, calibration R² 1.0000
Y axis: "Y", linear scale, range 0…50, 6 ticks, calibration R² 1.0000
Series 1 "linear A" (line, blue, 200 points), X ∈ [0; 10], Y ∈ [0.9868; 20.99]
FORMULA: y = 2·x + 0.9868
model "linear", R² = 1.00000, RMSE = 5.774e-13, 2 params
Series 2 "quad B" (line, red, 200 points), X ∈ [0; 10], Y ∈ [-0.01318; 49.99]
FORMULA: y = 0.5·x^2 + 0.0002635·x - 0.009103
model "parabola", R² = 1.00000, RMSE = 0.002764, 3 params
Series 3 "sine C" (line, green, 200 points), X ∈ [0; 10], Y ∈ [16.99; 32.98]
FORMULA: y = 7.993·sin(0.8·x - 0.0007325) + 24.99
model "sine", R² = 1.00000, RMSE = 0.007426, 4 params
Text extracted from the PDF (axis titles, curve labels) is of course reproduced in whatever language the document uses.
log10(value));
this matters more than it sounds, because a straight line on a semi-log axis is an
exponential, and without detecting the scale the formula comes out meaningless.Two independent front-ends feed step 3, chosen automatically:
src/vector.cpp) — the main path. In a PDF a chart is stored as paths and
text, so curve coordinates are read out of the file exactly, with no computer vision.
Accuracy: fractions of a percent.src/raster.cpp) — for scans and embedded images. Axes are found by
morphological opening with a long kernel, labels are read with Tesseract, the curve is
isolated by saturation/hue (for black curves: dark pixels minus long straight lines,
i.e. minus grid and frame), then a per-column median gives the trace. Measured accuracy
on the test scan: ≈ 0.3 % of the range.When several curves share a chart, each series gets its own label:
Matching is one-to-one and greedy by increasing cost. Text runs already consumed as axis numbers, axis titles or the chart title are excluded from the candidates. Vector branch only — see Limitations.
11 models: polynomials of degree 1–5, exponential, power, logarithm, sine, logistic,
Gaussian, hyperbola, square root. Each gets a meaningful initial guess (log-linearisation
for exponential and power, FFT peak plus mean-level crossing count for the sine, half-maximum
position for the logistic) — with p0 = {1,1,1} almost nothing converges.
The winner is not the maximum R². By R² a high-degree polynomial always wins, because it eats the noise and the discretisation error. The rules, in order:
On synthetic data (11 dependency types × 2 noise levels) this rule scores 22/22.
Dependencies (Ubuntu 24.04):
apt-get install cmake ninja-build pkg-config \
libmupdf-dev mupdf-tools libeigen3-dev libceres-dev \
libgflags-dev libgoogle-glog-dev \
libfreetype-dev libjpeg-dev libjbig2dec0-dev libopenjp2-7-dev \
libharfbuzz-dev libgumbo-dev libmujs-dev \
libopencv-dev libtesseract-dev tesseract-ocr tesseract-ocr-rus
tesseract-ocr-rus is only needed to read Cyrillic axis titles; everything else works
without it.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
Produces build/analyze_pdf.
./build/analyze_pdf chart.pdf # human-readable report
./build/analyze_pdf chart.pdf --json # machine-readable
./build/analyze_pdf scan.pdf --csv out/ # also dump curve points as CSV
./build/analyze_pdf chart.pdf --force raster # force the CV path
./build/analyze_pdf chart.pdf --force vector # force the vector path
./build/analyze_pdf scan.pdf --dpi 300 # render resolution for the raster path
Tesseract prints its own diagnostics to stderr; stdout stays clean, so --json can be piped
directly into a parser.
| Path | Role | Libraries |
|---|---|---|
src/calib.cpp | number parsing, axis calibration, log scale, minus-sign recovery | Eigen |
src/pdf_backend.cpp | MuPDF wrapper: paths, text runs, page rendering | MuPDF |
src/vector.cpp | axes, ticks, series, labels, "is this a chart" score | — |
src/raster.cpp | CV + OCR path | OpenCV, Tesseract |
src/fit.cpp | model library, initial guesses, parsimony/AICc selection | Eigen, Ceres |
src/report.cpp | report text, vector→raster fallback orchestration | — |
src/main.cpp | CLI | — |
python-reference/ | the original Python implementation this was ported from (docs in Russian) | — |
pdf_backend.hpp and raster.hpp are the only places that know about MuPDF and
OpenCV/Tesseract respectively; the rest of the code works with their plain structs
(PageContent, RawPath, TextSpan, PdfDocument::Raster).
reference/ holds the fixture PDFs plus two recorded outputs:
expected_cpp.txt — what this implementation prints on all nine fixtures. Regenerate and
diff it to catch regressions.expected.txt — the original Python implementation's output, in Russian. Kept for
provenance; useful for comparing numbers, not text.for f in exp sin logy scatter_parabola power_en no_chart raster_exp multi_text multi_legend; do
echo "########## $f.pdf"; ./build/analyze_pdf reference/$f.pdf 2>/dev/null; echo
done > /tmp/out.txt
diff /tmp/out.txt reference/expected_cpp.txt && echo "no regressions"
| File | Ground truth | Expected result |
|---|---|---|
exp.pdf | y = 2e^{0.5x} − 1 | exponential, R² = 1.0 |
sin.pdf | y = 4sin(1.3x + 0.4) + 2 | sine, R² ≈ 1.0 |
logy.pdf | y = 5e^{0.8x}, log Y axis | Y axis = log, exponential |
scatter_parabola.pdf | y = 3x² − 2x + 7, noise σ=2 | parabola, R² ≈ 0.994 |
power_en.pdf | y = 1.7x^{2.3} | power (NOT a polynomial) |
raster_exp.pdf | same as exp, but rasterised | raster path, R² ≈ 0.99999 |
no_chart.pdf | text and a table | no chart detected, score 0 |
multi_text.pdf | 3 curves labelled A/B/C next to each | 3 series, labels attached |
multi_legend.pdf | same 3 curves, labelled by legend | 3 series, labels via swatch colour |
y(x)
formula is meaningless for them; the report flags the X-ambiguity.tesseract-ocr-rus.−4 −2 0 2 4 extracts as 4 2 0 2 4. Handled by testing "first/last k labels are
negative" hypotheses and keeping the best R².Both cost real debugging time and are not obvious from the MuPDF docs.
fz_bound_page / fz_run_page already
hand you a page space whose origin is top-left with y growing downwards — unlike the raw
coordinates inside fz_path, which fz_path_walker sees before the ctm is applied.
The transform you pass should therefore be a pure shift,
fz_make_matrix(1,0,0,1,-x0,-y0); adding a flip mirrors the whole page.fill_path + stroke_path for the same path. The PDF operator B
(fill and stroke) reaches an fz_device as two separate callbacks with the same
fz_path* and the same ctm. PyMuPDF's get_drawings() reports this as a single object
(type: "fs", with fill and color together). Without merging them, the axes frame is
counted twice and the grid-line/tick statistics in the detection score come out inflated.scipy.optimize.curve_fit. The winning model's formula and
R² match the reference byte-for-byte almost everywhere; 2nd/3rd place in the "alternatives"
list occasionally differs, because on deliberately bad models (a Gaussian fitted over a
sine) Ceres converges to a different local optimum than scipy's LM. This never changed the
winner in testing.--force vector really means vector-only. In the original, force was only branched
on for "raster"; "vector" did not disable the automatic raster fallback, which
contradicted its own CLI help.reference/expected_cpp.txt baseline.AGPL-3.0-or-later — see LICENSE.
This is dictated by the dependency on MuPDF, which is AGPL (or a paid commercial licence
from Artifex). Everything else here — Eigen (MPL2), Ceres (BSD), OpenCV (Apache-2.0),
Tesseract (Apache-2.0) — is compatible with a more permissive licence. If you need one,
replace the MuPDF backend with PDFium (BSD): the PDF-specific code is confined to
src/pdf_backend.cpp behind the interface in include/plotparse/pdf_backend.hpp.
1 commits
Hacker News (1)
C++
67.5%
Python
31.6%
Recover analytical formulas from charts in PDF files - deterministic, no neural networks (C++, MuPDF, Ceres, OpenCV/Tesseract)
0
stars
1
commits
C++
primary language
Sep 13, 2026
updated
Finds charts in PDF files and recovers the analytical formula of every curve on them.
No neural networks anywhere: the whole pipeline is deterministic, reproducible and explainable — every number in the output can be traced back to a specific geometric feature of the page.
$ analyze_pdf paper.pdf
Page 1 — source: vector PDF graphics
Chart detected, confidence 0.96.
X axis: "X", linear scale, range 0…10, 6 ticks, calibration R² 1.0000
Y axis: "Y", linear scale, range 0…50, 6 ticks, calibration R² 1.0000
Series 1 "linear A" (line, blue, 200 points), X ∈ [0; 10], Y ∈ [0.9868; 20.99]
FORMULA: y = 2·x + 0.9868
model "linear", R² = 1.00000, RMSE = 5.774e-13, 2 params
Series 2 "quad B" (line, red, 200 points), X ∈ [0; 10], Y ∈ [-0.01318; 49.99]
FORMULA: y = 0.5·x^2 + 0.0002635·x - 0.009103
model "parabola", R² = 1.00000, RMSE = 0.002764, 3 params
Series 3 "sine C" (line, green, 200 points), X ∈ [0; 10], Y ∈ [16.99; 32.98]
FORMULA: y = 7.993·sin(0.8·x - 0.0007325) + 24.99
model "sine", R² = 1.00000, RMSE = 0.007426, 4 params
Text extracted from the PDF (axis titles, curve labels) is of course reproduced in whatever language the document uses.
log10(value));
this matters more than it sounds, because a straight line on a semi-log axis is an
exponential, and without detecting the scale the formula comes out meaningless.Two independent front-ends feed step 3, chosen automatically:
src/vector.cpp) — the main path. In a PDF a chart is stored as paths and
text, so curve coordinates are read out of the file exactly, with no computer vision.
Accuracy: fractions of a percent.src/raster.cpp) — for scans and embedded images. Axes are found by
morphological opening with a long kernel, labels are read with Tesseract, the curve is
isolated by saturation/hue (for black curves: dark pixels minus long straight lines,
i.e. minus grid and frame), then a per-column median gives the trace. Measured accuracy
on the test scan: ≈ 0.3 % of the range.When several curves share a chart, each series gets its own label:
Matching is one-to-one and greedy by increasing cost. Text runs already consumed as axis numbers, axis titles or the chart title are excluded from the candidates. Vector branch only — see Limitations.
11 models: polynomials of degree 1–5, exponential, power, logarithm, sine, logistic,
Gaussian, hyperbola, square root. Each gets a meaningful initial guess (log-linearisation
for exponential and power, FFT peak plus mean-level crossing count for the sine, half-maximum
position for the logistic) — with p0 = {1,1,1} almost nothing converges.
The winner is not the maximum R². By R² a high-degree polynomial always wins, because it eats the noise and the discretisation error. The rules, in order:
On synthetic data (11 dependency types × 2 noise levels) this rule scores 22/22.
Dependencies (Ubuntu 24.04):
apt-get install cmake ninja-build pkg-config \
libmupdf-dev mupdf-tools libeigen3-dev libceres-dev \
libgflags-dev libgoogle-glog-dev \
libfreetype-dev libjpeg-dev libjbig2dec0-dev libopenjp2-7-dev \
libharfbuzz-dev libgumbo-dev libmujs-dev \
libopencv-dev libtesseract-dev tesseract-ocr tesseract-ocr-rus
tesseract-ocr-rus is only needed to read Cyrillic axis titles; everything else works
without it.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
Produces build/analyze_pdf.
./build/analyze_pdf chart.pdf # human-readable report
./build/analyze_pdf chart.pdf --json # machine-readable
./build/analyze_pdf scan.pdf --csv out/ # also dump curve points as CSV
./build/analyze_pdf chart.pdf --force raster # force the CV path
./build/analyze_pdf chart.pdf --force vector # force the vector path
./build/analyze_pdf scan.pdf --dpi 300 # render resolution for the raster path
Tesseract prints its own diagnostics to stderr; stdout stays clean, so --json can be piped
directly into a parser.
| Path | Role | Libraries |
|---|---|---|
src/calib.cpp | number parsing, axis calibration, log scale, minus-sign recovery | Eigen |
src/pdf_backend.cpp | MuPDF wrapper: paths, text runs, page rendering | MuPDF |
src/vector.cpp | axes, ticks, series, labels, "is this a chart" score | — |
src/raster.cpp | CV + OCR path | OpenCV, Tesseract |
src/fit.cpp | model library, initial guesses, parsimony/AICc selection | Eigen, Ceres |
src/report.cpp | report text, vector→raster fallback orchestration | — |
src/main.cpp | CLI | — |
python-reference/ | the original Python implementation this was ported from (docs in Russian) | — |
pdf_backend.hpp and raster.hpp are the only places that know about MuPDF and
OpenCV/Tesseract respectively; the rest of the code works with their plain structs
(PageContent, RawPath, TextSpan, PdfDocument::Raster).
reference/ holds the fixture PDFs plus two recorded outputs:
expected_cpp.txt — what this implementation prints on all nine fixtures. Regenerate and
diff it to catch regressions.expected.txt — the original Python implementation's output, in Russian. Kept for
provenance; useful for comparing numbers, not text.for f in exp sin logy scatter_parabola power_en no_chart raster_exp multi_text multi_legend; do
echo "########## $f.pdf"; ./build/analyze_pdf reference/$f.pdf 2>/dev/null; echo
done > /tmp/out.txt
diff /tmp/out.txt reference/expected_cpp.txt && echo "no regressions"
| File | Ground truth | Expected result |
|---|---|---|
exp.pdf | y = 2e^{0.5x} − 1 | exponential, R² = 1.0 |
sin.pdf | y = 4sin(1.3x + 0.4) + 2 | sine, R² ≈ 1.0 |
logy.pdf | y = 5e^{0.8x}, log Y axis | Y axis = log, exponential |
scatter_parabola.pdf | y = 3x² − 2x + 7, noise σ=2 | parabola, R² ≈ 0.994 |
power_en.pdf | y = 1.7x^{2.3} | power (NOT a polynomial) |
raster_exp.pdf | same as exp, but rasterised | raster path, R² ≈ 0.99999 |
no_chart.pdf | text and a table | no chart detected, score 0 |
multi_text.pdf | 3 curves labelled A/B/C next to each | 3 series, labels attached |
multi_legend.pdf | same 3 curves, labelled by legend | 3 series, labels via swatch colour |
y(x)
formula is meaningless for them; the report flags the X-ambiguity.tesseract-ocr-rus.−4 −2 0 2 4 extracts as 4 2 0 2 4. Handled by testing "first/last k labels are
negative" hypotheses and keeping the best R².Both cost real debugging time and are not obvious from the MuPDF docs.
fz_bound_page / fz_run_page already
hand you a page space whose origin is top-left with y growing downwards — unlike the raw
coordinates inside fz_path, which fz_path_walker sees before the ctm is applied.
The transform you pass should therefore be a pure shift,
fz_make_matrix(1,0,0,1,-x0,-y0); adding a flip mirrors the whole page.fill_path + stroke_path for the same path. The PDF operator B
(fill and stroke) reaches an fz_device as two separate callbacks with the same
fz_path* and the same ctm. PyMuPDF's get_drawings() reports this as a single object
(type: "fs", with fill and color together). Without merging them, the axes frame is
counted twice and the grid-line/tick statistics in the detection score come out inflated.scipy.optimize.curve_fit. The winning model's formula and
R² match the reference byte-for-byte almost everywhere; 2nd/3rd place in the "alternatives"
list occasionally differs, because on deliberately bad models (a Gaussian fitted over a
sine) Ceres converges to a different local optimum than scipy's LM. This never changed the
winner in testing.--force vector really means vector-only. In the original, force was only branched
on for "raster"; "vector" did not disable the automatic raster fallback, which
contradicted its own CLI help.reference/expected_cpp.txt baseline.AGPL-3.0-or-later — see LICENSE.
This is dictated by the dependency on MuPDF, which is AGPL (or a paid commercial licence
from Artifex). Everything else here — Eigen (MPL2), Ceres (BSD), OpenCV (Apache-2.0),
Tesseract (Apache-2.0) — is compatible with a more permissive licence. If you need one,
replace the MuPDF backend with PDFium (BSD): the PDF-specific code is confined to
src/pdf_backend.cpp behind the interface in include/plotparse/pdf_backend.hpp.
Hacker News (1)
1 commits
C++
67.5%
Python
31.6%