Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ examples/csharp/ModelChat/models
!test/test_models/qwen3-vl-vision-preprocessing/*.onnx
!test/test_models/qwen35-hybrid-preprocessing/
!test/test_models/qwen35-hybrid-preprocessing/*.onnx
!test/test_models/mistral3-vision-preprocessing/

.ipynb_checkpoints/
/src/java/.gradle
Expand Down
5 changes: 5 additions & 0 deletions examples/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ def get_user_content(model_type: str, num_images: int, num_audios: int, prompt:
# Qwen-2.5 VL, Qwen-3 VL, Fara
image_tags = "".join(["<|vision_start|><|image_pad|><|vision_end|>" for _ in range(num_images)])
content = image_tags + prompt
elif model_type == "mistral3":
# Pixtral / Ministral-3 VLM: the C++ image processor expands each
# [IMG] into the full token sequence based on image resolution.
image_tags = "".join(["[IMG]" for _ in range(num_images)])
content = image_tags + prompt
else:
# Gemma-3 style: structured content
image_tags = [{"type": "image"} for _ in range(num_images)]
Expand Down
301 changes: 301 additions & 0 deletions src/models/mistral3_image_processor.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

#include "../generators.h"
#include "model.h"
#include "mistral3_image_processor.h"

namespace Generators {

namespace {

// Pixtral special tokens — resolved at runtime via tokenizer lookup.
constexpr char kImgToken[] = "[IMG]";
constexpr char kImgBreakToken[] = "[IMG_BREAK]";
constexpr char kImgEndToken[] = "[IMG_END]";
constexpr char kInstToken[] = "[INST]";

// Build input_ids for the image portion of the prompt.
// Returns the token IDs including [IMG], [IMG_BREAK], and [IMG_END].
std::vector<int32_t> BuildImageTokenSequence(int patch_rows, int patch_cols,
int32_t img_id, int32_t break_id, int32_t end_id) {
std::vector<int32_t> tokens;
tokens.reserve(patch_rows * patch_cols + patch_rows);

for (int r = 0; r < patch_rows; ++r) {
for (int c = 0; c < patch_cols; ++c) {
tokens.push_back(img_id);
}
if (r < patch_rows - 1) {
tokens.push_back(break_id);
} else {
tokens.push_back(end_id);
}
}
return tokens;
}

// Per-image dimensions: each image may have a different resolution after
// smart_resize. When image_sizes is available (from PixtralImageSizes),
// use per-image H/W. Otherwise fall back to the (padded) pixel_values shape.
struct PerImageInfo {
int patch_rows;
int patch_cols;
int64_t num_img_tokens; // [IMG] count only (excludes [IMG_BREAK]/[IMG_END])
std::vector<int32_t> token_sequence;
};

std::tuple<std::unique_ptr<OrtValue>, int64_t>
ProcessPixtralPrompt(const Tokenizer& tokenizer, const std::string& prompt,
OrtxTensor* pixel_values, OrtxTensor* image_sizes_tensor,
int patch_size, int spatial_merge_size,
Ort::Allocator& allocator) {
const int32_t img_token_id = tokenizer.TokenToTokenId(kImgToken);
const int32_t img_break_id = tokenizer.TokenToTokenId(kImgBreakToken);
const int32_t img_end_id = tokenizer.TokenToTokenId(kImgEndToken);
const int32_t inst_token_id = tokenizer.TokenToTokenId(kInstToken);

int64_t num_images = 0;
std::vector<PerImageInfo> image_infos;

if (pixel_values) {
const float* data{};
const int64_t* shape{};
size_t num_dims{};
CheckResult(OrtxGetTensorData(pixel_values, reinterpret_cast<const void**>(&data), &shape, &num_dims));
if (num_dims != 4) {
throw std::runtime_error(
"Mistral3ImageProcessor: expected 4D pixel_values [N,C,H,W], "
"got " +
std::to_string(num_dims) + "D tensor.");
}
num_images = shape[0];
int64_t padded_h = shape[2];
int64_t padded_w = shape[3];

// Read per-image sizes if available, otherwise use padded dimensions
const int64_t* sizes_data = nullptr;
if (image_sizes_tensor) {
const void* raw{};
const int64_t* sizes_shape{};
size_t sizes_dims{};
CheckResult(OrtxGetTensorData(image_sizes_tensor, &raw, &sizes_shape, &sizes_dims));
Comment thread
titaiwangms marked this conversation as resolved.

if (sizes_dims != 2) {
throw std::runtime_error(
"Mistral3ImageProcessor: expected 2D image_sizes tensor [N,2], "
"got " +
std::to_string(sizes_dims) + "D tensor.");
}
if (sizes_shape[1] != 2) {
throw std::runtime_error(
"Mistral3ImageProcessor: expected image_sizes tensor shape [N,2], "
"got second dimension " +
std::to_string(sizes_shape[1]) + ".");
}
if (sizes_shape[0] != num_images) {
throw std::runtime_error(
"Mistral3ImageProcessor: image_sizes tensor first dimension (" +
std::to_string(sizes_shape[0]) + ") must match pixel_values batch size (" +
std::to_string(num_images) + ").");
}
sizes_data = static_cast<const int64_t*>(raw);
}

int64_t effective_patch = static_cast<int64_t>(patch_size) * spatial_merge_size;
for (int64_t i = 0; i < num_images; ++i) {
int64_t h = sizes_data ? sizes_data[i * 2] : padded_h;
int64_t w = sizes_data ? sizes_data[i * 2 + 1] : padded_w;

if (h % effective_patch != 0 || w % effective_patch != 0) {
throw std::runtime_error(
"Mistral3ImageProcessor: image " + std::to_string(i) + " dimensions (" +
std::to_string(h) + "x" + std::to_string(w) +
") must be divisible by patch_size*merge_size (" +
std::to_string(effective_patch) + "). Check smart_resize configuration.");
}

PerImageInfo info;
info.patch_rows = static_cast<int>(h / effective_patch);
info.patch_cols = static_cast<int>(w / effective_patch);
info.token_sequence = BuildImageTokenSequence(info.patch_rows, info.patch_cols,
img_token_id, img_break_id, img_end_id);
// Count only [IMG] tokens — this equals the vision model's feature output count
// (patch_rows * patch_cols), excluding structural [IMG_BREAK]/[IMG_END] tokens.
info.num_img_tokens = static_cast<int64_t>(
std::count(info.token_sequence.begin(), info.token_sequence.end(), img_token_id));
image_infos.push_back(std::move(info));
}
Comment thread
titaiwangms marked this conversation as resolved.
}

int64_t total_img_tokens = 0;
for (const auto& info : image_infos) {
total_img_tokens += info.num_img_tokens;
}

Comment thread
tianleiwu marked this conversation as resolved.
// Tokenize the text prompt
std::vector<int32_t> input_ids;
if (!prompt.empty()) {
input_ids = tokenizer.Encode(prompt.c_str());
Comment thread
tianleiwu marked this conversation as resolved.
}

// Expand [IMG] placeholders for each image.
// Each [IMG] (or group of consecutive [IMG] tokens) in the prompt corresponds
// to one image, expanded with its per-image token sequence.
if (!image_infos.empty()) {
std::vector<int32_t> expanded_ids;
size_t total_expansion = input_ids.size();
for (const auto& info : image_infos) {
total_expansion += info.token_sequence.size();
}
expanded_ids.reserve(total_expansion);

size_t next_image = 0;
for (size_t i = 0; i < input_ids.size(); ++i) {
if (input_ids[i] == img_token_id && next_image < image_infos.size()) {
// Replace this [IMG] (and consecutive [IMG] tokens) with the image's token sequence
expanded_ids.insert(expanded_ids.end(),
image_infos[next_image].token_sequence.begin(),
image_infos[next_image].token_sequence.end());
++next_image;
// Skip consecutive [IMG] tokens from the original prompt
while (i + 1 < input_ids.size() && input_ids[i + 1] == img_token_id) {
++i;
}
Comment thread
titaiwangms marked this conversation as resolved.
} else {
expanded_ids.push_back(input_ids[i]);
}
}

// If not all images had placeholders, insert remaining after [INST]
if (next_image < image_infos.size()) {
std::vector<int32_t> remaining_tokens;
for (size_t img = next_image; img < image_infos.size(); ++img) {
remaining_tokens.insert(remaining_tokens.end(),
image_infos[img].token_sequence.begin(),
image_infos[img].token_sequence.end());
}

std::vector<int32_t> final_ids;
final_ids.reserve(expanded_ids.size() + remaining_tokens.size());
bool inserted = false;
for (size_t i = 0; i < expanded_ids.size(); ++i) {
final_ids.push_back(expanded_ids[i]);
if (expanded_ids[i] == inst_token_id && !inserted) {
final_ids.insert(final_ids.end(), remaining_tokens.begin(), remaining_tokens.end());
inserted = true;
}
}
if (!inserted) {
// No [INST] found — prepend remaining image tokens
final_ids.clear();
final_ids.insert(final_ids.end(), remaining_tokens.begin(), remaining_tokens.end());
final_ids.insert(final_ids.end(), expanded_ids.begin(), expanded_ids.end());
}
expanded_ids = std::move(final_ids);
}

input_ids = std::move(expanded_ids);
}

auto input_ids_value = OrtValue::CreateTensor<int32_t>(
allocator, std::vector<int64_t>{1, static_cast<int64_t>(input_ids.size())});
std::copy(input_ids.begin(), input_ids.end(),
input_ids_value->GetTensorMutableData<int32_t>());

return {std::move(input_ids_value), total_img_tokens};
}
} // namespace

Mistral3ImageProcessor::Mistral3ImageProcessor(Config& config, const SessionInfo& session_info)
: pixel_values_type_{session_info.GetInputDataType(config.model.vision.inputs.pixel_values)},
patch_size_{config.model.vision.patch_size},
spatial_merge_size_{config.model.vision.spatial_merge_size} {
const auto processor_config =
(config.config_path / fs::path(config.model.vision.config_filename)).string();
CheckResult(OrtxCreateProcessor(processor_.ToBeAssigned(), processor_config.c_str()));

config.AddMapping(std::string(Config::Defaults::InputIdsName),
config.model.embedding.inputs.input_ids);
config.AddMapping(std::string(Config::Defaults::PixelValuesName),
config.model.vision.inputs.pixel_values);
}

std::unique_ptr<NamedTensors> Mistral3ImageProcessor::Process(
const Tokenizer& tokenizer, const Payload& payload) const {
std::string prompt = std::string(payload.prompt);
const Images* images = payload.images;
Ort::Allocator& allocator{Ort::Allocator::GetWithDefaultOptions()};
auto named_tensors = std::make_unique<NamedTensors>();

if (!images) {
// Text-only: tokenize prompt without image processing
auto [input_ids, num_img_tokens] =
ProcessPixtralPrompt(tokenizer, prompt, nullptr, nullptr, patch_size_,
spatial_merge_size_, allocator);
named_tensors->emplace(Config::Defaults::InputIdsName,
std::make_shared<Tensor>(std::move(input_ids)));

// Explicitly set num_image_tokens=0 for text-only inputs so downstream
// pipeline components know there are no vision features to process.
auto zero_tokens = OrtValue::CreateTensor<int64_t>(allocator, std::vector<int64_t>{1});
zero_tokens->GetTensorMutableData<int64_t>()[0] = 0;
named_tensors->emplace(std::string(Config::Defaults::NumImageTokens),
std::make_shared<Tensor>(std::move(zero_tokens)));
return named_tensors;
}

// Process images through the ort-extensions processor (normalization, resizing)
ort_extensions::OrtxObjectPtr<OrtxTensorResult> result;
CheckResult(OrtxImagePreProcess(processor_.get(), images->images_.get(),
result.ToBeAssigned()));

OrtxTensor* pixel_values = nullptr;
CheckResult(OrtxTensorResultGetAt(result.get(), 0, &pixel_values));

// Tensor 1: image_sizes[N, 2] from PixtralImageSizes (post-resize, pre-padding).
// Only present when processor_config.json includes the PixtralImageSizes step.
OrtxTensor* image_sizes = nullptr;
if (OrtxTensorResultGetAt(result.get(), 1, &image_sizes) != kOrtxOK) {
Comment thread
titaiwangms marked this conversation as resolved.
Outdated
image_sizes = nullptr;
}

auto [input_ids, num_img_tokens] =
ProcessPixtralPrompt(tokenizer, prompt, pixel_values, image_sizes, patch_size_,
spatial_merge_size_, allocator);

named_tensors->emplace(std::string(Config::Defaults::InputIdsName),
std::make_shared<Tensor>(std::move(input_ids)));

// Convert pixel_values to the vision model's expected dtype (NCHW layout
// is already handled by the Permute3D step in processor_config.json).
{
std::unique_ptr<OrtValue> pv_ortvalue;
if (pixel_values_type_ == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) {
pv_ortvalue = ProcessTensor<float>(pixel_values, allocator);
} else if (pixel_values_type_ == ONNX_TENSOR_ELEMENT_DATA_TYPE_BFLOAT16) {
pv_ortvalue = ProcessTensor<Ort::BFloat16_t>(pixel_values, allocator);
} else {
pv_ortvalue = ProcessTensor<Ort::Float16_t>(pixel_values, allocator);
}
named_tensors->emplace(std::string(Config::Defaults::PixelValuesName),
std::make_shared<Tensor>(std::move(pv_ortvalue)));
}

// Add image_sizes[N, 2] for PixtralVisionState to slice per-image dimensions
if (image_sizes) {
named_tensors->emplace(std::string(Config::Defaults::ImageSizesName),
std::make_shared<Tensor>(ProcessTensor<int64_t>(image_sizes, allocator)));
}

// Add num_image_tokens (total across all images) for the embedding model
auto num_img_tokens_value = OrtValue::CreateTensor<int64_t>(
allocator, std::vector<int64_t>{1});
num_img_tokens_value->GetTensorMutableData<int64_t>()[0] = num_img_tokens;
named_tensors->emplace(std::string(Config::Defaults::NumImageTokens),
std::make_shared<Tensor>(std::move(num_img_tokens_value)));

return named_tensors;
}

} // namespace Generators
23 changes: 23 additions & 0 deletions src/models/mistral3_image_processor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

#pragma once

#include "processor.h"

namespace Generators {

struct Mistral3ImageProcessor : Processor {
Mistral3ImageProcessor(Config& config, const SessionInfo& session_info);

std::unique_ptr<NamedTensors> Process(const Tokenizer& tokenizer, const Payload& payload) const override;

private:
ort_extensions::OrtxObjectPtr<OrtxProcessor> processor_;

ONNXTensorElementDataType pixel_values_type_;
int patch_size_;
int spatial_merge_size_;
};

} // namespace Generators
2 changes: 2 additions & 0 deletions src/models/model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include "decoder_only_pipeline.h"
#include "qwen_vl_model.h"
#include "qwen2_5_vl_image_processor.h"
#include "mistral3_image_processor.h"
#include "../dml/interface.h"
#include "../openvino/interface.h"
#include "../ryzenai/interface.h"
Expand Down Expand Up @@ -918,6 +919,7 @@ MultiModalProcessor::MultiModalProcessor(Config& config, const SessionInfo& sess
{"whisper", Processor::Create<WhisperProcessor>},
{"phi4mm", Processor::Create<PhiMultiModalProcessor>},
{"gemma3", Processor::Create<GemmaImageProcessor>},
{"mistral3", Processor::Create<Mistral3ImageProcessor>},
{"fara", Processor::Create<QwenImageProcessor>},
{"qwen2_5_vl", Processor::Create<QwenImageProcessor>},
{"qwen3_vl", Processor::Create<QwenImageProcessor>},
Expand Down
7 changes: 6 additions & 1 deletion src/models/model_type.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ struct ModelType {

inline static bool IsVLM(const std::string& model_type) {
// Vision-language model (VLM)
static constexpr std::array<std::string_view, 6> VLM = {"fara", "gemma3", "phi3v", "qwen2_5_vl", "qwen3_vl", "qwen3_5"};
static constexpr std::array<std::string_view, 7> VLM = {"fara", "gemma3", "mistral3", "phi3v", "qwen2_5_vl", "qwen3_vl", "qwen3_5"};
return std::find(VLM.begin(), VLM.end(), model_type) != VLM.end();
}

Expand All @@ -30,6 +30,11 @@ struct ModelType {
return model_type == "fara" || model_type == "qwen2_5_vl" || model_type == "qwen3_vl" || model_type == "qwen3_5";
}

inline static bool IsPixtralFamily(const std::string& model_type) {
// Pixtral family: per-image vision loop with variable resolution
return model_type == "mistral3";
}

inline static bool IsALM(const std::string& model_type) {
// Audio-language model (ALM)
static constexpr std::array<std::string_view, 1> ALM = {"whisper"};
Expand Down
Loading
Loading