How to Convert Qwen-Image-2.1 to OpenVINO IR (and Build an Uncensored Version)
Export Qwen-Image-2.1 to OpenVINO IR on Python 3.12: work around the allow_new export crash, quantize the text encoder to INT4 with group_size=-1, and swap in an abliterated encoder for an uncensored build.
Qwen-Image-2.1 is a 33 GB unified image model — text-to-image and image editing in one pipeline — and OpenVINO has day-0 support for it. Converting it to OpenVINO IR is not a single command, though. The official export path crashes on Python 3.14 with a NormalizedConfig.__init__() got multiple values for argument 'allow_new' error, and on a 30 GB machine a full-pipeline export gets OOM-killed before it writes anything. This walks through a conversion that actually completes, and then swaps in an abliterated text encoder to build an uncensored version.
The short version: run the export on Python 3.12, export the text encoder by itself rather than the whole pipeline, quantize it to INT4 with group_size=-1, and graft it into a pre-converted INT4 pipeline. The abliterated text encoder is the only component that needs to change for an uncensored build.
What Qwen-Image-2.1 is made of
The pipeline is a diffusers QwenImage21Pipeline with three weight-bearing components:
text_encoder Qwen3VLForConditionalGeneration 17.5 GB
transformer QwenImage21Transformer2DModel 14.2 GB
vae AutoencoderKLQwenImage21 1.4 GB
source total 33.1 GB
The text encoder is a Qwen3-VL model run text-only for prompt embeddings. The transformer is block-causal. The VAE is a 3D causal-conv (video) VAE run on a single temporal frame.
That 17.5 GB text encoder is the single largest component, and it is also where the uncensoring lives — which is why the component-wise approach below works so well for building an uncensored variant.
The Python 3.14 export bug
On Python 3.14, optimum-cli export openvino fails immediately:
File "optimum/exporters/base.py", line 151, in __init__
self._normalized_config = self.NORMALIZED_CONFIG_CLASS(self._config)
TypeError: NormalizedConfig.__init__() got multiple values for argument 'allow_new'
The cause is subtle. NormalizedConfig.with_args() returns a functools.partial:
@classmethod
def with_args(cls, allow_new=False, **kwargs):
return functools.partial(cls, allow_new=allow_new, **kwargs)
A functools.partial is a descriptor on Python 3.14. Storing it as a class attribute means self.NORMALIZED_CONFIG_CLASS no longer returns the partial — it returns a bound method with self already prepended. Calling it with the config as a positional argument produces two positional arguments, and the second collides with the allow_new keyword:
NormalizedConfig.__init__() got multiple values for argument 'allow_new'
You can confirm the mechanism in one line:
import functools
class NC:
def __init__(self, config, allow_new=False, **kw): ...
@classmethod
def with_args(cls, allow_new=False, **kwargs):
return functools.partial(cls, allow_new=allow_new, **kwargs)
class Holder:
NCC = NC.with_args(allow_new=True, hidden_size="x")
h = Holder()
print(h.NCC) # <bound method ? of <Holder object>> <- not a partial
This reproduces on Python 3.14 and not on 3.11 or 3.12. Build the export environment on Python 3.12. That is the fix.
Install the toolchain
Qwen-Image-2.1 support lives on the main branches, not in any release. Release diffusers 0.37.1 does not even define QwenImage21Transformer2DModel.
uv venv venv/qi21e --python 3.12
uv pip install --python venv/qi21e/bin/python3 -U --index-strategy unsafe-best-match \
--extra-index-url https://download.pytorch.org/whl/cpu \
"git+https://github.com/huggingface/diffusers.git@main" \
"git+https://github.com/huggingface/optimum-intel.git@main" \
torch accelerate nncf numpy pillow tqdm transformers huggingface_hub safetensors
uv pip install --python venv/qi21e/bin/python3 -U --prerelease allow \
--index-strategy unsafe-best-match \
--extra-index-url https://storage.openvinotoolkit.org/simple/wheels/nightly \
"openvino>=2026.5.0.dev" openvino-tokenizers openvino-genai
Confirm the model class exists before you download 33 GB:
venv/qi21e/bin/python3 -c "from diffusers import QwenImage21Transformer2DModel; print('ok')"
The --index-strategy unsafe-best-match flag matters: without it, uv refuses to look past the first index that contains a package and the resolve fails.
Exporting the whole pipeline does not fit
The obvious command is a full-pipeline export:
export OMP_NUM_THREADS=4 MKL_NUM_THREADS=4
optimum-cli export openvino \
--model /path/to/Qwen-Image-2.1 \
--task text-to-image --library diffusers --weight-format fp16 \
/path/to/qwen-image-2.1-ov-fp16
On a 30 GB machine this is killed. optimum-cli loads the full 33 GB of bf16 weights into PyTorch and then builds the traced graph, and the two together exceed RAM plus swap:
Killed
EXPORT_EXIT=137
137 is 128 + 9 — SIGKILL, from the OOM killer. It dies after loading all five components successfully, during tracing, with no Python traceback. If you see an exit code of 137 and no error, check RSS against your available memory before assuming a code bug.
Export the text encoder by itself
Since the uncensored delta is entirely in the text encoder, export only that. The 17.5 GB source fits comfortably where 33 GB did not.
Only the language-model half is needed — the pipeline reads the last decoder layer’s output, and optimum exports it as last_hidden_state:
import os, torch
from pathlib import Path
from transformers import Qwen3VLForConditionalGeneration
from optimum.exporters.openvino.convert import export_models
from optimum.exporters.tasks import TasksManager
SRC = "/path/to/text_encoder"
te = Qwen3VLForConditionalGeneration.from_pretrained(SRC, dtype=torch.float16)
lm = te.model.language_model
lm.config._class_name = te.__class__.__name__ # so config.json matches model_index.json
ctor = TasksManager.get_exporter_config_constructor(
model=lm, exporter="openvino", library_name="diffusers",
task="feature-extraction", model_type="qwenimage21-text-encoder")
cfg = ctor(lm.config, int_dtype="int64", float_dtype="fp32")
cfg.runtime_options = {"ACTIVATIONS_SCALE_FACTOR": "8.0"}
Path("/path/to/out").mkdir(parents=True, exist_ok=True)
export_models({"text_encoder": (lm, cfg)}, output_dir=Path("/path/to/out"),
library_name="diffusers")
This completes in 57 seconds and produces a 14.4 GB fp16 IR with exactly the interface the pipeline expects:
inputs : ['input_ids', 'attention_mask']
outputs: ['last_hidden_state']
Quantize the IR to INT4
A 14.4 GB fp16 text encoder makes the pipeline load slowly and hold far more memory than the INT4 stock model. Compress the IR — this never loads PyTorch, so it peaks low:
import openvino as ov, nncf
m = ov.Core().read_model("out/openvino_model.xml")
c = nncf.compress_weights(
m,
mode=nncf.CompressWeightsMode.INT4_ASYM,
group_size=-1, # auto
ratio=1.0,
)
ov.save_model(c, "text_encoder_int4/openvino_model.xml")
Use group_size=-1. Both 128 and 64 fail on this architecture:
nncf.errors.InvalidGroupSizeError: Failed to apply group-wise quantization with group size value 128.
-1 lets NNCF pick a valid size per layer. The result is 3.8 GB, matching the stock INT4 model’s 4.0 GB, in 37 seconds.
Swap in an abliterated text encoder
For an uncensored build you replace the stock Qwen3-VL text encoder with an abliterated one. pottokao/Qwen-Image-2.1-Text-Encoder-Heretic ships the full transformers layout, so it drops straight into the export above — point SRC at its directory.
Start from a pre-converted INT4 pipeline rather than exporting all 33 GB yourself. The transformer, VAE, and vision encoder are unchanged by uncensoring; only the text encoder differs:
cp -al qwen-image-2.1-ov-int4 qi21-uc-ov-int4
for slot in text_encoder text_encoder_i2i; do
cp text_encoder_int4/openvino_model.xml qi21-uc-ov-int4/$slot/openvino_model.xml
cp text_encoder_int4/openvino_model.bin qi21-uc-ov-int4/$slot/openvino_model.bin
done
The text_encoder_i2i slot is a second copy the pipeline uses for editing, so replace both. Use cp -al for the initial copy so the unchanged components share inodes instead of duplicating 13 GB.
Verify the swap actually did something
Do not trust that a directory swap changed the model. Compare the embeddings directly:
import openvino as ov, numpy as np
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("qwen-image-2.1-ov-int4/processor")
enc = tok("a portrait of a woman, photorealistic", return_tensors="np")
core = ov.Core()
stock = core.compile_model("qwen-image-2.1-ov-int4/text_encoder/openvino_model.xml", "CPU")
heret = core.compile_model("qi21-uc-ov-int4/text_encoder/openvino_model.xml", "CPU")
a = stock([enc["input_ids"], enc["attention_mask"]])[0]
b = heret([enc["input_ids"], enc["attention_mask"]])[0]
print("max abs diff :", np.abs(a.astype("float32") - b.astype("float32")).max())
print("identical :", bool(np.array_equal(a, b)))
The two encoders must differ substantially:
stock mean 0.59543
heretic mean 0.22803
max abs diff : 1550.99353
mean abs diff: 10.12831
identical : False
If identical is True, you copied the wrong file.
Performance
On an Intel Core Ultra 7 258V (Arc 130V/140V iGPU, 8 cores, 30 GB RAM), using the INT4 pipeline:
| Test | Result |
|---|---|
| 512x512, 12 steps | 16.8 s |
| 1024x1024, 20 steps | 139.8 s (6.99 s/step) |
| pipeline load (cold) | 63-75 s |
| peak RSS | ~6 GB |
| text encoder export | 57 s |
| INT4 quantization | 37 s |
Image editing (Image2ImagePipeline) loads but exhausts 30 GB during generation — it needs the vision encoder and both text encoders resident at once. This is not resolution-bound; a 256x256 source with 8 steps still reached 28 GB. Text-to-image is unaffected.
Common mistakes
Exporting on Python 3.14
The functools.partial descriptor change makes the export config unconstructible. Use Python 3.12.
Trying to export the full pipeline on a 30 GB machine
33 GB of bf16 weights plus the traced graph will not fit. Export components separately.
Passing an explicit group size to NNCF
group_size=128 and 64 both raise InvalidGroupSizeError on this architecture. Use -1.
Replacing only text_encoder
The pipeline has a second slot, text_encoder_i2i, used for editing. Replace both or editing silently keeps the stock encoder.
Forgetting --index-strategy unsafe-best-match
uv will not resolve the mixed nightly and main-branch requirements without it.
Comments
One comment per thread every 30 minutes · edits are unlimited.