Spaces:
Sleeping
Sleeping
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}
|