scanlayer 1.0.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.
- scanlayer/__init__.py +32 -0
- scanlayer/__main__.py +10 -0
- scanlayer/cli/__init__.py +10 -0
- scanlayer/cli/dry_run.py +191 -0
- scanlayer/cli/parser.py +199 -0
- scanlayer/cli/run.py +217 -0
- scanlayer/config.py +267 -0
- scanlayer/fonts/DejaVuSans.ttf +0 -0
- scanlayer/layout/__init__.py +0 -0
- scanlayer/layout/columns.py +264 -0
- scanlayer/main.py +666 -0
- scanlayer/ocr/__init__.py +5 -0
- scanlayer/ocr/engine.py +335 -0
- scanlayer/ocr/export.py +264 -0
- scanlayer/pdf/__init__.py +5 -0
- scanlayer/pdf/builder.py +309 -0
- scanlayer/pdf/fonts.py +110 -0
- scanlayer/preprocessing/__init__.py +5 -0
- scanlayer/preprocessing/enhance.py +367 -0
- scanlayer/utils/__init__.py +19 -0
- scanlayer/utils/debug_image.py +85 -0
- scanlayer/utils/errors.py +42 -0
- scanlayer/utils/logger.py +100 -0
- scanlayer/utils/validators.py +199 -0
- scanlayer-1.0.0.dist-info/METADATA +17 -0
- scanlayer-1.0.0.dist-info/RECORD +30 -0
- scanlayer-1.0.0.dist-info/WHEEL +5 -0
- scanlayer-1.0.0.dist-info/entry_points.txt +2 -0
- scanlayer-1.0.0.dist-info/licenses/LICENSE.md +21 -0
- scanlayer-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source image preprocessing.
|
|
3
|
+
|
|
4
|
+
Two outputs:
|
|
5
|
+
1. background: straightened image, original colors/resolution.
|
|
6
|
+
2. ocr_image: grayscale, illumination-corrected, denoised, contrast-enhanced.
|
|
7
|
+
|
|
8
|
+
Both share the same coordinate system (ocr_scale maps between them).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pytesseract
|
|
18
|
+
from PIL import Image, ImageOps
|
|
19
|
+
|
|
20
|
+
from scanlayer import config
|
|
21
|
+
from scanlayer.utils.logger import get_logger, log_warning, stage_timer
|
|
22
|
+
|
|
23
|
+
log = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PreprocessResult:
|
|
28
|
+
background: Image.Image
|
|
29
|
+
ocr_image: np.ndarray
|
|
30
|
+
ocr_scale: float
|
|
31
|
+
effective_dpi: int
|
|
32
|
+
exif_orientation_applied: int = 1
|
|
33
|
+
gross_rotation_applied: int = 0
|
|
34
|
+
deskew_angle_applied: float = 0.0
|
|
35
|
+
manual_rotation_applied: "float | None" = None
|
|
36
|
+
orientation_correction_skipped: bool = False
|
|
37
|
+
source_std: float = 0.0
|
|
38
|
+
likely_blank: bool = False
|
|
39
|
+
preprocessing_warnings: tuple = ()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pil_to_cv(image: Image.Image) -> np.ndarray:
|
|
43
|
+
return cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cv_to_pil(image: np.ndarray) -> Image.Image:
|
|
47
|
+
return Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_exif_orientation(image: Image.Image) -> tuple[Image.Image, int]:
|
|
51
|
+
"""Apply the EXIF Orientation tag before any other processing.
|
|
52
|
+
|
|
53
|
+
PIL's Image.open() never does this automatically.
|
|
54
|
+
"""
|
|
55
|
+
if not config.APPLY_EXIF_ORIENTATION:
|
|
56
|
+
return image, 1
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
exif = image.getexif()
|
|
60
|
+
orientation = exif.get(0x0112, 1) # 0x0112 = Orientation tag
|
|
61
|
+
except Exception:
|
|
62
|
+
orientation = 1
|
|
63
|
+
|
|
64
|
+
if orientation == 1:
|
|
65
|
+
return image, 1
|
|
66
|
+
|
|
67
|
+
corrected = ImageOps.exif_transpose(image)
|
|
68
|
+
log.info(f"EXIF: orientation tag {orientation} applied")
|
|
69
|
+
return corrected, orientation
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fix_gross_orientation(image: Image.Image) -> tuple[Image.Image, int]:
|
|
73
|
+
"""Correct obvious rotations (0/90/180/270) using Tesseract OSD."""
|
|
74
|
+
try:
|
|
75
|
+
osd = pytesseract.image_to_osd(image, output_type=pytesseract.Output.DICT)
|
|
76
|
+
rotate = int(osd.get("rotate", 0))
|
|
77
|
+
confidence = float(osd.get("orientation_conf", 0))
|
|
78
|
+
except pytesseract.TesseractError as exc:
|
|
79
|
+
log_warning(
|
|
80
|
+
log,
|
|
81
|
+
f"OSD unavailable (page too text-sparse?), "
|
|
82
|
+
f"orientation not corrected. Detail: {exc}"
|
|
83
|
+
)
|
|
84
|
+
return image, 0
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
# Non-Tesseract error (most often: missing tessdata, or
|
|
87
|
+
# image too large for OSD which downscales poorly internally).
|
|
88
|
+
log_warning(
|
|
89
|
+
log,
|
|
90
|
+
f"OSD failed ({type(exc).__name__}: {exc}), "
|
|
91
|
+
f"orientation not corrected."
|
|
92
|
+
)
|
|
93
|
+
return image, 0
|
|
94
|
+
|
|
95
|
+
if rotate == 0:
|
|
96
|
+
log.debug(f"OSD: orientation OK (conf={confidence:.1f})")
|
|
97
|
+
return image, 0
|
|
98
|
+
|
|
99
|
+
# If OSD has low confidence, we log a warning but still apply,
|
|
100
|
+
# a wrong gross angle ruins the entire OCR, better to try.
|
|
101
|
+
if confidence < 5.0:
|
|
102
|
+
log_warning(
|
|
103
|
+
log,
|
|
104
|
+
f"OSD: rotation {rotate}° proposed with very low confidence "
|
|
105
|
+
f"({confidence:.1f}/20), applied anyway, but verify the result."
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
log.info(f"OSD: rotation {rotate}° applied (conf={confidence:.1f}/20)")
|
|
109
|
+
|
|
110
|
+
# PIL rotate() rotates counter-clockwise; OSD "rotate" indicates
|
|
111
|
+
# the clockwise angle needed to upright the text.
|
|
112
|
+
rotated = image.rotate(-rotate, expand=True, fillcolor=(255, 255, 255))
|
|
113
|
+
return rotated, rotate
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rotate_by_angle(cv_image: np.ndarray, angle: float) -> np.ndarray:
|
|
117
|
+
"""Rotate cv_image by an exact angle in degrees (clockwise positive)."""
|
|
118
|
+
(h, w) = cv_image.shape[:2]
|
|
119
|
+
center = (w // 2, h // 2)
|
|
120
|
+
matrix = cv2.getRotationMatrix2D(center, -angle, 1.0)
|
|
121
|
+
cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
|
|
122
|
+
new_w = int((h * sin) + (w * cos))
|
|
123
|
+
new_h = int((h * cos) + (w * sin))
|
|
124
|
+
matrix[0, 2] += (new_w / 2) - center[0]
|
|
125
|
+
matrix[1, 2] += (new_h / 2) - center[1]
|
|
126
|
+
return cv2.warpAffine(
|
|
127
|
+
cv_image, matrix, (new_w, new_h),
|
|
128
|
+
flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT,
|
|
129
|
+
borderValue=(255, 255, 255),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _deskew_fine(cv_image: np.ndarray) -> tuple[np.ndarray, float, float]:
|
|
134
|
+
"""Correct slight tilt via minAreaRect on text pixels.
|
|
135
|
+
|
|
136
|
+
Returns (corrected_image, applied_angle, source_std).
|
|
137
|
+
"""
|
|
138
|
+
gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
|
|
139
|
+
source_std = float(gray.std())
|
|
140
|
+
|
|
141
|
+
if source_std < config.DESKEW_MIN_STD:
|
|
142
|
+
log.debug(
|
|
143
|
+
f"Deskew: image std {source_std:.2f} < {config.DESKEW_MIN_STD} "
|
|
144
|
+
f"(near-uniform image, likely blank), Otsu skipped, no correction."
|
|
145
|
+
)
|
|
146
|
+
return cv_image, 0.0, source_std
|
|
147
|
+
|
|
148
|
+
thresh = cv2.threshold(
|
|
149
|
+
gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU
|
|
150
|
+
)[1]
|
|
151
|
+
|
|
152
|
+
coords = np.column_stack(np.where(thresh > 0))
|
|
153
|
+
if len(coords) < config.DESKEW_MIN_PIXELS:
|
|
154
|
+
log.debug(
|
|
155
|
+
f"Deskew: not enough signal ({len(coords)} pixels < "
|
|
156
|
+
f"{config.DESKEW_MIN_PIXELS}), image is probably empty."
|
|
157
|
+
)
|
|
158
|
+
return cv_image, 0.0, source_std
|
|
159
|
+
|
|
160
|
+
angle = cv2.minAreaRect(coords)[-1]
|
|
161
|
+
if angle < -45:
|
|
162
|
+
angle = -(90 + angle)
|
|
163
|
+
else:
|
|
164
|
+
angle = -angle
|
|
165
|
+
|
|
166
|
+
if abs(angle) > config.DESKEW_MAX_ANGLE:
|
|
167
|
+
log_warning(
|
|
168
|
+
log,
|
|
169
|
+
f"Deskew: angle {angle:.2f}° rejected (beyond "
|
|
170
|
+
f"{config.DESKEW_MAX_ANGLE}°), probably a minAreaRect artifact "
|
|
171
|
+
f"(page border detected instead of text lines). "
|
|
172
|
+
f"Tilt not corrected."
|
|
173
|
+
)
|
|
174
|
+
return cv_image, 0.0, source_std
|
|
175
|
+
|
|
176
|
+
if abs(angle) < config.DESKEW_MIN_ANGLE:
|
|
177
|
+
log.debug(f"Deskew: angle {angle:.2f}° < threshold, no correction applied")
|
|
178
|
+
return cv_image, 0.0, source_std
|
|
179
|
+
|
|
180
|
+
log.info(f"Deskew: correction of {angle:.2f}° applied")
|
|
181
|
+
|
|
182
|
+
(h, w) = cv_image.shape[:2]
|
|
183
|
+
center = (w // 2, h // 2)
|
|
184
|
+
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
|
185
|
+
cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
|
|
186
|
+
new_w = int((h * sin) + (w * cos))
|
|
187
|
+
new_h = int((h * cos) + (w * sin))
|
|
188
|
+
matrix[0, 2] += (new_w / 2) - center[0]
|
|
189
|
+
matrix[1, 2] += (new_h / 2) - center[1]
|
|
190
|
+
|
|
191
|
+
return cv2.warpAffine(
|
|
192
|
+
cv_image, matrix, (new_w, new_h),
|
|
193
|
+
flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT,
|
|
194
|
+
borderValue=(255, 255, 255),
|
|
195
|
+
), angle, source_std
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _remove_shadows(gray: np.ndarray) -> np.ndarray:
|
|
199
|
+
"""Correct non-uniform illumination (cast shadows, light gradients).
|
|
200
|
+
|
|
201
|
+
Divides by a background estimate (Gaussian + morphological closing).
|
|
202
|
+
"""
|
|
203
|
+
h, w = gray.shape[:2]
|
|
204
|
+
|
|
205
|
+
sigma = max(31.0, min(w, h) / 20.0)
|
|
206
|
+
bg_gauss = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma)
|
|
207
|
+
|
|
208
|
+
kernel_size = min(51, max(21, min(w, h) // 50))
|
|
209
|
+
if kernel_size % 2 == 0:
|
|
210
|
+
kernel_size += 1
|
|
211
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
|
|
212
|
+
bg_morph = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel)
|
|
213
|
+
|
|
214
|
+
background = np.maximum(bg_gauss, bg_morph)
|
|
215
|
+
background = np.where(background == 0, 1, background).astype(np.float32)
|
|
216
|
+
normalized = cv2.divide(
|
|
217
|
+
gray.astype(np.float32), background, scale=255.0
|
|
218
|
+
)
|
|
219
|
+
normalized = np.clip(normalized, 0, 255).astype(np.uint8)
|
|
220
|
+
return normalized
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _sharpen(gray: np.ndarray) -> np.ndarray:
|
|
224
|
+
"""Light unsharp mask after denoising/CLAHE to recover edge definition."""
|
|
225
|
+
amount = config.UNSHARP_AMOUNT
|
|
226
|
+
if amount <= 0:
|
|
227
|
+
return gray
|
|
228
|
+
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=config.UNSHARP_SIGMA)
|
|
229
|
+
return cv2.addWeighted(gray, 1.0 + amount, blurred, -amount, 0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _build_ocr_image(
|
|
233
|
+
cv_image: np.ndarray, dpi: int
|
|
234
|
+
) -> tuple[np.ndarray, float, int, tuple]:
|
|
235
|
+
"""Build the grayscale copy optimized for Tesseract.
|
|
236
|
+
|
|
237
|
+
Returns (ocr_image, scale, effective_dpi, warnings).
|
|
238
|
+
"""
|
|
239
|
+
warnings_list: list[str] = []
|
|
240
|
+
gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
|
|
241
|
+
orig_width = gray.shape[1]
|
|
242
|
+
|
|
243
|
+
scale = 1.0
|
|
244
|
+
if orig_width < config.OCR_UPSCALE_MIN_WIDTH:
|
|
245
|
+
scale = config.OCR_UPSCALE_MIN_WIDTH / orig_width
|
|
246
|
+
gray = cv2.resize(
|
|
247
|
+
gray, None, fx=scale, fy=scale,
|
|
248
|
+
interpolation=cv2.INTER_CUBIC,
|
|
249
|
+
)
|
|
250
|
+
log.debug(
|
|
251
|
+
f"Upscale x{scale:.2f}: {orig_width}px -> {gray.shape[1]}px "
|
|
252
|
+
f"(below {config.OCR_UPSCALE_MIN_WIDTH}px)"
|
|
253
|
+
)
|
|
254
|
+
elif orig_width > config.OCR_DOWNSCALE_MAX_WIDTH:
|
|
255
|
+
scale = config.OCR_DOWNSCALE_MAX_WIDTH / orig_width
|
|
256
|
+
gray = cv2.resize(
|
|
257
|
+
gray, None, fx=scale, fy=scale,
|
|
258
|
+
interpolation=cv2.INTER_AREA,
|
|
259
|
+
)
|
|
260
|
+
warnings_list.append(
|
|
261
|
+
f"Very wide image ({orig_width}px > {config.OCR_DOWNSCALE_MAX_WIDTH}px) "
|
|
262
|
+
f"- downscaling x{scale:.2f} to avoid excessive processing time."
|
|
263
|
+
)
|
|
264
|
+
log.debug(
|
|
265
|
+
f"Downscale x{scale:.2f}: {orig_width}px -> {gray.shape[1]}px"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
gray = _remove_shadows(gray)
|
|
269
|
+
|
|
270
|
+
denoised = cv2.fastNlMeansDenoising(gray, h=config.DENOISING_H)
|
|
271
|
+
|
|
272
|
+
clahe = cv2.createCLAHE(
|
|
273
|
+
clipLimit=config.CLAHE_CLIP_LIMIT,
|
|
274
|
+
tileGridSize=config.CLAHE_TILE_GRID,
|
|
275
|
+
)
|
|
276
|
+
enhanced = clahe.apply(denoised)
|
|
277
|
+
enhanced = _sharpen(enhanced)
|
|
278
|
+
|
|
279
|
+
effective_dpi = max(72, int(dpi * scale))
|
|
280
|
+
|
|
281
|
+
return enhanced, scale, effective_dpi, tuple(warnings_list)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def preprocess(
|
|
285
|
+
image: Image.Image, dpi: int, orientation: "str | float | None" = None
|
|
286
|
+
) -> PreprocessResult:
|
|
287
|
+
"""Full preprocessing pipeline.
|
|
288
|
+
|
|
289
|
+
orientation: None=auto (EXIF+OSD+deskew), "none"=disable, or float degrees.
|
|
290
|
+
"""
|
|
291
|
+
manual_rotation_applied = None
|
|
292
|
+
orientation_correction_skipped = False
|
|
293
|
+
|
|
294
|
+
with stage_timer(log, "Preprocessing"):
|
|
295
|
+
if orientation is not None and str(orientation).strip().lower() == "none":
|
|
296
|
+
log.info("Orientation: correction disabled (orientation='none')")
|
|
297
|
+
exif_orientation = 1
|
|
298
|
+
gross_rot = 0
|
|
299
|
+
orientation_correction_skipped = True
|
|
300
|
+
cv_image = _pil_to_cv(image)
|
|
301
|
+
deskew_angle = 0.0
|
|
302
|
+
source_std = float(
|
|
303
|
+
cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY).std()
|
|
304
|
+
)
|
|
305
|
+
elif orientation is not None:
|
|
306
|
+
angle = float(orientation)
|
|
307
|
+
log.info(f"Orientation: manual angle {angle}° applied (auto-detection skipped)")
|
|
308
|
+
exif_orientation = 1
|
|
309
|
+
gross_rot = 0
|
|
310
|
+
manual_rotation_applied = angle
|
|
311
|
+
cv_image = _pil_to_cv(image)
|
|
312
|
+
cv_image = _rotate_by_angle(cv_image, angle)
|
|
313
|
+
deskew_angle = 0.0
|
|
314
|
+
source_std = float(
|
|
315
|
+
cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY).std()
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
image, exif_orientation = _apply_exif_orientation(image)
|
|
319
|
+
image, gross_rot = _fix_gross_orientation(image)
|
|
320
|
+
cv_image = _pil_to_cv(image)
|
|
321
|
+
cv_image, deskew_angle, source_std = _deskew_fine(cv_image)
|
|
322
|
+
|
|
323
|
+
likely_blank = source_std < config.DESKEW_MIN_STD
|
|
324
|
+
if likely_blank:
|
|
325
|
+
log.info(
|
|
326
|
+
f"Preprocessing: source std={source_std:.2f} < "
|
|
327
|
+
f"{config.DESKEW_MIN_STD}, page is likely blank, "
|
|
328
|
+
f"skipping OCR-image build (denoise/CLAHE/upscale)."
|
|
329
|
+
)
|
|
330
|
+
ocr_image = np.zeros((1, 1), dtype=np.uint8) # unused placeholder
|
|
331
|
+
scale = 1.0
|
|
332
|
+
effective_dpi = max(72, dpi)
|
|
333
|
+
warnings_t: tuple = ()
|
|
334
|
+
else:
|
|
335
|
+
ocr_image, scale, effective_dpi, warnings_t = _build_ocr_image(
|
|
336
|
+
cv_image, dpi
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
background = _cv_to_pil(cv_image)
|
|
340
|
+
|
|
341
|
+
ocr_image_desc = (
|
|
342
|
+
"skipped (likely blank)"
|
|
343
|
+
if likely_blank
|
|
344
|
+
else f"{ocr_image.shape[1]}x{ocr_image.shape[0]}px"
|
|
345
|
+
)
|
|
346
|
+
log.info(
|
|
347
|
+
f"Preprocessing complete: "
|
|
348
|
+
f"exif_orientation={exif_orientation}, gross_rotation={gross_rot}°, "
|
|
349
|
+
f"background={background.size[0]}x{background.size[1]}px, "
|
|
350
|
+
f"ocr_image={ocr_image_desc}, "
|
|
351
|
+
f"scale=x{scale:.2f}, effective_dpi={effective_dpi}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return PreprocessResult(
|
|
355
|
+
background=background,
|
|
356
|
+
ocr_image=ocr_image,
|
|
357
|
+
ocr_scale=scale,
|
|
358
|
+
effective_dpi=effective_dpi,
|
|
359
|
+
exif_orientation_applied=exif_orientation,
|
|
360
|
+
gross_rotation_applied=gross_rot,
|
|
361
|
+
deskew_angle_applied=deskew_angle,
|
|
362
|
+
manual_rotation_applied=manual_rotation_applied,
|
|
363
|
+
orientation_correction_skipped=orientation_correction_skipped,
|
|
364
|
+
source_std=source_std,
|
|
365
|
+
likely_blank=likely_blank,
|
|
366
|
+
preprocessing_warnings=warnings_t,
|
|
367
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""utils - shared logging, validation, and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from scanlayer.utils.logger import (
|
|
4
|
+
get_logger,
|
|
5
|
+
log_error,
|
|
6
|
+
log_stage_start,
|
|
7
|
+
log_success,
|
|
8
|
+
log_warning,
|
|
9
|
+
stage_timer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"get_logger",
|
|
14
|
+
"stage_timer",
|
|
15
|
+
"log_success",
|
|
16
|
+
"log_warning",
|
|
17
|
+
"log_error",
|
|
18
|
+
"log_stage_start",
|
|
19
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Debug visualization for OCR results.
|
|
3
|
+
|
|
4
|
+
Draws word bounding boxes, confidence scores, and run metadata onto
|
|
5
|
+
a copy of the PDF background image. Used with --debug-image CLI flag.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
13
|
+
|
|
14
|
+
_HIGH_CONF = 80.0
|
|
15
|
+
_MID_CONF = 50.0
|
|
16
|
+
|
|
17
|
+
_COLOR_HIGH = (0, 150, 0)
|
|
18
|
+
_COLOR_MID = (210, 140, 0)
|
|
19
|
+
_COLOR_LOW = (200, 0, 0)
|
|
20
|
+
|
|
21
|
+
_BUNDLED_FONT_PATH = os.path.join(
|
|
22
|
+
os.path.dirname(__file__), "..", "fonts", "DejaVuSans.ttf"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_font(size: int) -> ImageFont.FreeTypeFont:
|
|
27
|
+
try:
|
|
28
|
+
return ImageFont.truetype(_BUNDLED_FONT_PATH, size)
|
|
29
|
+
except OSError:
|
|
30
|
+
return ImageFont.load_default()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _color_for_confidence(confidence: float) -> tuple[int, int, int]:
|
|
34
|
+
if confidence >= _HIGH_CONF:
|
|
35
|
+
return _COLOR_HIGH
|
|
36
|
+
if confidence >= _MID_CONF:
|
|
37
|
+
return _COLOR_MID
|
|
38
|
+
return _COLOR_LOW
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_debug_image(
|
|
42
|
+
background: Image.Image,
|
|
43
|
+
words: list,
|
|
44
|
+
best_psm: "int | None" = None,
|
|
45
|
+
mean_confidence: float = 0.0,
|
|
46
|
+
language_used: str = "",
|
|
47
|
+
exif_orientation: int = 1,
|
|
48
|
+
gross_rotation: int = 0,
|
|
49
|
+
rotation_note: str = "",
|
|
50
|
+
) -> Image.Image:
|
|
51
|
+
"""Returns a NEW PIL image: a copy of `background` with word boxes,
|
|
52
|
+
per-word confidence, and a run-summary header overlaid. Does not
|
|
53
|
+
modify `background` in place.
|
|
54
|
+
"""
|
|
55
|
+
img = background.convert("RGB").copy()
|
|
56
|
+
draw = ImageDraw.Draw(img)
|
|
57
|
+
|
|
58
|
+
word_font = _load_font(max(10, int(img.width / 150)))
|
|
59
|
+
header_font = _load_font(max(14, int(img.width / 90)))
|
|
60
|
+
|
|
61
|
+
for w in words:
|
|
62
|
+
x0, y0 = w.x, w.y
|
|
63
|
+
x1, y1 = w.x + w.width, w.y + w.height
|
|
64
|
+
color = _color_for_confidence(w.confidence)
|
|
65
|
+
draw.rectangle([x0, y0, x1, y1], outline=color, width=2)
|
|
66
|
+
draw.text((x0, max(0, y0 - word_font.size - 2)),
|
|
67
|
+
f"{w.confidence:.0f}", fill=color, font=word_font)
|
|
68
|
+
|
|
69
|
+
header_lines = [
|
|
70
|
+
f"PSM {best_psm if best_psm is not None else '-'} | "
|
|
71
|
+
f"{len(words)} word(s) | mean confidence {mean_confidence:.1f}% | "
|
|
72
|
+
f"lang={language_used or '-'}",
|
|
73
|
+
f"EXIF orientation={exif_orientation} gross_rotation={gross_rotation} deg"
|
|
74
|
+
+ (f" {rotation_note}" if rotation_note else ""),
|
|
75
|
+
]
|
|
76
|
+
pad = 6
|
|
77
|
+
line_height = header_font.size + 6
|
|
78
|
+
box_height = pad * 2 + line_height * len(header_lines)
|
|
79
|
+
draw.rectangle([0, 0, img.width, box_height], fill=(255, 255, 255))
|
|
80
|
+
y = pad
|
|
81
|
+
for line in header_lines:
|
|
82
|
+
draw.text((pad, y), line, fill=(0, 0, 0), font=header_font)
|
|
83
|
+
y += line_height
|
|
84
|
+
|
|
85
|
+
return img
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime pipeline errors, distinct from `utils.validators.ValidationError`.
|
|
3
|
+
|
|
4
|
+
Validators catch problems BEFORE any processing starts (missing file, missing
|
|
5
|
+
Tesseract binary...). This module covers failures that happen DURING
|
|
6
|
+
processing, once every precondition was already confirmed valid: a page
|
|
7
|
+
that times out, a language pack that fails to load, a PDF write that dies
|
|
8
|
+
mid-way. These are not user mistakes and not pre-flight environment issues,
|
|
9
|
+
they're operational failures that still need to surface loudly instead of
|
|
10
|
+
being swallowed into a silent "0 words, exit 0" success.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PipelineError(Exception):
|
|
15
|
+
"""Base class: a processing stage started but failed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OCRProcessingError(PipelineError):
|
|
19
|
+
"""Every configured PSM candidate failed for a real reason (timeout,
|
|
20
|
+
Tesseract crash, corrupted language data), not just 'no text found'."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PDFBuildError(PipelineError):
|
|
24
|
+
"""The PDF could not be written (disk full, permissions, corrupted
|
|
25
|
+
background image, reportlab failure)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BlankPageDetectedError(PipelineError):
|
|
29
|
+
"""The source image is near-uniform (very low grayscale contrast),
|
|
30
|
+
almost certainly a blank/white page rather than genuine content.
|
|
31
|
+
|
|
32
|
+
Raised BEFORE the expensive OCR pipeline runs (not after), since the
|
|
33
|
+
signal (image std) is available early in preprocessing and there is
|
|
34
|
+
no point running denoising/CLAHE/upscale/4-way Tesseract on a page
|
|
35
|
+
that is, with high confidence, blank.
|
|
36
|
+
|
|
37
|
+
Not raised as a silent "0 words" success: a blank page is often
|
|
38
|
+
unexpected (wrong file, failed scan, camera fired on nothing) and
|
|
39
|
+
deserves an explicit signal rather than a quietly empty PDF. Pass
|
|
40
|
+
`force=True` to `convert()` (or `--force` on the CLI) to generate
|
|
41
|
+
the PDF anyway, background image only, no fabricated text layer.
|
|
42
|
+
"""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logger and per-stage timer.
|
|
3
|
+
|
|
4
|
+
Provides logging configuration, a timing context manager, and uniform
|
|
5
|
+
message helpers. The internal `LOG_LEVEL` env var (set by the CLI's
|
|
6
|
+
`--verbose`/`--quiet`) overrides `config.LOG_LEVEL`, which itself comes
|
|
7
|
+
from the user-facing `SCANLAYER_LOG_LEVEL` env var.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Generator
|
|
17
|
+
|
|
18
|
+
from scanlayer import config
|
|
19
|
+
|
|
20
|
+
_LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)-18s | %(message)s"
|
|
21
|
+
_DATE_FORMAT = "%H:%M:%S"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_known_loggers: list[logging.Logger] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_level() -> int:
|
|
28
|
+
"""Return the effective log level.
|
|
29
|
+
|
|
30
|
+
Priority: CLI `LOG_LEVEL` env var > `config.LOG_LEVEL` (from
|
|
31
|
+
`SCANLAYER_LOG_LEVEL`) > INFO.
|
|
32
|
+
"""
|
|
33
|
+
raw = os.environ.get("LOG_LEVEL", "").strip().upper()
|
|
34
|
+
if not raw:
|
|
35
|
+
raw = str(getattr(config, "LOG_LEVEL", "INFO")).upper()
|
|
36
|
+
return getattr(logging, raw, logging.INFO)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_logger(name: str) -> logging.Logger:
|
|
40
|
+
"""Return a named logger with the configured log level.
|
|
41
|
+
|
|
42
|
+
Idempotent: multiple calls won't overwrite the handler setup.
|
|
43
|
+
"""
|
|
44
|
+
logger = logging.getLogger(name)
|
|
45
|
+
if not logger.handlers:
|
|
46
|
+
handler = logging.StreamHandler()
|
|
47
|
+
handler.setFormatter(logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
|
48
|
+
logger.addHandler(handler)
|
|
49
|
+
logger.propagate = False # avoid duplicates via the root logger
|
|
50
|
+
_known_loggers.append(logger)
|
|
51
|
+
logger.setLevel(_resolve_level())
|
|
52
|
+
return logger
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_log_level(level: str) -> None:
|
|
56
|
+
"""Apply a log level to all loggers created via get_logger().
|
|
57
|
+
|
|
58
|
+
Needed because modules call get_logger(__name__) at import time,
|
|
59
|
+
so a runtime configure() call wouldn't reach them otherwise.
|
|
60
|
+
"""
|
|
61
|
+
level_value = getattr(logging, str(level).upper(), None)
|
|
62
|
+
if level_value is None:
|
|
63
|
+
raise ValueError(f"Unknown log level: {level!r}")
|
|
64
|
+
for logger in _known_loggers:
|
|
65
|
+
logger.setLevel(level_value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@contextmanager
|
|
69
|
+
def stage_timer(logger: logging.Logger, stage_name: str) -> Generator[None, None, None]:
|
|
70
|
+
"""Context manager that logs the duration of a block.
|
|
71
|
+
|
|
72
|
+
Timing is only logged if config.LOG_TIMING is True or DEBUG is active.
|
|
73
|
+
"""
|
|
74
|
+
enable = bool(getattr(config, "LOG_TIMING", True)) or logger.isEnabledFor(logging.DEBUG)
|
|
75
|
+
start = time.perf_counter() if enable else None
|
|
76
|
+
try:
|
|
77
|
+
yield
|
|
78
|
+
finally:
|
|
79
|
+
if enable:
|
|
80
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
81
|
+
logger.info(f"{stage_name}: completed in {elapsed_ms:.0f} ms")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def log_success(logger: logging.Logger, msg: str) -> None:
|
|
85
|
+
logger.info(msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def log_warning(logger: logging.Logger, msg: str) -> None:
|
|
89
|
+
logger.warning(msg)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def log_error(logger: logging.Logger, msg: str, exc: Exception | None = None) -> None:
|
|
93
|
+
if exc is not None:
|
|
94
|
+
logger.error(f"{msg}: {type(exc).__name__}: {exc}")
|
|
95
|
+
else:
|
|
96
|
+
logger.error(msg)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def log_stage_start(logger: logging.Logger, msg: str) -> None:
|
|
100
|
+
logger.info(msg)
|