newspaper-ocr 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.
- newspaper_ocr/__init__.py +5 -0
- newspaper_ocr/cli.py +87 -0
- newspaper_ocr/detectors/__init__.py +12 -0
- newspaper_ocr/detectors/as_yolo.py +502 -0
- newspaper_ocr/detectors/base.py +10 -0
- newspaper_ocr/detectors/paddlex.py +59 -0
- newspaper_ocr/formatters/__init__.py +10 -0
- newspaper_ocr/formatters/base.py +9 -0
- newspaper_ocr/formatters/hocr.py +29 -0
- newspaper_ocr/formatters/json_fmt.py +29 -0
- newspaper_ocr/formatters/text.py +7 -0
- newspaper_ocr/layout_processor.py +504 -0
- newspaper_ocr/models.py +53 -0
- newspaper_ocr/pipeline.py +147 -0
- newspaper_ocr/recognizers/__init__.py +28 -0
- newspaper_ocr/recognizers/base.py +19 -0
- newspaper_ocr/recognizers/effocr.py +47 -0
- newspaper_ocr/recognizers/glm_ocr.py +198 -0
- newspaper_ocr/recognizers/tesseract.py +72 -0
- newspaper_ocr/recognizers/tesserocr_backend.py +100 -0
- newspaper_ocr/registry.py +19 -0
- newspaper_ocr/spell_checker.py +171 -0
- newspaper_ocr/text_cleaner.py +181 -0
- newspaper_ocr-0.1.0.dist-info/METADATA +243 -0
- newspaper_ocr-0.1.0.dist-info/RECORD +29 -0
- newspaper_ocr-0.1.0.dist-info/WHEEL +5 -0
- newspaper_ocr-0.1.0.dist-info/entry_points.txt +2 -0
- newspaper_ocr-0.1.0.dist-info/licenses/LICENSE +21 -0
- newspaper_ocr-0.1.0.dist-info/top_level.txt +1 -0
newspaper_ocr/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI entry point for newspaper-ocr."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
|
|
10
|
+
@click.option("--backend", "-b", default="tesseract",
|
|
11
|
+
help="Recognition backend: tesseract, tesserocr, effocr")
|
|
12
|
+
@click.option("--detector", "-d", default="as_yolo",
|
|
13
|
+
help="Detection backend: as_yolo, paddlex")
|
|
14
|
+
@click.option("--output", "-o", default="text",
|
|
15
|
+
help="Output format: text, json, hocr")
|
|
16
|
+
@click.option("--model", "-m", default=None,
|
|
17
|
+
help="Custom model path (e.g., traineddata for Tesseract)")
|
|
18
|
+
@click.option("--model-dir", default=None,
|
|
19
|
+
help="Model cache directory")
|
|
20
|
+
@click.option("--mode", default="region",
|
|
21
|
+
help="Recognition mode: line or region (default: region)")
|
|
22
|
+
@click.option("--no-layout-processing", is_flag=True,
|
|
23
|
+
help="Disable reading order post-processing")
|
|
24
|
+
@click.option("--no-text-cleaning", is_flag=True,
|
|
25
|
+
help="Disable dehyphenation and line-joining post-processing")
|
|
26
|
+
@click.option("--spell-check", is_flag=True, default=False,
|
|
27
|
+
help="Enable SymSpell spell correction post-processing (off by default)")
|
|
28
|
+
@click.option("--fallback", default=None,
|
|
29
|
+
help="Fallback recognizer for low-confidence lines (e.g. glm-ocr)")
|
|
30
|
+
@click.option("--fallback-threshold", default=70, type=float,
|
|
31
|
+
help="Confidence threshold (0-100) below which fallback is used (default: 70)")
|
|
32
|
+
@click.option("--outdir", default=None,
|
|
33
|
+
help="Output directory (default: stdout)")
|
|
34
|
+
def main(images, backend, detector, output, model, model_dir, mode, no_layout_processing, no_text_cleaning, spell_check, fallback, fallback_threshold, outdir):
|
|
35
|
+
"""OCR historical newspaper scans.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
newspaper-ocr page.jp2
|
|
39
|
+
newspaper-ocr page.jp2 --backend tesserocr --output json
|
|
40
|
+
newspaper-ocr *.jp2 --outdir results/ --output text
|
|
41
|
+
newspaper-ocr page.jp2 --model news_gold_v2.traineddata
|
|
42
|
+
"""
|
|
43
|
+
from newspaper_ocr import Pipeline
|
|
44
|
+
|
|
45
|
+
# Build recognizer with mode
|
|
46
|
+
from newspaper_ocr.recognizers import RECOGNIZERS
|
|
47
|
+
import inspect
|
|
48
|
+
rec_cls = RECOGNIZERS.get(backend)
|
|
49
|
+
rec_kwargs = {}
|
|
50
|
+
params = inspect.signature(rec_cls.__init__).parameters
|
|
51
|
+
if "mode" in params:
|
|
52
|
+
rec_kwargs["mode"] = mode
|
|
53
|
+
if model:
|
|
54
|
+
# Route model arg to the right param
|
|
55
|
+
if "model" in params:
|
|
56
|
+
rec_kwargs["model"] = model
|
|
57
|
+
elif "model_dir" in params:
|
|
58
|
+
rec_kwargs["model_dir"] = model
|
|
59
|
+
|
|
60
|
+
recognizer = rec_cls(**rec_kwargs)
|
|
61
|
+
|
|
62
|
+
pipe = Pipeline(
|
|
63
|
+
detector=detector,
|
|
64
|
+
recognizer=recognizer,
|
|
65
|
+
output=output,
|
|
66
|
+
model_cache_dir=model_dir,
|
|
67
|
+
layout_processing=not no_layout_processing,
|
|
68
|
+
text_cleaning=not no_text_cleaning,
|
|
69
|
+
spell_check=spell_check,
|
|
70
|
+
fallback=fallback,
|
|
71
|
+
fallback_threshold=fallback_threshold,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for image_path in images:
|
|
75
|
+
result = pipe.ocr(image_path)
|
|
76
|
+
|
|
77
|
+
if outdir:
|
|
78
|
+
out_path = Path(outdir) / (Path(image_path).stem + _ext(output))
|
|
79
|
+
Path(outdir).mkdir(parents=True, exist_ok=True)
|
|
80
|
+
out_path.write_text(result)
|
|
81
|
+
click.echo(f"Saved: {out_path}", err=True)
|
|
82
|
+
else:
|
|
83
|
+
click.echo(result)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ext(fmt: str) -> str:
|
|
87
|
+
return {"text": ".txt", "json": ".json", "hocr": ".hocr"}.get(fmt, ".txt")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from newspaper_ocr.detectors.base import Detector
|
|
2
|
+
from newspaper_ocr.detectors.as_yolo import AsYoloDetector
|
|
3
|
+
from newspaper_ocr.registry import Registry
|
|
4
|
+
|
|
5
|
+
DETECTORS = Registry("detector")
|
|
6
|
+
DETECTORS.register("as_yolo", AsYoloDetector)
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from newspaper_ocr.detectors.paddlex import PaddleXDetector
|
|
10
|
+
DETECTORS.register("paddlex", PaddleXDetector)
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""American Stories YOLO-based layout and line detector.
|
|
2
|
+
|
|
3
|
+
Uses YOLOv8 ONNX models from the American Stories pipeline to detect
|
|
4
|
+
layout regions and text lines in newspaper page images.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from math import ceil, floor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
import onnx
|
|
16
|
+
import onnxruntime as ort
|
|
17
|
+
import torch
|
|
18
|
+
from PIL import Image
|
|
19
|
+
from torchvision.ops import nms
|
|
20
|
+
|
|
21
|
+
from newspaper_ocr.detectors.base import Detector
|
|
22
|
+
from newspaper_ocr.models import BBox, Line, PageLayout, Region
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Constants
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
LAYOUT_CLASSES: dict[int, str] = {
|
|
31
|
+
0: "article",
|
|
32
|
+
1: "author",
|
|
33
|
+
2: "cartoon_or_advertisement",
|
|
34
|
+
3: "headline",
|
|
35
|
+
4: "image_caption",
|
|
36
|
+
5: "masthead",
|
|
37
|
+
6: "newspaper_header",
|
|
38
|
+
7: "page_number",
|
|
39
|
+
8: "photograph",
|
|
40
|
+
9: "table",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
LAYOUT_TYPES_FOR_LINES: set[str] = {
|
|
44
|
+
"article",
|
|
45
|
+
"headline",
|
|
46
|
+
"author",
|
|
47
|
+
"image_caption",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "newspaper-ocr" / "models"
|
|
51
|
+
_DROPBOX_FALLBACK = Path(
|
|
52
|
+
"/Users/nealcaren/Dropbox/american-stories/american_stories_models"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
LAYOUT_MODEL_FILENAME = "layout_model_new.onnx"
|
|
56
|
+
LINE_MODEL_FILENAME = "line_model_new.onnx"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# YOLO helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_onnx_input_name(model_path: str | Path) -> str:
|
|
65
|
+
"""Return the input tensor name for an ONNX model."""
|
|
66
|
+
model = onnx.load(str(model_path))
|
|
67
|
+
input_all = [node.name for node in model.graph.input]
|
|
68
|
+
input_init = [node.name for node in model.graph.initializer]
|
|
69
|
+
net_input = list(set(input_all) - set(input_init))
|
|
70
|
+
del model
|
|
71
|
+
return net_input[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _letterbox(
|
|
75
|
+
im: np.ndarray,
|
|
76
|
+
new_shape: tuple[int, int] = (640, 640),
|
|
77
|
+
color: tuple[int, int, int] = (114, 114, 114),
|
|
78
|
+
) -> tuple[np.ndarray, tuple[float, float], tuple[float, float]]:
|
|
79
|
+
"""Resize and pad image while meeting stride-multiple constraints."""
|
|
80
|
+
shape = im.shape[:2] # (height, width)
|
|
81
|
+
r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
|
|
82
|
+
r = min(r, 1.0)
|
|
83
|
+
|
|
84
|
+
new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
|
|
85
|
+
dw = (new_shape[1] - new_unpad[0]) / 2
|
|
86
|
+
dh = (new_shape[0] - new_unpad[1]) / 2
|
|
87
|
+
|
|
88
|
+
if shape[::-1] != new_unpad:
|
|
89
|
+
im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
|
|
90
|
+
|
|
91
|
+
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
|
|
92
|
+
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
|
|
93
|
+
im = cv2.copyMakeBorder(
|
|
94
|
+
im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color
|
|
95
|
+
)
|
|
96
|
+
return im, (r, r), (dw, dh)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _xywh2xyxy(x: torch.Tensor) -> torch.Tensor:
|
|
100
|
+
"""Convert boxes from [x_center, y_center, w, h] to [x0, y0, x1, y1]."""
|
|
101
|
+
y = x.clone()
|
|
102
|
+
y[..., 0] = x[..., 0] - x[..., 2] / 2
|
|
103
|
+
y[..., 1] = x[..., 1] - x[..., 3] / 2
|
|
104
|
+
y[..., 2] = x[..., 0] + x[..., 2] / 2
|
|
105
|
+
y[..., 3] = x[..., 1] + x[..., 3] / 2
|
|
106
|
+
return y
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _nms_yolov8(
|
|
110
|
+
prediction: torch.Tensor,
|
|
111
|
+
conf_thres: float = 0.25,
|
|
112
|
+
iou_thres: float = 0.45,
|
|
113
|
+
max_det: int = 300,
|
|
114
|
+
agnostic: bool = False,
|
|
115
|
+
) -> list[torch.Tensor]:
|
|
116
|
+
"""YOLOv8-format NMS: prediction shape (batch, num_classes+4, num_boxes)."""
|
|
117
|
+
if isinstance(prediction, (list, tuple)):
|
|
118
|
+
prediction = prediction[0]
|
|
119
|
+
|
|
120
|
+
bs = prediction.shape[0]
|
|
121
|
+
nc = prediction.shape[1] - 4
|
|
122
|
+
xc = prediction[:, 4 : 4 + nc].amax(1) > conf_thres
|
|
123
|
+
|
|
124
|
+
max_wh = 7680
|
|
125
|
+
max_nms = 30000
|
|
126
|
+
output = [torch.zeros((0, 6), device=prediction.device)] * bs
|
|
127
|
+
|
|
128
|
+
for xi, x in enumerate(prediction):
|
|
129
|
+
x = x.transpose(0, -1)[xc[xi]]
|
|
130
|
+
if not x.shape[0]:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
box, cls = x[:, :4], x[:, 4 : 4 + nc]
|
|
134
|
+
box = _xywh2xyxy(box)
|
|
135
|
+
conf, j = cls.max(1, keepdim=True)
|
|
136
|
+
x = torch.cat((box, conf, j.float()), 1)[conf.view(-1) > conf_thres]
|
|
137
|
+
|
|
138
|
+
n = x.shape[0]
|
|
139
|
+
if not n:
|
|
140
|
+
continue
|
|
141
|
+
x = x[x[:, 4].argsort(descending=True)[:max_nms]]
|
|
142
|
+
|
|
143
|
+
c = x[:, 5:6] * (0 if agnostic else max_wh)
|
|
144
|
+
boxes, scores = x[:, :4] + c, x[:, 4]
|
|
145
|
+
i = torch.ops.torchvision.nms(boxes, scores, iou_thres)
|
|
146
|
+
i = i[:max_det]
|
|
147
|
+
output[xi] = x[i]
|
|
148
|
+
|
|
149
|
+
return output
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Layout detection
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_layout_detection(
|
|
158
|
+
session: ort.InferenceSession,
|
|
159
|
+
input_name: str,
|
|
160
|
+
ca_img: np.ndarray,
|
|
161
|
+
) -> list[tuple[str, tuple[int, int, int, int], Image.Image]]:
|
|
162
|
+
"""Detect layout regions on a full page image (BGR numpy).
|
|
163
|
+
|
|
164
|
+
Returns list of (class_label, (x0, y0, x1, y1), pil_crop).
|
|
165
|
+
"""
|
|
166
|
+
im = _letterbox(ca_img, (1280, 1280))[0]
|
|
167
|
+
im = im.transpose((2, 0, 1))[::-1] # HWC->CHW, BGR->RGB
|
|
168
|
+
im = np.expand_dims(np.ascontiguousarray(im), axis=0).astype(np.float32) / 255.0
|
|
169
|
+
|
|
170
|
+
predictions = session.run(None, {input_name: im})
|
|
171
|
+
predictions = torch.from_numpy(predictions[0])
|
|
172
|
+
predictions = _nms_yolov8(
|
|
173
|
+
predictions, conf_thres=0.01, iou_thres=0.1, max_det=2000, agnostic=True
|
|
174
|
+
)[0]
|
|
175
|
+
|
|
176
|
+
bboxes = predictions[:, :4]
|
|
177
|
+
labels = predictions[:, -1]
|
|
178
|
+
|
|
179
|
+
layout_img = Image.fromarray(cv2.cvtColor(ca_img, cv2.COLOR_BGR2RGB))
|
|
180
|
+
im_width, im_height = layout_img.size
|
|
181
|
+
|
|
182
|
+
# Compute rescaling from 1280x1280 letterboxed coords back to original
|
|
183
|
+
if im_width > im_height:
|
|
184
|
+
w_ratio = 1280.0
|
|
185
|
+
h_ratio = (im_width / im_height) * 1280.0
|
|
186
|
+
w_trans = 0.0
|
|
187
|
+
h_trans = 1280.0 * ((1 - (im_height / im_width)) / 2)
|
|
188
|
+
else:
|
|
189
|
+
h_trans = 0.0
|
|
190
|
+
h_ratio = 1280.0
|
|
191
|
+
w_trans = 1280.0 * ((1 - (im_width / im_height)) / 2)
|
|
192
|
+
w_ratio = 1280.0 * (im_width / im_height)
|
|
193
|
+
|
|
194
|
+
results: list[tuple[str, tuple[int, int, int, int], Image.Image]] = []
|
|
195
|
+
for bbox, pred_class in zip(bboxes, labels):
|
|
196
|
+
x0, y0, x1, y1 = torch.round(bbox)
|
|
197
|
+
x0 = int(floor((x0.item() - w_trans) * im_width / w_ratio))
|
|
198
|
+
y0 = int(floor((y0.item() - h_trans) * im_height / h_ratio))
|
|
199
|
+
x1 = int(ceil((x1.item() - w_trans) * im_width / w_ratio))
|
|
200
|
+
y1 = int(ceil((y1.item() - h_trans) * im_height / h_ratio))
|
|
201
|
+
|
|
202
|
+
x0, y0 = max(0, x0), max(0, y0)
|
|
203
|
+
x1, y1 = min(im_width, x1), min(im_height, y1)
|
|
204
|
+
|
|
205
|
+
if x1 <= x0 or y1 <= y0:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
crop = layout_img.crop((x0, y0, x1, y1))
|
|
209
|
+
class_label = LAYOUT_CLASSES.get(int(pred_class.item()), "unknown")
|
|
210
|
+
results.append((class_label, (x0, y0, x1, y1), crop))
|
|
211
|
+
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Line detection
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _get_crops_from_layout_image(
|
|
221
|
+
image: Image.Image,
|
|
222
|
+
) -> list[Image.Image]:
|
|
223
|
+
"""Chunk tall layout regions into overlapping crops for line detection."""
|
|
224
|
+
im_width, im_height = image.size
|
|
225
|
+
if im_height <= im_width * 2:
|
|
226
|
+
return [image]
|
|
227
|
+
|
|
228
|
+
y0 = 0
|
|
229
|
+
y1 = im_width * 2
|
|
230
|
+
crops: list[Image.Image] = []
|
|
231
|
+
while y1 <= im_height:
|
|
232
|
+
crops.append(image.crop((0, y0, im_width, y1)))
|
|
233
|
+
y0 += int(im_width * 1.5)
|
|
234
|
+
y1 += int(im_width * 1.5)
|
|
235
|
+
crops.append(image.crop((0, y0, im_width, im_height)))
|
|
236
|
+
return crops
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _readjust_line_detections(
|
|
240
|
+
line_preds: list[tuple[list[tuple[int, int, int, int]], torch.Tensor, torch.Tensor]],
|
|
241
|
+
orig_img_width: int,
|
|
242
|
+
) -> list[tuple[float, float, float, float]]:
|
|
243
|
+
"""Merge line detections from overlapping crops back to layout coords."""
|
|
244
|
+
y0 = 0
|
|
245
|
+
dif = int(orig_img_width * 1.5)
|
|
246
|
+
all_preds: list[tuple[float, ...]] = []
|
|
247
|
+
|
|
248
|
+
for preds, probs, _labels in line_preds:
|
|
249
|
+
for i, pred in enumerate(preds):
|
|
250
|
+
all_preds.append(
|
|
251
|
+
(pred[0], pred[1] + y0, pred[2], pred[3] + y0, probs[i].item())
|
|
252
|
+
)
|
|
253
|
+
y0 += dif
|
|
254
|
+
|
|
255
|
+
all_preds_t = torch.tensor(all_preds)
|
|
256
|
+
final: list[tuple[float, float, float, float]] = []
|
|
257
|
+
if all_preds_t.dim() > 1 and all_preds_t.shape[0] > 0:
|
|
258
|
+
keep = nms(all_preds_t[:, :4], all_preds_t[:, -1], iou_threshold=0.15)
|
|
259
|
+
filtered = all_preds_t[keep, :4]
|
|
260
|
+
filtered = filtered[filtered[:, 1].sort()[1]] # sort by y
|
|
261
|
+
for pred in filtered:
|
|
262
|
+
px0, py0, px1, py1 = torch.round(pred)
|
|
263
|
+
final.append((px0.item(), py0.item(), px1.item(), py1.item()))
|
|
264
|
+
|
|
265
|
+
return final
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _run_line_detection(
|
|
269
|
+
session: ort.InferenceSession,
|
|
270
|
+
input_name: str,
|
|
271
|
+
layout_crops: list[tuple[str, tuple[int, int, int, int], Image.Image]],
|
|
272
|
+
) -> list[tuple[int, str, tuple[int, int, int, int], tuple[int, int, int, int], Image.Image]]:
|
|
273
|
+
"""Run line detection on text-bearing layout regions.
|
|
274
|
+
|
|
275
|
+
Returns list of (layout_idx, class_label, layout_bbox, page_bbox, pil_line_crop).
|
|
276
|
+
"""
|
|
277
|
+
all_lines: list[tuple[int, str, tuple[int, int, int, int], tuple[int, int, int, int], Image.Image]] = []
|
|
278
|
+
|
|
279
|
+
for layout_idx, (class_label, layout_bbox, layout_crop) in enumerate(layout_crops):
|
|
280
|
+
if class_label not in LAYOUT_TYPES_FOR_LINES:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
im_width, im_height = layout_crop.size
|
|
284
|
+
chunks = _get_crops_from_layout_image(layout_crop)
|
|
285
|
+
|
|
286
|
+
chunk_preds: list[tuple[list[tuple[int, int, int, int]], torch.Tensor, torch.Tensor]] = []
|
|
287
|
+
for chunk in chunks:
|
|
288
|
+
chunk_cv = cv2.cvtColor(np.array(chunk), cv2.COLOR_RGB2BGR)
|
|
289
|
+
im = _letterbox(chunk_cv, (640, 640))[0]
|
|
290
|
+
im = im.transpose((2, 0, 1))[::-1]
|
|
291
|
+
im = (
|
|
292
|
+
np.expand_dims(np.ascontiguousarray(im), axis=0).astype(np.float32)
|
|
293
|
+
/ 255.0
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
preds = session.run(None, {input_name: im})
|
|
297
|
+
preds = torch.from_numpy(preds[0])
|
|
298
|
+
preds = _nms_yolov8(
|
|
299
|
+
preds, conf_thres=0.2, iou_thres=0.15, max_det=200
|
|
300
|
+
)[0]
|
|
301
|
+
|
|
302
|
+
preds = preds[preds[:, 1].sort()[1]] # sort by y
|
|
303
|
+
line_bboxes = preds[:, :4]
|
|
304
|
+
line_confs = preds[:, -2]
|
|
305
|
+
line_labels = preds[:, -1]
|
|
306
|
+
|
|
307
|
+
chunk_w, chunk_h = chunk.size
|
|
308
|
+
if chunk_w > chunk_h:
|
|
309
|
+
h_ratio = (chunk_h / chunk_w) * 640
|
|
310
|
+
h_trans = 640 * ((1 - (chunk_h / chunk_w)) / 2)
|
|
311
|
+
else:
|
|
312
|
+
h_trans = 0.0
|
|
313
|
+
h_ratio = 640.0
|
|
314
|
+
|
|
315
|
+
line_proj_crops: list[tuple[int, int, int, int]] = []
|
|
316
|
+
for bbox in line_bboxes:
|
|
317
|
+
bx0, by0, bx1, by1 = torch.round(bbox)
|
|
318
|
+
lx0 = 0
|
|
319
|
+
ly0 = int(floor((by0.item() - h_trans) * chunk_h / h_ratio))
|
|
320
|
+
lx1 = chunk_w
|
|
321
|
+
ly1 = int(ceil((by1.item() - h_trans) * chunk_h / h_ratio))
|
|
322
|
+
line_proj_crops.append((lx0, ly0, lx1, ly1))
|
|
323
|
+
|
|
324
|
+
chunk_preds.append((line_proj_crops, line_confs, line_labels))
|
|
325
|
+
|
|
326
|
+
line_bboxes_in_layout = _readjust_line_detections(chunk_preds, im_width)
|
|
327
|
+
|
|
328
|
+
lx0_page, ly0_page = layout_bbox[0], layout_bbox[1]
|
|
329
|
+
for line_bbox in line_bboxes_in_layout:
|
|
330
|
+
x0 = max(0, int(line_bbox[0]))
|
|
331
|
+
y0 = max(0, int(line_bbox[1]))
|
|
332
|
+
x1 = min(im_width, int(line_bbox[2]))
|
|
333
|
+
y1 = min(im_height, int(line_bbox[3]))
|
|
334
|
+
|
|
335
|
+
if x1 <= x0 or y1 <= y0:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
line_crop = layout_crop.crop((x0, y0, x1, y1))
|
|
339
|
+
if line_crop.size[0] == 0 or line_crop.size[1] == 0:
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
page_bbox = (
|
|
343
|
+
lx0_page + x0,
|
|
344
|
+
ly0_page + y0,
|
|
345
|
+
lx0_page + x1,
|
|
346
|
+
ly0_page + y1,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
all_lines.append(
|
|
350
|
+
(layout_idx, class_label, layout_bbox, page_bbox, line_crop)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return all_lines
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# Detector class
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _resolve_model_path(
|
|
362
|
+
explicit: str | Path | None,
|
|
363
|
+
filename: str,
|
|
364
|
+
model_dir: Path | None,
|
|
365
|
+
) -> Path:
|
|
366
|
+
"""Resolve a model path, checking explicit path, model_dir, cache, and fallback."""
|
|
367
|
+
if explicit is not None:
|
|
368
|
+
p = Path(explicit)
|
|
369
|
+
if p.is_file():
|
|
370
|
+
return p
|
|
371
|
+
raise FileNotFoundError(f"Model file not found: {p}")
|
|
372
|
+
|
|
373
|
+
# Check model_dir if given
|
|
374
|
+
if model_dir is not None:
|
|
375
|
+
p = Path(model_dir) / filename
|
|
376
|
+
if p.is_file():
|
|
377
|
+
return p
|
|
378
|
+
|
|
379
|
+
# Check default cache
|
|
380
|
+
p = _DEFAULT_CACHE_DIR / filename
|
|
381
|
+
if p.is_file():
|
|
382
|
+
return p
|
|
383
|
+
|
|
384
|
+
# Check Dropbox fallback
|
|
385
|
+
p = _DROPBOX_FALLBACK / filename
|
|
386
|
+
if p.is_file():
|
|
387
|
+
return p
|
|
388
|
+
|
|
389
|
+
raise FileNotFoundError(
|
|
390
|
+
f"Could not find {filename}. Searched: "
|
|
391
|
+
f"{model_dir or '(none)'}, {_DEFAULT_CACHE_DIR}, {_DROPBOX_FALLBACK}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class AsYoloDetector(Detector):
|
|
396
|
+
"""American Stories YOLO detector for newspaper layout and line detection.
|
|
397
|
+
|
|
398
|
+
Uses YOLOv8 ONNX models to first detect layout regions (articles, headlines,
|
|
399
|
+
etc.) then detect individual text lines within text-bearing regions.
|
|
400
|
+
|
|
401
|
+
Parameters
|
|
402
|
+
----------
|
|
403
|
+
model_dir : str or Path, optional
|
|
404
|
+
Directory containing both layout and line ONNX models.
|
|
405
|
+
layout_model : str or Path, optional
|
|
406
|
+
Explicit path to the layout detection ONNX model.
|
|
407
|
+
line_model : str or Path, optional
|
|
408
|
+
Explicit path to the line detection ONNX model.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
def __init__(
|
|
412
|
+
self,
|
|
413
|
+
model_dir: str | Path | None = None,
|
|
414
|
+
layout_model: str | Path | None = None,
|
|
415
|
+
line_model: str | Path | None = None,
|
|
416
|
+
):
|
|
417
|
+
model_dir_path = Path(model_dir) if model_dir is not None else None
|
|
418
|
+
|
|
419
|
+
layout_path = _resolve_model_path(
|
|
420
|
+
layout_model, LAYOUT_MODEL_FILENAME, model_dir_path
|
|
421
|
+
)
|
|
422
|
+
line_path = _resolve_model_path(
|
|
423
|
+
line_model, LINE_MODEL_FILENAME, model_dir_path
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
logger.info("Loading layout model from %s", layout_path)
|
|
427
|
+
self._layout_input_name = _get_onnx_input_name(layout_path)
|
|
428
|
+
self._layout_session = ort.InferenceSession(str(layout_path))
|
|
429
|
+
|
|
430
|
+
logger.info("Loading line model from %s", line_path)
|
|
431
|
+
self._line_input_name = _get_onnx_input_name(line_path)
|
|
432
|
+
self._line_session = ort.InferenceSession(str(line_path))
|
|
433
|
+
|
|
434
|
+
def detect(self, image: Image.Image) -> PageLayout:
|
|
435
|
+
"""Detect layout regions and text lines in a newspaper page image.
|
|
436
|
+
|
|
437
|
+
Parameters
|
|
438
|
+
----------
|
|
439
|
+
image : PIL.Image.Image
|
|
440
|
+
Full page image (RGB).
|
|
441
|
+
|
|
442
|
+
Returns
|
|
443
|
+
-------
|
|
444
|
+
PageLayout
|
|
445
|
+
Detected regions with nested lines containing cropped images.
|
|
446
|
+
"""
|
|
447
|
+
# Convert PIL RGB to OpenCV BGR
|
|
448
|
+
img_array = np.array(image)
|
|
449
|
+
if img_array.ndim == 2:
|
|
450
|
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR)
|
|
451
|
+
elif img_array.shape[2] == 4:
|
|
452
|
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2BGR)
|
|
453
|
+
else:
|
|
454
|
+
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR)
|
|
455
|
+
|
|
456
|
+
# Layout detection
|
|
457
|
+
layout_crops = _run_layout_detection(
|
|
458
|
+
self._layout_session, self._layout_input_name, img_array
|
|
459
|
+
)
|
|
460
|
+
logger.info("Detected %d layout regions", len(layout_crops))
|
|
461
|
+
|
|
462
|
+
# Line detection
|
|
463
|
+
line_results = _run_line_detection(
|
|
464
|
+
self._line_session, self._line_input_name, layout_crops
|
|
465
|
+
)
|
|
466
|
+
logger.info("Detected %d lines", len(line_results))
|
|
467
|
+
|
|
468
|
+
# Build PageLayout
|
|
469
|
+
# Group lines by layout_idx
|
|
470
|
+
lines_by_region: dict[int, list[tuple[tuple[int, int, int, int], Image.Image]]] = {}
|
|
471
|
+
for layout_idx, _cls, _layout_bbox, page_bbox, line_crop in line_results:
|
|
472
|
+
lines_by_region.setdefault(layout_idx, []).append((page_bbox, line_crop))
|
|
473
|
+
|
|
474
|
+
regions: list[Region] = []
|
|
475
|
+
for layout_idx, (class_label, layout_bbox, layout_crop) in enumerate(
|
|
476
|
+
layout_crops
|
|
477
|
+
):
|
|
478
|
+
lines: list[Line] = []
|
|
479
|
+
for page_bbox, line_crop in lines_by_region.get(layout_idx, []):
|
|
480
|
+
lines.append(
|
|
481
|
+
Line(
|
|
482
|
+
bbox=BBox(*page_bbox),
|
|
483
|
+
image=line_crop,
|
|
484
|
+
)
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
regions.append(
|
|
488
|
+
Region(
|
|
489
|
+
bbox=BBox(*layout_bbox),
|
|
490
|
+
image=layout_crop,
|
|
491
|
+
label=class_label,
|
|
492
|
+
lines=lines,
|
|
493
|
+
confidence=1.0, # Already passed NMS threshold
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return PageLayout(
|
|
498
|
+
image=image,
|
|
499
|
+
regions=regions,
|
|
500
|
+
width=image.width,
|
|
501
|
+
height=image.height,
|
|
502
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from PIL import Image
|
|
4
|
+
from newspaper_ocr.models import PageLayout
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Detector(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def detect(self, image: Image.Image) -> PageLayout:
|
|
10
|
+
"""Detect layout regions and lines in an image."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from newspaper_ocr.detectors.base import Detector
|
|
9
|
+
from newspaper_ocr.models import BBox, PageLayout, Region
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PaddleXDetector(Detector):
|
|
13
|
+
"""PP-DocLayout_plus-L detector. Requires: pip install paddlepaddle paddlex"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, model_name: str = "PP-DocLayout_plus-L", **kwargs):
|
|
16
|
+
try:
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
|
20
|
+
from paddlex import create_model
|
|
21
|
+
except ImportError:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"PaddleX is not installed. Install with:\n"
|
|
24
|
+
" pip install paddlepaddle paddlex\n"
|
|
25
|
+
" or: uv pip install paddlepaddle paddlex"
|
|
26
|
+
)
|
|
27
|
+
self.model = create_model(model_name)
|
|
28
|
+
|
|
29
|
+
def detect(self, image: Image.Image) -> PageLayout:
|
|
30
|
+
w, h = image.size
|
|
31
|
+
|
|
32
|
+
# PaddleX needs a file path
|
|
33
|
+
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
|
34
|
+
image.save(f.name)
|
|
35
|
+
results = list(self.model.predict(f.name))
|
|
36
|
+
Path(f.name).unlink(missing_ok=True)
|
|
37
|
+
|
|
38
|
+
regions = []
|
|
39
|
+
for result in results:
|
|
40
|
+
for box in result.get("boxes", []):
|
|
41
|
+
coords = box.get("coordinate", [0, 0, 0, 0])
|
|
42
|
+
x0, y0, x1, y1 = [int(c) for c in coords]
|
|
43
|
+
x0, y0 = max(0, x0), max(0, y0)
|
|
44
|
+
x1, y1 = min(w, x1), min(h, y1)
|
|
45
|
+
if x1 <= x0 or y1 <= y0:
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
crop = image.crop((x0, y0, x1, y1))
|
|
49
|
+
regions.append(
|
|
50
|
+
Region(
|
|
51
|
+
bbox=BBox(x0, y0, x1, y1),
|
|
52
|
+
image=crop,
|
|
53
|
+
label=box.get("label", "unknown"),
|
|
54
|
+
lines=[],
|
|
55
|
+
confidence=box.get("score", 0.0),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return PageLayout(image=image, regions=regions, width=w, height=h)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from newspaper_ocr.formatters.base import Formatter
|
|
2
|
+
from newspaper_ocr.formatters.text import TextFormatter
|
|
3
|
+
from newspaper_ocr.formatters.json_fmt import JsonFormatter
|
|
4
|
+
from newspaper_ocr.formatters.hocr import HocrFormatter
|
|
5
|
+
from newspaper_ocr.registry import Registry
|
|
6
|
+
|
|
7
|
+
FORMATTERS = Registry("formatter")
|
|
8
|
+
FORMATTERS.register("text", TextFormatter)
|
|
9
|
+
FORMATTERS.register("json", JsonFormatter)
|
|
10
|
+
FORMATTERS.register("hocr", HocrFormatter)
|