## Summary - Accept `.jpg`, `.jpeg`, and `.png` files in addition to `.pdf` - Images are loaded directly via Pillow — no poppler required - Unsupported extensions fail fast with a clear error message - Output header uses "Image N" for images, "Page N" for PDFs - `--dpi` and `--poppler` args apply to PDFs only (no behaviour change) ## Test plan - [ ] Run on a JPEG scan and verify output is correct - [ ] Run on a PNG and verify output is correct - [ ] Run on a PDF and verify nothing regressed - [ ] Pass an unsupported extension and verify the error message Co-authored-by: Randa <obuvuyoviz26@gmail.com> Reviewed-on: http://forgejo.localhost:3000/forgejo_admin/arabic-ocr/pulls/2
219 lines
12 KiB
Python
219 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Arabic OCR — universal prompting via Ollama vision model
|
|
=========================================================
|
|
Uses a single mixed prompt that handles all document types:
|
|
handwritten text, certificates, IDs, tables, forms, and printed Arabic.
|
|
|
|
Usage:
|
|
python arabic_ocr_smart.py document.pdf [output.txt]
|
|
python arabic_ocr_smart.py scan.jpg [output.txt]
|
|
python arabic_ocr_smart.py scan.png [output.txt]
|
|
python arabic_ocr_smart.py scan.pdf --model qwen2.5vl:7b # default
|
|
python arabic_ocr_smart.py scan.pdf --model llava:13b # any Ollama model
|
|
python arabic_ocr_smart.py scan.pdf --host http://localhost:11434
|
|
python arabic_ocr_smart.py scan.pdf --dpi 300 # PDF only
|
|
|
|
Install:
|
|
pip install pdf2image pillow
|
|
sudo apt-get install -y poppler-utils
|
|
"""
|
|
|
|
import sys
|
|
import base64
|
|
import json
|
|
import io
|
|
import argparse
|
|
import urllib.request
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from pdf2image import convert_from_path
|
|
from PIL import Image
|
|
|
|
# ── Config ────────────────────────────────────────────────────────────────────
|
|
|
|
DEFAULT_HOST = "http://192.168.122.1:11434"
|
|
DEFAULT_MODEL = "qwen2.5vl:7b"
|
|
DPI = 300
|
|
TIMEOUT = 600 # seconds between streaming chunks (not total response time)
|
|
|
|
# ── Prompt ────────────────────────────────────────────────────────────────────
|
|
|
|
PROMPT = (
|
|
# ── Persona ────────────────────────────────────────────────────────────
|
|
"أنت عالِم متخصص في قراءة المخطوطات العربية وخبير بجميع أنماط الخط العربي: النسخ، الرقعة، الديواني، الإجازة، والكوفي. "
|
|
"عملت عقوداً في فك رموز الوثائق الرسمية والمخطوطات اليدوية الصعبة. "
|
|
"لا يوجد نص عربي يعجز عنك — حتى الخط الرديء أو المتلاشي تستطيع قراءته بالسياق والخبرة.\n\n"
|
|
|
|
# ── Two-pass schema recognition ────────────────────────────────────────
|
|
"قبل أن تبدأ النسخ، قم بهاتين الخطوتين الذهنيتين بصمت:\n"
|
|
"الخطوة الأولى — تعرّف على نوع الوثيقة: ما هي؟ ما السياق العام؟ "
|
|
"من المُرسِل والمُرسَل إليه إن وُجدا؟ ما الغرض من النص؟ "
|
|
"لا تقيّد نفسك بأنواع محددة — الوثيقة تُعرّف نفسها بنفسها.\n"
|
|
"الخطوة الثانية — توقّع المحتوى: بناءً على نوع الوثيقة الذي حددته، "
|
|
"استحضر من خبرتك الصيغَ والمصطلحاتِ والتراكيبَ اللغوية الشائعة في هذا النوع تحديداً. "
|
|
"استخدم هذه التوقعات كمرجع لحل الغموض في الخط — لا كقيد يمنعك من قراءة ما هو فعلاً مكتوب.\n"
|
|
"بعد هاتين الخطوتين، ابدأ النسخ الفعلي.\n\n"
|
|
|
|
# ── Task ───────────────────────────────────────────────────────────────
|
|
"مهمتك: انسخ كل النص المرئي في هذه الصورة حرفياً.\n\n"
|
|
|
|
# ── Strict rules ───────────────────────────────────────────────────────
|
|
"قواعد صارمة:\n"
|
|
"- اقرأ الصورة سطراً سطراً من الأعلى إلى الأسفل. كل سطر في الصورة = سطر جديد في الإخراج. لا تدمج أسطراً منفصلة في جملة واحدة.\n"
|
|
"- اتجاه القراءة من اليمين إلى اليسار للعربية، ومن اليسار إلى اليمين للأرقام والنص اللاتيني.\n"
|
|
"- الأرقام المضمّنة داخل النص العربي: اكتبها كما تظهر (عربية أو هندية أو لاتينية) دون تحويل.\n"
|
|
"- للكلمات غير الواضحة: استخدم السياق والكلمات المحيطة لاستنتاج القراءة الأرجح.\n"
|
|
"- الخطأ الأكبر هو كتابة كلمة خاطئة بدلاً من الاعتراف بالغموض. [؟] أفضل دائماً من كلمة مخترعة.\n"
|
|
"- ممنوع منعاً باتاً اختراع كلمات أو أسماء لم تظهر في الصورة. إذا كانت الكلمة غير واضحة تماماً، ضع [؟].\n"
|
|
"- الصيغ الرسمية الشائعة في أي نوع من الوثائق يجب التعرف عليها كوحدات متكاملة وليس كلمة كلمة.\n"
|
|
"- احتفظ بالتشكيل (الحركات) إن ظهر في الصورة.\n"
|
|
"- الأختام والطوابع الرسمية: إذا كان نصها مقروءاً، انسخه هكذا: [ختم: ...].\n"
|
|
"- لا تعكس ترتيب الكلمات أو الأسطر — حافظ على الاتجاه الأصلي للنص.\n"
|
|
"- لا تبدأ ردك بأي مقدمة أو جملة تمهيدية — ابدأ مباشرةً بأول كلمة في الصورة.\n"
|
|
"- لا تُضِف أي تعليق أو وصف أو تفسير — فقط النص المستخرج.\n\n"
|
|
|
|
# ── Output format ──────────────────────────────────────────────────────
|
|
"تنسيق الإخراج حسب نوع المحتوى:\n"
|
|
"- نص عادي (مطبوع أو يدوي): اكتبه سطراً سطراً كما هو.\n"
|
|
"- عناوين وترويسات: ضعها على سطر منفصل مع نجمتين ** حولها.\n"
|
|
"- جداول: أعد إنتاجها بتنسيق Markdown مع الحفاظ على جميع الأعمدة والصفوف.\n"
|
|
"- حقول نماذج: اكتبها بصيغة 'اسم الحقل: القيمة'، وإذا كان فارغاً اكتب [فارغ].\n"
|
|
"- مربعات اختيار: ✓ للمحدد، ☐ للفارغ.\n"
|
|
"- بيانات هوية أو وثيقة رسمية: استخرج كل حقل مرئي بصيغة 'اسم الحقل: القيمة'.\n"
|
|
"- منطقة MRZ (الأحرف الآلية في أسفل جواز السفر أو الهوية): انسخها كما هي في سطر منفصل.\n"
|
|
)
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def image_to_base64(pil_image: Image.Image) -> str:
|
|
buf = io.BytesIO()
|
|
pil_image.save(buf, format="PNG")
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def call_ollama(host: str, model: str, pil_image: Image.Image, timeout: int = TIMEOUT, num_ctx: int = 8192) -> str:
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": PROMPT,
|
|
"images": [image_to_base64(pil_image)],
|
|
}
|
|
],
|
|
"stream": True,
|
|
"options": {"num_ctx": num_ctx},
|
|
}
|
|
data = json.dumps(payload).encode()
|
|
req = urllib.request.Request(
|
|
f"{host}/api/chat",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
chunks = []
|
|
eval_count = None
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
for line in resp:
|
|
if not line.strip():
|
|
continue
|
|
obj = json.loads(line)
|
|
chunk = obj.get("message", {}).get("content", "")
|
|
if chunk:
|
|
if not chunks:
|
|
print("generating", end="", flush=True)
|
|
chunks.append(chunk)
|
|
print(".", end="", flush=True)
|
|
if obj.get("done"):
|
|
eval_count = obj.get("eval_count")
|
|
prompt_eval_count = obj.get("prompt_eval_count")
|
|
break
|
|
return "".join(chunks).strip(), eval_count, prompt_eval_count
|
|
except urllib.error.URLError as e:
|
|
raise ConnectionError(
|
|
f"Cannot reach Ollama at {host}. "
|
|
f"Is it running? (OLLAMA_HOST=0.0.0.0:11434 ollama serve)\n{e}"
|
|
) from e
|
|
|
|
|
|
def ocr_page(host: str, model: str, pil_image: Image.Image, page_num: int, timeout: int = TIMEOUT, num_ctx: int = 8192) -> str:
|
|
print(f" Page {page_num}: prefill...", end=" ", flush=True)
|
|
text, eval_count, prompt_eval_count = call_ollama(host, model, pil_image, timeout, num_ctx)
|
|
tokens_info = f" (prompt: {prompt_eval_count}, output: {eval_count})" if eval_count is not None else ""
|
|
print(f" done.{tokens_info}", flush=True)
|
|
return text
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Arabic OCR with universal mixed-content prompting.",
|
|
)
|
|
parser.add_argument("input", help="Input file: PDF, JPEG, or PNG")
|
|
parser.add_argument("output", nargs="?", help="Output .txt file (optional)")
|
|
parser.add_argument(
|
|
"--host", default=DEFAULT_HOST,
|
|
help=f"Ollama host URL (default: {DEFAULT_HOST})"
|
|
)
|
|
parser.add_argument(
|
|
"--model", default=DEFAULT_MODEL,
|
|
help=f"Ollama model name (default: {DEFAULT_MODEL})"
|
|
)
|
|
parser.add_argument(
|
|
"--dpi", type=int, default=DPI,
|
|
help=f"PDF render resolution (default: {DPI})"
|
|
)
|
|
parser.add_argument(
|
|
"--timeout", type=int, default=TIMEOUT,
|
|
help=f"Seconds to wait between streaming chunks (default: {TIMEOUT})"
|
|
)
|
|
parser.add_argument(
|
|
"--ctx", type=int, default=12288,
|
|
help="Model context window in tokens (default: 12288)"
|
|
)
|
|
parser.add_argument(
|
|
"--poppler", default=None,
|
|
help="Path to poppler bin/ directory (Windows only, e.g. C:\\poppler\\bin)"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
input_path = Path(args.input)
|
|
output_path = Path(args.output) if args.output \
|
|
else input_path.with_name(input_path.stem + "_ocr.txt")
|
|
|
|
if not input_path.is_file():
|
|
sys.exit(f"Error: file not found: {input_path}")
|
|
|
|
suffix = input_path.suffix.lower()
|
|
if suffix not in {".pdf", ".jpg", ".jpeg", ".png"}:
|
|
sys.exit(f"Error: unsupported file type '{suffix}'. Supported: .pdf, .jpg, .jpeg, .png")
|
|
|
|
print(f"\n[*] Input : {input_path}")
|
|
print(f"[*] Model : {args.model}")
|
|
print(f"[*] Ollama : {args.host}")
|
|
print(f"[*] Output : {output_path}\n")
|
|
|
|
if suffix == ".pdf":
|
|
print("[*] Converting PDF to images...")
|
|
pages = convert_from_path(str(input_path), dpi=args.dpi, poppler_path=args.poppler)
|
|
print(f" {len(pages)} page(s) found.\n")
|
|
else:
|
|
pages = [Image.open(input_path).convert("RGB")]
|
|
print(f"[*] Loaded image ({suffix})\n")
|
|
|
|
label = "Page" if suffix == ".pdf" else "Image"
|
|
sections = []
|
|
for i, page_img in enumerate(pages, start=1):
|
|
text = ocr_page(args.host, args.model, page_img, i, args.timeout, args.ctx)
|
|
header = f"{'='*60}\n{label} {i}\n{'='*60}"
|
|
sections.append(f"{header}\n\n{text}")
|
|
print()
|
|
|
|
output_path.write_text("\n\n".join(sections), encoding="utf-8")
|
|
print(f"[✓] Done. Output saved to: {output_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|