sci-fi-parser 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.
Files changed (43) hide show
  1. sci_fi_parser/__init__.py +5 -0
  2. sci_fi_parser/api.py +184 -0
  3. sci_fi_parser/benchmark/README.md +130 -0
  4. sci_fi_parser/benchmark/__init__.py +12 -0
  5. sci_fi_parser/benchmark/bench_pipeline.py +234 -0
  6. sci_fi_parser/benchmark/benchmark.py +441 -0
  7. sci_fi_parser/benchmark/draw_lap.py +425 -0
  8. sci_fi_parser/benchmark/report_adapter.py +42 -0
  9. sci_fi_parser/benchmark/scoring.py +83 -0
  10. sci_fi_parser/benchmark/truth.py +98 -0
  11. sci_fi_parser/benchmark/vlm_compare.py +760 -0
  12. sci_fi_parser/classifier/__init__.py +0 -0
  13. sci_fi_parser/classifier/classifier_pipeline.py +18 -0
  14. sci_fi_parser/classifier/cnn.py +89 -0
  15. sci_fi_parser/classifier/image_classifier.py +112 -0
  16. sci_fi_parser/cli.py +62 -0
  17. sci_fi_parser/config/synthetic_bars.toml +84 -0
  18. sci_fi_parser/config/vlm.toml +13 -0
  19. sci_fi_parser/config/vlm_comparison.toml +68 -0
  20. sci_fi_parser/config/vlm_smoke.toml +34 -0
  21. sci_fi_parser/image_extraction/extraction_pipeline.py +78 -0
  22. sci_fi_parser/image_extraction/image_loader.py +41 -0
  23. sci_fi_parser/image_extraction/image_parser.py +150 -0
  24. sci_fi_parser/main.py +37 -0
  25. sci_fi_parser/object_detection/__init__.py +0 -0
  26. sci_fi_parser/object_detection/computer_vision/bars.py +157 -0
  27. sci_fi_parser/object_detection/computer_vision/config.py +15 -0
  28. sci_fi_parser/object_detection/computer_vision/lines.py +100 -0
  29. sci_fi_parser/object_detection/debug_draw.py +130 -0
  30. sci_fi_parser/object_detection/detection_pipeline.py +149 -0
  31. sci_fi_parser/object_detection/ocr.py +69 -0
  32. sci_fi_parser/schema.py +217 -0
  33. sci_fi_parser/storage/writer.py +139 -0
  34. sci_fi_parser/vlm/__init__.py +0 -0
  35. sci_fi_parser/vlm/vlm.py +110 -0
  36. sci_fi_parser/vlm/vlm_config.py +68 -0
  37. sci_fi_parser/vlm/vlm_pipeline.py +45 -0
  38. sci_fi_parser/vlm/vlm_schema.py +25 -0
  39. sci_fi_parser-0.1.0.dist-info/METADATA +70 -0
  40. sci_fi_parser-0.1.0.dist-info/RECORD +43 -0
  41. sci_fi_parser-0.1.0.dist-info/WHEEL +4 -0
  42. sci_fi_parser-0.1.0.dist-info/entry_points.txt +4 -0
  43. sci_fi_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ from .api import parse_folder
2
+
3
+ __all__ = [
4
+ "parse_folder",
5
+ ]
sci_fi_parser/api.py ADDED
@@ -0,0 +1,184 @@
1
+ """Public parsing API for running the Sci-Fi-Parser pipeline.
2
+
3
+ This module exposes a convenience function for parsing a folder of PDFs and a
4
+ result object for accessing the parsed image and PDF sets, saving outputs, and
5
+ loading benchmark-friendly data frames.
6
+ """
7
+
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ import pandas as pd
12
+
13
+ from sci_fi_parser.classifier.classifier_pipeline import start_classification
14
+ from sci_fi_parser.image_extraction.extraction_pipeline import start_extraction
15
+ from sci_fi_parser.object_detection.detection_pipeline import start_ocr
16
+ from sci_fi_parser.schema import ImageSet, PdfSet
17
+ from sci_fi_parser.storage.writer import save_image_set
18
+ from sci_fi_parser.vlm.vlm_pipeline import start_vlm
19
+
20
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
21
+
22
+ DEFAULT_EXTRACTED_IMAGE_DIR = PROJECT_ROOT / "temp" / "extracted_images"
23
+ DEFAULT_VLM_CONFIG = files("sci_fi_parser.config").joinpath("vlm.toml")
24
+
25
+
26
+ class ParseResult:
27
+ """Represents the result of one parsing run."""
28
+
29
+ def __init__(
30
+ self,
31
+ image_set: ImageSet,
32
+ pdf_set: PdfSet,
33
+ output_dir: str | Path | None = None,
34
+ ):
35
+ self._image_set = image_set
36
+ self._pdf_set = pdf_set
37
+ self._output_dir = Path(output_dir) if output_dir else None
38
+
39
+ @property
40
+ def images(self) -> ImageSet:
41
+ """Return the parsed images and associated metadata."""
42
+
43
+ return self._image_set
44
+
45
+ @property
46
+ def pdfs(self) -> PdfSet:
47
+ """Return the parsed PDFs and associated metadata."""
48
+
49
+ return self._pdf_set
50
+
51
+ @property
52
+ def output_dir(self) -> Path | None:
53
+ """Return the saved output directory, if one has been set."""
54
+
55
+ return self._output_dir
56
+
57
+ def summary(self) -> dict:
58
+ """Return a compact summary of the parsed dataset.
59
+
60
+ Returns:
61
+ A dictionary containing PDF and image counts.
62
+ """
63
+
64
+ return {
65
+ "pdf_count": len(self._pdf_set),
66
+ "image_count": len(self._image_set),
67
+ }
68
+
69
+ def save(self, output_dir: str | Path) -> None:
70
+ """Save the parsed dataset as JSONL and Parquet.
71
+
72
+ Args:
73
+ output_dir: Directory where the parsed dataset will be written.
74
+ """
75
+
76
+ output_dir = Path(output_dir)
77
+
78
+ save_image_set(
79
+ image_set=self._image_set,
80
+ output_dir=output_dir,
81
+ )
82
+
83
+ self._output_dir = output_dir
84
+
85
+ def _require_saved_output(self) -> Path:
86
+ if self._output_dir is None:
87
+ raise ValueError(
88
+ "No output directory available. Pass output_dir to parse_folder() or call result.save(...)."
89
+ )
90
+
91
+ return self._output_dir
92
+
93
+ def charts_dataframe(self) -> pd.DataFrame:
94
+ """Load ``charts.parquet`` as a DataFrame.
95
+
96
+ Returns:
97
+ A DataFrame loaded from ``tables/charts.parquet``.
98
+ """
99
+
100
+ output_dir = self._require_saved_output()
101
+
102
+ return pd.read_parquet(output_dir / "tables" / "charts.parquet")
103
+
104
+ def series_dataframe(self) -> pd.DataFrame:
105
+ """Load ``series.parquet`` as a DataFrame.
106
+
107
+ Returns:
108
+ A DataFrame loaded from ``tables/series.parquet``.
109
+ """
110
+
111
+ output_dir = self._require_saved_output()
112
+
113
+ return pd.read_parquet(output_dir / "tables" / "series.parquet")
114
+
115
+ def points_dataframe(self) -> pd.DataFrame:
116
+ """Load ``points.parquet`` as a DataFrame.
117
+
118
+ Returns:
119
+ A DataFrame loaded from ``tables/points.parquet``.
120
+ """
121
+
122
+ output_dir = self._require_saved_output()
123
+
124
+ return pd.read_parquet(output_dir / "tables" / "points.parquet")
125
+
126
+
127
+ def parse_folder(
128
+ input_dir: str | Path,
129
+ *,
130
+ output_dir: str | Path | None = None,
131
+ extracted_image_dir: str | Path = DEFAULT_EXTRACTED_IMAGE_DIR,
132
+ vlm_config: str | Path = DEFAULT_VLM_CONFIG,
133
+ classify: bool = True,
134
+ ocr: bool = True,
135
+ vlm: bool = True,
136
+ ) -> ParseResult:
137
+ """Parse all PDFs inside a folder.
138
+
139
+ Args:
140
+ input_dir: Folder containing PDF files.
141
+ output_dir: If provided, save JSONL and Parquet outputs.
142
+ extracted_image_dir: Temporary folder used during image extraction.
143
+ vlm_config: Path to the VLM configuration file.
144
+ classify: Whether to run chart classification.
145
+ ocr: Whether to run OCR.
146
+ vlm: Whether to run VLM extraction.
147
+
148
+ Returns:
149
+ A parse result containing the populated image and PDF sets.
150
+ """
151
+
152
+ image_set = ImageSet()
153
+ pdf_set = PdfSet()
154
+
155
+ start_extraction(
156
+ Path(input_dir),
157
+ image_set,
158
+ pdf_set,
159
+ Path(extracted_image_dir),
160
+ )
161
+
162
+ if classify:
163
+ start_classification(image_set)
164
+
165
+ if ocr:
166
+ start_ocr(image_set)
167
+
168
+ if vlm:
169
+ start_vlm(
170
+ image_set,
171
+ Path(vlm_config),
172
+ )
173
+
174
+ if output_dir is not None:
175
+ save_image_set(
176
+ image_set=image_set,
177
+ output_dir=Path(output_dir),
178
+ )
179
+
180
+ return ParseResult(
181
+ image_set=image_set,
182
+ pdf_set=pdf_set,
183
+ output_dir=output_dir,
184
+ )
@@ -0,0 +1,130 @@
1
+ # `sci_fi_parser.accuracy` — measurement layer
2
+
3
+ Tools for generating chart datasets with **ground-truth labels by construction**
4
+ and scoring any extractor against them. Two CLI commands and a pluggable
5
+ `Extractor` protocol.
6
+
7
+ The CLIs (`synthetic-bars`, `benchmark`) are installed by `uv sync` via
8
+ `[project.scripts]` — no `python -m …` prefix needed.
9
+
10
+ ## `synthetic-bars` — generate ground-truth chart datasets
11
+
12
+ Charts whose data is known *by construction* (no model-labelled images, no
13
+ circularity risk). Default mode is a *controlled density series*: per enabled
14
+ type the style is fixed once and only the bar count varies across
15
+ `density_steps`; each density is rendered as a matched **labels-off / labels-on
16
+ pair** sharing identical data. Labels are auto-fitted so they never overlap.
17
+
18
+ ```bash
19
+ synthetic-bars --config config/synthetic_bars.toml --overlay --sqlite
20
+ ```
21
+
22
+ Pick types, density ladder, and resolutions in
23
+ [../../../config/synthetic_bars.toml](../../../config/synthetic_bars.toml):
24
+ `output_types`, `density_steps`, `resolutions`. Run with `--preview` first to
25
+ render a small sample of every catalog type.
26
+
27
+ | Flag | Effect |
28
+ |---|---|
29
+ | `--preview` | one sample per catalog type into `preview/` |
30
+ | `--random N` | random mode instead: N fully-random charts |
31
+ | `--augment` | add JPEG/noise/blur realism (labels stay exact) |
32
+ | `--overlay` | write `_debug/overlay_*.png` to eyeball label accuracy |
33
+ | `--sqlite` | also write `dataset.sqlite3` (mirrors the real schema + geometry) |
34
+ | `--refresh` | delete prior outputs in `--out` before generating |
35
+ | `--seed N` | reproducibility |
36
+
37
+ Outputs in `--out`:
38
+ - `images/<type>_s<k>_d<NN>_{off,on}_<res>.png` (series) or `random_NNNNN_<res>.png`
39
+ - `labels.jsonl` — per chart: `label1` (chart type), `label2` (axes +
40
+ per-series points + `value_range`), `geometry` (bboxes + tick positions, or
41
+ `null`), `meta`
42
+ - `_debug/` overlays (with `--overlay`)
43
+ - `dataset.sqlite3` (with `--sqlite`)
44
+
45
+ Tuning: every knob is documented inline in `GenConfig`
46
+ ([synthetic/config.py](synthetic/config.py)) and mirrored with comments in the
47
+ TOML.
48
+
49
+ ## `benchmark` — score any extractor against ground truth
50
+
51
+ ```bash
52
+ benchmark --data train_data/synthetic --out reports/run1
53
+ ```
54
+
55
+ The default extractor is `noisy-oracle` (a test double that perturbs truth) so
56
+ the harness runs without a model server. For a real model, install
57
+ [ollama](https://ollama.com) locally, `ollama pull qwen2.5vl:7b`, then use
58
+ `--extractor vlm` to pick up the model declared in
59
+ [../../../config/vlm.toml](../../../config/vlm.toml) — or override per-run
60
+ with `--extractor vlm:<tag>`. The extractor talks to any OpenAI-compatible
61
+ chat-completions endpoint (`base_url` in the profile); ollama's `/v1` compat
62
+ layer is the default.
63
+
64
+ **Metrics**
65
+ - Error = `|predicted − true|` as **% of the true value**. Hover any card in
66
+ the HTML report for the exact definition.
67
+ - **Recall** = bars found / true bars. **Precision** = correct
68
+ (series, category) bars / predicted.
69
+ - **Type acc** = fraction of charts where the extractor's `chart_type` matched
70
+ truth. **Mean conf** = average VLM self-reported confidence (not necessarily
71
+ calibrated).
72
+
73
+ **Outputs** in `--out`:
74
+ - `report.html` — self-contained: summary cards (hover for tooltips), breakdowns
75
+ by preset / density / labels, ranked best/worst charts with thumbnails,
76
+ sortable per-chart table
77
+ - `results.json` — machine-readable per-chart rows for CI / trend tracking
78
+
79
+ ## Configuring the VLM extractor
80
+
81
+ The active model, prompt, and endpoint live in
82
+ [../../../config/vlm.toml](../../../config/vlm.toml). Edit that file to swap
83
+ models permanently, or override on the CLI for one-offs:
84
+
85
+ ```bash
86
+ benchmark --data ... --extractor vlm:qwen2.5vl:7b-q8_0 # one-shot
87
+ benchmark --data ... --vlm-config config/vlm_q8.toml # A/B test
88
+ ```
89
+
90
+ Resolution order: CLI flag > `config/vlm.toml` in cwd > built-in defaults.
91
+
92
+ ## Adding a new extractor
93
+
94
+ Anything implementing the `Extractor` protocol from
95
+ [../schema.py](../schema.py) plugs in:
96
+
97
+ ```python
98
+ from pathlib import Path
99
+ from sci_fi_parser.schema import ChartData
100
+
101
+ class MyExtractor:
102
+ name = "my-extractor"
103
+ def extract(self, image_path: Path) -> ChartData: ...
104
+ ```
105
+
106
+ Register it in `build_extractor` ([benchmark.py](benchmark.py)). `ChartData`
107
+ is the single canonical schema every extractor must return: the VLM is
108
+ constrained to emit it (via `response_format: json_schema`), CV+OCR pipelines
109
+ are mapped into it.
110
+
111
+ > Note: pure OCR is *not* a standalone extractor (it reads text, not data
112
+ > points) — pair it with CV.
113
+
114
+ ## Layout
115
+
116
+ ```
117
+ accuracy/
118
+ __init__.py
119
+ benchmark.py # `benchmark` CLI: runs an extractor, writes report
120
+ vlm_compare.py # `benchmark-compare` CLI: runs N models, builds leaderboard
121
+ synthetic/ # ground-truth chart generator
122
+ ```
123
+
124
+ The VLM extractor itself (`ChatCompletionsVLM` + `VLMProfile` + `load_profile`) lives
125
+ in the sibling package [../vlm/](../vlm/) — separated from this package so
126
+ non-benchmark callers (e.g. the runtime pipeline) can import the model
127
+ client without pulling in the measurement machinery.
128
+
129
+ Config files live at repo-root [config/](../../../config/), separate from the
130
+ package source so users edit data, not code.
@@ -0,0 +1,12 @@
1
+ """Accuracy-metrics layer.
2
+
3
+ Two cooperating pieces, coupled by a data contract (``labels.jsonl``), not by code:
4
+
5
+ * :mod:`sci_fi_parser.accuracy.synthetic` -- generates charts whose values are known
6
+ *by construction*, so they carry exact ground truth.
7
+ * :mod:`sci_fi_parser.accuracy.benchmark` -- scores any extractor against that ground
8
+ truth and turns the per-chart errors into an empirical error margin.
9
+
10
+ Import the subpackage you need directly; this package intentionally pulls in neither,
11
+ so e.g. importing ``benchmark`` does not drag in matplotlib.
12
+ """
@@ -0,0 +1,234 @@
1
+ """MVP staged benchmark pipeline.
2
+
3
+ This module only orchestrates stages. Scoring, aggregation, extractor building,
4
+ and report formatting are reused from the legacy benchmark where possible.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ import numpy as np
16
+
17
+ from sci_fi_parser.benchmark import benchmark
18
+ from sci_fi_parser.benchmark.report_adapter import (
19
+ value_results_to_breakdowns,
20
+ value_results_to_draw_lap_charts,
21
+ )
22
+ from sci_fi_parser.benchmark.scoring import (
23
+ aggregate_value_results,
24
+ score_vlm_outputs,
25
+ )
26
+ from sci_fi_parser.benchmark.truth import (
27
+ ChartTruth,
28
+ load_benetech_truth,
29
+ load_synthetic_truth,
30
+ )
31
+ from sci_fi_parser.image_extraction.image_loader import load_images_from_folder
32
+ from sci_fi_parser.schema import ImageSet
33
+ from sci_fi_parser.vlm.vlm_config import VLMProfile
34
+ from sci_fi_parser.vlm.vlm_schema import ChartData
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class PipelineInputs:
39
+ image_set: ImageSet
40
+ truth_by_image_id: dict[str, ChartTruth]
41
+ image_dir: Path
42
+
43
+
44
+ DatasetKind = Literal["synthetic", "benetech"]
45
+
46
+
47
+ def load_inputs(
48
+ data: Path,
49
+ limit: int | None = None,
50
+ dataset: DatasetKind = "synthetic",
51
+ ) -> PipelineInputs:
52
+ def truth_key(path: Path) -> str:
53
+ if dataset == "synthetic":
54
+ return path.name
55
+ if dataset == "benetech":
56
+ return path.stem
57
+
58
+ if dataset == "synthetic":
59
+ truth_by_key, metadata_by_key = load_synthetic_truth(data)
60
+ elif dataset == "benetech":
61
+ truth_by_key, metadata_by_key = load_benetech_truth(data)
62
+ else:
63
+ raise ValueError(f"unknown benchmark dataset: {dataset}")
64
+
65
+ img_dir = data / "images"
66
+
67
+ image_set = ImageSet()
68
+ load_images_from_folder(img_dir, image_set)
69
+
70
+ image_ids = [
71
+ image_id
72
+ for image_id, _ in sorted(image_set.items(), key=lambda item: image_set.get_image_path(item[0]).name)
73
+ if truth_key(image_set.get_image_path(image_id)) in truth_by_key
74
+ ]
75
+ if limit:
76
+ image_ids = image_ids[:limit]
77
+
78
+ truth_by_image_id: dict[str, ChartTruth] = {}
79
+ for image_id in image_ids:
80
+ key = truth_key(image_set.get_image_path(image_id))
81
+ truth_by_image_id[image_id] = truth_by_key[key]
82
+ image_set.get(image_id)["metadata"]["benchmark"] = metadata_by_key.get(key, {})
83
+
84
+ return PipelineInputs(
85
+ image_set=image_set,
86
+ truth_by_image_id=truth_by_image_id,
87
+ image_dir=img_dir,
88
+ )
89
+
90
+
91
+ def run_classification_stage(inputs: PipelineInputs) -> None:
92
+ from sci_fi_parser.classifier.classifier_pipeline import start_classification
93
+
94
+ start_classification(inputs.image_set)
95
+
96
+
97
+ def run_ocr_cv_stage(inputs: PipelineInputs) -> None:
98
+ from sci_fi_parser.object_detection.detection_pipeline import start_ocr
99
+
100
+ scoped = ImageSet()
101
+ for image_id in inputs.truth_by_image_id:
102
+ scoped.add(image_id, inputs.image_set.get(image_id))
103
+ start_ocr(scoped)
104
+
105
+
106
+ def _prompt_suffix(inputs: PipelineInputs, image_id: str) -> str:
107
+ value = inputs.image_set.get(image_id).get("ocrcv", {}).get("result", "")
108
+ return f"OCR/CV context:\n{value}" if isinstance(value, str) and value else ""
109
+
110
+
111
+ def run_vlm_stage(inputs: PipelineInputs, extractor: benchmark.Extractor) -> None:
112
+ from tqdm import tqdm
113
+
114
+ for image_id in tqdm(inputs.truth_by_image_id):
115
+ record = inputs.image_set.get(image_id)
116
+ path = inputs.image_set.get_image_path(image_id)
117
+ t0 = time.perf_counter()
118
+ try:
119
+ parsed, raw = extractor.extract(
120
+ path,
121
+ prompt_suffix=_prompt_suffix(inputs, image_id),
122
+ )
123
+ parsed_payload = ChartData.model_validate(parsed).model_dump()
124
+ except Exception as exc:
125
+ print(f" ! {path.name}: {type(exc).__name__}: {exc}")
126
+ parsed_payload = ChartData(chart_type="none", series=[], log_scale=False).model_dump()
127
+ raw = {"error": f"{type(exc).__name__}: {exc}"}
128
+ inputs.image_set.add_vlm_result(image_id, parsed_payload)
129
+ inputs.image_set.add_vlm_result_raw(image_id, raw)
130
+ record["metadata"]["vlm"]["seconds"] = time.perf_counter() - t0
131
+
132
+
133
+ def write_outputs(
134
+ out: Path,
135
+ extractor: benchmark.Extractor,
136
+ agg: dict,
137
+ results: list[benchmark.ChartResult],
138
+ img_dir: Path,
139
+ ) -> None:
140
+ out.mkdir(parents=True, exist_ok=True)
141
+ benchmark._write_results_json(out, extractor, agg, results)
142
+ from sci_fi_parser.benchmark import draw_lap
143
+
144
+ draw_lap.write_html(
145
+ out / "report.html",
146
+ extractor._model,
147
+ agg,
148
+ value_results_to_draw_lap_charts(results),
149
+ value_results_to_breakdowns(results),
150
+ img_dir,
151
+ )
152
+
153
+
154
+ def run_pipeline(
155
+ *,
156
+ data: Path,
157
+ out: Path,
158
+ extractor_name: str = "noisy-oracle",
159
+ profile: VLMProfile | None = None,
160
+ seed: int = 0,
161
+ limit: int | None = None,
162
+ dataset: DatasetKind = "synthetic",
163
+ classification: bool = False,
164
+ ocr_cv: bool = False,
165
+ print_summary: bool = True,
166
+ ) -> dict:
167
+ inputs = load_inputs(data, limit, dataset)
168
+ if classification:
169
+ run_classification_stage(inputs)
170
+ if ocr_cv:
171
+ run_ocr_cv_stage(inputs)
172
+
173
+ extractor = benchmark.build_extractor(
174
+ extractor_name,
175
+ {
176
+ inputs.image_set.get_image_path(image_id).name: truth
177
+ for image_id, truth in inputs.truth_by_image_id.items()
178
+ },
179
+ np.random.default_rng(seed),
180
+ profile=profile,
181
+ )
182
+ run_vlm_stage(inputs, extractor)
183
+
184
+ results = score_vlm_outputs(
185
+ inputs.image_set,
186
+ inputs.truth_by_image_id,
187
+ )
188
+ agg = aggregate_value_results(results)
189
+ write_outputs(out, extractor, agg, results, inputs.image_dir)
190
+ if print_summary:
191
+ benchmark._print_summary(extractor, agg, results, out)
192
+ return agg
193
+
194
+
195
+ def _parse_args() -> argparse.Namespace:
196
+ ap = argparse.ArgumentParser(description=__doc__)
197
+ ap.add_argument(
198
+ "--data",
199
+ type=Path,
200
+ required=True,
201
+ help="dataset dir containing images/ plus truth.jsonl or annotations/",
202
+ )
203
+ ap.add_argument("--dataset", choices=("synthetic", "benetech"), default="synthetic")
204
+ ap.add_argument("--out", type=Path, default=Path("reports/pipeline"))
205
+ ap.add_argument(
206
+ "--extractor",
207
+ default="noisy-oracle",
208
+ help="noisy-oracle | vlm | vlm:<model>",
209
+ )
210
+ ap.add_argument("--vlm-config", type=Path, default=None, help="VLM profile TOML")
211
+ ap.add_argument("--seed", type=int, default=0)
212
+ ap.add_argument("--limit", type=int, default=None, help="only first N charts")
213
+ ap.add_argument("--classification", action="store_true", help="run classification stage")
214
+ ap.add_argument("--ocr-cv", action="store_true", help="run OCR/CV stage")
215
+ return ap.parse_args()
216
+
217
+
218
+ def main() -> None:
219
+ args = _parse_args()
220
+ run_pipeline(
221
+ data=args.data,
222
+ out=args.out,
223
+ extractor_name=args.extractor,
224
+ profile=benchmark._resolve_profile(args.vlm_config),
225
+ seed=args.seed,
226
+ limit=args.limit,
227
+ dataset=args.dataset,
228
+ classification=args.classification,
229
+ ocr_cv=args.ocr_cv,
230
+ )
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()