Skip to content

Commit bbb8dd6

Browse files
committed
Add Bumblebee.Multimodal.ImageTextToText.generate helper
Single-call generation for vision-language prompts. Featurizes the image, expands the <|image_pad|> marker in the prompt to the correct number of visual tokens (derived from image_grid_thw + spatial_merge), runs Bumblebee.Text.Generation, and decodes the result. This is intentionally a function, not an Nx.Serving, because Nx.Batch requires every tensor in the batch to share the same first-axis size — which breaks for Qwen3-VL since pixel_values is shaped {num_patches, _} while input_ids is shaped {1, seq_len}. A proper batched serving needs static-shape padding so different image sizes can share one compiled graph; that work is a follow-up. Real-model check on Qwen/Qwen3-VL-2B-Instruct + COCO image 39769: generated "A group of cats lying on a pink blanket with remote controls." in 28.7s (includes JIT compile). Refs #442.
1 parent 7c19bb0 commit bbb8dd6

2 files changed

Lines changed: 146 additions & 20 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule Bumblebee.Multimodal.ImageTextToText do
2+
@moduledoc """
3+
Generation helper for vision-language models like Qwen3-VL.
4+
5+
This wraps featurization, prompt expansion, and `Bumblebee.Text.Generation`
6+
in a single call. Each call recompiles the generation graph if the
7+
image or prompt produces a different total patch count or sequence
8+
length, which makes this best suited for interactive or one-shot use.
9+
For high-throughput serving with batched, varying image sizes, see
10+
the static-shape padding follow-up.
11+
"""
12+
13+
alias Bumblebee.Text
14+
15+
@placeholder "<|image_pad|>"
16+
17+
@doc """
18+
Generates text from a prompt that includes a `<|image_pad|>` marker
19+
and an image.
20+
21+
## Required arguments
22+
23+
* `model_info` - a loaded `Bumblebee.Multimodal.Qwen3VL` (or compatible)
24+
model
25+
* `featurizer` - a configured `Bumblebee.Vision.Qwen3VLFeaturizer`
26+
* `tokenizer` - a loaded tokenizer for the same model
27+
* `generation_config` - a `Bumblebee.Text.GenerationConfig`
28+
* `text` - the user prompt containing exactly one `<|image_pad|>` marker
29+
* `image` - an image tensor or `t:StbImage.t/0`
30+
31+
## Returns
32+
33+
%{text: "<generated text>", token_ids: [...]}
34+
35+
## Example
36+
37+
{:ok, model_info} = Bumblebee.load_model({:hf, "Qwen/Qwen3-VL-2B-Instruct"})
38+
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, "Qwen/Qwen3-VL-2B-Instruct"})
39+
40+
{:ok, featurizer} =
41+
Bumblebee.load_featurizer({:hf, "Qwen/Qwen3-VL-2B-Instruct"},
42+
module: Bumblebee.Vision.Qwen3VLFeaturizer
43+
)
44+
45+
featurizer = Bumblebee.configure(featurizer, quality: :low)
46+
{:ok, gen_config} = Bumblebee.load_generation_config({:hf, "Qwen/Qwen3-VL-2B-Instruct"})
47+
gen_config = Bumblebee.configure(gen_config, max_new_tokens: 64)
48+
49+
Bumblebee.Multimodal.ImageTextToText.generate(
50+
model_info, featurizer, tokenizer, gen_config,
51+
"<|im_start|>user\\n<|vision_start|><|image_pad|><|vision_end|>What is in this image?<|im_end|>\\n<|im_start|>assistant\\n",
52+
image
53+
)
54+
"""
55+
def generate(
56+
model_info,
57+
featurizer,
58+
tokenizer,
59+
%Text.GenerationConfig{} = generation_config,
60+
text,
61+
image
62+
) do
63+
%{model: model, params: params, spec: spec} = model_info
64+
65+
unless Map.has_key?(spec, :image_token_id) do
66+
raise ArgumentError,
67+
"expected a multimodal model with :image_token_id, got #{inspect(spec.__struct__)}"
68+
end
69+
70+
merge_size =
71+
case spec do
72+
%{vision_spec: %{spatial_merge_size: ms}} -> ms
73+
_ -> 1
74+
end
75+
76+
image_inputs = Bumblebee.apply_featurizer(featurizer, image)
77+
visual_tokens = visual_tokens_for(image_inputs["image_grid_thw"], merge_size)
78+
expanded_text = expand_marker(text, visual_tokens)
79+
80+
tokenizer = Bumblebee.configure(tokenizer, return_token_type_ids: false)
81+
text_inputs = Bumblebee.apply_tokenizer(tokenizer, expanded_text)
82+
83+
inputs =
84+
text_inputs
85+
|> Map.merge(image_inputs)
86+
|> Map.put("seed", Nx.tensor([:erlang.system_time()], type: :s64))
87+
88+
generate_fun = Text.Generation.build_generate(model, spec, generation_config)
89+
%{token_ids: token_ids} = generate_fun.(params, inputs)
90+
91+
decoded =
92+
token_ids
93+
|> Nx.to_batched(1)
94+
|> Enum.map(&Bumblebee.Tokenizer.decode(tokenizer, Nx.to_flat_list(&1)))
95+
|> hd()
96+
97+
%{text: decoded, token_ids: token_ids}
98+
end
99+
100+
defp expand_marker(text, visual_tokens) do
101+
case String.split(text, @placeholder) do
102+
[_only] ->
103+
raise ArgumentError,
104+
"the prompt must contain a #{@placeholder} marker where the image " <>
105+
"embedding should be spliced in, got: #{inspect(text)}"
106+
107+
[prefix, suffix] ->
108+
prefix <> String.duplicate(@placeholder, visual_tokens) <> suffix
109+
110+
_multiple ->
111+
raise ArgumentError,
112+
"expected exactly one #{@placeholder} marker in the prompt"
113+
end
114+
end
115+
116+
defp visual_tokens_for(grid_thw, merge_size) do
117+
grid_thw
118+
|> Nx.to_list()
119+
|> Enum.map(fn [t, h, w] ->
120+
t * div(h, merge_size) * div(w, merge_size)
121+
end)
122+
|> Enum.sum()
123+
end
124+
end

notebooks/qwen3_vl.livemd

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -111,36 +111,38 @@ predicted_ids = Nx.argmax(logits, axis: -1)
111111
Bumblebee.Tokenizer.decode(tokenizer, predicted_ids)
112112
```
113113

114-
## Using the Generation Serving (Recommended)
114+
## Generating in One Call
115115

116-
For better text generation with proper sampling, use the generation serving:
116+
`Bumblebee.Multimodal.ImageTextToText.generate/6` is a single-call
117+
helper that featurizes the image, expands the `<|image_pad|>` marker
118+
in your prompt to the right number of visual tokens, and runs
119+
generation:
117120

118121
```elixir
119-
serving =
120-
Bumblebee.Text.generation(model_info, tokenizer,
121-
max_new_tokens: 256,
122-
compile: [batch_size: 1, sequence_length: 2048]
123-
)
122+
{:ok, generation_config} = Bumblebee.load_generation_config({:hf, repo})
123+
generation_config = Bumblebee.configure(generation_config, max_new_tokens: 64)
124124

125-
# Create the prompt with image placeholder
126125
prompt = "<|im_start|>user
127-
<|vision_start|><|image_pad|><|vision_end|>What do you see in this image? Describe it in detail.<|im_end|>
126+
<|vision_start|><|image_pad|><|vision_end|>What is in this image?<|im_end|>
128127
<|im_start|>assistant
129128
"
130129

131-
# Process image
132-
image_inputs = Bumblebee.apply_featurizer(featurizer, image)
133-
134-
# Combine prompt with image inputs
135-
generation_input = %{
136-
prompt: prompt,
137-
images: image_inputs
138-
}
139-
140-
# Generate
141-
Nx.Serving.run(serving, generation_input)
130+
Bumblebee.Multimodal.ImageTextToText.generate(
131+
model_info,
132+
featurizer,
133+
tokenizer,
134+
generation_config,
135+
prompt,
136+
image
137+
)
138+
#=> %{text: "A group of cats lying on a pink blanket with remote controls.", token_ids: ...}
142139
```
143140

141+
> Note: this is a single-call helper — each call recompiles the
142+
> generation graph if the image size or sequence length changes. The
143+
> follow-up static-shape padding work lets one compiled graph serve
144+
> repeated calls with varying image sizes.
145+
144146
## Multiple Images in One Prompt
145147

146148
`apply_featurizer/2` accepts a list of images of differing sizes. They

0 commit comments

Comments
 (0)