A production-grade Python application that automatically digitizes 2D graphs and scientific charts. Given a chart image (PNG, JPG, etc.), it produces precise (x, y) coordinate datasets — including per-point uncertainty estimates — using a deterministic computer vision pipeline. No machine learning is used for pixel-level work; only Tesseract OCR is used for reading axis labels.
- What It Does
- Architecture Overview
- Project Structure
- Installation & Setup
- Running the Application
- Using the Python API
- The 7-Stage Pipeline — Deep Dive
- Data Types Reference
- Key Algorithms Explained
- Confidence & Quality Metrics
- Error Handling & Warnings
- Contributing Guide
When scientists or engineers publish graphs in papers or reports, the underlying data is rarely available. ChartExtractor solves this by treating the chart image as input and recovering the original numerical data.
Input: A raster image of a 2D chart (line graph, scatter plot, etc.)
Output:
(x, y)coordinate pairs in the same units as the chart's axis labels- Per-point uncertainty
σ_xandσ_y(how precisely we could locate each point) - The detected axis scales (
linear,log10, orln) - Confidence scores for both axes
- Non-fatal warnings (e.g., low-coverage axis, chart rotation)
Capabilities:
- Handles linear and logarithmic (base-10 and natural) axes — auto-detected
- Optional color-based curve isolation for multi-curve charts
- Subpixel precision (typically ±0.1 pixel) using parabolic profile fitting
- Automatic RANSAC-based outlier rejection for noisy OCR labels
- Curvature-adaptive resampling to place more output points at sharp bends
The entire extraction is orchestrated by a single function extract_curve() in pipeline.py. It calls each processing stage in sequence, passing data forward as plain Python dataclasses.
Image (BGR numpy array)
│
▼
┌─────────────────────┐
│ Stage 1 │ preprocess.py
│ Preprocessing │ Denoise, flatten illumination, binarize, detect edges
└────────┬────────────┘
│ gray, binary, edges
▼
┌─────────────────────┐
│ Stage 2 │ axes.py
│ Axis & Tick │ Find x/y axes; detect tick positions at subpixel accuracy
│ Localization │
└────────┬────────────┘
│ AxisFrame (axis pixel positions + tick lists)
▼
┌─────────────────────┐
│ Stage 3a │ ocr.py
│ OCR Label Reading │ Run Tesseract on axis label strips; parse numeric values
└────────┬────────────┘
│ (pixel, value, confidence) tuples for each tick
▼
┌─────────────────────┐
│ Stage 3b │ calibration.py
│ Calibration & │ Fit pixel→data mapping; auto-detect linear/log scale;
│ Scale Detection │ RANSAC outlier rejection
└────────┬────────────┘
│ Calibration object (coefficients + scale kind)
▼
┌─────────────────────┐
│ Stage 4 │ curve.py
│ Curve Isolation & │ Remove axes/gridlines; skeletonize to 1-pixel width;
│ Skeletonization │ extract ordered (u, v) pixel points per column
└────────┬────────────┘
│ (u_pixels, v_subpixel, σ_v uncertainty)
▼
┌─────────────────────┐
│ Stage 5 │ pipeline.py + calibration.py
│ Pixel → Data │ Apply calibration mapping with uncertainty propagation
└────────┬────────────┘
│ (x_data, y_data, σ_x, σ_y)
▼
┌─────────────────────┐
│ Stage 6 │ sampling.py
│ Smoothing │ Savitzky-Golay filter to suppress digitization noise
└────────┬────────────┘
│ smoothed (x, y)
▼
┌─────────────────────┐
│ Stage 7 │ sampling.py
│ Resampling │ Uniform or curvature-adaptive resampling to n points
└────────┬────────────┘
│
▼
ExtractionResult
(PointSeries, Calibration, confidences, warnings, reprojected pixels)
Every stage is a set of pure functions — no global state, no side effects. This makes each stage independently testable and replaceable.
D:\Dev\ChartExtractor\
│
├── app.py # Streamlit web UI — entry point
├── requirements.txt # Python package dependencies
├── Research.txt # Academic references and algorithm notes
│
└── src/
└── chart_extractor/ # Core library (importable package)
├── __init__.py # Public API: exports extract_curve() and types
├── types.py # All dataclass definitions
├── pipeline.py # extract_curve() — orchestrates stages 1–7
├── preprocess.py # Stage 1: image normalization
├── axes.py # Stage 2: axis/tick detection
├── ocr.py # Stage 3a: Tesseract OCR wrapper
├── calibration.py # Stage 3b+5: pixel↔data mapping
├── curve.py # Stage 4: curve isolation and extraction
└── sampling.py # Stages 6+7: smoothing and resampling
Rule of thumb: Every file corresponds to exactly one pipeline stage. When debugging, start by identifying which stage produces wrong output, then open only that file.
Python 3.10+ is required.
Tesseract OCR must be installed as a system binary — it is not a Python package:
- Windows: Download from UB Mannheim Tesseract builds. Install to the default path (
C:\Program Files\Tesseract-OCR\). The code auto-detects this path. - macOS:
brew install tesseract - Linux:
sudo apt install tesseract-ocr
# Create and activate a virtual environment (strongly recommended)
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
# Install all dependencies
pip install -r requirements.txtWhat each package does:
| Package | Version | Role |
|---|---|---|
numpy |
≥1.24 | All array/matrix math; backbone of every stage |
opencv-python |
≥4.8 | Core CV: morphology, Hough, Canny, HSV, Gaussian filter |
scikit-image |
≥0.22 | Zhang-Suen skeletonization (skimage.morphology.skeletonize) |
scipy |
≥1.11 | Savitzky-Golay filter, spline interpolation, peak finding |
pytesseract |
≥0.3.10 | Python wrapper around the Tesseract binary |
Pillow |
≥10.0 | Image I/O (used as fallback by some loaders) |
streamlit |
≥1.32 | Web UI framework |
pandas |
≥2.1 | DataFrame for CSV/JSON export |
Note on opencv-contrib: The guided filter (
cv2.ximgproc.guidedFilter) lives inopencv-contrib-python, notopencv-python. If you installopencv-contrib-python, you get both. If onlyopencv-pythonis installed, the code automatically falls back to bilateral filtering — correct but slightly slower on large images.
streamlit run app.pyThis starts a local web server. Open the URL printed in your terminal (typically http://localhost:8501).
UI walkthrough:
- Upload a chart image using the file uploader (PNG, JPG, BMP, TIF, or WEBP).
- Configure extraction parameters in the left sidebar:
- Output points (
n_points): How many(x, y)pairs to produce (10–10000). More points = smoother output curve. Default: 500. - Adaptive sampling: If enabled, more points are placed at high-curvature regions (sharp bends). If disabled, points are evenly spaced in arc length.
- Smooth window (
smooth_window): Width of the Savitzky-Golay smoothing window (must be odd, 3–51). Larger = smoother but reduces detail. Default: 11. - Use color isolation: Enable if your chart has multiple curves — lets you isolate one curve by color.
- Hue (0–179): OpenCV HSV hue of the curve color. Appears only when color isolation is on.
- Overlay dot size: Visual size of the orange reprojected-point dots drawn on the output image.
- Output points (
- Click "Extract coordinates" — the pipeline runs (typically 1–5 seconds).
- Inspect results:
- Output image with orange dots showing where each extracted point lands
- Quality metrics: number of points, x/y axis confidence, detected scale types
- Warnings if anything looks suspicious
- Data table with
x,y,sigma_x,sigma_ycolumns - Line chart preview
- Download buttons for CSV and JSON
See Section 6 below.
import cv2
from src.chart_extractor import extract_curve
# Load image with OpenCV (BGR format — the pipeline expects BGR, not RGB)
image = cv2.imread("my_chart.png")
# Run extraction with default settings
result = extract_curve(image)
# Or with all parameters specified:
result = extract_curve(
image,
curve_color_hue=120, # Isolate a blue curve (HSV hue 120 ≈ green-blue)
n_output_points=500, # How many (x, y) pairs to output
adaptive_sampling=True, # More points at sharp bends
smooth_window=11, # Savitzky-Golay smoothing window (must be odd)
)
# Access results
print(f"Extracted {len(result.points.x)} points")
print(f"X-axis scale: {result.calibration.x_scale}") # "linear", "log10", or "ln"
print(f"Y-axis scale: {result.calibration.y_scale}")
print(f"X confidence: {result.x_axis_confidence:.2f}")
print(f"Y confidence: {result.y_axis_confidence:.2f}")
# The actual data
x = result.points.x # numpy array of x data values
y = result.points.y # numpy array of y data values
sx = result.points.sigma_x # uncertainty in x (same units as x)
sy = result.points.sigma_y # uncertainty in y (same units as y)
# Any non-fatal warnings
for w in result.warnings:
print(f"Warning: {w}")
# Pixel coordinates of extracted points (for overlay drawing)
# result.reprojected_pixels is an Nx2 array of (u, v) pixel coordsKey import: The public API is src.chart_extractor. Import only from there, not from internal submodules — internal APIs may change.
File: src/chart_extractor/preprocess.py
Goal: Produce a clean binary (black/white) image and a grayscale image from the raw input, while preserving sharp edges and suppressing noise and uneven illumination.
Functions and what they do:
Converts BGR (or BGRA) to 8-bit grayscale. Handles 4-channel images by dropping the alpha channel first.
BGR image → Gray 8-bit image
Removes sensor noise while keeping edge sharpness. Uses a guided filter (O(N) time), which smooths flat regions while leaving transitions intact. Falls back to bilateral filtering if opencv-contrib is not installed.
Why not Gaussian blur? Gaussian blurs edges, which hurts tick detection. Why not median filter? Median is slow and doesn't preserve sub-pixel sharpness as well.
Fixes uneven illumination (common in scanned images or photos). Subtracts a large-radius local median from the image. Anything brighter than its neighborhood becomes bright; anything darker becomes dark. This flattens background gradients.
flat_gray = gray - cv2.medianBlur(gray, radius=large) + 128
Converts grayscale to binary (0 or 255). Uses adaptive thresholding — each pixel's threshold is determined from its local neighborhood (Gaussian-weighted mean), so the same image can have different thresholds in bright and dark regions. This handles uneven ink density or faded prints. Result is inverted so foreground (ink) = 255, background = 0.
Runs Canny edge detection. Thresholds are set automatically from the image histogram:
lo = 0.66 × median_gray_value
hi = 1.33 × median_gray_value
This avoids hard-coded thresholds that fail on high- or low-contrast images.
The entry point called by pipeline.py. Chains all the above and returns a dict:
{
"gray": gray image, # for OCR and subpixel fitting
"denoised": denoised gray, # for guided filter output
"flat": illumination-flat, # for binarization
"binary": binary foreground, # for morphology, axis detection, skeletonization
"edges": Canny edges, # for rotation estimation
}File: src/chart_extractor/axes.py
Goal: Find the x-axis (horizontal line) and y-axis (vertical line) in pixel coordinates, detect tick positions, and return everything at subpixel precision.
This is the most important utility in the file. Given a 1D intensity profile and a coarse peak index, it fits a parabola through 3 neighboring samples and finds the vertex analytically:
Profile values at three adjacent positions: f₋₁, f₀, f₊₁
Parabolic vertex offset: δ = 0.5 × (f₋₁ − f₊₁) / (f₋₁ − 2·f₀ + f₊₁)
Subpixel position: idx + clip(δ, −0.5, +0.5)
This achieves ~0.1 pixel localization accuracy. The clipping prevents wild extrapolation from noisy profiles.
Uses probabilistic Hough line detection on the edge map. Clusters detected line angles around 0° (horizontal) and 90° (vertical). Returns the dominant residual rotation. If |rotation| > 1.5°, a warning is emitted. At large rotations, the columnar curve extraction becomes inaccurate.
Finds the pixel positions of the x and y axis lines:
- Apply morphological opening with elongated horizontal kernel
(w//20, 1)→ isolates long horizontal lines (x-axis candidates). Repeat with(1, h//20)for vertical (y-axis). - Sum along columns (for y-axis detection) and along rows (for x-axis). This gives projection profiles where peaks correspond to continuous horizontal/vertical structures.
- Find the strongest peak in the lower half of the horizontal projection (x-axis) and in the left half of the vertical projection (y-axis) — standard chart layout assumption.
- Apply
_subpixel_peak()to refine to subpixel accuracy. - Compute coverage: what fraction of the axis line is continuously foreground? Lower coverage → lower confidence.
Returns an AxisFrame with x_row (float pixel row) and y_col (float pixel column).
For the x-axis: extract a thin horizontal strip of pixels directly below the x-axis line (~8px tall). Project this strip vertically (sum each column). Peaks in this 1D profile are tick positions. Use scipy.signal.find_peaks with:
heightthreshold (must exceed background noise)distance≈width // 100(minimum spacing between ticks)prominence(tick must stand above its surroundings)
Apply _subpixel_peak() to each detected tick. Repeat symmetrically for y-axis (strip left of y-axis, project horizontally).
Associates OCR-read labels with their nearest tick. For each tick position, finds the closest OCR label centroid within a tolerance (25 pixels). Unmatched ticks get value=None and are marked as uncalibrated.
File: src/chart_extractor/ocr.py
Goal: Read the numeric values printed next to tick marks on both axes.
The code searches standard installation paths for the Tesseract binary across Windows, macOS, and Linux. If found, it sets pytesseract.pytesseract.tesseract_cmd. If not found, OCR calls will raise a clear error.
Converts an OCR string to a float:
- Replaces Unicode minus variants (
−,–,—) with ASCII- - Strips whitespace, removes commas
- Validates with regex:
^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$ - Returns
Noneif string does not match a valid number
This handles: 123, -1.5, 1.2e3, 2.5E-4, 0.001.
Runs Tesseract on a region of the grayscale image:
- Crops to
bbox = (x, y, w, h) - If the region is smaller than ~30px tall, upscales by 2–4× (Tesseract needs minimum font size)
- Runs
pytesseract.image_to_data()with:psm(page segmentation mode): 7 = single line, 6 = block of texttessedit_char_whitelist=0123456789.+-eE— discard any OCR output that isn't a digit or valid numeric character
- Returns list of
(text, confidence, bbox)tuples
Defines a strip below the x-axis (full width of the plot, 40px tall) and calls _ocr_region() with PSM=6. Returns centroids as (label_centre_x, label_centre_y, numeric_value, confidence).
Same but for a strip left of the y-axis (60px wide, full height of plot). OCR text in this region is typically rotated, so the strip is wider to ensure labels are captured.
File: src/chart_extractor/calibration.py
Goal: Turn (tick_pixel, tick_value) pairs into a precise mathematical mapping from any pixel coordinate to its data-space value, and automatically determine whether the scale is linear, log₁₀, or ln.
On a logarithmic axis, the pixel positions of 1, 10, 100 are evenly spaced, but the values are multiplicative. A linear fit on raw values would fail; the correct fit is on log₁₀(value) vs. pixel.
Closed-form least squares: solve A @ [a, b]ᵀ = values where A is the 2-column matrix [pixels, 1s]. Returns (a, b, residual_std).
data_value = a × pixel + b
RANSAC (Random Sample Consensus) robustly fits a line even when some OCR labels are wrong:
- Repeat
n_iter(200) times:- Randomly pick 2 points
- Fit a line through those 2 points
- Count how many remaining points are within
tolof that line (inliers)
- Keep the fit with the most inliers
- Refit using all inliers only
- Tolerance = 2% of data range
If fewer than 2 inliers are ever found, falls back to using all points.
- Extract all
(pixel, value)pairs wherevalue is not None - For each scale candidate (
linear,log10,ln):- Transform values: linear →
v, log10 →log₁₀(v), ln →ln(v) - Run RANSAC on
(pixel, transformed_value) - Compute relative residual:
residual_std / data_range(normalized so large-scale and small-scale axes compare fairly)
- Transform values: linear →
- Pick the scale with the smallest relative residual
- Refit final coefficients on RANSAC inliers for that scale
- Compute confidence:
confidence *= max(0, 1 − min(1, relative_residual × 10))
Calls calibrate_axis() on both axes independently and assembles a Calibration object.
For perspective-distorted images (photos of printed charts). Computes a 3×3 homography matrix using cv2.findHomography with RANSAC. The Calibration object can hold this matrix. Not used in the default pipeline but available for extension.
File: src/chart_extractor/curve.py
Goal: Isolate the curve line from everything else (axes, tick marks, gridlines, labels) and thin it down to a 1-pixel-wide skeleton for precise coordinate extraction.
- Erase axis bands: Zero out a ±2px band around each axis line.
- Isolate gridlines morphologically:
- Horizontal gridlines: opening with kernel
(max(15, w//4), 1)— only structures longer than w//4 survive - Vertical gridlines: opening with kernel
(1, max(15, h//4))
- Horizontal gridlines: opening with kernel
- Subtract extracted gridlines from the binary image → only short structures (curve segments, tick marks) remain.
- Closing operation to repair small gaps where the curve crossed a gridline and was accidentally erased.
Used only when curve_color_hue is provided:
- Convert BGR → HSV
- Create a mask for pixels within ±12 hue units of the target
- Add constraint: saturation ≥ 60, value ≥ 40 (excludes white/grey/black regions)
- Handle red hue wraparound (hues near 0° and near 179° are both "red")
Returns a binary mask where 255 = curve color detected.
Uses scikit-image's Zhang-Suen thinning algorithm to reduce any curve thickness down to exactly 1 pixel. The result is a binary image where connected pixels represent the curve centerline.
Why skeletonization rather than edge detection? Edges produce two parallel lines (both sides of a thick curve). Skeletonization gives one centerline, which is what we want to sample from.
The core curve extraction loop. For each column u from u_min to u_max:
- Find all skeleton pixels in column
uwithin the plot's vertical bounds - If multiple candidates (e.g., noise speckles), pick the one closest to the previous column's y position — continuity heuristic to follow the curve rather than jump to noise
- Apply parabolic subpixel refinement on the inverted grayscale profile at pixel
v:Invert: I = 255 - gray (so dark curve → bright peak) δ = 0.5 × (I[v-1] - I[v+1]) / (I[v-1] - 2·I[v] + I[v+1]) v_sub = v + clip(δ, -0.5, +0.5) - Estimate per-point uncertainty from profile sharpness:
curvature = I[v-1] - 2·I[v] + I[v+1] σ_v = 1 / sqrt(|curvature|) (sharper peak → lower uncertainty)
Returns arrays (u_pixels, v_subpixel, sigma_v).
For non-functional curves (closed loops, curves with multiple y-values at a single x). Walks the skeleton as a connected polyline using depth-first search from an endpoint. Returns an ordered Nx2 array of (u, v) pixel coordinates.
Done in: pipeline.py lines 105–115, using Calibration.pixel_to_data() from types.py lines 51–67.
For each extracted pixel (u, v), apply the calibration mapping:
| Scale | Formula |
|---|---|
linear |
x = ax × u + bx |
log10 |
x = 10^(ax × u + bx) |
ln |
x = e^(ax × u + bx) |
Uncertainty propagation:
The pixel uncertainty σ_v (from profile curvature) propagates to data-space uncertainty σ_y:
| Scale | Uncertainty |
|---|---|
linear |
`σ_y = |
log10 |
`σ_y = ln(10) × y_data × |
ln |
`σ_y = y_data × |
X-axis uncertainty σ_x is fixed at half a pixel in data units: σ_x = 0.5 × |ax|.
File: src/chart_extractor/sampling.py, lines 11–30
Applies a Savitzky-Golay filter to the y values. This filter fits a low-degree polynomial to each local window of points and evaluates it at the center — mathematically equivalent to a weighted moving average but preserving peaks and inflection points (unlike simple moving average which flattens them).
Parameters:
window: Must be odd. Larger values = stronger smoothing but less detail.polyorder: Degree of the local polynomial. Default 3 (cubic).- Auto-reduces window if fewer than
windowdata points exist.
File: src/chart_extractor/sampling.py, lines 33–88
After smoothing, the points are on a per-column grid (one per image column). This does not give uniform arc-length spacing or any particular density distribution. Resampling fixes this.
Computes cumulative arc length along the curve, then places n points uniformly in arc length. The result looks evenly spaced when plotted, regardless of the curve's actual direction.
Used when adaptive_sampling=True. Places more points at high-curvature regions:
- Compute arc-length parameterization
- Fit cubic splines
x(s)andy(s)(wheres= arc length) - Compute curvature
κfrom spline derivatives:κ = |x'·y'' - y'·x''| / (x'² + y'²)^(3/2) - Define a density function combining uniform and curvature terms:
d(s) = (1 - w) + w × κ_normalized(s) (strictly positive) - Integrate to get CDF, invert to draw samples at equal density intervals
The result: flat stretches of the curve get fewer points; sharp bends get more.
File: src/chart_extractor/types.py
@dataclass
class TickLabel:
pixel: float # Subpixel position of tick centre (row for x-axis, col for y-axis)
value: Optional[float] # Numeric value from OCR (None if OCR failed or no label found)
ocr_confidence: float # OCR confidence normalized to [0.0, 1.0]@dataclass
class AxisInfo:
orientation: Literal["x", "y"] # Which axis
pixel_position: float # Subpixel position of the axis line itself
ticks: list[TickLabel] # All ticks detected on this axis
fit_residual: float # Std dev of the calibration line fit
confidence: float # Overall detection confidence [0.0, 1.0]@dataclass
class Calibration:
ax: float # x = ax × u + bx (or similar for log scales)
bx: float
ay: float # y = ay × v + by
by: float
x_scale: ScaleKind # "linear" | "log10" | "ln"
y_scale: ScaleKind
homography: Optional[np.ndarray] # 3×3 perspective rectification (usually None)Key method:
def pixel_to_data(self, u: float, v: float) -> tuple[float, float]:
# Converts pixel (u=col, v=row) to (x_data, y_data)@dataclass
class PointSeries:
x: np.ndarray # Output x coordinates (data units)
y: np.ndarray # Output y coordinates (data units)
sigma_x: Optional[np.ndarray] # Per-point uncertainty in x
sigma_y: Optional[np.ndarray] # Per-point uncertainty in yScaleKind = Literal["linear", "log10", "ln"]@dataclass
class ExtractionResult:
points: PointSeries
calibration: Calibration
x_axis_confidence: float # [0.0, 1.0]
y_axis_confidence: float
warnings: list[str] # Non-fatal issues
reprojected_pixels: np.ndarray # Nx2 array of (u, v) pixel coords for overlayUsed in axis localization and curve extraction to achieve ~0.1 pixel accuracy without iterative optimization.
Idea: Given a discrete intensity profile with a peak at index i, assume the true peak lies between i-1 and i+1. Fit a parabola through the three samples and find its vertex analytically:
f(i-1) = a(i-1)² + b(i-1) + c
f(i) = ai² + bi + c
f(i+1) = a(i+1)² + b(i+1) + c
Vertex at: i* = i - (f(i+1) - f(i-1)) / (2 × (f(i+1) - 2f(i) + f(i-1)))
This is clamped to ±0.5 pixels to handle cases where the profile is flat or noisy.
When OCR reads axis labels, some readings will be wrong (misread digits, partial text). RANSAC handles this:
Repeat 200 times:
1. Randomly pick 2 (pixel, value) pairs
2. Fit a line through them
3. Count how many OTHER pairs agree with this line (within 2% tolerance)
4. Remember the line with the most agreeing pairs (inliers)
Final fit: least-squares over all inliers of the best line
If even 1 in 3 OCR readings is wrong, RANSAC still recovers the correct calibration.
OpenCV's morphological opening with an elongated structuring element is a powerful way to selectively keep or remove image structures by length:
- Kernel
(1, h//4)= vertical bar, heighth//4 - Opening with this kernel: only vertical structures longer than
h//4survive - Opening with
(w//4, 1): only horizontal structures longer thanw//4survive
This is how axes and long gridlines are separated from the curve (which has no single long straight segment when viewed as horizontal or vertical lines).
A simple moving average blurs everything, including peaks. Savitzky-Golay fits a polynomial locally and evaluates it — this preserves the shape of peaks and inflections while still suppressing high-frequency noise. For digitized curves where the peak position and height matter, Savitzky-Golay is the correct choice.
Computed in axes.py and updated in calibration.py:
coverage = fraction of axis line that is continuous foreground pixels
(gaps indicate the axis is partially invisible or merged with background)
relative_residual = calibration_std / data_range
(how well OCR labels align with a line fit)
confidence = coverage × max(0, 1 − min(1, relative_residual × 10))
A confidence of 1.0 means: full axis coverage + perfect calibration fit. A confidence below 0.4 triggers a warning.
| Confidence | Interpretation |
|---|---|
| 0.8–1.0 | High quality; all labels read correctly, full axis coverage |
| 0.5–0.8 | Acceptable; some OCR noise but RANSAC corrected it |
| 0.3–0.5 | Low; inspect visually, check for chart rotation or faint axes |
| < 0.3 | Very low; results may be unreliable |
| Condition | Where | Message |
|---|---|---|
| Fewer than 2 ticks with valid OCR labels on either axis | pipeline.py:73–76 |
"Insufficient calibration points" |
| No curve pixels found after isolation | pipeline.py:101–102 |
"No curve points recovered" |
| Condition | Threshold |
|---|---|
| Chart rotation detected | |rotation| > 1.5° |
| Axis coverage too low | coverage < 0.4 |
| Too few calibration ticks | < 2 ticks with value |
| Low OCR confidence | Average confidence < 0.5 |
Warnings do not stop extraction. The result may still be usable — inspect the overlay image and confidence scores.
-
Read
types.pyfirst. All data flows through the dataclasses defined there. UnderstandingAxisInfo,Calibration, andPointSeriesis the prerequisite for understanding any other file. -
Read
pipeline.pysecond. It is short (~150 lines) and shows exactly how the stages connect. Each function call inextract_curve()is one stage. -
Then read the stage file you want to modify. Each file is self-contained and ~80–180 lines.
- Pure functions: Every processing function takes explicit inputs and returns explicit outputs. Do not read from or write to global state. Do not mutate inputs.
- No ML for pixel work: The project uses classical CV (morphology, Hough, thresholding, etc.) deliberately. Tesseract is the only ML/statistical model allowed, and only for text recognition.
- Subpixel accuracy: Whenever you detect a spatial location (tick, axis line, curve point), apply parabolic subpixel refinement. Never return integer pixel coordinates as a final answer.
- Robustness first: Prefer algorithms that degrade gracefully (RANSAC, adaptive thresholds) over algorithms that require tuning per image.
Example: adding support for axis grid detection and suppression of dotted grids
- Identify which stage it belongs to. Dotted grid removal belongs in Stage 4 (curve.py), specifically in
remove_axes_and_grid(). - Write the logic as a new function in that file. Test it in isolation with a few images before wiring it in.
- Add it to the
remove_axes_and_grid()call chain. - If the feature needs a new parameter, add it to
extract_curve()inpipeline.pyand thread it through. Update the Streamlit UI inapp.pyto expose it. - Add the new parameter to the public API docstring in
__init__.pyif applicable.
If extraction produces wrong results:
- Check warnings first.
result.warningstells you if confidence is low. - Look at the overlay image. The orange dots should track the curve. If they don't, the issue is in Stage 4 (curve isolation) or Stage 2 (axis localization).
- Check axis confidence. Low x-axis confidence → tick detection or OCR failed → check Stage 2 or Stage 3a.
- Add intermediate output. In
pipeline.py, temporarily save intermediate images withcv2.imwrite():cv2.imwrite("debug_binary.png", prep["binary"]) cv2.imwrite("debug_skeleton.png", skeleton * 255)
- Isolate the stage. Call individual functions directly from a test script rather than running the full pipeline.
- Python 3.10+ type hints throughout
- Dataclasses for all structured data (no dicts as function return values)
- No global variables
- Line length ≤ 100 characters
- Comments only when the why is non-obvious — well-named variables document the what