dev/notes
⌕

Spot a mistake? Highlight any text in a post and click Report — it goes straight to the author.

← all posts
advanced · AI · October 7, 2026 · 6 min read

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:

TestResult
512x512, 12 steps16.8 s
1024x1024, 20 steps139.8 s (6.99 s/step)
pipeline load (cold)63-75 s
peak RSS~6 GB
text encoder export57 s
INT4 quantization37 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.

Related posts

Comments

One comment per thread every 30 minutes · edits are unlimited.