From 21c871d50117e3362432d8e133bce99315d0e2de Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Sun, 12 Apr 2026 18:30:00 +0800 Subject: [PATCH] feat(ocr): add OpenCV preprocessing pipeline for OCR --- packages/ai/python/ocr_preprocess.py | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/ai/python/ocr_preprocess.py diff --git a/packages/ai/python/ocr_preprocess.py b/packages/ai/python/ocr_preprocess.py new file mode 100644 index 00000000..7b67540f --- /dev/null +++ b/packages/ai/python/ocr_preprocess.py @@ -0,0 +1,86 @@ +"""Image preprocessing pipeline for OCR accuracy improvement. + +Uses OpenCV for deskew, adaptive binarization, CLAHE contrast enhancement, +and denoising. All operations work on the image file in-place (overwrite). +""" +import cv2 +import numpy as np +import sys +import json + + +def emit_progress(percent, stage): + print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True) + + +def deskew(image): + """Detect and correct rotation/skew using Hough line transform.""" + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image + edges = cv2.Canny(gray, 50, 150, apertureSize=3) + lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=100, maxLineGap=10) + + if lines is None: + return image + + angles = [] + for line in lines: + x1, y1, x2, y2 = line[0] + angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) + if abs(angle) < 45: + angles.append(angle) + + if not angles: + return image + + median_angle = np.median(angles) + if abs(median_angle) < 0.5 or abs(median_angle) > 15: + return image + + h, w = image.shape[:2] + center = (w // 2, h // 2) + matrix = cv2.getRotationMatrix2D(center, median_angle, 1.0) + rotated = cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + return rotated + + +def binarize(image): + """Adaptive thresholding for high-contrast black/white.""" + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image + binary = cv2.adaptiveThreshold( + gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 + ) + return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) + + +def enhance_contrast(image): + """CLAHE contrast enhancement for uneven lighting.""" + lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + l = clahe.apply(l) + enhanced = cv2.merge([l, a, b]) + return cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR) + + +def denoise(image): + """Bilateral filter to remove noise while preserving text edges.""" + return cv2.bilateralFilter(image, 9, 75, 75) + + +def preprocess(input_path, output_path): + """Run the full preprocessing pipeline and save result. + + Steps: deskew -> enhance contrast -> denoise -> binarize + Order matters: binarize last because it strips color info needed by CLAHE. + """ + image = cv2.imread(input_path) + if image is None: + raise ValueError(f"Cannot read image: {input_path}") + + image = deskew(image) + image = enhance_contrast(image) + image = denoise(image) + image = binarize(image) + + cv2.imwrite(output_path, image) + return output_path