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.
@@ -0,0 +1,5 @@
1
+ """newspaper-ocr: Modular OCR pipeline for historical newspaper scans."""
2
+ from newspaper_ocr.models import BBox, Line, Region, PageLayout
3
+ from newspaper_ocr.pipeline import Pipeline
4
+
5
+ __all__ = ["Pipeline", "BBox", "Line", "Region", "PageLayout"]
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)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from newspaper_ocr.models import PageLayout
4
+
5
+
6
+ class Formatter(ABC):
7
+ @abstractmethod
8
+ def format(self, layout: PageLayout) -> str:
9
+ """Format OCR results into output string."""