Spaces:
Running
Running
| """ | |
| معالج الصور المسبق | |
| ==================== | |
| تحضير الصور لمحركات التعرف على النصوص (OCR) عبر سلسلة خطوات معالجة. | |
| القدرات: | |
| - تحسين التباين باستخدام CLAHE | |
| - إزالة الضوضاء (Denoising) | |
| - تصحيح الميل (Deskewing) | |
| - ثنائنة أوتسو (Otsu Binarization) | |
| - التوسع (Dilation) لتجزئة الكلمات | |
| - تجزئة ذكية للصور إلى كلمات | |
| ملاحظة: المكتبات مطلوبة هي opencv-python و Pillow | |
| """ | |
| import logging | |
| from typing import Optional, Union | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| class ImagePreprocessor: | |
| """ | |
| معالج الصور المسبق - يحسن جودة الصور قبل تمريرها لمحرك OCR. | |
| مثال الاستخدام: | |
| >>> preprocessor = ImagePreprocessor( | |
| ... clahe_clip_limit=2.0, | |
| ... denoise_strength=10, | |
| ... apply_deskew=True, | |
| ... ) | |
| >>> from PIL import Image | |
| >>> img = Image.open("handwriting.png") | |
| >>> processed = preprocessor.preprocess(img) | |
| >>> words = preprocessor.smart_segment(img) | |
| """ | |
| def __init__( | |
| self, | |
| # إعدادات CLAHE | |
| apply_clahe: bool = True, | |
| clahe_clip_limit: float = 2.0, | |
| clahe_tile_size: tuple[int, int] = (8, 8), | |
| # إعدادات إزالة الضوضاء | |
| apply_denoise: bool = True, | |
| denoise_strength: int = 10, | |
| denoise_template_window: int = 7, | |
| denoise_search_window: int = 21, | |
| # إعدادات تصحيح الميل | |
| apply_deskew: bool = True, | |
| deskew_angle_threshold: float = 5.0, | |
| # إعدادات الثنائنة | |
| apply_binarize: bool = True, | |
| # إعدادات التوسع | |
| apply_dilate: bool = False, | |
| dilate_kernel_size: tuple[int, int] = (2, 2), | |
| dilate_iterations: int = 1, | |
| # إعدادات عامة | |
| target_size: Optional[tuple[int, int]] = None, | |
| convert_to_grayscale: bool = True, | |
| ) -> None: | |
| """ | |
| تهيئة معالج الصور. | |
| Args: | |
| apply_clahe: تفعيل تحسين التباين CLAHE | |
| clahe_clip_limit: حد القص لـ CLAHE (الافتراضي 2.0) | |
| clahe_tile_size: حجم البلاط لـ CLAHE (الافتراضي 8×8) | |
| apply_denoise: تفعيل إزالة الضوضاء | |
| denoise_strength: قوة إزالة الضوضاء (1-20، الافتراضي 10) | |
| denoise_template_window: حجم نافذة القالب (يجب أن يكون فردياً) | |
| denoise_search_window: حجم نافذة البحث (يجب أن يكون فردياً) | |
| apply_deskew: تفعيل تصحيح الميل | |
| deskew_angle_threshold: زاوية الميل المقبولة بالدرجات | |
| apply_binarize: تفعيل ثنائنة أوتسو | |
| apply_dilate: تفعيل التوسع (مفيد لتجزئة الكلمات) | |
| dilate_kernel_size: حجم نواة التوسع | |
| dilate_iterations: عدد تكرارات التوسع | |
| target_size: الحجم المستهدف (عرض، ارتفاع) أو None للحفاظ على الأصل | |
| convert_to_grayscale: تحويل إلى تدرج رمادي | |
| """ | |
| self.apply_clahe = apply_clahe | |
| self.clahe_clip_limit = clahe_clip_limit | |
| self.clahe_tile_size = clahe_tile_size | |
| self.apply_denoise = apply_denoise | |
| self.denoise_strength = denoise_strength | |
| self.denoise_template_window = max(1, denoise_template_window | 1) # التأكد من أنه فردي | |
| self.denoise_search_window = max(1, denoise_search_window | 1) | |
| self.apply_deskew = apply_deskew | |
| self.deskew_angle_threshold = deskew_angle_threshold | |
| self.apply_binarize = apply_binarize | |
| self.apply_dilate = apply_dilate | |
| self.dilate_kernel_size = dilate_kernel_size | |
| self.dilate_iterations = dilate_iterations | |
| self.target_size = target_size | |
| self.convert_to_grayscale = convert_to_grayscale | |
| # التحقق من توفر المكتبات | |
| self._has_cv2 = self._check_library("cv2", "opencv-python") | |
| self._has_pil = self._check_library("PIL", "Pillow") | |
| if not self._has_cv2: | |
| logger.warning( | |
| "OpenCV غير مثبت. لن تعمل معالجة الصور بشكل كامل. " | |
| "قم بالتثبيت: pip install opencv-python" | |
| ) | |
| def _check_library(import_name: str, package_name: str) -> bool: | |
| """التحقق من توفر مكتبة.""" | |
| try: | |
| __import__(import_name) | |
| return True | |
| except ImportError: | |
| return False | |
| # ------------------------------------------------------------------ | |
| # الأساليب العامة (Public API) | |
| # ------------------------------------------------------------------ | |
| def preprocess( | |
| self, | |
| image: Union["np.ndarray", "PIL.Image.Image"], | |
| return_numpy: bool = False, | |
| ) -> Union["np.ndarray", "PIL.Image.Image"]: | |
| """ | |
| تطبيق سلسلة المعالجة المسبقة الكاملة على الصورة. | |
| الترتيب: | |
| 1. تحويل إلى تدرج رمادي | |
| 2. تغيير الحجم (اختياري) | |
| 3. تحسين التباين (CLAHE) | |
| 4. إزالة الضوضاء | |
| 5. تصحيح الميل | |
| 6. الثنائنة (Otsu) | |
| 7. التوسع (اختياري) | |
| Args: | |
| image: صورة PIL أو مصفوفة numpy | |
| return_numpy: إرجاع مصفوفة numpy بدلاً من PIL Image | |
| Returns: | |
| الصورة المعالجة (PIL Image أو numpy array) | |
| """ | |
| # التحويل إلى numpy | |
| img_array = self._to_numpy(image) | |
| # 1. تحويل إلى تدرج رمادي عند الحاجة فقط | |
| needs_grayscale = ( | |
| self.convert_to_grayscale | |
| and img_array.ndim == 3 | |
| and ( | |
| self.apply_clahe | |
| or self.apply_denoise | |
| or self.apply_deskew | |
| or self.apply_binarize | |
| or self.apply_dilate | |
| ) | |
| ) | |
| if needs_grayscale: | |
| img_array = self._to_grayscale(img_array) | |
| # 2. تغيير الحجم | |
| if self.target_size is not None: | |
| img_array = self._resize(img_array, self.target_size) | |
| # 3. تحسين التباين CLAHE | |
| if self.apply_clahe: | |
| img_array = self._apply_clahe(img_array) | |
| # 4. إزالة الضوضاء | |
| if self.apply_denoise: | |
| img_array = self._apply_denoise(img_array) | |
| # 5. تصحيح الميل | |
| if self.apply_deskew: | |
| img_array = self._apply_deskew(img_array) | |
| # 6. الثنائنة (Otsu) | |
| if self.apply_binarize: | |
| img_array = self._apply_otsu(img_array) | |
| # 7. التوسع | |
| if self.apply_dilate: | |
| img_array = self._apply_dilate(img_array) | |
| # إرجاع النتيجة | |
| if return_numpy: | |
| return img_array | |
| else: | |
| return self._to_pil(img_array) | |
| def smart_segment( | |
| self, | |
| image: Union["np.ndarray", "PIL.Image.Image"], | |
| min_word_area: int = 100, | |
| padding: int = 5, | |
| ) -> list["PIL.Image.Image"]: | |
| """ | |
| تجزئة الصورة إلى صور كلمات فردية. | |
| يستخدم كشف الحواف والمحيطات لفصل الكلمات. | |
| Args: | |
| image: صورة PIL أو مصفوفة numpy | |
| min_word_area: الحد الأدنى لمساحة الكلمة بالبكسل | |
| padding: حشوة إضافية حول كل كلمة | |
| Returns: | |
| قائمة صور PIL لكل كلمة | |
| """ | |
| if not self._has_cv2: | |
| logger.warning("OpenCV غير متاح - لا يمكن تجزئة الصورة") | |
| return [] | |
| try: | |
| import cv2 | |
| from PIL import Image | |
| img_array = self._to_numpy(image) | |
| # التأكد من تدرج رمادي | |
| if img_array.ndim == 3: | |
| gray = self._to_grayscale(img_array) | |
| else: | |
| gray = img_array.copy() | |
| # ثنائنة | |
| _, binary = cv2.threshold( | |
| gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU | |
| ) | |
| # توسع خفيف لربط أجزاء الكلمة | |
| kernel = cv2.getStructuringElement( | |
| cv2.MORPH_RECT, (3, 3) | |
| ) | |
| dilated = cv2.dilate(binary, kernel, iterations=1) | |
| # كشف المحيطات | |
| contours, _ = cv2.findContours( | |
| dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE | |
| ) | |
| word_images: list[Image.Image] = [] | |
| img_h, img_w = gray.shape | |
| # فرز المحيطات من اليسار لليمين | |
| bounding_boxes = [] | |
| for contour in contours: | |
| x, y, w, h = cv2.boundingRect(contour) | |
| area = w * h | |
| if area >= min_word_area: | |
| bounding_boxes.append((x, y, w, h, area)) | |
| # ترتيب حسب الموقع (أولاً حسب Y ثم حسب X) | |
| bounding_boxes.sort(key=lambda b: (b[1] // 20, b[0])) | |
| for x, y, w, h, _ in bounding_boxes: | |
| # حساب الحشوة مع مراعاة حدود الصورة | |
| x1 = max(0, x - padding) | |
| y1 = max(0, y - padding) | |
| x2 = min(img_w, x + w + padding) | |
| y2 = min(img_h, y + h + padding) | |
| word_crop = gray[y1:y2, x1:x2] | |
| word_pil = Image.fromarray(word_crop).convert("RGB") | |
| word_images.append(word_pil) | |
| logger.debug( | |
| "تم تجزئة الصورة إلى %d كلمة", len(word_images) | |
| ) | |
| return word_images | |
| except Exception as e: | |
| logger.error("فشل في تجزئة الصورة: %s", e) | |
| return [] | |
| def get_word_bounding_boxes( | |
| self, | |
| image: Union["np.ndarray", "PIL.Image.Image"], | |
| min_word_area: int = 100, | |
| padding: int = 5, | |
| ) -> list[dict]: | |
| """ | |
| استخراج مربعات إحاطة الكلمات مع مواقعها. | |
| مفيد للخطوات التالية في إعادة تجميع النصوص. | |
| Args: | |
| image: صورة PIL أو مصفوفة numpy | |
| min_word_area: الحد الأدنى لمساحة الكلمة | |
| padding: حشوة إضافية | |
| Returns: | |
| قائمة قواميس: {bbox: (x, y, w, h), center: (cx, cy)} | |
| """ | |
| if not self._has_cv2: | |
| return [] | |
| try: | |
| import cv2 | |
| img_array = self._to_numpy(image) | |
| if img_array.ndim == 3: | |
| gray = self._to_grayscale(img_array) | |
| else: | |
| gray = img_array.copy() | |
| _, binary = cv2.threshold( | |
| gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU | |
| ) | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) | |
| dilated = cv2.dilate(binary, kernel, iterations=1) | |
| contours, _ = cv2.findContours( | |
| dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE | |
| ) | |
| boxes: list[dict] = [] | |
| img_h, img_w = gray.shape | |
| for contour in contours: | |
| x, y, w, h = cv2.boundingRect(contour) | |
| area = w * h | |
| if area >= min_word_area: | |
| cx = x + w // 2 | |
| cy = y + h // 2 | |
| boxes.append({ | |
| "bbox": (x, y, w, h), | |
| "center": (cx, cy), | |
| "area": area, | |
| }) | |
| # ترتيب حسب الموقع | |
| boxes.sort(key=lambda b: (b["center"][1] // 20, b["center"][0])) | |
| return boxes | |
| except Exception as e: | |
| logger.error("فشل في استخراج مربعات الإحاطة: %s", e) | |
| return [] | |
| # ------------------------------------------------------------------ | |
| # أساليب المعالجة الفردية | |
| # ------------------------------------------------------------------ | |
| def _apply_clahe(self, gray: np.ndarray) -> np.ndarray: | |
| """ | |
| تحسين التباين باستخدام خوارزمية CLAHE. | |
| CLAHE = Contrast Limited Adaptive Histogram Equalization | |
| مفيدة جداً للمخطوطات والنصوص ذات الإضاءة غير المتساوية. | |
| """ | |
| if not self._has_cv2: | |
| return gray | |
| try: | |
| import cv2 | |
| clahe = cv2.createCLAHE( | |
| clipLimit=self.clahe_clip_limit, | |
| tileGridSize=self.clahe_tile_size, | |
| ) | |
| return clahe.apply(gray) | |
| except Exception as e: | |
| logger.warning("فشل في تطبيق CLAHE: %s", e) | |
| return gray | |
| def _apply_denoise(self, gray: np.ndarray) -> np.ndarray: | |
| """ | |
| إزالة الضوضاء باستخدام خوارزمية fastNlMeansDenoising. | |
| تعمل فقط على الصور ذات التدرج الرمادي. | |
| """ | |
| if not self._has_cv2: | |
| return gray | |
| try: | |
| import cv2 | |
| strength = max(1, min(30, self.denoise_strength)) | |
| return cv2.fastNlMeansDenoising( | |
| gray, | |
| None, | |
| h=strength, | |
| templateWindowSize=self.denoise_template_window, | |
| searchWindowSize=self.denoise_search_window, | |
| ) | |
| except Exception as e: | |
| logger.warning("فشل في إزالة الضوضاء: %s", e) | |
| return gray | |
| def _apply_deskew(self, gray: np.ndarray) -> np.ndarray: | |
| """ | |
| تصحيح ميل النص في الصورة. | |
| يكتشف زاوية الميل باستخدام تحويل Hough ويصححها. | |
| """ | |
| if not self._has_cv2: | |
| return gray | |
| try: | |
| import cv2 | |
| # ثنائنة | |
| _, binary = cv2.threshold( | |
| gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU | |
| ) | |
| # تحويل Hough لكشف الخطوط | |
| edges = cv2.Canny(binary, 50, 150, apertureSize=3) | |
| lines = cv2.HoughLinesP( | |
| edges, | |
| rho=1, | |
| theta=np.pi / 180, | |
| threshold=100, | |
| minLineLength=gray.shape[1] // 4, | |
| maxLineGap=20, | |
| ) | |
| if lines is None: | |
| return gray | |
| # حساب زاوية الميل المتوسطة | |
| angles: list[float] = [] | |
| for line in lines: | |
| x1, y1, x2, y2 = line[0] | |
| if x2 - x1 == 0: | |
| continue | |
| angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) | |
| # فقط الزوايا الصغيرة (نص مائل، وليس خطوط عمودية) | |
| if abs(angle) < self.deskew_angle_threshold: | |
| angles.append(angle) | |
| if not angles: | |
| return gray | |
| median_angle = float(np.median(angles)) | |
| logger.debug("زاوية الميل المكتشفة: %.2f درجة", median_angle) | |
| # تصحيح الميل | |
| if abs(median_angle) > 0.1: | |
| h, w = gray.shape | |
| center = (w // 2, h // 2) | |
| rotation_matrix = cv2.getRotationMatrix2D(center, median_angle, 1.0) | |
| rotated = cv2.warpAffine( | |
| gray, rotation_matrix, (w, h), | |
| flags=cv2.INTER_CUBIC, | |
| borderMode=cv2.BORDER_REPLICATE, | |
| ) | |
| return rotated | |
| return gray | |
| except Exception as e: | |
| logger.warning("فشل في تصحيح الميل: %s", e) | |
| return gray | |
| def _apply_otsu(self, gray: np.ndarray) -> np.ndarray: | |
| """ | |
| تحويل الصورة إلى صورة ثنائية باستخدام طريقة أوتسو. | |
| مفيد جداً لـ OCR حيث يحول الصورة إلى أبيض وأسود فقط. | |
| """ | |
| if not self._has_cv2: | |
| return gray | |
| try: | |
| import cv2 | |
| # التأكد من أن القيم 0-255 | |
| if gray.dtype != np.uint8: | |
| gray = np.clip(gray, 0, 255).astype(np.uint8) | |
| _, binary = cv2.threshold( | |
| gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU | |
| ) | |
| return binary | |
| except Exception as e: | |
| logger.warning("فشل في تطبيق ثنائنة أوتسو: %s", e) | |
| return gray | |
| def _apply_dilate(self, gray: np.ndarray) -> np.ndarray: | |
| """ | |
| تطبيق التوسع على الصورة الثنائية. | |
| مفيد لتجزئة الكلمات الملتصقة. | |
| """ | |
| if not self._has_cv2: | |
| return gray | |
| try: | |
| import cv2 | |
| kernel = cv2.getStructuringElement( | |
| cv2.MORPH_RECT, self.dilate_kernel_size | |
| ) | |
| dilated = cv2.dilate( | |
| gray, kernel, iterations=self.dilate_iterations | |
| ) | |
| return dilated | |
| except Exception as e: | |
| logger.warning("فشل في تطبيق التوسع: %s", e) | |
| return gray | |
| # ------------------------------------------------------------------ | |
| # أدوات التحويل | |
| # ------------------------------------------------------------------ | |
| def _to_numpy(image: Union[np.ndarray, "PIL.Image.Image"]) -> np.ndarray: | |
| """تحويل أي صورة إلى مصفوفة numpy.""" | |
| if isinstance(image, np.ndarray): | |
| return image.copy() | |
| try: | |
| from PIL import Image | |
| if isinstance(image, Image.Image): | |
| return np.array(image) | |
| except ImportError: | |
| pass | |
| raise TypeError(f"نوع غير مدعوم: {type(image)} - مطلوب PIL.Image أو numpy.ndarray") | |
| def _to_grayscale(self, img_array: np.ndarray) -> np.ndarray: | |
| """تحويل مصفوفة ألوان إلى تدرج رمادي.""" | |
| if not self._has_cv2: | |
| # استخدام PIL كاحتياطي | |
| try: | |
| from PIL import Image | |
| pil_img = Image.fromarray(img_array) | |
| return np.array(pil_img.convert("L")) | |
| except Exception: | |
| # احتياطي بسيط: المتوسط المرجح | |
| if img_array.ndim == 3: | |
| return np.dot(img_array[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8) | |
| try: | |
| import cv2 | |
| return cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) | |
| except Exception: | |
| if img_array.ndim == 3: | |
| return np.dot(img_array[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8) | |
| return img_array | |
| def _to_pil(img_array: np.ndarray) -> "PIL.Image.Image": | |
| """تحويل مصفوفة numpy إلى صورة PIL.""" | |
| try: | |
| from PIL import Image | |
| except ImportError: | |
| raise RuntimeError("Pillow غير مثبت") | |
| if img_array.ndim == 2: | |
| return Image.fromarray(img_array, mode="L").convert("RGB") | |
| elif img_array.ndim == 3: | |
| if img_array.shape[2] == 4: | |
| return Image.fromarray(img_array, mode="RGBA") | |
| elif img_array.shape[2] == 3: | |
| return Image.fromarray(img_array, mode="RGB") | |
| else: | |
| return Image.fromarray(img_array[:, :, 0], mode="L").convert("RGB") | |
| else: | |
| return Image.fromarray(img_array).convert("RGB") | |
| def _resize(img_array: np.ndarray, target_size: tuple[int, int]) -> np.ndarray: | |
| """تغيير حجم مصفوفة الصورة.""" | |
| try: | |
| from PIL import Image | |
| pil_img = Image.fromarray(img_array) | |
| pil_img = pil_img.resize(target_size, Image.LANCZOS) | |
| return np.array(pil_img) | |
| except Exception as e: | |
| logger.warning("فشل في تغيير الحجم: %s", e) | |
| return img_array | |