trace-digitiser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trace_digitiser/__init__.py +222 -0
- trace_digitiser/calibration.py +283 -0
- trace_digitiser/cli.py +123 -0
- trace_digitiser/diagnostics.py +144 -0
- trace_digitiser/digitise.py +85 -0
- trace_digitiser/geometry.py +61 -0
- trace_digitiser/io.py +74 -0
- trace_digitiser/line_detection.py +182 -0
- trace_digitiser/models.py +148 -0
- trace_digitiser/ocr.py +240 -0
- trace_digitiser/panel_detection.py +816 -0
- trace_digitiser/summarise.py +68 -0
- trace_digitiser/synthetic.py +206 -0
- trace_digitiser/trace_detection.py +337 -0
- trace_digitiser/x_calibration.py +228 -0
- trace_digitiser-0.1.0.dist-info/METADATA +176 -0
- trace_digitiser-0.1.0.dist-info/RECORD +20 -0
- trace_digitiser-0.1.0.dist-info/WHEEL +5 -0
- trace_digitiser-0.1.0.dist-info/entry_points.txt +2 -0
- trace_digitiser-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""trace_digitiser — template-free digitisation of raster scientific line plots.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from trace_digitiser import digitise
|
|
6
|
+
|
|
7
|
+
result = digitise(
|
|
8
|
+
"figure.jpg",
|
|
9
|
+
layout_mode="stacked",
|
|
10
|
+
expected_rows=2,
|
|
11
|
+
expected_cols=1,
|
|
12
|
+
output_dir="outputs",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
print(result.trace_data.head())
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from .calibration import propagate_y_calibration_across_rows, robust_y_calibration
|
|
26
|
+
from .diagnostics import draw_digitised_trace, draw_panel_overlay, draw_trace_mask, print_panel_summary
|
|
27
|
+
from .digitise import digitise_trace_mask
|
|
28
|
+
from .io import build_panel_metadata, load_image, save_outputs
|
|
29
|
+
from .models import Calibration, DigitiserResult, Panel, Trace, XLabel
|
|
30
|
+
from .ocr import detect_x_labels
|
|
31
|
+
from .panel_detection import find_plot_panels
|
|
32
|
+
from .summarise import summarise_by_detected_labels
|
|
33
|
+
from .trace_detection import detect_trace_masks
|
|
34
|
+
from .x_calibration import calibrate_x_axis
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"digitise",
|
|
38
|
+
"Calibration",
|
|
39
|
+
"DigitiserResult",
|
|
40
|
+
"Panel",
|
|
41
|
+
"Trace",
|
|
42
|
+
"XLabel",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def digitise(
|
|
47
|
+
image_path: str | Path,
|
|
48
|
+
*,
|
|
49
|
+
layout_mode: str = "auto",
|
|
50
|
+
expected_rows: Optional[int] = None,
|
|
51
|
+
expected_cols: Optional[int] = None,
|
|
52
|
+
expected_panels: Optional[int] = None,
|
|
53
|
+
output_dir: Optional[str | Path] = None,
|
|
54
|
+
output_prefix: Optional[str] = None,
|
|
55
|
+
show_debug: bool = False,
|
|
56
|
+
save_diagnostics: bool = False,
|
|
57
|
+
) -> DigitiserResult:
|
|
58
|
+
"""End-to-end chart digitisation pipeline.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
image_path : str or Path
|
|
63
|
+
Path to the input raster image.
|
|
64
|
+
layout_mode : str
|
|
65
|
+
``"auto"``, ``"single"``, ``"stacked"``, ``"horizontal"``, or
|
|
66
|
+
``"grid"``.
|
|
67
|
+
expected_rows, expected_cols, expected_panels : int, optional
|
|
68
|
+
Layout hints that constrain panel selection.
|
|
69
|
+
output_dir : str or Path, optional
|
|
70
|
+
Directory for CSV outputs. Defaults to current directory.
|
|
71
|
+
output_prefix : str, optional
|
|
72
|
+
Prefix for output filenames. Defaults to the image stem.
|
|
73
|
+
show_debug : bool
|
|
74
|
+
If True, display inline diagnostic plots (for interactive use).
|
|
75
|
+
save_diagnostics : bool
|
|
76
|
+
If True, write diagnostic overlay PNGs to *output_dir*.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
DigitiserResult
|
|
81
|
+
Structured result with panels, traces, DataFrames, and paths.
|
|
82
|
+
"""
|
|
83
|
+
image_path = Path(image_path)
|
|
84
|
+
rgb = load_image(image_path)
|
|
85
|
+
|
|
86
|
+
if output_prefix is None:
|
|
87
|
+
output_prefix = image_path.stem
|
|
88
|
+
|
|
89
|
+
diag_dir: Optional[Path] = None
|
|
90
|
+
if save_diagnostics:
|
|
91
|
+
diag_dir = Path(output_dir or ".") / "diagnostics"
|
|
92
|
+
diag_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
if show_debug:
|
|
95
|
+
print("Processing:", image_path)
|
|
96
|
+
print("Image size:", rgb.shape[1], "×", rgb.shape[0])
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# 1. Detect panels
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
panels, h_lines, v_lines = find_plot_panels(
|
|
102
|
+
rgb,
|
|
103
|
+
layout_mode=layout_mode,
|
|
104
|
+
expected_rows=expected_rows,
|
|
105
|
+
expected_cols=expected_cols,
|
|
106
|
+
expected_panels=expected_panels,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if show_debug or save_diagnostics:
|
|
110
|
+
print_panel_summary(panels, h_lines, v_lines, layout_mode)
|
|
111
|
+
draw_panel_overlay(rgb, panels, h_lines, v_lines, output_dir=diag_dir, show=show_debug)
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# 2. Y-axis calibration
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
for p in panels:
|
|
117
|
+
calib = robust_y_calibration(rgb, p, verbose=show_debug)
|
|
118
|
+
p["y_calibration"] = calib.to_dict()
|
|
119
|
+
|
|
120
|
+
panels = propagate_y_calibration_across_rows(panels, verbose=show_debug)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# 2b. X-axis calibration (numeric x ticks)
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
for p in panels:
|
|
126
|
+
x_cal = calibrate_x_axis(rgb, p, verbose=show_debug)
|
|
127
|
+
if x_cal is not None:
|
|
128
|
+
p["x_calibration"] = x_cal
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
# 3. Trace detection and digitisation
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
all_trace_frames: list[pd.DataFrame] = []
|
|
134
|
+
trace_debug: list[tuple[dict, dict]] = []
|
|
135
|
+
|
|
136
|
+
for p in panels:
|
|
137
|
+
masks = detect_trace_masks(rgb, p)
|
|
138
|
+
if show_debug:
|
|
139
|
+
print(f"Panel {p['panel_id']}: detected {len(masks)} coloured trace(s)")
|
|
140
|
+
|
|
141
|
+
for tr in masks:
|
|
142
|
+
if show_debug:
|
|
143
|
+
print(" ", {k: v for k, v in tr.items() if k != "mask"})
|
|
144
|
+
all_trace_frames.append(digitise_trace_mask(p, tr))
|
|
145
|
+
trace_debug.append((p, tr))
|
|
146
|
+
|
|
147
|
+
trace_data = pd.concat(all_trace_frames, ignore_index=True) if all_trace_frames else pd.DataFrame()
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# 4. X-label OCR
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
for p in panels:
|
|
153
|
+
p["x_labels"] = detect_x_labels(rgb, p)
|
|
154
|
+
if show_debug:
|
|
155
|
+
print(f"Panel {p['panel_id']} x labels:")
|
|
156
|
+
for lab in p["x_labels"]:
|
|
157
|
+
print(f" {lab['text']:>8s} x={lab['x']:.1f} conf={lab['conf']:.1f}")
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# 5. Interval summaries
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
summary_by_label = summarise_by_detected_labels(trace_data, panels)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# 6. Diagnostics
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
if show_debug or save_diagnostics:
|
|
168
|
+
for p, tr in trace_debug:
|
|
169
|
+
draw_trace_mask(rgb, p, tr, output_dir=diag_dir, show=show_debug)
|
|
170
|
+
|
|
171
|
+
if not trace_data.empty:
|
|
172
|
+
for (panel_id, trace_id), _ in trace_data.groupby(["panel_id", "trace_id"]):
|
|
173
|
+
draw_digitised_trace(trace_data, panel_id, trace_id, output_dir=diag_dir, show=show_debug)
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# 7. Save outputs
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
panel_metadata = build_panel_metadata(panels)
|
|
179
|
+
trace_csv, meta_csv, summary_csv = save_outputs(
|
|
180
|
+
trace_data, panel_metadata, summary_by_label, output_prefix, output_dir
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if show_debug:
|
|
184
|
+
print("Wrote:")
|
|
185
|
+
print(" -", trace_csv)
|
|
186
|
+
print(" -", meta_csv)
|
|
187
|
+
if summary_csv:
|
|
188
|
+
print(" -", summary_csv)
|
|
189
|
+
else:
|
|
190
|
+
print(" - no label summary; fewer than two labels detected")
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# 8. Build structured result
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
return DigitiserResult(
|
|
196
|
+
image_path=image_path,
|
|
197
|
+
rgb=rgb,
|
|
198
|
+
panels=[
|
|
199
|
+
Panel(
|
|
200
|
+
panel_id=p["panel_id"],
|
|
201
|
+
x0=p["x0"],
|
|
202
|
+
x1=p["x1"],
|
|
203
|
+
y_top=p["y_top"],
|
|
204
|
+
y_bottom=p["y_bottom"],
|
|
205
|
+
gridline_y=p["gridline_y"],
|
|
206
|
+
source=p["source"],
|
|
207
|
+
score=p["score"],
|
|
208
|
+
layout_mode=p.get("layout_mode", layout_mode),
|
|
209
|
+
calibration=Calibration(**p["y_calibration"]) if "y_calibration" in p else None,
|
|
210
|
+
x_labels=[
|
|
211
|
+
XLabel(**lab) for lab in p.get("x_labels", [])
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
for p in panels
|
|
215
|
+
],
|
|
216
|
+
trace_data=trace_data,
|
|
217
|
+
summary_by_label=summary_by_label,
|
|
218
|
+
panel_metadata=panel_metadata,
|
|
219
|
+
trace_csv_path=trace_csv,
|
|
220
|
+
summary_csv_path=summary_csv,
|
|
221
|
+
metadata_csv_path=meta_csv,
|
|
222
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Y-axis calibration: OCR-based linear fitting and cross-panel propagation.
|
|
2
|
+
|
|
3
|
+
The calibration model is::
|
|
4
|
+
|
|
5
|
+
y_value = a * y_pixel + b
|
|
6
|
+
|
|
7
|
+
Because image y-coordinates increase downward, normal scientific axes
|
|
8
|
+
will have a negative ``a``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .models import Calibration
|
|
16
|
+
from .ocr import ocr_number_near_y
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ===================================================================
|
|
20
|
+
# Orientation guard
|
|
21
|
+
# ===================================================================
|
|
22
|
+
|
|
23
|
+
def orient_calibration_upward(a: float, b: float, panel: dict) -> tuple[float, float, bool]:
|
|
24
|
+
"""Ensure the calibration maps the top of the panel to a higher value.
|
|
25
|
+
|
|
26
|
+
Returns ``(a, b, was_flipped)``.
|
|
27
|
+
"""
|
|
28
|
+
top_y = panel["y_top"]
|
|
29
|
+
bottom_y = panel["y_bottom"]
|
|
30
|
+
top_val = a * top_y + b
|
|
31
|
+
bottom_val = a * bottom_y + b
|
|
32
|
+
|
|
33
|
+
if top_val >= bottom_val:
|
|
34
|
+
return float(a), float(b), False
|
|
35
|
+
|
|
36
|
+
new_top_val = max(top_val, bottom_val)
|
|
37
|
+
new_bottom_val = min(top_val, bottom_val)
|
|
38
|
+
new_a = (new_bottom_val - new_top_val) / max(1, bottom_y - top_y)
|
|
39
|
+
new_b = new_top_val - new_a * top_y
|
|
40
|
+
return float(new_a), float(new_b), True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ===================================================================
|
|
44
|
+
# Robust y-axis calibration
|
|
45
|
+
# ===================================================================
|
|
46
|
+
|
|
47
|
+
def robust_y_calibration(
|
|
48
|
+
rgb: np.ndarray,
|
|
49
|
+
panel: dict,
|
|
50
|
+
verbose: bool = False,
|
|
51
|
+
) -> Calibration:
|
|
52
|
+
"""Fit a linear y-pixel → y-value mapping for one panel.
|
|
53
|
+
|
|
54
|
+
Steps:
|
|
55
|
+
|
|
56
|
+
1. OCR numbers near all detected gridlines.
|
|
57
|
+
2. Try candidate line fits between OCR pairs (linear and log).
|
|
58
|
+
3. Score fits by number of inlier gridlines.
|
|
59
|
+
4. Refit from inliers if possible.
|
|
60
|
+
5. Enforce upward orientation.
|
|
61
|
+
6. Fall back to normalised 0–1 scale if OCR is insufficient.
|
|
62
|
+
|
|
63
|
+
Log-scale detection: if the OCR'd values are all positive and
|
|
64
|
+
better explained by ``log10(y_value) = a * y_pixel + b``, the
|
|
65
|
+
calibration uses ``scale_type = "log_ocr_value"``.
|
|
66
|
+
"""
|
|
67
|
+
y_lines = panel["gridline_y"]
|
|
68
|
+
ocr: dict[int, list[float]] = {int(y): ocr_number_near_y(rgb, panel, int(y)) for y in y_lines}
|
|
69
|
+
|
|
70
|
+
pairs: list[tuple[float, float]] = []
|
|
71
|
+
for y, vals in ocr.items():
|
|
72
|
+
for v in vals:
|
|
73
|
+
if abs(v) < 10_000:
|
|
74
|
+
pairs.append((float(y), float(v)))
|
|
75
|
+
|
|
76
|
+
best: dict | None = None
|
|
77
|
+
best_log: dict | None = None
|
|
78
|
+
|
|
79
|
+
if len(pairs) >= 2:
|
|
80
|
+
for i in range(len(pairs)):
|
|
81
|
+
y1, v1 = pairs[i]
|
|
82
|
+
for j in range(i + 1, len(pairs)):
|
|
83
|
+
y2, v2 = pairs[j]
|
|
84
|
+
if abs(y2 - y1) < 20 or abs(v2 - v1) < 1:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# --- Linear fit ---
|
|
88
|
+
a = (v2 - v1) / (y2 - y1)
|
|
89
|
+
b = v1 - a * y1
|
|
90
|
+
|
|
91
|
+
# Normal y-axis: values increase upward → a < 0.
|
|
92
|
+
if a < 0:
|
|
93
|
+
inliers: list[tuple[float, float]] = []
|
|
94
|
+
score = 0
|
|
95
|
+
total_error = 0.0
|
|
96
|
+
|
|
97
|
+
for yy, vals in ocr.items():
|
|
98
|
+
if not vals:
|
|
99
|
+
continue
|
|
100
|
+
pred = a * yy + b
|
|
101
|
+
errors = [abs(pred - vv) for vv in vals]
|
|
102
|
+
k = int(np.argmin(errors))
|
|
103
|
+
err = errors[k]
|
|
104
|
+
tol = max(18.0, 0.08 * abs(v2 - v1))
|
|
105
|
+
|
|
106
|
+
if err <= tol:
|
|
107
|
+
score += 1
|
|
108
|
+
total_error += err
|
|
109
|
+
inliers.append((float(yy), float(vals[k])))
|
|
110
|
+
|
|
111
|
+
if best is None or (score, -total_error) > (best["score"], -best["total_error"]):
|
|
112
|
+
best = {
|
|
113
|
+
"a": a, "b": b,
|
|
114
|
+
"score": score, "total_error": total_error,
|
|
115
|
+
"inliers": inliers,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# --- Log fit: log10(value) = a_log * y_pixel + b_log ---
|
|
119
|
+
if v1 > 0 and v2 > 0:
|
|
120
|
+
lv1, lv2 = np.log10(v1), np.log10(v2)
|
|
121
|
+
if abs(lv2 - lv1) < 0.3:
|
|
122
|
+
continue
|
|
123
|
+
a_log = (lv2 - lv1) / (y2 - y1)
|
|
124
|
+
b_log = lv1 - a_log * y1
|
|
125
|
+
|
|
126
|
+
if a_log >= 0:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
log_inliers: list[tuple[float, float]] = []
|
|
130
|
+
log_score = 0
|
|
131
|
+
log_error = 0.0
|
|
132
|
+
|
|
133
|
+
for yy, vals in ocr.items():
|
|
134
|
+
if not vals:
|
|
135
|
+
continue
|
|
136
|
+
pred_log = a_log * yy + b_log
|
|
137
|
+
pred_val = 10 ** pred_log
|
|
138
|
+
errors = [abs(pred_val - vv) / max(1e-9, abs(vv)) for vv in vals if vv > 0]
|
|
139
|
+
if not errors:
|
|
140
|
+
continue
|
|
141
|
+
k = int(np.argmin(errors))
|
|
142
|
+
rel_err = errors[k]
|
|
143
|
+
|
|
144
|
+
if rel_err <= 0.20: # 20 % relative tolerance
|
|
145
|
+
log_score += 1
|
|
146
|
+
log_error += rel_err
|
|
147
|
+
pos_vals = [vv for vv in vals if vv > 0]
|
|
148
|
+
log_inliers.append((float(yy), float(pos_vals[k])))
|
|
149
|
+
|
|
150
|
+
if log_score >= 2:
|
|
151
|
+
if best_log is None or (log_score, -log_error) > (best_log["score"], -best_log["total_error"]):
|
|
152
|
+
best_log = {
|
|
153
|
+
"a": a_log, "b": b_log,
|
|
154
|
+
"score": log_score, "total_error": log_error,
|
|
155
|
+
"inliers": log_inliers,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
orientation_flipped = False
|
|
159
|
+
use_log = False
|
|
160
|
+
|
|
161
|
+
# Decide between linear and log.
|
|
162
|
+
if best_log is not None and best_log["score"] >= 2:
|
|
163
|
+
linear_score = best["score"] if best is not None else 0
|
|
164
|
+
if best_log["score"] > linear_score or (best_log["score"] == linear_score and best is None):
|
|
165
|
+
use_log = True
|
|
166
|
+
|
|
167
|
+
if use_log and best_log is not None:
|
|
168
|
+
inlier_arr = np.array(best_log["inliers"], dtype=float)
|
|
169
|
+
log_vals = np.log10(inlier_arr[:, 1])
|
|
170
|
+
if len(inlier_arr) >= 2:
|
|
171
|
+
a, b = np.polyfit(inlier_arr[:, 0], log_vals, deg=1)
|
|
172
|
+
else:
|
|
173
|
+
a, b = best_log["a"], best_log["b"]
|
|
174
|
+
scale_type = "log_ocr_value"
|
|
175
|
+
# For log scale, "upward" means higher values at top → a < 0.
|
|
176
|
+
if a > 0:
|
|
177
|
+
a, b = -a, -(b) # flip
|
|
178
|
+
orientation_flipped = True
|
|
179
|
+
best_used = best_log
|
|
180
|
+
elif best is not None and best["score"] >= 2:
|
|
181
|
+
inlier_arr = np.array(best["inliers"], dtype=float)
|
|
182
|
+
if len(inlier_arr) >= 2:
|
|
183
|
+
a, b = np.polyfit(inlier_arr[:, 0], inlier_arr[:, 1], deg=1)
|
|
184
|
+
else:
|
|
185
|
+
a, b = best["a"], best["b"]
|
|
186
|
+
|
|
187
|
+
scale_type = "ocr_value"
|
|
188
|
+
a, b, orientation_flipped = orient_calibration_upward(a, b, panel)
|
|
189
|
+
best_used = best
|
|
190
|
+
else:
|
|
191
|
+
a = -1.0 / max(1, panel["y_bottom"] - panel["y_top"])
|
|
192
|
+
b = -a * panel["y_bottom"]
|
|
193
|
+
scale_type = "normalised_0_to_1"
|
|
194
|
+
best_used = {"score": 0, "inliers": []}
|
|
195
|
+
a, b, orientation_flipped = orient_calibration_upward(a, b, panel)
|
|
196
|
+
|
|
197
|
+
if verbose:
|
|
198
|
+
print(f"Panel {panel['panel_id']} y-axis OCR candidates:")
|
|
199
|
+
for y in y_lines:
|
|
200
|
+
print(f" y={y}: {ocr[y]}")
|
|
201
|
+
if scale_type == "log_ocr_value":
|
|
202
|
+
print(f" calibration: log10(value) = {a:.6g} * y_pixel + {b:.6g}")
|
|
203
|
+
print(f" top value: {10 ** (a * panel['y_top'] + b):.3g}")
|
|
204
|
+
print(f" bottom value: {10 ** (a * panel['y_bottom'] + b):.3g}")
|
|
205
|
+
else:
|
|
206
|
+
print(f" calibration: value = {a:.6g} * y_pixel + {b:.6g}")
|
|
207
|
+
print(f" top value: {a * panel['y_top'] + b:.3g}")
|
|
208
|
+
print(f" bottom value: {a * panel['y_bottom'] + b:.3g}")
|
|
209
|
+
print(f" scale_type: {scale_type}")
|
|
210
|
+
print(f" inliers: {best_used['inliers']}")
|
|
211
|
+
|
|
212
|
+
return Calibration(
|
|
213
|
+
a=float(a),
|
|
214
|
+
b=float(b),
|
|
215
|
+
scale_type=scale_type,
|
|
216
|
+
orientation_flipped=orientation_flipped,
|
|
217
|
+
ocr_candidates=ocr,
|
|
218
|
+
inliers=best["inliers"],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ===================================================================
|
|
223
|
+
# Cross-row propagation
|
|
224
|
+
# ===================================================================
|
|
225
|
+
|
|
226
|
+
def propagate_y_calibration_across_rows(
|
|
227
|
+
panels: list[dict],
|
|
228
|
+
verbose: bool = False,
|
|
229
|
+
) -> list[dict]:
|
|
230
|
+
"""Copy y-axis calibration from labelled panels to row-aligned siblings.
|
|
231
|
+
|
|
232
|
+
In horizontal multi-panel figures only the leftmost subplot may have
|
|
233
|
+
y-axis labels. This copies its calibration to sibling panels at
|
|
234
|
+
the same y-pixel extents.
|
|
235
|
+
"""
|
|
236
|
+
if not panels:
|
|
237
|
+
return panels
|
|
238
|
+
|
|
239
|
+
used: set[int] = set()
|
|
240
|
+
row_groups: list[list[dict]] = []
|
|
241
|
+
|
|
242
|
+
for i, p in enumerate(panels):
|
|
243
|
+
if i in used:
|
|
244
|
+
continue
|
|
245
|
+
group = [p]
|
|
246
|
+
used.add(i)
|
|
247
|
+
for j, q in enumerate(panels):
|
|
248
|
+
if j in used:
|
|
249
|
+
continue
|
|
250
|
+
same_row = abs(p["y_top"] - q["y_top"]) < 18 and abs(p["y_bottom"] - q["y_bottom"]) < 18
|
|
251
|
+
if same_row:
|
|
252
|
+
group.append(q)
|
|
253
|
+
used.add(j)
|
|
254
|
+
row_groups.append(group)
|
|
255
|
+
|
|
256
|
+
for group in row_groups:
|
|
257
|
+
refs = [p for p in group if p["y_calibration"]["scale_type"] == "ocr_value"]
|
|
258
|
+
if not refs:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
ref = refs[0]
|
|
262
|
+
ref_cal = ref["y_calibration"]
|
|
263
|
+
ref_top_val = ref_cal["a"] * ref["y_top"] + ref_cal["b"]
|
|
264
|
+
ref_bottom_val = ref_cal["a"] * ref["y_bottom"] + ref_cal["b"]
|
|
265
|
+
|
|
266
|
+
for p in group:
|
|
267
|
+
if p["y_calibration"]["scale_type"] == "ocr_value":
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
a = (ref_bottom_val - ref_top_val) / max(1, p["y_bottom"] - p["y_top"])
|
|
271
|
+
b = ref_top_val - a * p["y_top"]
|
|
272
|
+
|
|
273
|
+
p["y_calibration"] = {
|
|
274
|
+
**p["y_calibration"],
|
|
275
|
+
"a": float(a),
|
|
276
|
+
"b": float(b),
|
|
277
|
+
"scale_type": f"propagated_from_panel_{ref['panel_id']}",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if verbose:
|
|
281
|
+
print(f"Panel {p['panel_id']}: propagated y calibration from panel {ref['panel_id']}")
|
|
282
|
+
|
|
283
|
+
return panels
|
trace_digitiser/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Command-line interface for trace_digitiser.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
trace-digitiser figure.jpg --layout stacked --rows 2 --cols 1
|
|
6
|
+
|
|
7
|
+
# Batch mode
|
|
8
|
+
trace-digitiser figures/*.jpg --layout auto --output-dir results/
|
|
9
|
+
|
|
10
|
+
# Generate synthetic test figures
|
|
11
|
+
trace-digitiser --generate-test-figures --output-dir test_figures/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
p = argparse.ArgumentParser(
|
|
23
|
+
prog="trace-digitiser",
|
|
24
|
+
description="Template-free digitisation of raster scientific line plots.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
p.add_argument(
|
|
28
|
+
"images",
|
|
29
|
+
nargs="*",
|
|
30
|
+
type=Path,
|
|
31
|
+
help="Input image file(s) to digitise.",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument(
|
|
34
|
+
"--layout",
|
|
35
|
+
dest="layout_mode",
|
|
36
|
+
default="auto",
|
|
37
|
+
choices=["auto", "single", "stacked", "horizontal", "grid"],
|
|
38
|
+
help="Panel layout hint (default: auto).",
|
|
39
|
+
)
|
|
40
|
+
p.add_argument("--rows", dest="expected_rows", type=int, default=None, help="Expected number of panel rows.")
|
|
41
|
+
p.add_argument("--cols", dest="expected_cols", type=int, default=None, help="Expected number of panel columns.")
|
|
42
|
+
p.add_argument("--panels", dest="expected_panels", type=int, default=None, help="Expected total panel count.")
|
|
43
|
+
p.add_argument(
|
|
44
|
+
"--output-dir",
|
|
45
|
+
"-o",
|
|
46
|
+
dest="output_dir",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help="Output directory for CSV files and diagnostics.",
|
|
50
|
+
)
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"--save-diagnostics",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Write diagnostic overlay images.",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"--generate-test-figures",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Generate synthetic test plots instead of digitising.",
|
|
60
|
+
)
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--seed",
|
|
63
|
+
type=int,
|
|
64
|
+
default=123,
|
|
65
|
+
help="Random seed for synthetic test generation (default: 123).",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return p
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main(argv: list[str] | None = None) -> None:
|
|
72
|
+
parser = _build_parser()
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
|
|
75
|
+
# --- Generate test figures -------------------------------------------
|
|
76
|
+
if args.generate_test_figures:
|
|
77
|
+
from .synthetic import generate_noisy_example_plots
|
|
78
|
+
|
|
79
|
+
out = args.output_dir or Path("test_figures")
|
|
80
|
+
paths, truth = generate_noisy_example_plots(output_dir=out, seed=args.seed, show=False)
|
|
81
|
+
print("Generated test figures:")
|
|
82
|
+
for p in paths:
|
|
83
|
+
print(f" {p}")
|
|
84
|
+
print(f"Ground truth: {truth}")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# --- Digitise images -------------------------------------------------
|
|
88
|
+
if not args.images:
|
|
89
|
+
parser.print_help()
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
from . import digitise
|
|
93
|
+
|
|
94
|
+
for image_path in args.images:
|
|
95
|
+
if not image_path.exists():
|
|
96
|
+
print(f"WARNING: {image_path} not found, skipping.", file=sys.stderr)
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
print(f"\n{'=' * 60}")
|
|
100
|
+
print(f"Processing: {image_path}")
|
|
101
|
+
|
|
102
|
+
result = digitise(
|
|
103
|
+
image_path,
|
|
104
|
+
layout_mode=args.layout_mode,
|
|
105
|
+
expected_rows=args.expected_rows,
|
|
106
|
+
expected_cols=args.expected_cols,
|
|
107
|
+
expected_panels=args.expected_panels,
|
|
108
|
+
output_dir=args.output_dir,
|
|
109
|
+
show_debug=False,
|
|
110
|
+
save_diagnostics=args.save_diagnostics,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
n_panels = len(result.panels)
|
|
114
|
+
n_traces = result.trace_data["trace_id"].nunique() if not result.trace_data.empty else 0
|
|
115
|
+
print(f" Panels: {n_panels}, Traces: {n_traces}")
|
|
116
|
+
print(f" Trace CSV: {result.trace_csv_path}")
|
|
117
|
+
print(f" Metadata CSV: {result.metadata_csv_path}")
|
|
118
|
+
if result.summary_csv_path:
|
|
119
|
+
print(f" Summary CSV: {result.summary_csv_path}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|