diff --git a/README.md b/README.md index d99348cb..47a37766 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ API and command-line option may change frequently.*** ## ๐Ÿ”ฅImportant News +* **2026/09/20** ๐Ÿš€ stable-diffusion.cpp adds **Day-0 support for Qwen-Image-2.1** * **2026/08/20** ๐Ÿš€ stable-diffusion.cpp now supports **LTX-2.5** * **2026/08/04** ๐Ÿš€ stable-diffusion.cpp adds **Day-1 support for MiniMax-H3** * **2026/06/25** ๐Ÿš€ stable-diffusion.cpp now supports **Krea2** @@ -47,6 +48,7 @@ API and command-line option may change frequently.*** - [Chroma](./docs/chroma.md) - [Chroma1-Radiance](./docs/chroma_radiance.md) - [Qwen Image](./docs/qwen_image.md) + - [Qwen Image 2.1](./docs/qwen_image_2.1.md) - [PiD](./docs/pid.md) - [LongCat Image](./docs/longcat_image.md) - [Z-Image](./docs/z_image.md) diff --git a/assets/qwen/qwen_image_2.1.png b/assets/qwen/qwen_image_2.1.png new file mode 100644 index 00000000..7fc7fd4c Binary files /dev/null and b/assets/qwen/qwen_image_2.1.png differ diff --git a/docs/qwen_image_2.1.md b/docs/qwen_image_2.1.md new file mode 100644 index 00000000..08a50875 --- /dev/null +++ b/docs/qwen_image_2.1.md @@ -0,0 +1,41 @@ +# How to Use + +Qwen Image 2.1 supports text-to-image generation and image editing, using Qwen3-VL-8B as the text encoder and its own VAE. + +## Download weights + +- Download Qwen Image 2.1 + - safetensors: https://huggingface.co/Comfy-Org/Qwen-Image-2.1/tree/main/diffusion_models + - gguf: https://huggingface.co/leejet/Qwen-Image-2.1-GGUF/tree/main +- Download vae + - safetensors: https://huggingface.co/Comfy-Org/Qwen-Image-2.1/tree/main/vae +- Download Qwen3-VL-8B-Instruct + - safetensors (BF16 or INT8 convrot): https://huggingface.co/Comfy-Org/Qwen-Image-2.1/tree/main/text_encoders + - gguf: https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-GGUF/tree/main + - For image editing with a GGUF text encoder, also download `mmproj-Qwen3VL-8B-Instruct-F16.gguf` from the same repository and pass it with `--llm_vision`. + +Use `qwen_image_2.1_vae_bf16.safetensors` with this model. The earlier Qwen Image and Wan 2.2 VAE weights are not interchangeable with the Qwen Image 2.1 VAE weights. + +## Examples + +Run the following commands from the build directory. Use image dimensions divisible by 32. The resolution-dependent flow schedule is selected automatically. + +### Text to image + +```powershell +.\bin\Release\sd-cli.exe --diffusion-model ..\models\diffusion_models\qwen_image_2.1_int8_convrot.safetensors --vae ..\models\vae\qwen_image_2.1_vae_bf16.safetensors --llm ..\models\text_encoders\Qwen3VL-8B-Instruct-Q4_K_M.gguf -p "a lovely cat holding a sign says 'qwen2.1.cpp'" --cfg-scale 6.0 --sampling-method euler -v --offload-to-cpu -o qwen_image_2.1.png +``` + +Qwen Image 2.1 example + +To use GGUF diffusion weights, set `--diffusion-model` to the path of a file such as `qwen_image_2.1-Q4_K.gguf`. + +### Image editing + +Pass the reference image with `-r` and describe the edit in `-p`. Vision weights are required; the example below loads them separately with `--llm_vision`. + +```powershell +.\bin\Release\sd-cli.exe --diffusion-model ..\models\diffusion_models\qwen_image_2.1_int8_convrot.safetensors --vae ..\models\vae\qwen_image_2.1_vae_bf16.safetensors --llm ..\models\text_encoders\Qwen3VL-8B-Instruct-Q4_K_M.gguf --llm_vision ..\models\text_encoders\Qwen3VL-8B-Instruct-mmproj-BF16.gguf -r ..\assets\qwen\qwen_image_2.1.png -p "change 'qwen2.1.cpp' to 'sd.cpp'" --cfg-scale 6.0 --sampling-method euler -v --offload-to-cpu -o qwen_image_2.1_edit.png +``` + +For multiple reference images, repeat `-r` in the desired order, for example `-r first.png -r second.png`. diff --git a/src/conditioning/conditioner.hpp b/src/conditioning/conditioner.hpp index 0566f93f..a0544eec 100644 --- a/src/conditioning/conditioner.hpp +++ b/src/conditioning/conditioner.hpp @@ -1978,7 +1978,8 @@ struct LLMEmbedder : public Conditioner { arch = LLM::LLMArch::GPT_OSS_20B; } else if (sd_version_is_pid(version)) { arch = LLM::LLMArch::GEMMA2_2B; - } else if (sd_version_is_lingbot_video(version) || + } else if (version == VERSION_QWEN_IMAGE_2_1 || + sd_version_is_lingbot_video(version) || sd_version_is_ideogram4(version) || sd_version_is_boogu_image(version) || sd_version_is_sefi_image(version) || @@ -2547,6 +2548,67 @@ struct LLMEmbedder : public Conditioner { prompt += conditioner_params.text; prompt_attn_range = {0, 0}; prompt += "<|im_end|>\n<|im_start|>assistant\n"; + } else if (version == VERSION_QWEN_IMAGE_2_1) { + if (!llm->enable_vision && conditioner_params.ref_images != nullptr && !conditioner_params.ref_images->empty()) { + LOG_ERROR("Qwen Image 2.1 editing requires Qwen3-VL vision weights; provide --llm_vision or a combined encoder"); + return {}; + } + prompt = "<|im_start|>system\nComprehend and analyze the provided prompt.<|im_end|>\n"; + std::vector system_tokens; + if (!tokenizer->encode(prompt, system_tokens, nullptr)) { + return {}; + } + prompt_template_encode_start_idx = static_cast(system_tokens.size()); + out_layers = {static_cast(llm->config.num_layers)}; + prompt += "<|im_start|>user\n"; + if (llm->enable_vision && conditioner_params.ref_images != nullptr) { + for (size_t i = 0; i < conditioner_params.ref_images->size(); ++i) { + const auto& image = (*conditioner_params.ref_images)[i]; + int64_t width = image.shape()[0]; + int64_t height = image.shape()[1]; + int64_t pixels = width * height; + if (width % 32 != 0 || height % 32 != 0) { + LOG_ERROR("Qwen Image 2.1 reference dimensions must be multiples of 32"); + return {}; + } + auto rgb = sd::Tensor({width, height, 3, 1}); + for (int64_t p = 0; p < pixels; ++p) { + float alpha = image.shape()[2] == 4 ? image[p + 3 * pixels] : 1.f; + for (int c = 0; c < 3; ++c) { + rgb[p + c * pixels] = 2.f * (image[p + c * pixels] * alpha + 1.f - alpha) - 1.f; + } + } + auto outputs = llm->encode_image_outputs(n_threads, rgb, false); + if (outputs.empty()) { + return {}; + } + prompt += (i == 0 ? "" : " ") + std::string("<|vision_start|>"; + std::vector prefix_tokens; + if (!tokenizer->encode(prompt, prefix_tokens, nullptr)) { + return {}; + } + int index = static_cast(prefix_tokens.size()); + int count = static_cast(outputs[0].shape()[1]); + image_embeds.emplace_back(index, std::move(outputs[0])); + if (deepstack_image_embeds.empty()) { + deepstack_image_embeds.resize(outputs.size() - 1); + } + for (size_t layer = 1; layer < outputs.size(); ++layer) { + deepstack_image_embeds[layer - 1].emplace_back(index, std::move(outputs[layer])); + } + image_grids.push_back({index, count, + static_cast(height) / llm->config.vision.patch_size, + static_cast(width) / llm->config.vision.patch_size}); + for (int j = 0; j < count; ++j) { + prompt += "<|image_pad|>"; + } + prompt += "<|vision_end|>"; + } + } + prompt_attn_range.first = static_cast(prompt.size()); + prompt += conditioner_params.text.empty() ? " " : conditioner_params.text; + prompt_attn_range.second = static_cast(prompt.size()); + prompt += "<|im_end|>\n<|im_start|>assistant\n"; } else if (sd_version_is_qwen_image(version) || sd_version_is_mage_flow(version)) { if (llm->enable_vision && conditioner_params.ref_images != nullptr && !conditioner_params.ref_images->empty()) { LOG_INFO("%s", sd_version_is_mage_flow(version) ? "MageFlowEditPipeline" : "QwenImageEditPlusPipeline"); @@ -3074,6 +3136,21 @@ struct LLMEmbedder : public Conditioner { SDCondition result; result.c_crossattn = std::move(hidden_states); result.extra_c_crossattns = std::move(extra_hidden_states_vec); + if (version == VERSION_QWEN_IMAGE_2_1) { + auto slots = sd::Tensor::zeros({result.c_crossattn.shape()[1]}); + for (size_t i = 0; i < image_embeds.size(); ++i) { + int64_t begin = image_embeds[i].first - prompt_template_encode_start_idx; + int64_t end = begin + image_embeds[i].second.shape()[1]; + if (begin < 0 || end > slots.numel()) { + LOG_ERROR("Qwen Image 2.1 image slots exceed the encoded prompt"); + return {}; + } + for (int64_t j = begin; j < end; ++j) { + slots[j] = static_cast(i + 1); + } + } + result.c_token_types = std::move(slots); + } if (sd_version_is_minimax_h3(version)) { std::vector tags(static_cast(result.c_crossattn.shape()[1]), 1); for (const auto& [index, image_embed] : image_embeds) { diff --git a/src/model.h b/src/model.h index 7a8bc757..90044ecb 100644 --- a/src/model.h +++ b/src/model.h @@ -39,6 +39,7 @@ enum SDVersion { VERSION_LINGBOT_VIDEO, VERSION_QWEN_IMAGE, VERSION_QWEN_IMAGE_LAYERED, + VERSION_QWEN_IMAGE_2_1, VERSION_HUNYUAN_VIDEO, VERSION_ANIMA, VERSION_FLUX2, @@ -145,7 +146,7 @@ static inline bool sd_version_is_lingbot_video(SDVersion version) { } static inline bool sd_version_is_qwen_image(SDVersion version) { - if (version == VERSION_QWEN_IMAGE || version == VERSION_QWEN_IMAGE_LAYERED) { + if (version == VERSION_QWEN_IMAGE || version == VERSION_QWEN_IMAGE_LAYERED || version == VERSION_QWEN_IMAGE_2_1) { return true; } return false; diff --git a/src/model/diffusion/model.hpp b/src/model/diffusion/model.hpp index a4b3c38f..cfbd5c40 100644 --- a/src/model/diffusion/model.hpp +++ b/src/model/diffusion/model.hpp @@ -66,6 +66,10 @@ struct AnimaDiffusionExtra { const sd::Tensor* t5_weights = nullptr; }; +struct QwenImage21DiffusionExtra { + const sd::Tensor* image_slots = nullptr; +}; + struct WanDiffusionExtra { const sd::Tensor* vace_context = nullptr; float vace_strength = 1.f; @@ -132,6 +136,7 @@ using DiffusionExtraParams = std::variant axes_dim = {16, 56, 56}; + + static QwenImage21Config detect_from_weights(const String2TensorStorage& weights, const std::string& prefix) { + QwenImage21Config config; + auto find = [&](const std::string& suffix) -> const TensorStorage* { + auto it = weights.find(prefix + "." + suffix); + return it == weights.end() ? nullptr : &it->second; + }; + if (auto w = find("img_in.weight")) { + config.in_channels = w->ne[0]; + config.hidden_size = w->ne[1]; + } + if (auto w = find("proj_out.weight")) { + config.out_channels = w->ne[1]; + } + if (auto w = find("txt_in.in_layer.weight")) { + config.context_dim = w->ne[0]; + } + if (auto w = find("transformer_blocks.0.attn.norm_q.weight")) { + config.head_dim = w->ne[0]; + } + if (auto w = find("transformer_blocks.0.img_mlp.gate_up.weight")) { + config.intermediate_size = w->ne[1] / 2; + config.fused_mlp = true; + } else if (auto w = find("transformer_blocks.0.img_mlp.proj.weight")) { + config.intermediate_size = w->ne[1]; + } + int layers = 0; + const std::string block_prefix = prefix + ".transformer_blocks."; + for (const auto& [name, _] : weights) { + if (starts_with(name, block_prefix)) { + layers = std::max(layers, atoi(name.substr(block_prefix.size()).c_str()) + 1); + } + } + if (layers > 0) { + config.num_layers = layers; + LOG_VERBOSE("qwen_image_2_1: layers = %d, hidden_size = %" PRId64 ", context_dim = %" PRId64, + layers, config.hidden_size, config.context_dim); + } + return config; + } + }; + + struct QwenImage21Segment { + int64_t start; + int64_t end; + int64_t context_start; + int image_index; + }; + + struct QwenImage21Layout { + std::vector segments; + std::vector> positions; + int64_t prefix_length = 0; + + static QwenImage21Layout build(int64_t text_length, + const sd::Tensor& image_slots, + const std::vector>& image_shapes) { + if (image_shapes.empty() || (!image_slots.empty() && image_slots.numel() != text_length)) { + throw std::runtime_error("Qwen Image 2.1: invalid image token layout"); + } + QwenImage21Layout layout; + int64_t position = 0; + int next_image = 0; + auto append_image = [&](int index, int64_t context_start) { + auto [height, width] = image_shapes[index]; + int64_t start = static_cast(layout.positions.size()); + layout.segments.push_back({start, start + height * width, context_start, index}); + for (int64_t h = 0; h < height; ++h) { + for (int64_t w = 0; w < width; ++w) { + layout.positions.push_back({static_cast(position), + static_cast(h - (height - height / 2)), + static_cast(w - (width - width / 2))}); + } + } + position += std::max(height, width); + }; + for (int64_t i = 0; i < text_length;) { + int tag = image_slots.empty() ? 0 : image_slots[i]; + int64_t begin = i++; + while (i < text_length && (image_slots.empty() ? 0 : image_slots[i]) == tag) { + ++i; + } + if (tag != 0) { + if (tag != next_image + 1 || next_image + 1 >= static_cast(image_shapes.size()) || + (i - begin) * 4 != image_shapes[next_image].first * image_shapes[next_image].second) { + throw std::runtime_error("Qwen Image 2.1: vision slots and reference latents must have matching sizes"); + } + append_image(next_image++, begin); + } else { + int64_t start = static_cast(layout.positions.size()); + layout.segments.push_back({start, start + i - begin, begin, -1}); + for (int64_t j = begin; j < i; ++j, ++position) { + float p = static_cast(position); + layout.positions.push_back({p, p, p}); + } + } + } + if (next_image + 1 != static_cast(image_shapes.size())) { + throw std::runtime_error("Qwen Image 2.1: missing reference image slots"); + } + layout.prefix_length = static_cast(layout.positions.size()); + append_image(next_image, text_length); + return layout; + } + }; + + class QwenImage21ZeroCenterRMSNorm : public RMSNorm { + public: + using RMSNorm::RMSNorm; + + ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x) override { + auto weight = params["weight"]; + if (ctx->weight_adapter) { + weight = ctx->weight_adapter->patch_weight(ctx->ggml_ctx, ctx->backend, weight, prefix + "weight"); + } + weight = ggml_scale_bias(ctx->ggml_ctx, weight, 1.f, 1.f); + return ggml_mul(ctx->ggml_ctx, ggml_rms_norm(ctx->ggml_ctx, x, eps), weight); + } + }; + + class QwenImage21TextProjection : public GGMLBlock { + public: + QwenImage21TextProjection(const QwenImage21Config& config) { + blocks["text_norm"] = std::make_shared(config.context_dim, 1e-6f); + blocks["in_layer"] = std::make_shared(config.context_dim, config.hidden_size, false); + blocks["out_layer"] = std::make_shared(config.hidden_size, config.hidden_size, false); + } + + ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x) { + x = std::dynamic_pointer_cast(blocks["text_norm"])->forward(ctx, x); + x = std::dynamic_pointer_cast(blocks["in_layer"])->forward(ctx, x); + x = ggml_ext_gelu(ctx->ggml_ctx, x); + return std::dynamic_pointer_cast(blocks["out_layer"])->forward(ctx, x); + } + }; + + class QwenImage21Attention : public QwenImageAttention { + public: + QwenImage21Attention(const QwenImage21Config& config) + : QwenImageAttention(config.hidden_size, config.head_dim, config.hidden_size / config.head_dim, 0, 0, false, false) { + for (const auto* name : {"add_q_proj", "add_k_proj", "add_v_proj", "norm_added_q", "norm_added_k", "to_add_out"}) { + blocks.erase(name); + } + } + + ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x, ggml_tensor* pe, const std::vector& segments, const std::vector& masks) { + int64_t heads = x->ne[0] / dim_head; + auto project = [&](const char* name) { + auto h = std::dynamic_pointer_cast(blocks[name])->forward(ctx, x); + return ggml_reshape_4d(ctx->ggml_ctx, h, dim_head, heads, x->ne[1], x->ne[2]); + }; + auto q = project("to_q"); + auto k = project("to_k"); + auto v = project("to_v"); + q = std::dynamic_pointer_cast(blocks["norm_q"])->forward(ctx, q); + k = std::dynamic_pointer_cast(blocks["norm_k"])->forward(ctx, k); + q = Rope::apply_rope(ctx->ggml_ctx, q, pe); + k = Rope::apply_rope(ctx->ggml_ctx, k, pe); + ggml_tensor* result = nullptr; + for (size_t i = 0; i < segments.size(); ++i) { + const auto& segment = segments[i]; + auto sq = ggml_ext_slice(ctx->ggml_ctx, q, 1, segment.start, segment.end); + auto sk = ggml_ext_slice(ctx->ggml_ctx, k, 1, 0, segment.end); + auto sv = ggml_ext_slice(ctx->ggml_ctx, v, 2, 0, segment.end); + auto out = ggml_ext_attention_ext(ctx, sq, sk, sv, heads, masks[i], true, ctx->flash_attn_enabled); + result = result == nullptr ? out : ggml_concat(ctx->ggml_ctx, result, out, 1); + } + auto to_out = std::dynamic_pointer_cast(blocks["to_out.0"]); + if (sd_backend_is(ctx->backend, "Vulkan") || sd_backend_is(ctx->backend, "ROCm")) { + to_out->set_force_prec_f32(true); + } + return to_out->forward(ctx, result); + } + }; + + class QwenImage21TransformerBlock : public GGMLBlock { + public: + QwenImage21TransformerBlock(const QwenImage21Config& config) { + blocks["img_norm1"] = std::make_shared(config.hidden_size, 1e-6f, false); + blocks["img_norm2"] = std::make_shared(config.hidden_size, 1e-6f, false); + blocks["attn"] = std::make_shared(config); + if (config.fused_mlp) { + blocks["img_mlp.gate_up"] = std::make_shared(config.hidden_size, 2 * config.intermediate_size, false); + } else { + blocks["img_mlp.proj"] = std::make_shared(config.hidden_size, config.intermediate_size, false); + blocks["img_mlp.gate_layer"] = std::make_shared(config.hidden_size, config.intermediate_size, false); + } + blocks["img_mlp.out"] = std::make_shared(config.intermediate_size, config.hidden_size, false); + } + + static ggml_tensor* modulate(ggml_context* ctx, ggml_tensor* x, ggml_tensor* params, int64_t prefix_length, bool gate = false) { + auto rows = ggml_ext_chunk(ctx, params, 2, 1); + auto apply = [&](ggml_tensor* part, ggml_tensor* row) { + row = gate ? ggml_tanh(ctx, row) : ggml_scale_bias(ctx, row, 1.f, 1.f); + return ggml_mul(ctx, part, row); + }; + auto target = apply(ggml_ext_slice(ctx, x, 1, prefix_length, x->ne[1]), rows[0]); + if (prefix_length == 0) { + return target; + } + auto prefix = apply(ggml_ext_slice(ctx, x, 1, 0, prefix_length), rows[1]); + return ggml_concat(ctx, prefix, target, 1); + } + + ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x, const std::vector& modulation, ggml_tensor* pe, const QwenImage21Layout& layout, const std::vector& masks) { + auto h = std::dynamic_pointer_cast(blocks["img_norm1"])->forward(ctx, x); + h = modulate(ctx->ggml_ctx, h, modulation[0], layout.prefix_length); + h = std::dynamic_pointer_cast(blocks["attn"])->forward(ctx, h, pe, layout.segments, masks); + x = ggml_add(ctx->ggml_ctx, x, modulate(ctx->ggml_ctx, h, modulation[1], layout.prefix_length, true)); + h = std::dynamic_pointer_cast(blocks["img_norm2"])->forward(ctx, x); + h = modulate(ctx->ggml_ctx, h, modulation[2], layout.prefix_length); + ggml_tensor* gate; + auto fused = blocks.find("img_mlp.gate_up"); + if (fused != blocks.end()) { + auto gate_up = std::dynamic_pointer_cast(fused->second)->forward(ctx, h); + auto parts = ggml_ext_chunk(ctx->ggml_ctx, gate_up, 2, 0); + gate = parts[0]; + h = parts[1]; + } else { + gate = std::dynamic_pointer_cast(blocks["img_mlp.gate_layer"])->forward(ctx, h); + h = std::dynamic_pointer_cast(blocks["img_mlp.proj"])->forward(ctx, h); + } + h = ggml_mul(ctx->ggml_ctx, h, ggml_silu(ctx->ggml_ctx, gate)); + h = std::dynamic_pointer_cast(blocks["img_mlp.out"])->forward(ctx, h); + return ggml_add(ctx->ggml_ctx, x, modulate(ctx->ggml_ctx, h, modulation[3], layout.prefix_length, true)); + } + }; + + class QwenImage21Model : public GGMLBlock { + QwenImage21Config config; + + public: + QwenImage21Model(const QwenImage21Config& config) + : config(config) { + blocks["time_text_embed.timestep_embedder"] = std::make_shared(256, config.hidden_size, 0, 0, false); + blocks["txt_in"] = std::make_shared(config); + blocks["img_in"] = std::make_shared(config.in_channels, config.hidden_size, false); + blocks["modulation.1"] = std::make_shared(config.hidden_size, 4 * config.hidden_size, false); + blocks["norm_out.linear"] = std::make_shared(config.hidden_size, config.hidden_size, false); + blocks["norm_out.norm"] = std::make_shared(config.hidden_size, 1e-6f, false); + blocks["proj_out"] = std::make_shared(config.hidden_size, config.out_channels, false); + for (int i = 0; i < config.num_layers; ++i) { + blocks["transformer_blocks." + std::to_string(i)] = std::make_shared(config); + } + } + + ggml_tensor* forward(GGMLRunnerContext* ctx, ggml_tensor* x, ggml_tensor* timestep, ggml_tensor* context, const std::vector& refs, ggml_tensor* pe, const QwenImage21Layout& layout, const std::vector& masks) { + auto time = ggml_concat(ctx->ggml_ctx, timestep, ggml_ext_zeros_like(ctx->ggml_ctx, timestep), 0); + // Runtime flow timesteps already use the [0, 1000] scale. + time = ggml_ext_timestep_embedding(ctx->ggml_ctx, time, 256, 10000, 1.f); + time = std::dynamic_pointer_cast(blocks["time_text_embed.timestep_embedder"])->forward(ctx, time); + time = ggml_silu(ctx->ggml_ctx, time); + auto modulation = std::dynamic_pointer_cast(blocks["modulation.1"])->forward(ctx, time); + auto mod = ggml_ext_chunk(ctx->ggml_ctx, modulation, 4, 0); + auto text = std::dynamic_pointer_cast(blocks["txt_in"])->forward(ctx, context); + auto img_in = std::dynamic_pointer_cast(blocks["img_in"]); + ggml_tensor* joint = nullptr; + for (const auto& segment : layout.segments) { + ggml_tensor* h; + if (segment.image_index < 0) { + h = ggml_ext_slice(ctx->ggml_ctx, text, 1, segment.context_start, + segment.context_start + segment.end - segment.start); + } else { + auto image = segment.image_index == static_cast(refs.size()) ? x : refs[segment.image_index]; + h = img_in->forward(ctx, DiT::patchify(ctx->ggml_ctx, image, 1, 1)); + } + joint = joint == nullptr ? h : ggml_concat(ctx->ggml_ctx, joint, h, 1); + } + sd::ggml_graph_cut::mark_graph_cut(joint, "qwen_image_2_1.prelude", "joint"); + for (int i = 0; i < config.num_layers; ++i) { + auto block = std::dynamic_pointer_cast(blocks["transformer_blocks." + std::to_string(i)]); + joint = block->forward(ctx, joint, mod, pe, layout, masks); + sd::ggml_graph_cut::mark_graph_cut(joint, "qwen_image_2_1.transformer_blocks." + std::to_string(i), "joint"); + } + joint = ggml_ext_slice(ctx->ggml_ctx, joint, 1, layout.prefix_length, joint->ne[1]); + auto scale = std::dynamic_pointer_cast(blocks["norm_out.linear"])->forward(ctx, ggml_ext_chunk(ctx->ggml_ctx, time, 2, 1)[0]); + joint = std::dynamic_pointer_cast(blocks["norm_out.norm"])->forward(ctx, joint); + joint = ggml_mul(ctx->ggml_ctx, joint, ggml_scale_bias(ctx->ggml_ctx, scale, 1.f, 1.f)); + joint = std::dynamic_pointer_cast(blocks["proj_out"])->forward(ctx, joint); + return DiT::unpatchify_and_crop(ctx->ggml_ctx, joint, x->ne[1], x->ne[0], 1, 1); + } + }; + + struct QwenImage21Runner : public DiffusionModelRunner { + QwenImage21Config config; + QwenImage21Model model; + std::vector pe_data; + std::vector> mask_data; + + QwenImage21Runner(ggml_backend_t backend, const String2TensorStorage& weights, const std::string& prefix, std::shared_ptr weight_manager = nullptr) + : DiffusionModelRunner(backend, prefix, weight_manager), + config(QwenImage21Config::detect_from_weights(weights, prefix)), + model(config) { + model.init(params_ctx, weights, prefix); + } + + std::string get_desc() override { return "qwen_image_2_1"; } + + void get_param_tensors(std::map& tensors, const std::string& prefix) override { + model.get_param_tensors(tensors, prefix); + } + + sd::Tensor compute(int n_threads, const DiffusionParams& inputs) override { + const auto& x = tensor_or_empty(inputs.x); + const auto& context = tensor_or_empty(inputs.context); + if (x.empty() || context.empty() || context.dim() < 2 || context.shape()[0] != config.context_dim || + tensor_or_empty(inputs.timesteps).numel() != 1 || + x.dim() != 4 || x.shape()[3] != 1 || x.shape()[2] != config.in_channels) { + LOG_ERROR("Qwen Image 2.1 requires an image latent and text conditioning with batch size 1"); + return {}; + } + static const std::vector> empty_refs; + const auto& refs = inputs.ref_latents && inputs.ref_image_params.pass_to_dit ? *inputs.ref_latents : empty_refs; + std::vector> shapes; + for (const auto& ref : refs) { + if (ref.dim() != 4 || ref.shape()[2] != config.in_channels || ref.shape()[3] != 1) { + LOG_ERROR("Qwen Image 2.1: invalid reference latent shape"); + return {}; + } + shapes.emplace_back(ref.shape()[1], ref.shape()[0]); + } + shapes.emplace_back(x.shape()[1], x.shape()[0]); + const auto* extra = std::get_if(&inputs.extra); + QwenImage21Layout layout; + try { + layout = QwenImage21Layout::build(context.shape()[1], tensor_or_empty(extra ? extra->image_slots : nullptr), shapes); + } catch (const std::exception& error) { + LOG_ERROR("%s", error.what()); + return {}; + } + pe_data = Rope::embed_nd(layout.positions, 1, 10000.f, config.axes_dim); + mask_data.clear(); + for (const auto& segment : layout.segments) { + sd::Tensor mask; + if (segment.image_index < 0) { + mask = sd::Tensor::zeros({segment.end, segment.end - segment.start}); + for (int64_t q = segment.start; q < segment.end; ++q) { + for (int64_t k = q + 1; k < segment.end; ++k) { + mask[k + segment.end * (q - segment.start)] = -INFINITY; + } + } + } + mask_data.push_back(std::move(mask)); + } + auto build = [&]() { + auto graph = new_graph_custom(QWEN_IMAGE_GRAPH_SIZE * 2); + auto pe = ggml_new_tensor_4d(compute_ctx, GGML_TYPE_F32, 2, 2, config.head_dim / 2, layout.positions.size()); + set_backend_tensor_data(pe, pe_data.data()); + std::vector masks, ref_inputs; + for (const auto& mask : mask_data) { + masks.push_back(mask.empty() ? nullptr : make_input(mask)); + } + for (const auto& ref : refs) { + ref_inputs.push_back(make_input(ref)); + } + auto ctx = get_context(); + auto out = model.forward(&ctx, make_input(x), make_input(*inputs.timesteps), make_input(context), + ref_inputs, pe, layout, masks); + ggml_build_forward_expand(graph, out); + return graph; + }; + return restore_trailing_singleton_dims(GGMLRunner::compute(build, n_threads, false), x.dim()); + } + }; +} + +#endif // __SD_MODEL_DIFFUSION_QWEN_IMAGE_2_1_H__ diff --git a/src/model/vae/vae.hpp b/src/model/vae/vae.hpp index 2c952213..4d8d1351 100644 --- a/src/model/vae/vae.hpp +++ b/src/model/vae/vae.hpp @@ -162,7 +162,7 @@ public: int scale_factor = 8; if (version == VERSION_LTXAV) { scale_factor = 32; - } else if (version == VERSION_WAN2_2_TI2V || sd_version_is_hunyuan_video(version) || sd_version_is_mage_flow(version) || sd_version_is_minimax_h3(version)) { + } else if (version == VERSION_WAN2_2_TI2V || version == VERSION_QWEN_IMAGE_2_1 || sd_version_is_hunyuan_video(version) || sd_version_is_mage_flow(version) || sd_version_is_minimax_h3(version)) { scale_factor = 16; } else if (sd_version_uses_flux2_vae(version)) { scale_factor = 16; diff --git a/src/model/vae/wan_vae.hpp b/src/model/vae/wan_vae.hpp index 0ea8cd8c..79efca4a 100644 --- a/src/model/vae/wan_vae.hpp +++ b/src/model/vae/wan_vae.hpp @@ -26,6 +26,13 @@ namespace WAN { bool bias; void init_params(ggml_context* ctx, const String2TensorStorage& tensor_storage_map = {}, const std::string prefix = "") override { + auto weight = tensor_storage_map.find(prefix + "weight"); + if (weight != tensor_storage_map.end() && weight->second.ne[2] == 1 && + weight->second.ne[3] == in_channels * out_channels) { + // Image VAE exports may retain Conv3d weights with a singleton temporal kernel. + std::get<0>(kernel_size) = 1; + std::get<0>(padding) = 0; + } params["weight"] = ggml_new_tensor_4d(ctx, GGML_TYPE_F16, std::get<2>(kernel_size), @@ -140,7 +147,7 @@ namespace WAN { std::string mode; public: - Resample(int64_t dim, const std::string& mode, bool wan2_2 = false) + Resample(int64_t dim, const std::string& mode, bool wan2_2 = false, bool is_2D = false) : dim(dim), mode(mode) { if (mode == "upsample2d") { if (wan2_2) { @@ -154,12 +161,20 @@ namespace WAN { } else { blocks["resample.1"] = std::shared_ptr(new Conv2d(dim, dim / 2, {3, 3}, {1, 1}, {1, 1})); } - blocks["time_conv"] = std::shared_ptr(new CausalConv3d(dim, dim * 2, {3, 1, 1}, {1, 1, 1}, {1, 0, 0})); + if (is_2D) { + blocks["time_conv"] = std::make_shared(dim, dim * 2, std::pair{1, 1}); + } else { + blocks["time_conv"] = std::shared_ptr(new CausalConv3d(dim, dim * 2, {3, 1, 1}, {1, 1, 1}, {1, 0, 0})); + } } else if (mode == "downsample2d") { blocks["resample.1"] = std::shared_ptr(new Conv2d(dim, dim, {3, 3}, {2, 2})); } else if (mode == "downsample3d") { blocks["resample.1"] = std::shared_ptr(new Conv2d(dim, dim, {3, 3}, {2, 2})); - blocks["time_conv"] = std::shared_ptr(new CausalConv3d(dim, dim, {3, 1, 1}, {2, 1, 1}, {0, 0, 0})); + if (is_2D) { + blocks["time_conv"] = std::make_shared(dim, dim, std::pair{1, 1}); + } else { + blocks["time_conv"] = std::shared_ptr(new CausalConv3d(dim, dim, {3, 1, 1}, {2, 1, 1}, {0, 0, 0})); + } } else if (mode == "none") { // nn.Identity() } else { @@ -469,7 +484,7 @@ namespace WAN { } if (down_flag) { std::string mode = temperal_downsample ? "downsample3d" : "downsample2d"; - blocks["downsamples." + std::to_string(i)] = std::shared_ptr(new Resample(out_dim, mode, true)); + blocks["downsamples." + std::to_string(i)] = std::shared_ptr(new Resample(out_dim, mode, true, is_2D)); i++; } } @@ -532,7 +547,7 @@ namespace WAN { } if (up_flag) { std::string mode = temperal_upsample ? "upsample3d" : "upsample2d"; - blocks["upsamples." + std::to_string(i)] = std::shared_ptr(new Resample(out_dim, mode, true)); + blocks["upsamples." + std::to_string(i)] = std::shared_ptr(new Resample(out_dim, mode, true, is_2D)); i++; } } @@ -1054,9 +1069,24 @@ namespace WAN { input_channels = 4; } + if (version == VERSION_QWEN_IMAGE_2_1) { + wan2_2 = true; + dec_dim = 144; + z_dim = 64; + input_channels = 4; + dim_mult = {1, 2, 4, 8, 8}; + } + if (is_2D) { - temperal_upsample = {false, false, false}; - temperal_downsample = {false, false, false}; + temperal_upsample.assign(dim_mult.size() - 1, false); + temperal_downsample.assign(dim_mult.size() - 1, false); + } + if (version == VERSION_QWEN_IMAGE_2_1) { + // Temporal shortcut factors still affect single-frame channel grouping. + temperal_upsample = {true, true, true, false}; + temperal_downsample = {false, true, true, true}; + _conv_num = 2 * (2 + static_cast(dim_mult.size()) * (num_res_blocks + 1)) + 3 + (is_2D ? 0 : 2); + _enc_conv_num = 2 * (2 + static_cast(dim_mult.size()) * num_res_blocks) + 3 + (is_2D ? 0 : 2); } if (!decode_only) { @@ -1271,18 +1301,9 @@ namespace WAN { SDVersion version = VERSION_WAN2, std::shared_ptr weight_manager = nullptr) : VAE(version, backend, prefix, weight_manager), decode_only(decode_only) { - bool is_2D = false; - for (const auto& [name, tensor_storage] : tensor_storage_map) { - if (ends_with(name, "decoder.conv1.weight")) { - if (tensor_storage.ne[2] > 3) { - is_2D = true; - } - break; - } - } - if (is_2D) { - LOG_VERBOSE("USING 2D VAE"); - } + const auto conv_in = tensor_storage_map.find((prefix.empty() ? "" : prefix + ".") + "decoder.conv1.weight"); + const bool is_2D = conv_in != tensor_storage_map.end() && conv_in->second.ne[2] > 3; + LOG_VERBOSE("Wan VAE convolution type: %s", is_2D ? "2D" : "3D"); ae = WanVAE(decode_only, version, is_2D); ae.init(params_ctx, tensor_storage_map, prefix); } @@ -1342,6 +1363,28 @@ namespace WAN { std_tensor.reshape_(stats_shape); return {std::move(mean_tensor), std::move(std_tensor)}; } + if (version == VERSION_QWEN_IMAGE_2_1 && latents.shape()[channel_dim] == 64) { + stats_shape[static_cast(channel_dim)] = 64; + auto mean_tensor = sd::Tensor::from_vector({0.5126f, 0.7721f, -0.0631f, 1.3506f, -0.7855f, -2.1025f, -0.3458f, 1.3722f, + 1.8873f, -1.7177f, -0.6510f, 0.2732f, 0.7562f, -0.6163f, -1.0277f, 3.8363f, + 2.0210f, 0.0472f, 0.9320f, 2.0087f, 2.4954f, -0.1391f, -1.4249f, 1.8464f, + -0.5236f, 1.2826f, 3.7046f, -1.3035f, 2.7286f, -1.4518f, -1.9036f, -1.9955f, + -0.0342f, -1.0265f, -0.7636f, 3.0555f, 0.0746f, -3.0751f, -0.1076f, 1.7376f, + -1.0914f, -1.9435f, -0.2784f, -1.3680f, 0.4809f, -0.4433f, 0.3764f, 0.5729f, + -2.0595f, 1.0960f, -1.3260f, -2.0211f, -5.0179f, 0.5275f, 4.0162f, 1.8505f, + 0.3026f, 1.9373f, 1.4937f, 0.2632f, 0.5547f, -1.7121f, -0.1562f, 0.0304f}); + auto std_tensor = sd::Tensor::from_vector({3.2001f, 3.2936f, 3.4321f, 3.0091f, 3.1061f, 4.0379f, 4.0705f, 3.7910f, + 3.0785f, 3.6500f, 3.9308f, 3.0904f, 2.8778f, 3.7675f, 3.7320f, 5.0756f, + 3.2864f, 4.0397f, 3.1317f, 4.0443f, 2.9249f, 3.9454f, 3.0988f, 4.2489f, + 3.4896f, 3.8513f, 3.9323f, 3.4719f, 3.7498f, 4.2830f, 3.5694f, 4.2467f, + 3.9037f, 3.2947f, 5.0770f, 3.5075f, 3.2700f, 3.4767f, 2.8063f, 5.1125f, + 3.5327f, 4.7833f, 3.1286f, 4.1819f, 3.8527f, 3.8312f, 3.5605f, 4.3875f, + 3.9624f, 4.0168f, 3.5643f, 4.0550f, 5.5614f, 4.2963f, 4.4080f, 3.4959f, + 3.8747f, 3.7608f, 3.5735f, 3.1490f, 3.7662f, 3.6746f, 3.4563f, 3.8161f}); + mean_tensor.reshape_(stats_shape); + std_tensor.reshape_(stats_shape); + return {std::move(mean_tensor), std::move(std_tensor)}; + } GGML_ABORT("unexpected latent channel dimension %lld for version %d", (long long)latents.shape()[channel_dim], version); diff --git a/src/model_loader.cpp b/src/model_loader.cpp index a2afd835..544582e6 100644 --- a/src/model_loader.cpp +++ b/src/model_loader.cpp @@ -423,6 +423,9 @@ SDVersion ModelLoader::get_sd_version() const { if (tensor_storage.name.find("language_model.model.layers.0.self_attn.q_proj_mot_gen.weight") != std::string::npos) { return VERSION_SENSENOVA_U1_5; } + if (tensor_storage.name == "model.diffusion_model.txt_in.text_norm.weight") { + return VERSION_QWEN_IMAGE_2_1; + } if (tensor_storage.name.find("model.diffusion_model.transformer_blocks.0.img_mod.1.weight") != std::string::npos) { auto img_in = tensor_storage_map.find("model.diffusion_model.img_in.weight"); if (img_in != tensor_storage_map.end() && img_in->second.ne[0] == 128) { diff --git a/src/name_conversion.cpp b/src/name_conversion.cpp index d0ff45c8..a3249ef5 100644 --- a/src/name_conversion.cpp +++ b/src/name_conversion.cpp @@ -999,7 +999,30 @@ std::string convert_diffusers_vae_to_original_sd1(std::string name) { return result; } -std::string convert_diffusers_to_original_wan_vae(std::string name) { +std::string convert_diffusers_to_original_wan_vae(std::string name, bool qwen_image_2_1 = false) { + if (qwen_image_2_1) { + for (int i = 0; i < 5; ++i) { + const auto index = std::to_string(i); + for (const auto& side : {std::string("encoder"), std::string("decoder")}) { + const bool encoder = side == "encoder"; + const std::string old_prefix = side + (encoder ? ".down_blocks." : ".up_blocks.") + index + "."; + const std::string new_prefix = side + (encoder ? ".downsamples." : ".upsamples.") + index + "."; + if (!starts_with(name, old_prefix)) { + continue; + } + name.replace(0, old_prefix.size(), new_prefix); + const std::string layers = encoder ? "downsamples." : "upsamples."; + for (int j = 0; j < (encoder ? 2 : 3); ++j) { + const auto old_resnet = new_prefix + "resnets." + std::to_string(j) + "."; + const auto new_resnet = new_prefix + layers + std::to_string(j) + "."; + replace_with_prefix_map(name, std::vector>{{old_resnet + "conv_shortcut.", new_resnet + "shortcut."}, + {old_resnet, new_resnet + "residual."}}); + } + replace_with_prefix_map(name, std::vector>{{new_prefix + (encoder ? "downsampler." : "upsampler."), + new_prefix + layers + (encoder ? "2." : "3.")}}); + } + } + } static const std::vector> prefix_map = { {"quant_conv.", "conv1."}, {"post_quant_conv.", "conv2."}, @@ -1055,7 +1078,11 @@ std::string convert_diffusers_to_original_wan_vae(std::string name) { }; replace_with_name_map(name, shared_name_map); - replace_with_prefix_map(name, prefix_map); + if (qwen_image_2_1) { + replace_with_prefix_map(name, std::vector>{{"quant_conv.", "conv1."}, {"post_quant_conv.", "conv2."}}); + } else { + replace_with_prefix_map(name, prefix_map); + } // Only apply the ResNet-specific renaming if the tensor belongs to a ResNet block. // This prevents generic ".conv1." or ".conv2." matching on top-level encoder/decoder convolutions. @@ -1071,7 +1098,7 @@ std::string convert_first_stage_model_name(std::string name, std::string prefix, return name; } if (sd_version_uses_wan_vae(version)) { - return convert_diffusers_to_original_wan_vae(name); + return convert_diffusers_to_original_wan_vae(name, version == VERSION_QWEN_IMAGE_2_1); } static std::unordered_map vae_name_map = { {"decoder.post_quant_conv.", "post_quant_conv."}, @@ -1489,7 +1516,7 @@ std::string convert_tensor_name(std::string name, SDVersion version) { replace_with_prefix_map(name, prefix_map); - if (sd_version_is_boogu_image(version) || sd_version_is_krea2(version) || sd_version_is_mage_flow(version) || sd_version_is_minimax_h3(version)) { + if (version == VERSION_QWEN_IMAGE_2_1 || sd_version_is_boogu_image(version) || sd_version_is_krea2(version) || sd_version_is_mage_flow(version) || sd_version_is_minimax_h3(version)) { const std::string hf_vision_prefix = "text_encoders.llm.model.visual."; if (starts_with(name, hf_vision_prefix)) { name = "text_encoders.llm.visual." + name.substr(hf_vision_prefix.size()); diff --git a/src/pipeline/diffusion_engine.cpp b/src/pipeline/diffusion_engine.cpp index e0663c65..d9ccc4ab 100644 --- a/src/pipeline/diffusion_engine.cpp +++ b/src/pipeline/diffusion_engine.cpp @@ -80,6 +80,7 @@ const char* model_version_to_str[] = { "LingBot Video", "Qwen Image", "Qwen Image Layered", + "Qwen Image 2.1", "Hunyuan Video", "Anima", "Flux.2", @@ -2375,6 +2376,8 @@ sd::Tensor StableDiffusionGGML::sample(const std::shared_ptrwidth; vae_height = request->height; } else { - int target_pixels = ref_image_params.vae_input_max_pixels > 0 ? ref_image_params.vae_input_max_pixels : 1024 * 1024; + int default_pixels = sd->version == VERSION_QWEN_IMAGE_2_1 ? request->width * request->height : 1024 * 1024; + int target_pixels = ref_image_params.vae_input_max_pixels > 0 ? ref_image_params.vae_input_max_pixels : default_pixels; int vae_image_size = std::min(target_pixels, request->width * request->height); vae_width = sqrt(vae_image_size * ref_images[i].shape()[0] / ref_images[i].shape()[1]); vae_height = vae_width * ref_images[i].shape()[1] / ref_images[i].shape()[0]; @@ -309,6 +310,9 @@ namespace sd::pipeline { resized_ref_img.shape()[0]); ref_latent = sd->encode_first_stage(resized_ref_img); + if (sd->version == VERSION_QWEN_IMAGE_2_1) { + ref_images[i] = std::move(resized_ref_img); + } } else { ref_latent = sd->encode_first_stage(ref_images[i]); } diff --git a/src/pipeline/model_builders.cpp b/src/pipeline/model_builders.cpp index 84d03278..31b0f2b4 100644 --- a/src/pipeline/model_builders.cpp +++ b/src/pipeline/model_builders.cpp @@ -28,6 +28,7 @@ #include "model/diffusion/model.hpp" #include "model/diffusion/pid.hpp" #include "model/diffusion/qwen_image.hpp" +#include "model/diffusion/qwen_image_2_1.hpp" #include "model/diffusion/sensenova_u1.h" #include "model/diffusion/unet.hpp" #include "model/diffusion/wan.hpp" @@ -285,12 +286,19 @@ namespace sd::model_builders { enable_vision, weight_manager, tokenizers); - result.diffusion = std::make_shared(ctx.backends.runtime_backend(SDBackendModule::DIFFUSION), - tensor_storage_map, - "model.diffusion_model", - version, - weight_manager, - sd_ctx_params->model_args); + if (version == VERSION_QWEN_IMAGE_2_1) { + result.diffusion = std::make_shared(ctx.backends.runtime_backend(SDBackendModule::DIFFUSION), + tensor_storage_map, + "model.diffusion_model", + weight_manager); + } else { + result.diffusion = std::make_shared(ctx.backends.runtime_backend(SDBackendModule::DIFFUSION), + tensor_storage_map, + "model.diffusion_model", + version, + weight_manager, + sd_ctx_params->model_args); + } } else if (sd_version_is_mage_flow(version)) { result.conditioner = std::make_shared(ctx.backends.runtime_backend(SDBackendModule::TE), tensor_storage_map, diff --git a/src/pipeline/request.cpp b/src/pipeline/request.cpp index 2874bbae..8befcd7c 100644 --- a/src/pipeline/request.cpp +++ b/src/pipeline/request.cpp @@ -65,7 +65,7 @@ namespace sd::pipeline { return LCM_SCHEDULER; } else if (sample_method == DDIM_TRAILING_SAMPLE_METHOD) { return SIMPLE_SCHEDULER; - } else if (sd != nullptr && sd_version_is_flux(sd->version)) { + } else if (sd != nullptr && (sd_version_is_flux(sd->version) || sd->version == VERSION_QWEN_IMAGE_2_1)) { return FLUX_SCHEDULER; } else if (sd != nullptr && sd_version_is_flux2(sd->version)) { return FLUX2_SCHEDULER;