88 lines
2.8 KiB
Python
88 lines
2.8 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Local receipt OCR: given a receipt image, print an ordered list of rows of text.
|
||
|
|
|
||
|
|
Uses a local Qwen2.5-VL model served by ollama. No cloud calls, no API key.
|
||
|
|
Zero third-party dependencies — talks to the ollama HTTP API with the stdlib.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 receipt_ocr.py [image.jpg] # pretty, numbered rows
|
||
|
|
python3 receipt_ocr.py image.jpg --json # raw JSON {"rows": [...]}
|
||
|
|
|
||
|
|
Env:
|
||
|
|
OLLAMA_HOST default http://127.0.0.1:11434
|
||
|
|
OLLAMA_MODEL default qwen2.5vl:7b
|
||
|
|
"""
|
||
|
|
|
||
|
|
import base64
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import urllib.request
|
||
|
|
|
||
|
|
DEFAULT_IMAGE = os.path.join(os.path.dirname(__file__), "example", "data", "aldi.jpg")
|
||
|
|
HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
|
||
|
|
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5vl:7b")
|
||
|
|
|
||
|
|
PROMPT = (
|
||
|
|
"You are an OCR engine for retail receipts. Transcribe EVERY line of text on "
|
||
|
|
"this receipt, from top to bottom, exactly as printed. Each physical row of the "
|
||
|
|
"receipt becomes one string in the output, with its columns joined left-to-right "
|
||
|
|
"and separated by single spaces (e.g. an item code, its name, and its price all "
|
||
|
|
"go in the same row). Do not merge or split rows, do not reorder, do not add "
|
||
|
|
"commentary, do not correct spelling, do not invent text. Preserve numbers and "
|
||
|
|
"punctuation verbatim. Return strict JSON only."
|
||
|
|
)
|
||
|
|
|
||
|
|
# Structured-output schema: forces the model to return {"rows": ["...", ...]}.
|
||
|
|
SCHEMA = {
|
||
|
|
"type": "object",
|
||
|
|
"properties": {"rows": {"type": "array", "items": {"type": "string"}}},
|
||
|
|
"required": ["rows"],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def ocr(image_path):
|
||
|
|
with open(image_path, "rb") as f:
|
||
|
|
b64 = base64.b64encode(f.read()).decode("ascii")
|
||
|
|
|
||
|
|
payload = {
|
||
|
|
"model": MODEL,
|
||
|
|
"messages": [{"role": "user", "content": PROMPT, "images": [b64]}],
|
||
|
|
"stream": False,
|
||
|
|
"format": SCHEMA,
|
||
|
|
"options": {"temperature": 0, "num_ctx": 8192},
|
||
|
|
}
|
||
|
|
|
||
|
|
req = urllib.request.Request(
|
||
|
|
f"{HOST}/api/chat",
|
||
|
|
data=json.dumps(payload).encode("utf-8"),
|
||
|
|
headers={"Content-Type": "application/json"},
|
||
|
|
)
|
||
|
|
with urllib.request.urlopen(req, timeout=600) as resp:
|
||
|
|
body = json.load(resp)
|
||
|
|
|
||
|
|
content = body["message"]["content"]
|
||
|
|
return json.loads(content)["rows"]
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||
|
|
as_json = "--json" in sys.argv
|
||
|
|
image_path = args[0] if args else DEFAULT_IMAGE
|
||
|
|
|
||
|
|
if not os.path.exists(image_path):
|
||
|
|
sys.exit(f"image not found: {image_path}")
|
||
|
|
|
||
|
|
rows = ocr(image_path)
|
||
|
|
|
||
|
|
if as_json:
|
||
|
|
print(json.dumps({"rows": rows}, ensure_ascii=False, indent=2))
|
||
|
|
else:
|
||
|
|
width = len(str(len(rows)))
|
||
|
|
for i, row in enumerate(rows, 1):
|
||
|
|
print(f"{str(i).rjust(width)} {row}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|