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,508 @@
|
|
|
1
|
+
"""Getting any scan into one predictable shape before anything is measured.
|
|
2
|
+
|
|
3
|
+
Nothing here judges a page. It opens files, applies EXIF orientation so every
|
|
4
|
+
later measurement refers to the upright image, flattens the zoo of Pillow modes
|
|
5
|
+
(L, LA, P, RGBA, CMYK, I, F, 1) down to 8-bit luminance, and builds the two
|
|
6
|
+
downscaled working planes the measurements share.
|
|
7
|
+
|
|
8
|
+
Three scales are kept on purpose:
|
|
9
|
+
|
|
10
|
+
``lum``
|
|
11
|
+
Native resolution, 8-bit. Sharpness and clipping have to be read here -
|
|
12
|
+
resizing an image changes exactly the thing they measure.
|
|
13
|
+
``work``
|
|
14
|
+
Long edge capped at :data:`WORK_LONG_EDGE`. Lighting, show-through and the
|
|
15
|
+
text mask live here; they care about page-sized structure, not pixels.
|
|
16
|
+
``fine``
|
|
17
|
+
Long edge capped at :data:`SKEW_LONG_EDGE`. Skew and line pitch live here,
|
|
18
|
+
small enough that a few hundred candidate angles cost milliseconds.
|
|
19
|
+
|
|
20
|
+
The caller's image is never modified. Every array handed out is freshly
|
|
21
|
+
allocated, and nothing in this package writes through a view of it.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import dataclasses
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from PIL import Image, ImageOps
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
#: File suffixes the CLI picks up when pointed at a directory.
|
|
37
|
+
IMAGE_SUFFIXES = {
|
|
38
|
+
".bmp", ".gif", ".jpeg", ".jpg", ".jp2", ".png", ".pnm", ".ppm", ".pgm",
|
|
39
|
+
".tif", ".tiff", ".webp",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#: Long edge of the plane used for lighting, show-through and the text mask.
|
|
43
|
+
WORK_LONG_EDGE = 1024
|
|
44
|
+
#: Long edge of the plane used for skew, line pitch and text height.
|
|
45
|
+
SKEW_LONG_EDGE = 512
|
|
46
|
+
#: Long edge of the thumbnail used to ask "is this page in colour at all".
|
|
47
|
+
COLOUR_LONG_EDGE = 192
|
|
48
|
+
#: Anything claiming fewer dots per inch than this is metadata noise, not a dpi.
|
|
49
|
+
MIN_CREDIBLE_DPI = 24.0
|
|
50
|
+
#: Anything claiming more than this is metadata noise too.
|
|
51
|
+
MAX_CREDIBLE_DPI = 4800.0
|
|
52
|
+
#: A dpi read from a file within this of a whole number is taken as that
|
|
53
|
+
#: number: PNG's pixels-per-metre field cannot store 300 dpi exactly.
|
|
54
|
+
DPI_SNAP = 0.05
|
|
55
|
+
|
|
56
|
+
_EXIF_ORIENTATION_TAG = 0x0112
|
|
57
|
+
_WIDE_MODES = ("I", "I;16", "I;16B", "I;16L", "I;16N", "F")
|
|
58
|
+
|
|
59
|
+
# Pillow moved the resampling filters in 9.1; both spellings are supported.
|
|
60
|
+
_BOX = getattr(getattr(Image, "Resampling", Image), "BOX")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: About how many pixels a page-wide percentile is taken over. A level such as
|
|
64
|
+
#: "the 99.5th percentile of the page" is a statistic of the whole sheet, and
|
|
65
|
+
#: an evenly strided sample of this size gives it to well under a grey level
|
|
66
|
+
#: while costing a fraction of a full partition of a megapixel plane.
|
|
67
|
+
PERCENTILE_SAMPLE = 200_000
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def percentile_sample(plane: np.ndarray, target: int = PERCENTILE_SAMPLE) -> np.ndarray:
|
|
71
|
+
"""An evenly strided view of ``plane`` with about ``target`` pixels.
|
|
72
|
+
|
|
73
|
+
Only ever read from; a plane already that small comes back as it is.
|
|
74
|
+
"""
|
|
75
|
+
stride = int(round(np.sqrt(plane.size / float(max(1, target)))))
|
|
76
|
+
if stride <= 1:
|
|
77
|
+
return plane
|
|
78
|
+
return plane[::stride, ::stride]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def looks_like_image_path(path: Any) -> bool:
|
|
82
|
+
"""True when ``path`` has a suffix Pillow normally reads."""
|
|
83
|
+
return os.path.splitext(str(path))[1].lower() in IMAGE_SUFFIXES
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def open_image(source: Any) -> Image.Image:
|
|
87
|
+
"""Open ``source`` as a PIL image without altering it.
|
|
88
|
+
|
|
89
|
+
Accepts a ``PIL.Image.Image`` (returned as-is), a path (``str`` or
|
|
90
|
+
``os.PathLike``), or a numpy array shaped ``(h, w)``, ``(h, w, 1)``,
|
|
91
|
+
``(h, w, 3)`` or ``(h, w, 4)``.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
FileNotFoundError: if a path does not exist.
|
|
95
|
+
TypeError: if ``source`` is none of the accepted kinds.
|
|
96
|
+
ValueError: if an array has a shape no image could have.
|
|
97
|
+
"""
|
|
98
|
+
if isinstance(source, Image.Image):
|
|
99
|
+
return source
|
|
100
|
+
if isinstance(source, np.ndarray):
|
|
101
|
+
return image_from_array(source)
|
|
102
|
+
if isinstance(source, (str, os.PathLike)):
|
|
103
|
+
path = os.fspath(source)
|
|
104
|
+
if not os.path.exists(path):
|
|
105
|
+
raise FileNotFoundError("{0!r} does not exist".format(path))
|
|
106
|
+
if os.path.isdir(path):
|
|
107
|
+
raise IsADirectoryError(
|
|
108
|
+
"{0!r} is a folder, not an image file; pass the files in it, or "
|
|
109
|
+
"use assess_batch".format(path)
|
|
110
|
+
)
|
|
111
|
+
image = Image.open(path)
|
|
112
|
+
try:
|
|
113
|
+
image.load()
|
|
114
|
+
except BaseException:
|
|
115
|
+
# A truncated or corrupt file must not stay open - on Windows an
|
|
116
|
+
# open handle keeps the file locked until garbage collection.
|
|
117
|
+
image.close()
|
|
118
|
+
raise
|
|
119
|
+
return image
|
|
120
|
+
raise TypeError(
|
|
121
|
+
"image must be a PIL.Image, a file path or a numpy array, not {0}".format(
|
|
122
|
+
type(source).__name__
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def image_from_array(array: np.ndarray) -> Image.Image:
|
|
128
|
+
"""Wrap a numpy array as a PIL image, copying so the caller's array is safe.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: if the array is empty or has a shape no image could have.
|
|
132
|
+
"""
|
|
133
|
+
if array.ndim not in (2, 3):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
"array images must be 2-D or 3-D, got shape {0}".format(array.shape)
|
|
136
|
+
)
|
|
137
|
+
if array.ndim == 3 and array.shape[2] not in (1, 3, 4):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
"array images need 1, 3 or 4 channels, got {0}".format(array.shape[2])
|
|
140
|
+
)
|
|
141
|
+
if array.size == 0:
|
|
142
|
+
raise ValueError("array image is empty")
|
|
143
|
+
data = array
|
|
144
|
+
if data.ndim == 3 and data.shape[2] == 1:
|
|
145
|
+
data = data[:, :, 0]
|
|
146
|
+
if data.dtype != np.uint8:
|
|
147
|
+
data = to_8bit(data)
|
|
148
|
+
else:
|
|
149
|
+
data = data.copy()
|
|
150
|
+
# uint8 (h, w), (h, w, 3) and (h, w, 4) arrays open as L, RGB and RGBA.
|
|
151
|
+
return Image.fromarray(np.ascontiguousarray(data))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def to_8bit(data: np.ndarray, sixteen_bit: bool = False) -> np.ndarray:
|
|
155
|
+
"""Bring a numeric array onto the 0-255 scale without changing its contrast.
|
|
156
|
+
|
|
157
|
+
Four conventions meet here, and each is mapped by its own fixed scale, so
|
|
158
|
+
a faded page stays faded however it arrives: floats in 0-1 are multiplied
|
|
159
|
+
by 255, anything already in 0-255 is kept, and 16-bit data - a ``uint16``
|
|
160
|
+
array, a 16-bit Pillow mode, or any values in 0-65535 - is divided by 257,
|
|
161
|
+
which takes 65535 to exactly 255. Stretching the range actually used onto
|
|
162
|
+
0-255 instead would turn ink at 200 on paper at 245 into black on white,
|
|
163
|
+
and pass a page OCR will struggle with. Only data on no recognisable scale
|
|
164
|
+
at all - negative values, or beyond 16 bits - has its used range
|
|
165
|
+
stretched, because there is no other scale to read it on.
|
|
166
|
+
|
|
167
|
+
``sixteen_bit`` says the data is 16-bit whatever values it holds.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
TypeError: if the data is complex or not numeric at all.
|
|
171
|
+
"""
|
|
172
|
+
kind = data.dtype.kind
|
|
173
|
+
if kind == "c":
|
|
174
|
+
raise TypeError("complex arrays are not images; pass the magnitude or real part")
|
|
175
|
+
if kind == "b":
|
|
176
|
+
return data.astype(np.uint8) * np.uint8(255)
|
|
177
|
+
if kind not in "uif":
|
|
178
|
+
raise TypeError(
|
|
179
|
+
"array images must hold numbers, not {0}".format(data.dtype)
|
|
180
|
+
)
|
|
181
|
+
if kind == "u" and data.dtype.itemsize == 2:
|
|
182
|
+
sixteen_bit = True
|
|
183
|
+
floats = np.asarray(data, dtype=np.float64)
|
|
184
|
+
finite = np.isfinite(floats)
|
|
185
|
+
values = floats[finite]
|
|
186
|
+
low = float(values.min()) if values.size else 0.0
|
|
187
|
+
high = float(values.max()) if values.size else 0.0
|
|
188
|
+
floats = np.where(finite, floats, low)
|
|
189
|
+
if sixteen_bit and low >= 0.0 and high <= 65535.0:
|
|
190
|
+
floats = floats / 257.0
|
|
191
|
+
elif low >= 0.0 and high <= 1.0:
|
|
192
|
+
floats = floats * 255.0
|
|
193
|
+
elif low >= 0.0 and high <= 255.0:
|
|
194
|
+
pass
|
|
195
|
+
elif low >= 0.0 and high <= 65535.0:
|
|
196
|
+
floats = floats / 257.0
|
|
197
|
+
else:
|
|
198
|
+
span = high - low
|
|
199
|
+
logger.warning(
|
|
200
|
+
"image values run from %g to %g, which is no standard scale; "
|
|
201
|
+
"stretching them onto 0-255", low, high,
|
|
202
|
+
)
|
|
203
|
+
floats = (floats - low) * (255.0 / span) if span > 1e-12 else floats * 0.0
|
|
204
|
+
return np.clip(np.rint(floats), 0.0, 255.0).astype(np.uint8)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def apply_exif_orientation(image: Image.Image) -> Tuple[Image.Image, bool]:
|
|
208
|
+
"""Turn ``image`` the way its EXIF tag says, before anything else looks at it.
|
|
209
|
+
|
|
210
|
+
Returns ``(image, was_turned)``. The original is never modified.
|
|
211
|
+
"""
|
|
212
|
+
orientation = 1
|
|
213
|
+
try:
|
|
214
|
+
exif = image.getexif()
|
|
215
|
+
except Exception: # pragma: no cover - broken EXIF blocks exist in the wild
|
|
216
|
+
exif = None
|
|
217
|
+
if exif:
|
|
218
|
+
try:
|
|
219
|
+
orientation = int(exif.get(_EXIF_ORIENTATION_TAG, 1) or 1)
|
|
220
|
+
except (TypeError, ValueError): # pragma: no cover - nonsense tag value
|
|
221
|
+
orientation = 1
|
|
222
|
+
if orientation in (0, 1):
|
|
223
|
+
return image, False
|
|
224
|
+
turned = ImageOps.exif_transpose(image)
|
|
225
|
+
if turned is None: # pragma: no cover - only on very old Pillow
|
|
226
|
+
return image, False
|
|
227
|
+
return turned, True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _flatten_alpha(image: Image.Image) -> Image.Image:
|
|
231
|
+
"""Composite transparency onto white: unscanned corners then read as paper."""
|
|
232
|
+
rgba = image.convert("RGBA")
|
|
233
|
+
white = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
|
234
|
+
return Image.alpha_composite(white, rgba)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _stretch_wide_mode(image: Image.Image) -> Image.Image:
|
|
238
|
+
"""16-bit, 32-bit and float scans as 8-bit, each on its own fixed scale.
|
|
239
|
+
|
|
240
|
+
See :func:`to_8bit`: a 16-bit mode is divided by 257, a float mode is read
|
|
241
|
+
as 0-1 or 0-255, and nothing is stretched unless it sits on no known scale.
|
|
242
|
+
"""
|
|
243
|
+
data = np.asarray(image)
|
|
244
|
+
return Image.fromarray(to_8bit(data, sixteen_bit=image.mode.startswith("I;16")))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _has_alpha(image: Image.Image) -> bool:
|
|
248
|
+
"""True when this mode actually carries transparency."""
|
|
249
|
+
return image.mode in ("RGBA", "LA", "PA") or (
|
|
250
|
+
image.mode == "P" and "transparency" in image.info
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def to_display_rgb(image: Image.Image) -> Image.Image:
|
|
255
|
+
"""``image`` as plain RGB, with transparency flattened onto white."""
|
|
256
|
+
if image.mode in _WIDE_MODES:
|
|
257
|
+
return _stretch_wide_mode(image).convert("RGB")
|
|
258
|
+
if _has_alpha(image):
|
|
259
|
+
return _flatten_alpha(image).convert("RGB")
|
|
260
|
+
if image.mode == "RGB":
|
|
261
|
+
return image
|
|
262
|
+
return image.convert("RGB")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def to_luminance(image: Image.Image) -> np.ndarray:
|
|
266
|
+
"""Native-resolution 8-bit luminance as a freshly allocated ``(h, w)`` array."""
|
|
267
|
+
if image.mode in _WIDE_MODES:
|
|
268
|
+
grey = _stretch_wide_mode(image)
|
|
269
|
+
elif _has_alpha(image):
|
|
270
|
+
grey = _flatten_alpha(image).convert("L")
|
|
271
|
+
elif image.mode == "L":
|
|
272
|
+
grey = image
|
|
273
|
+
else:
|
|
274
|
+
grey = image.convert("L")
|
|
275
|
+
return np.array(grey, dtype=np.uint8, copy=True)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def downscale_plane(lum: np.ndarray, long_edge: int) -> Tuple[np.ndarray, float]:
|
|
279
|
+
"""Box-filter ``lum`` down to ``long_edge``; return ``(float32 in 0-1, scale)``.
|
|
280
|
+
|
|
281
|
+
``scale`` maps native pixels to plane pixels, so a length measured on the
|
|
282
|
+
plane becomes native pixels when divided by it. A page already smaller than
|
|
283
|
+
``long_edge`` comes back at its own size with ``scale`` 1.0.
|
|
284
|
+
"""
|
|
285
|
+
height, width = lum.shape
|
|
286
|
+
longest = max(height, width)
|
|
287
|
+
if long_edge <= 0 or longest <= long_edge:
|
|
288
|
+
return lum.astype(np.float32) / np.float32(255.0), 1.0
|
|
289
|
+
ratio = long_edge / float(longest)
|
|
290
|
+
target = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
|
|
291
|
+
small = Image.fromarray(np.ascontiguousarray(lum, dtype=np.uint8)).resize(target, _BOX)
|
|
292
|
+
plane = np.asarray(small, dtype=np.float32) / np.float32(255.0)
|
|
293
|
+
return plane, small.size[0] / float(width)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _shrink_plane(plane: np.ndarray, scale: float, long_edge: int) -> Tuple[np.ndarray, float]:
|
|
297
|
+
"""``plane`` (already at ``scale`` of native) box-filtered down to ``long_edge``.
|
|
298
|
+
|
|
299
|
+
Shrinking the work plane rather than the native image gives the same
|
|
300
|
+
small plane for a fraction of the reading.
|
|
301
|
+
"""
|
|
302
|
+
height, width = plane.shape
|
|
303
|
+
longest = max(height, width)
|
|
304
|
+
if long_edge <= 0 or longest <= long_edge:
|
|
305
|
+
return plane.copy(), scale
|
|
306
|
+
ratio = long_edge / float(longest)
|
|
307
|
+
target = (max(1, int(round(width * ratio))), max(1, int(round(height * ratio))))
|
|
308
|
+
small = Image.fromarray(np.ascontiguousarray(plane, dtype=np.float32)).resize(target, _BOX)
|
|
309
|
+
return np.asarray(small, dtype=np.float32).copy(), scale * small.size[0] / float(width)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def colour_spread(image: Image.Image) -> float:
|
|
313
|
+
"""How far from neutral grey this page is: 0 a grey scan, 1 fully saturated.
|
|
314
|
+
|
|
315
|
+
Measured on a small thumbnail as the mean of ``max(r,g,b) - min(r,g,b)``,
|
|
316
|
+
which paper and ink keep near zero even when the scanner was set to colour,
|
|
317
|
+
and a photograph does not.
|
|
318
|
+
"""
|
|
319
|
+
if image.mode in ("1", "L", "LA", "I", "F") or image.mode.startswith("I;16"):
|
|
320
|
+
return 0.0 # no colour channels, nothing to spread
|
|
321
|
+
rgb = to_display_rgb(image)
|
|
322
|
+
width, height = rgb.size
|
|
323
|
+
longest = max(width, height)
|
|
324
|
+
if longest > COLOUR_LONG_EDGE:
|
|
325
|
+
ratio = COLOUR_LONG_EDGE / float(longest)
|
|
326
|
+
rgb = rgb.resize(
|
|
327
|
+
(max(1, int(round(width * ratio))), max(1, int(round(height * ratio)))),
|
|
328
|
+
_BOX,
|
|
329
|
+
)
|
|
330
|
+
data = np.asarray(rgb, dtype=np.int16)
|
|
331
|
+
if data.ndim != 3: # pragma: no cover - to_display_rgb always returns RGB
|
|
332
|
+
return 0.0
|
|
333
|
+
spread = data.max(axis=2) - data.min(axis=2)
|
|
334
|
+
return float(spread.mean()) / 255.0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def dpi_from_image(image: Image.Image) -> Optional[float]:
|
|
338
|
+
"""The dots per inch the file claims, or ``None`` when it claims nothing.
|
|
339
|
+
|
|
340
|
+
This is read, never guessed: a file with no resolution tag comes back
|
|
341
|
+
``None`` so the report can leave resolution advice out rather than invent it.
|
|
342
|
+
"""
|
|
343
|
+
info = getattr(image, "info", None) or {}
|
|
344
|
+
candidates = []
|
|
345
|
+
raw = info.get("dpi")
|
|
346
|
+
if raw is not None:
|
|
347
|
+
candidates.extend(raw if isinstance(raw, (tuple, list)) else [raw])
|
|
348
|
+
if not candidates and info.get("jfif_unit") == 1:
|
|
349
|
+
candidates.extend([info.get("jfif_density"), info.get("jfif_x_density")])
|
|
350
|
+
values = []
|
|
351
|
+
for candidate in candidates:
|
|
352
|
+
try:
|
|
353
|
+
value = float(candidate)
|
|
354
|
+
except (TypeError, ValueError):
|
|
355
|
+
continue
|
|
356
|
+
if MIN_CREDIBLE_DPI <= value <= MAX_CREDIBLE_DPI:
|
|
357
|
+
values.append(value)
|
|
358
|
+
if not values:
|
|
359
|
+
return None
|
|
360
|
+
found = float(sum(values) / len(values))
|
|
361
|
+
# PNG stores whole pixels per metre, so 300 dpi comes back as 299.9994 and
|
|
362
|
+
# 200 dpi as 199.9996 - just under the floor it was saved at. Anything
|
|
363
|
+
# within a twentieth of a dot of a whole number is that whole number.
|
|
364
|
+
nearest = float(round(found))
|
|
365
|
+
if abs(found - nearest) <= DPI_SNAP:
|
|
366
|
+
found = nearest
|
|
367
|
+
return found
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def coerce_dpi(dpi: Any) -> Optional[float]:
|
|
371
|
+
"""Validate a caller-supplied dpi, returning it as a float or ``None``.
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ValueError: if ``dpi`` is not a positive, finite number.
|
|
375
|
+
"""
|
|
376
|
+
if dpi is None:
|
|
377
|
+
return None
|
|
378
|
+
try:
|
|
379
|
+
value = float(dpi)
|
|
380
|
+
except (TypeError, ValueError):
|
|
381
|
+
raise ValueError(
|
|
382
|
+
"dpi must be a number or None, got {0!r}".format(dpi)
|
|
383
|
+
) from None
|
|
384
|
+
if not np.isfinite(value) or value <= 0:
|
|
385
|
+
raise ValueError("dpi must be a positive number, got {0!r}".format(dpi))
|
|
386
|
+
return value
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass
|
|
390
|
+
class PagePlanes:
|
|
391
|
+
"""One page, prepared once and shared by every measurement."""
|
|
392
|
+
|
|
393
|
+
width: int
|
|
394
|
+
height: int
|
|
395
|
+
lum: np.ndarray
|
|
396
|
+
work: np.ndarray
|
|
397
|
+
work_scale: float
|
|
398
|
+
fine: np.ndarray
|
|
399
|
+
fine_scale: float
|
|
400
|
+
colour: float
|
|
401
|
+
dpi: Optional[float]
|
|
402
|
+
dpi_source: Optional[str]
|
|
403
|
+
exif_applied: bool
|
|
404
|
+
|
|
405
|
+
#: ``(left, top, right, bottom)`` in native pixels when the planes have been
|
|
406
|
+
#: cropped to the inside of a scanner border, else ``None``.
|
|
407
|
+
interior: Optional[Tuple[int, int, int, int]] = None
|
|
408
|
+
#: Share of the image that was black lid outside a crooked sheet, painted
|
|
409
|
+
#: over with paper before measuring. 0 when there was none.
|
|
410
|
+
outside_share: float = 0.0
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def megapixels(self) -> float:
|
|
414
|
+
"""Page area in megapixels."""
|
|
415
|
+
return (self.width * self.height) / 1e6
|
|
416
|
+
|
|
417
|
+
def turned(self, quarter_turns: int) -> "PagePlanes":
|
|
418
|
+
"""These planes turned ``quarter_turns`` x 90 degrees counter-clockwise.
|
|
419
|
+
|
|
420
|
+
Used to measure a page lying on its side as the upright page it will
|
|
421
|
+
be once rotated. Width, height and dpi describe the file and are kept.
|
|
422
|
+
"""
|
|
423
|
+
k = int(quarter_turns) % 4
|
|
424
|
+
if k == 0:
|
|
425
|
+
return self
|
|
426
|
+
return dataclasses.replace(
|
|
427
|
+
self,
|
|
428
|
+
lum=np.ascontiguousarray(np.rot90(self.lum, k)),
|
|
429
|
+
work=np.ascontiguousarray(np.rot90(self.work, k)),
|
|
430
|
+
fine=np.ascontiguousarray(np.rot90(self.fine, k)),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def cropped(self, box: Tuple[int, int, int, int]) -> "PagePlanes":
|
|
434
|
+
"""These planes cut down to ``box`` (native ``left, top, right, bottom``).
|
|
435
|
+
|
|
436
|
+
Width, height and dpi stay those of the whole image, because they are
|
|
437
|
+
facts about the scan; only the pixels measured change. Every array is a
|
|
438
|
+
fresh copy, never a view.
|
|
439
|
+
"""
|
|
440
|
+
left, top, right, bottom = (int(value) for value in box)
|
|
441
|
+
left, top = max(0, left), max(0, top)
|
|
442
|
+
right, bottom = min(self.lum.shape[1], right), min(self.lum.shape[0], bottom)
|
|
443
|
+
if right - left < 2 or bottom - top < 2:
|
|
444
|
+
return self
|
|
445
|
+
|
|
446
|
+
def cut(plane: np.ndarray, scale: float) -> np.ndarray:
|
|
447
|
+
rows, columns = plane.shape
|
|
448
|
+
y0 = min(rows - 1, int(np.floor(top * scale)))
|
|
449
|
+
x0 = min(columns - 1, int(np.floor(left * scale)))
|
|
450
|
+
y1 = max(y0 + 1, min(rows, int(np.ceil(bottom * scale))))
|
|
451
|
+
x1 = max(x0 + 1, min(columns, int(np.ceil(right * scale))))
|
|
452
|
+
return plane[y0:y1, x0:x1].copy()
|
|
453
|
+
|
|
454
|
+
return PagePlanes(
|
|
455
|
+
width=self.width,
|
|
456
|
+
height=self.height,
|
|
457
|
+
lum=self.lum[top:bottom, left:right].copy(),
|
|
458
|
+
work=cut(self.work, self.work_scale),
|
|
459
|
+
work_scale=self.work_scale,
|
|
460
|
+
fine=cut(self.fine, self.fine_scale),
|
|
461
|
+
fine_scale=self.fine_scale,
|
|
462
|
+
colour=self.colour,
|
|
463
|
+
dpi=self.dpi,
|
|
464
|
+
dpi_source=self.dpi_source,
|
|
465
|
+
exif_applied=self.exif_applied,
|
|
466
|
+
interior=(left, top, right, bottom),
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def prepare(source: Any, dpi: Any = None) -> PagePlanes:
|
|
471
|
+
"""Open ``source`` and build every plane the measurements need.
|
|
472
|
+
|
|
473
|
+
``dpi`` given by the caller wins; otherwise the file's own resolution tag is
|
|
474
|
+
used; otherwise it stays ``None`` and no resolution advice is produced.
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
ValueError: if the image has no pixels, or ``dpi`` is not positive.
|
|
478
|
+
"""
|
|
479
|
+
given = coerce_dpi(dpi)
|
|
480
|
+
image = open_image(source)
|
|
481
|
+
image, exif_applied = apply_exif_orientation(image)
|
|
482
|
+
width, height = image.size
|
|
483
|
+
if width < 1 or height < 1: # pragma: no cover - Pillow rejects these first
|
|
484
|
+
raise ValueError("image has no pixels")
|
|
485
|
+
|
|
486
|
+
lum = to_luminance(image)
|
|
487
|
+
work, work_scale = downscale_plane(lum, WORK_LONG_EDGE)
|
|
488
|
+
fine, fine_scale = _shrink_plane(work, work_scale, SKEW_LONG_EDGE)
|
|
489
|
+
|
|
490
|
+
if given is not None:
|
|
491
|
+
resolved, origin = given, "argument"
|
|
492
|
+
else:
|
|
493
|
+
found = dpi_from_image(image)
|
|
494
|
+
resolved, origin = (found, "image metadata") if found else (None, None)
|
|
495
|
+
|
|
496
|
+
return PagePlanes(
|
|
497
|
+
width=int(width),
|
|
498
|
+
height=int(height),
|
|
499
|
+
lum=lum,
|
|
500
|
+
work=work,
|
|
501
|
+
work_scale=work_scale,
|
|
502
|
+
fine=fine,
|
|
503
|
+
fine_scale=fine_scale,
|
|
504
|
+
colour=colour_spread(image),
|
|
505
|
+
dpi=resolved,
|
|
506
|
+
dpi_source=origin,
|
|
507
|
+
exif_applied=exif_applied,
|
|
508
|
+
)
|