Hands-On — To'liq OCR Moduli
Nima quramiz
Oldingi to'qqiz bobning hammasini bitta production-ready modulga yig'amiz. RAG qo'llanmasi 12-bobidagi chatbot kabi — haqiqiy, ishlaydigan kod:
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
# 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
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
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
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
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
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
# 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
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:
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)
# 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
# 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.