{<Z Kordian Zadrożny

AI, Strony WWW, Programowanie, Bazy danych

Mały test małych lokalnych modeli SLM pod kątem OCR

utworzone przez | sty 11, 2026 | różne, AI | 2 Komentarze

Cześć

W celach a jakże zawodowych stanąłem przed problemem przetestowania lokalnych modeli AI w temacie OCR.

Nie jest to jakiś duży, profesjonalny test. Pierwszy odbył się na dokumentach dostarczonych przez klienta, ale ich udostępnić nie mogę, wiec robię specjalnie na bloga test z kilkoma dokumentami z Internetu.

Założenie było takie, aby przetestować sam model, z najprostszym promptem. Można by pewnie podkręcić jakość, ale to jest mniej ważne. Finalnie, zawsze taki proces będzie się składał z kilku kroków:

  • Skanowanie
  • OCR by AI
  • Obróbka ponowna przez AI juz tekstu
  • Pobranie danych
  • Kategoryzacja itd.

Test to punkt drugi tego workflow.

Stack testowy:

  • Framework agenta: Pydantic AI 1.9.1
  • Hostowanie LLM: Ollama
  • Sprzęt: Intel i7-13700F, 64GB RAM, RTX 4060 Ti 8GB VRAM

Modele biorące w „turnieju;)”:

  • llava:13b
  • minicpm-v
  • qwen3-vl:8b
  • mistral-small3.2
  • gemma3:12b

Moja ollama:

Testowe grafiki:

Przegląd plików:

1. Skany_dokumentow_historycznych_001.jpg (3.3 MB)

  • Typ: Historyczna gazeta „DZIENNIK PORANNY” z 1941r
  • Trudność: ⭐⭐⭐⭐⭐ BARDZO TRUDNA
  • Wyzwania: Stary druk, żółty papier, czcionka gotycka, wielokolumnowy layout

2. cv_Darka.png (136 KB) – stare CV kolegi z którym często współpracujemy przy różnych projektach – za jego zgodą: Dariusz Baraniewicz | PHP które działa

  • Typ: CV/Resume (mgr inż. Dariusz Baraniewicz)
  • Trudność: ⭐⭐ ŁATWA
  • Wyzwania: Struktura tabelaryczna, daty, technologie

3. instrukcja-suzuki.png (179 KB)

  • Typ: Instrukcja obsługi (lampka ostrzegawcza układu hamulcowego)
  • Trudność: ⭐⭐⭐ ŚREDNIA
  • Wyzwania: Ikony, ostrzeżenia, formatowanie, znaki specjalne

4. raport-stanu.png (70 KB)

  • Typ: Raport magazynowy z tabelami
  • Trudność: ⭐⭐⭐ ŚREDNIA
  • Wyzwania: Tabele z danymi, kody EAN, statusy

5. raport.png (362 KB)

  • Typ: Raport z badania ankietowego + wykres
  • Trudność: ⭐⭐⭐⭐ TRUDNA
  • Wyzwania: Tekst + wykres słupkowy, tabela statystyk

6. rzecznik.png (360 KB)

  • Typ: Pismo urzędowe (Minister Rolnictwa)
  • Trudność: ⭐⭐ ŁATWA
  • Wyzwania: Pieczątki, podpisy odręczne, godło

7. tekst urzedowy.jpg (7.1 MB!)

  • Typ: Zdjęcie ekranu z dokumentem (efekt moiré)
  • Trudność: ⭐⭐⭐⭐⭐ BARDZO TRUDNA
  • Wyzwania: Zdjęcie ekranu, zniekształcenia, tabela, nieostra jakość

Skrypt testowy

import asyncio
from pydantic_ai import Agent, ImageUrl
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
import base64
import time
from datetime import datetime
import os
from PIL import Image
import io

DIR = 'pages6'

def resize_image(image_path: str, max_size: int = 2000) -> bytes:
    img = Image.open(image_path)
    if max(img.size) > max_size:
        ratio = max_size / max(img.size)
        new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
        img = img.resize(new_size, Image.Resampling.LANCZOS)
    buffer = io.BytesIO()
    img.save(buffer, format='JPEG', quality=85)
    return buffer.getvalue()

def encode_image(image_path: str) -> str:
    image_bytes = resize_image(image_path)
    return base64.b64encode(image_bytes).decode('utf-8')

def create_model(model_name: str):
    model = OpenAIChatModel(
    model_name,
    provider=OpenAIProvider(
        base_url='http://localhost:11434/v1',
        api_key='ollama',
        )
    )
    agent = Agent(
        model=model,
        system_prompt='''Jesteś specjalistą od OCR, i z wielką dokładnością przekazujesz treść przesłanego pliku.''',
        model_settings={
            'temperature': 0.1,
        }
    )   
    return agent

async def warmup_model(agent):
    """Pre-loaduje model w Ollama wysyłając małe żądanie testowe"""
    try:
        print("  Ładowanie modelu do pamięci...")
        await asyncio.wait_for(
            agent.run("test"),
            timeout=60.0
        )
        print("  Model załadowany ✓")
    except Exception as e:
        print(f"  Ostrzeżenie: warmup nie powiódł się ({e})")     

models = ['llava:13b','minicpm-v','qwen3-vl:8b','mistral-small3.2:latest','gemma3:12b',]

async def main():
    images = [f for f in os.listdir(DIR) if os.path.isfile(os.path.join(DIR, f))]
    
    for model in models:
        print(f"\n=== Model: {model} ===")
        agent = create_model(model)
        await warmup_model(agent)
        
        model_folder = model.replace(':', '_')
        os.makedirs(f'{DIR}/{model_folder}', exist_ok=True)
        
        for image in images:
            out_path = f'{DIR}/{model_folder}/{image}.txt'
            if os.path.exists(out_path):
                print(f"POMINIĘTO | Model: {model} | Plik: {image} | Wynik już istnieje")
                continue
                
            image_path = os.path.join(DIR, image)
            image_base64 = encode_image(image_path)
            start_time = time.time()
            start_datetime = datetime.now()
            print(f"Model: {model} | Plik: {image} | Start: {start_datetime.strftime('%H:%M:%S')}")
            
            try:
                result = await asyncio.wait_for(
                    agent.run([
                        'Zrób dokładny OCR i zwróć tekst. Jeśli to nie dokument a zdjęcie to dokładnie je opisz.',
                        ImageUrl(url=f'data:image/png;base64,{image_base64}')
                    ]),
                    timeout=1000.0
                )
                end_time = time.time()
                duration = end_time - start_time

                end_datetime = datetime.now()
                print(f"Model: {model} | Plik: {image} | Koniec: {end_datetime.strftime('%H:%M:%S')} | Czas: {duration:.2f}s")
                with open(f'{DIR}/{model_folder}/{image}.txt', 'w', encoding='utf-8') as f:
                    f.write(f"Model: {model}\n")
                    f.write(f"Plik: {image}\n")
                    f.write(f"Start: {start_datetime.strftime('%H:%M:%S')}\n")
                    f.write(f"Koniec: {end_datetime.strftime('%H:%M:%S')}\n")
                    f.write(f"Czas wykonania: {duration:.2f} sekund\n")
                    f.write("\n")
                    f.write(result.output)
            
            except asyncio.TimeoutError:
                print(f"TIMEOUT | Model: {model} | Plik: {image} | Przekroczono 1000s")
                with open(f'{DIR}/{model_folder}/{image}.ERROR.txt', 'w', encoding='utf-8') as f:
                    f.write(f"BŁĄD: Timeout (1000s)\n")
            except Exception as e:
                print(f"BŁĄD | Model: {model} | Plik: {image} | {type(e).__name__}: {e}")
                with open(f'{DIR}/{model_folder}/{image}.ERROR.txt', 'w', encoding='utf-8') as f:
                    f.write(f"BŁĄD: {type(e).__name__}\n{e}\n")

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

Wyjaśnienia

Oryginalne pliki były duże, w skrypcie zmniejszałem je do 2000px, tu pliki były mniejsze, wiec mogło być tak, ze powiększaliśmy bezsensownie obraz, ale już tego nie zmieniałem. Ważne, że każdy model ma te same zadania.

Zastosowałem też 1000s limit na operację.

Mój test na realnych skanowanych dokumentach wygrał nieznacznie Mistral, jeśli chodzi o jakość, a Gemma prawie tak samo dobra jakościowo a sporo szybsza.

Test na powyższym zestawie obrazów przebiegł ciut inaczej, a winowajcą był skan ze starej gazety;)

WYNIKI TESTÓW OCR – ANALIZA PORÓWNAWCZA

=== Model: llava:13b ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: llava:13b | Plik: cv_Darka.png | Start: 22:27:53
Model: llava:13b | Plik: cv_Darka.png | Koniec: 22:29:17 | Czas: 83.75s
Model: llava:13b | Plik: instrukcja-suzuki.png | Start: 22:29:17
Model: llava:13b | Plik: instrukcja-suzuki.png | Koniec: 22:29:50 | Czas: 33.56s
Model: llava:13b | Plik: raport-stanu.png | Start: 22:29:50
Model: llava:13b | Plik: raport-stanu.png | Koniec: 22:30:15 | Czas: 25.00s
Model: llava:13b | Plik: raport.png | Start: 22:30:15
Model: llava:13b | Plik: raport.png | Koniec: 22:30:33 | Czas: 18.05s
Model: llava:13b | Plik: rzecznik.png | Start: 22:30:33
Model: llava:13b | Plik: rzecznik.png | Koniec: 22:31:17 | Czas: 43.54s
Model: llava:13b | Plik: Skany_dokumentow_historycznych_001.jpg | Start: 22:31:17
Model: llava:13b | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 22:31:59 | Czas: 41.57s
Model: llava:13b | Plik: tekst urzedowy.jpg | Start: 22:31:59
Model: llava:13b | Plik: tekst urzedowy.jpg | Koniec: 22:32:12 | Czas: 13.63s

=== Model: minicpm-v ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: minicpm-v | Plik: cv_Darka.png | Start: 22:32:26
Model: minicpm-v | Plik: cv_Darka.png | Koniec: 22:42:59 | Czas: 633.12s
Model: minicpm-v | Plik: instrukcja-suzuki.png | Start: 22:42:59
Model: minicpm-v | Plik: instrukcja-suzuki.png | Koniec: 22:43:35 | Czas: 35.74s
Model: minicpm-v | Plik: raport-stanu.png | Start: 22:43:35
Model: minicpm-v | Plik: raport-stanu.png | Koniec: 22:44:33 | Czas: 58.82s
Model: minicpm-v | Plik: raport.png | Start: 22:44:33
TIMEOUT | Model: minicpm-v | Plik: raport.png | Przekroczono 1000s
Model: minicpm-v | Plik: rzecznik.png | Start: 23:01:13
TIMEOUT | Model: minicpm-v | Plik: rzecznik.png | Przekroczono 1000s
Model: minicpm-v | Plik: Skany_dokumentow_historycznych_001.jpg | Start: 23:17:54
Model: minicpm-v | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 23:18:10 | Czas: 16.52s
Model: minicpm-v | Plik: tekst urzedowy.jpg | Start: 23:18:10
TIMEOUT | Model: minicpm-v | Plik: tekst urzedowy.jpg | Przekroczono 1000s

=== Model: qwen3-vl:8b ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: qwen3-vl:8b | Plik: cv_Darka.png | Start: 23:35:12
Model: qwen3-vl:8b | Plik: cv_Darka.png | Koniec: 23:36:18 | Czas: 66.01s
Model: qwen3-vl:8b | Plik: instrukcja-suzuki.png | Start: 23:36:18
BŁĄD | Model: qwen3-vl:8b | Plik: instrukcja-suzuki.png | UnexpectedModelBehavior: Exceeded maximum retries (1) for output validation
Model: qwen3-vl:8b | Plik: raport-stanu.png | Start: 23:38:20
Model: qwen3-vl:8b | Plik: raport-stanu.png | Koniec: 23:39:41 | Czas: 80.72s
Model: qwen3-vl:8b | Plik: raport.png | Start: 23:39:41
BŁĄD | Model: qwen3-vl:8b | Plik: raport.png | UnexpectedModelBehavior: Exceeded maximum retries (1) for output validation
Model: qwen3-vl:8b | Plik: rzecznik.png | Start: 23:41:37
Model: qwen3-vl:8b | Plik: rzecznik.png | Koniec: 23:43:13 | Czas: 96.31s
Model: qwen3-vl:8b | Plik: Skany_dokumentow_historycznych_001.jpg | Start: 23:43:13
Model: qwen3-vl:8b | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 23:45:50 | Czas: 156.66s
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Start: 23:45:50
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Koniec: 23:47:21 | Czas: 90.99s

=== Model: mistral-small3.2:latest ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Start: 23:47:45
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Koniec: 23:50:31 | Czas: 166.31s
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Start: 23:50:31
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Koniec: 23:54:53 | Czas: 261.35s
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Start: 23:54:53
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Koniec: 23:59:40 | Czas: 287.98s
Model: mistral-small3.2:latest | Plik: raport.png | Start: 23:59:41
Model: qwen3-vl:8b | Plik: Skany_dokumentow_historycznych_001.jpg | Start: 23:43:13
Model: qwen3-vl:8b | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 23:45:50 | Czas: 156.66s
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Start: 23:45:50
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Koniec: 23:47:21 | Czas: 90.99s

=== Model: mistral-small3.2:latest ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Start: 23:47:45
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Koniec: 23:50:31 | Czas: 166.31s
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Start: 23:50:31
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Koniec: 23:54:53 | Czas: 261.35s
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Start: 23:54:53
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Koniec: 23:59:40 | Czas: 287.98s
Model: mistral-small3.2:latest | Plik: raport.png | Start: 23:59:41
Model: mistral-small3.2:latest | Plik: raport.png | Koniec: 00:03:2Model: qwen3-vl:8b | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 23:45:50 | Czas: 156.66s        ecznik.png | Start: 00:03:
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Start: 23:45:50
Model: qwen3-vl:8b | Plik: tekst urzedowy.jpg | Koniec: 23:47:21 | Czas: 90.99s

=== Model: mistral-small3.2:latest ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Start: 23:47:45
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Koniec: 23:50:31 | Czas: 166.31s
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Start: 23:50:31
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Koniec: 23:54:53 | Czas: 261.35s
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Start: 23:54:53
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Koniec: 23:59:40 | Czas: 287.98s
Model: mistral-small3.2:latest | Plik: raport.png | Start: 23:59:41
Model: mistral-small3.2:latest | Plik: raport.png | Koniec: 00:03:2=== Model: mistral-small3.2:latest ===
  Ładowanie modelu do pamięci...
  Model załadowany ✓
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Start: 23:47:45
Model: mistral-small3.2:latest | Plik: cv_Darka.png | Koniec: 23:50:31 | Czas: 166.31s        0
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Start: 23:50:31
Model: mistral-small3.2:latest | Plik: instrukcja-suzuki.png | Koniec: 23:54:53 | Czas: 261.35s
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Start: 23:54:53
Model: mistral-small3.2:latest | Plik: raport-stanu.png | Koniec: 23:59:40 | Czas: 287.98s    
_Darka.png | Koniec: 00:27:07 | Czas: 36.76s
Model: gemma3:12b | Plik: instrukcja-suzuki.png | Start: 00:27:07
Model: gemma3:12b | Plik: instrukcja-suzuki.png | Koniec: 00:28:11 | Czas: 64.33s
Model: gemma3:12b | Plik: raport-stanu.png | Start: 00:28:11
Model: gemma3:12b | Plik: raport-stanu.png | Koniec: 00:29:22 | Czas: 70.85s
Model: gemma3:12b | Plik: raport.png | Start: 00:29:22
Model: gemma3:12b | Plik: raport.png | Koniec: 00:30:28 | Czas: 65.46s
Model: gemma3:12b | Plik: rzecznik.png | Start: 00:30:28
Model: gemma3:12b | Plik: rzecznik.png | Koniec: 00:31:19 | Czas: 51.68s
Model: gemma3:12b | Plik: Skany_dokumentow_historycznych_001.jpg | Start: 00:31:20
Model: gemma3:12b | Plik: Skany_dokumentow_historycznych_001.jpg | Koniec: 00:44:03 | Czas: 763.24s
Model: gemma3:12b | Plik: tekst urzedowy.jpg | Start: 00:44:03
Model: gemma3:12b | Plik: tekst urzedowy.jpg | Koniec: 00:45:10 | Czas: 66.82s

Tabela wyników – sukces/porażka

Modelcv_Darkainstrukcjaraport-stanuraportrzecznikskan_1941tekst_urzSukcesAvg czas
llava:13bXXXXXXX0/7 (0%)37s
minicpm-vxxx4/7 (43%)186s
qwen3-vl:8bxx5/7 (71%)98s
mistral-small3.2x6/7 (86%)219s
gemma3:12b7/7 (100%)157s

Legenda: ✓ = sukces, X = błąd, timeout (>1000s)

RANKING FINALNY

1. Gemma3:12b – DZISIEJSZY ZWYCIĘZCA

Wynik: 7/7 (100% sukcesu)
Średni czas: 157s
Zakres czasów: 37s – 763s

✅ Mocne strony:

  • Jedyny model który przetworzył wszystkie 7 dokumentów
  • Najlepszy na historycznej gazecie (763s, ale się udało!)
  • Świetna równowaga szybkość/jakość
  • Dobra obsługa tabel i wykresów

⚠️ Słabe strony:

  • Trochę halucynacji
  • Czasem jakoś gorsza niż Mistral

Rekomendacja: Najlepszy do produkcji, ale wymaga walidacji wyników na nietypowych dokumentach

2. Mistral-small3.2 – Wczoraj pierwszy a dziś drugi

Wynik: 6/7 (86% sukcesu)
Średni czas: 219s
Zakres czasów: 159s – 288s

✅ Mocne strony:

  • Zero halucynacji – tylko faktyczna treść
  • Najlepsza jakość OCR (perfekcyjne tabele, formatowanie)
  • Stabilne czasy przetwarzania
  • Świetna obsługa wykresów (opisuje je tekstowo)

❌ Słabe strony:

  • Timeout na historycznej gazecie (jedyny błąd)
  • Najwolniejszy z działających modeli
  • 1.4x wolniejszy niż Gemma

Rekomendacja: Wybór #1 gdy jakość > szybkość. Produkcja bez ryzyka – ale może wymagać zwiększenia dopuszczalnego czasu oczekiwania;) lub mocniejszego sprzętu.

3. Qwen3-vl:8b – NIESPODZIANKA

Wynik: 5/7 (71% sukcesu)
Średni czas: 98s
Zakres czasów: 66s – 157s

✅ Mocne strony:

  • Najszybszy z działających i nadających się do czegokolwiek modeli
  • Drugi który odczytał historyczną gazetę (157s!)
  • Dobra jakość na prostych dokumentach
  • 1.6x szybszy niż Gemma

❌ Słabe strony:

  • 2x UnexpectedModelBehavior (instrukcja, raport)
  • Błędy w szczegółach (SOL zamiast SQL)
  • Niestabilny – losowe błędy walidacji

Rekomendacja: Dobry do szybkich testów, ale niestabilny do produkcji.

4. MiniCPM-v – NIESTABILNY

Wynik: 3/7 (43% sukcesu)
Średni czas: 186s (bez timeoutów)
3x TIMEOUT (raport, rzecznik, tekst_urz)

✅ Mocne strony:

  • Czasami szybki (16s, 36s)
  • Odczytał historyczną gazetę (16s!) ale bezużytecznie

❌ Słabe strony:

  • 3 timeouty na średnio trudnych dokumentach
  • Chaotyczne czasy (16s → 633s → timeout)
  • Słaba jakość OCR (błędy, dziwne formatowanie)
  • 633s na CV (najdłużej ze wszystkich!)

Rekomendacja: NIE używać – zbyt niestabilny.

5. Llava:13b – BEZUŻYTECZNY DO OCR

Wynik: 0/7 (0% sukcesu)
Średni czas: 37s
Zakres czasów: 14s – 84s

✅ Mocne strony:

  • Bardzo szybki
  • Dobre opisy obrazów po angielsku

❌ Słabe strony:

  • NIE ROBI OCR – tylko opisuje obrazy
  • 100% porażka na wszystkich dokumentach
  • Bezużyteczny do ekstrakcji tekstu

🎯 Rekomendacja: NIE używać do OCR – model do opisu obrazów, nie ekstrakcji tekstu.

🎯 WNIOSKI I REKOMENDACJE

Dla produkcji:

1. Gemma3:12b – gdy potrzebujesz szybkości

  • ✅ Przetworzy każdy dokument
  • ⚠️ Wymaga walidacji (ryzyko halucynacji)
  • 💰 Najlepszy stosunek jakość/szybkość

lub

2. Mistral-small3.2 – gdy potrzebujesz perfekcyjnej jakości

  • ✅ Zero halucynacji
  • ✅ Idealne tabele i formatowanie
  • ❌ Wolniejszy, może timeout na ekstremalnych plikach

Pliki z testu do pobrania, sprawdź faktyczne wyniki: https://drive.google.com/file/d/1q4zYXlimMxZfDnSOES3p0elsI9BfwsY5/view?usp=sharing

2 komentarze

  1. Uka Sh

    Witam, ciekawy lab. Lecz uwagi mam, brak informacji o stopniu kwatetyzacji modeli trochę szkoda. Wykonywałem podobne testy kw. BF16 tych modeli na runtime vLLM. Mam trochę inne doświadczenia, Qwen 3-VL zniszczył konkurentów. Gemma podobnie jak u Pana. Llava to samo porażka. Jeszcze próbowałem Granit z IBM też dawał rade. Co z promptami użytkownika i Base rules? Te same dla wszystkich? Czy indywidualnie podejście do modeli? Różne modele inaczej rozumieją prompty. Ja swoje testy oparłem o dokumenty wypełnione pismem odręcznym.

    Odpowiedz

Wyślij komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Share This

Share this post with your friends!