8.7.15. dasLLAMA-14 — Vision Chat

Tutorial 08 gave a conversation ears. This one gives it eyes. A vision chat model pairs a normal text decoder with an image encoder: the encoder turns pixels into soft tokens — embedding rows the decoder reads inline with text, one row per group of image patches. The supported family is gemma-4 dense, shipped as a decoder GGUF plus its vision multimodal projector (mmproj) GGUF.

Run it with a decoder, its mmproj, and any image stbimage decodes:

daslang.exe -jit tutorials/dasLLAMA/14_vision_chat.das -- decoder.gguf mmproj.gguf photo.jpg "What is in this picture?"

8.7.15.1. A conversation that can see

The easy path is four calls. load_image_rgb decodes the file to an RGB8 VisionImage. load_gemma4uv_embedder reads the mmproj — the vision half of the model. create_chat(model, embedder) moves the embedder into the chat, and add_user_image queues the picture for the next turn: geometry, letterbox and the encoder run right there, so respond only prefills and generates. One image per turn; text via add_user rides along:

var inscope img <- load_image_rgb(image_path)
var emb <- load_gemma4uv_embedder(mmproj_path)
var chat <- create_chat(m, emb, "", 96l)
add_user_image(chat, img)
add_user(chat, "Describe this image in one sentence.")
respond(m, chat, SamplingParams()) $(piece) {
    print("{piece}")
    return true
}

8.7.15.2. Soft tokens by hand

Now the same turn built ourselves — the shape a scheduler uses when the encoder runs on a worker and the decoder session lives elsewhere. encode_image is the whole image path in one call: geometry, letterbox, normalize, encode. It appends npos rows of model.config.dim floats and returns npos. render_turn_image renders the turn’s tokens as two spans — head before the image rows, tail after — split exactly at the template’s image marker, so tokenizer merges never cross the picture:

var scratch = Gemma4uvState()
var img_rows : array<float>
let n_img = encode_image(emb, scratch, img, "tag", img_rows)

var chat = create_chat_renderer(m, "", 96l)
add_user(chat, prompt)
var head : array<int64>
var tail : array<int64>
render_turn_image(m, chat, head, tail)

The prompt we prefill is head rows + image rows + tail rows, one array — prefilling tokens is prefilling their embedding rows (tutorial 08 proves that identity), so embed_text_rows fills the text parts. Then eval_embd_span runs the whole thing with one twist: the rows in [span_lo, span_hi) are non-causal — every query in the span attends the whole span, because a picture has no left-to-right order. The text around it stays causal:

eval_embd_span(m, s, rows, np, n_head, n_head + n_img)
// ...then sample() one token at a time, like any session

eval_embd is the same call without a span — every row causal. Audio rows keep their time order, so the audio family prefills through it; use it whenever you already hold the embedding rows and no image span is in play.

See also

Full source: tutorials/dasLLAMA/14_vision_chat.das

Next tutorial: dasLLAMA-15 — The Prefix Cache by Hand

The audio twin of this tutorial: dasLLAMA-08 — Audio Chat