Asosiy qismga o'tish

Loyihalar — 7 ta amaliy ish, kod bilan

12-bob 21 kod bloki 13634 so'z

Loyihalar haqida

Har loyiha 11-bobdagi ocrlib.py va samples/ ustida quriladi va bu yerda to'liq ishlaydigan boshlang'ich kod bilan beriladi: talaba nol dan boshlamaydi, ishlayotgan narsani kengaytiradi. Har kod bloki ostida shu mashinada olingan haqiqiy chiqish. Shaxsiy hujjatlar (pasport, ID, tibbiy karta) hech bir loyihada ishlatilmaydi.

#LoyihaBoblarKodQiyinlikVaqt
1Skan → matn CLI + sifat o'lchagich3, 4, 9ocr_cli.py, evaluate.py⭐⭐1 hafta
2Chek → JSON: regex vs VLM4, 6, 9receipt.py⭐⭐⭐1–2 hafta
3Telegram bot: surat → matn3, 4, 8ocr_service.py, bot.py⭐⭐⭐1–2 hafta
4Jadval → Excel5, 6table2xlsx.py⭐⭐⭐1–2 hafta
5O'zbek lotin/kirill benchmark4, 5, 6, 9benchmark.py, translit.py⭐⭐⭐⭐2 hafta
6Eski gazeta arxivi3, 4, 8, 9archive.py⭐⭐⭐⭐2 hafta
7Capstone: arxiv → qidiruv7–10 + RAGdb.py, ingest.py, search.py, app.py⭐⭐⭐⭐⭐3 hafta

Tavsiya: 1-loyiha hamma uchun, 2–6 dan bittasi tanlab, 7 jamoada. Baholash har loyihada bir xil tamoyilda: ishlaydigan kod, raqamli natija (CER/WER yoki maydon aniqligi), natijani o'qigan xulosa, kod tozaligi. "Ishladi" degan gap ball emas, jadval ball.

Ground truth bepul usuli, hamma loyihada: digital PDF ning matnini pdfplumber bilan oling (bu haqiqat), xuddi shu sahifani chop etib skanerlang yoki suratga oling. Rasm + to'g'ri matn juftligi tayyor, qo'lda terish yo'q. 11-bob 0-lab buni sun'iy qiladi; real loyihada printer bilan qilinadi.

Papka tuzilishi

python9 qator
ocr_book/projects/
  p1_cli/        ocr_cli.py  evaluate.py
  p2_receipt/    receipt.py
  p3_bot/        ocr_service.py  bot.py
  p4_table/      table2xlsx.py
  p5_benchmark/  benchmark.py  translit.py
  p6_archive/    archive.py
  p7_capstone/   db.py  ingest.py  search.py  app.py
Har fayl boshida:  sys.path.insert(0, ...parents[2])  ->  ocrlib.py ni topadi

1-loyiha: Skan → matn CLI + sifat o'lchagich

Maqsad. Buyruq qatori vositasi: rasm yoki PDF beriladi, matn va confidence qaytadi; yoniga ground truth ustida preprocessing variantlarini solishtiradigan evaluate.py. Bu keyingi hamma loyihaning tayanchi.

Dizayn. PDF bo'lsa sahifama-sahifa text layer tekshiruvi (7-bob), rasm bo'lsa preprocessing (3-bob) va ocr_with_confidence (10-bob), keyin clean_ocr_text (8-bob) va darvoza (8-bob). Har sahifa uchun method, confidence, gate JSON ga yoziladi: bu 7-loyihadagi metadata ning o'zi.

ocr_cli.py

python105 qator
# ocr_cli.py — 1-loyiha: rasm yoki PDF -> matn + confidence. Buyruq qatori vositasi.
#
#   python ocr_cli.py ../../samples/05_telefon_soya.jpg --lang uzb --preprocess adaptive --boxes
#   python ocr_cli.py ../../samples/09_aralash.pdf --lang rus+uzb
#
# Chiqish: out/<fayl>.txt (matn), out/<fayl>.json (sahifa, method, confidence, gate),
#          --boxes bo'lsa out/<fayl>_boxes.png (so'z ramkalari, qizil = conf <= 60)
import argparse
import json
import sys
import time
from pathlib import Path

import cv2

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))          # ocrlib.py shu yerda
from ocrlib import clean_ocr_text, ocr_data, ocr_with_confidence, pdf_page_count, pdf_page_text, preprocess, render_pdf_page, to_gray  # noqa: E402

MIN_CHARS = 100          # text layer bor deb hisoblash chegarasi (7-bob)


def gate(method, conf):
    """8-bob quality gate."""
    if method == "text_layer":
        return "accept"
    if conf >= 80:
        return "accept"
    if conf >= 60:
        return "accept_with_flag"
    return "manual_review"


def prepare(img, args):
    if args.preprocess == "none":
        return to_gray(img)
    return preprocess(img, args.preprocess, do_deskew=not args.no_deskew)


def draw_boxes(gray, lang, psm, out_path):
    canvas = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
    d = ocr_data(gray, lang=lang, psm=psm)
    for x, y, w, h, c, t in zip(d["left"], d["top"], d["width"], d["height"], d["conf"], d["text"]):
        c = int(float(c))
        if c <= 0 or not t.strip():
            continue
        cv2.rectangle(canvas, (x, y), (x + w, y + h), (0, 180, 0) if c > 60 else (0, 0, 255), 3)
    cv2.imwrite(str(out_path), canvas)


def ocr_page(img, args):
    prep = prepare(img, args)
    text, conf = ocr_with_confidence(prep, lang=args.lang, psm=args.psm)
    return (clean_ocr_text(text) if not args.raw else text), conf, prep


def process_file(path, args):
    pages = []
    if path.suffix.lower() == ".pdf":
        for i in range(1, pdf_page_count(path) + 1):
            layer = pdf_page_text(path, i).strip()
            if len(layer) >= MIN_CHARS:
                pages.append({"page": i, "text": layer, "method": "text_layer", "confidence": 100.0})
                continue
            img = render_pdf_page(path, i, dpi=args.dpi)
            text, conf, _ = ocr_page(img, args)
            pages.append({"page": i, "text": text, "method": "ocr_tesseract", "confidence": round(conf, 1)})
    else:
        text, conf, prep = ocr_page(path, args)
        pages.append({"page": 1, "text": text, "method": "ocr_tesseract", "confidence": round(conf, 1)})
        if args.boxes:
            draw_boxes(prep, args.lang, args.psm, args.out / f"{path.stem}_boxes.png")
    for p in pages:
        p["gate"] = gate(p["method"], p["confidence"])
    return pages


def main():
    ap = argparse.ArgumentParser(description="OCR CLI: rasm/PDF -> matn + confidence")
    ap.add_argument("inputs", nargs="+", type=Path)
    ap.add_argument("--lang", default="rus+uzb")
    ap.add_argument("--psm", type=int, default=3)
    ap.add_argument("--preprocess", choices=["none", "otsu", "adaptive"], default="otsu")
    ap.add_argument("--no-deskew", action="store_true")
    ap.add_argument("--dpi", type=int, default=300, help="PDF sahifasini rasmga aylantirish DPI")
    ap.add_argument("--raw", action="store_true", help="clean_ocr_text qo'llanmasin")
    ap.add_argument("--boxes", action="store_true", help="so'z ramkalarini rasmga chizish")
    ap.add_argument("--out", type=Path, default=Path("out"))
    args = ap.parse_args()
    args.out.mkdir(exist_ok=True)

    for path in args.inputs:
        t0 = time.perf_counter()
        pages = process_file(path, args)
        (args.out / f"{path.stem}.txt").write_text("\n\n---\n\n".join(p["text"] for p in pages), encoding="utf-8")
        (args.out / f"{path.stem}.json").write_text(
            json.dumps([{k: v for k, v in p.items() if k != "text"} for p in pages], ensure_ascii=False, indent=1),
            encoding="utf-8")
        print(f"{path.name}: {len(pages)} sahifa, {time.perf_counter() - t0:.1f} s")
        for p in pages:
            print(f"  sahifa {p['page']}: {p['method']:14s} conf={p['confidence']:6.1f} -> {p['gate']:17s} "
                  f"'{p['text'][:50].replace(chr(10), ' ')}...'")


if __name__ == "__main__":
    main()

Chiqish: telefon surati (adaptive, ramkalar bilan) va aralash PDF

python11 qator
05_telefon_soya.jpg: 1 sahifa, 1.9 s
  sahifa 1: ocr_tesseract  conf=  95.8 -> accept            'SHARTNOMA М: 45 Toshkent shahri, 2024-yil 15-yanva...'
09_aralash.pdf: 3 sahifa, 2.5 s
  sahifa 1: text_layer     conf= 100.0 -> accept            'SHARTNOMA № 45 Toshkent shahri, 2024-yil 15-yanvar...'
  sahifa 2: ocr_tesseract  conf=  95.2 -> accept            'ДОГОВОР № 45 г. Ташкент, 15 января 2024 г.  ООО "Н...'
  sahifa 3: ocr_tesseract  conf=  92.7 -> accept            'ЗНАВТМОМА № 45  Toshkent shahri, 2024-yil 15-ya  "...'
05_telefon_soya.json
05_telefon_soya.txt
05_telefon_soya_boxes.png
09_aralash.json
09_aralash.txt

3-sahifadagi ЗНАВТМОМА ga e'tibor bering: --lang rus+uzb bilan Tesseract lotin sarlavhani kirill modeli bilan o'qidi. Aralash tilli PDF da har sahifa uchun til tanlash kerak, yoki o'zbek sahifalarga faqat uzb. 11-bob 4-lab A: keraksiz til qo'shmang.

evaluate.py

python66 qator
# evaluate.py — 1-loyiha: ground_truth/ ustida preprocessing variantlarini solishtirish.
#
#   python evaluate.py --preprocess none otsu adaptive
#   python evaluate.py --preprocess otsu --psm 6 --only chek
#
# Har rasm uchun CER/WER va o'rtacha; --csv bilan jadval faylga.
import argparse
import csv
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import ocr_text, preprocess, score, to_gray  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S, G = ROOT / "samples", ROOT / "ground_truth"


def lang_for(name):
    if "kirill" in name or "gazeta" in name:
        return "uzb_cyrl"
    if "rus" in name:
        return "rus"
    return "uzb"


def variant(path, mode, deskew):
    return to_gray(path) if mode == "none" else preprocess(path, mode, do_deskew=deskew)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--preprocess", nargs="+", default=["none", "otsu", "adaptive"])
    ap.add_argument("--psm", type=int, default=3)
    ap.add_argument("--no-deskew", action="store_true")
    ap.add_argument("--only", default="", help="fayl nomida shu so'z bo'lganlar")
    ap.add_argument("--csv", type=Path)
    args = ap.parse_args()

    files = sorted(p for p in S.iterdir() if p.suffix in (".png", ".jpg") and (G / (p.stem + ".txt")).exists()
                   and args.only in p.name)
    table = {p.name: {} for p in files}
    for mode in args.preprocess:
        t0, cers, wers = time.perf_counter(), [], []
        for p in files:
            truth = (G / (p.stem + ".txt")).read_text(encoding="utf-8")
            sc = score(truth, ocr_text(variant(p, mode, not args.no_deskew), lang=lang_for(p.name), psm=args.psm))
            table[p.name][mode] = sc
            cers.append(sc["cer"]); wers.append(sc["wer"])
        print(f"{mode:10s} o'rtacha CER={sum(cers)/len(cers):.3f} WER={sum(wers)/len(wers):.3f} ({time.perf_counter()-t0:.0f} s)")

    print(f"\n{'rasm':22s}" + "".join(f"{m:>14s}" for m in args.preprocess))
    for name, row in table.items():
        print(f"{name:22s}" + "".join(f"{row[m]['cer']:7.3f}/{row[m]['wer']:4.2f} " for m in args.preprocess))
    if args.csv:
        with open(args.csv, "w", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            w.writerow(["file"] + [f"{m}_{k}" for m in args.preprocess for k in ("cer", "wer")])
            for name, row in table.items():
                w.writerow([name] + [f"{row[m][k]:.4f}" for m in args.preprocess for k in ("cer", "wer")])
        print("CSV:", args.csv)


if __name__ == "__main__":
    main()

Chiqish: cheklar, PSM 6, otsu vs adaptive

python8 qator
otsu       o'rtacha CER=0.109 WER=0.257 (2 s)
adaptive   o'rtacha CER=0.041 WER=0.196 (2 s)

rasm                            otsu      adaptive
10_chek_1.jpg           0.022/0.14   0.045/0.22 
10_chek_2.jpg           0.293/0.51   0.048/0.22 
10_chek_3.jpg           0.012/0.12   0.031/0.15 
CSV: out/chek.csv

Chek 2 telefon surati: otsu 0.293, adaptive 0.048. Chek 1 va 3 skaner: otsu yaxshiroq. Bitta global sozlama yo'q, shuning uchun CLI da --preprocess bayroq va evaluate da jadval.

MezonBall
CLI ishlaydi: rasm, PDF, --boxes, JSON chiqish40
evaluate jadvali: kamida 3 variant × 10 fayl, 5 gap xulosa30
Preprocessing tanlovi raqam bilan asoslangan20
Kod tozaligi, README10
Kengaytma: --deskew faqat burchak > 0.5° bo'lsa; --lang auto (kirill/lotin ni aniqlash)+10

2-loyiha: Chek → JSON, regex vs VLM

Maqsad. Chek suratidan {sana, inn, summa, mahsulotlar}. Ikki yo'l yonma-yon: Tesseract + regex (arzon, tushunarli, xatosi ko'rinadi) va VLM + JSON prompt (qulay, lekin to'qishi mumkin). Baholash maydon darajasida, ground_truth/receipts.json bilan.

receipt.py

python116 qator
# receipt.py — 2-loyiha: chek surati -> JSON {sana, summa, inn, mahsulotlar}. Ikki usul yonma-yon:
#   A) Tesseract (PSM 6) + regex     B) VLM + JSON prompt (OLLAMA_HOST ochiq bo'lsa)
# Baholash: ground_truth/receipts.json bilan har maydon bo'yicha to'g'ri/noto'g'ri.
#
#   python receipt.py                 # 3 ta chek, ikkala usul, natija jadvali
import json
import os
import re
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import ocr_text, preprocess  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S, G = ROOT / "samples", ROOT / "ground_truth"

DATE = re.compile(r"(\d{2})[./-](\d{2})[./-](\d{4})")
INN = re.compile(r"(?:INN|ИНН|STIR)\s*[:№]?\s*(\d{9})", re.I)
TOTAL = re.compile(r"(?:JAMI|ИТОГО|ИТОГ|TOTAL)\s*[::]?\s*((?:\d[\d ]*\d)|\d)", re.I)
# "Non oq 400g 1 x 4 500": nom ichida raqam bo'lishi mumkin (400g), shuning uchun nom = .+? (eng qisqa),
# keyin miqdor, x (lotin yoki kirill х — OCR ikkalasini beradi), narx
ITEM = re.compile(r"^(?P<name>.+?)\s+(?P<qty>\d{1,2})\s*[xхX×]\s*(?P<price>\d[\d ]*\d)\s*$")


def num(s):
    return int(re.sub(r"\D", "", s))


def extract_regex(text):
    """OCR matnidan maydonlar. Topilmasa None (taxmin qilmaymiz)."""
    d = DATE.search(text)
    inn = INN.search(text)
    total = TOTAL.search(text)
    items = []
    for ln in text.splitlines():
        m = ITEM.match(ln.strip())
        if m:
            items.append([m["name"].strip(), int(m["qty"]), num(m["price"])])
    return {"sana": f"{d[1]}.{d[2]}.{d[3]}" if d else None,
            "inn": inn[1] if inn else None,
            "summa": num(total[1]) if total else None,
            "mahsulotlar": items}


def extract_vlm(image_path, model=os.environ.get("VLM_MODEL", "qwen2.5vl:7b")):
    """6-bob: prompt bilan maydon ajratish. Faqat JSON so'raymiz, topilmagan -> null, taxmin taqiqlanadi."""
    import base64
    from openai import OpenAI
    client = OpenAI(base_url=f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/v1", api_key="ollama")
    b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
    prompt = ('Bu do\'kon cheki. Faqat JSON qaytar: {"sana": "DD.MM.YYYY", "inn": "9 raqam", "summa": butun son, '
              '"mahsulotlar": [["nomi", miqdor, narx], ...]}. Topilmagan maydon uchun null yoz. Taxmin qilma. '
              'JSON dan boshqa hech narsa yozma.')
    r = client.chat.completions.create(model=model, temperature=0, messages=[{"role": "user", "content": [
        {"type": "text", "text": prompt},
        {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}]}])
    raw = r.choices[0].message.content
    m = re.search(r"\{.*\}", raw, re.S)
    try:
        return json.loads(m.group(0)) if m else {"_xato": raw[:120]}
    except json.JSONDecodeError:
        return {"_xato": raw[:120]}


def compare(pred, truth):
    """Har maydon: to'g'ri (1) yoki yo'q (0). Mahsulotlar: nechtasi nomi va narxi bilan mos."""
    res = {"sana": pred.get("sana") == truth["sana"],
           "inn": str(pred.get("inn")) == truth["inn"],
           "summa": pred.get("summa") == truth["summa"]}
    # mahsulotlar: qat'iy (nom + narx) va yumshoq (faqat narx) — OCR "1L" ni "IL" deb o'qisa nom mos kelmaydi, narx mos keladi
    t_items = {(n.lower(), p) for n, q, p in truth["mahsulotlar"]}
    p_items = {(str(n).lower(), p) for n, q, p in (pred.get("mahsulotlar") or []) if isinstance(p, int)}
    t_prices = sorted(p for _, p in t_items)
    p_prices = sorted(p for _, p in p_items)
    res["mahsulotlar"] = f"{len(t_items & p_items)}/{len(t_items)} (narx: {sum(1 for p in t_prices if p in p_prices)}/{len(t_prices)})"
    return res


def vlm_available(model=os.environ.get("VLM_MODEL", "qwen2.5vl:7b")):
    """Server ochiq VA model yuklangan bo'lsagina True (aks holda 404 bilan yiqilardi)."""
    try:
        import json as _json
        import urllib.request
        tags = _json.load(urllib.request.urlopen(f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/tags", timeout=3))
        return any(m["name"] == model for m in tags["models"])
    except Exception:
        return False


if __name__ == "__main__":
    truths = json.loads((G / "receipts.json").read_text(encoding="utf-8"))
    use_vlm = vlm_available()
    print(f"VLM: {'ochiq' if use_vlm else 'yopiq (faqat regex)'}\n")
    totals = {"regex": {"sana": 0, "inn": 0, "summa": 0}, "vlm": {"sana": 0, "inn": 0, "summa": 0}}
    for t in truths:
        img = S / t["file"]
        mode = "adaptive" if "2" in t["file"] else "otsu"                # 2-chek telefon surati
        t0 = time.perf_counter()
        text = ocr_text(preprocess(img, mode, do_deskew=False), lang="uzb", psm=6)
        pr = extract_regex(text)
        cr = compare(pr, t)
        print(f"{t['file']}  [regex, {time.perf_counter()-t0:.1f} s]  sana={pr['sana']} inn={pr['inn']} summa={pr['summa']} "
              f"mahsulot={len(pr['mahsulotlar'])} -> {cr}")
        for k in ("sana", "inn", "summa"):
            totals["regex"][k] += int(cr[k])
        if use_vlm:
            t0 = time.perf_counter()
            pv = extract_vlm(img)
            cv = compare(pv, t) if "_xato" not in pv else {"xato": pv["_xato"]}
            print(f"{'':14s}  [vlm,   {time.perf_counter()-t0:.1f} s]  sana={pv.get('sana')} inn={pv.get('inn')} "
                  f"summa={pv.get('summa')} mahsulot={len(pv.get('mahsulotlar') or [])} -> {cv}")
            for k in ("sana", "inn", "summa"):
                totals["vlm"][k] += int(bool(cv.get(k)))
    print("\nMaydon aniqligi (3 chekdan):", totals["regex"], "| vlm:", totals["vlm"] if use_vlm else "-")
Chiqish
VLM: ochiq

10_chek_1.jpg  [regex, 0.4 s]  sana=15.01.2024 inn=302145678 summa=95800 mahsulot=5 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '4/5 (narx: 5/5)'}
                [vlm,   2.2 s]  sana=15.01.2024 inn=302145678 summa=95800 mahsulot=5 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '4/5 (narx: 5/5)'}
10_chek_2.jpg  [regex, 0.4 s]  sana=02.03.2024 inn=305987123 summa=115500 mahsulot=4 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '1/4 (narx: 4/4)'}
                [vlm,   1.4 s]  sana=02.03.2024 inn=305987123 summa=115500 mahsulot=4 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '0/4 (narx: 4/4)'}
10_chek_3.jpg  [regex, 0.3 s]  sana=27.06.2024 inn=301456789 summa=119500 mahsulot=3 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '3/3 (narx: 3/3)'}
                [vlm,   1.4 s]  sana=27.06.2024 inn=301456789 summa=119500 mahsulot=3 -> {'sana': True, 'inn': True, 'summa': True, 'mahsulotlar': '3/3 (narx: 3/3)'}

Maydon aniqligi (3 chekdan): {'sana': 3, 'inn': 3, 'summa': 3} | vlm: {'sana': 3, 'inn': 3, 'summa': 3}
  • Sana, INN, summa: regex ham, VLM ham 3/3. Chek uchun asosiy maydonlar regex bilan yechiladi: JAMI, INN va sana formati barqaror.
  • Mahsulotlar: bu yerda farq. Regex qatorni nom miqdor x narx naqshi bilan oladi; OCR "1L" ni "IL", "1 x" ni "1x" deb o'qisa nom mos kelmaydi, narx mos keladi (shuning uchun ikki ko'rsatkich: qat'iy va narx bo'yicha). VLM esa satrni tushunib ajratadi. Telefon suratida (chek 2) ikkalasi ham qiynaladi.
  • Talabaga topshiriq: 30 ta haqiqiy chek surati, ularning JSON haqiqati qo'lda (2 soat), keyin shu jadval. Qaysi maydonda VLM ishonch bilan noto'g'ri raqam yozdi? Shu son hisobotning eng muhim raqami (6-bob).
MezonBall
Preprocessing + PSM 6 + regex pipeline30
Maydon aniqligi jadvali, 3 usul (regex, VLM, gibrid)30
Hallucination tahlili: VLM ning ishonchli noto'g'ri javoblari sanalgan20
Kod10
Kengaytma: 30 chek → oylik xarajat Excel; [NOANIQ] bilan VLM xatosi kamaydimi+10

3-loyiha: Telegram bot, surat → matn

Maqsad. Foydalanuvchi surat yuboradi, bot matn + confidence + maslahat qaytaradi. Quality gate (8-bob) foydalanuvchi tiliga tarjima qilinadi: "sifat past, qayta suratga oling: yorug'lik, parallel tuting".

Dizayn. OCR mantiqi ocr_service.py da, Telegram siz test qilinadi. bot.py faqat ulash: aiogram 3, surat → vaqtinchalik fayl → asyncio.to_thread (OCR event loop ni bloklamasin) → javob. Xavfsizlik: token faqat muhitdan, fayl ishlagach o'chadi (TemporaryDirectory), daqiqasiga 5 surat, 10 MB chegara.

ocr_service.py

python33 qator
# ocr_service.py — 3-loyiha: botdan ajratilgan "toza" funksiya. Telegram siz ham test qilinadi.
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import clean_ocr_text, ocr_with_confidence, preprocess  # noqa: E402

GOOD, OK = 80, 60


def process_image(path, lang="uzb"):
    """Surat -> {'text', 'conf', 'advice'}. Quality gate (8-bob) foydalanuvchi xabariga aylanadi."""
    prep = preprocess(path, "adaptive")                 # telefon surati: notekis yorug'lik
    text, conf = ocr_with_confidence(prep, lang=lang)
    text = clean_ocr_text(text)
    if not text:
        advice = "Matn topilmadi. Hujjatni to'liq kadrga oling va yorug'roq joyda suratga oling."
    elif conf < OK:
        advice = (f"Sifat past ({conf:.0f}%). Qayta suratga oling: yorug'lik bir tekis bo'lsin, "
                  "telefonni qog'ozga parallel tuting, soyadan qoching.")
    elif conf < GOOD:
        advice = f"O'rtacha sifat ({conf:.0f}%). Raqam va sanalarni tekshirib oling."
    else:
        advice = f"Yaxshi ({conf:.0f}%)."
    return {"text": text, "conf": round(conf, 1), "advice": advice, "words": len(text.split())}


if __name__ == "__main__":
    root = Path(__file__).resolve().parents[2] / "samples"
    for name in ("05_telefon_soya.jpg", "12_dogli.png", "10_chek_2.jpg"):
        r = process_image(root / name)
        print(f"{name}: conf={r['conf']} so'zlar={r['words']} -> {r['advice']}")
        print("   ", r["text"][:80].replace("\n", " "), "...")

bot.py

python82 qator
# bot.py — 3-loyiha: Telegram bot (aiogram 3). Surat -> matn + confidence + maslahat.
#
#   pip install aiogram
#   BOT_TOKEN=123456:ABC... python bot.py
#
# Xavfsizlik: token faqat muhit o'zgaruvchisidan; fayl vaqtinchalik papkada va ishlagach o'chadi;
# foydalanuvchi uchun daqiqasiga RATE_LIMIT surat; 10 MB dan katta fayl qabul qilinmaydi.
import asyncio
import os
import tempfile
import time
from collections import defaultdict
from pathlib import Path

from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command
from aiogram.types import Message

from ocr_service import process_image

RATE_LIMIT = 5                 # surat / daqiqa / foydalanuvchi
MAX_BYTES = 10 * 1024 * 1024
LANGS = {"uz": "uzb", "ru": "rus", "uzk": "uzb_cyrl", "uzru": "rus+uzb"}

dp = Dispatcher()
user_lang = {}                              # user_id -> tesseract lang
hits = defaultdict(list)                    # user_id -> [timestamp, ...]


def rate_limited(user_id):
    now = time.time()
    hits[user_id] = [t for t in hits[user_id] if now - t < 60]
    if len(hits[user_id]) >= RATE_LIMIT:
        return True
    hits[user_id].append(now)
    return False


@dp.message(Command("start"))
async def on_start(message: Message):
    await message.answer("Hujjat suratini yuboring, matnini qaytaraman.\n"
                         "Til: /lang uz (lotin), /lang uzk (kirill), /lang ru, /lang uzru")


@dp.message(Command("lang"))
async def on_lang(message: Message):
    code = (message.text.split(maxsplit=1) + [""])[1].strip().lower()
    if code not in LANGS:
        await message.answer("Variantlar: " + ", ".join(LANGS))
        return
    user_lang[message.from_user.id] = LANGS[code]
    await message.answer(f"Til: {LANGS[code]}")


@dp.message(F.photo)
async def on_photo(message: Message, bot: Bot):
    uid = message.from_user.id
    if rate_limited(uid):
        await message.answer(f"Daqiqasiga {RATE_LIMIT} ta surat. Biroz kuting.")
        return
    photo = message.photo[-1]                             # eng katta o'lcham
    if photo.file_size and photo.file_size > MAX_BYTES:
        await message.answer("Fayl 10 MB dan katta.")
        return
    with tempfile.TemporaryDirectory() as tmp:            # chiqishda papka va fayl o'chadi
        path = Path(tmp) / "in.jpg"
        await bot.download(photo, destination=path)
        result = await asyncio.to_thread(process_image, path, user_lang.get(uid, "uzb"))   # OCR event loop ni bloklamasin
    text = result["text"][:3500] or "(bo'sh)"
    await message.answer(f"{text}\n\n— {result['advice']}")


async def main():
    token = os.environ.get("BOT_TOKEN")
    if not token:
        raise SystemExit("BOT_TOKEN muhit o'zgaruvchisi kerak")
    bot = Bot(token)
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Chiqish: ocr_service 3 ta suratda, bot.py import tekshiruvi

python7 qator
05_telefon_soya.jpg: conf=95.8 so'zlar=143 -> Yaxshi (96%).
    SHARTNOMA М: 45 Toshkent shahri, 2024-yil 15-yanvar  "Nihol" MChJ nomidan direkt ...
12_dogli.png: conf=83.7 so'zlar=143 -> Yaxshi (84%).
    “SHARTNOMA Ne 45 Toshkent shahri, 2024-yil.15-yanvar  "Nihol" MChJ nomidan Anvar ...
10_chek_2.jpg: conf=89.1 so'zlar=38 -> Yaxshi (89%).
    MAKRO MARKET INN 305987123  Sana: 02.03.2024 09:15  Guruch lazer 1kg Yog' IL Kar ...
bot.py import OK, handlers: 3

12_dogli da conf 84 va "Yaxshi", lekin 11-bob 3-labdan bilamiz: shu rasmda CER 0.10 (median siz). Bot preprocess("adaptive") ishlatadi, dog'li skan uchun median kerak. Talabaga birinchi topshiriq: ocr_stats ning past-conf ulushini maslahatga ulash va rasm turiga qarab preprocessing tanlash.

Ishga tushirish

bash3 qator
pip install aiogram
export BOT_TOKEN=123456789:AAF...        # @BotFather dan
python bot.py                             # /start, /lang uz|uzk|ru|uzru, surat yuboring
MezonBall
Bot ishlaydi: surat → matn + maslahat, /lang30
Yomon suratda to'g'ri maslahat (20 test surat jadvali)20
Xavfsizlik ro'yxati: token, fayl o'chirish, limit, hajm20
ocr_service testlari (Telegram siz)20
Kod10
Kengaytma: "qidiruvga qo'sh" tugmasi → 7-loyiha bazasi+10

4-loyiha: Jadval → Excel

Maqsad. Chiziqli jadval skani → .xlsx, katak tuzilishi saqlangan holda. 11-bob 2-labda ko'rdik: Tesseract jadvalda 13 ta so'z topadi, chunki chiziqlarni harf deb o'ylaydi. Ikki yo'l: (A) chiziqlarni morfologiya bilan topib olib tashlash va ularning koordinatalari bilan so'zlarni kataklarga joylash; (B) VLM → Markdown → pandas.

Chapda asl jadval, o'ngda chiziqlar olib tashlangan (MORPH_OPEN uzun gorizontal va vertikal yadro bilan). Chiziqlar koordinatalari katak chegaralari bo'ladi.

Chapda asl jadval, o'ngda chiziqlar olib tashlangan (MORPH_OPEN uzun gorizontal va vertikal yadro bilan). Chiziqlar koordinatalari katak chegaralari bo'ladi.

table2xlsx.py

python145 qator
# table2xlsx.py — 4-loyiha: jadvalli skan -> .xlsx. Ikki yo'l:
#   A) Tesseract so'z koordinatalari + ustun chegaralarini o'zimiz topamiz (bo'shliq bo'yicha)
#   B) VLM -> Markdown jadval -> pandas (OLLAMA_HOST ochiq bo'lsa)
# Baholash: katak aniqligi = to'g'ri kataklar / jami kataklar (ground_truth/07_jadval.txt)
import os
import re
import sys
import time
from pathlib import Path

import cv2
import numpy as np
import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import ocr_data, preprocess  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S, G, OUT = ROOT / "samples", ROOT / "ground_truth", Path("out")
OUT.mkdir(exist_ok=True)


# ---------- A) Tesseract: avval jadval chiziqlarini topamiz, keyin so'zlarni kataklarga joylaymiz ----------
def grid_lines(binary, frac=12):
    """Uzun gorizontal/vertikal chiziqlar (morfologiya). Qaytaradi: chiziqlar maskasi, ustun x lari, qator y lari."""
    inv = 255 - binary                                   # matn va chiziqlar oq
    h, w = inv.shape
    horiz = cv2.morphologyEx(inv, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (w // frac, 1)))
    vert = cv2.morphologyEx(inv, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, h // frac)))

    def positions(profile, min_run=3):
        pos, start = [], None
        for i, v in enumerate(profile):
            if v and start is None:
                start = i
            elif not v and start is not None:
                if i - start >= min_run:
                    pos.append((start + i) // 2)
                start = None
        return pos
    xs = positions((vert.sum(axis=0) > 0.3 * 255 * h))     # ustun chiziqlari
    ys = positions((horiz.sum(axis=1) > 0.3 * 255 * w))    # qator chiziqlari
    return cv2.bitwise_or(horiz, vert), xs, ys


def tesseract_table(binary, lang="uzb"):
    """1) chiziqlarni olib tashlaymiz (Tesseract ularni harf deb o'ylaydi)  2) so'zlar -> (qator, ustun) katak."""
    grid, xs, ys = grid_lines(binary)
    no_lines = cv2.bitwise_or(binary, cv2.dilate(grid, np.ones((3, 3), np.uint8)))   # chiziq o'rni oq bo'ladi
    d = ocr_data(no_lines, lang=lang, psm=11)                   # siyrak matn: har so'z alohida
    if len(xs) < 2 or len(ys) < 2:
        return [], no_lines
    cells = {}
    for i, t in enumerate(d["text"]):
        if int(float(d["conf"][i])) <= 0 or not t.strip():
            continue
        cx, cy = d["left"][i] + d["width"][i] / 2, d["top"][i] + d["height"][i] / 2
        col = sum(1 for x in xs if cx > x) - 1
        row = sum(1 for y in ys if cy > y) - 1
        if 0 <= col < len(xs) - 1 and 0 <= row < len(ys) - 1:
            cells.setdefault((row, col), []).append((d["left"][i], t))
    table = []
    for r in range(len(ys) - 1):
        table.append([" ".join(t for _, t in sorted(cells.get((r, c), []))) for c in range(len(xs) - 1)])
    return table, no_lines


# ---------- B) VLM -> Markdown ----------
def markdown_table_to_rows(md):
    rows = []
    for ln in md.splitlines():
        ln = ln.strip()
        if not ln.startswith("|") or re.match(r"^\|?\s*:?-{2,}", ln):
            continue
        rows.append([c.strip() for c in ln.strip("|").split("|")])
    return rows


def vlm_table(image_path, model=os.environ.get("VLM_MODEL", "qwen2.5vl:7b")):
    import base64
    from openai import OpenAI
    client = OpenAI(base_url=f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/v1", api_key="ollama")
    b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
    r = client.chat.completions.create(model=model, temperature=0, messages=[{"role": "user", "content": [
        {"type": "text", "text": "Rasmdagi jadvalni Markdown jadval qilib qaytar. Sarlavha qatorini ham. Boshqa hech narsa yozma."},
        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])
    return r.choices[0].message.content


# ---------- umumiy ----------
def rows_to_xlsx(rows, path):
    width = max(len(r) for r in rows)
    rows = [r + [""] * (width - len(r)) for r in rows]
    pd.DataFrame(rows[1:], columns=[c or f"col{i}" for i, c in enumerate(rows[0])]).to_excel(path, index=False)


def norm(s):
    return re.sub(r"\s+", "", str(s)).lower().replace("'", "").replace("’", "")


def cell_accuracy(pred, truth):
    """Har haqiqiy katak bashoratda (o'z qatorida) bormi. Raqamli kataklar alohida sanaladi."""
    total = num_total = hit = num_hit = 0
    for i, trow in enumerate(truth):
        prow = pred[i] if i < len(pred) else []
        pcells = {norm(c) for c in prow}
        for c in trow:
            if not c:
                continue
            total += 1
            is_num = bool(re.fullmatch(r"[\d ]+", c))
            num_total += is_num
            if norm(c) in pcells:
                hit += 1
                num_hit += is_num
    return hit, total, num_hit, num_total


if __name__ == "__main__":
    truth = [ln.split(" | ") for ln in (G / "07_jadval.txt").read_text(encoding="utf-8").splitlines()]
    img = preprocess(S / "07_jadval.png", "otsu", do_deskew=False)

    t0 = time.perf_counter()
    rows_a, no_lines = tesseract_table(img)
    cv2.imwrite(str(OUT / "jadval_chiziqsiz.png"), no_lines)
    rows_to_xlsx(rows_a, OUT / "jadval_tesseract.xlsx")
    h, t, nh, nt = cell_accuracy(rows_a, truth)
    print(f"A) Tesseract + chiziqlar bo'yicha kataklar: {len(rows_a)} qator, kataklar {h}/{t}, raqamli {nh}/{nt}  ({time.perf_counter()-t0:.1f} s)")
    for r in rows_a[:4]:
        print("   ", r)

    try:
        import urllib.request
        urllib.request.urlopen(f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/tags", timeout=3)
        t0 = time.perf_counter()
        md = vlm_table(S / "07_jadval.png")
        rows_b = markdown_table_to_rows(md)
        rows_to_xlsx(rows_b, OUT / "jadval_vlm.xlsx")
        h, t, nh, nt = cell_accuracy(rows_b, truth)
        print(f"B) VLM Markdown:               {len(rows_b)} qator, kataklar {h}/{t}, raqamli {nh}/{nt}  ({time.perf_counter()-t0:.1f} s)")
        for r in rows_b[:4]:
            print("   ", r)
    except Exception as e:
        print(f"B) VLM o'tkazib yuborildi: {type(e).__name__}")
    print("Excel fayllar:", sorted(p.name for p in OUT.glob("*.xlsx")))
Chiqish
A) Tesseract + chiziqlar bo'yicha kataklar: 7 qator, kataklar 24/26, raqamli 14/16  (0.2 s)
    ['Mahsulot', 'Miqdor', "Narx (so'm)", 'Jami']
    ['Monitor 24 dyuym', '4', '1850 000', '7 400 000']
    ['Klaviatura', '10', '180 000', '1800 000']
    ['Sichqoncha', '10', '95 000', '950 000']
B) VLM Markdown:               7 qator, kataklar 26/26, raqamli 16/16  (3.4 s)
    ['Mahsulot', 'Miqdor', "Narx (so'm)", 'Jami']
    ['Monitor 24 dyuym', '4', '1 850 000', '7 400 000']
    ['Klaviatura', '10', '180 000', '1 800 000']
    ['Sichqoncha', '10', '95 000', '950 000']
Excel fayllar: ['jadval_tesseract.xlsx', 'jadval_vlm.xlsx']
  • A yo'li 0/26 dan (bo'shliq bo'yicha ustun ajratish, birinchi urinish) 24/26 ga chiqdi. Ikki xato: 1 850 000 → 1850 000 (raqam ichidagi bo'shliq). Raqamli kataklar 14/16. Bu Tesseract ning haqiqiy imkoniyati: jadval chiziqlarini olib tashlasangiz, u jadvalni o'qiydi.
  • B yo'li 26/26, 3.4 s (GPU). Jadval VLM ning kuchli tomoni (6-bob). Lekin: confidence yo'q; 20 ta jadvaldan bittasida raqam to'qilsa, buni faqat ground truth topadi.
  • Talabaga: 15 jadval (chiziqli, chiziqsiz, telefon surati). Chiziqsiz jadvalda A yo'li ishlamaydi: ustunlarni so'z x-koordinatalarini klasterlab topish kerak (proyeksiya, 6-loyihadagi kabi).
MezonBall
Ikki yo'l ishlaydi, Excel to'g'ri30
Katak aniqligi jadvali, 15 sahifa × 2 yo'l30
Raqamli kataklar alohida tahlili20
Chiziqsiz jadval uchun ustun klasterlash10
Kod10
Kengaytma: birlashgan kataklar (merged cells)+10

5-loyiha: O'zbek lotin / kirill benchmark

Maqsad. O'zbek matni uchun qaysi dvigatel yaxshi, raqam bilan: Tesseract (uzb, uzb_cyrl), EasyOCR (en/ru, o'zbek yo'q), Qwen2.5-VL. Yoniga belgi darajasidagi xato tahlili (qaysi harf qaysi harfga almashadi) va kirill → lotin transliteratsiya.

translit.py

python44 qator
# translit.py — 5-loyiha: o'zbek kirill -> lotin. Qoidaga asoslangan, lug'atsiz.
# Nozik joylar: е so'z boshida/unlidan keyin "ye", ц -> "ts", ъ -> ' (tutuq belgisi), ь tashlanadi.
import re

VOWELS = "аеёиоуўэюяАЕЁИОУЎЭЮЯ"
MAP = {
    "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "ё": "yo", "ж": "j", "з": "z", "и": "i", "й": "y",
    "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
    "ф": "f", "х": "x", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sh", "ъ": "'", "ь": "", "э": "e", "ю": "yu",
    "я": "ya", "ў": "o'", "қ": "q", "ғ": "g'", "ҳ": "h",
}


IOTATED = "еёюяЕЁЮЯ"


def _one(ch, prev, nxt):
    lower = ch.lower()
    if lower == "е":
        # so'z boshida, unlidan keyin va ъ/ь dan keyin "ye": етти -> yetti, объект -> obyekt
        out = "ye" if (prev is None or prev in VOWELS or prev.lower() in "ъь" or not prev.isalpha()) else "e"
    elif lower == "ъ" and nxt is not None and nxt in IOTATED:
        out = ""                       # объект -> obyekt: tutuq belgisi yozilmaydi, е o'zi "ye" bo'ladi
    else:
        out = MAP.get(lower, ch)
    return out.capitalize() if ch.isupper() and out else out


def cyr2lat(text):
    out = []
    for i, ch in enumerate(text):
        prev = text[i - 1] if i > 0 else None
        nxt = text[i + 1] if i + 1 < len(text) else None
        out.append(_one(ch, prev, nxt))
    return "".join(out)


if __name__ == "__main__":
    tests = [("Тошкент шаҳри", "Toshkent shahri"), ("ўзбек тили", "o'zbek tili"), ("Ғафур Ғулом", "G'afur G'ulom"),
             ("етказиб бериш", "yetkazib berish"), ("шартнома", "shartnoma"), ("Қуйидагилар ҳақида", "Quyidagilar haqida"),
             ("объект", "obyekt"), ("санъат", "san'at"), ("цирк", "tsirk"), ("Ёзилган", "Yozilgan")]
    for src, want in tests:
        got = cyr2lat(src)
        print(("OK " if got == want else "!! ") + f"{src} -> {got}" + ("" if got == want else f"  (kutilgan: {want})"))

Chiqish: translit testlari

matn10 qator
OK Тошкент шаҳри -> Toshkent shahri
OK ўзбек тили -> o'zbek tili
OK Ғафур Ғулом -> G'afur G'ulom
OK етказиб бериш -> yetkazib berish
OK шартнома -> shartnoma
OK Қуйидагилар ҳақида -> Quyidagilar haqida
OK объект -> obyekt
OK санъат -> san'at
OK цирк -> tsirk
OK Ёзилган -> Yozilgan

benchmark.py

python125 qator
# benchmark.py — 5-loyiha: o'zbek lotin va kirill uchun uch dvigatelni raqam bilan solishtirish.
#   Tesseract (uzb / uzb_cyrl), EasyOCR (en / ru — o'zbek yo'q), VLM (OLLAMA_HOST ochiq bo'lsa)
# Chiqish: CER/WER/vaqt matritsasi, eng ko'p adashadigan belgilar, kirill OCR + translit tajribasi.
import difflib
import os
import sys
import time
from collections import Counter
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import normalize, ocr_text, preprocess, score  # noqa: E402
from translit import cyr2lat  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S, G = ROOT / "samples", ROOT / "ground_truth"

LATIN = ["03_uzb_lotin.png", "05_telefon_soya.jpg", "06_qiyshiq.png", "11_past_dpi.png", "12_dogli.png"]
CYRIL = ["04_uzb_kirill.png", "14_gazeta_1.png", "14_gazeta_3.png"]


def truth_of(name):
    return (G / (Path(name).stem + ".txt")).read_text(encoding="utf-8")


def mode_for(name):
    return "adaptive" if "telefon" in name else "otsu"


# ---------- dvigatellar ----------
def tesseract_engine(script):
    lang = "uzb" if script == "lotin" else "uzb_cyrl"
    return lambda name: ocr_text(preprocess(S / name, mode_for(name)), lang=lang)


def easyocr_engine(script):
    import easyocr
    reader = easyocr.Reader(["en"] if script == "lotin" else ["ru"], gpu=False, verbose=False)
    return lambda name: "\n".join(r[1] for r in reader.readtext(str(S / name)) if r[2] > 0.3)


def vlm_engine(model=os.environ.get("VLM_MODEL", "qwen2.5vl:7b")):
    import base64
    import io
    from openai import OpenAI
    from PIL import Image
    client = OpenAI(base_url=f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/v1", api_key="ollama")

    def run(name):
        im = Image.open(S / name).convert("RGB")
        im.thumbnail((1600, 1600))
        buf = io.BytesIO()
        im.save(buf, format="PNG")
        b64 = base64.b64encode(buf.getvalue()).decode()
        # 11-bob 6-lab: yumshoq prompt bilan model lotinni kirillga o'giradi. Qat'iy prompt + ``` qavslarni olib tashlash.
        prompt = ("Rasmdagi matnni AYNAN yozilganidek ko'chir: qaysi alifboda bo'lsa (lotin yoki kirill) shu alifboda, "
                  "tarjima qilma, alifboni o'zgartirma, imloni tuzatma. Qatorlarni saqla. Markdown, ``` qavslar, izoh qo'shma.")
        r = client.chat.completions.create(model=model, temperature=0, messages=[{"role": "user", "content": [
            {"type": "text", "text": prompt},
            {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])
        import re as _re
        return _re.sub(r"^```[a-z]*\s*|\s*```$", "", r.choices[0].message.content.strip(), flags=_re.M)
    return run


def confusions(truth, pred, top=8):
    """Qaysi belgi qaysi belgiga almashgan (SequenceMatcher 'replace' bloklari, 1-2 belgili)."""
    t, p = normalize(truth), normalize(pred)
    c = Counter()
    for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, t, p, autojunk=False).get_opcodes():
        if tag == "replace" and i2 - i1 <= 2 and j2 - j1 <= 2:
            c[(t[i1:i2], p[j1:j2])] += 1
    return c.most_common(top)


def evaluate(engine, files, label):
    cers, wers, t0, conf = [], [], time.perf_counter(), Counter()
    for name in files:
        pred = engine(name)
        sc = score(truth_of(name), pred)
        cers.append(sc["cer"]); wers.append(sc["wer"])
        for k, v in confusions(truth_of(name), pred):
            conf[k] += v
    dt = (time.perf_counter() - t0) / len(files)
    print(f"  {label:22s} CER={sum(cers)/len(cers):.3f}  WER={sum(wers)/len(wers):.3f}  {dt:5.1f} s/sahifa   "
          f"xatolar: {', '.join(f'{a}->{b}({n})' for (a, b), n in conf.most_common(4))}")
    print("  " + " " * 22 + "sahifalar: " + "  ".join(f"{n[:12]}={c:.3f}" for n, c in zip(files, cers)))
    return cers


def available(kind):
    if kind == "easyocr":
        try:
            import easyocr  # noqa: F401
            return True
        except ImportError:
            return False
    if kind == "vlm":
        try:
            import json
            import urllib.request
            tags = json.load(urllib.request.urlopen(f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/tags", timeout=3))
            return any(m["name"] == os.environ.get("VLM_MODEL", "qwen2.5vl:7b") for m in tags["models"])
        except Exception:
            return False


if __name__ == "__main__":
    for script, files in (("lotin", LATIN), ("kirill", CYRIL)):
        print(f"\n== {script} ({len(files)} sahifa)")
        evaluate(tesseract_engine(script), files, "Tesseract")
        if available("easyocr"):
            evaluate(easyocr_engine(script), files, "EasyOCR (en/ru)")
        if available("vlm"):
            evaluate(vlm_engine(), files, "VLM")

    print("\n== Translit tajribasi: kirill OCR -> lotin, lotin haqiqat bilan solishtirish")
    # 04_uzb_kirill va 03_uzb_lotin bir xil shartnoma: kirill OCR ni lotinga o'girib, lotin haqiqatga solishtiramiz
    pred = cyr2lat(ocr_text(preprocess(S / "04_uzb_kirill.png", "otsu"), lang="uzb_cyrl"))
    truth_lat = truth_of("03_uzb_lotin.png")
    sc = score(truth_lat, pred)
    print(f"  kirill OCR + translit vs lotin haqiqat: CER={sc['cer']:.3f} WER={sc['wer']:.3f}")
    sc2 = score(truth_lat, ocr_text(preprocess(S / "03_uzb_lotin.png", "otsu"), lang="uzb"))
    print(f"  to'g'ridan-to'g'ri lotin OCR:           CER={sc2['cer']:.3f} WER={sc2['wer']:.3f}")
    print("  farq: imlo (йил/yil, 2024 йил/2024-yil) va translit qoidalari, OCR emas —", confusions(truth_lat, pred, 5))
Chiqish
  w_ih = torch.quantize_per_tensor(

== lotin (5 sahifa)
  Tesseract              CER=0.017  WER=0.083    1.0 s/sahifa   xatolar: .->:(4),  ->-(3), .->,(3), №->Ne(2)
                        sahifalar: 03_uzb_lotin=0.002  05_telefon_s=0.002  06_qiyshiq.p=0.003  11_past_dpi.=0.002  12_dogli.png=0.076
  EasyOCR (en/ru)        CER=0.120  WER=0.273    5.9 s/sahifa   xatolar: .->:(21), o->0(16), №->Ne(4), ,->;(4)
                        sahifalar: 03_uzb_lotin=0.024  05_telefon_s=0.036  06_qiyshiq.p=0.465  11_past_dpi.=0.015  12_dogli.png=0.060
  VLM                    CER=0.394  WER=0.432    5.0 s/sahifa   xatolar: o->о(5), ji->жи(3), va->ва(2), z->з(2)
                        sahifalar: 03_uzb_lotin=0.013  05_telefon_s=0.004  06_qiyshiq.p=0.001  11_past_dpi.=1.087  12_dogli.png=0.863

== kirill (3 sahifa)
  Tesseract              CER=0.225  WER=0.290    0.8 s/sahifa   xatolar: ·->:(2), №->М(1), ь->ъ(1)
                        sahifalar: 04_uzb_kiril=0.002  14_gazeta_1.=0.015  14_gazeta_3.=0.657
  EasyOCR (en/ru)        CER=0.502  WER=0.712    6.8 s/sahifa   xatolar: қ->к(11), ҳ->х(11), ў->у(11), ў->й(3)
                        sahifalar: 04_uzb_kiril=0.055  14_gazeta_1.=0.736  14_gazeta_3.=0.715
  VLM                    CER=0.171  WER=0.345    3.9 s/sahifa   xatolar: ҳ->х(8), ў->у(7), қ->к(6), ў->ү(4)
                        sahifalar: 04_uzb_kiril=0.264  14_gazeta_1.=0.162  14_gazeta_3.=0.086

== Translit tajribasi: kirill OCR -> lotin, lotin haqiqat bilan solishtirish
  kirill OCR + translit vs lotin haqiqat: CER=0.174 WER=0.245
  to'g'ridan-to'g'ri lotin OCR:           CER=0.002 WER=0.007
  farq: imlo (йил/yil, 2024 йил/2024-yil) va translit qoidalari, OCR emas — [(('-', ' '), 4), (('H', 'h'), 1), (('№', 'M'), 1)]
  • Lotin, o'rtacha: Tesseract 0.017, EasyOCR 0.120, VLM 0.394. Lekin o'rtacha aldaydi, sahifalar qatoriga qarang. VLM toza skan, telefon surati va hatto 3° qiyshiq sahifada 0.001–0.013 (Tesseract darajasida, deskew siz). ~50 DPI (11) va dog'li (12) sahifalarda esa 1.09 va 0.86: matn kirillga o'tib ketadi (o→о, ji→жи) yoki to'qiladi. Qat'iy prompt toza rasmda ishladi, buzilgan rasmda ishlamadi. VLM past sifatli rasmni "taxmin" qiladi, 6-bob aynan shuni aytgan edi. EasyOCR ning o'z zaif joyi: qiyshiq sahifa (06) 0.465, deskew siz neyron detection ham adashadi.
  • Kirill, o'rtacha: Tesseract 0.225, EasyOCR 0.502, VLM 0.171. Yana sahifalarga qarang: toza kirill skanda (04) Tesseract 0.002, VLM 0.264 (ў қ ғ ҳ → у к х, rus harflariga), EasyOCR 0.055 (ў қ ғ ҳ hammasi yo'q). Gazeta 3-sahifada esa teskari: Tesseract 0.657 (ustunlar birlashgan), VLM 0.086, chunki VLM layout ni tushunadi. Xulosa: o'zbek kirill harflari uchun Tesseract uzb_cyrl, murakkab layout uchun VLM yoki 6-loyihadagi ustun ajratish.
  • Translit. Kirill OCR + translit ni lotin haqiqat bilan solishtirganda CER 0.17, lekin xatolar OCR emas: imlo (2024 йил / 2024-yil, №). Xulosa: transliteratsiya alifbo almashtirish, imlo emas; lotin hujjat kerak bo'lsa lotin OCR.
MezonBall
Ground truth to'plami (20 lotin + 20 kirill) va evaluate25
3 dvigatel × 2 alifbo × (skan/surat) jadvali25
Belgi xatolari tahlili (confusion)20
Translit tajribasi va xulosa15
2 sahifalik hisobot15
Kengaytma: Tesseract ni uzb uchun fine-tune (tesstrain), CER qancha tushdi+10

6-loyiha: Eski gazeta arxivi

Maqsad. Sarg'aygan, ikki ustunli, orqa tomoni ko'rinadigan gazeta sahifalari → toza, qidiriladigan matn. Uch muammo: og'ir preprocessing, ustunli layout, takrorlanadigan kolontitul.

Dizayn. (1) Preprocessing zanjiri, har qadamdan keyin CER: qaysi qadam foyda, qaysi zarar. (2) Tesseract avtomatik layout ni tekshirish: 11-bob 9-labda 3 sahifadan 2 tasida ikki ustun bitta bo'lib o'qilgan (CER 0.61, 0.66). Yechim: vertikal proyeksiya (har x da nechta qora piksel) bilan ustunlar orasidagi eng keng oq bo'shliqni topib, sahifani sarlavha + chap + o'ng ga bo'lish, har bo'lakni PSM 6 bilan. (3) Sahifalar orasida takrorlangan qatorlar = kolontitul.

archive.py

python115 qator
# archive.py — 6-loyiha: eski gazeta (sarg'aygan, ikki ustun, orqa tomon ko'rinadi) -> toza matn.
#   1) preprocessing zanjiri, har qadamdan keyin CER    2) ustunlarni o'zimiz ajratamiz (proyeksiya)
#   3) sahifalar orasida takrorlangan qatorlar (kolontitul) olib tashlanadi   4) txt + metadata.json
import json
import re
import sys
from pathlib import Path

import cv2
import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import clean_ocr_text, deskew, ocr_text, ocr_with_confidence, score, to_gray  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S, G, OUT = ROOT / "samples", ROOT / "ground_truth", Path("out")
OUT.mkdir(exist_ok=True)
LANG = "uzb_cyrl"
PAGES = ["14_gazeta_1.png", "14_gazeta_2.png", "14_gazeta_3.png"]


# ---------- 1) zanjir ----------
def chain(gray):
    """Har qadam (nom, rasm) qaytaradi — qaysi qadam foyda berganini o'lchash uchun."""
    steps = [("kulrang", gray)]
    med = cv2.medianBlur(gray, 3)
    steps.append(("+ medianBlur(3)", med))
    _, otsu = cv2.threshold(med, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    steps.append(("+ Otsu", otsu))
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
    opened = cv2.morphologyEx(otsu, cv2.MORPH_CLOSE, kernel)     # oq fon ichidagi mayda qora nuqtalar (bleed qoldig'i)
    steps.append(("+ morph CLOSE 2x2", opened))
    steps.append(("+ deskew", deskew(opened)))
    return steps


# ---------- 2) ustunlar ----------
def split_columns(binary, min_gap=40, margin=0.15, header=0.08, footer=0.92):
    """Sahifa: sarlavha tasmasi (ikki ustunga cho'zilgan) + tana (ustunlar) + sahifa raqami.
    Tana uchun vertikal proyeksiya: har x da nechta qora piksel; o'rtadagi eng keng oq bo'shliq = ustunlar orasi.
    Qaytaradi: [sarlavha, chap ustun, o'ng ustun] (yoki ustun topilmasa [sarlavha, tana])."""
    h, w = binary.shape
    head, body = binary[:int(h * header), :], binary[int(h * header):int(h * footer), :]
    ink = (body < 128).sum(axis=0)
    lo, hi = int(w * margin), int(w * (1 - margin))
    cur_start, gaps = None, []
    for x in range(lo, hi):
        if ink[x] == 0:
            cur_start = x if cur_start is None else cur_start
        elif cur_start is not None:
            gaps.append((x - cur_start, cur_start, x))
            cur_start = None
    if not gaps or max(gaps)[0] < min_gap:
        return [head, body]
    _, a, b = max(gaps)
    mid = (a + b) // 2
    return [head, body[:, :mid], body[:, mid:]]


def ocr_columns(binary):
    parts = split_columns(binary)
    texts = [ocr_text(p, lang=LANG, psm=6) for p in parts]          # har bo'lak: bitta yaxlit blok
    return "\n\n".join(t.strip() for t in texts if t.strip()), len(parts) - 1


# ---------- 3) kolontitul ----------
def remove_repeated_lines(pages_text, min_pages=2):
    norm = lambda s: re.sub(r"\W+", "", s).lower()
    count = {}
    for text in pages_text:
        for ln in {norm(l) for l in text.splitlines() if l.strip()}:
            count[ln] = count.get(ln, 0) + 1
    repeated = {k for k, v in count.items() if v >= min_pages and len(k) > 10}
    out = []
    for text in pages_text:
        kept = [l for l in text.splitlines() if norm(l) not in repeated]
        out.append("\n".join(kept))
    return out, repeated


if __name__ == "__main__":
    print("1) Preprocessing zanjiri, har qadamdan keyingi CER (PSM 3, avtomatik layout):")
    print(f"   {'qadam':20s}" + "".join(f"{p[:-4]:>14s}" for p in PAGES) + "    o'rtacha")
    chains = {p: chain(to_gray(S / p)) for p in PAGES}
    means = []
    for i, (name, _) in enumerate(chains[PAGES[0]]):
        row = [score((G / (p[:-4] + ".txt")).read_text(encoding="utf-8"), ocr_text(chains[p][i][1], lang=LANG))["cer"]
               for p in PAGES]
        means.append(sum(row) / len(row))
        print(f"   {name:20s}" + "".join(f"{c:14.3f}" for c in row) + f"{means[-1]:12.3f}")
    best = min(range(len(means)), key=means.__getitem__)
    print(f"   -> eng yaxshi qadam: '{chains[PAGES[0]][best][0]}'. Undan keyingi qadamlar zarar qildi, ularni tashlaymiz.")
    finals = {p: chains[p][best][1] for p in PAGES}

    print("\n2) Ustunlarni proyeksiya bilan ajratish (PSM 6 har ustunga) vs avtomatik (PSM 3):")
    pages_text, meta = [], []
    for p in PAGES:
        truth = (G / (p[:-4] + ".txt")).read_text(encoding="utf-8")
        auto = ocr_text(finals[p], lang=LANG)
        manual, ncol = ocr_columns(finals[p])
        _, conf = ocr_with_confidence(finals[p], lang=LANG)
        print(f"   {p}: auto CER={score(truth, auto)['cer']:.3f}   ustunlar={ncol} CER={score(truth, manual)['cer']:.3f}   conf={conf:.1f}")
        pages_text.append(manual)
        meta.append({"file": p, "columns": ncol, "confidence": round(conf, 1)})

    print("\n3) Takrorlangan qatorlar (kolontitul) va sahifa raqamlari:")
    cleaned, repeated = remove_repeated_lines(pages_text)
    print("   olib tashlandi:", [r[:40] for r in repeated])
    for p, text, m in zip(PAGES, cleaned, meta):
        final = clean_ocr_text(text)
        (OUT / (p[:-4] + ".txt")).write_text(final, encoding="utf-8")
        m["chars"] = len(final)
    (OUT / "metadata.json").write_text(json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8")
    print("   yozildi:", sorted(f.name for f in OUT.iterdir()))
    print("   1-sahifa boshi:", repr(clean_ocr_text(cleaned[0])[:100]))
Chiqish
1) Preprocessing zanjiri, har qadamdan keyingi CER (PSM 3, avtomatik layout):
   qadam                  14_gazeta_1   14_gazeta_2   14_gazeta_3    o'rtacha
   kulrang                      0.022         0.615         0.663       0.433
   + medianBlur(3)              0.023         0.617         0.663       0.434
   + Otsu                       0.017         0.610         0.657       0.428
   + morph CLOSE 2x2            0.046         0.612         0.659       0.439
   + deskew                     0.046         0.612         0.659       0.439
   -> eng yaxshi qadam: '+ Otsu'. Undan keyingi qadamlar zarar qildi, ularni tashlaymiz.

2) Ustunlarni proyeksiya bilan ajratish (PSM 6 har ustunga) vs avtomatik (PSM 3):
   14_gazeta_1.png: auto CER=0.017   ustunlar=2 CER=0.017   conf=94.0
   14_gazeta_2.png: auto CER=0.610   ustunlar=2 CER=0.024   conf=93.7
   14_gazeta_3.png: auto CER=0.657   ustunlar=2 CER=0.026   conf=93.5

3) Takrorlangan qatorlar (kolontitul) va sahifa raqamlari:
   olib tashlandi: ['тошкентоқшоми1994йил12мартм2481123']
   yozildi: ['14_gazeta_1.txt', '14_gazeta_2.txt', '14_gazeta_3.txt', 'metadata.json']
   1-sahifa boshi: 'Шаҳар ҳокимлиги янги йўл қурилиши тўғрисида қарор қабул қилди. Чилонзор туманидаги марказий кўчалард'
  • Zanjir. Otsu eng yaxshi qadam. Undan keyingi morphologyEx zarar qildi (0.017 → 0.046): 2×2 yadro mayda dog'lar bilan birga harf chiziqlarini ham yeydi. Kitob 3-bobda "orqa tomon ko'rinishi → morph OPEN" deydi; bu sahifada median + Otsu bleed-through ni o'zi yo'qotgan, morph ortiqcha. Har qadamni o'lchash shuning uchun.
  • Ustunlar. 1-sahifa: auto 0.017 = manual 0.017. 2- va 3-sahifa: auto 0.61 va 0.66, manual 0.024 va 0.026. Bir xil dizayn, uch sahifa, Tesseract ikkitasida adashdi. Ustunlarni o'zingiz ajratsangiz natija barqaror.
  • Kolontitul. Faqat sarlavha qatori ("ТОШКЕНТ ОҚШОМИ · 1994 йил...") takrorlangan va olib tashlangan. Birinchi versiyada 3 sahifa bir xil maqolalar bilan yasalgan edi va detektor butun matnni "kolontitul" deb o'chirdi: test ma'lumoti real bo'lishi kerak.
MezonBall
Zanjir tajribasi jadvali, har qadam CER bilan30
Ustun ajratish: auto vs manual, 3+ sahifa20
Kolontitul detektori15
Chiqish: txt + metadata.json15
VLM bilan eng yomon 3 sahifa solishtiruvi10
Kod10
Kengaytma: 1990-yillar imlosi uchun lug'at post-processing+10

7-loyiha: Capstone, skanerlangan arxiv → qidiruv tizimi

Maqsad. Aralash hujjatlar to'plami (digital PDF, skan, telefon surati, jadval, gazeta) → 10-bob pipeline → SQLite (matn + metadata + FTS5 indeks) → qidiruv interfeysi. Javobda manba sahifa, extraction_method va confidence belgisi. Admin uchun "ko'rib chiqish" navbati. Bu butun kitobning amaldagi ko'rinishi.

Dizayn. To'rt fayl, har biri bitta vazifa. db.py: sxema, FTS5 (unicode61 tokenizer kirill va lotinni bir xil ishlaydi), snippet() va bm25(). ingest.py: har fayl → sahifalar → Otsu, past bo'lsa adaptive, darvoza manual_review desa va VLM bor bo'lsa VLM (asl rasm bilan) → gate bilan bazaga. search.py: buyruq qatori. app.py: Streamlit, ikki tab.

db.py

python62 qator
# db.py — 7-loyiha: SQLite + FTS5. pages jadvali (matn + metadata) va to'liq matnli indeks.
import sqlite3
from datetime import datetime

SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
    id INTEGER PRIMARY KEY, filename TEXT UNIQUE, pages INTEGER, created TEXT);
CREATE TABLE IF NOT EXISTS pages (
    id INTEGER PRIMARY KEY, doc_id INTEGER REFERENCES documents(id), page_num INTEGER,
    text TEXT, method TEXT, confidence REAL, gate TEXT, review_status TEXT DEFAULT 'none');
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
    text, content='pages', content_rowid='id', tokenize='unicode61 remove_diacritics 0');
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
    INSERT INTO pages_fts(rowid, text) VALUES (new.id, new.text); END;
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
    INSERT INTO pages_fts(pages_fts, rowid, text) VALUES ('delete', old.id, old.text); END;
"""


def connect(path="archive.db"):
    con = sqlite3.connect(path)
    con.row_factory = sqlite3.Row
    con.executescript(SCHEMA)
    return con


def add_document(con, filename, n_pages):
    con.execute("DELETE FROM pages WHERE doc_id IN (SELECT id FROM documents WHERE filename=?)", (filename,))
    con.execute("DELETE FROM documents WHERE filename=?", (filename,))
    cur = con.execute("INSERT INTO documents(filename, pages, created) VALUES (?,?,?)",
                      (filename, n_pages, datetime.now().isoformat(timespec="seconds")))
    return cur.lastrowid


def add_page(con, doc_id, page_num, text, method, confidence, gate):
    con.execute("INSERT INTO pages(doc_id, page_num, text, method, confidence, gate) VALUES (?,?,?,?,?,?)",
                (doc_id, page_num, text, method, confidence, gate))


def search(con, query, limit=10, min_conf=0):
    """FTS5 MATCH + snippet. min_conf: faqat ishonchli sahifalar (8-bob: filtering)."""
    sql = """SELECT p.id, d.filename, p.page_num, p.method, p.confidence, p.gate,
                    snippet(pages_fts, 0, '[', ']', '…', 12) AS snip, bm25(pages_fts) AS rank
             FROM pages_fts JOIN pages p ON p.id = pages_fts.rowid JOIN documents d ON d.id = p.doc_id
             WHERE pages_fts MATCH ? AND p.confidence >= ? ORDER BY rank LIMIT ?"""
    return con.execute(sql, (query, min_conf, limit)).fetchall()


def flagged(con):
    return con.execute("""SELECT p.id, d.filename, p.page_num, p.method, p.confidence, p.gate, p.review_status, substr(p.text, 1, 300) AS preview
                          FROM pages p JOIN documents d ON d.id = p.doc_id
                          WHERE p.gate != 'accept' ORDER BY p.confidence""").fetchall()


def set_review(con, page_id, status):
    con.execute("UPDATE pages SET review_status=? WHERE id=?", (status, page_id))
    con.commit()


def stats(con):
    return con.execute("""SELECT COUNT(*) AS pages, SUM(method='text_layer') AS text_layer,
                                 SUM(method LIKE 'ocr%') AS ocr, SUM(gate!='accept') AS flagged FROM pages""").fetchone()

ingest.py

python108 qator
# ingest.py — 7-loyiha: samples/ dagi PDF va rasmlar -> 10-bob pipeline -> SQLite.
#   python ingest.py                 # hamma faylni qayta o'qiydi, archive.db ni yangilaydi
import base64
import io
import json
import os
import sys
import time
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from ocrlib import clean_ocr_text, ocr_stats, pdf_page_count, pdf_page_text, preprocess, quality_gate, render_pdf_page  # noqa: E402
import db  # noqa: E402

ROOT = Path(__file__).resolve().parents[2]
S = ROOT / "samples"
MIN_CHARS = 100
HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
VLM_MODEL = os.environ.get("VLM_MODEL", "qwen2.5vl:7b")
VLM_PROMPT = ("Rasmdagi matnni AYNAN yozilganidek ko'chir: qaysi alifboda bo'lsa shu alifboda, tarjima qilma, "
              "imloni tuzatma. Qator va paragraflarni saqla. Markdown, ``` qavslar, izoh qo'shma. "
              "Aniq o'qiy olmagan joyni [NOANIQ] deb belgila, taxmin qilma.")


def vlm_ready():
    try:
        tags = json.load(urllib.request.urlopen(f"{HOST}/api/tags", timeout=3))
        return any(m["name"] == VLM_MODEL for m in tags["models"])
    except Exception:
        return False


def vlm_ocr(img):
    """10-bob vlm_ocr: asl rasm (binar emas) VLM ga."""
    from openai import OpenAI
    client = OpenAI(base_url=f"{HOST}/v1", api_key="ollama")
    im = img if hasattr(img, "save") else __import__("PIL.Image").Image.open(img)
    im = im.convert("RGB")
    im.thumbnail((1600, 1600))
    buf = io.BytesIO()
    im.save(buf, format="PNG")
    b64 = base64.b64encode(buf.getvalue()).decode()
    r = client.chat.completions.create(model=VLM_MODEL, temperature=0, messages=[{"role": "user", "content": [
        {"type": "text", "text": VLM_PROMPT},
        {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])
    text = r.choices[0].message.content.strip()
    return text.strip("`").removeprefix("markdown").strip()


def lang_for(name):
    if "kirill" in name or "gazeta" in name:
        return "uzb_cyrl"
    if "rus" in name:
        return "rus"
    return "uzb"


def ocr_image(img, lang, use_vlm):
    """Otsu -> (past bo'lsa) adaptive -> (darvoza 'manual_review' desa va VLM bo'lsa) VLM. 7- va 10-boblar."""
    best = None
    for mode in ("otsu", "adaptive"):
        text, st = ocr_stats(preprocess(img, mode), lang=lang)
        if best is None or st["low_ratio"] < best[1]["low_ratio"]:
            best = (text, st, mode)
        if quality_gate("ocr_tesseract", st) == "accept":
            break
    text, st, mode = best
    g = quality_gate("ocr_tesseract", st)
    # qaytish tartibi extract() bilan bir xil: (text, method, conf, gate, stats)
    if g == "manual_review" and use_vlm:
        return clean_ocr_text(vlm_ocr(img)), "ocr_vlm", -1.0, "accept_with_flag", st
    return clean_ocr_text(text), "ocr_tesseract", st["conf"], g, st


def extract(path, use_vlm):
    """Fayl -> [(page_num, text, method, conf, gate, stats)] — 10-bob extract_pdf, rasmlar bilan."""
    lang = lang_for(path.name)
    if path.suffix.lower() == ".pdf":
        out = []
        for i in range(1, pdf_page_count(path) + 1):
            layer = pdf_page_text(path, i).strip()
            if len(layer) >= MIN_CHARS:
                out.append((i, layer, "text_layer", 100.0, "accept", {}))
            else:
                out.append((i, *ocr_image(render_pdf_page(path, i, dpi=200), "rus" if i == 2 else lang, use_vlm)))
        return out
    from PIL import Image
    return [(1, *ocr_image(Image.open(path), lang, use_vlm))]


if __name__ == "__main__":
    use_vlm = vlm_ready()
    print("VLM fallback: " + (f"yoqilgan ({VLM_MODEL})" if use_vlm else "yo'q"))
    con = db.connect("archive.db")
    files = sorted(p for p in S.iterdir() if p.suffix.lower() in (".pdf", ".png", ".jpg"))
    t0 = time.perf_counter()
    for path in files:
        pages = extract(path, use_vlm)
        doc_id = db.add_document(con, path.name, len(pages))
        for page_num, text, method, conf, g, st in pages:
            if text.strip():
                db.add_page(con, doc_id, page_num, text, method, round(conf, 1), g)
        con.commit()
        desc = ", ".join(f"{m}({c:.0f}, low={st.get('low_ratio', 0):.0%}, {g})" for _, _, m, c, g, st in pages)
        print(f"  {path.name:22s} {desc}")
    s = db.stats(con)
    print(f"\nJami {time.perf_counter()-t0:.0f} s: {s['pages']} sahifa, text_layer={s['text_layer']}, ocr={s['ocr']}, flagged={s['flagged']}")

search.py

python21 qator
# search.py — 7-loyiha: buyruq qatoridan qidiruv (FTS5: bir nechta so'z = hammasi bo'lishi shart, OR ham bo'ladi).
#   python search.py "ofis jihozlari"             python search.py "ҳокимлиги" --min-conf 70
#   python search.py "Nestle OR Kartoshka"        python search.py "shartn*"      (prefiks)
import argparse

import db

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("query")
    ap.add_argument("--min-conf", type=float, default=0)
    ap.add_argument("--limit", type=int, default=5)
    args = ap.parse_args()
    con = db.connect("archive.db")
    rows = db.search(con, args.query, args.limit, args.min_conf)
    print(f"'{args.query}': {len(rows)} natija (min_conf={args.min_conf})")
    for r in rows:
        badge = "✅" if r["gate"] == "accept" else "⚠️"
        print(f"  {badge} {r['filename']} sahifa {r['page_num']}  [{r['method']}, conf={r['confidence']:.0f}]")
        print(f"     …{r['snip']}…")
    print("\nFlagged (admin ko'rigi):", [(f['filename'], f['page_num'], f['confidence']) for f in db.flagged(con)][:6])

app.py

python45 qator
# app.py — 7-loyiha: Streamlit interfeys. Qidiruv (confidence belgisi bilan) + Admin (flagged sahifalar).
#   streamlit run app.py
from pathlib import Path

import streamlit as st

import db

ROOT = Path(__file__).resolve().parents[2]
con = db.connect("archive.db")
st.set_page_config(page_title="Skanerlangan arxiv", layout="wide")
st.title("Skanerlangan arxiv — qidiruv")

tab_search, tab_admin = st.tabs(["Qidiruv", "Admin: ko'rib chiqish"])

with tab_search:
    q = st.text_input("So'rov", placeholder="masalan: shartnoma summa")
    min_conf = st.slider("Minimal confidence", 0, 100, 0, help="8-bob: faqat ishonchli manbalar")
    if q:
        rows = db.search(con, q, limit=10, min_conf=min_conf)
        st.caption(f"{len(rows)} natija")
        for r in rows:
            badge = "🟢" if r["gate"] == "accept" else "🟡" if r["gate"] == "accept_with_flag" else "🔴"
            st.markdown(f"{badge} **{r['filename']}**, sahifa {r['page_num']} · `{r['method']}` · conf {r['confidence']:.0f}")
            st.write("…" + r["snip"] + "…")
            if r["method"] != "text_layer":
                st.caption("Skanerlangan hujjatdan olindi, xato bo'lishi mumkin.")

with tab_admin:
    s = db.stats(con)
    st.write(f"Sahifalar: {s['pages']} · text layer: {s['text_layer']} · OCR: {s['ocr']} · ko'rib chiqish kerak: {s['flagged']}")
    for f in db.flagged(con):
        with st.expander(f"{f['filename']} sahifa {f['page_num']} — {f['gate']} (conf {f['confidence']:.0f}) [{f['review_status']}]"):
            col1, col2 = st.columns(2)
            img = ROOT / "samples" / f["filename"]
            if img.suffix.lower() in (".png", ".jpg"):
                col1.image(str(img), width=350)
            col2.text(f["preview"])
            c1, c2 = col2.columns(2)
            if c1.button("Tasdiqlash", key=f"ok{f['id']}"):
                db.set_review(con, f["id"], "approved")
                st.rerun()
            if c2.button("Qayta skanerlash kerak", key=f"re{f['id']}"):
                db.set_review(con, f["id"], "rescan")
                st.rerun()

Chiqish: ingest (VLM fallback bilan) va uchta qidiruv

python47 qator
VLM fallback: yoqilgan (qwen2.5vl:7b)
  01_digital.pdf         text_layer(100, low=0%, accept)
  02_skan_rus.pdf        ocr_tesseract(95, low=0%, accept)
  02_skan_rus.png        ocr_tesseract(95, low=0%, accept)
  03_uzb_lotin.png       ocr_tesseract(95, low=1%, accept)
  04_uzb_kirill.png      ocr_tesseract(94, low=1%, accept)
  05_telefon_soya.jpg    ocr_tesseract(96, low=0%, accept)
  06_qiyshiq.png         ocr_tesseract(95, low=1%, accept)
  07_jadval.png          ocr_tesseract(91, low=5%, accept_with_flag)
  09_aralash.pdf         text_layer(100, low=0%, accept), ocr_tesseract(95, low=0%, accept), ocr_tesseract(93, low=3%, accept)
  10_chek_1.jpg          ocr_tesseract(94, low=0%, accept)
  10_chek_2.jpg          ocr_tesseract(93, low=0%, accept)
  10_chek_3.jpg          ocr_tesseract(94, low=0%, accept)
  11_past_dpi.png        ocr_tesseract(95, low=0%, accept)
  12_dogli.png           ocr_vlm(-1, low=12%, accept_with_flag)
  13_xira_raqam.png      ocr_tesseract(95, low=1%, accept)
  14_gazeta_1.png        ocr_tesseract(94, low=3%, accept)
  14_gazeta_2.png        ocr_tesseract(94, low=3%, accept_with_flag)
  14_gazeta_3.png        ocr_tesseract(94, low=0%, accept)
  15_juda_xira.jpg       ocr_vlm(-1, low=12%, accept_with_flag)

Jami 33 s: 21 sahifa, text_layer=2, ocr=19, flagged=4
'ofis jihozlari': 1 natija (min_conf=0)
  ⚠️ 07_jadval.png sahifa 1  [ocr_tesseract, conf=91]
     …[Ofis] [jihozlari] — narxlar ro'yxati

Monitor 24 dyuym 1 850 000 7……

Flagged (admin ko'rigi): [('12_dogli.png', 1, -1.0), ('15_juda_xira.jpg', 1, -1.0), ('07_jadval.png', 1, 91.4), ('14_gazeta_2.png', 1, 93.9)]
'ҳокимлиги': 1 natija (min_conf=70.0)
  ✅ 14_gazeta_1.png sahifa 1  [ocr_tesseract, conf=94]
     ……Ме 48 (1123)

Шаҳар [ҳокимлиги] янги йўл қурилиши тўғрисида қарор қабул қилди……

Flagged (admin ko'rigi): [('12_dogli.png', 1, -1.0), ('15_juda_xira.jpg', 1, -1.0), ('07_jadval.png', 1, 91.4), ('14_gazeta_2.png', 1, 93.9)]
'Nestle OR Kartoshka': 2 natija (min_conf=0)
  ✅ 10_chek_2.jpg sahifa 1  [ocr_tesseract, conf=93]
     ……02.03.2024 69

Guruch lazer 1Кд Yog' IL [Kartoshka] 1Кд

Sabzi……
  ✅ 10_chek_1.jpg sahifa 1  [ocr_tesseract, conf=94]
     ……x 4 500 Sut IL [Nestle] 2 х 14 900 Tuxum 10……

Flagged (admin ko'rigi): [('12_dogli.png', 1, -1.0), ('15_juda_xira.jpg', 1, -1.0), ('07_jadval.png', 1, 91.4), ('14_gazeta_2.png', 1, 93.9)]
  • Darvoza ishlayapti. 21 sahifadan 4 tasi belgilandi. Juda xira surat (15) va dog'li skan (12) past-conf ulushi 12% bilan manual_review ga tushdi va VLM bilan qayta o'qildi (ocr_vlm, har doim accept_with_flag, admin navbatida). Jadval (07, past-conf 5%) va gazeta 2 (3%) accept_with_flag: qidiruv natijasida ⚠️ bilan chiqadi. Toza skanlar va cheklar accept.
  • Darvoza ushlamaydigan narsa. Gazeta 3: conf 94, past-conf 0%, accept, matni esa ikki ustun aralashgan holda (11-bob 9-lab: CER 0.66). Xira band tashlab yuborilgan sahifa (13): conf 95, accept, 16 ta so'z yo'q. Confidence layout xatosini va yo'qolgan matnni ko'rmaydi. Yechim: hujjat turini bilish (jadval → 4-loyiha yo'li, gazeta → 6-loyiha yo'li), so'zlar sonini kutilgan bilan solishtirish, yoki VLM bilan ikkinchi o'qish. Bu capstone ning ochiq vazifasi.
  • FTS5. Bir nechta so'z = hammasi shu sahifada bo'lishi shart ("shartnoma summa" 0 natija berdi, chunki matnda "summa" so'zi yo'q, "qiymati" bor). OR va prefiks (shartn*) bor. Embedding qidiruv (RAG kursi) shu yerga ulanadi: "summa" so'rovi "qiymati" ni topadi.
  • Streamlit ilovasi bu mashinada --server.headless bilan ishga tushdi (HTTP 200, health ok). Admin tabida flagged sahifalar rasm va matn bilan, ikkita tugma: tasdiqlash / qayta skanerlash.

Ishga tushirish

bash4 qator
cd projects/p7_capstone
python ingest.py                          # samples/ -> archive.db (VLM: OLLAMA_HOST, VLM_MODEL muhitdan)
python search.py "ofis jihozlari"
streamlit run app.py                      # http://localhost:8501
HaftaIshNatija
1ingest: sahifama-sahifa, PageResult → SQLite, flagged jadvali; 20 sahifa ground truth da CERarchive.db, CER hisoboti
2qidiruv: FTS5 (yoki pgvector, RAG kursi); tozalangan matnda chunking; natijada method + confsearch.py
3interfeys: qidiruv sahifasi, admin navbati, min-conf filtri; 20 savol → recall@5, skan sahifalar bilan va ularsizapp.py, hisobot
MezonBall
Pipeline to'liq, sahifama-sahifa, VLM fallback25
Qidiruv ishlaydi, metadata ko'rinadi25
Admin review oqimi15
CER va recall hisoboti20
Kod tuzilishi, README15
Kengaytma: VLM sahifalarini ikki marta o'qitib solishtirish; 3-loyiha boti shu bazaga yozadi+10

Hamma loyiha uchun umumiy talablar

  • README: nima qiladi, qanday ishga tushiriladi, qaysi bob qaysi funksiyada.
  • Kod alohida fayllarda (preprocess, ocr, evaluate, main), 800 qatorli bitta fayl emas.
  • Har raqam qayta hisoblanadigan: python evaluate.py bitta buyruq bilan jadvalni chiqarsin.
  • Maxfiylik: shaxsiy hujjat repo ga kirmaydi, .gitignore da samples/private/.
  • Himoya 10 daqiqa: istalgan funksiyani tushuntirish, jonli ishga tushirish, bitta parametrni o'zgartirib natijani bashorat qilish.

Xulosa

LoyihaEng muhim raqam
1 CLIChek 2: otsu 0.29, adaptive 0.05 — bitta global sozlama yo'q
2 ChekSana/INN/summa: regex 3/3; mahsulotlar: VLM yaxshi, lekin confidence siz
3 Botconf 84 va CER 0.10 bir vaqtda: maslahat past-conf ulushiga qarasin
4 JadvalChiziqlar olib tashlangach Tesseract 24/26; VLM 26/26
5 BenchmarkKirill: EasyOCR va VLM ў қ ғ ҳ ni yo'qotadi; Tesseract uzb_cyrl saqlaydi
6 GazetaAuto layout 3 dan 2 sahifada adashdi (0.61, 0.66); o'z column-split 0.02
7 CapstoneDarvoza xira sahifani ushladi, jadval va ustun xatosini ushlamadi

Bu bobni bepul o'qidingiz.

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

Telegramda yozish Narxlar