document-quality 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,67 @@
1
+ """Decide whether a scanned page is good enough to OCR before you pay to OCR it.
2
+
3
+ OCR is billed per page and a bad scan costs the same as a good one, then has to
4
+ be caught, re-scanned and re-run. This package reads the page first and answers
5
+ in one line::
6
+
7
+ import document_quality
8
+
9
+ report = document_quality.assess("invoice.png")
10
+ print(report.summary())
11
+
12
+ if not report.ocr_ready:
13
+ for issue in report.issues:
14
+ print(issue.kind, "->", issue.fix)
15
+
16
+ Every verdict carries the number behind it and the boundary it was compared
17
+ against, and every problem carries the concrete remedy: "rescan at 300 dpi",
18
+ "deskew by 2.3 degrees clockwise", "increase lighting on the left edge",
19
+ "crop the black border before OCR". Nothing here
20
+ downloads a model, calls out to a network, or needs OpenCV - it is numpy and
21
+ Pillow measuring a page.
22
+
23
+ What gets measured: effective resolution, skew angle, ink-to-paper contrast,
24
+ sharpness, uneven lighting (as a gradient of the paper level, so a shadow is
25
+ never mistaken for a scanner border), show-through from the reverse side, black
26
+ and white clipping, genuinely black scanner borders, text line height in
27
+ pixels, and how much of the page looks like text.
28
+ A blank sheet is reported as blank rather than as eight failures, and a
29
+ photograph is reported as not a document page rather than as a bad one.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from ._assess import (
34
+ SCORE_WEIGHTS,
35
+ assess,
36
+ assess_batch,
37
+ detect_orientation,
38
+ estimate_skew,
39
+ )
40
+ from ._report import PAGE_KINDS, SEVERITIES, BatchReport, Issue, Measure, PageReport
41
+ from ._thresholds import (
42
+ DEFAULT_THRESHOLDS,
43
+ PASS_SCORE,
44
+ Thresholds,
45
+ describe_thresholds,
46
+ )
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "assess",
52
+ "assess_batch",
53
+ "estimate_skew",
54
+ "detect_orientation",
55
+ "PageReport",
56
+ "BatchReport",
57
+ "Issue",
58
+ "Measure",
59
+ "Thresholds",
60
+ "DEFAULT_THRESHOLDS",
61
+ "describe_thresholds",
62
+ "PAGE_KINDS",
63
+ "SEVERITIES",
64
+ "SCORE_WEIGHTS",
65
+ "PASS_SCORE",
66
+ "__version__",
67
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m document_quality`` runs the same command line as ``document-quality``."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__": # pragma: no cover - module entry point
7
+ raise SystemExit(main())
@@ -0,0 +1,477 @@
1
+ """The four public entry points, and the order the work happens in.
2
+
3
+ One page goes through the same seven steps every time:
4
+
5
+ 1. :func:`document_quality._images.prepare` opens it once and builds the planes.
6
+ 2. :func:`document_quality._lighting.find_border` looks for genuinely black
7
+ bands along the image edges, and the planes are cropped to inside them;
8
+ the black corners of a sheet scanned crooked on a dark lid are painted out.
9
+ 3. :func:`document_quality._measures.analyse` computes what every measure
10
+ shares, against a paper surface estimated everywhere on the page.
11
+ 4. :func:`document_quality._skew.orient` settles which way up the page is,
12
+ from ink flat-fielded by that same surface. A page lying on its side is
13
+ turned upright, so that every line measure describes the page it will be
14
+ once rotated.
15
+ 5. The projection profile gives the skew and the line geometry.
16
+ 6. :func:`document_quality._measures.classify` decides whether this is a
17
+ document, a blank sheet or a photograph.
18
+ 7. Each measure turns its number into a score and, when something is wrong, an
19
+ :class:`~document_quality._report.Issue` carrying the remedy.
20
+
21
+ Two of those steps exist to stop the report from lying. Classification comes
22
+ before the issue list because a blank sheet should be told it is blank, not
23
+ handed eight complaints about text it does not have, and a photograph should be
24
+ told it is not a document page rather than advised to rescan at 300 dpi.
25
+ Resolution is scored only when a dpi is actually known, because a pixel count
26
+ alone cannot tell a 300 dpi letter page from a 600 dpi receipt.
27
+
28
+ The caller's image is never modified. Arrays are copied on the way in, planes
29
+ are freshly allocated, and nothing here writes through a view of the original.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import dataclasses
34
+ import logging
35
+ import os
36
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
37
+
38
+ import numpy as np
39
+ from PIL import Image
40
+
41
+ from . import _images, _lighting, _measures, _skew
42
+ from ._lighting import Border
43
+ from ._images import PagePlanes
44
+ from ._measures import PageStats
45
+ from ._report import BatchReport, Issue, Measure, PageReport
46
+ from ._skew import LineGeometry
47
+ from ._thresholds import ThresholdLike, Thresholds, resolve_thresholds
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ #: How much each measure counts towards the overall score. Measures that did
52
+ #: not apply drop out and the rest are re-weighted, so a file with no dpi is
53
+ #: not punished for the resolution measure it could not take.
54
+ SCORE_WEIGHTS: Dict[str, float] = {
55
+ "resolution": 1.0,
56
+ "text_size": 1.5,
57
+ "skew": 1.0,
58
+ "contrast": 1.5,
59
+ "sharpness": 1.5,
60
+ "lighting": 0.8,
61
+ "show_through": 0.8,
62
+ "clipping": 0.5,
63
+ }
64
+
65
+ def _prepare(image: Any, dpi: Any) -> Tuple[PagePlanes, Border]:
66
+ """Open ``image``, crop to inside any black border, and paint out the lid.
67
+
68
+ A border is a black band along a whole side, and is cropped off and
69
+ reported. What is left of the black after that - the wedges in the corners
70
+ of a sheet scanned crooked on a dark lid - is not part of the page either,
71
+ but it cannot be cropped without cropping the page, so it is painted over
72
+ with paper on every plane. Left in, a blank sheet scanned crooked would
73
+ read as a page of solid black triangles, and their edges as lines of text.
74
+ """
75
+ planes = _images.prepare(image, dpi)
76
+ border = _lighting.find_border(planes.work, planes.work_scale)
77
+ if border.found:
78
+ planes = planes.cropped(border.box(planes.width, planes.height))
79
+ mask, share, black_level = _lighting.find_outside(planes.work)
80
+ if share > 0.0:
81
+ planes = _paint_outside(planes, mask, share, black_level)
82
+ return planes, border
83
+
84
+
85
+ def _paint_outside(
86
+ planes: PagePlanes, mask: np.ndarray, share: float, black_level: float
87
+ ) -> PagePlanes:
88
+ """``planes`` with the black lid outside the sheet painted as paper."""
89
+ paper = float(np.percentile(planes.work[~mask], 90.0)) if (~mask).any() else 1.0
90
+ work = planes.work.copy()
91
+ work[mask] = np.float32(paper)
92
+ grow = _lighting.OUTSIDE_GROW_PX
93
+ edge = _lighting.BORDER_EDGE_PX
94
+ step = _lighting.BORDER_STEP * float(np.percentile(planes.work, 90.0))
95
+ fine = planes.fine.copy()
96
+ fine_ratio = planes.fine_scale / max(planes.work_scale, 1e-9)
97
+ fine[_lighting.outside_mask(
98
+ fine, black_level, int(np.ceil(grow * fine_ratio)), step,
99
+ int(np.ceil(edge * fine_ratio)),
100
+ )] = np.float32(paper)
101
+ lum = planes.lum.copy()
102
+ native_ratio = 1.0 / max(planes.work_scale, 1e-9)
103
+ lum[_lighting.outside_mask(
104
+ lum, black_level * 255.0, int(np.ceil(grow * native_ratio)), step * 255.0,
105
+ int(np.ceil(edge * native_ratio)),
106
+ )] = int(round(paper * 255.0))
107
+ return dataclasses.replace(planes, lum=lum, work=work, fine=fine, outside_share=share)
108
+
109
+
110
+ def _skew_and_lines(
111
+ planes: PagePlanes, light: Optional[_lighting.PaperSurface] = None
112
+ ) -> Tuple[float, LineGeometry, bool]:
113
+ """The skew angle, the line geometry it implies, and whether there are lines.
114
+
115
+ Both planes are flat-fielded against the page's paper surface first. The
116
+ angle is swept on the small plane, where hundreds of candidate shears cost
117
+ milliseconds, and refined on the work plane; the geometry it implies is
118
+ measured once on the work plane, where a line of text is enough pixels
119
+ tall to measure.
120
+ """
121
+ ink = _skew.ink_planes(planes, light)
122
+ degrees, _ = _skew.skew_of(ink)
123
+ geometry = _skew.line_geometry_of_ink(ink.work, degrees, ink.work_bank())
124
+ return degrees, geometry, _skew.holds_text_lines(geometry, planes.work.shape[0])
125
+
126
+
127
+ def _describe_source(image: Any, given: Optional[str]) -> str:
128
+ """A short name for whatever the caller passed in."""
129
+ if given is not None:
130
+ return str(given)
131
+ if isinstance(image, (str, os.PathLike)):
132
+ return os.fspath(image)
133
+ if isinstance(image, np.ndarray):
134
+ return "<array {0}>".format("x".join(str(n) for n in image.shape))
135
+ if isinstance(image, Image.Image):
136
+ name = getattr(image, "filename", None)
137
+ return str(name) if name else "<image {0}x{1}>".format(*image.size)
138
+ return "<image>"
139
+
140
+
141
+ def _overall_score(measures: Dict[str, Measure]) -> float:
142
+ """Weighted mean of the measures that applied and produced a score."""
143
+ total = 0.0
144
+ weight = 0.0
145
+ for name, measure in measures.items():
146
+ if measure.score is None or not measure.applies:
147
+ continue
148
+ share = SCORE_WEIGHTS.get(name, 1.0)
149
+ total += share * float(measure.score)
150
+ weight += share
151
+ if weight <= 0.0:
152
+ return 0.0
153
+ return float(np.clip(total / weight, 0.0, 100.0))
154
+
155
+
156
+ def _blank_issue(stats: PageStats, reasons: Sequence[str]) -> Issue:
157
+ """The one thing worth saying about a sheet with nothing on it.
158
+
159
+ Severity is ``info``, not a failure: a blank page is a fact about the
160
+ stack of paper, not a fault in the scan. Nothing else is reported, because
161
+ every other complaint would be about text that is not there.
162
+ """
163
+ why = reasons[0] if reasons else "nothing was found on the sheet"
164
+ return Issue(
165
+ "blank",
166
+ "info",
167
+ "This sheet is blank: {0}. There is nothing on it to OCR.".format(why),
168
+ "skip this page, or check the scanner fed the printed side of the sheet",
169
+ )
170
+
171
+
172
+ def _photograph_issue(reasons: Sequence[str]) -> Issue:
173
+ """Why this is not a document page, and what to do with it instead."""
174
+ why = "; ".join(reasons) if reasons else "it does not look like ink on paper"
175
+ return Issue(
176
+ "not_a_document",
177
+ "warning",
178
+ "This does not look like a document page: {0}.".format(why),
179
+ "send this to an image pipeline rather than an OCR one; if it really is "
180
+ "a page, crop to the sheet and rescan it as a document",
181
+ )
182
+
183
+
184
+ def _orientation_issue(turn: int) -> Optional[Issue]:
185
+ """The page is not the right way up, and every measure below it is sideways."""
186
+ if turn == 0:
187
+ return None
188
+ if turn == 180:
189
+ return Issue(
190
+ "orientation", "failure",
191
+ "The text on this page reads upside down.",
192
+ "rotate the page 180 degrees before OCR",
193
+ )
194
+ direction = "counter-clockwise" if turn == 90 else "clockwise"
195
+ return Issue(
196
+ "orientation", "failure",
197
+ "The text on this page runs sideways, so the page is lying on its side.",
198
+ "rotate the page 90 degrees {0} before OCR".format(direction),
199
+ )
200
+
201
+
202
+ def _measure_page(
203
+ planes: PagePlanes,
204
+ stats: PageStats,
205
+ geometry: LineGeometry,
206
+ degrees: float,
207
+ thresholds: Thresholds,
208
+ kind: str = "document",
209
+ ) -> Tuple[Dict[str, Measure], List[Issue], Optional[float]]:
210
+ """Run every measure; return them by name, their issues, and the text height."""
211
+ measures: Dict[str, Measure] = {}
212
+ issues: List[Issue] = []
213
+
214
+ def add(built: Tuple[Measure, Optional[Issue]]) -> Measure:
215
+ measure, issue = built
216
+ measures[measure.name] = measure
217
+ if issue is not None:
218
+ issues.append(issue)
219
+ return measure
220
+
221
+ add(_measures.resolution(planes, thresholds))
222
+
223
+ size_measure, size_issue, text_height_px = _measures.text_size(
224
+ planes, geometry, planes.work_scale, thresholds, document=kind == "document"
225
+ )
226
+ measures[size_measure.name] = size_measure
227
+ if size_issue is not None:
228
+ issues.append(size_issue)
229
+
230
+ add(_measures.skew(degrees, thresholds, applies=geometry.text_height is not None))
231
+ add(_measures.contrast(stats, thresholds))
232
+ add(_measures.sharpness(stats, text_height_px, thresholds))
233
+ add(_measures.lighting(stats, thresholds))
234
+ add(_measures.show_through(stats, thresholds))
235
+ add(_measures.clipping(stats, thresholds))
236
+ add(_measures.border(stats))
237
+ measures["text_coverage"] = _measures.text_coverage(stats)
238
+ return measures, issues, text_height_px
239
+
240
+
241
+ def assess(
242
+ image: Any,
243
+ *,
244
+ dpi: Optional[float] = None,
245
+ thresholds: ThresholdLike = None,
246
+ source: Optional[str] = None,
247
+ check_orientation: bool = True,
248
+ ) -> PageReport:
249
+ """Decide whether one scanned page is worth sending to an OCR engine.
250
+
251
+ Args:
252
+ image: a path, a ``PIL.Image.Image``, or a numpy array shaped
253
+ ``(h, w)``, ``(h, w, 1)``, ``(h, w, 3)`` or ``(h, w, 4)``. Greyscale
254
+ and colour are both fine, and the caller's image is never modified.
255
+ dpi: the scan resolution, if you know it. When it is ``None`` the
256
+ file's own resolution tag is used, and when the file has none
257
+ either, resolution advice is left out of the report rather than
258
+ guessed from the pixel count.
259
+ thresholds: a :class:`~document_quality.Thresholds` or a dict of
260
+ overrides, for pages that are not 300 dpi office scans.
261
+ source: a name for this page in the report. Defaults to the path, or a
262
+ description of the in-memory image.
263
+ check_orientation: whether to also test the three quarter-turns, and
264
+ measure a sideways page as the upright page it will be. Costs a
265
+ fraction of a skew search; pass ``False`` for pages you know are
266
+ the right way up.
267
+
268
+ Returns:
269
+ A :class:`~document_quality.PageReport`. Start with ``.ocr_ready``,
270
+ ``.score`` and ``.issues``; every issue carries its own ``.fix``.
271
+
272
+ Raises:
273
+ FileNotFoundError: if a path does not exist.
274
+ TypeError: if ``image`` is none of the accepted kinds.
275
+ ValueError: if the image has no pixels, or ``dpi`` is not positive.
276
+ """
277
+ settings = resolve_thresholds(thresholds)
278
+ name = _describe_source(image, source)
279
+ planes, border = _prepare(image, dpi)
280
+
281
+ stats = _measures.analyse(planes, settings, border)
282
+
283
+ # Orientation is settled before any line is measured. A page lying on its
284
+ # side has its lines running down the image, and every line measure taken
285
+ # that way round - text height, skew, the text-row test itself - would be
286
+ # a measure of the wrong axis. So a sideways page is turned upright first
287
+ # and reported as the page it will be once rotated. A page whose tone
288
+ # already rules it out as a document skips the search entirely.
289
+ turn, basis = 0, "none"
290
+ found: Optional[_skew.Orientation] = None
291
+ if check_orientation and not _measures.settled_without_lines(planes, stats, settings):
292
+ found = _skew.orient(planes, stats.light)
293
+ turn, basis = found.degrees, found.basis
294
+ if turn in (90, 270):
295
+ planes = planes.turned(turn // 90)
296
+ stats = _measures.turn_stats(stats, turn // 90)
297
+ if found is not None:
298
+ # The orientation search already measured the skew and the lines of
299
+ # the page the right way round; searching again would find the same.
300
+ degrees, geometry = found.skew, found.geometry
301
+ else:
302
+ degrees, geometry, _ = _skew_and_lines(planes, stats.light)
303
+ kind, evidence, reasons = _measures.classify(planes, stats, geometry, settings)
304
+ if kind != "document":
305
+ # A blank sheet or a photograph has no lines of text, whatever the
306
+ # profile of its grain or its horizon happened to suggest. Reporting a
307
+ # text height or a skew for it would be a number about nothing.
308
+ degrees, geometry = 0.0, LineGeometry(line_contrast=geometry.line_contrast)
309
+ turn = 0
310
+
311
+ measures, issues, text_height_px = _measure_page(
312
+ planes, stats, geometry, degrees, settings, kind
313
+ )
314
+ notes: List[str] = []
315
+ if planes.exif_applied:
316
+ notes.append(
317
+ "The file carried an EXIF orientation tag, which was applied before "
318
+ "measuring, so these numbers describe the upright page."
319
+ )
320
+
321
+ if border.found:
322
+ notes.append(
323
+ "A black scanner border was cropped off the {0} before measuring, so "
324
+ "these numbers describe the page inside it.".format(border.describe())
325
+ )
326
+ if planes.outside_share > 0.0:
327
+ notes.append(
328
+ "The sheet lies crooked on a dark lid; the black corners outside it "
329
+ "({0:.1%} of the image) were left out of every measure.".format(
330
+ planes.outside_share
331
+ )
332
+ )
333
+
334
+ if turn in (90, 270):
335
+ notes.append(
336
+ "The text runs down the image, so the page was measured as if "
337
+ "already turned upright; the numbers describe it after rotation."
338
+ )
339
+ if kind == "document" and basis == "lines":
340
+ if turn:
341
+ notes.append(
342
+ "Which way the lines run was clear, but upright versus upside "
343
+ "down was not, so a 180 degree turn cannot be ruled out."
344
+ )
345
+ else:
346
+ # Silence here would read as "checked, and upright". Say instead
347
+ # that the letters gave no clear sign either way - a soft scan,
348
+ # or text set in capitals or figures - so an upside-down page is
349
+ # still possible.
350
+ notes.append(
351
+ "The letters give no clear sign of which way up the page "
352
+ "reads, so it was taken to be upright; an upside-down page "
353
+ "cannot be ruled out."
354
+ )
355
+
356
+ # A page that is not a document gets one thing said about it, not eight.
357
+ # Telling the owner of a photograph to increase the lighting on its left
358
+ # edge is advice about a page they do not have.
359
+ if kind == "blank":
360
+ issues = [_blank_issue(stats, reasons)]
361
+ elif kind == "photograph":
362
+ issues = [_photograph_issue(reasons)]
363
+ else:
364
+ turn_issue = _orientation_issue(turn)
365
+ if turn_issue is not None:
366
+ issues.insert(0, turn_issue)
367
+ issues.sort(key=lambda item: item.rank)
368
+
369
+ # The score answers "how good is this for OCR", so a sheet with nothing to
370
+ # read scores nothing however cleanly it was scanned. The per-measure
371
+ # scores are still in .measures for anyone who wants the scan quality.
372
+ score = _overall_score(measures) if kind == "document" else 0.0
373
+ ready = (
374
+ kind == "document"
375
+ and score >= settings.ready_score
376
+ and not any(item.severity == "failure" for item in issues)
377
+ )
378
+
379
+ return PageReport(
380
+ source=name,
381
+ width=planes.width,
382
+ height=planes.height,
383
+ kind=kind,
384
+ ocr_ready=bool(ready),
385
+ score=score,
386
+ skew_degrees=float(degrees),
387
+ estimated_text_height_px=text_height_px,
388
+ issues=issues,
389
+ measures=measures,
390
+ dpi=planes.dpi,
391
+ dpi_source=planes.dpi_source,
392
+ evidence=dict(evidence),
393
+ notes=notes,
394
+ )
395
+
396
+
397
+ def assess_batch(
398
+ images: Iterable[Any],
399
+ *,
400
+ dpi: Optional[float] = None,
401
+ thresholds: ThresholdLike = None,
402
+ check_orientation: bool = True,
403
+ ) -> BatchReport:
404
+ """Assess many pages and sort the ones needing attention to the front.
405
+
406
+ A page that cannot be read does not stop the batch: the error is recorded
407
+ against that page in :attr:`~document_quality.BatchReport.failures` and the
408
+ rest are assessed.
409
+
410
+ Args:
411
+ images: any iterable of the things :func:`assess` accepts.
412
+ dpi: applied to every page that does not carry its own.
413
+ thresholds: as for :func:`assess`.
414
+ check_orientation: as for :func:`assess`.
415
+
416
+ Returns:
417
+ A :class:`~document_quality.BatchReport`. Start with ``.not_ready``.
418
+
419
+ Raises:
420
+ TypeError: if ``images`` is a single image or a bare string rather than
421
+ an iterable of them.
422
+ """
423
+ if isinstance(images, (str, bytes, os.PathLike, Image.Image, np.ndarray)):
424
+ raise TypeError(
425
+ "assess_batch takes an iterable of images; pass [image] for one, or "
426
+ "call assess(image)"
427
+ )
428
+ settings = resolve_thresholds(thresholds)
429
+ batch = BatchReport()
430
+ for item in images:
431
+ name = _describe_source(item, None)
432
+ try:
433
+ batch.reports.append(
434
+ assess(
435
+ item, dpi=dpi, thresholds=settings,
436
+ check_orientation=check_orientation,
437
+ )
438
+ )
439
+ except (OSError, ValueError, TypeError) as error:
440
+ logger.warning("could not assess %s: %s", name, error)
441
+ batch.failures.append({"source": name, "error": str(error)})
442
+ return batch
443
+
444
+
445
+ def estimate_skew(image: Any) -> float:
446
+ """How far the text on ``image`` is turned from horizontal, in degrees.
447
+
448
+ Positive is counter-clockwise, matching ``PIL.Image.rotate``, so
449
+ ``image.rotate(-estimate_skew(image))`` puts the page straight. A page with
450
+ no text lines on it returns ``0.0``.
451
+
452
+ Raises:
453
+ FileNotFoundError: if a path does not exist.
454
+ TypeError: if ``image`` is none of the accepted kinds.
455
+ ValueError: if the image has no pixels.
456
+ """
457
+ planes, _ = _prepare(image, None)
458
+ degrees, _, has_lines = _skew_and_lines(planes)
459
+ return float(degrees) if has_lines else 0.0
460
+
461
+
462
+ def detect_orientation(image: Any) -> int:
463
+ """Which quarter turn sets ``image`` upright: ``0``, ``90``, ``180`` or ``270``.
464
+
465
+ The answer is counter-clockwise degrees, so
466
+ ``image.rotate(detect_orientation(image), expand=True)`` is the correction.
467
+ A page with no text lines on it returns ``0``, because there is nothing to
468
+ be upright about.
469
+
470
+ Raises:
471
+ FileNotFoundError: if a path does not exist.
472
+ TypeError: if ``image`` is none of the accepted kinds.
473
+ ValueError: if the image has no pixels.
474
+ """
475
+ planes, _ = _prepare(image, None)
476
+ turn, _ = _skew.detect_orientation_on_plane(planes)
477
+ return int(turn)