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.
@@ -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()