{ "cells": [ { "cell_type": "markdown", "id": "bcf8bfe6-3c3f-42d6-b5fe-c46afed62587", "metadata": {}, "source": [ "# code de normalisation pour evaluer les 8 metrics d accuracy du text " ] }, { "cell_type": "code", "execution_count": 1, "id": "60277596-11a4-436e-858d-9f8c06a69d50", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "✅ 6/6 tests passés\n" ] } ], "source": [ "import re\n", "import unicodedata\n", "\n", "\n", "def normalize_arabic_ocr(text: str, remove_markdown: bool = False) -> str:\n", " \"\"\"\n", " Pipeline de normalisation canonique pour benchmark OCR arabe.\n", " À appliquer de manière IDENTIQUE sur GT et hypothèse OCR.\n", "\n", " Étapes :\n", " 1. (Optionnel) Suppression Markdown\n", " 2. NFKC — formes de présentation arabes\n", " 3. Caractères invisibles (ZWJ, ZWNJ, marques bidi, BOM)\n", " 4. Diacritiques arabes (tashkil)\n", " 5. Tatweel / kashida\n", " 6. Alef variants → ا\n", " 7. Alef maqsura → ي ← nouveau\n", " 8. Ta marbuta → ه\n", " 9. Hamza sur support : ؤ→و ئ→ي\n", " 10. Ponctuation arabe + latine → espace\n", " 11. Whitelist : garde arabe + chiffres + latin + espace ← nouveau\n", " 12. Chiffres arabes-indiens → ASCII\n", " 13. Latin → minuscules ← nouveau\n", " 14. Espaces multiples → un seul, strip\n", " \"\"\"\n", " if not isinstance(text, str) or not text.strip():\n", " return \"\"\n", "\n", " # ── 1. Markdown (désactivé par défaut pour OCR pur) ──────────────────────\n", " if remove_markdown:\n", " text = re.sub(r'#{1,6}\\s*', '', text)\n", " text = re.sub(r'^\\s*[-*+]\\s+', '', text, flags=re.MULTILINE)\n", " text = re.sub(r'\\*{1,3}(.*?)\\*{1,3}', r'\\1', text, flags=re.DOTALL)\n", " text = re.sub(r'`{1,3}.*?`{1,3}', ' ', text, flags=re.DOTALL)\n", " text = re.sub(r'\\[([^\\]]*)\\]\\([^\\)]*\\)', r'\\1', text)\n", " text = re.sub(r'!\\[[^\\]]*\\]\\([^\\)]*\\)', ' ', text)\n", " text = re.sub(r'\\|', ' ', text)\n", "\n", " # ── 2. NFKC ──────────────────────────────────────────────────────────────\n", " # NFC ne suffit pas : NFKC décompose aussi les formes de présentation\n", " # arabes (Presentation Forms-A/B : ﻛ ﻜ ﻟ ﻻ …) très fréquentes en OCR.\n", " text = unicodedata.normalize('NFKC', text)\n", "\n", " # ── 3. Caractères invisibles & marques bidirectionnelles ─────────────────\n", " text = re.sub(\n", " r'[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\uFEFF\\u00AD]',\n", " '', text\n", " )\n", "\n", " # ── 4. Diacritiques arabes (tashkil) ─────────────────────────────────────\n", " text = re.sub(\n", " r'[\\u064B-\\u065F' # fatha, damma, kasra, shadda, sukun …\n", " r'\\u0610-\\u061A' # Arabic extended marks\n", " r'\\u06D6-\\u06DC'\n", " r'\\u06DF-\\u06E4'\n", " r'\\u06E7\\u06E8'\n", " r'\\u06EA-\\u06ED]',\n", " '', text\n", " )\n", "\n", " # ── 5. Tatweel / kashida ─────────────────────────────────────────────────\n", " text = text.replace('\\u0640', '')\n", "\n", " # ── 6. Alef variants → ا ─────────────────────────────────────────────────\n", " text = re.sub(r'[\\u0622\\u0623\\u0625\\u0671\\u0672\\u0673\\u0675]', '\\u0627', text)\n", "\n", " # ── 7. Alef maqsura → ي ──────────────────────────────────────────────────\n", " text = text.replace('\\u0649', '\\u064A')\n", "\n", " # ── 8. Ta marbuta → ه ────────────────────────────────────────────────────\n", " text = text.replace('\\u0629', '\\u0647')\n", "\n", " # ── 9. Hamza sur support ─────────────────────────────────────────────────\n", " text = text.replace('\\u0624', '\\u0648') # ؤ → و\n", " text = text.replace('\\u0626', '\\u064A') # ئ → ي\n", "\n", " # ── 10. Ponctuation arabe + latine → espace ──────────────────────────────\n", " text = re.sub(r'[\\u060C\\u061B\\u061F\\u066A-\\u066D\\u0600-\\u0605]', ' ', text)\n", " text = re.sub(r'[.,:;()\\[\\]{}\\-«»\"\\'!?/\\\\~@#$%^&*_+=<>|]', ' ', text)\n", " \n", " # ── 13. Latin → minuscules ───────────────────────────────────────────────\n", " text = text.lower()\n", "\n", "\n", " # ── 11. Whitelist ─────────────────────────────────────────────────────────\n", " # Conserve : bloc arabe (0600-06FF + 0750-077F), chiffres ASCII,\n", " # latin a-z (noms propres, acronymes), espace.\n", " # Élimine : symboles math/monnaie, emojis, tirets typographiques,\n", " # guillemets exotiques, caractères de contrôle résiduels.\n", " text = re.sub(r'[^\\u0600-\\u06FF\\u0750-\\u077Fa-z0-9\\s]', ' ', text)\n", "\n", " # ── 12. Chiffres arabes-indiens → ASCII ──────────────────────────────────\n", " text = text.translate(str.maketrans('٠١٢٣٤٥٦٧٨٩', '0123456789'))\n", "\n", " # ── 13. Latin → minuscules ───────────────────────────────────────────────\n", " text = text.lower()\n", "\n", " # ── 14. Espaces multiples → un seul ──────────────────────────────────────\n", " return re.sub(r'\\s+', ' ', text).strip()\n", "\n", "\n", "# ─── Vérification rapide ───────────────────────────────────────────────────\n", "if __name__ == '__main__':\n", " samples = [\n", " (\"Diacritiques\", \"الكِتَابُ المُفِيدُ\", \"الكتاب المفيد\"),\n", " (\"Alef maqsura\", \"موسى ويحيى\", \"موسي ويحيي\"),\n", " (\"Formes présent.\", \"ﻛﻠﻤﺔ\", \"كلمه\"),\n", " (\"Invisible chars\",\"م\\u200Bر\\u200Fحبا\", \"مرحبا\"),\n", " (\"Whitelist\", \"السعر: €15 أو 15€ !\", \"السعر 15 او 15\"),\n", " (\"Mixte latin\", \"نموذج GPT-4 وClaude\", \"نموذج gpt 4 وclaude\"),\n", " ]\n", " ok, fail = 0, 0\n", " for name, inp, expected in samples:\n", " result = normalize_arabic_ocr(inp)\n", " status = \"✅\" if result == expected else \"❌\"\n", " if result == expected:\n", " ok += 1\n", " else:\n", " fail += 1\n", " print(f\"{status} {name}: '{result}' ≠ '{expected}'\")\n", " print(f\"\\n{'✅' if fail == 0 else '⚠️'} {ok}/{ok+fail} tests passés\")" ] }, { "cell_type": "code", "execution_count": null, "id": "91743108-1eb3-47b7-a09e-bb3c048d7ee9", "metadata": {}, "outputs": [], "source": [ "import os\n", "from pathlib import Path\n", "\n", "input_folder = Path(\"/home/skiredj.abderrahman/khalil/Benchmarking_OCR/Ground_truth_first_20_not_normalised\")\n", "output_folder = Path(\"/home/skiredj.abderrahman/khalil/Benchmarking_OCR/Ground_truth_first_20_normalised\")\n", "output_folder.mkdir(parents=True, exist_ok=True)\n", "\n", "for md_file in input_folder.glob(\"*.md\"):\n", " text = md_file.read_text(encoding=\"utf-8\")\n", " normalized = normalize_arabic_ocr(text, remove_markdown=True)\n", " out_path = output_folder / md_file.name\n", " out_path.write_text(normalized, encoding=\"utf-8\")\n", " print(f\"✅ {md_file.name}\")" ] }, { "cell_type": "markdown", "id": "041e5c05-eb98-4b85-a4ec-a7367458a005", "metadata": {}, "source": [ "# code benchmarking. evaluation des 8 metrics d accuracy + 1 metric TEDS pour les tableaux (9 metrics )" ] }, { "cell_type": "code", "execution_count": null, "id": "b4913d1b-5730-45dd-92d7-11c843fb014e", "metadata": {}, "outputs": [], "source": [ "import re\n", "import unicodedata\n", "import html as _html\n", "import json\n", "import editdistance\n", "import jiwer\n", "import sacrebleu\n", "from rouge_score import rouge_scorer as rs\n", "from apted import APTED, Config\n", "from lxml import etree\n", "from typing import Optional\n", "\n", "# ── Paths ─────────────────────────────────────────────────────────────────────\n", "GT_RAW_PATH = \"/home/skiredj.abderrahman/khalil/Benchmarking_OCR/Ground_truth_first_20_not_normalised/1to17.md\"\n", "HYP_RAW_PATH = \"/home/skiredj.abderrahman/khalil/Benchmarking_OCR/tests_deepseek2_no_split/output_markdown_no_pageN/images_de_tests.md\"\n", "MODEL_NAME = \"Deepseek2_without_split\"\n", "\n", "# ── Normalization ─────────────────────────────────────────────────────────────\n", "def normalize_arabic_ocr(text: str, remove_markdown: bool = False) -> str:\n", " if not isinstance(text, str) or not text.strip():\n", " return \"\"\n", " if remove_markdown:\n", " text = re.sub(r'#{1,6}\\s*', '', text)\n", " text = re.sub(r'^\\s*[-*+]\\s+', '', text, flags=re.MULTILINE)\n", " text = re.sub(r'\\*{1,3}(.*?)\\*{1,3}', r'\\1', text, flags=re.DOTALL)\n", " text = re.sub(r'`{1,3}.*?`{1,3}', ' ', text, flags=re.DOTALL)\n", " text = re.sub(r'\\[([^\\]]*)\\]\\([^\\)]*\\)', r'\\1', text)\n", " text = re.sub(r'!\\[[^\\]]*\\]\\([^\\)]*\\)', ' ', text)\n", " text = re.sub(r'\\|', ' ', text)\n", " text = unicodedata.normalize('NFKC', text)\n", " text = re.sub(r'[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\uFEFF\\u00AD]', '', text)\n", " text = re.sub(\n", " r'[\\u064B-\\u065F\\u0610-\\u061A\\u06D6-\\u06DC\\u06DF-\\u06E4\\u06E7\\u06E8\\u06EA-\\u06ED]',\n", " '', text\n", " )\n", " text = text.replace('\\u0640', '')\n", " text = re.sub(r'[\\u0622\\u0623\\u0625\\u0671\\u0672\\u0673\\u0675]', '\\u0627', text)\n", " text = text.replace('\\u0649', '\\u064A')\n", " text = text.replace('\\u0629', '\\u0647')\n", " text = text.replace('\\u0624', '\\u0648')\n", " text = text.replace('\\u0626', '\\u064A')\n", " text = re.sub(r'[\\u060C\\u061B\\u061F\\u066A-\\u066D\\u0600-\\u0605]', ' ', text)\n", " text = re.sub(r'[.,:;()\\[\\]{}\\-«»\"\\'!?/\\\\~@#$%^&*_+=<>|]', ' ', text)\n", " text = text.lower()\n", " text = re.sub(r'[^\\u0600-\\u06FF\\u0750-\\u077Fa-z0-9\\s]', ' ', text)\n", " text = text.translate(str.maketrans('٠١٢٣٤٥٦٧٨٩', '0123456789'))\n", " text = text.lower()\n", " return re.sub(r'\\s+', ' ', text).strip()\n", "\n", "# ── Page splitter ─────────────────────────────────────────────────────────────\n", "def split_pages_raw(content: str) -> dict:\n", " pattern = re.compile(r'^===\\s*(.+?)\\s*===$', re.MULTILINE)\n", " matches = list(pattern.finditer(content))\n", " if not matches:\n", " print(\"⚠️ No page separators found\")\n", " return {\"page_01\": content.strip()}\n", " pages = {}\n", " for idx, m in enumerate(matches):\n", " page_id = m.group(1).strip()\n", " start = m.end()\n", " end = matches[idx + 1].start() if idx + 1 < len(matches) else len(content)\n", " text = content[start:end].strip()\n", " if text:\n", " pages[page_id] = text\n", " return pages\n", "\n", "# ── TEDS helpers ──────────────────────────────────────────────────────────────\n", "\n", "def _is_separator_row(row: str) -> bool:\n", " \"\"\"True if row is a markdown separator like |---|---|\"\"\"\n", " cells = [c.strip() for c in row.strip().strip('|').split('|')]\n", " return bool(cells) and all(re.match(r'^[-:\\s]+$', c) for c in cells if c)\n", "\n", "\n", "def _expand_inline_table(line: str) -> Optional[str]:\n", " \"\"\"\n", " Detect and expand a single-line collapsed markdown table.\n", "\n", " The HYP model outputs tables like:\n", " header1 | header2 | ... | --- | --- | ... | cell1 | cell2 | ...\n", " all on ONE line. We detect the separator fragment '| --- |' or '|---|'\n", " and reconstruct a proper multi-line table.\n", "\n", " Returns a multi-line markdown table string, or None if not detected.\n", " \"\"\"\n", " # Must contain a separator pattern inside the line\n", " if not re.search(r'\\|\\s*-{2,}[\\s:]*\\|', line):\n", " return None\n", "\n", " # Tokenise: split on '|' but keep content\n", " raw_cells = [c.strip() for c in line.split('|')]\n", " # Remove leading/trailing empty strings from splitting\n", " while raw_cells and raw_cells[0] == '':\n", " raw_cells.pop(0)\n", " while raw_cells and raw_cells[-1] == '':\n", " raw_cells.pop()\n", "\n", " if not raw_cells:\n", " return None\n", "\n", " # Find all separator positions (cells that look like '---', ':---:', etc.)\n", " sep_positions = [i for i, c in enumerate(raw_cells) if re.match(r'^[-:\\s]+$', c) and len(c) >= 2]\n", "\n", " if not sep_positions:\n", " return None\n", "\n", " # We expect separators to appear as a consecutive block.\n", " # Strategy: find the first run of separator cells → that marks the header/sep boundary.\n", " # Rows are inferred by counting columns = number of separators.\n", " n_cols = len(sep_positions)\n", "\n", " # The separator cells should be consecutive; find where they start\n", " sep_start = sep_positions[0]\n", "\n", " # Everything before the separator block = header cells\n", " header_cells = raw_cells[:sep_start]\n", "\n", " # If header has more cells than n_cols, it might contain preamble text.\n", " # Trim header to last n_cols cells.\n", " if len(header_cells) > n_cols:\n", " header_cells = header_cells[len(header_cells) - n_cols:]\n", " elif len(header_cells) < n_cols:\n", " # Pad header with empty cells\n", " header_cells = [''] * (n_cols - len(header_cells)) + header_cells\n", "\n", " # Everything after the separator block = data cells\n", " data_cells = raw_cells[sep_start + n_cols:]\n", "\n", " # Build rows from data_cells in chunks of n_cols\n", " rows = []\n", " for i in range(0, len(data_cells), n_cols):\n", " chunk = data_cells[i:i + n_cols]\n", " # Pad incomplete last row\n", " while len(chunk) < n_cols:\n", " chunk.append('')\n", " rows.append(chunk)\n", "\n", " if not rows:\n", " return None\n", "\n", " # Reconstruct multi-line markdown table\n", " sep_row = '| ' + ' | '.join(['---'] * n_cols) + ' |'\n", " header_row = '| ' + ' | '.join(header_cells) + ' |'\n", " data_rows = ['| ' + ' | '.join(r) + ' |' for r in rows]\n", "\n", " return '\\n'.join([header_row, sep_row] + data_rows)\n", "\n", "\n", "def extract_md_tables(text: str) -> list:\n", " \"\"\"\n", " Extract markdown tables from text.\n", " Handles both:\n", " - Normal multi-line markdown tables (one row per line)\n", " - Collapsed single-line tables (entire table on one line, as produced by some OCR models)\n", " \"\"\"\n", " tables = []\n", " lines = text.split('\\n')\n", " cur = []\n", "\n", " for line in lines:\n", " stripped = line.strip()\n", "\n", " # ── Try to detect a collapsed single-line table ───────────────────\n", " if re.search(r'\\|\\s*-{2,}[\\s:]*\\|', stripped):\n", " # Flush any in-progress multi-line table first\n", " if len(cur) >= 2:\n", " tables.append('\\n'.join(cur))\n", " cur = []\n", " expanded = _expand_inline_table(stripped)\n", " if expanded:\n", " tables.append(expanded)\n", " continue # Don't add this raw line to cur\n", "\n", " # ── Normal multi-line table accumulation ──────────────────────────\n", " if stripped.startswith('|') or stripped.count('|') >= 2:\n", " cur.append(stripped)\n", " else:\n", " if len(cur) >= 2:\n", " tables.append('\\n'.join(cur))\n", " cur = []\n", "\n", " if len(cur) >= 2:\n", " tables.append('\\n'.join(cur))\n", "\n", " return tables\n", "\n", "\n", "def md_table_to_html(md: str) -> str:\n", " lines = [l.strip() for l in md.strip().split('\\n') if l.strip()]\n", " lines = [l for l in lines if not _is_separator_row(l)]\n", " if not lines:\n", " return '