File size: 10,335 Bytes
0869963
f99e19b
0869963
f99e19b
0869963
f99e19b
 
 
 
 
 
0869963
 
 
 
f99e19b
0869963
 
 
f99e19b
 
 
 
 
 
 
 
0869963
 
 
 
 
 
 
 
 
 
 
 
f99e19b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96b7ed2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0869963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f99e19b
 
0869963
 
 
 
 
 
 
 
 
 
 
 
 
f99e19b
 
0869963
 
f99e19b
0869963
 
 
 
 
 
f99e19b
 
0869963
 
 
f99e19b
0869963
f99e19b
 
 
 
 
 
 
 
 
0869963
 
 
 
f99e19b
0869963
 
f99e19b
0869963
 
 
 
 
 
 
 
 
f99e19b
 
0869963
f99e19b
0869963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f99e19b
 
 
0869963
 
 
 
 
 
 
 
 
 
 
 
 
 
f99e19b
 
 
 
 
 
 
0869963
f99e19b
 
 
0869963
 
f99e19b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0869963
f99e19b
0869963
 
f99e19b
0869963
 
f99e19b
 
 
 
 
 
 
 
0869963
f99e19b
 
 
 
 
0869963
f99e19b
0869963
96b7ed2
0869963
 
f99e19b
96b7ed2
 
 
0869963
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
preprocessor.py — Advanced Computer Vision preprocessing pipeline for documents and live camera photos.
Features:
  - EXIF rotation normalization (vital for mobile/camera photos)
  - Auto-deskew via Hough line transform
  - Background shadow & illumination normalization
  - Unsharp masking for camera blur correction
  - CLAHE adaptive contrast enhancement
  - Bilateral edge-preserving denoising
  - Adaptive Otsu binarization
  - Perspective warp for angled captures
"""

import cv2
import numpy as np
from PIL import Image, ImageOps
import math


def fix_exif_orientation(pil_img: Image.Image) -> Image.Image:
    """Normalize image orientation using EXIF data (crucial for phone & webcam photos)."""
    try:
        return ImageOps.exif_transpose(pil_img)
    except Exception:
        return pil_img


def pil_to_cv2(pil_img: Image.Image) -> np.ndarray:
    """Convert PIL Image → OpenCV BGR ndarray."""
    arr = np.array(pil_img.convert("RGB"))
    return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)


def cv2_to_pil(cv2_img: np.ndarray) -> Image.Image:
    """Convert OpenCV BGR ndarray → PIL Image."""
    rgb = cv2.cvtColor(cv2_img, cv2.COLOR_BGR2RGB)
    return Image.fromarray(rgb)


def remove_shadows_and_normalize(cv2_img: np.ndarray) -> np.ndarray:
    """
    Remove uneven background shadows and normalize illumination.
    Particularly effective for phone photos with cast shadows or glare.
    """
    # Split into color planes
    rgb_planes = cv2.split(cv2_img)
    result_planes = []

    for plane in rgb_planes:
        # Dilate to approximate background
        dilated_img = cv2.dilate(plane, np.ones((7, 7), np.uint8))
        bg_img = cv2.medianBlur(dilated_img, 21)
        # Difference between plane and estimated background
        diff_img = 255 - cv2.absdiff(plane, bg_img)
        # Normalize to full dynamic range
        norm_img = cv2.normalize(
            diff_img, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8UC1
        )
        result_planes.append(norm_img)

    return cv2.merge(result_planes)


def sharpen_image(cv2_img: np.ndarray, strength: float = 1.3) -> np.ndarray:
    """Apply unsharp mask filter to sharpen slightly blurry camera captures."""
    gaussian = cv2.GaussianBlur(cv2_img, (0, 0), 2.0)
    sharpened = cv2.addWeighted(cv2_img, 1.0 + strength, gaussian, -strength, 0)
    return sharpened


def adjust_brightness_contrast(cv2_img: np.ndarray, brightness: int = 0, contrast: float = 1.0) -> np.ndarray:
    """
    Adjust brightness (-100 to 100) and contrast (0.5 to 3.0).
    Allows user to brighten underexposed camera captures.
    """
    adjusted = cv2.convertScaleAbs(cv2_img, alpha=contrast, beta=brightness)
    return adjusted


def extreme_contrast_text(cv2_img: np.ndarray) -> np.ndarray:
    """
    Extract faint, low-contrast ink on paper by computing adaptive local contrast.
    Guarantees text characters become distinct and visible.
    """
    gray = cv2.cvtColor(cv2_img, cv2.COLOR_BGR2GRAY)
    # Estimate background illumination
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 25))
    bg = cv2.morphologyEx(gray, cv2.MORPH_DILATE, kernel)
    # Background subtraction
    diff = 255 - cv2.absdiff(gray, bg)
    norm = cv2.normalize(diff, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8UC1)
    
    # Adaptive enhancement
    clahe = cv2.createCLAHE(clipLimit=3.5, tileGridSize=(8, 8))
    enhanced = clahe.apply(norm)
    return cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)


def auto_deskew(img: np.ndarray) -> np.ndarray:
    """Detect text tilt and rotate image to align text horizontally."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    gray = cv2.bitwise_not(gray)
    thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1]

    # Find coordinates of white pixels
    coords = np.column_stack(np.where(thresh > 0))
    if len(coords) < 10:
        return img

    # minAreaRect gives angle
    angle = cv2.minAreaRect(coords)[-1]
    if angle < -45:
        angle = -(90 + angle)
    else:
        angle = -angle

    # Only correct if tilt is moderate and significant
    if abs(angle) < 0.6 or abs(angle) > 40:
        return img

    h, w = img.shape[:2]
    center = (w // 2, h // 2)
    M = cv2.getRotationMatrix2D(center, angle, 1.0)
    rotated = cv2.warpAffine(
        img, M, (w, h),
        flags=cv2.INTER_CUBIC,
        borderMode=cv2.BORDER_REPLICATE
    )
    return rotated


def enhance_contrast(img: np.ndarray, clip_limit: float = 2.5) -> np.ndarray:
    """Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)."""
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    l, a, b = cv2.split(lab)
    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))
    l_enhanced = clahe.apply(l)
    merged = cv2.merge([l_enhanced, a, b])
    return cv2.cvtColor(merged, cv2.COLOR_LAB2BGR)


def denoise(img: np.ndarray) -> np.ndarray:
    """Bilateral filter to remove camera noise while preserving text edges."""
    return cv2.bilateralFilter(img, d=7, sigmaColor=50, sigmaSpace=50)


def binarize(img: np.ndarray) -> np.ndarray:
    """Adaptive Gaussian thresholding for crisp black/white document text."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    # Background shadow removal prior to binarization
    dilated = cv2.dilate(gray, np.ones((7, 7), np.uint8))
    bg = cv2.medianBlur(dilated, 21)
    diff = 255 - cv2.absdiff(gray, bg)
    norm = cv2.normalize(diff, None, alpha=0, beta=255, norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8UC1)
    
    binary = cv2.adaptiveThreshold(
        norm, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 21, 10
    )
    return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)


def detect_document_corners(img: np.ndarray):
    """Detect the 4 corners of a document for perspective warp."""
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    edged = cv2.Canny(blurred, 50, 150)

    contours, _ = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        return None

    contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
    for c in contours:
        peri = cv2.arcLength(c, True)
        approx = cv2.approxPolyDP(c, 0.02 * peri, True)
        if len(approx) == 4 and cv2.contourArea(c) > (img.shape[0] * img.shape[1] * 0.15):
            return approx

    return None


def order_points(pts):
    """Order points as: top-left, top-right, bottom-right, bottom-left."""
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]
    return rect


def perspective_warp(img: np.ndarray, corners) -> np.ndarray:
    """Flatten a perspective-distorted document using 4-point transform."""
    pts = corners.reshape(4, 2).astype("float32")
    rect = order_points(pts)
    tl, tr, br, bl = rect

    width_a = np.linalg.norm(br - bl)
    width_b = np.linalg.norm(tr - tl)
    max_width = max(int(width_a), int(width_b))

    height_a = np.linalg.norm(tr - br)
    height_b = np.linalg.norm(tl - bl)
    max_height = max(int(height_a), int(height_b))

    if max_width < 100 or max_height < 100:
        return img

    dst = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]
    ], dtype="float32")

    M = cv2.getPerspectiveTransform(rect, dst)
    warped = cv2.warpPerspective(img, M, (max_width, max_height))
    return warped


def full_preprocess(pil_img: Image.Image, mode: str = "auto") -> tuple[Image.Image, dict]:
    """
    Comprehensive document preprocessing pipeline.
    Modes:
      - 'auto': EXIF fix + deskew + shadow removal + CLAHE + sharpening + gentle denoise
      - 'camera_enhance': Specialized for live camera / phone photos (shadows, glare, blur compensation)
      - 'binarize': High contrast B&W text thresholding
      - 'warp': 4-corner perspective flattening
      - 'raw': Passthrough with EXIF rotation fixed
    """
    # Always normalize EXIF orientation first
    fixed_pil = fix_exif_orientation(pil_img)
    steps_applied = ["exif_orientation_fixed"]

    if mode == "raw":
        return fixed_pil, {"steps": ["raw_passthrough"]}

    cv2_img = pil_to_cv2(fixed_pil)

    # Resolution scaling check: if too small (e.g. low-res webcam), upscale with bicubic
    h, w = cv2_img.shape[:2]
    if min(h, w) < 700:
        scale = 800.0 / min(h, w)
        cv2_img = cv2.resize(cv2_img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
        steps_applied.append("rescale_upscale")

    # Step 1: Perspective Warp if explicitly selected
    if mode == "warp":
        corners = detect_document_corners(cv2_img)
        if corners is not None:
            cv2_img = perspective_warp(cv2_img, corners)
            steps_applied.append("perspective_warp")
        else:
            steps_applied.append("warp_not_detected")

    # Step 2: Auto Deskew
    deskewed = auto_deskew(cv2_img)
    if not np.array_equal(deskewed, cv2_img):
        steps_applied.append("auto_deskew")
    cv2_img = deskewed

    # Step 3: Camera photo shadow removal & illumination flattening
    if mode in ("camera_enhance", "auto"):
        cv2_img = remove_shadows_and_normalize(cv2_img)
        steps_applied.append("shadow_illumination_norm")

    # Step 4: CLAHE Contrast Enhancement
    cv2_img = enhance_contrast(cv2_img, clip_limit=3.0 if mode == "camera_enhance" else 2.2)
    steps_applied.append("clahe_contrast")

    # Step 5: Unsharp masking to sharpen characters
    cv2_img = sharpen_image(cv2_img, strength=1.2 if mode == "camera_enhance" else 0.8)
    steps_applied.append("unsharp_mask_sharpen")

    # Step 6: Denoise
    cv2_img = denoise(cv2_img)
    steps_applied.append("bilateral_denoise")

    # Step 7: Binarization or Extreme Contrast if requested
    if mode == "binarize":
        cv2_img = binarize(cv2_img)
        steps_applied.append("adaptive_binarize")
    elif mode == "extreme_contrast":
        cv2_img = extreme_contrast_text(cv2_img)
        steps_applied.append("extreme_contrast_text")

    return cv2_to_pil(cv2_img), {"steps": steps_applied}