Spaces:
Sleeping
Sleeping
| """ | |
| Image Preprocessor β OpenCV pipeline for cleaning dirty scans before OCR. | |
| Designed for low-quality input common in African contexts: | |
| poor lighting, skewed documents, noise, low resolution. | |
| IMPORTANT: Tesseract works best with clean grayscale or lightly processed images. | |
| Over-processing (aggressive binarization, morphological ops) destroys text on | |
| low-quality scans. This pipeline uses a TIERED approach: | |
| Tier 1 (Gentle): Grayscale β Upscale β Light denoise β CLAHE contrast | |
| Tier 2 (Medium): Adds Otsu binarization | |
| Tier 3 (Heavy): Adds morphological shadow removal + adaptive threshold | |
| The OCR engine tries Tier 1 first. If confidence is too low, it escalates. | |
| """ | |
| import cv2 | |
| import numpy as np | |
| from pathlib import Path | |
| from PIL import Image | |
| import pytesseract | |
| import os | |
| import sys | |
| # Auto-detect Tesseract on Windows | |
| if sys.platform == "win32": | |
| _win_paths = [ | |
| r"C:\Program Files\Tesseract-OCR\tesseract.exe", | |
| r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe", | |
| os.path.expandvars(r"%LOCALAPPDATA%\Programs\Tesseract-OCR\tesseract.exe"), | |
| ] | |
| for _p in _win_paths: | |
| if os.path.exists(_p): | |
| pytesseract.pytesseract.tesseract_cmd = _p | |
| break | |
| def fix_orientation(img_bgr: np.ndarray) -> np.ndarray: | |
| """ | |
| Detect the correct orientation by actively probing 4 angles (0, 90, 180, 270). | |
| It runs a fast OCR pass on a high-res center crop of the image to pick the | |
| angle with the most valid text and highest confidence. | |
| """ | |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) | |
| # Do NOT downscale! Downscaling destroys text legibility for Tesseract. | |
| # Instead, crop a 1200x1200 square from the center to keep it fast but high-res. | |
| h, w = gray.shape[:2] | |
| crop_size = min(1200, min(h, w)) | |
| start_y = (h - crop_size) // 2 | |
| start_x = (w - crop_size) // 2 | |
| probe_img = gray[start_y:start_y+crop_size, start_x:start_x+crop_size] | |
| # Binarize to make the probe even faster and clearer | |
| _, probe_img = cv2.threshold(probe_img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) | |
| best_score = -1 | |
| best_angle = 0 | |
| angles = [0, 90, 180, 270] | |
| for angle in angles: | |
| if angle == 90: | |
| test_img = cv2.rotate(probe_img, cv2.ROTATE_90_CLOCKWISE) | |
| elif angle == 180: | |
| test_img = cv2.rotate(probe_img, cv2.ROTATE_180) | |
| elif angle == 270: | |
| test_img = cv2.rotate(probe_img, cv2.ROTATE_90_COUNTERCLOCKWISE) | |
| else: | |
| test_img = probe_img | |
| try: | |
| # psm 11 = Sparse text | |
| data = pytesseract.image_to_data(test_img, output_type=pytesseract.Output.DICT, config='--psm 11') | |
| total_conf = 0.0 | |
| word_count = 0 | |
| # We will score based on the length of words. Real text has long words. Gibberish is mostly single characters. | |
| word_length_score = 0 | |
| for i in range(len(data['text'])): | |
| text = data['text'][i].strip() | |
| conf = data['conf'][i] | |
| try: | |
| conf_val = float(conf) | |
| except (ValueError, TypeError): | |
| conf_val = -1.0 | |
| # Only consider reasonably confident detections that contain actual letters | |
| if conf_val > 50 and text and any(c.isalpha() for c in text): | |
| # Clean the word of punctuation to get its true length | |
| clean_word = ''.join(c for c in text if c.isalnum()) | |
| if len(clean_word) > 1: # Ignore single character gibberish | |
| # Square the length to heavily reward actual words over random noise | |
| word_length_score += (len(clean_word) ** 2) | |
| total_conf += conf_val | |
| word_count += 1 | |
| avg_conf = (total_conf / word_count) if word_count > 0 else 0 | |
| score = word_length_score * (avg_conf / 100.0) | |
| if score > best_score: | |
| best_score = score | |
| best_angle = angle | |
| except Exception as e: | |
| print(f"Probe failed for angle {angle}: {e}") | |
| # Fallback to landscape heuristic if probing completely failed (score <= 0) | |
| if best_score <= 0: | |
| h, w = img_bgr.shape[:2] | |
| if w > h: | |
| # 270 degrees (counter-clockwise) is much more common for landscape scans | |
| # (e.g. holding phone sideways with top to the left) than 90 degrees. | |
| best_angle = 270 | |
| print(f"Active orientation probe decided angle: {best_angle} (Score: {best_score})") | |
| if best_angle == 90: | |
| return cv2.rotate(img_bgr, cv2.ROTATE_90_CLOCKWISE) | |
| elif best_angle == 180: | |
| return cv2.rotate(img_bgr, cv2.ROTATE_180) | |
| elif best_angle == 270: | |
| return cv2.rotate(img_bgr, cv2.ROTATE_90_COUNTERCLOCKWISE) | |
| return img_bgr | |
| def _ensure_min_resolution(gray_img: np.ndarray, min_width: int = 2000) -> np.ndarray: | |
| """ | |
| Upscale image if it's too small for reliable OCR. | |
| Target: at least 2000px wide (roughly 300 DPI for a standard page). | |
| """ | |
| h, w = gray_img.shape[:2] | |
| if w < min_width: | |
| scale = min_width / w | |
| new_w = int(w * scale) | |
| new_h = int(h * scale) | |
| return cv2.resize(gray_img, (new_w, new_h), interpolation=cv2.INTER_CUBIC) | |
| return gray_img | |
| def _deskew(img: np.ndarray) -> np.ndarray: | |
| """ | |
| Detect and correct skew angle using minAreaRect on contours. | |
| Falls back to no correction if skew detection fails. | |
| """ | |
| try: | |
| # If the image isn't binary, threshold it just for skew detection | |
| if len(img.shape) == 2: | |
| _, thresh = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) | |
| else: | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) | |
| coords = np.column_stack(np.where(thresh > 0)) | |
| if len(coords) < 100: | |
| return img | |
| angle = cv2.minAreaRect(coords)[-1] | |
| if angle < -45: | |
| angle = -(90 + angle) | |
| else: | |
| angle = -angle | |
| # Only correct if skew is significant but not extreme | |
| if abs(angle) < 0.5 or abs(angle) > 15: | |
| return img | |
| h, w = img.shape[:2] | |
| center = (w // 2, h // 2) | |
| rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0) | |
| rotated = cv2.warpAffine( | |
| img, rotation_matrix, (w, h), | |
| flags=cv2.INTER_CUBIC, | |
| borderMode=cv2.BORDER_REPLICATE, | |
| ) | |
| return rotated | |
| except Exception: | |
| return img | |
| def preprocess_gentle(img: np.ndarray) -> np.ndarray: | |
| """ | |
| Tier 1 β Gentle preprocessing. Best for most scanned documents. | |
| Steps: Grayscale β Upscale β Light Denoise β CLAHE β Deskew | |
| Does NOT binarize. Tesseract handles grayscale images very well and its | |
| internal Otsu thresholding is often better than ours. | |
| """ | |
| # Convert to grayscale | |
| if len(img.shape) == 3: | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| else: | |
| gray = img | |
| # Upscale small images | |
| gray = _ensure_min_resolution(gray) | |
| # Light denoise β gentler than before (h=6 vs h=10) | |
| denoised = cv2.fastNlMeansDenoising(gray, None, h=6, templateWindowSize=7, searchWindowSize=21) | |
| # CLAHE contrast enhancement β gentler settings | |
| clahe = cv2.createCLAHE(clipLimit=1.5, tileGridSize=(8, 8)) | |
| enhanced = clahe.apply(denoised) | |
| # Deskew | |
| result = _deskew(enhanced) | |
| return result | |
| def preprocess_medium(img: np.ndarray) -> np.ndarray: | |
| """ | |
| Tier 2 β Medium preprocessing. For documents with moderate shadows. | |
| Steps: Gentle pipeline + Otsu binarization | |
| """ | |
| # Start with the gentle pipeline | |
| enhanced = preprocess_gentle(img) | |
| # Add Otsu binarization (let OpenCV pick the optimal threshold) | |
| _, binary = cv2.threshold(enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| return binary | |
| def preprocess_heavy(img: np.ndarray) -> np.ndarray: | |
| """ | |
| Tier 3 β Heavy preprocessing. For severely degraded scans with deep shadows. | |
| Steps: Grayscale β Upscale β Morphological Background Division β CLAHE β Denoise β Adaptive Threshold β Deskew | |
| """ | |
| if len(img.shape) == 3: | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| else: | |
| gray = img | |
| gray = _ensure_min_resolution(gray) | |
| # Morphological Background Division (flattens shadows & wrinkles) | |
| kernel_size = 45 | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) | |
| background = cv2.morphologyEx(gray, cv2.MORPH_DILATE, kernel) | |
| diff = 255 - cv2.absdiff(gray, background) | |
| # CLAHE | |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) | |
| enhanced = clahe.apply(diff) | |
| # Denoise | |
| denoised = cv2.fastNlMeansDenoising(enhanced, None, h=10, templateWindowSize=7, searchWindowSize=21) | |
| # Adaptive threshold | |
| binary = cv2.adaptiveThreshold( | |
| denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 15 | |
| ) | |
| result = _deskew(binary) | |
| return result | |
| def preprocess_image(image_path: str | Path, output_path: str | Path | None = None) -> np.ndarray: | |
| """ | |
| Full preprocessing pipeline for a dirty scan or photo. | |
| Uses the GENTLE tier by default (best for Tesseract). | |
| The OCR engine's adaptive pipeline will escalate if needed. | |
| """ | |
| img = cv2.imread(str(image_path)) | |
| if img is None: | |
| raise ValueError(f"Could not load image: {image_path}") | |
| img = fix_orientation(img) | |
| result = preprocess_gentle(img) | |
| if output_path: | |
| cv2.imwrite(str(output_path), result) | |
| return result | |
| def preprocess_for_ocr(image_path: str | Path) -> str: | |
| """ | |
| Preprocess an image and save the cleaned version to a temp file. | |
| Returns the path to the cleaned image for OCR consumption. | |
| """ | |
| output_path = Path(str(image_path)).with_suffix(".cleaned.png") | |
| preprocess_image(image_path, output_path) | |
| return str(output_path) | |