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.
- document_quality/__init__.py +67 -0
- document_quality/__main__.py +7 -0
- document_quality/_assess.py +477 -0
- document_quality/_images.py +508 -0
- document_quality/_lighting.py +636 -0
- document_quality/_measures.py +1070 -0
- document_quality/_report.py +522 -0
- document_quality/_skew.py +1009 -0
- document_quality/_thresholds.py +295 -0
- document_quality/cli.py +253 -0
- document_quality-0.1.0.dist-info/METADATA +192 -0
- document_quality-0.1.0.dist-info/RECORD +15 -0
- document_quality-0.1.0.dist-info/WHEEL +4 -0
- document_quality-0.1.0.dist-info/entry_points.txt +2 -0
- document_quality-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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)
|