Image Processing With Local Ollama Models

Here we use a very small model qwen3.5:0.8b to support the use case of natural language queries to read image files and answer questions on the image content. This can easily be customized for specific data processing pipelines.

The code for this example can be found in the directory loving-common-lisp/src/ollama_images.

Design Notes for the Example code

Briefly dear reader, here are my design notes for this project:

  • Network Delegation to litelm: The module performs no HTTP of its own. Every request goes through the litelm library (src/litelm), which routes a "provider/model" string to an OpenAI-compatible endpoint using dexador. That removes the curl subprocess, the hand-rolled JSON encoder, and the external JSON decoder that earlier drafts of this example required.
  • Multimodal Messages as Lisp Data: The prompt and the images travel as ordinary Lisp structures in litelm’s message format – a :user message whose content is the OpenAI-compatible content-parts list, one text part plus one image_url part per image. litelm encodes that to JSON internally, so there is no wire format to maintain by hand and no shell is involved, which eliminates the command-injection surface entirely.
  • Structured Error Handling: Because litelm maps HTTP failures onto a condition hierarchy (litelm:not-found-error, litelm:api-error, litelm:rate-limit-error, and friends), an unknown model name or an unreachable server surfaces as a catchable Lisp condition instead of a string that has to be parsed.
  • Functional Wrappers: The public API (image-to-text, describe-image-simple) hides the encoding and transport complexity, presenting a clean, functional interface with sensible dynamic variables (model-name, ollama-host) for environment overrides.

Code to Process Images

This Common Lisp module acts as a lightweight client for the Ollama vision API. It base64-encodes local images, wraps each one in an OpenAI-compatible image_url content part, and hands the resulting message to litelm:completion, which builds the request, performs the HTTP POST, and decodes the reply. The only work left to this file is turning image files into base64 strings and shaping them as litelm content parts.

  1 ;;;; describe-image.lisp — Send images to Ollama vision models for description
  2 ;;;;
  3 ;;;; Usage (from SBCL REPL):
  4 ;;;;   (load "describe-image.lisp")
  5 ;;;;   (describe-image:image-to-text "ticket.png" "Print out the plain text in this image")
  6 ;;;;   (describe-image:image-to-text '("a.png" "b.png") "Compare these two images.")
  7 ;;;;   (describe-image:describe-image-simple "photo.jpg")
  8 ;;;;
  9 ;;;; Environment:
 10 ;;;;   OLLAMA_MODEL — optional model override (default: qwen3.5:0.8b)
 11 ;;;;   OLLAMA_HOST  — optional OpenAI-compatible base URL override
 12 ;;;;                  (default: http://localhost:11434/v1)
 13 ;;;;
 14 ;;;; Transport, JSON encoding/decoding, and error mapping are delegated to the
 15 ;;;; litelm library (src/litelm), the same way the ollama package does it.
 16 
 17 (ql:quickload '(:litelm :cl-base64 :uiop) :silent t)
 18 
 19 (defpackage #:describe-image
 20   (:use #:cl)
 21   (:export #:image-to-text
 22            #:describe-image-simple
 23            #:*model-name*
 24            #:*ollama-host*))
 25 
 26 (in-package #:describe-image)
 27 
 28 (defvar *model-name* (or (uiop:getenv "OLLAMA_MODEL") "qwen3.5:0.8b")
 29   "Default vision-capable model for image queries.")
 30 
 31 (defvar *ollama-host* (or (uiop:getenv "OLLAMA_HOST") "http://localhost:11434/v1")
 32   "OpenAI-compatible Ollama API base URL; litelm appends /chat/completions.")
 33 
 34 (defun encode-image (image-path)
 35   "Read IMAGE-PATH file and return its contents as a base64-encoded string.
 36    Signals an error immediately if the file does not exist."
 37   (unless (probe-file image-path)
 38     (error "Image file not found: ~a" image-path))
 39   (with-open-file (in image-path :element-type '(unsigned-byte 8))
 40     (let ((bytes (make-array (file-length in) :element-type '(unsigned-byte 8))))
 41       (read-sequence bytes in)
 42       (cl-base64:usb8-array-to-base64-string bytes))))
 43 
 44 (defun image-media-type (image-path)
 45   "Return the media type for IMAGE-PATH, derived from its file extension."
 46   (let ((type (string-downcase (or (pathname-type image-path) ""))))
 47     (cond ((member type '("jpg" "jpeg") :test #'string=) "image/jpeg")
 48           ((string= type "gif")  "image/gif")
 49           ((string= type "webp") "image/webp")
 50           ((string= type "bmp")  "image/bmp")
 51           (t "image/png"))))
 52 
 53 (defun text-part (prompt)
 54   "Build the OpenAI-compatible text content part for PROMPT."
 55   (list (cons "type" "text")
 56         (cons "text" prompt)))
 57 
 58 (defun image-part (image-path base64)
 59   "Build the OpenAI-compatible image content part for one base64-encoded image.
 60    IMAGE-PATH supplies the media type; BASE64 is the encoded image data."
 61   (list (cons "type" "image_url")
 62         (cons "image_url"
 63               (list (cons "url"
 64                           (concatenate 'string
 65                                        "data:" (image-media-type image-path)
 66                                        ";base64," base64))))))
 67 
 68 (defun ensure-model-name (model)
 69   "Ensure MODEL has a provider prefix for litelm routing (defaults to ollama/)."
 70   (if (find #\/ model)
 71       model
 72       (concatenate 'string "ollama/" model)))
 73 
 74 (defun call-ollama-vision (image-parts prompt &key (model *model-name*) (host *ollama-host*))
 75   "Send IMAGE-PARTS (content parts built by IMAGE-PART) and PROMPT to Ollama.
 76    litelm builds the request, performs the HTTP POST, and decodes the response.
 77    Returns the model's text response string."
 78   (let ((response (litelm:completion (ensure-model-name model)
 79                                      :messages (list (list :user (cons (text-part prompt)
 80                                                                        image-parts)))
 81                                      :api-base host)))
 82     (or (litelm:response-content response) "")))
 83 
 84 (defun image-to-text (image-paths prompt &key (model nil) (host nil))
 85   "Send one or more images to an Ollama vision model with PROMPT and return text.
 86 
 87    IMAGE-PATHS may be a single path string or a list of path strings.
 88    Optional keyword arguments:
 89      :model — override *model-name*
 90      :host  — override *ollama-host*
 91 
 92    Examples:
 93      (describe-image:image-to-text \"test.jpg\" \"What is in this image?\")
 94      (describe-image:image-to-text \"ticket.png\" \"Print out the text\" :model \"llava\")
 95      (describe-image:image-to-text '(\"before.png\" \"after.png\") \"Compare these images.\")"
 96   (let ((paths (if (listp image-paths) image-paths (list image-paths))))
 97     ;; Validate all paths upfront before encoding any of them
 98     (dolist (p paths)
 99       (unless (probe-file p)
100         (error "Image file not found: ~a" p)))
101     (let ((parts      (loop for p in paths
102                             collect (image-part p (encode-image p))))
103           (use-model  (or model *model-name*))
104           (use-host   (or host  *ollama-host*)))
105       (call-ollama-vision parts prompt :model use-model :host use-host))))
106 
107 (defun describe-image-simple (image-path)
108   "Convenience wrapper — describe a single image using the default model and prompt.
109    Equivalent to: (image-to-text IMAGE-PATH \"What is in this image?\")"
110   (image-to-text image-path "What is in this image?"))

This lightweight module serves as an excellent foundation for embedding local, privacy-preserving vision capabilities into larger architectures. You could use it to automate the annotation and curation of proprietary training datasets by running bulk inference on unlabelled image directories, integrate it into a CI/CD pipeline for intelligent visual regression testing to compare “before and after” graphical states, or deploy it as the ingestion layer for an expert system that extracts structured semantic data from scanned technical diagrams and legacy documents without relying on external cloud APIs.

The following diagram shows the high-level architecture of the Ollama image processing module developed in this chapter:

Architecture diagram

Example Program Output

Here is a sample run:

 1 $ sbcl
 2 * (load "describe-image.lisp")
 3 * (describe-image:image-to-text "ticket.png" "Print out the text in this image")
 4 "Fanfares and Fireworks
 5 Flagstaff Symphony Orchestra
 6 Ardrey Memorial Auditorium
 7 Friday, September 26, 2025
 8 7:30 PM (AZ)
 9 
10 Level Section Row Seat
11 Main Main Level M 31
12 
13 WJJNBY.1.2406.1498
14 Friday, September 26, 2025 @ 7:30 PM
15 
16 Price Service Fee Ticket Type
17 $53.00 $0.00 Early Bird Tickets
18 New Subscriber C3
19 
20 The unique barcodes on this ticket allow only one entry to the event. If multiple copies of an ETTicket are made, the first copy of the ETTicket to arrive at the event will gain entry after scanning and validation. Other copies of this ticket will be denied entry."
21 * 

Optional Practice Problems

  1. Batch Directory Processor: Write a function describe-images-in-directory that takes a directory path and an optional prompt string (defaulting to “What is in this image?”), finds all .png, .jpg, and .jpeg files in the directory using uiop:directory-files, calls describe-image:image-to-text on each one, and returns an alist of (filename . description) pairs. Print a progress line for each image processed. This exercise practices combining UIOP file utilities with the vision API for bulk processing.

  2. Model Comparison: The *model-name* variable defaults to qwen3.5:0.8b, but Ollama supports several vision models (e.g., llava, llava:13b, moondream). Write a function compare-models that takes an image path, a prompt, and a list of model name strings. For each model, call image-to-text with the :model keyword, measure the wall-clock time using get-internal-real-time, and print the model name, response time in seconds, and the first 200 characters of the response. Discuss how response quality and speed vary across model sizes.

  3. Structured Data Extraction: The image-to-text function returns free-form text. Write a function extract-ticket-info that takes an image path of a ticket or receipt, crafts a prompt that asks the model to return the data as a JSON object with specific keys (e.g., "event", "date", "time", "price", "seat"), calls image-to-text with that prompt, and then parses the returned JSON string with litelm:json-decode. Return the parsed alist. Handle the case where the model returns text that isn’t valid JSON by wrapping the parse in handler-case and falling back to the raw text.

  4. Image Diff Reporter: The image-to-text function already supports multiple images. Write a function visual-diff-report that takes two image paths (e.g., a “before” and “after” screenshot), sends them both with a prompt like “Describe all visual differences between these two images in a numbered list”, and returns the model’s comparison. Then write a wrapper visual-regression-test that takes a directory of “expected” images and a directory of “actual” images, pairs them by filename, and generates a diff report for each pair. This simulates a visual regression testing pipeline.

  5. Fetch and Describe URL Images: The current encode-image function only reads local files. Write a function encode-image-from-url that uses uiop:run-program to call curl to download an image from a URL into a temporary file (via uiop:with-temporary-file), then base64-encodes it. Write a public wrapper url-image-to-text that accepts a URL string and a prompt, downloads the image, and passes the local path to the existing image-to-text function. Test it with a publicly accessible image URL. Be sure to clean up the temporary file afterwards.

  6. Conversational Image REPL: The current API is single-shot — each call to image-to-text is independent. Write a function image-chat that loads one or more images, displays an initial description, then enters a REPL loop where the user can ask follow-up questions about the same images. Maintain a chat-history list of (role content) messages in litelm’s message format and modify call-ollama-vision (or write a variant) to pass that full list to litelm:completion rather than the single :user message it builds today. This lets the model reference its previous answers when the user asks “What color is the text?” after an initial “Describe this image” turn.