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.
- sci_fi_parser/__init__.py +5 -0
- sci_fi_parser/api.py +184 -0
- sci_fi_parser/benchmark/README.md +130 -0
- sci_fi_parser/benchmark/__init__.py +12 -0
- sci_fi_parser/benchmark/bench_pipeline.py +234 -0
- sci_fi_parser/benchmark/benchmark.py +441 -0
- sci_fi_parser/benchmark/draw_lap.py +425 -0
- sci_fi_parser/benchmark/report_adapter.py +42 -0
- sci_fi_parser/benchmark/scoring.py +83 -0
- sci_fi_parser/benchmark/truth.py +98 -0
- sci_fi_parser/benchmark/vlm_compare.py +760 -0
- sci_fi_parser/classifier/__init__.py +0 -0
- sci_fi_parser/classifier/classifier_pipeline.py +18 -0
- sci_fi_parser/classifier/cnn.py +89 -0
- sci_fi_parser/classifier/image_classifier.py +112 -0
- sci_fi_parser/cli.py +62 -0
- sci_fi_parser/config/synthetic_bars.toml +84 -0
- sci_fi_parser/config/vlm.toml +13 -0
- sci_fi_parser/config/vlm_comparison.toml +68 -0
- sci_fi_parser/config/vlm_smoke.toml +34 -0
- sci_fi_parser/image_extraction/extraction_pipeline.py +78 -0
- sci_fi_parser/image_extraction/image_loader.py +41 -0
- sci_fi_parser/image_extraction/image_parser.py +150 -0
- sci_fi_parser/main.py +37 -0
- sci_fi_parser/object_detection/__init__.py +0 -0
- sci_fi_parser/object_detection/computer_vision/bars.py +157 -0
- sci_fi_parser/object_detection/computer_vision/config.py +15 -0
- sci_fi_parser/object_detection/computer_vision/lines.py +100 -0
- sci_fi_parser/object_detection/debug_draw.py +130 -0
- sci_fi_parser/object_detection/detection_pipeline.py +149 -0
- sci_fi_parser/object_detection/ocr.py +69 -0
- sci_fi_parser/schema.py +217 -0
- sci_fi_parser/storage/writer.py +139 -0
- sci_fi_parser/vlm/__init__.py +0 -0
- sci_fi_parser/vlm/vlm.py +110 -0
- sci_fi_parser/vlm/vlm_config.py +68 -0
- sci_fi_parser/vlm/vlm_pipeline.py +45 -0
- sci_fi_parser/vlm/vlm_schema.py +25 -0
- sci_fi_parser-0.1.0.dist-info/METADATA +70 -0
- sci_fi_parser-0.1.0.dist-info/RECORD +43 -0
- sci_fi_parser-0.1.0.dist-info/WHEEL +4 -0
- sci_fi_parser-0.1.0.dist-info/entry_points.txt +4 -0
- sci_fi_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|