ocr-harness 1.0.3__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.
consts.py ADDED
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ APP_ROOT = Path(__file__).parent
4
+
5
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"}
evaluation/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Evaluation pipeline, metrics, and ground-truth types.
2
+
3
+ The re-export below exposes the package's public ground-truth type
4
+ and, as a necessary side effect, ensures every ``SerializableClass``
5
+ subclass in this package registers itself in
6
+ ``SerializableClass._registry`` (via ``__init_subclass__``) the moment
7
+ any consumer touches the package. Add a line here when introducing a
8
+ new ``SerializableClass`` subclass under ``evaluation/``.
9
+ """
10
+
11
+ from evaluation.ocr_ground_truth import OCRGroundTruth
12
+
13
+ __all__ = ["OCRGroundTruth"]
@@ -0,0 +1,237 @@
1
+ """Plotting and analysis utilities for OCR evaluation results.
2
+
3
+ Loads aggregate and per-image results produced by the evaluation
4
+ pipeline and generates comparative visualisations across OCR modules.
5
+ """
6
+
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+
13
+ from evaluation.evaluation_pipeline import ALL_TAGS_KEY, AggregateResult
14
+ from evaluation.metrics import Metric
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def plot_metric_comparison(
20
+ aggregate: AggregateResult,
21
+ stat: str = "mean",
22
+ ci_level: str = "95",
23
+ save_path: str | Path | None = None,
24
+ tag: str = ALL_TAGS_KEY,
25
+ ) -> plt.Figure:
26
+ """Bar chart comparing all modules across every metric.
27
+
28
+ Each metric gets a group of bars, one per module. When ``stat`` is
29
+ ``mean``, asymmetric error bars are drawn from the percentile-based
30
+ confidence interval stored under the requested ``ci_level``.
31
+
32
+ Args:
33
+ aggregate: An :class:`AggregateResult` (e.g. from
34
+ ``AggregateResult.from_path(...)`` or
35
+ :class:`EvaluationResult.aggregate` of a live run).
36
+ stat: Which statistic to plot (mean, median, min, max).
37
+ ci_level: Confidence interval key to use for error bars
38
+ (default ``"95"``).
39
+ save_path: If given, save the figure to this path.
40
+ tag: Which tag's slice to plot. Defaults to ``ALL_TAGS_KEY``
41
+ (aggregated across all images).
42
+
43
+ Returns:
44
+ The matplotlib Figure.
45
+
46
+ Raises:
47
+ KeyError: If ``tag`` is not present in ``aggregate``.
48
+ """
49
+ view = aggregate.for_tag(tag)
50
+
51
+ x = np.arange(len(view.metrics))
52
+ width = 0.8 / len(view.labels)
53
+
54
+ fig, ax = plt.subplots(figsize=(max(10, len(view.metrics) * 2), 6))
55
+
56
+ for i, label in enumerate(view.labels):
57
+ values = np.array([view.data[label][m][stat] for m in view.metrics])
58
+ yerr = None
59
+ if stat == "mean":
60
+ ci_bounds = [view.data[label][m].get("ci", {}).get(ci_level) for m in view.metrics]
61
+ if all(b is not None for b in ci_bounds):
62
+ lower = np.array([values[j] - b[0] for j, b in enumerate(ci_bounds)])
63
+ upper = np.array([b[1] - values[j] for j, b in enumerate(ci_bounds)])
64
+ yerr = np.array([lower, upper])
65
+ ax.bar(x + i * width, values, width, label=label, yerr=yerr, capsize=3)
66
+
67
+ ax.set_xticks(x + width * (len(view.labels) - 1) / 2)
68
+ ax.set_xticklabels([Metric._registry[m].display_name for m in view.metrics], rotation=30, ha="right")
69
+ ax.set_ylabel(stat.capitalize())
70
+ ax.set_title(f"OCR Module Comparison ({stat})")
71
+ ax.legend()
72
+ fig.tight_layout()
73
+
74
+ if save_path:
75
+ fig.savefig(save_path, dpi=150)
76
+
77
+ return fig
78
+
79
+
80
+ def plot_radar(
81
+ aggregate: AggregateResult,
82
+ stat: str = "mean",
83
+ save_path: str | Path | None = None,
84
+ tag: str = ALL_TAGS_KEY,
85
+ ) -> plt.Figure:
86
+ """Radar (spider) chart comparing modules across all metrics.
87
+
88
+ All values are clipped to [0, 1] for display so the chart axes
89
+ remain comparable.
90
+
91
+ Args:
92
+ aggregate: An :class:`AggregateResult` (e.g. from
93
+ ``AggregateResult.from_path(...)`` or
94
+ :class:`EvaluationResult.aggregate` of a live run).
95
+ stat: Which statistic to plot (mean, median, min, max).
96
+ save_path: If given, save the figure to this path.
97
+ tag: Which tag's slice to plot. Defaults to ``ALL_TAGS_KEY``
98
+ (aggregated across all images).
99
+
100
+ Returns:
101
+ The matplotlib Figure.
102
+
103
+ Raises:
104
+ KeyError: If ``tag`` is not present in ``aggregate``.
105
+ """
106
+ view = aggregate.for_tag(tag)
107
+ n_metrics = len(view.metrics)
108
+
109
+ angles = np.linspace(0, 2 * np.pi, n_metrics, endpoint=False).tolist()
110
+ angles.append(angles[0])
111
+
112
+ fig, ax = plt.subplots(figsize=(8, 8), subplot_kw={"polar": True})
113
+
114
+ for label in view.labels:
115
+ values = [min(view.data[label][m][stat], 1.0) for m in view.metrics]
116
+ values.append(values[0])
117
+ ax.plot(angles, values, linewidth=2, label=label)
118
+ ax.fill(angles, values, alpha=0.15)
119
+
120
+ ax.set_xticks(angles[:-1])
121
+ ax.set_xticklabels([])
122
+
123
+ label_radius = 1.18
124
+ display_names = [Metric._registry[m].display_name for m in view.metrics]
125
+ for angle, name in zip(angles[:-1], display_names, strict=True):
126
+ angle_deg = np.degrees(angle)
127
+ rotation = angle_deg - 90 if angle_deg <= 180 else angle_deg + 90
128
+ ha = "center"
129
+ if not (np.isclose(angle, 0) or np.isclose(angle, np.pi)):
130
+ ha = "left" if 0 < angle < np.pi else "right"
131
+ ax.text(
132
+ angle,
133
+ label_radius,
134
+ name,
135
+ size=9,
136
+ ha=ha,
137
+ va="center",
138
+ rotation=rotation,
139
+ rotation_mode="anchor",
140
+ )
141
+
142
+ ax.set_ylim(0, 1.05)
143
+ ax.set_title(f"OCR Radar Chart ({stat})", y=1.08)
144
+ ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1))
145
+
146
+ if save_path:
147
+ fig.savefig(save_path, dpi=150, bbox_inches="tight")
148
+
149
+ return fig
150
+
151
+
152
+ def plot_stat_range(
153
+ aggregate: AggregateResult,
154
+ save_path: str | Path | None = None,
155
+ tag: str = ALL_TAGS_KEY,
156
+ ) -> plt.Figure:
157
+ """Box-style range plot showing min, mean, and max per metric per module.
158
+
159
+ Args:
160
+ aggregate: An :class:`AggregateResult` (e.g. from
161
+ ``AggregateResult.from_path(...)`` or
162
+ :class:`EvaluationResult.aggregate` of a live run).
163
+ save_path: If given, save the figure to this path.
164
+ tag: Which tag's slice to plot. Defaults to ``ALL_TAGS_KEY``
165
+ (aggregated across all images).
166
+
167
+ Returns:
168
+ The matplotlib Figure.
169
+
170
+ Raises:
171
+ KeyError: If ``tag`` is not present in ``aggregate``.
172
+ """
173
+ view = aggregate.for_tag(tag)
174
+
175
+ fig, axes = plt.subplots(1, len(view.metrics), figsize=(4 * len(view.metrics), 5), sharey=False)
176
+ if len(view.metrics) == 1:
177
+ axes = [axes]
178
+
179
+ for ax, metric in zip(axes, view.metrics, strict=True):
180
+ means = [view.data[label][metric]["mean"] for label in view.labels]
181
+ mins = [view.data[label][metric]["min"] for label in view.labels]
182
+ maxs = [view.data[label][metric]["max"] for label in view.labels]
183
+
184
+ x = np.arange(len(view.labels))
185
+ ax.errorbar(
186
+ x,
187
+ means,
188
+ yerr=[np.array(means) - np.array(mins), np.array(maxs) - np.array(means)],
189
+ fmt="o",
190
+ capsize=5,
191
+ capthick=2,
192
+ )
193
+ ax.set_xticks(x)
194
+ ax.set_xticklabels(view.labels, rotation=45, ha="right", fontsize=8)
195
+ ax.set_title(Metric._registry[metric].display_name, fontsize=10)
196
+
197
+ fig.suptitle("Metric Ranges (min / mean / max)", fontsize=13)
198
+ fig.tight_layout()
199
+
200
+ if save_path:
201
+ fig.savefig(save_path, dpi=150)
202
+
203
+ return fig
204
+
205
+
206
+ def summary_table(aggregate: AggregateResult, stat: str = "mean", tag: str = ALL_TAGS_KEY) -> str:
207
+ """Return a formatted text table of aggregate results.
208
+
209
+ Args:
210
+ aggregate: An :class:`AggregateResult` (e.g. from
211
+ ``AggregateResult.from_path(...)`` or
212
+ :class:`EvaluationResult.aggregate` of a live run).
213
+ stat: Which statistic to tabulate.
214
+ tag: Which tag's slice to tabulate. Defaults to ``ALL_TAGS_KEY``
215
+ (aggregated across all images).
216
+
217
+ Returns:
218
+ A multi-line string table suitable for printing.
219
+
220
+ Raises:
221
+ KeyError: If ``tag`` is not present in ``aggregate``.
222
+ """
223
+ view = aggregate.for_tag(tag)
224
+
225
+ col_width = max(len(Metric._registry[m].display_name) for m in view.metrics) + 2
226
+ label_width = max(len(label) for label in view.labels) + 2
227
+
228
+ header = " " * label_width + "".join(Metric._registry[m].display_name.rjust(col_width) for m in view.metrics)
229
+ lines = [header, "-" * len(header)]
230
+
231
+ for label in view.labels:
232
+ row = label.ljust(label_width) + "".join(
233
+ f"{view.data[label][m][stat]:.4f}".rjust(col_width) for m in view.metrics
234
+ )
235
+ lines.append(row)
236
+
237
+ return "\n".join(lines)