LoRA adapter on top of
lapa-llm/lapa-v0.1.2-instruct
(a Gemma-3-12B Ukrainian vision-language model) for
Ukrainian handwritten-text
recognition (HTR / OCR) on document crops.
CER > 1 on the base reflects heavy paraphrasing (output far longer than ground truth).
The adapter removes that behavior and produces faithful transcriptions.
Not tuned for: full-page layout, non-Ukrainian scripts, or marginal / very low-quality
regions (CER rises to ~0.55 on hard, low-confidence regions).
1import torch
2from PIL import Image
3from peft import PeftModel
4from transformers import AutoModelForImageTextToText, AutoProcessor
5
6BASE = "lapa-llm/lapa-v0.1.2-instruct"
7ADAPTER = "lapa-llm/lapa-ocr-lora" # this repo
8
9base = AutoModelForImageTextToText.from_pretrained(
10 BASE,
11 torch_dtype=torch.bfloat16,
12 device_map="auto",
13 attn_implementation="sdpa",
14)
15model = PeftModel.from_pretrained(base, ADAPTER).eval()
16processor = AutoProcessor.from_pretrained(BASE)
17
18PROMPT = "Transcribe Ukrainian text literally. Output only the text, no preamble."
19img = Image.open("crop.png").convert("RGB")
20messages = [{
21 "role": "user",
22 "content": [
23 {"type": "image", "image": img},
24 {"type": "text", "text": PROMPT},
25 ],
26}]
27
28inputs = processor.apply_chat_template(
29 messages, add_generation_prompt=True, tokenize=True,
30 return_dict=True, return_tensors="pt", padding=True,
31).to(model.device, dtype=torch.bfloat16)
32
33with torch.inference_mode():
34 gen = model.generate(**inputs, max_new_tokens=256, do_sample=False, num_beams=1)
35text = processor.batch_decode(
36 gen[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True
37)[0].strip()
38print(text)
1apt-get update && apt-get install -y build-essential # gcc, for triton's JIT
2pip install -U "torch>=2.6" torchvision --index-url https://download.pytorch.org/whl/cu124
3pip install -U "transformers>=4.57" "peft>=0.19" "accelerate>=1.0" \
4 "bitsandbytes>=0.49" pillow sentencepiece
1import torch
2from PIL import Image
3from peft import PeftModel
4from transformers import AutoModelForImageTextToText, AutoProcessor, BitsAndBytesConfig
5
6BASE = "lapa-llm/lapa-v0.1.2-instruct"
7ADAPTER = "lapa-llm/lapa-ocr-lora" # this repo
8
9# 4-bit NF4 (~9 GB). For ~bf16 fidelity use load_in_8bit=True instead (~15 GB).
10bnb = BitsAndBytesConfig(
11 load_in_4bit=True,
12 bnb_4bit_quant_type="nf4",
13 bnb_4bit_use_double_quant=True,
14 bnb_4bit_compute_dtype=torch.bfloat16, # bf16, NOT fp16
15 llm_int8_skip_modules=["vision_tower", "multi_modal_projector", "lm_head", "embed_tokens"],
16)
17
18model = AutoModelForImageTextToText.from_pretrained(
19 BASE, quantization_config=bnb, torch_dtype=torch.bfloat16,
20 device_map="auto", attn_implementation="eager",
21)
22model = PeftModel.from_pretrained(model, ADAPTER).eval()
23
24# Some load paths drop the generation config (runtime eos_token_id=None); reinstate it.
25model.generation_config.eos_token_id = [1, 106] # <eos>, <end_of_turn>
26model.generation_config.pad_token_id = 0
27
28processor = AutoProcessor.from_pretrained(BASE)
29PROMPT = "Transcribe Ukrainian text literally. Output only the text, no preamble."
30img = Image.open("crop.png").convert("RGB")
31messages = [{"role": "user", "content": [
32 {"type": "image", "image": img}, {"type": "text", "text": PROMPT}]}]
33inputs = processor.apply_chat_template(
34 messages, add_generation_prompt=True, tokenize=True,
35 return_dict=True, return_tensors="pt",
36).to(model.device)
37
38with torch.inference_mode():
39 gen = model.generate(**inputs, max_new_tokens=256, do_sample=False,
40 eos_token_id=[1, 106], pad_token_id=0)
41print(processor.decode(gen[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip())
Verified on an RTX 3090 24 GB (an A10G analog), 50 handwritten crops from
UkrainianCatholicUniversity/rukopys: