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,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
+ )