Digitize plots from phone photos of conference slides back into SVG + CSV.
A photo of a projected slide is perspective-distorted, noisy, and color-washed.
figdig rectifies the slide, finds the plot axes, separates multiple traces
(even where they cross, are dashed, or are occluded by scatter points), extracts
scatter data, calibrates pixels → data coordinates from the tick marks, and
exports a clean vector figure plus the underlying numbers.
# 1. Rectify + detect plots/ticks; writes a starter config + annotated images
uv run figdig inspect photo.jpg -o out/
# 2. Edit out/config.yaml: set each axis type (linear / log / normalized)
# and type in the tick values printed on the figure
# (x ticks left→right, y ticks top→bottom).
# 3. Extract and export
uv run figdig process photo.jpg -c out/config.yaml -o out/Without a config, process still runs and exports everything in normalized
0–1 axis coordinates.
| File | Content |
|---|---|
<plot>.svg |
vector figure: axes, ticks, traces, scatter points, legend |
data/<plot>_<trace>.csv |
trace polylines in data coordinates |
data/<plot>_points.csv |
scatter points in data coordinates |
verification_overlay.jpg |
extraction drawn over the rectified photo |
fidelity_misses.jpg |
red = ink not represented in the export |
report.json |
fidelity metrics + axis-fit residuals |
Automatic extraction from a photo can never be blindly trusted, so figdig
measures its own fidelity instead of assuming it:
- Axis-fit residuals — the pixel→data mapping is least-squares fitted over all detected tick marks; the RMS/max residual (in px) is reported. A bad tick or a wrong tick-value list shows up immediately as a large residual.
- Ink recall / precision — the fraction of plot ink covered by the
export, and the fraction of exported geometry backed by real ink.
processwarns when either drops below 90%. fidelity_misses.jpg— every uncovered ink pixel painted red, so you can see at a glance what was left out (usually in-plot label text, which is intentionally not exported as data).verification_overlay.jpg— the extracted vectors on top of the photo for direct visual comparison.
Always look at the two verification images before reusing the data.
- Rectify (
rectify.py) — the slide is found as the largest bright quadrilateral (Otsu + contour), perspective-warped flat, and denoised with an edge-preserving bilateral filter. - Plot/tick detection (
plotbox.py) — long horizontal/vertical ink lines are isolated morphologically; their connected structures give the axes boxes (works for full frames and L-shaped axes). Tick marks are read from thin bands just outside the left/bottom edges. - Extraction (
extract.py) —- scatter dots via distance-transform peaks, validated by roundness, chroma uniformity and a ring test (≤ 2 line exits), so dots sitting on a curve are still found and curve crossings are not mistaken for dots;
- label text removed by grouping small glyph components into words (dash sequences are told apart by axis alignment, absence of holes and unbranched skeletons; scatter-dot clusters by circularity);
- all remaining ink is skeletonized into a chain graph; each chain's color is averaged along its core pixels (robust against washed-out projector colors);
- chains are stitched through junctions (crossing traces) and across gaps (dashes, occlusions) by tangent continuity + color similarity;
- finished paths are grouped into named traces by color; fragments of one trace severed by crossings are re-joined by mutual endpoint-tangent matching (with a guarded apex case for curve peaks);
- each path is smoothed with corner-preserving B-splines (deviation bounded to ≤ 2.5 px of the raw skeleton) and exported as cubic Béziers.
- Labels (
labels.py+ocr.py, macOS) — in-plot text labels are located from the removed-text regions plus any ink no trace explains, de-rotated via PCA orientation, read with Apple's Vision OCR (rotated and flipped fallbacks), and rendered as SVG<text>at the original position/rotation/color/size. Ink that was read as a label counts as exported in the fidelity metrics.label_fixes: {"lons": "Ions"}in the config corrects OCR quirks;labels: falsedisables the stage. - Calibration (
calibrate.py) — linear / log10 / normalized axis fits from tick positions + user-supplied tick values, with residual reporting. - Export (
svgout.py) — SVG (with optional per-trace display names and color overrides from the config), CSVs, overlay, misses image, report.
plots: # matched to detected boxes, reading order
- name: my_plot
title: "Figure title"
x_axis:
label: Year
type: linear # linear | log | normalized
ticks: [1900, 1910, ...] # values printed at the ticks, left→right
# ticks_px: [...] # optional: override detected tick pixel positions
y_axis:
type: log
ticks: [1e13, 1e11, ...] # top→bottom
points_name: "measured data"
traces: # optional: rename / recolor auto-named traces
darkred: {name: "Integrated Circuit", color: "#8b1a1a"}
blue: "Vacuum Tube" # plain string = rename only
options: # optional extraction tuning
detect_points: true
min_points: 8 # fewer detected dots than this → no scatter set
dot_min_r: 3.2 # px, dot radius range
dot_max_r: 12.0
gap_max: 28.0 # px, max dash/occlusion gap to bridge
color_merge: 6.5 # Lab ΔE for merging paths into one trace
trace_gap_max: 0.1 # fraction of plot diagonal: max same-trace rejoin gap
smooth_sigma: 1.4 # px, spline smoothing (0 = polyline output)
smooth_max_dev: 2.5 # px, max smoothing deviation from raw skeleton
simplify_eps: 1.2 # px, polyline simplification (smooth_sigma: 0)examples/slide.jpg + examples/slide_config.yaml reproduce the two plots
of the bundled SiQEW-2025 slide ("122 years of Moore's law" and a hype-cycle
sketch): 4 + 7 traces, 56 scatter points, 98% ink recall / 100% precision on
the quantitative plot, axis-fit residuals ≈ 1.4 px (x) / 0.7 px (y).
- Tick values are typed in by the user (tiny tick labels in phone photos are not reliably OCR-able); calibration correctness is then verified via the fit residuals rather than guessed.
- Label text overlapping a curve can locally distort the skeleton; check the overlay.
- Traces drawn in the same color that also cross each other may end up in one trace group (they are still separate paths in the SVG/CSV).
- Dotted (not dashed) lines would be picked up as scatter points.