TurkceOCR / turkceocr /data /pdf_and_table_engine.py
ersanbil's picture
Release TurkceOCR v2.0 (Apache-2.0)
c8b6043
Raw History Blame Contribute Delete
20.5 kB
"""
TürkçeOCR — İleri Düzey Karmaşık Muhasebe Tablosu (Çoklu KDV %1/%10/%20, İskonto,
Birleşik Hücre, Çizgisiz/Zebra/Izgara Stilleri) ve Born-Digital Türkçe PDF -> Bozulmuş
İkiz (`BornDigitalPDFPairGenerator`) Üretim Motoru.
Bu modül:
1. `ComplexTurkishTableRenderer`:
- 5–7 sütunlu (`Sıra`, `Stok Kodu`, `Kalem / Hizmet Açıklaması`, `Miktar`, `İskonto`,
`KDV Oranı (%1, %10, %20)`, `Tutar (TL)`) yoğun kurumsal tabloları,
- `full_grid` (tam ızgara), `horizontal_only` (dikey çizgisiz modern ERP faturası) ve
`zebra_striped` (satır dolgulu) olmak üzere 3 farklı görsel tablo stilinde çizer,
- Alt kısımda **Çoklu KDV Matrah ve Tutar Dökümü Tablosu** oluşturur.
2. `BornDigitalPDFPairGenerator`:
- Gerçek born-digital Türkçe PDF sayfalarını (eğer ortamda `pymupdf`/`fitz` veya `pypdf`
mevcutsa doğrudan PDF'den, değilse çift sütunlu akademik/mevzuat PDF mizanpaj
motorundan) yüksek çözünürlüklü görüntüye çevirir,
- Metin bloklarının koordinatlarını (`bbox`) çıkarıp `TurkceDegrade` ile tam metin/tablo
üzerine ıslak kaşe/mühür/imza basarak `(Bozulmuş_Görüntü, Temiz_Görüntü, Markdown,
SURR_Kaşe_Altı_Metni, Token_Maskesi, Satır_Kırpımları)` ikizini üretir.
"""
from __future__ import annotations
import io
import random
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from PIL import Image, ImageDraw
from turkceocr.data.degradation import TurkceDegrade
from turkceocr.data.stamp_signature import _load_turkish_font
from turkceocr.data.synthetic_generator import (
AGGLUTINATIVE_HARD_LEXICON,
SyntheticDocumentSample,
TurkishDocumentGenerator,
)
from turkceocr.postprocess.financial_solver import format_turkish_amount
ERP_LINE_ITEMS_POOL = [
("STK-0104", "Kurumsal Türkçe Görsel-Dil Modeli (VLM) Sunucu Lisansı", "Adet", 0.20),
("STK-0219", "Yüksek Çözünürlüklü Belge Tarama ve Arşivleme Hizmeti", "Ay", 0.20),
("GDA-0842", "Organik Iğdır Kayısısı ve Kuruyemiş Kumanya Paketi", "Koli", 0.01),
("TKS-0511", "Personel İş Güvenliği Koruyucu Tekstil Ekipmanı", "Takım", 0.10),
("DNS-0930", "Sözleşmelerimizdeki Mevzuat Uyum ve Mali Müşavirlik Hizmeti", "Saat", 0.20),
("EGT-0315", "Yapılandırılmış Veri Analitiği ve Kurumsal Eğitim Programı", "Gün", 0.10),
("TLM-0774", "Temel Gıda Toptan Un ve Bakliyat Sevkiyatı", "Çuval", 0.01),
]
@dataclass
class ComplexTableItem:
"""Karmaşık muhasebe tablosundaki tek bir satır kalemi."""
index: int
sku_code: str
description: str
quantity: int
unit: str
unit_price: float
discount_rate: float # 0.00, 0.05, 0.10
vat_rate: float # 0.01, 0.10, 0.20
@property
def gross_amount(self) -> float:
return round(self.quantity * self.unit_price, 2)
@property
def discount_amount(self) -> float:
return round(self.gross_amount * self.discount_rate, 2)
@property
def net_matrah(self) -> float:
return round(self.gross_amount - self.discount_amount, 2)
@property
def vat_amount(self) -> float:
return round(self.net_matrah * self.vat_rate, 2)
class ComplexTurkishTableRenderer:
"""
`TEDS >= %96.5` (KPI-8) ve `CVKR >= %99.5` (KPI-10) hedefleri için çoklu KDV
(`%1, %10, %20`), iskonto ve 3 farklı tablo çizgi topolojisi (`full_grid`,
`horizontal_only`, `zebra_striped`) üreten yüksek entropili tablo motoru.
"""
def __init__(self, seed: Optional[int] = None) -> None:
self.rng = random.Random(seed)
self.doc_gen = TurkishDocumentGenerator(seed=seed)
self.degrader = TurkceDegrade(seed=seed)
def generate_multi_vat_erp_invoice(
self,
apply_degradation: bool = True,
is_ood: bool = False,
table_style: Optional[str] = None,
) -> SyntheticDocumentSample:
w, h = (920, 1140) if is_ood else (880, 1080)
clean_img = Image.new("RGB", (w, h), (254, 254, 252))
draw = ImageDraw.Draw(clean_img)
style = table_style or self.rng.choice(["full_grid", "horizontal_only", "zebra_striped"])
font_h1 = _load_turkish_font(19, style="bold", rng=self.rng)
font_bold = _load_turkish_font(13, style="bold", rng=self.rng)
font_reg = _load_turkish_font(12, style="sans", rng=self.rng)
font_mono = _load_turkish_font(12, style="mono", rng=self.rng)
tckn = self.doc_gen.generate_valid_tckn()
vkn = self.doc_gen.generate_valid_vkn()
iban = self.doc_gen.generate_valid_tr_iban()
num_rows = self.rng.randint(3, 5)
sampled_specs = self.rng.sample(ERP_LINE_ITEMS_POOL, k=num_rows)
items: List[ComplexTableItem] = []
for idx, (sku, desc, unit, vat_r) in enumerate(sampled_specs, start=1):
qty = self.rng.randint(1, 12)
u_price = round(self.rng.uniform(450.0, 4800.0), 2)
disc_r = self.rng.choice([0.0, 0.05, 0.10]) if is_ood else 0.0
items.append(
ComplexTableItem(
index=idx,
sku_code=sku,
description=desc,
quantity=qty,
unit=unit,
unit_price=u_price,
discount_rate=disc_r,
vat_rate=vat_r,
)
)
total_matrah = round(sum(it.net_matrah for it in items), 2)
total_kdv = round(sum(it.vat_amount for it in items), 2)
grand_total = round(total_matrah + total_kdv, 2)
header = (
"T.C. GELİR İDARESİ BAŞKANLIĞI — ÇOKLU KDV KURUMSAL ERP e-FATURA"
if is_ood
else "T.C. GELİR İDARESİ BAŞKANLIĞI — TİCARİ e-FATURA DETAY CETVELİ"
)
seller = "SATICI: ANADOLU ENDÜSTRİYEL BİLİŞİM VE LOJİSTİK A.Ş."
buyer = "ALICI: İSTANBUL ŞİŞLİ VE IĞDIR BÖLGE MÜDÜRLÜĞÜ"
line_bboxes: List[Tuple[Tuple[int, int, int, int], str]] = []
draw.text((36, 30), header, fill=(15, 15, 15), font=font_h1)
line_bboxes.append(((32, 26, w - 32, 60), header))
draw.line([(36, 64), (w - 36, 64)], fill=(30, 30, 30), width=2)
draw.text((36, 80), seller, fill=(20, 20, 20), font=font_bold)
draw.text((36, 104), f"VERGİ KİMLİK NO (VKN): {vkn} | BANKA IBAN: {iban}", fill=(20, 20, 20), font=font_reg)
draw.text((36, 130), f"{buyer} | TCKN: {tckn}", fill=(20, 20, 20), font=font_reg)
line_bboxes.append(((32, 100, w - 32, 126), f"VERGİ KİMLİK NO (VKN): {vkn} | BANKA IBAN: {iban}"))
# Tablo sütun X koordinatları
col_x = [36, 95, 185, 515, 605, 685, 765, w - 36]
headers = ["Sıra", "Stok Kodu", "Kalem / Hizmet Açıklaması", "Miktar", "İskonto", "KDV", "Net Tutar (TL)"]
table_top = 175
row_h = 38
table_bottom = table_top + (num_rows + 1) * row_h
# Başlık arka planı
draw.rectangle([col_x[0], table_top, col_x[-1], table_top + row_h], fill=(232, 236, 242))
for c_i, h_text in enumerate(headers):
draw.text((col_x[c_i] + 6, table_top + 10), h_text, fill=(10, 10, 10), font=font_bold)
md_table_rows = [
"| Sıra | Stok Kodu | Kalem / Hizmet Açıklaması | Miktar | İskonto | KDV | Net Tutar (TL) |",
"| :---: | :---: | :--- | :---: | :---: | :---: | ---: |",
]
for r_i, it in enumerate(items):
y_r = table_top + (r_i + 1) * row_h
if style == "zebra_striped" and r_i % 2 == 1:
draw.rectangle([col_x[0], y_r, col_x[-1], y_r + row_h], fill=(246, 247, 250))
if style in ("full_grid", "horizontal_only"):
draw.line([(col_x[0], y_r), (col_x[-1], y_r)], fill=(120, 120, 120), width=1)
qty_str = f"{it.quantity} {it.unit}"
disc_str = f"%{int(it.discount_rate * 100)}"
vat_str = f"%{int(it.vat_rate * 100)}"
net_str = format_turkish_amount(it.net_matrah)
cells = [
str(it.index),
it.sku_code,
it.description[:42],
qty_str,
disc_str,
vat_str,
net_str,
]
for c_i, cell_val in enumerate(cells):
f_use = font_mono if c_i in (1, 3, 4, 5, 6) else font_reg
draw.text((col_x[c_i] + 6, y_r + 10), cell_val, fill=(18, 18, 18), font=f_use)
line_bboxes.append(
((col_x[0], y_r + 2, col_x[-1], y_r + row_h - 2), f"{it.sku_code} {it.description} {net_str}")
)
md_table_rows.append(
f"| {it.index} | {it.sku_code} | {it.description} | {qty_str} | {disc_str} | {vat_str} | {net_str} |"
)
if style == "full_grid":
draw.rectangle([col_x[0], table_top, col_x[-1], table_bottom], outline=(40, 40, 40), width=2)
for cx in col_x[1:-1]:
draw.line([(cx, table_top), (cx, table_bottom)], fill=(130, 130, 130), width=1)
else:
draw.line([(col_x[0], table_top), (col_x[-1], table_top)], fill=(30, 30, 30), width=2)
draw.line([(col_x[0], table_top + row_h), (col_x[-1], table_top + row_h)], fill=(30, 30, 30), width=1)
draw.line([(col_x[0], table_bottom), (col_x[-1], table_bottom)], fill=(30, 30, 30), width=2)
# Kaşe altı hedef satırı (ortadaki tablo satırı)
mid_item = items[len(items) // 2]
underneath_text = mid_item.description
target_bbox = (col_x[2], table_top + row_h, col_x[6], table_bottom)
matrah_s = format_turkish_amount(total_matrah)
kdv_s = format_turkish_amount(total_kdv)
toplam_s = format_turkish_amount(grand_total)
y_sum = table_bottom + 35
sum_x = w - 360
draw.text((sum_x, y_sum), f"MATRAH (ARA TOPLAM): {matrah_s} TL", fill=(15, 15, 15), font=font_reg)
draw.text((sum_x, y_sum + 28), f"HESAPLANAN KDV: {kdv_s} TL", fill=(15, 15, 15), font=font_reg)
draw.text((sum_x, y_sum + 56), f"GENEL TOPLAM: {toplam_s} TL", fill=(10, 10, 10), font=font_bold)
line_bboxes.append(((sum_x - 4, y_sum + 52, w - 32, y_sum + 82), f"GENEL TOPLAM: {toplam_s} TL"))
final_img = clean_img
stamp_tags: List[str] = []
if apply_degradation:
deg = self.degrader.degrade(
clean_img,
preset="corporate_stamped",
target_text_bbox=target_bbox,
)
final_img = deg.image
stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures]
md_lines = [
f"# {header}",
f"**{seller}**",
f"VERGİ KİMLİK NO (VKN): <vkn>{vkn}</vkn>",
f"BANKA IBAN: <iban>{iban}</iban>",
f"**{buyer}**",
f"T.C. KİMLİK NO (TCKN): <tckn>{tckn}</tckn>",
"",
*md_table_rows,
"",
f"- **MATRAH (ARA TOPLAM):** {matrah_s} TL",
f"- **HESAPLANAN KDV:** {kdv_s} TL",
f"- **GENEL TOPLAM:** {toplam_s} TL",
]
if stamp_tags:
md_lines.append("")
md_lines.extend(stamp_tags)
markdown = "\n".join(md_lines)
plain = "\n".join(
[header, seller, f"VKN: {vkn}", f"IBAN: {iban}", buyer, f"TCKN: {tckn}"]
+ [f"{it.sku_code} {it.description} {format_turkish_amount(it.net_matrah)}" for it in items]
+ [f"MATRAH: {matrah_s} TL", f"KDV: {kdv_s} TL", f"GENEL TOPLAM: {toplam_s} TL"]
)
return SyntheticDocumentSample(
doc_type="multi_vat_erp_invoice",
image=final_img,
clean_image=clean_img,
ground_truth_markdown=markdown,
plain_text=plain,
stamp_underneath_text=underneath_text,
expected_kie={
"tckn": tckn,
"vkn": vkn,
"iban": iban,
"matrah": matrah_s,
"kdv": kdv_s,
"toplam": toplam_s,
},
is_ood=is_ood,
layout_variant=f"{'ood' if is_ood else 'id'}_erp_table_{style}",
line_crops=TurkishDocumentGenerator._crop_lines_from_image(final_img, line_bboxes),
)
class BornDigitalPDFPairGenerator:
"""
Born-Digital (vektörel metin katmanlı) Türkçe PDF sayfalarını veya çift sütunlu
akademik/mevzuat makale sayfalarını:
1. Yüksek çözünürlüklü temiz sayfa görüntüsüne (`clean_image`),
2. Tam okuma sıralı (`RO-NED` uyumlu) yapısal Markdown metnine,
3. Hedef paragraf/tablo üzerine `TurkceDegrade` ile kaşe/imza basılmış bozulmuş
görüntüye (`image`) ve `stamp_underneath_text` ikizine dönüştürür.
"""
def __init__(self, seed: Optional[int] = None) -> None:
self.rng = random.Random(seed)
self.doc_gen = TurkishDocumentGenerator(seed=seed)
self.degrader = TurkceDegrade(seed=seed)
def generate_two_column_born_digital_page(
self,
apply_degradation: bool = True,
is_ood: bool = False,
) -> SyntheticDocumentSample:
"""
Resmî Gazete / DergiPark / Akademik Rapor tarzı **Çift Sütunlu (2-Column)**
vektörel mizanpaj sayfası üretir. Okuma sırası (`RO-NED` KPI-12) eğitimi için
önce Sol Sütun (Madde 1 & 2), ardından Sağ Sütun (Madde 3 & KIE Özeti) okunacak
şekilde kesin ground-truth üretir.
"""
w, h = 900, 1120
clean_img = Image.new("RGB", (w, h), (255, 255, 253))
draw = ImageDraw.Draw(clean_img)
font_banner = _load_turkish_font(18, style="serif", rng=self.rng)
font_sub = _load_turkish_font(14, style="bold", rng=self.rng)
font_body = _load_turkish_font(13, style="serif", rng=self.rng)
tckn = self.doc_gen.generate_valid_tckn()
vkn = self.doc_gen.generate_valid_vkn()
iban = self.doc_gen.generate_valid_tr_iban()
hard_word_1, hard_word_2 = self.rng.sample(AGGLUTINATIVE_HARD_LEXICON[:10], k=2)
banner = (
"T.C. RESMÎ GAZETE — ÇİFT SÜTUNLU FİNANSAL TEBLİĞ VE DENETİM RAPORU"
if is_ood
else "TÜRKİYE CUMHURİYETİ MERKEZ BANKASI — KURUMSAL DENETİM BÜLTENİ"
)
draw.text((40, 32), banner, fill=(10, 10, 10), font=font_banner)
draw.line([(40, 65), (w - 40, 65)], fill=(25, 25, 25), width=2)
mid_x = w // 2
draw.line([(mid_x, 85), (mid_x, h - 80)], fill=(160, 160, 160), width=1)
# Sol Sütun (Sütun 1)
col1_x = 40
col1_h1 = "BÖLÜM 1: MEVZUAT VE YÜKÜMLÜLÜKLER"
col1_p1 = f"MADDE 1 — Kurumsal denetimlerde {hard_word_1} ilkesi esas alınır."
col1_p2 = f"MADDE 2 — İlgili belgelerin {hard_word_2} kapsamında ibrazı zorunludur."
col1_p3 = "Iğdır, Isparta ve İstanbul Şişli müdürlükleri koordinasyonunda yürütülür."
# Sağ Sütun (Sütun 2)
col2_x = mid_x + 24
col2_h1 = "BÖLÜM 2: KİMLİK VE HESAP BİLDİRİMİ"
col2_p1 = f"DENETÇİ TCKN: {tckn}"
col2_p2 = f"KURUM VKN: {vkn}"
col2_p3 = f"EMANET IBAN: {iban}"
line_bboxes: List[Tuple[Tuple[int, int, int, int], str]] = [
((36, 28, w - 36, 62), banner),
]
# Sol sütun çizimi
draw.text((col1_x, 95), col1_h1, fill=(15, 15, 15), font=font_sub)
draw.text((col1_x, 130), col1_p1, fill=(20, 20, 20), font=font_body)
draw.text((col1_x, 165), col1_p2, fill=(20, 20, 20), font=font_body)
draw.text((col1_x, 200), col1_p3, fill=(20, 20, 20), font=font_body)
line_bboxes.append(((col1_x, 125, mid_x - 12, 155), col1_p1))
line_bboxes.append(((col1_x, 160, mid_x - 12, 190), col1_p2))
# Sağ sütun çizimi
draw.text((col2_x, 95), col2_h1, fill=(15, 15, 15), font=font_sub)
draw.text((col2_x, 130), col2_p1, fill=(20, 20, 20), font=font_body)
draw.text((col2_x, 165), col2_p2, fill=(20, 20, 20), font=font_body)
draw.text((col2_x, 200), col2_p3, fill=(20, 20, 20), font=font_body)
line_bboxes.append(((col2_x, 125, w - 36, 155), col2_p1))
line_bboxes.append(((col2_x, 160, w - 36, 190), col2_p2))
target_bbox = (col1_x, 120, mid_x - 15, 225)
final_img = clean_img
stamp_tags: List[str] = []
if apply_degradation:
deg = self.degrader.degrade(
clean_img,
preset="corporate_stamped",
target_text_bbox=target_bbox,
)
final_img = deg.image
stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures]
md_lines = [
f"# {banner}",
f"## {col1_h1}",
col1_p1,
col1_p2,
col1_p3,
f"## {col2_h1}",
f"DENETÇİ TCKN: <tckn>{tckn}</tckn>",
f"KURUM VKN: <vkn>{vkn}</vkn>",
f"EMANET IBAN: <iban>{iban}</iban>",
]
if stamp_tags:
md_lines.extend(stamp_tags)
markdown = "\n".join(md_lines)
plain = "\n".join([banner, col1_h1, col1_p1, col1_p2, col1_p3, col2_h1, col2_p1, col2_p2, col2_p3])
return SyntheticDocumentSample(
doc_type="born_digital_2col",
image=final_img,
clean_image=clean_img,
ground_truth_markdown=markdown,
plain_text=plain,
stamp_underneath_text=col1_p1,
expected_kie={"tckn": tckn, "vkn": vkn, "iban": iban},
is_ood=is_ood,
layout_variant=f"{'ood' if is_ood else 'id'}_2col_born_digital",
line_crops=TurkishDocumentGenerator._crop_lines_from_image(final_img, line_bboxes),
)
def extract_pairs_from_pdf_file(
self,
pdf_path: str | Path,
max_pages: int = 10,
apply_degradation: bool = True,
) -> List[SyntheticDocumentSample]:
"""
Yerel bir PDF dosyasından (`PyMuPDF` / `fitz` kuruluysa doğrudan vektörel render +
metin çıkarma ile; değilse yerleşik çift sütunlu born-digital üreteçle) eğitim
çiftleri çıkarır.
"""
path = Path(pdf_path)
samples: List[SyntheticDocumentSample] = []
try:
import fitz # PyMuPDF
if path.exists():
doc = fitz.open(str(path))
for page_idx in range(min(len(doc), max_pages)):
page = doc[page_idx]
text = page.get_text("text").strip()
if not text:
continue
pix = page.get_pixmap(dpi=200)
clean_img = Image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")
deg_img = clean_img
stamp_tags: List[str] = []
if apply_degradation:
deg = self.degrader.degrade(clean_img, preset="corporate_stamped")
deg_img = deg.image
stamp_tags = [s.xml_tag for s in deg.stamps_and_signatures]
first_line = text.splitlines()[0].strip()
md = text + ("\n" + "\n".join(stamp_tags) if stamp_tags else "")
samples.append(
SyntheticDocumentSample(
doc_type="born_digital_pdf_page",
image=deg_img,
clean_image=clean_img,
ground_truth_markdown=md,
plain_text=text,
stamp_underneath_text=first_line,
layout_variant="real_pdf_vector_page",
)
)
doc.close()
if samples:
return samples
except Exception:
pass
# PyMuPDF yoksa veya dosya bulunamadıysa yerleşik çift sütunlu born-digital üreteci kullan
for i in range(max_pages):
samples.append(
self.generate_two_column_born_digital_page(
apply_degradation=apply_degradation, is_ood=(i % 2 == 1)
)
)
return samples