OmniFile-Processor / modules /vision /image_preprocessor.py
Dr. Abdulmalek
deploy: OmniFile AI Processor v4.3.0
900df0b
"""
معالج الصور المسبق
====================
تحضير الصور لمحركات التعرف على النصوص (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"
)
@staticmethod
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
# ------------------------------------------------------------------
# أدوات التحويل
# ------------------------------------------------------------------
@staticmethod
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
@staticmethod
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")
@staticmethod
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