Local OCR with Ollama and Qwen2.5-VL: Perfect on Screenshots, Confidently Wrong on Blurry Photos

I often need to get a table out of a screenshot: a parts list from a supplier page, a spec table from a PDF, a statement from an app that won't export. Cloud vision models do this well, but some screenshots I'd rather not upload anywhere. So I run OCR locally with Ollama and qwen2.5vl:7b, a 7B vision-language model, on my ASUS Ascent GX10.

For this post I tested it on a parts table I generated, so the correct answer is known exactly. The short version: on a clean screenshot it was perfect, in about 5 seconds. On a small, blurry image it invented a part that isn't there, and it can't be trusted to add up the numbers it reads.

Setup

ollama pull qwen2.5vl:7b    # about 6 GB

Then a small script that sends the image to Ollama's chat API. It uses only the Python standard library:

#!/usr/bin/env python3
"""Local OCR with Ollama + qwen2.5vl: turn a screenshot (e.g. a table) into Markdown.
The image never leaves your machine.

Usage:
  python3 ocr_table.py screenshot.png
  python3 ocr_table.py screenshot.png "List only the part numbers"
"""
import base64
import json
import sys
import time
import urllib.request

MODEL = "qwen2.5vl:7b"
DEFAULT = ("Transcribe all text in this image exactly as written. "
           "Format tables as Markdown tables. Do not add any explanation.")

if len(sys.argv) < 2:
    sys.exit(__doc__.strip())
image, prompt = sys.argv[1], " ".join(sys.argv[2:]) or DEFAULT

body = {
    "model": MODEL,
    "stream": False,
    "options": {"temperature": 0},  # same image -> same answer
    "messages": [
        {"role": "system", "content": "You are a precise OCR assistant. "
         "If something is unreadable, write [unreadable] instead of guessing."},
        {"role": "user", "content": prompt,
         "images": [base64.b64encode(open(image, "rb").read()).decode()]},
    ],
}
req = urllib.request.Request("http://127.0.0.1:11434/api/chat",
                             json.dumps(body).encode(),
                             {"Content-Type": "application/json"})
t = time.time()
reply = json.load(urllib.request.urlopen(req, timeout=600))
print(reply["message"]["content"].strip())
print(f"\n[{time.time() - t:.1f} s]", file=sys.stderr)

Two settings matter. temperature: 0 makes the output repeatable, so the same image gives the same text every time (I checked: two runs were identical). And the system prompt tells the model to write [unreadable] instead of guessing. As you'll see, it doesn't always obey.

Test 1: a clean screenshot

This is the test image: a 9-row parts list with units (kΩ, µF), part numbers and prices, at normal screenshot resolution (660 × 326 pixels).

Test image: a parts list rendered at screenshot resolution
Test image: a parts list rendered at screenshot resolution

The output, unedited:

| Part | Qty | Value | Package | Unit price |
| --- | --- | --- | --- | --- |
| Resistor | 12 | 4.7 kΩ | 0603 | $0.012 |
| Resistor | 4 | 220 Ω | 0805 | $0.010 |
| Capacitor | 8 | 100 nF | 0603 | $0.018 |
| Capacitor | 2 | 10 µF | 1206 | $0.095 |
| LED (red) | 6 | Vf 2.0 V | 0805 | $0.041 |
| NPN transistor | 3 | 2N2222A | SOT-23 | $0.064 |
| Voltage regulator | 1 | AMS1117-3.3 | SOT-223 | $0.210 |
| ESP32-C3 module | 1 | 4 MB flash | SMD | $2.350 |

Every cell is correct, including the Ω and µ symbols, AMS1117-3.3 and SOT-223. The first run took 9.8 seconds including loading the model into memory; after that each image took about 5 seconds. I also tried a half-size copy (340 × 173 pixels): still 100% correct, in 5.9 seconds.

Test 2: a small, blurry, tilted image

Next I made the image look like a bad phone photo: shrunk to 40%, rotated 3 degrees, slightly blurred, and saved as a low-quality JPEG (272 × 144 pixels).

The same table, shrunk, tilted, blurred and saved as a low-quality JPEG
The same table, shrunk, tilted, blurred and saved as a low-quality JPEG

The output:

| Part | Qty | Value | Package | Unit price |
| --- | --- | --- | --- | --- |
| Resistor | 12 | 4.7 kΩ | 0603 | $0.012 |
| Resistor | 4 | 220 Ω | 0805 | $0.009 |
| Capacitor | 8 | 100 nF | 0603 | $0.018 |
| Capacitor | 2 | 10 μF | 1206 | $0.095 |
| LED (red) | 8 | VF 2.0 V | 0805 | $0.043 |
| MOSFET | 1 | IRFZ44N | SOT-23 | $0.064 |
| Voltage regulator | 2 | AMS1117-3.3 | SOT-223 | $0.210 |
| ESP32-C3 module | 1 | 4 MB flash | SMD | $2.350 |

The table structure survived, and most cells are right. But compare it with the original:

  • The NPN transistor, qty 3, 2N2222A row became MOSFET, qty 1, IRFZ44N. That's a real part number, and it isn't anywhere in the image.
  • LED quantity 6 became 8, and its price $0.041 became $0.043.
  • The regulator quantity 1 became 2; one resistor price $0.010 became $0.009.
  • It never wrote [unreadable]. Every wrong value looks as confident as the right ones.

This is the important failure mode. The model doesn't produce garbled text when it can't read something. It produces plausible text: a common MOSFET in place of a common transistor, nearby digits in place of the real ones. A quick glance at the output won't catch it.

Test 3: asking it to do the math

Back to the clean image, I asked: "What is the total cost of all parts (Qty times Unit price, summed)? Show the calculation." It listed every line item correctly (12 × $0.012 = $0.144, and so on), then added them up:

$0.144 + $0.040 + $0.144 + $0.190 + $0.246 + $0.192 + $0.210 + $2.350 = $4.286

The correct sum of those eight numbers is $3.516. Every input was right and the final addition was wrong by 22%. Use the model to read the numbers, and do the arithmetic in code.

How I use it now

  • Screenshots, not photos. Native-resolution screenshots were perfect. Photos of screens and paper are where it starts inventing.
  • Ask for transcription only. Get the Markdown table, then parse it and compute totals in Python.
  • Check the numbers that matter. Quantities, prices and part numbers: compare a few against the source, especially if the image is small.
  • Crop and enlarge. If the source is small, crop to just the table and upscale before sending it; more pixels per character is what helped.

For sensitive screenshots, this is now my default: nothing leaves the machine, and on a GX10 it's fast enough that I don't think about it. For the speed of the text models on the same machine, see Ollama on the GX10: real tokens per second.

Comments

Popular posts from this blog

Gear Ratio Calculator: Multi-Stage Ratio, Output Speed, Torque and Direction

Mecanum-Wheel Pick-and-Place Robot: From Arduino UNO to Mega2560 + ESP32-CAM

Voltage Divider Calculator: Output Voltage, or the Best E24 Resistor Pair for a Target Voltage