TurkceOCR / turkceocr /data /hf_loader.py
ersanbil's picture
Release TurkceOCR v2.0 (Apache-2.0)
c8b6043
Raw History Blame Contribute Delete
20.4 kB
"""
Hugging Face Açık Kaynak Türkçe OCR Veri Seti Birleştirici, Kalite Filtresi (`Mojibake` &
Çözünürlük Denetimi), Müfredat Öğrenimi (3-Stage Curriculum) ve `TurkceDegrade` Canlı Zenginleştiricisi.
Doğrulanmış Açık Kaynak Türkçe Veri Kaynakları (Kasım 2026):
1. Tam Sayfa Kurumsal / Doküman OCR:
- `Werea-co/werea-tr-doc-ocr-enterprise-v2`
- `Werea-co/werea-tr-doc-ocr-synthetic`
- `HumynLabs/Turkish_Documents_Datasets_PDF`
- `ytu-ce-cosmos/turkce-kitap`
- `moganai/turkish-resmi-gazete`
2. Doküman Soru-Cevap (DocVQA):
- `Ethosoft/TR-DocVQA-Synth` (`question` + `answer` çiftleri `<doc_qa>` şemasıyla korunur)
3. Satır / El Yazısı / Sahne Metni (`TürkçeOCR-Line` için):
- `emredeveloper/turkish-ocr` (%70 el yazısı, %30 matbu)
- `selimfirat/bilkent-turkish-writings-dataset`
- `bartosgaye/thedataset` (T-H-E 156K el yazısı karakter/kelime)
- `serdaryildiz/turkish-scene-text-recognition-dataset` (TS-TR & STS-TR)
4. Metin/Mevzuat Korpusu (Sentetik Belge Metin Enjeksiyonu için):
- `CtnkyaABC/turkish-law-corpus`
- `muhammetakkurt/mevzuat-gov-dataset`
Sızıntı Koruması (Zero Data Contamination):
- `metunlp/ocrturk` (EACL 2026 SIGTURK, 180 gerçek belge) yalnızca kıyaslama/test içindir;
eğitim havuzuna eklenmesi `BENCHMARK_ONLY_CONTAMINATION_GUARD` tarafından engellenir.
"""
from __future__ import annotations
import math
import random
from collections import Counter
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Tuple
from PIL import Image
from turkceocr.data.degradation import TurkceDegrade
from turkceocr.data.pdf_and_table_engine import (
BornDigitalPDFPairGenerator,
ComplexTurkishTableRenderer,
)
from turkceocr.data.synthetic_generator import (
AGGLUTINATIVE_HARD_LEXICON,
SyntheticDocumentSample,
TurkishDocumentGenerator,
)
# Eğitim setine kesinlikle karıştırılmaması gereken resmi test benchmarkları
BENCHMARK_ONLY_CONTAMINATION_GUARD = {
"metunlp/ocrturk",
"ocrturk",
}
# Yaygın UTF-8 -> Latin-1 Mojibake dönüşüm tablosu (Bozuk dış veri setlerini onarmak için)
TURKISH_MOJIBAKE_MAP: Dict[str, str] = {
"ç": "ç",
"Ç": "Ç",
"ÄŸ": "ğ",
"Äž": "Ğ",
"ı": "ı",
"İ": "İ",
"ö": "ö",
"Ö": "Ö",
"ÅŸ": "ş",
"Åž": "Ş",
"ü": "ü",
"Ü": "Ü",
"â": "â",
"Â": "Â",
}
@dataclass
class DatasetSourceSpec:
"""Hugging Face veri seti kaynağı, görev türü, bozulma ön-ayarı ve örnekleme ağırlığı."""
hf_path: str
weight: float
task_type: str = "full_page_ocr" # "full_page_ocr" | "doc_vqa" | "line_ocr" | "text_corpus"
image_column: str = "image"
text_column: str = "text"
allowed_presets: Tuple[str, ...] = ("corporate_stamped",)
split: str = "train"
DEFAULT_TURKISH_HF_SOURCES: List[DatasetSourceSpec] = [
DatasetSourceSpec(
"Werea-co/werea-tr-doc-ocr-enterprise-v2",
weight=0.22,
task_type="full_page_ocr",
text_column="markdown",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"Werea-co/werea-tr-doc-ocr-synthetic",
weight=0.12,
task_type="full_page_ocr",
text_column="markdown",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"Ethosoft/TR-DocVQA-Synth",
weight=0.16,
task_type="doc_vqa",
text_column="answer",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"HumynLabs/Turkish_Documents_Datasets_PDF",
weight=0.12,
task_type="full_page_ocr",
text_column="text",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"ytu-ce-cosmos/turkce-kitap",
weight=0.12,
task_type="full_page_ocr",
text_column="text",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"moganai/turkish-resmi-gazete",
weight=0.10,
task_type="full_page_ocr",
text_column="text",
allowed_presets=("corporate_stamped",),
),
DatasetSourceSpec(
"emredeveloper/turkish-ocr",
weight=0.10,
task_type="line_ocr",
text_column="text",
allowed_presets=("mixed_handwritten_form",),
),
DatasetSourceSpec(
"selimfirat/bilkent-turkish-writings-dataset",
weight=0.06,
task_type="line_ocr",
text_column="text",
allowed_presets=("mixed_handwritten_form",),
),
]
class TurkishOCRDataQualityFilter:
"""
Dış kaynaklardan (Hugging Face / PDF kazıma) gelen örneklerde:
1. UTF-8 Mojibake (`ü -> ü`, `ı -> ı`, `ÅŸ -> ş`) onarımı,
2. Çözünürlük alt sınırı kontrolü (tam sayfa vs. satır),
3. Boş veya bozuk (`\\ufffd`) etiket filtrelemesi
uygulayan kalite kapısı.
"""
def __init__(
self,
min_page_side: int = 256,
min_line_height: int = 16,
min_text_chars: int = 2,
) -> None:
self.min_page_side = min_page_side
self.min_line_height = min_line_height
self.min_text_chars = min_text_chars
@staticmethod
def repair_mojibake(text: str) -> str:
fixed = text
for bad, good in TURKISH_MOJIBAKE_MAP.items():
if bad in fixed:
fixed = fixed.replace(bad, good)
return fixed
def validate_and_clean(
self,
image: Image.Image,
text: str,
task_type: str = "full_page_ocr",
) -> Tuple[bool, str]:
if not isinstance(text, str):
return False, ""
cleaned = self.repair_mojibake(text).strip()
if len(cleaned) < self.min_text_chars or "\ufffd" in cleaned:
return False, ""
w, h = image.size
if task_type == "line_ocr":
if h < self.min_line_height or w < self.min_line_height:
return False, ""
else:
if min(w, h) < self.min_page_side:
return False, ""
return True, cleaned
def build_stamp_underneath_token_mask(
full_text: str,
stamp_underneath_text: str,
char_Or_token_sequence: Optional[Sequence[str]] = None,
) -> List[float]:
"""
`TurkceOCRVL.compute_turkish_vl_loss` ve `DiacriticFocalLoss` içinde kaşe altında
kalan metin bölgesine `2.2x` odak ağırlığı vermek için karakter/token maskesi üretir.
"""
if not full_text:
return []
mask = [0.0] * len(full_text)
sub = stamp_underneath_text.strip()
if sub:
start_idx = full_text.find(sub)
if start_idx != -1:
for i in range(start_idx, min(len(full_text), start_idx + len(sub))):
mask[i] = 1.0
if char_Or_token_sequence is None:
return mask
# Eğer alt-kelime (subword) token dizisi verilmişse karakter maskesini tokenlara izdüşür
token_mask: List[float] = []
cursor = 0
for tok in char_Or_token_sequence:
t_len = max(1, len(tok))
span = mask[cursor : min(len(mask), cursor + t_len)]
token_mask.append(1.0 if any(v > 0.5 for v in span) else 0.0)
cursor = min(len(mask), cursor + t_len)
return token_mask
@dataclass
class DatasetSufficiencySummary:
"""Eğitim veri seti yeterlilik, entropi ve Türkçe morfoloji analiz raporu."""
total_page_samples: int
total_line_crops: int
layout_entropy_bits: float
diacritic_char_ratio: float
agglutinative_long_word_ratio: float
stamped_sample_ratio: float
id_sample_count: int
ood_sample_count: int
passes_sufficiency_gate: bool
notes: List[str] = field(default_factory=list)
class TurkishOCRDatasetMixer:
"""
Hugging Face veri setlerini ve yerel parametrik `TurkishDocumentGenerator` motorunu
birleştirip 3 evreli Müfredat Öğrenimi (Curriculum Learning) ve `TurkceDegrade` ile
zenginleştiren PyTorch uyumlu veri kümesi.
"""
def __init__(
self,
degrade_prob: float = 0.75,
curriculum_stage: int = 2,
seed: int = 42,
synthetic_fallback_size: int = 1000,
) -> None:
self.seed = seed
self.rng = random.Random(seed)
self.degrader = TurkceDegrade(seed=seed)
self.generator = TurkishDocumentGenerator(seed=seed)
self.table_renderer = ComplexTurkishTableRenderer(seed=seed)
self.pdf_pair_generator = BornDigitalPDFPairGenerator(seed=seed)
self.quality_filter = TurkishOCRDataQualityFilter()
self.synthetic_fallback_size = synthetic_fallback_size
self._external_samples: List[Dict[str, Any]] = []
self.curriculum_stage = curriculum_stage
self.degrade_prob = degrade_prob
self.set_curriculum_stage(curriculum_stage, custom_degrade_prob=degrade_prob)
def set_curriculum_stage(
self, stage: int, custom_degrade_prob: Optional[float] = None
) -> None:
"""
3 Evreli Müfredat Öğrenimi (Curriculum Learning):
- Evre 1 (Temiz + Hafif Bozulma): `degrade_prob = 0.30`
- Evre 2 (Yoğun Kaşe, İmza, Termal Fiş): `degrade_prob = 0.75`
- Evre 3 (Zorlu Morfoloji, Çoklu Şablon + RLVR): `degrade_prob = 0.85`
"""
if stage not in (1, 2, 3):
raise ValueError("curriculum_stage 1, 2 veya 3 olmalıdır.")
self.curriculum_stage = stage
if custom_degrade_prob is not None:
self.degrade_prob = custom_degrade_prob
elif stage == 1:
self.degrade_prob = 0.30
elif stage == 2:
self.degrade_prob = 0.75
else:
self.degrade_prob = 0.85
def load_hf_source(
self,
repo_id: str,
split: str = "train",
max_samples: Optional[int] = None,
streaming: bool = True,
) -> int:
"""
İlgili Hugging Face veri setini indirip kalite filtresinden geçirerek ve
görev tipine göre (`<doc_qa>` veya tam sayfa Markdown) şemalandırarak havuza ekler.
"""
norm_id = repo_id.strip().lower()
if any(guard in norm_id for guard in BENCHMARK_ONLY_CONTAMINATION_GUARD):
raise ValueError(
f"SIZINTI ENGELİ (Data Contamination Guard): '{repo_id}' resmi bir test "
"benchmarkıdır ve eğitim veri kümesine dahil edilemez!"
)
from datasets import load_dataset
spec_map = {s.hf_path: s for s in DEFAULT_TURKISH_HF_SOURCES}
spec = spec_map.get(
repo_id,
DatasetSourceSpec(repo_id, weight=0.10, task_type="full_page_ocr"),
)
ds = load_dataset(repo_id, split=split, streaming=streaming)
count = 0
for row in ds:
img = row.get("image") or row.get("page_image") or row.get("img")
if not isinstance(img, Image.Image):
continue
# Ethosoft/TR-DocVQA-Synth gibi soru-cevap veri setlerinde soruyu kaybetme!
if "question" in row and ("answer" in row or "answers" in row):
q_str = str(row.get("question", "")).strip()
a_raw = row.get("answer") or row.get("answers")
a_str = a_raw[0] if isinstance(a_raw, list) and a_raw else str(a_raw or "")
raw_txt = f"<doc_qa>\nSoru: {q_str}\nCevap: {a_str.strip()}\n</doc_qa>"
task_type = "doc_vqa"
else:
raw_txt = (
row.get("markdown")
or row.get("text")
or row.get("ground_truth")
or row.get("label")
or ""
)
task_type = spec.task_type
rgb_img = img.convert("RGB")
is_valid, cleaned_txt = self.quality_filter.validate_and_clean(
rgb_img, str(raw_txt), task_type=task_type
)
if not is_valid:
continue
self._external_samples.append(
{
"image": rgb_img,
"text": cleaned_txt,
"source": repo_id,
"task_type": task_type,
"allowed_presets": spec.allowed_presets,
"weight": spec.weight,
}
)
count += 1
if max_samples is not None and count >= max_samples:
break
return count
def _select_compatible_preset(
self, img: Image.Image, allowed_presets: Sequence[str]
) -> str:
"""Görüntü en-boy oranına ve veri kaynağına uyumlu bozulma ön-ayarı seçer."""
if allowed_presets:
return self.rng.choice(list(allowed_presets))
w, h = img.size
aspect = w / max(1.0, float(h))
if aspect < 0.72:
return "thermal_pos_receipt"
if aspect > 1.35:
return "id_card_camera"
return "corporate_stamped"
def __len__(self) -> int:
if self._external_samples:
return len(self._external_samples) + self.synthetic_fallback_size
return self.synthetic_fallback_size
def __getitem__(self, idx: int) -> Dict[str, Any]:
if idx < len(self._external_samples):
item = self._external_samples[idx]
img: Image.Image = item["image"]
text: str = item["text"]
if self.rng.random() < self.degrade_prob:
preset = self._select_compatible_preset(
img, item.get("allowed_presets", ("corporate_stamped",))
)
deg = self.degrader.degrade(img, preset=preset)
img = deg.image
if deg.stamps_and_signatures:
tags = "\n".join(s.xml_tag for s in deg.stamps_and_signatures)
text = f"{text}\n{tags}"
return {
"image": img,
"text": text,
"source": item["source"],
"task_type": item.get("task_type", "full_page_ocr"),
"stamp_underneath_mask": [0.0] * len(text),
"line_crops": [],
}
# 6 Şablon Ailesinden Parametrik Türkçe Belge Üretimi
doc_choice = idx % 6
apply_deg = self.rng.random() < self.degrade_prob
# Evre 3'te şablon çeşitliliğini artırmak için %25 oranında çok-sütunlu / alternatif topoloji üret
use_alt_topology = self.curriculum_stage == 3 and (idx % 5 == 0)
if doc_choice == 0:
sample = self.generator.generate_corporate_invoice(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
elif doc_choice == 1:
sample = self.generator.generate_thermal_pos_receipt(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
elif doc_choice == 2:
sample = self.generator.generate_mixed_handwritten_form(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
elif doc_choice == 3:
sample = self.generator.generate_official_gazette_or_contract(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
elif doc_choice == 4:
sample = self.table_renderer.generate_multi_vat_erp_invoice(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
else:
sample = self.pdf_pair_generator.generate_two_column_born_digital_page(
apply_degradation=apply_deg, is_ood=use_alt_topology
)
char_mask = build_stamp_underneath_token_mask(
sample.ground_truth_markdown, sample.stamp_underneath_text
)
return {
"image": sample.image,
"clean_image": sample.clean_image,
"text": sample.ground_truth_markdown,
"plain_text": sample.plain_text,
"stamp_underneath_text": sample.stamp_underneath_text,
"stamp_underneath_mask": char_mask,
"expected_kie": sample.expected_kie,
"layout_variant": sample.layout_variant,
"line_crops": sample.line_crops,
"source": f"synthetic:{sample.doc_type}",
"task_type": "full_page_ocr",
}
def extract_line_training_pairs(
self, num_documents: int = 24
) -> List[Tuple[Image.Image, str]]:
"""
`TürkçeOCR-Line (SVTRv2-TR)` eğitimi için bozulmuş/kaşeli tam sayfalardan
gerçekçi satır kırpımları (`(crop_image, line_text)`) üretir.
"""
pairs: List[Tuple[Image.Image, str]] = []
for i in range(num_documents):
item = self[len(self._external_samples) + i]
for crop_img, line_txt in item.get("line_crops", []):
pairs.append((crop_img, line_txt))
return pairs
def evaluate_data_sufficiency(self, sample_count: int = 32) -> DatasetSufficiencySummary:
"""
Üretilen/birleştirilen eğitim havuzunun 12 KPI hedefimiz için yeterli
yerleşim entropisine ($H_{\\text{layout}}$), diakritik yoğunluğuna,
sondan eklemeli uzun kelime oranına ve kaşe altı kurtarma (`SURR`) çiftlerine
sahip olup olmadığını nicel olarak denetler.
"""
layout_counts: Counter[str] = Counter()
total_chars = 0
diac_chars = 0
total_words = 0
long_words = 0
stamped_count = 0
id_count = 0
ood_count = 0
total_crops = 0
diac_set = set("çğıöşüÇĞİÖŞÜâîûÂÎÛ")
for i in range(sample_count):
item = self[i]
txt = item["text"]
variant = item.get("layout_variant", item.get("source", "external"))
layout_counts[variant] += 1
total_crops += len(item.get("line_crops", []))
if "ood" in str(variant):
ood_count += 1
else:
id_count += 1
if "<stamp>" in txt or item.get("stamp_underneath_text"):
stamped_count += 1
total_chars += len(txt)
diac_chars += sum(1 for ch in txt if ch in diac_set)
words = [w.strip(".,;:()[]*#<>\"'") for w in txt.split()]
words = [w for w in words if w.isalpha()]
total_words += len(words)
long_words += sum(1 for w in words if len(w) >= 8)
# Shannon yerleşim entropisi (bit cinsinden)
entropy = 0.0
for cnt in layout_counts.values():
p = cnt / max(1, sample_count)
if p > 0:
entropy -= p * math.log2(p)
diac_ratio = diac_chars / max(1, total_chars)
long_word_ratio = long_words / max(1, total_words)
stamped_ratio = stamped_count / max(1, sample_count)
notes: List[str] = []
passes = True
if entropy < 1.5:
passes = False
notes.append(f"Düşük yerleşim entropisi ({entropy:.2f} bit < 1.50 bit): Şablon ezberleme riski!")
if diac_ratio < 0.05:
passes = False
notes.append(f"Düşük Türkçe diakritik yoğunluğu ({diac_ratio:.4f} < 0.0500)!")
if long_word_ratio < 0.20:
passes = False
notes.append(f"Düşük sondan eklemeli uzun kelime oranı ({long_word_ratio:.4f} < 0.2000)!")
if stamped_ratio < 0.40:
passes = False
notes.append(f"Düşük kaşeli belge oranı ({stamped_ratio:.4f} < 0.4000): SURR KPI-7 için yetersiz!")
if passes:
notes.append(
"Eğitim veri havuzu tüm yerleşim entropisi, Türkçe diakritik, morfolojik uzun kelime ve SURR kaşe altı kriterlerini karşılıyor."
)
return DatasetSufficiencySummary(
total_page_samples=sample_count,
total_line_crops=total_crops,
layout_entropy_bits=round(entropy, 4),
diacritic_char_ratio=round(diac_ratio, 4),
agglutinative_long_word_ratio=round(long_word_ratio, 4),
stamped_sample_ratio=round(stamped_ratio, 4),
id_sample_count=id_count,
ood_sample_count=ood_count,
passes_sufficiency_gate=passes,
notes=notes,
)