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 families are gemma-4 dense (the gemma4uv embedder), gemma-4 E-series (the gemma4v ViT tower), and gemma-3 (the gemma3v SigLIP tower), each shipped as a decoder GGUF plus its vision multimodal projector (mmproj) GGUF; the loader sniffs which family an mmproj is.

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_vision_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_vision_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 = VisionState()
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.

The qwen families (Qwen3-Omni, Qwen3-VL, Qwen2.5-Omni) rope image rows by their 2D patch position instead of by sequence order. After the encode, vision_mrope_grid reports the merged grid, and eval_embd_span_mrope prefills the same non-causal span with grid-shaped angles — the position advance after the image is max(grid.x, grid.y), not the row count, and the session tracks that delta for every later eval:

let grid = vision_mrope_grid(emb, scratch)
eval_embd_span_mrope(m, s, rows, np, n_head, n_head + n_img, grid)

A gemma pair reports a zero grid — the plain eval_embd_span shape serves it, and the tutorial’s third section falls back exactly that way. The same section then runs the one-call form: generate_embd takes the spliced rows, the span bounds and the grid, prefills and streams the reply — what respond runs under the hood for a media turn. The section closes with the pre-encoded-rows seam: add_user_image_rows moves the same soft tokens and the grid onto a plain chat — no embedder attached — and respond then runs the spliced turn. That is the path for a scheduler that owns its own embedder:

var chat2 = create_chat(m, "", 96l)
add_user_image_rows(m, chat2, img_rows, n_img, grid)
add_user(chat2, prompt)

One boundary worth knowing: a deepstack pair (dense Qwen3-VL) encodes wider rows — (1 + n_deepstack) × dim floats each; the narrow by-hand splice still captions (the extra slices are additive refinement), and create_chat(model, embedder) or the add_user_image_rows seam are the paths that carry them in full — the seam length-checks the wide quantum.

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