Spaces:
Running
Running
| """ | |
| HandwrittenOCR - معالجة الصور المسبقة v5.1 | |
| ============================================== | |
| تحسين الصور قبل التعرف مع تسجيل مفصّل لكل خطوة: | |
| - تسوية الميل (Deskewing) مع borderMode=BORDER_REPLICATE | |
| - CLAHE + Denoising + Thresholding (OTSU + Adaptive) | |
| - تجزئة ذكية: EasyOCR أولاً + IoU matching + الكنتورات كبديل | |
| - مُحصّن: crops من img_bgr الأصلية (ليس من الصورة الثنائية) | |
| - detect_columns(): كشف الأعمدة في الصفحة | |
| - column_aware_sort(): ترتيب الصناديق عمودياً داخل كل عمود | |
| - smart_segmentation() محسّن لدعم التقسيم المتقدم | |
| """ | |
| import cv2 | |
| import numpy as np | |
| import logging | |
| import time | |
| from typing import Tuple, Optional, List | |
| from config import Config | |
| logger = logging.getLogger("HandwrittenOCR") | |
| # استيراد أدوات اللوق المفصّل | |
| try: | |
| from src.logger import log_step, log_error_full, log_result | |
| except ImportError: | |
| def log_step(lg, name, data=None): | |
| lg.info(f"STEP [{name}]") | |
| if data: | |
| for k, v in data.items(): | |
| lg.info(f" {k}: {v}") | |
| def log_error_full(lg, ctx, err, extra=None): | |
| lg.error(f"ERROR [{ctx}] {type(err).__name__}: {err}", exc_info=True) | |
| def log_result(lg, name, result): | |
| lg.info(f"RESULT [{name}] {result}") | |
| def deskew(gray: np.ndarray, config: Config = None) -> np.ndarray: | |
| """تسوية ميل الصورة مع تسجيل مفصّل للزاوية والنتيجة.""" | |
| coords = np.column_stack(np.where(gray < 250)) | |
| if len(coords) < 50: | |
| logger.debug("deskew: نقاط قليلة جداً (<50) — يتجاوز التسوية") | |
| return gray | |
| angle = cv2.minAreaRect(coords)[-1] | |
| angle = -(90 + angle) if angle < -45 else -angle | |
| if abs(angle) < 0.3: | |
| logger.debug(f"deskew: زاوية صغيرة جداً ({angle:.3f}°) — لا حاجة للتسوية") | |
| return gray | |
| h, w = gray.shape[:2] | |
| M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) | |
| result = cv2.warpAffine(gray, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) | |
| logger.info(f"deskew: زاوية={angle:.2f}°, حجم الصورة={w}x{h}") | |
| return result | |
| def preprocess_image(img_bgr: np.ndarray, config: Config = None, adaptive: bool = False) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| معالجة الصورة المسبقة مع تسجيل مفصّل لكل خطوة. | |
| الخطوات: | |
| 1. تحويل لرمادي | |
| 2. تسوية الميل (Deskewing) | |
| 3. CLAHE (تحسين التباين) | |
| 4. Denoising (إزالة الضوضاء) | |
| 5. Thresholding (ثنائية) | |
| Returns: | |
| tuple: (binary, gray) | |
| """ | |
| if config is None: | |
| config = Config() | |
| logger.debug(f"preprocess_image: أبعاد={img_bgr.shape[:2]}, dtype={img_bgr.dtype}, adaptive={adaptive}") | |
| start = time.time() | |
| # 1. تحويل لرمادي | |
| if img_bgr.ndim == 3: | |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) | |
| logger.debug(f" تحويل لرمادي: {img_bgr.shape} => {gray.shape}") | |
| else: | |
| gray = img_bgr.copy() | |
| # 2. تسوية الميل | |
| if config.enable_deskew: | |
| gray = deskew(gray, config) | |
| logger.debug(" تم تسوية الميل") | |
| else: | |
| logger.debug(" تخطي تسوية الميل (enable_deskew=False)") | |
| # 3. CLAHE | |
| clahe = cv2.createCLAHE(clipLimit=config.clahe_clip, tileGridSize=config.clahe_tile) | |
| gray = clahe.apply(gray) | |
| logger.debug(f" CLAHE: clipLimit={config.clahe_clip}, tile={config.clahe_tile}") | |
| # 4. Denoising | |
| gray = cv2.fastNlMeansDenoising(gray, h=config.denoise_h) | |
| logger.debug(f" Denoising: h={config.denoise_h}") | |
| # 5. Thresholding | |
| if adaptive: | |
| binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 11) | |
| logger.debug(" Thresholding: Adaptive (GAUSSIAN_C, block=31, C=11)") | |
| else: | |
| _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) | |
| logger.debug(" Thresholding: OTSU (تلقائي)") | |
| elapsed = time.time() - start | |
| logger.info(f"preprocess_image اكتمل في {elapsed:.3f}s | أبعاد الصورة={gray.shape}") | |
| return binary, gray | |
| def compute_iou(b1, b2) -> float: | |
| """حساب IoU بين صندوقين.""" | |
| x1, y1, w1, h1 = b1 | |
| x2, y2, w2, h2 = b2 | |
| xi1, yi1 = max(x1, x2), max(y1, y2) | |
| xi2, yi2 = min(x1 + w1, x2 + w2), min(y1 + h1, y2 + h2) | |
| inter = max(0, xi2 - xi1) * max(0, yi2 - yi1) | |
| union = w1 * h1 + w2 * h2 - inter | |
| return inter / union if union > 0 else 0 | |
| def detect_columns(boxes, img_width, gap_threshold=0.15, min_column_words=3): | |
| """كشف الأعمدة في الصفحة مع تسجيل مفصّل.""" | |
| if not boxes or len(boxes) < min_column_words * 2: | |
| logger.debug(f"detect_columns: صناديق قليلة ({len(boxes) if boxes else 0}) — عمود واحد") | |
| return [sorted(boxes, key=lambda b: (b[1], b[0]))] | |
| gap_px = img_width * gap_threshold | |
| box_centers = [(x + w / 2, x, y, w, h) for x, y, w, h in boxes] | |
| sorted_by_x = sorted(box_centers, key=lambda c: c[0]) | |
| column_breaks = [] | |
| for i in range(1, len(sorted_by_x)): | |
| prev_cx = sorted_by_x[i - 1][0] | |
| curr_cx = sorted_by_x[i][0] | |
| gap = abs(curr_cx - prev_cx) | |
| if gap > gap_px: | |
| column_breaks.append(i) | |
| logger.debug(f" فاصل عمود عند الفهرس {i}: gap={gap:.0f}px > threshold={gap_px:.0f}px") | |
| if not column_breaks: | |
| logger.debug("detect_columns: لا فواصل عمود — عمود واحد") | |
| return [sorted(boxes, key=lambda b: (b[1], b[0]))] | |
| columns = [] | |
| prev_break = 0 | |
| for brk in column_breaks + [len(sorted_by_x)]: | |
| col_boxes = [(x, y, w, h) for _, x, y, w, h in sorted_by_x[prev_break:brk]] | |
| if col_boxes: | |
| col_boxes_sorted = sorted(col_boxes, key=lambda b: (b[1], b[0])) | |
| if len(col_boxes_sorted) >= min_column_words: | |
| columns.append(col_boxes_sorted) | |
| else: | |
| if columns: | |
| columns[-1].extend(col_boxes_sorted) | |
| columns[-1].sort(key=lambda b: (b[1], b[0])) | |
| else: | |
| columns.append(col_boxes_sorted) | |
| prev_break = brk | |
| logger.info(f"detect_columns: {len(columns)} عمود (من {len(boxes)} صندوق)") | |
| for i, col in enumerate(columns): | |
| logger.debug(f" عمود {i}: {len(col)} كلمة, x_range=[{col[0][0]}, {col[-1][0] + col[-1][2]}]") | |
| return columns if columns else [sorted(boxes, key=lambda b: (b[1], b[0]))] | |
| def column_aware_sort(boxes, img_width, lang="en"): | |
| """ترتيب الصناديق مع مراعاة الأعمدة والاتجاه.""" | |
| if not boxes: | |
| return [] | |
| columns = detect_columns(boxes, img_width) | |
| if len(columns) == 1: | |
| return columns[0] | |
| # ترتيب الأعمدة حسب اللغة | |
| if lang == "ar": | |
| columns.sort(key=lambda col: -col[0][0]) # RTL | |
| logger.debug("column_aware_sort: ترتيب RTL (عربي)") | |
| else: | |
| columns.sort(key=lambda col: col[0][0]) # LTR | |
| logger.debug("column_aware_sort: ترتيب LTR") | |
| result = [] | |
| for col in columns: | |
| result.extend(col) | |
| return result | |
| def smart_segmentation(img_bgr, binary, easyocr_detections=None, config=None): | |
| """ | |
| تجزئة ذكية للنص مع تسجيل مفصّل. | |
| يفضل EasyOCR detections أولاً، ثم يلجأ للكنتورات. | |
| """ | |
| if config is None: | |
| config = Config() | |
| log_step(logger, "smart_segmentation", { | |
| "img_size": f"{img_bgr.shape[:2]}", | |
| "detections_provided": str(easyocr_detections is not None), | |
| "num_detections": str(len(easyocr_detections) if easyocr_detections else 0), | |
| "min_word_w": config.min_word_w, | |
| "min_word_h": config.min_word_h, | |
| }) | |
| # === الطريقة 1: EasyOCR detections === | |
| if easyocr_detections: | |
| boxes = [] | |
| skipped = 0 | |
| for det in easyocr_detections: | |
| pts = np.array(det[0], dtype=np.int32) | |
| x, y, w, h = cv2.boundingRect(pts) | |
| if w > config.min_word_w and h > config.min_word_h: | |
| boxes.append((x, y, w, h)) | |
| else: | |
| skipped += 1 | |
| logger.info(f" EasyOCR boxes: {len(boxes)} صالح, {skipped} متجاوز (صغير)") | |
| if boxes: | |
| sorted_boxes = sorted(boxes, key=lambda b: (b[1], b[0])) | |
| logger.debug(f" smart_segmentation => {len(sorted_boxes)} صندوق من EasyOCR") | |
| return sorted_boxes | |
| logger.info(" لم ينتج عن EasyOCR صناديق صالحة — الانتقال للكنتورات") | |
| # === الطريقة 2: الكنتورات === | |
| logger.debug(" استخدام طريقة الكنتورات") | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, config.dilation_kernel) | |
| dilated = cv2.dilate(binary, kernel, iterations=1) | |
| contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| boxes = [ | |
| (x, y, w, h) for c in contours | |
| for x, y, w, h in [cv2.boundingRect(c)] | |
| if w > config.min_word_w and h > config.min_word_h | |
| ] | |
| sorted_boxes = sorted(boxes, key=lambda b: (b[1], b[0])) | |
| logger.info(f" الكنتورات: {len(contours)} كانتور => {len(boxes)} صندوق صالح") | |
| return sorted_boxes | |
| def match_boxes_with_detections(boxes, detections, iou_threshold=0.3): | |
| """مطابقة الصناديق مع detections EasyOCR باستخدام IoU مع تسجيل مفصّل.""" | |
| if not detections: | |
| logger.debug(f"match_boxes: لا detections — {len(boxes)} صندوق بدون مطابقة") | |
| return [(b, None) for b in boxes] | |
| det_boxes = [] | |
| for det in detections: | |
| pts = np.array(det[0], dtype=np.int32) | |
| det_boxes.append((cv2.boundingRect(pts), det)) | |
| result, used = [], set() | |
| matched_count = 0 | |
| unmatched = 0 | |
| for i, box in enumerate(boxes): | |
| best_det, best_iou = None, 0 | |
| for j, (db, det) in enumerate(det_boxes): | |
| if j in used: | |
| continue | |
| iou = compute_iou(box, db) | |
| if iou > best_iou and iou > iou_threshold: | |
| best_iou, best_det = iou, det | |
| used.add(j) | |
| if best_det: | |
| matched_count += 1 | |
| else: | |
| unmatched += 1 | |
| result.append((box, best_det)) | |
| logger.info(f"match_boxes: {matched_count} مطابق, {unmatched} غير مطابق (IoU>{iou_threshold})") | |
| return result | |
| def crop_safe(img, x, y, w, h): | |
| """قص آمن مع تسجيل الحالات الشاذة.""" | |
| H, W = img.shape[:2] | |
| crop = img[max(0, y): min(H, y + h), max(0, x): min(W, x + w)] | |
| if crop.size == 0: | |
| logger.warning(f"crop_safe: صورة فارغة! img={img.shape[:2]}, box=({x},{y},{w},{h})") | |
| elif crop.shape[0] < 3 or crop.shape[1] < 3: | |
| logger.debug(f"crop_safe: صندوق صغير جداً: {crop.shape[:2]}") | |
| return crop | |