phoenix-arabic-manuscript-htr / CODEX_BRIEF_reading_candidates.md
factlogic's picture
Update publication links for Phoenix model name
af3342d verified
|
Raw
History Blame
14.5 kB

CODEX BRIEF — احتمالات القراءة المبنية على الصورة والسياق

مواصفة تنفيذية لنموذج Codex. الكاتب (المهندس) حدّد المنطق والعقود والمعايير؛ مهمتك كتابة الكود والتصميم وفقها. لا تغيّر السلوك الافتراضي للتعرّف الحالي. كل ميزة جديدة خلف علم تشغيل (flag) أو on-demand.

0. السياق (الوضع الحالي — لا تعِد بناءه)

  • التعرّف: backend/app/services/ocr/kraken_service.pyextract_text_with_coordinates() يُرجع قائمة أسطر: {text, left, top, width, height, conf}. يحتوي تكامل LM اختياري (self.lm_decoder) ويبني lm_line_imgs عبر extract_polygons(bw_img, seg_res) حين USE_LM.
  • فك الترميز: backend/app/services/ocr/lm_decoder.pyLMDecoder:
    • decode_matrix(mat, idx2char) يعمل beam search مع دمج LM (alpha,beta) ويُرجع أفضل سلسلة واحدة (بترتيب بصري visual).
    • decode_line(model, pil_line_image, fallback) يبني المصفوفة عبر model.forward ثم decode_matrix ثم يحوّل للمنطقي عبر _to_logical (python-bidi، base_dir='R').
    • beams بنيته {prefix: [pb, pnb]} ودرجة السلسلة = logaddexp(pb, pnb).
  • النموذج اللغوي الحرفي: char_ngram_lm.pyCharNgramLM.load, .order, .logprob(ch, ctx).
  • القاموس الحالي: backend/app/services/dictionary_service.pyMANUSCRIPT_VOCAB (مجموعة يدوية ~250 كلمة) + get_suggestions بـ Levenshtein ≤2. هذا هو سبب ضعف المرشّحات.
  • endpoints التحليل: backend/app/api/endpoints/analysis_api.py (موجود، فيه /analyze/word-candidates).
  • الواجهة: frontend/js/api.js (نمط _postJSON), frontend/js/app.js (currentData = مصفوفة الأسطر، نافذة #line-popup, نافذة #analysis-modal).
  • مفتاح LLM: settings.OPENROUTER_API_KEYapp.state.llm_service (LLMService._make_request, ._extract_json).
  • بيئة التشغيل والاختبار للجزء B: Docker/Linux فقط (يلزم kraken+torch+النموذج). الجزء A والـ JS يُختبران على ويندوز.

الخطة A — قاموس حقيقي من بياناتنا (رخيصة، مستقلة، بلا تبعيات جديدة)

الهدف: استبدال الـ250 كلمة اليدوية بمفردات واقعية + ترددات مبنية من نصوص GT (RASAM/TariMa، نفس المصدر الذي بُني منه char8_trainval.lm).

A.1 سكربت بناء (offline، يُشغّل مرة)

  • ملف جديد: backend/scripts/build_lexicon.py
  • CLI: python build_lexicon.py --corpus <path...> --out app/services/data/lexicon.json --min-count 2
  • المنطق:
    1. اقرأ كل نصوص GT (ابحث عن .txt/المدوّنة المستخدمة في language_model/؛ إن لم تجد، اقبل مساراً يدوياً عبر --corpus).
    2. قسّم إلى كلمات: احتفظ بالرموز العربية [؀-ۿ]+، طول ≥2.
    3. خزّن نسختين لكل كلمة: الأصلية + المطبّعة (إزالة تشكيل/توحيد همزات — أعد استخدام منطق _norm من analysis_api.py).
    4. عُدّ الترددات؛ احذف ما تردده < min_count.
    5. اكتب lexicon.json: {"words": {"<word>": freq, ...}, "total": N} مرتّبة تنازلياً.
    6. اطبع تقريراً: حجم المفردات، أعلى 20 كلمة.

A.2 تعديل dictionary_service.py

  • حمّل lexicon.json كسولاً (مساره من settings أو افتراضي بجوار الخدمة)؛ إن غاب الملف → استمر بالـMANUSCRIPT_VOCAB فقط (توافق خلفي، لا أعطال).
  • المفردات الفعّالة = MANUSCRIPT_VOCAB ∪ مفاتيح القاموس.
  • عدّل get_suggestions(word, limit=3, context=None):
    • المرشّحون = كلمات بمسافة Levenshtein ≤2 (احسبها مقابل النسخة المطبّعة لتقليل التكلفة).
    • الترتيب = (edit_distance تصاعدي, frequency تنازلي).
    • أبقِ التوقيع متوافقاً (المعاملات الإضافية اختيارية بقيم افتراضية).
  • أضِف word_frequency(word) -> int و حدّث is_valid_word ليشمل القاموس الجديد.

A.3 معايير القبول (A)

  • وحدة اختبار: كلمة محرّفة قريبة من كلمة عالية التردد تُرجع التصحيح الصحيح أولاً.
  • لا أعطال عند غياب lexicon.json.
  • حجم المفردات الجديد ≥ بضعة آلاف (تقرير السكربت).

الخطة B — مرشّحات قراءة مبنية على الصورة + ترجيح بالسياق (الجوهر) + تحسينات

B.0 المبدأ المعماري (التزِم به)

beam يقترح مرشّحات من الحبر (الصورة) ← LLM/char-LM يرجّحها بالسياق ← القاموس (الخطة A) احتياط. ⚠️ LLM لا يخترع قراءات غير مدعومة بالصورة؛ دوره الترجيح فقط على مرشّحات beam.

B.1 توسعة lm_decoder.py — top-K

أعد هيكلة بناء المصفوفة في دالة مشتركة، ثم أضِف:

def _forward_matrix(self, model, pil_line_image) -> (mat: np.ndarray[C,W], idx2char: dict)
    # نفس منطق decode_line الحالي حتى الحصول على mat + idx2char (أعد الاستخدام، لا تكرّر)

def decode_matrix_topk(self, mat, idx2char, k=5) -> list[(str_visual, logprob)]
    # نفس حلقة beam في decode_matrix، لكن في النهاية:
    #   - ادمج الأشعة حسب السلسلة النهائية (قد تُنتج بادئات مختلفة نفس النص بعد الطيّ)
    #   - score = logaddexp(pb, pnb) لكل سلسلة فريدة
    #   - أعد أعلى k سلاسل **متمايزة** تنازلياً

def decode_line_topk(self, model, pil_line_image, k=5) -> list[{"text": str_logical, "score": float}]
    # mat, idx2char = self._forward_matrix(...)
    # cands = self.decode_matrix_topk(mat, idx2char, k)
    # حوّل كل نص للمنطقي عبر _to_logical
    # softmax على logprobs → احتمال [0..1]
    # ادمج المكرّرات بعد التحويل المنطقي (اجمع احتمالاتها)
  • يجب أن يطابق أول عنصر من decode_line_topk ناتجَ decode_line الحالي (اختبار اتساق).

B.2 تعديل kraken_service.py — إتاحة صور الأسطر بدقّة + ميزة المرشّحات

  • التحسين #1 (الأهم — الجودة): المرشّحات يجب أن تُحسب من نفس صورة السطر التي رآها المعرّف (مقصوصة من bw_img بقناع المضلّع via extract_polygonsلا من قصّ خام لـ bbox من الصورة الأصلية (مجال مختلف → جودة أسوأ).
    • أثناء extract_text_with_coordinates: خزّن صور الأسطر المستخرجة في كاش LRU على المحرّك: self._line_img_cache[img_hash] = [PIL_line_0, PIL_line_1, ...] (سعة محدودة مثل 8 صور، احذف الأقدم). img_hash متاح عبر database.get_image_hash.
    • افصل بناء صور الأسطر عن USE_LM (احسبها إذا كانت ميزة المرشّحات مفعّلة settings.CANDIDATES_ENABLED أو دائماً مع كاش خفيف).
  • أضِف decoder للمرشّحات حتى لو USE_LM=false: أنشئ LMDecoder كسولاً عند أول طلب مرشّحات (self._cand_decoder)، يحمّل settings.LM_PATH إن وُجد؛ وإلا يعمل acoustic فقط (alpha=0).
  • أضِف دالة:
def line_candidates(self, img_hash, line_index, k=5) -> list[{"text","score"}]
    # احضر PIL من self._line_img_cache[img_hash][line_index]
    # decoder = self._cand_decoder
    # return decoder.decode_line_topk(self.rec_model, pil, k)
  • التحسين #3 (تحكّم بالكلفة): اختياري settings.AUTO_CANDIDATES (افتراضي OFF): عند التشغيل، للأسطر conf < τ فقط (τ=70) احسب top-K مسبقاً وألصقها في السطر المُرجَع كـ record["candidates"] (حدّ أقصى لعدد الأسطر التلقائية/صفحة مثل 15). هذا يربط ميزة (4) التمييز بميزة (3).

B.3 endpoint جديد في analysis_api.py

POST /api/analyze/line-candidates
Request (وضعان):
  أ) المُخزَّن: {"img_hash": "...", "line_index": 3, "k": 5, "context": "<اختياري: نص الصفحة>"}
  ب) المباشر: {"line_image_b64": "<png base64>", "k": 5, "context": "..."}
Response 200:
  {
    "line_index": 3,
    "candidates": [{"text":"...","score":0.61,"source":"beam"}, ...],   // مرتّبة بدرجة الصورة
    "best": "<الأرجح بعد الترجيح>",
    "reranked_by": "llm" | "char_lm" | "none",
    "reason": "<سبب موجز إن llm>"
  }

المنطق:

  1. احصل على صورة السطر: من كاش المحرّك (وضع أ) أو فك base64 (وضع ب).
  2. cands = ocr_engine.line_candidates(...) أو _cand_decoder.decode_line_topk للصورة المباشرة.
  3. التحسين #2 (شفافية): أبقِ درجات الصورة كما هي. إن توفّر context وllm_service: أعد ترتيب المرشّحات بالسياق (وسّع برومبت word-candidates ليقبل قائمة قراءات سطر كامل ويُرجع ranked/best/reason). reranked_by="llm".
  4. التحسين #7 (احتياط بلا إنترنت): إن غاب LLM لكن وُجد char8 LM: رجّح المرشّحات بدرجة char-LM داخل السياق. reranked_by="char_lm".
  5. التحسين #10 (كاش): خزّن النتيجة بمفتاح (img_hash, line_index, k).
  6. التحسين #9 (تدرّج آمن): beam(صورة) → إن غاب decoder: مرشّحات القاموس+التردد (الخطة A) على كلمات السطر → إن فشل: أعد النص الأصلي. لا ترمِ 500.

B.4 الواجهة

  • ocr.py: ضمّن img_hash في حمولة حدث ocr_data (SSE) ونتائج المهام، ليحفظه العميل في الحالة.
  • api.js: أضِف getLineCandidates({imgHash, lineIndex, context, k}) بنمط _postJSON.
  • app.js: خزّن currentImgHash. في #line-popup (يُفتح بنقر سطر) أضِف قسم «بدائل القراءة» + زر «❓ بدائل» يظهر فقط للأسطر المشكوكة (conf منخفض أو مميّزة من ميزة 4) أو عند اختيار المستخدم يدوياً. عند النقر: استدعِ getLineCandidates، اعرض المرشّحات مرتّبة بنسبها المئوية؛ نقر مرشّح → استبدل currentData[i].text + أعد العرض + حدّث #full-preview.
  • التحسين #8: أبرِز فرق الأحرف بين المرشّح والنص الأصلي (تمييز بصري للحروف المختلفة).
  • CSS بنمط .analysis-*/.cand-list الموجود.

B.5 تحسينات B (ملخّص لتطبّقها)

  1. صورة السطر الدقيقة (مقنّعة بالمضلّع من bw) عبر كاش، لا قصّ bbox خام. [جودة]
  2. فصل درجات الصورة عن ترجيح السياق وإظهارهما (شفافية: «الصورة ترجّح س، السياق يرجّح ص»).
  3. الحساب للأسطر المشكوكة فقط + on-demand؛ K≤5؛ حدّ أقصى أسطر تلقائية/صفحة. [كلفة]
  4. دمج المرشّحات المتطابقة بعد التحويل المنطقي/التطبيع (لا تكرار).
  5. softmax على درجات beam → نسبة مئوية لكل مرشّح.
  6. ضمان تمايز سلاسل beam (ادمج بادئات تُعطي نفس النص).
  7. احتياط char-LM للترجيح حين لا LLM.
  8. إبراز فرق الأحرف بين المرشّحات.
  9. سلسلة تدرّج آمنة beam → قاموس(A) → الأصل.
  10. كاش المرشّحات بمفتاح (img_hash, line_index).

B.6 معايير القبول + خطة الاختبار (تُشغَّل في Docker)

  • اتساق: أول عنصر من decode_line_topk == ناتج decode_line لنفس السطر.
  • تمايز: decode_matrix_topk يُرجع ≤k سلاسل فريدة مرتّبة تنازلياً؛ مجموع احتمالات decode_line_topk ≈ 1.
  • عدم انحدار: التعرّف الافتراضي بلا تغيير حين الميزة مطفأة؛ py_compile لكل الملفات يمرّ.
  • الـ endpoint: يُرجع 200 ومرشّحات لسطر مشكوك معروف؛ ويتدرّج بأمان (200 + احتياط) عند غياب decoder؛ بلا 500.
  • pytest: (1) decode_matrix_topk على مصفوفة اصطناعية صغيرة، (2) تكامل الـ endpoint على صورة سطر عيّنة، (3) اختبار الاتساق أعلاه.
  • اختبار يدوي UI: رفع مخطوطة → فتح سطر مشكوك → «❓ بدائل» → ظهور قائمة مرتّبة → اختيار يستبدل النص.

B.7 خارج النطاق (لا تفعله الآن)

  • مرشّحات على مستوى الكلمة (يحتاج قصّ كلمات لا يوفّره kraken) — لاحقاً.
  • البحث الدلالي بالـ embeddings — مؤجّل.

ترتيب التسليم المطلوب من Codex

  1. الخطة A كاملة (سكربت + تعديل القاموس + اختبارها).
  2. B.1 + B.6 اختبارات decoder (وحدات).
  3. B.2 + B.3 (خادم) + اختبار تكامل.
  4. B.4 (واجهة) + اختبار يدوي. بعد كل مرحلة: py_compile/node --check + تقرير موجز بما تغيّر والملفات المعدّلة. النتائج تُسلَّم للمراجعة قبل اعتماد المرحلة التالية.