File size: 7,782 Bytes
900df0b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""
وحدة تحويل مخرجات أي محرك OCR إلى الهيكل القياسي JSON.

هذه الوحدة مسؤولة عن توحيد نتائج جميع محركات OCR (Tesseract, EasyOCR,
TrOCR, PaddleOCR, Surya) في هيكل JSON موحد يمكن استهلاكه من وحدات
التصدير (layout_preserving.py) وواجهة المراجعة (mobile_review).

هيكل JSON القياسي:
{
    "metadata": {
        "source_file": "image.jpg",
        "processing_date": "2026-05-03T13:00:00",
        "engine": "surya",
        "languages_detected": ["ar", "en"],
        "page_count": 1,
        "version": "1.0"
    },
    "pages": [{
        "page_index": 0,
        "width": 2480,
        "height": 3508,
        "image_path": "image.jpg",
        "blocks": [{
            "id": "block_1",
            "type": "paragraph",
            "bbox": [0.1, 0.2, 0.9, 0.3],
            "text": "...",
            "confidence": 0.95
        }]
    }]
}

المؤلف: Dr Abdulmalek Tamer Al-husseini
الترخيص: MIT
"""

import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional

logger = logging.getLogger(__name__)


def normalize_ocr_output(
    raw_blocks: list[dict[str, Any]],
    image_path: str,
    page_width: int,
    page_height: int,
    engine_name: str,
    languages: Optional[list[str]] = None,
) -> dict[str, Any]:
    """
    تحويل كتل OCR الخام إلى الهيكل القياسي JSON.

    Args:
        raw_blocks: قائمة كائنات (block) من المحرك. كل كائن يحتوي على:
            - bbox (نسبي): [x1, y1, x2, y2]
            - text: النص
            - confidence: (اختياري) نسبة الثقة 0-1
            - type: نوع الكتلة ('paragraph', 'table', 'image', 'header', ...)
            - cells: (للجداول) قائمة صفوف تحتوي على نصوص الخلايا
            - image_file: (للصور) مسار ملف الصورة
            - caption: (للصور) {'text': ..., 'bbox': ...}
        image_path: مسار ملف الصورة الأصلي
        page_width: عرض الصورة بالبكسل
        page_height: ارتفاع الصورة بالبكسل
        engine_name: اسم المحرك المستخدم (surya, tesseract, easyocr, ...)
        languages: قائمة اللغات المكتشفة

    Returns:
        dict يمثل صفحة واحدة متوافقة مع الهيكل القياسي
    """
    if languages is None:
        languages = ["ar", "en"]

    blocks_normalized = []
    for idx, block in enumerate(raw_blocks):
        entry: dict[str, Any] = {
            "id": f"block_{idx + 1}",
            "type": block.get("type", "paragraph"),
            "bbox": block.get("bbox", [0, 0, 1, 1]),
            "text": block.get("text", ""),
            "confidence": block.get("confidence", 0.0),
        }

        # إذا كان جدولاً، التعامل مع الخلايا
        if entry["type"] == "table" and "cells" in block:
            cells_list = []
            cells_data = block["cells"]
            if isinstance(cells_data, list):
                for r_idx, row in enumerate(cells_data):
                    if isinstance(row, list):
                        for c_idx, cell_text in enumerate(row):
                            cells_list.append({
                                "row": r_idx,
                                "col": c_idx,
                                "text": str(cell_text),
                                "bbox": [],
                                "confidence": block.get("confidence", 0.0),
                            })
            entry["structure"] = {
                "rows": len(cells_data) if isinstance(cells_data, list) else 0,
                "cols": (
                    len(cells_data[0])
                    if isinstance(cells_data, list) and cells_data
                    else 0
                ),
                "cells": cells_list,
            }

        # التعامل مع الصور والتسميات
        if entry["type"] == "image":
            entry["image_file"] = block.get("image_file", "")
            if "caption" in block:
                caption_data = block["caption"]
                entry["caption"] = {
                    "text": caption_data.get("text", "")
                    if isinstance(caption_data, dict)
                    else str(caption_data),
                    "bbox": caption_data.get("bbox", [])
                    if isinstance(caption_data, dict)
                    else [],
                }

        blocks_normalized.append(entry)

    # بناء كائن الصفحة
    page = {
        "page_index": 0,
        "width": page_width,
        "height": page_height,
        "image_path": image_path,
        "blocks": blocks_normalized,
    }

    # بناء الهيكل الكامل
    result = {
        "metadata": {
            "source_file": image_path,
            "processing_date": datetime.now(timezone.utc).isoformat(),
            "engine": engine_name,
            "languages_detected": languages,
            "page_count": 1,
            "version": "1.0",
        },
        "pages": [page],
    }

    logger.info(
        "تم تطبيع %d كتلة من محرك %s",
        len(blocks_normalized),
        engine_name,
    )

    return result


def merge_pages(normalized_results: list[dict[str, Any]]) -> dict[str, Any]:
    """
    دمج نتائج تطبيع متعددة (صفحات متعددة) في نتيجة واحدة.

    Args:
        normalized_results: قائمة نتائج من normalize_ocr_output()

    Returns:
        dict يحتوي على كل الصفحات المدمجة
    """
    if not normalized_results:
        return {}

    if len(normalized_results) == 1:
        return normalized_results[0]

    # البدء بالنتيجة الأولى كأساس
    merged = {
        "metadata": dict(normalized_results[0]["metadata"]),
        "pages": [],
    }

    total_pages = 0
    all_engines = set()

    for result in normalized_results:
        for page in result.get("pages", []):
            page["page_index"] = total_pages
            merged["pages"].append(page)
            total_pages += 1

        meta = result.get("metadata", {})
        all_engines.add(meta.get("engine", "unknown"))

    merged["metadata"]["page_count"] = total_pages
    merged["metadata"]["engine"] = ", ".join(sorted(all_engines))

    return merged


def save_normalized(
    normalized_data: dict[str, Any],
    output_path: str,
) -> str:
    """
    حفظ النتيجة الموحدة في ملف JSON.

    Args:
        normalized_data: بيانات من normalize_ocr_output() أو merge_pages()
        output_path: مسار ملف JSON المطلوب

    Returns:
        مسار الملف المحفوظ
    """
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(normalized_data, f, ensure_ascii=False, indent=2)

    logger.info("تم حفظ النتيجة الموحدة: %s", output_path)
    return output_path


def load_normalized(input_path: str) -> dict[str, Any]:
    """
    تحميل ملف JSON بتنسيق الهيكل القياسي.

    Args:
        input_path: مسار ملف JSON

    Returns:
        dict يحتوي على البيانات الموحدة
    """
    with open(input_path, "r", encoding="utf-8") as f:
        data = json.load(f)

    # التحقق الأساسي من الهيكل
    if "pages" not in data:
        raise ValueError("ملف JSON غير صالح: يفتقد حقل 'pages'")
    if "metadata" not in data:
        raise ValueError("ملف JSON غير صالح: يفتقد حقل 'metadata'")

    return data