Asosiy qismga o'tish

Hands-On — To'liq OCR Moduli

10-bob 12 kod bloki 2413 so'z

Nima quramiz

Oldingi to'qqiz bobning hammasini bitta production-ready modulga yig'amiz. RAG qo'llanmasi 12-bobidagi chatbot kabi — haqiqiy, ishlaydigan kod:

python8 qator
app/ingestion/ocr.py           <- asosiy modul (shu bobda yozamiz)
app/ingestion/documents.py     <- ingestion pipeline ga ulanish

Oqim (har sahifa uchun alohida):
sahifa -> text layer bormi? -> [ha:   pdfplumber]
                            -> [yo'q: preprocess -> Tesseract -> conf tekshiruv
                                                 -> past bo'lsa: Qwen2.5-VL (asl rasm)]
       -> clean_ocr_text -> quality gate -> metadata bilan saqlash

Dizayn qarorlari (hammasi oldingi boblardan):

Qaror Sabab Bob
Har sahifada text layer tekshiruvi Digital PDF ga OCR — isrof; aralash PDF da faqat skan sahifalar 7
Tesseract asosiy dvigatel Tez, bepul, uzb+rus, confidence 4
conf < 60 → VLM fallback Gibrid: reranking printsipi 6, 7
Har sahifa alohida natija Sahifa metadata + partial fail 8
Tozalash majburiy Chunking buzilmasin 8
Quality gate + review flag Buzuq matn bazaga kirmasin 8

Modul: sozlamalar va natija tipi

python26 qator
# app/ingestion/ocr.py
import re
import io
import base64
from dataclasses import dataclass

import cv2
import numpy as np
import pdfplumber
import pytesseract
from pdf2image import convert_from_path
from openai import OpenAI

OCR_LANGS = "rus+uzb"
OCR_DPI = 300
CONF_THRESHOLD = 60           # bundan past sahifa -> VLM fallback
MIN_CHARS_PER_PAGE = 100      # text layer bor deb hisoblash chegarasi

vlm_client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

@dataclass
class PageResult:
    page_num: int
    text: str
    method: str          # "text_layer" | "ocr_tesseract" | "ocr_vlm"
    confidence: float    # 0-100; VLM uchun -1

PageResult — modulning "shartnomasi": har sahifa qanday o'qilgani va qanchalik ishonchli ekani natija bilan birga yuradi. Bu 8-bobdagi metadata enrichment ning manbasi.

Text layer tekshiruvi

python4 qator
def has_text_layer(page) -> bool:
    """Bitta pdfplumber sahifasida matn qatlami bormi?"""
    text = page.extract_text() or ""
    return len(text.strip()) >= MIN_CHARS_PER_PAGE

7-bobdagi is_scanned butun faylni birinchi 3 sahifa bo'yicha baholardi. Production da tekshiruv sahifama-sahifa: aralash PDF (digital matn + skanerlangan ilova) da faqat skanerlangan sahifalar OCR ga tushadi, qolganlari bepul va aniq olinadi.

Tesseract + confidence

python33 qator
def preprocess_page(page_img):
    """PIL rasm -> OCR uchun tayyor binar rasm (3-bob: kulrang, denoise, Otsu, deskew)."""
    gray = cv2.cvtColor(np.array(page_img.convert("RGB")), cv2.COLOR_RGB2GRAY)
    denoised = cv2.fastNlMeansDenoising(gray, h=10)
    _, binary = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    return deskew(binary)                        # 3-bobdagi funksiya, modulga ko'chiring


def ocr_with_confidence(image) -> tuple[str, float]:
    """Tesseract OCR + o'rtacha confidence. Qator va paragraf tuzilishi saqlanadi."""
    data = pytesseract.image_to_data(
        image, lang=OCR_LANGS, output_type=pytesseract.Output.DICT
    )
    lines, confs = {}, []
    for i, word in enumerate(data["text"]):
        conf = int(float(data["conf"][i]))       # "95.2" kabi satr ham keladi
        if conf <= 0 or not word.strip():        # -1 = bo'sh blok
            continue
        key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
        lines.setdefault(key, []).append(word)
        confs.append(conf)
    if not confs:
        return "", 0.0

    # So'zlar -> qatorlar (\n), paragraflar orasida bo'sh qator (\n\n):
    # clean_ocr_text (8-bob) aynan shu tuzilishni kutadi
    out, prev_par = [], None
    for key, words in lines.items():
        if prev_par is not None and key[:2] != prev_par:
            out.append("")
        out.append(" ".join(words))
        prev_par = key[:2]
    return "\n".join(out), sum(confs) / len(confs)

E'tibor bering: image_to_string emas, image_to_data — bitta chaqiruvda ham matn, ham confidence olamiz. So'zlarni shunchaki bo'shliq bilan ulasak, qator va paragraf chegaralari yo'qoladi va 8-bobdagi tozalash ishlamay qoladi — shuning uchun block_num / par_num / line_num bo'yicha guruhlaymiz. Tesseract ga tozalangan rasm boradi (preprocess_page), 3-bobdagi qoida.

VLM fallback

python22 qator
def vlm_ocr(image) -> str:
    """Qiyin sahifalar uchun Qwen2.5-VL."""
    buf = io.BytesIO()
    image.save(buf, format="PNG")
    image_b64 = base64.b64encode(buf.getvalue()).decode()

    response = vlm_client.chat.completions.create(
        model="qwen2.5vl:7b",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text",
                 "text": "Rasmdagi barcha matnni o'qib ber. Tuzilishini saqla, "
                         "jadvallarni Markdown qilib qaytar. O'zingdan qo'shma. "
                         "Aniq o'qiy olmagan joyni [NOANIQ] deb belgila."},
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
            ],
        }],
        temperature=0,
    )
    return response.choices[0].message.content

6-bobdagi hamma himoya chorasi joyida: temperature=0, "o'zingdan qo'shma", [NOANIQ] belgisi.

Tozalash

python7 qator
def clean_ocr_text(text: str) -> str:
    text = re.sub(r"([\w'])-\n(\w)", r"\1\2", text)           # bo'g'in ko'chirish
    text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)           # yolg'iz \n -> bo'shliq
    text = re.sub(r"[ \t]{2,}", " ", text)                 # ortiqcha bo'shliq
    text = re.sub(r"^\s*-?\s*\d+\s*-?\s*$", "",            # sahifa raqamlari
                  text, flags=re.MULTILINE)
    return text.strip()

Asosiy funksiya: hammasi birga

python27 qator
def extract_pdf(pdf_path: str) -> list[PageResult]:
    """PDF -> sahifalar bo'yicha PageResult ro'yxati.

    Gibrid strategiya, har sahifa uchun alohida:
      1. Text layer bor  -> pdfplumber (bepul, 100% aniq)
      2. Skanerlangan    -> preprocessing + Tesseract (tez)
      3. Confidence past -> Qwen2.5-VL (sekin, kuchli) — asl rasm bilan
    """
    results = []
    with pdfplumber.open(pdf_path) as pdf:
        for i, page in enumerate(pdf.pages, 1):
            if has_text_layer(page):
                results.append(PageResult(i, page.extract_text(), "text_layer", 100.0))
                continue

            # Faqat shu sahifani rasmga aylantiramiz (RAM: 200 sahifani birdan emas)
            page_img = convert_from_path(pdf_path, dpi=OCR_DPI,
                                         first_page=i, last_page=i)[0]
            text, conf = ocr_with_confidence(preprocess_page(page_img))

            if conf < CONF_THRESHOLD:
                text = vlm_ocr(page_img)             # VLM ga asl (rangli) rasm boradi
                results.append(PageResult(i, clean_ocr_text(text), "ocr_vlm", -1.0))
            else:
                results.append(PageResult(i, clean_ocr_text(text), "ocr_tesseract", conf))

    return results

Ingestion pipeline ga ulash

python37 qator
# app/ingestion/documents.py
from app.ingestion.ocr import extract_pdf, CONF_THRESHOLD
from app import crud

def process_uploaded_document(session, doc):
    """Yuklangan hujjat -> matn + metadata -> baza."""
    pages = extract_pdf(doc.file_path)

    ocr_pages = [p for p in pages if p.method != "text_layer"]
    # Quality gate (8-bob): past confidence YOKI VLM (confidence yo'q) -> admin ko'rigi
    needs_review = [p for p in ocr_pages
                    if p.method == "ocr_vlm" or p.confidence < CONF_THRESHOLD]

    for page in pages:
        if not page.text.strip():
            continue
        crud.save_extracted_content(
            session,
            document_id=doc.id,
            page_num=page.page_num,
            content=page.text,
            meta={
                "extraction_method": page.method,
                "ocr_confidence": page.confidence,
                "source_file": doc.filename,
            },
        )

    if needs_review:
        crud.flag_for_review(session, doc.id,
                             pages=[p.page_num for p in needs_review])

    return {
        "total_pages": len(pages),
        "ocr_pages": len(ocr_pages),
        "flagged": len(needs_review),
    }

Shu joydan boshlab hujjat RAG qo'llanmasidagi tanish yo'ldan yuradi: chunking → embedding → pgvector → retrieval.

Sinab ko'rish

python12 qator
results = extract_pdf("test_hujjat.pdf")

for r in results:
    print(f"Sahifa {r.page_num}: {r.method}, conf={r.confidence:.0f}")
    print(f"  {r.text[:80]}...")

# Sahifa 1: ocr_tesseract, conf=87
#   ShARTNOMA № 45 Toshkent sh. 2024-yil 15-yanvar «Nihol» MChJ nomidan...
# Sahifa 2: ocr_tesseract, conf=91
#   ...
# Sahifa 3: ocr_vlm, conf=-1        <- jadvalli sahifa, Tesseract qiynaldi
#   | Mahsulot | Miqdor | Narx | ...

Yakuniy qaror daraxti

Butun qo'llanmaning xulosasi bitta daraxtda:

python17 qator
Hujjat toza, mashinkada terilgan, rus/o'zbek?
  -> Tesseract (4-bob)

Telefon surati, murakkab fon?
  -> Preprocessing kuchaytirilsin (3-bob) + EasyOCR/Paddle (5-bob)

Jadval va murakkab layout?
  -> Qwen2.5-VL (6-bob) yoki PaddleOCR PP-Structure

Qo'lyozma yoki juda past sifat?
  -> VLM; bo'lmasa qayta skanerlash

Maxfiy hujjatlar (davlat, tibbiyot, moliya)?
  -> FAQAT offline: Tesseract yoki lokal VLM. Cloud API MUMKIN EMAS.

Katta hajm + cheklangan resurs?
  -> Gibrid: Tesseract hammaga, VLM conf < 60 ga (7-bob)

Katta taqqoslash jadvali

Mezon Tesseract EasyOCR PaddleOCR Qwen2.5-VL Cloud API
Narx Bepul Bepul Bepul GPU kerak ~$1.5/1000 sahifa
Offline ✅ ✅ ✅ ✅ ❌
O'zbek lotin ✅ ❌ ❌ ✅ ✅
O'zbek kirill ✅ ❌ ❌ ✅ ✅
Rus ✅ ✅ ✅ ✅ ✅
Jadval ❌ ❌ ✅ ✅✅ ✅
Qo'lyozma ❌ ⚠️ ⚠️ ✅ ✅
Confidence ✅ ✅ ✅ ❌ ✅
Tezlik (CPU) ~1 s ~10 s ~5 s GPU shart ~2 s (tarmoq)

Yakuniy xulosa

Tushuncha Asosiy fikr
Arxitektura Text layer → Tesseract → VLM fallback: har bosqich keyingisidan arzon
PageResult Har sahifa method + confidence bilan yuradi — metadata va quality gate asosi
Himoya VLM da temperature=0 + [NOANIQ] + review flag
Integratsiya OCR = extraction bosqichi; keyin RAG ning tanish yo'li
O'lchash Ground truth + CER/WER (9-bob) — har o'zgarish raqam bilan tasdiqlanadi

OCR bilan RAG tizimingiz to'liq bo'ldi: endi u ham digital, ham skanerlangan, ham suratga olingan hujjatlarni o'qiydi. Bilim bazasida "ko'r nuqta" qolmadi.

</> Loyihaning haqiqiy kodi — bu bob amalda qanday ishlaydi

app/ingestion/ocr.py — gibrid OCR moduli (to'liq fayl)

python150 qator
# app/ingestion/ocr.py — skanerlangan hujjatlar uchun OCR moduli
import re
import io
import base64
from dataclasses import dataclass

import cv2
import numpy as np
import pdfplumber
import pytesseract
from pdf2image import convert_from_path
from openai import OpenAI

OCR_LANGS = "rus+uzb"
OCR_DPI = 300
CONF_THRESHOLD = 60           # bundan past sahifa -> VLM fallback
MIN_CHARS_PER_PAGE = 100      # text layer bor deb hisoblash chegarasi

vlm_client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

@dataclass
class PageResult:
    page_num: int
    text: str
    method: str          # "text_layer" | "ocr_tesseract" | "ocr_vlm"
    confidence: float    # 0-100; VLM uchun -1


def deskew(binary):
    # Matn (qora) piksellarining koordinatalari — (x, y) tartibida
    coords = cv2.findNonZero(255 - binary)

    # Ularni o'rab turgan eng kichik burchakli to'rtburchak
    angle = cv2.minAreaRect(coords)[-1]

    # OpenCV 4.5+ burchakni [0, 90) da beradi, eski versiyalar [-90, 0) da.
    # Ikkalasini ham (-45, 45] oralig'iga keltiramiz — qiyshiqlik shu yerda
    if angle > 45:
        angle -= 90
    elif angle < -45:
        angle += 90

    # Shu burchakka buramiz (musbat = soat miliga teskari)
    (h, w) = binary.shape
    M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
    return cv2.warpAffine(binary, M, (w, h),
                          flags=cv2.INTER_CUBIC,
                          borderMode=cv2.BORDER_REPLICATE)


def has_text_layer(page) -> bool:
    """Bitta pdfplumber sahifasida matn qatlami bormi?"""
    text = page.extract_text() or ""
    return len(text.strip()) >= MIN_CHARS_PER_PAGE


def preprocess_page(page_img):
    """PIL rasm -> OCR uchun tayyor binar rasm (3-bob: kulrang, denoise, Otsu, deskew)."""
    gray = cv2.cvtColor(np.array(page_img.convert("RGB")), cv2.COLOR_RGB2GRAY)
    denoised = cv2.fastNlMeansDenoising(gray, h=10)
    _, binary = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    return deskew(binary)                        # 3-bobdagi funksiya, modulga ko'chiring


def ocr_with_confidence(image) -> tuple[str, float]:
    """Tesseract OCR + o'rtacha confidence. Qator va paragraf tuzilishi saqlanadi."""
    data = pytesseract.image_to_data(
        image, lang=OCR_LANGS, output_type=pytesseract.Output.DICT
    )
    lines, confs = {}, []
    for i, word in enumerate(data["text"]):
        conf = int(float(data["conf"][i]))       # "95.2" kabi satr ham keladi
        if conf <= 0 or not word.strip():        # -1 = bo'sh blok
            continue
        key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
        lines.setdefault(key, []).append(word)
        confs.append(conf)
    if not confs:
        return "", 0.0

    # So'zlar -> qatorlar (\n), paragraflar orasida bo'sh qator (\n\n):
    # clean_ocr_text (8-bob) aynan shu tuzilishni kutadi
    out, prev_par = [], None
    for key, words in lines.items():
        if prev_par is not None and key[:2] != prev_par:
            out.append("")
        out.append(" ".join(words))
        prev_par = key[:2]
    return "\n".join(out), sum(confs) / len(confs)


def vlm_ocr(image) -> str:
    """Qiyin sahifalar uchun Qwen2.5-VL fallback."""
    buf = io.BytesIO()
    image.save(buf, format="PNG")
    image_b64 = base64.b64encode(buf.getvalue()).decode()

    response = vlm_client.chat.completions.create(
        model="qwen2.5vl:7b",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text",
                 "text": "Rasmdagi barcha matnni o'qib ber. Tuzilishini saqla, "
                         "jadvallarni Markdown qilib qaytar. O'zingdan qo'shma. "
                         "Aniq o'qiy olmagan joyni [NOANIQ] deb belgila."},
                {"type": "image_url",
                 "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
            ],
        }],
        temperature=0,
    )
    return response.choices[0].message.content


def clean_ocr_text(text: str) -> str:
    text = re.sub(r"([\w'])-\n(\w)", r"\1\2", text)          # bo'g'in ko'chirish
    text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)           # sun'iy qator uzilishi
    text = re.sub(r"[ \t]{2,}", " ", text)
    text = re.sub(r"^\s*-?\s*\d+\s*-?\s*$", "", text, flags=re.MULTILINE)
    return text.strip()


def extract_pdf(pdf_path: str) -> list[PageResult]:
    """PDF -> sahifalar bo'yicha PageResult ro'yxati.

    Gibrid strategiya, har sahifa uchun alohida:
      1. Text layer bor  -> pdfplumber (bepul, 100% aniq)
      2. Skanerlangan    -> preprocessing + Tesseract (tez)
      3. Confidence past -> Qwen2.5-VL (sekin, kuchli) — asl rasm bilan
    """
    results = []
    with pdfplumber.open(pdf_path) as pdf:
        for i, page in enumerate(pdf.pages, 1):
            if has_text_layer(page):
                results.append(PageResult(i, page.extract_text(), "text_layer", 100.0))
                continue

            # Faqat shu sahifani rasmga aylantiramiz (RAM: 200 sahifani birdan emas)
            page_img = convert_from_path(pdf_path, dpi=OCR_DPI,
                                         first_page=i, last_page=i)[0]
            text, conf = ocr_with_confidence(preprocess_page(page_img))

            if conf < CONF_THRESHOLD:
                text = vlm_ocr(page_img)             # VLM ga asl (rangli) rasm boradi
                results.append(PageResult(i, clean_ocr_text(text), "ocr_vlm", -1.0))
            else:
                results.append(PageResult(i, clean_ocr_text(text), "ocr_tesseract", conf))

    return results

app/ingestion/documents.py — ingestion pipeline ga ulanish

python37 qator
# app/ingestion/documents.py — extractor ga OCR ni ulash (5-bob pipeline)
from app.ingestion.ocr import extract_pdf, CONF_THRESHOLD
from app import crud

def process_uploaded_document(session, doc):
    """Yuklangan hujjat -> matn + metadata -> baza."""
    pages = extract_pdf(doc.file_path)

    ocr_pages = [p for p in pages if p.method != "text_layer"]
    # Quality gate (8-bob): past confidence YOKI VLM (confidence yo'q) -> admin ko'rigi
    needs_review = [p for p in ocr_pages
                    if p.method == "ocr_vlm" or p.confidence < CONF_THRESHOLD]

    for page in pages:
        if not page.text.strip():
            continue
        crud.save_extracted_content(
            session,
            document_id=doc.id,
            page_num=page.page_num,
            content=page.text,
            meta={
                "extraction_method": page.method,
                "ocr_confidence": page.confidence,
                "source_file": doc.filename,
            },
        )

    if needs_review:
        crud.flag_for_review(session, doc.id,
                             pages=[p.page_num for p in needs_review])

    return {
        "total_pages": len(pages),
        "ocr_pages": len(ocr_pages),
        "flagged": len(needs_review),
    }

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar