Skip to main content

cortiq_engine/
qwen_imagegen.rs

1//! Native Qwen-Image-Edit-2509 orchestration over independent CMF components.
2//! Model stages are scoped separately; only conditioning and latents survive.
3
4use crate::qwen_image::QwenImageTransformer;
5use crate::qwen_image_encoder::{Conditioning, QwenImageEncoder};
6use crate::qwen_image_vae::QwenImageVae;
7use crate::sampler::SplitMix64;
8use cortiq_core::CmfModel;
9use image::{RgbImage, imageops::FilterType};
10use serde::Deserialize;
11use std::path::{Path, PathBuf};
12
13pub struct QwenImagePaths {
14    pub transformer: PathBuf,
15    pub text_encoder: PathBuf,
16    pub vae: PathBuf,
17    pub scheduler: Option<PathBuf>,
18}
19
20pub struct QwenImageParams {
21    pub height: usize,
22    pub width: usize,
23    pub steps: usize,
24    pub true_cfg_scale: f32,
25    pub negative_prompt: Option<String>,
26    pub seed: u64,
27    /// Square-root of reference VAE pixel area; the official default is 1024.
28    pub reference_size: usize,
29    /// Optional raw NCHW f32 noise for comparisons using identical latents.
30    pub initial_latents: Option<PathBuf>,
31}
32
33impl Default for QwenImageParams {
34    fn default() -> Self {
35        Self {
36            height: 1024,
37            width: 1024,
38            steps: 40,
39            true_cfg_scale: 4.0,
40            negative_prompt: Some(" ".into()),
41            seed: 0,
42            reference_size: 1024,
43            initial_latents: None,
44        }
45    }
46}
47
48pub struct QwenImageOutput {
49    /// Channels-first RGB in [0, 1].
50    pub pixels: Vec<f32>,
51    pub height: usize,
52    pub width: usize,
53}
54
55impl QwenImageOutput {
56    pub fn save(&self, path: &Path) -> Result<(), String> {
57        let plane = self.width * self.height;
58        if self.pixels.len() != plane * 3 {
59            return Err("invalid output RGB plane lengths".into());
60        }
61        let mut bytes = Vec::with_capacity(plane * 3);
62        for p in 0..plane {
63            for c in 0..3 {
64                bytes.push((self.pixels[c * plane + p].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
65            }
66        }
67        if path
68            .extension()
69            .and_then(|s| s.to_str())
70            .is_some_and(|s| s.eq_ignore_ascii_case("ppm"))
71        {
72            let mut ppm = format!("P6\n{} {}\n255\n", self.width, self.height).into_bytes();
73            ppm.extend(bytes);
74            return std::fs::write(path, ppm).map_err(|e| e.to_string());
75        }
76        RgbImage::from_raw(self.width as u32, self.height as u32, bytes)
77            .ok_or("output image dimensions overflow")?
78            .save(path)
79            .map_err(|e| format!("{}: {e}", path.display()))
80    }
81}
82
83pub fn edit_files(
84    paths: &QwenImagePaths,
85    prompt: &str,
86    images: &[PathBuf],
87    params: &QwenImageParams,
88    progress: impl FnMut(&str, usize, usize),
89) -> Result<QwenImageOutput, String> {
90    let images = images
91        .iter()
92        .map(|path| {
93            image::open(path)
94                .map(|image| image.into_rgb8())
95                .map_err(|e| format!("{}: {e}", path.display()))
96        })
97        .collect::<Result<Vec<_>, _>>()?;
98    edit(paths, prompt, &images, params, progress)
99}
100
101#[derive(Clone, Debug, Deserialize)]
102#[serde(default)]
103pub struct FlowMatchConfig {
104    pub base_image_seq_len: usize,
105    pub max_image_seq_len: usize,
106    pub base_shift: f32,
107    pub max_shift: f32,
108    pub shift_terminal: f32,
109    pub num_train_timesteps: usize,
110    pub use_dynamic_shifting: bool,
111    pub time_shift_type: String,
112    pub invert_sigmas: bool,
113    pub stochastic_sampling: bool,
114    pub use_karras_sigmas: bool,
115    pub use_exponential_sigmas: bool,
116    pub use_beta_sigmas: bool,
117}
118
119impl Default for FlowMatchConfig {
120    fn default() -> Self {
121        Self {
122            base_image_seq_len: 256,
123            max_image_seq_len: 8192,
124            base_shift: 0.5,
125            max_shift: 0.9,
126            shift_terminal: 0.02,
127            num_train_timesteps: 1000,
128            use_dynamic_shifting: true,
129            time_shift_type: "exponential".into(),
130            invert_sigmas: false,
131            stochastic_sampling: false,
132            use_karras_sigmas: false,
133            use_exponential_sigmas: false,
134            use_beta_sigmas: false,
135        }
136    }
137}
138
139/// Diffusers FlowMatchEulerDiscreteScheduler, including terminal stretching.
140/// Resolution shifting uses generated tokens, excluding reference tokens.
141pub fn flow_match_sigmas(
142    steps: usize,
143    generated_tokens: usize,
144    config: &FlowMatchConfig,
145) -> Result<Vec<f32>, String> {
146    if steps < 2 || steps > 1000 || generated_tokens == 0 {
147        return Err("Qwen Image needs 2..=1000 steps and a nonempty latent grid".into());
148    }
149    if !config.use_dynamic_shifting
150        || config.time_shift_type != "exponential"
151        || config.invert_sigmas
152        || config.stochastic_sampling
153        || config.use_karras_sigmas
154        || config.use_exponential_sigmas
155        || config.use_beta_sigmas
156        || config.num_train_timesteps != 1000
157        || config.max_image_seq_len <= config.base_image_seq_len
158        || !config.base_shift.is_finite()
159        || !config.max_shift.is_finite()
160        || !(0.0..1.0).contains(&config.shift_terminal)
161    {
162        return Err(
163            "scheduler is not the supported Qwen Image exponential FlowMatch Euler contract".into(),
164        );
165    }
166    let slope = (config.max_shift as f64 - config.base_shift as f64)
167        / (config.max_image_seq_len - config.base_image_seq_len) as f64;
168    let mu = config.base_shift as f64
169        + slope * (generated_tokens as f64 - config.base_image_seq_len as f64);
170    let exp_mu = mu.exp() as f32;
171    let mut sigmas = (0..steps)
172        .map(|i| {
173            let sigma = (1.0 - i as f64 * (1.0 - 1.0 / steps as f64) / (steps - 1) as f64) as f32;
174            exp_mu / (exp_mu + (1.0 / sigma - 1.0))
175        })
176        .collect::<Vec<_>>();
177    if config.shift_terminal != 0.0 {
178        let scale = (1.0 - sigmas[steps - 1]) / (1.0 - config.shift_terminal);
179        if !scale.is_finite() || scale <= 0.0 {
180            return Err("invalid terminal-stretch scale".into());
181        }
182        for sigma in &mut sigmas {
183            *sigma = 1.0 - (1.0 - *sigma) / scale;
184        }
185    }
186    sigmas.push(0.0);
187    if sigmas.iter().any(|v| !v.is_finite()) || sigmas.windows(2).any(|w| w[0] <= w[1]) {
188        return Err("Qwen Image scheduler did not produce finite descending sigmas".into());
189    }
190    Ok(sigmas)
191}
192
193fn latent_len(channels: usize, height: usize, width: usize) -> Result<usize, String> {
194    channels
195        .checked_mul(height)
196        .and_then(|v| v.checked_mul(width))
197        .filter(|&v| v > 0)
198        .ok_or_else(|| "empty or overflowing latent dimensions".into())
199}
200
201/// NCHW -> token-major [h/2*w/2, channels*4], with channel,dy,dx order.
202pub fn pack_latents(
203    input: &[f32],
204    channels: usize,
205    height: usize,
206    width: usize,
207) -> Result<Vec<f32>, String> {
208    if height % 2 != 0 || width % 2 != 0 || input.len() != latent_len(channels, height, width)? {
209        return Err("latent packing needs even spatial dimensions and exact NCHW data".into());
210    }
211    let mut out = Vec::with_capacity(input.len());
212    for y in (0..height).step_by(2) {
213        for x in (0..width).step_by(2) {
214            for channel in 0..channels {
215                for dy in 0..2 {
216                    for dx in 0..2 {
217                        out.push(input[(channel * height + y + dy) * width + x + dx]);
218                    }
219                }
220            }
221        }
222    }
223    Ok(out)
224}
225
226pub fn unpack_latents(
227    input: &[f32],
228    channels: usize,
229    height: usize,
230    width: usize,
231) -> Result<Vec<f32>, String> {
232    if height % 2 != 0 || width % 2 != 0 || input.len() != latent_len(channels, height, width)? {
233        return Err("latent unpacking needs even spatial dimensions and exact packed data".into());
234    }
235    let mut out = vec![0.0; input.len()];
236    let mut i = 0;
237    for y in (0..height).step_by(2) {
238        for x in (0..width).step_by(2) {
239            for channel in 0..channels {
240                for dy in 0..2 {
241                    for dx in 0..2 {
242                        out[(channel * height + y + dy) * width + x + dx] = input[i];
243                        i += 1;
244                    }
245                }
246            }
247        }
248    }
249    Ok(out)
250}
251
252#[derive(Deserialize)]
253struct VaeScale {
254    z_dim: usize,
255    latents_mean: Vec<f32>,
256    latents_std: Vec<f32>,
257}
258
259fn vae_scale(path: &Path) -> Result<VaeScale, String> {
260    let model = CmfModel::open(path).map_err(|e| e.to_string())?;
261    let config_name = if model.tensor("image.vae.config_json").is_some() {
262        "image.vae.config_json"
263    } else {
264        "image.config_json"
265    };
266    let config: VaeScale = serde_json::from_slice(
267        model
268            .tensor_bytes(config_name)
269            .map_err(|e| format!("VAE CMF image.config_json: {e}"))?,
270    )
271    .map_err(|e| format!("VAE configuration: {e}"))?;
272    if config.z_dim != 16
273        || config.latents_mean.len() != 16
274        || config.latents_std.len() != 16
275        || config.latents_mean.iter().any(|v| !v.is_finite())
276        || config
277            .latents_std
278            .iter()
279            .any(|v| !v.is_finite() || *v <= 0.0)
280    {
281        return Err(
282            "Qwen Image requires 16 finite VAE means and positive standard deviations".into(),
283        );
284    }
285    Ok(config)
286}
287
288fn embedded_scheduler(path: &Path) -> Result<Option<FlowMatchConfig>, String> {
289    let model = CmfModel::open(path).map_err(|e| e.to_string())?;
290    let Some(entry) = model.tensor("image.scheduler_config_json") else {
291        return Ok(None);
292    };
293    if entry.dtype != cortiq_core::TensorDtype::U8
294        || entry.shape.len() != 1
295        || entry.shape[0] != entry.n_elems()
296    {
297        return Err("embedded Qwen scheduler must be a one-dimensional U8 blob".into());
298    }
299    serde_json::from_slice(model.entry_bytes(entry))
300        .map(Some)
301        .map_err(|e| format!("embedded scheduler configuration: {e}"))
302}
303
304fn normalize_latents(data: &mut [f32], config: &VaeScale, decode: bool) -> Result<(), String> {
305    if data.is_empty() || config.z_dim == 0 || data.len() % config.z_dim != 0 {
306        return Err("invalid VAE channel planes".into());
307    }
308    let plane = data.len() / config.z_dim;
309    for (channel, row) in data.chunks_exact_mut(plane).enumerate() {
310        for x in row {
311            *x = if decode {
312                *x * config.latents_std[channel] + config.latents_mean[channel]
313            } else {
314                (*x - config.latents_mean[channel]) / config.latents_std[channel]
315            };
316        }
317    }
318    Ok(())
319}
320
321fn gaussian_noise(count: usize, seed: u64, path: Option<&Path>) -> Result<Vec<f32>, String> {
322    if let Some(path) = path {
323        let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
324        if bytes.len() != count * 4 {
325            return Err("initial latent file has the wrong size".into());
326        }
327        let data = bytes
328            .chunks_exact(4)
329            .map(|b| f32::from_le_bytes(b.try_into().unwrap()))
330            .collect::<Vec<_>>();
331        if data.iter().any(|v| !v.is_finite()) {
332            return Err("initial latents contain nonfinite values".into());
333        }
334        return Ok(data);
335    }
336    let mut rng = SplitMix64::new(seed);
337    let mut data = Vec::with_capacity(count);
338    while data.len() < count {
339        let a = ((rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64).max(1e-300);
340        let b = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
341        let radius = (-2.0 * a.ln()).sqrt();
342        let angle = 2.0 * std::f64::consts::PI * b;
343        data.push((radius * angle.cos()) as f32);
344        if data.len() < count {
345            data.push((radius * angle.sin()) as f32);
346        }
347    }
348    Ok(data)
349}
350
351fn validate_conditioning(value: &Conditioning) -> Result<(), String> {
352    if value.seq_len == 0
353        || value.hidden_size == 0
354        || value.hidden.len()
355            != value
356                .seq_len
357                .checked_mul(value.hidden_size)
358                .ok_or("conditioning size overflow")?
359        || value.hidden.iter().any(|v| !v.is_finite())
360    {
361        return Err("text encoder returned invalid or nonfinite conditioning".into());
362    }
363    Ok(())
364}
365
366/// True CFG and Qwen's per-token vector-norm correction.
367fn rescale_cfg(conditional: &mut [f32], unconditional: &[f32], scale: f32) -> Result<(), String> {
368    if conditional.len() != unconditional.len() || conditional.len() % 64 != 0 || !scale.is_finite()
369    {
370        return Err("invalid packed CFG prediction shape or scale".into());
371    }
372    for (cond, uncond) in conditional
373        .chunks_exact_mut(64)
374        .zip(unconditional.chunks_exact(64))
375    {
376        let cond_norm = cond.iter().map(|v| v * v).sum::<f32>().sqrt();
377        for (c, u) in cond.iter_mut().zip(uncond) {
378            *c = *u + scale * (*c - *u);
379        }
380        let combined_norm = cond.iter().map(|v| v * v).sum::<f32>().sqrt();
381        if combined_norm > 0.0 {
382            for v in cond {
383                *v *= cond_norm / combined_norm;
384            }
385        }
386    }
387    Ok(())
388}
389
390/// Native image editing; events are stage name, completed steps, total steps.
391pub fn edit(
392    paths: &QwenImagePaths,
393    prompt: &str,
394    images: &[RgbImage],
395    params: &QwenImageParams,
396    mut progress: impl FnMut(&str, usize, usize),
397) -> Result<QwenImageOutput, String> {
398    let (height, width) = (params.height / 16 * 16, params.width / 16 * 16);
399    if images.is_empty() || images.iter().any(|i| i.width() == 0 || i.height() == 0) {
400        return Err("Qwen Image Edit requires at least one reference image".into());
401    }
402    if height == 0
403        || width == 0
404        || height > 2048
405        || width > 2048
406        || params.reference_size < 32
407        || params.reference_size > 2048
408        || !params.true_cfg_scale.is_finite()
409        || params.true_cfg_scale < 0.0
410    {
411        return Err("invalid Qwen Image dimensions, reference size, or CFG scale".into());
412    }
413    for path in [&paths.transformer, &paths.text_encoder, &paths.vae] {
414        if !path.is_file() {
415            return Err(format!("missing Qwen Image component: {}", path.display()));
416        }
417    }
418    let scheduler = match &paths.scheduler {
419        Some(path) => serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?)
420            .map_err(|e| format!("scheduler configuration: {e}"))?,
421        None => embedded_scheduler(&paths.transformer)?.unwrap_or_default(),
422    };
423    let (lh, lw) = (height / 8, width / 8);
424    let sigmas = flow_match_sigmas(params.steps, lh * lw / 4, &scheduler)?;
425    let scale = vae_scale(&paths.vae)?;
426    progress("text encoder", 0, params.steps);
427    let (positive, negative) = {
428        let mut stage = crate::gpu::image_stage_scope();
429        let encoder = QwenImageEncoder::open(&paths.text_encoder)?;
430        stage.track_model(encoder.model_uid());
431        let positive = encoder.encode(prompt, images)?;
432        validate_conditioning(&positive)?;
433        let negative = if params.true_cfg_scale > 1.0 {
434            params
435                .negative_prompt
436                .as_deref()
437                .map(|p| encoder.encode(p, images))
438                .transpose()?
439        } else {
440            None
441        };
442        if let Some(value) = &negative {
443            validate_conditioning(value)?;
444        }
445        (positive, negative)
446    };
447    progress("reference VAE", 0, params.steps);
448    let mut shapes = vec![[1, lh / 2, lw / 2]];
449    let reference = {
450        let _stage = crate::gpu::image_stage_scope();
451        let vae = QwenImageVae::open(&paths.vae)?;
452        let mut packed = Vec::new();
453        for image in images {
454            let ratio = image.width() as f64 / image.height() as f64;
455            let w = (params.reference_size as f64 * ratio.sqrt() / 32.0)
456                .round_ties_even()
457                .max(1.0) as usize
458                * 32;
459            let h = (params.reference_size as f64 / ratio.sqrt() / 32.0)
460                .round_ties_even()
461                .max(1.0) as usize
462                * 32;
463            if w > 32768 || h > 32768 || packed.len() / 64 + w / 16 * (h / 16) + lh * lw / 4 > 16384
464            {
465                return Err("reference images exceed the Qwen Image token budget".into());
466            }
467            let resized = image::imageops::resize(image, w as u32, h as u32, FilterType::Lanczos3);
468            let mut pixels = vec![0.0; w * h * 3];
469            for (i, pixel) in resized.pixels().enumerate() {
470                for channel in 0..3 {
471                    pixels[channel * w * h + i] = pixel[channel] as f32 / 127.5 - 1.0;
472                }
473            }
474            let mut latent = vae.encode_mean(&pixels, h, w)?;
475            if latent.len() != latent_len(16, h / 8, w / 8)? {
476                return Err("VAE encoder returned the wrong latent shape".into());
477            }
478            normalize_latents(&mut latent, &scale, false)?;
479            packed.extend(pack_latents(&latent, 16, h / 8, w / 8)?);
480            shapes.push([1, h / 16, w / 16]);
481        }
482        packed
483    };
484    let initial_path = params
485        .initial_latents
486        .clone()
487        .or_else(|| std::env::var_os("CMF_INIT_LATENT").map(PathBuf::from));
488    let noise = gaussian_noise(
489        latent_len(16, lh, lw)?,
490        params.seed,
491        initial_path.as_deref(),
492    )?;
493    let mut latents = pack_latents(&noise, 16, lh, lw)?;
494    drop(noise);
495    progress("denoiser", 0, params.steps);
496    {
497        let mut stage = crate::gpu::image_stage_scope();
498        let transformer = QwenImageTransformer::open(&paths.transformer)?;
499        stage.track_model(transformer.model_uid());
500        let mut input = Vec::with_capacity(latents.len() + reference.len());
501        for step in 0..params.steps {
502            input.clear();
503            input.extend_from_slice(&latents);
504            input.extend_from_slice(&reference);
505            let mut prediction = transformer.forward(
506                &input,
507                &positive.hidden,
508                &shapes,
509                positive.seq_len,
510                sigmas[step],
511            )?;
512            if prediction.len() != input.len() {
513                return Err("transformer output shape does not match packed image tokens".into());
514            }
515            prediction.truncate(latents.len());
516            if let Some(negative) = &negative {
517                let unconditional = transformer.forward(
518                    &input,
519                    &negative.hidden,
520                    &shapes,
521                    negative.seq_len,
522                    sigmas[step],
523                )?;
524                if unconditional.len() != input.len() {
525                    return Err("negative transformer output has the wrong shape".into());
526                }
527                rescale_cfg(
528                    &mut prediction,
529                    &unconditional[..latents.len()],
530                    params.true_cfg_scale,
531                )?;
532            }
533            let dt = sigmas[step + 1] - sigmas[step];
534            for (x, dx) in latents.iter_mut().zip(prediction) {
535                *x += dt * dx;
536            }
537            if latents.iter().any(|v| !v.is_finite()) {
538                return Err(format!("nonfinite latent at step {}", step + 1));
539            }
540            progress("denoiser", step + 1, params.steps);
541        }
542    }
543    drop(reference);
544    drop(positive);
545    drop(negative);
546    progress("decode VAE", params.steps, params.steps);
547    let mut raw = unpack_latents(&latents, 16, lh, lw)?;
548    drop(latents);
549    normalize_latents(&mut raw, &scale, true)?;
550    let mut pixels = {
551        let _stage = crate::gpu::image_stage_scope();
552        QwenImageVae::open(&paths.vae)?.decode(&raw, lh, lw)?
553    };
554    if pixels.len() != latent_len(3, height, width)? || pixels.iter().any(|v| !v.is_finite()) {
555        return Err("VAE decoder returned invalid RGB pixels".into());
556    }
557    for value in &mut pixels {
558        *value = (*value * 0.5 + 0.5).clamp(0.0, 1.0);
559    }
560    Ok(QwenImageOutput {
561        pixels,
562        height,
563        width,
564    })
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    fn fixture() -> serde_json::Value {
571        serde_json::from_str(include_str!("../tests/fixtures/qwen_image_pipeline.json")).unwrap()
572    }
573    fn values(v: &serde_json::Value) -> Vec<f32> {
574        serde_json::from_value(v.clone()).unwrap()
575    }
576    fn close(got: &[f32], expected: &[f32], tolerance: f32) {
577        assert_eq!(got.len(), expected.len());
578        for (i, (a, b)) in got.iter().zip(expected).enumerate() {
579            assert!(
580                a.is_finite() && (a - b).abs() <= tolerance,
581                "element {i}: {a} != {b}"
582            );
583        }
584    }
585    #[test]
586    fn official_diffusers_flowmatch_schedules() {
587        let reference = fixture();
588        for case in reference["schedules"].as_array().unwrap() {
589            let result = flow_match_sigmas(
590                case["steps"].as_u64().unwrap() as usize,
591                case["tokens"].as_u64().unwrap() as usize,
592                &FlowMatchConfig::default(),
593            )
594            .unwrap();
595            close(&result, &values(&case["sigmas"]), 3e-7);
596        }
597    }
598    #[test]
599    fn official_diffusers_non_square_latent_layout() {
600        let reference = fixture();
601        close(
602            &pack_latents(&values(&reference["nchw"]), 2, 4, 6).unwrap(),
603            &values(&reference["packed"]),
604            0.0,
605        );
606        close(
607            &unpack_latents(&values(&reference["packed"]), 2, 4, 6).unwrap(),
608            &values(&reference["unpacked"]),
609            0.0,
610        );
611        assert!(pack_latents(&[0.0; 6], 1, 3, 2).is_err());
612    }
613    #[test]
614    fn official_diffusers_cfg_rescales_each_image_token() {
615        let reference = fixture();
616        let mut prediction = values(&reference["cond"]);
617        rescale_cfg(&mut prediction, &values(&reference["uncond"]), 4.0).unwrap();
618        close(&prediction, &values(&reference["cfg"]), 5e-7);
619    }
620}