Skip to main content

cortiq_engine/
primal_vae.rs

1//! Primal VAE (`ostris/primal_vae`): a deterministic convolutional
2//! autoencoder, RGB in [-1, 1] ↔ a 1- or 2-channel latent at 2× spatial
3//! compression, already unit normal (no scaling or shift factor).
4//!
5//! Both released models are the same network:
6//!
7//! * `pixel_unshuffle(2)` of the image (12 channels at half size) into the
8//!   encoder; the decoder's 12 channels `pixel_shuffle(2)` back to RGB;
9//! * encoder and decoder are mirrored U-Nets ([`UStack`]): `conv_in`, per
10//!   level residual blocks then `pixel_unshuffle(2)` + a 3×3 conv down, the
11//!   bottom blocks, per level up a 3×3 conv + `pixel_shuffle(2)` + the
12//!   skip, residual blocks, then RMSNorm → SiLU → `conv_out`;
13//! * a residual block is `x + conv2(silu(norm2(conv1(silu(norm1(x))))))`,
14//!   `conv1` dilated by 1, 2, 4 cyclically along the level;
15//! * every norm is an RMSNorm over the channels of one pixel (ε = 1e-6,
16//!   a weight, no bias) — no spatial statistics, no attention.
17//!
18//! The architecture is read from the tensor shapes, as the reference
19//! `PrimalVAE.config_from_shapes` does. The forward runs as a step list
20//! ([`PvStep`]) that the device backends execute whole (`gpu::primal_vae_run`:
21//! one upload, one command buffer, one readback); the host executes the
22//! same list when no device takes it.
23//!
24//! Parity: `scripts/four/primal_vae_ref.py` dumps the diffusers model's
25//! latents and reconstructions in float32.
26
27use crate::pool::Pool;
28use cortiq_core::CmfModel;
29use std::sync::Arc;
30
31/// Dilation of block `j`'s first conv within a level.
32const DILATION_CYCLE: [u32; 3] = [1, 2, 4];
33const EPS: f32 = 1e-6;
34
35/// One 3×3 convolution: weight `[cout][cin·9]` (the source `[cout, cin, 3,
36/// 3]` flattened), bias `[cout]`.
37pub struct PvConv {
38    pub w: Vec<f32>,
39    pub b: Vec<f32>,
40    pub cin: usize,
41    pub cout: usize,
42}
43
44/// All weights of one model, in the order the step lists index them.
45#[derive(Default)]
46pub struct PvWeights {
47    pub convs: Vec<PvConv>,
48    pub gammas: Vec<Vec<f32>>,
49}
50
51/// One device step. Buffers are numbered; `h`×`w` is the spatial size of
52/// the step's SOURCE, NCHW f32 throughout.
53#[derive(Clone, Copy, Debug)]
54pub enum PvStep {
55    /// `dst = conv(src) + b (+ res)`, 3×3, dilation = padding = `dil`.
56    Conv { wi: usize, src: u8, dst: u8, res: Option<u8>, dil: u32, h: usize, w: usize },
57    /// `dst = silu(rmsnorm_c(src) · γ)`.
58    NormSilu { gi: usize, src: u8, dst: u8, c: usize, h: usize, w: usize },
59    /// `dst = pixel_unshuffle(src, 2)`: `[c, h, w] → [4c, h/2, w/2]`.
60    Unshuffle { src: u8, dst: u8, c: usize, h: usize, w: usize },
61    /// `dst = pixel_shuffle(src, 2) (+ add)`: `[c, h, w] → [c/4, 2h, 2w]`.
62    Shuffle { src: u8, dst: u8, add: Option<u8>, c: usize, h: usize, w: usize },
63}
64
65/// A compiled forward: steps, buffer sizes (elements), input / output ids.
66pub struct PvPlan {
67    pub steps: Vec<PvStep>,
68    pub buf_elems: Vec<usize>,
69    pub input: u8,
70    pub output: u8,
71    pub out_elems: usize,
72}
73
74struct ResBlock {
75    norm1: usize,
76    conv1: usize,
77    dil: u32,
78    norm2: usize,
79    conv2: usize,
80}
81
82/// One U-Net (encoder or decoder), as indices into [`PvWeights`].
83struct UStack {
84    widths: Vec<usize>,
85    conv_in: usize,
86    down_blocks: Vec<Vec<ResBlock>>,
87    downs: Vec<usize>,
88    ups: Vec<usize>,
89    up_blocks: Vec<Vec<ResBlock>>,
90    norm_out: usize,
91    conv_out: usize,
92}
93
94/// The model.
95pub struct PrimalVae {
96    pub compression: usize,
97    pub latent_channels: usize,
98    weights: PvWeights,
99    encoder: UStack,
100    decoder: UStack,
101    /// Process-unique key for the device weight cache.
102    key: u64,
103    pool: Option<Arc<Pool>>,
104}
105
106fn load_f32(model: &Arc<CmfModel>, name: &str) -> Result<Vec<f32>, String> {
107    crate::dit::cmf_f32(model, name)
108}
109
110fn shape(model: &CmfModel, name: &str) -> Option<Vec<usize>> {
111    model.tensor(name).map(|t| t.shape.clone())
112}
113
114fn push_conv(model: &Arc<CmfModel>, name: &str, cin_cout: Option<usize>, wts: &mut PvWeights) -> Result<usize, String> {
115    let s = shape(model, &format!("{name}.weight")).ok_or_else(|| format!("primal_vae: missing {name}.weight"))?;
116    if s.len() != 4 || s[2] != 3 || s[3] != 3 {
117        return Err(format!("primal_vae: {name} is not a 3×3 conv ({s:?})"));
118    }
119    if cin_cout.is_some_and(|c| s[0] != c || s[1] != c) {
120        return Err(format!("primal_vae: {name} is not a square conv of the level width"));
121    }
122    let w = load_f32(model, &format!("{name}.weight"))?;
123    let b = load_f32(model, &format!("{name}.bias"))?;
124    if b.len() != s[0] || w.len() != s[0] * s[1] * 9 {
125        return Err(format!("primal_vae: {name} shapes disagree"));
126    }
127    wts.convs.push(PvConv { w, b, cin: s[1], cout: s[0] });
128    Ok(wts.convs.len() - 1)
129}
130
131fn push_gamma(model: &Arc<CmfModel>, name: &str, c: usize, wts: &mut PvWeights) -> Result<usize, String> {
132    let g = load_f32(model, &format!("{name}.weight"))?;
133    if g.len() != c {
134        return Err(format!("primal_vae: {name} has {} channels, expected {c}", g.len()));
135    }
136    wts.gammas.push(g);
137    Ok(wts.gammas.len() - 1)
138}
139
140fn load_blocks(model: &Arc<CmfModel>, pre: &str, c: usize, wts: &mut PvWeights) -> Result<Vec<ResBlock>, String> {
141    let mut out = Vec::new();
142    for j in 0.. {
143        let p = format!("{pre}.{j}");
144        if model.tensor(&format!("{p}.conv1.weight")).is_none() {
145            break;
146        }
147        out.push(ResBlock {
148            norm1: push_gamma(model, &format!("{p}.norm1"), c, wts)?,
149            conv1: push_conv(model, &format!("{p}.conv1"), Some(c), wts)?,
150            dil: DILATION_CYCLE[j % DILATION_CYCLE.len()],
151            norm2: push_gamma(model, &format!("{p}.norm2"), c, wts)?,
152            conv2: push_conv(model, &format!("{p}.conv2"), Some(c), wts)?,
153        });
154    }
155    Ok(out)
156}
157
158impl UStack {
159    fn load(model: &Arc<CmfModel>, pre: &str, wts: &mut PvWeights) -> Result<Self, String> {
160        let conv_in = push_conv(model, &format!("{pre}.conv_in"), None, wts)?;
161        let mut widths = vec![wts.convs[conv_in].cout];
162        let mut downs = Vec::new();
163        while model.tensor(&format!("{pre}.downs.{}.weight", widths.len() - 1)).is_some() {
164            let i = widths.len() - 1;
165            let d = push_conv(model, &format!("{pre}.downs.{i}"), None, wts)?;
166            if wts.convs[d].cin != 4 * widths[i] {
167                return Err(format!("primal_vae: {pre}.downs.{i} does not take the unshuffled level"));
168            }
169            widths.push(wts.convs[d].cout);
170            downs.push(d);
171        }
172        let levels = widths.len();
173        let mut ups = Vec::new();
174        for i in 0..levels - 1 {
175            let u = push_conv(model, &format!("{pre}.ups.{i}"), None, wts)?;
176            if wts.convs[u].cin != widths[i + 1] || wts.convs[u].cout != 4 * widths[i] {
177                return Err(format!("primal_vae: {pre}.ups.{i} does not feed the shuffled level"));
178            }
179            ups.push(u);
180        }
181        let mut down_blocks = Vec::new();
182        for (i, &c) in widths.iter().enumerate() {
183            down_blocks.push(load_blocks(model, &format!("{pre}.down_blocks.{i}"), c, wts)?);
184        }
185        let mut up_blocks = Vec::new();
186        for (i, &c) in widths.iter().take(levels - 1).enumerate() {
187            up_blocks.push(load_blocks(model, &format!("{pre}.up_blocks.{i}"), c, wts)?);
188        }
189        let norm_out = push_gamma(model, &format!("{pre}.norm_out"), widths[0], wts)?;
190        let conv_out = push_conv(model, &format!("{pre}.conv_out"), None, wts)?;
191        Ok(UStack { widths, conv_in, down_blocks, downs, ups, up_blocks, norm_out, conv_out })
192    }
193
194    /// Append this U-Net's steps: `src` (`[cin, h, w]`) → `dst`. Buffers:
195    /// 0 holds the bottom level's stream, 3+i level i's (which is also the
196    /// level's skip: the up path adds into it in place), 1 and 2 are
197    /// temporaries.
198    fn plan(&self, wts: &PvWeights, steps: &mut Vec<PvStep>, sizes: &mut Vec<usize>, src: u8, dst: u8, h: usize, w: usize) {
199        let need = |sizes: &mut Vec<usize>, b: u8, n: usize| {
200            if sizes.len() <= b as usize {
201                sizes.resize(b as usize + 1, 0);
202            }
203            sizes[b as usize] = sizes[b as usize].max(n);
204        };
205        let levels = self.widths.len();
206        let xb = |i: usize| if i == levels - 1 { 0u8 } else { 3 + i as u8 };
207        let (t, hb) = (1u8, 2u8);
208        let block = |steps: &mut Vec<PvStep>, sizes: &mut Vec<usize>, x: u8, b: &ResBlock, c: usize, h: usize, w: usize| {
209            need(sizes, t, c * h * w);
210            need(sizes, hb, c * h * w);
211            steps.push(PvStep::NormSilu { gi: b.norm1, src: x, dst: t, c, h, w });
212            steps.push(PvStep::Conv { wi: b.conv1, src: t, dst: hb, res: None, dil: b.dil, h, w });
213            steps.push(PvStep::NormSilu { gi: b.norm2, src: hb, dst: t, c, h, w });
214            steps.push(PvStep::Conv { wi: b.conv2, src: t, dst: x, res: Some(x), dil: 1, h, w });
215        };
216        let (mut h, mut w) = (h, w);
217        steps.push(PvStep::Conv { wi: self.conv_in, src, dst: xb(0), res: None, dil: 1, h, w });
218        need(sizes, xb(0), self.widths[0] * h * w);
219        for i in 0..levels {
220            for b in &self.down_blocks[i] {
221                block(steps, sizes, xb(i), b, self.widths[i], h, w);
222            }
223            if i < levels - 1 {
224                let c = self.widths[i];
225                steps.push(PvStep::Unshuffle { src: xb(i), dst: t, c, h, w });
226                need(sizes, t, c * h * w);
227                steps.push(PvStep::Conv { wi: self.downs[i], src: t, dst: xb(i + 1), res: None, dil: 1, h: h / 2, w: w / 2 });
228                h /= 2;
229                w /= 2;
230                need(sizes, xb(i + 1), self.widths[i + 1] * h * w);
231            }
232        }
233        for i in (0..levels - 1).rev() {
234            let up = &wts.convs[self.ups[i]];
235            steps.push(PvStep::Conv { wi: self.ups[i], src: xb(i + 1), dst: t, res: None, dil: 1, h, w });
236            need(sizes, t, up.cout * h * w);
237            steps.push(PvStep::Shuffle { src: t, dst: xb(i), add: Some(xb(i)), c: up.cout, h, w });
238            h *= 2;
239            w *= 2;
240            for b in &self.up_blocks[i] {
241                block(steps, sizes, xb(i), b, self.widths[i], h, w);
242            }
243        }
244        steps.push(PvStep::NormSilu { gi: self.norm_out, src: xb(0), dst: t, c: self.widths[0], h, w });
245        steps.push(PvStep::Conv { wi: self.conv_out, src: t, dst, res: None, dil: 1, h, w });
246        need(sizes, dst, wts.convs[self.conv_out].cout * h * w);
247    }
248}
249
250static NEXT_KEY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
251
252/// Whether `model` is a Primal VAE file.
253pub fn is_primal_vae(model: &CmfModel) -> bool {
254    model.header.arch.arch_name == "primal_vae"
255}
256
257impl PrimalVae {
258    pub fn from_model(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
259        if !is_primal_vae(model) {
260            return Err("not a Primal VAE file (arch_name primal_vae)".into());
261        }
262        let mut weights = PvWeights::default();
263        let encoder = UStack::load(model, "encoder", &mut weights)?;
264        let decoder = UStack::load(model, "decoder", &mut weights)?;
265        let cin = weights.convs[encoder.conv_in].cin;
266        let compression = ((cin / 3) as f64).sqrt().round() as usize;
267        if compression * compression * 3 != cin || compression == 0 {
268            return Err(format!("primal_vae: conv_in takes {cin} channels, not 3·c²"));
269        }
270        let latent_channels = weights.convs[encoder.conv_out].cout;
271        if weights.convs[decoder.conv_in].cin != latent_channels || weights.convs[decoder.conv_out].cout != cin {
272            return Err("primal_vae: encoder and decoder disagree on the latent or pixel channels".into());
273        }
274        Ok(PrimalVae {
275            compression,
276            latent_channels,
277            weights,
278            encoder,
279            decoder,
280            key: NEXT_KEY.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
281            pool,
282        })
283    }
284
285    /// Image sides must be multiples of this (16 for the released models).
286    pub fn image_multiple(&self) -> usize {
287        self.compression * (1usize << (self.encoder.widths.len() - 1)).max(4)
288    }
289
290    fn plan(&self, encode: bool, h: usize, w: usize) -> PvPlan {
291        let mut steps = Vec::new();
292        let mut sizes = Vec::new();
293        let c2 = self.compression;
294        let (lh, lw) = (h / c2, w / c2);
295        // Buffers: 0..=2 work, 3..3+L-1 skips, then IN and OUT.
296        let levels = self.encoder.widths.len();
297        let (inp, mid, out) = (3 + levels as u8, 4 + levels as u8, 5 + levels as u8);
298        if encode {
299            // the image arrives in IN; unshuffle by the compression into MID
300            steps.push(PvStep::Unshuffle { src: inp, dst: mid, c: 3, h, w });
301            if c2 != 2 {
302                unreachable!("released models compress by 2");
303            }
304            self.encoder.plan(&self.weights, &mut steps, &mut sizes, mid, out, lh, lw);
305            let out_elems = self.latent_channels * lh * lw;
306            sizes.resize(sizes.len().max(out as usize + 1), 0);
307            sizes[inp as usize] = 3 * h * w;
308            sizes[mid as usize] = sizes[mid as usize].max(3 * h * w);
309            sizes[out as usize] = sizes[out as usize].max(out_elems);
310            PvPlan { steps, buf_elems: sizes, input: inp, output: out, out_elems }
311        } else {
312            self.decoder.plan(&self.weights, &mut steps, &mut sizes, inp, mid, lh, lw);
313            steps.push(PvStep::Shuffle { src: mid, dst: out, add: None, c: 3 * c2 * c2, h: lh, w: lw });
314            sizes.resize(sizes.len().max(out as usize + 1), 0);
315            sizes[inp as usize] = self.latent_channels * lh * lw;
316            sizes[out as usize] = 3 * h * w;
317            PvPlan { steps, buf_elems: sizes, input: inp, output: out, out_elems: 3 * h * w }
318        }
319    }
320
321    /// Run a plan on the device, else on the host.
322    fn run(&self, plan: &PvPlan, input: &[f32]) -> Result<Vec<f32>, String> {
323        let mut out = vec![0f32; plan.out_elems];
324        if device_on() && crate::gpu::primal_vae_run(self.key, &self.weights, plan, input, &mut out) {
325            return Ok(out);
326        }
327        let mut bufs: Vec<Vec<f32>> = plan.buf_elems.iter().map(|&n| vec![0f32; n]).collect();
328        bufs[plan.input as usize][..input.len()].copy_from_slice(input);
329        for s in &plan.steps {
330            host_step(&self.weights, s, &mut bufs, self.pool.as_deref());
331        }
332        out.copy_from_slice(&bufs[plan.output as usize][..plan.out_elems]);
333        Ok(out)
334    }
335
336    fn check(&self, h: usize, w: usize) -> Result<(), String> {
337        let m = self.image_multiple();
338        if h == 0 || w == 0 || h % m != 0 || w % m != 0 {
339            return Err(format!("image sides must be multiples of {m}, got {w}x{h}"));
340        }
341        Ok(())
342    }
343
344    /// `[3, h, w]` in [-1, 1] → `[latent_channels, h/c, w/c]`.
345    pub fn encode(&self, img: &[f32], h: usize, w: usize) -> Result<Vec<f32>, String> {
346        self.check(h, w)?;
347        if img.len() != 3 * h * w {
348            return Err("primal_vae: image is not [3, h, w]".into());
349        }
350        self.run(&self.plan(true, h, w), img)
351    }
352
353    /// `[latent_channels, h/c, w/c]` → `[3, h, w]` (nominally [-1, 1], not
354    /// clamped). `h`, `w` are the IMAGE sides.
355    pub fn decode(&self, z: &[f32], h: usize, w: usize) -> Result<Vec<f32>, String> {
356        self.check(h, w)?;
357        let c = self.compression;
358        if z.len() != self.latent_channels * (h / c) * (w / c) {
359            return Err("primal_vae: latent does not match the image size".into());
360        }
361        self.run(&self.plan(false, h, w), z)
362    }
363}
364
365/// `CMF_PRIMAL_VAE_GPU=0` keeps the forward on the host.
366fn device_on() -> bool {
367    std::env::var("CMF_PRIMAL_VAE_GPU").as_deref() != Ok("0") && crate::gpu::enabled_here()
368}
369
370/// Rows of output pixels per host conv band (bounds the im2col panel).
371const BAND_PIXELS: usize = 8192;
372
373fn host_conv(cv: &PvConv, src: &[f32], dst: &mut [f32], res: Option<&[f32]>, dil: usize, h: usize, w: usize, pool: Option<&Pool>) {
374    let (cin, cout) = (cv.cin, cv.cout);
375    let hw = h * w;
376    let k = cin * 9;
377    let mut panel = vec![0f32; BAND_PIXELS.min(hw) * k];
378    let mut yb = vec![0f32; BAND_PIXELS.min(hw) * cout];
379    let mut p0 = 0usize;
380    while p0 < hw {
381        let np = BAND_PIXELS.min(hw - p0);
382        let pp = crate::pool::SendMut::new(panel.as_mut_ptr());
383        let fill = |lo: usize, hi: usize| {
384            for i in lo..hi {
385                let p = p0 + i;
386                let (y, x) = ((p / w) as isize, (p % w) as isize);
387                // SAFETY: disjoint panel rows per worker.
388                let row = unsafe { std::slice::from_raw_parts_mut(pp.at(i * k), k) };
389                for ci in 0..cin {
390                    let plane = &src[ci * hw..(ci + 1) * hw];
391                    for tap in 0..9 {
392                        let sy = y + (tap / 3) as isize * dil as isize - dil as isize;
393                        let sx = x + (tap % 3) as isize * dil as isize - dil as isize;
394                        row[ci * 9 + tap] = if sy < 0 || sx < 0 || sy >= h as isize || sx >= w as isize {
395                            0.0
396                        } else {
397                            plane[sy as usize * w + sx as usize]
398                        };
399                    }
400                }
401            }
402        };
403        match pool {
404            Some(p) => p.run_rows(np, &fill),
405            None => fill(0, np),
406        }
407        crate::fcd_ops::gemm_nt_host(&panel[..np * k], &cv.w, &mut yb[..np * cout], np, k, cout, pool);
408        for i in 0..np {
409            let p = p0 + i;
410            for o in 0..cout {
411                let r = res.map_or(0.0, |r| r[o * hw + p]);
412                dst[o * hw + p] = yb[i * cout + o] + cv.b[o] + r;
413            }
414        }
415        p0 += np;
416    }
417}
418
419fn host_step(wts: &PvWeights, s: &PvStep, bufs: &mut [Vec<f32>], pool: Option<&Pool>) {
420    // Steps never alias src and dst except a conv's residual (read before
421    // the write of the same element).
422    match *s {
423        PvStep::Conv { wi, src, dst, res, dil, h, w } => {
424            let cv = &wts.convs[wi];
425            let srcv = std::mem::take(&mut bufs[src as usize]);
426            let resv = res.map(|r| if r == src { srcv.clone() } else { bufs[r as usize].clone() });
427            let mut d = std::mem::take(&mut bufs[dst as usize]);
428            if src == dst {
429                d = vec![0f32; srcv.len().max(cv.cout * h * w)];
430            }
431            host_conv(cv, &srcv, &mut d, resv.as_deref(), dil as usize, h, w, pool);
432            if src != dst {
433                bufs[src as usize] = srcv;
434            }
435            bufs[dst as usize] = d;
436        }
437        PvStep::NormSilu { gi, src, dst, c, h, w } => {
438            let g = &wts.gammas[gi];
439            let hw = h * w;
440            let srcv = std::mem::take(&mut bufs[src as usize]);
441            let d = &mut bufs[dst as usize];
442            for p in 0..hw {
443                let mut ss = 0f32;
444                for ch in 0..c {
445                    let v = srcv[ch * hw + p];
446                    ss += v * v;
447                }
448                let inv = 1.0 / (ss / c as f32 + EPS).sqrt();
449                for ch in 0..c {
450                    let v = srcv[ch * hw + p] * inv * g[ch];
451                    d[ch * hw + p] = v / (1.0 + (-v).exp());
452                }
453            }
454            bufs[src as usize] = srcv;
455        }
456        PvStep::Unshuffle { src, dst, c, h, w } => {
457            let srcv = std::mem::take(&mut bufs[src as usize]);
458            let d = &mut bufs[dst as usize];
459            let (oh, ow) = (h / 2, w / 2);
460            for ch in 0..c {
461                for i in 0..2 {
462                    for j in 0..2 {
463                        let oc = ch * 4 + i * 2 + j;
464                        for y in 0..oh {
465                            for x in 0..ow {
466                                d[(oc * oh + y) * ow + x] = srcv[(ch * h + 2 * y + i) * w + 2 * x + j];
467                            }
468                        }
469                    }
470                }
471            }
472            bufs[src as usize] = srcv;
473        }
474        PvStep::Shuffle { src, dst, add, c, h, w } => {
475            let srcv = std::mem::take(&mut bufs[src as usize]);
476            let addv = add.map(|a| bufs[a as usize].clone());
477            let d = &mut bufs[dst as usize];
478            let (oh, ow, oc) = (2 * h, 2 * w, c / 4);
479            for ch in 0..oc {
480                for i in 0..2 {
481                    for j in 0..2 {
482                        let ic = ch * 4 + i * 2 + j;
483                        for y in 0..h {
484                            for x in 0..w {
485                                let o = (ch * oh + 2 * y + i) * ow + 2 * x + j;
486                                d[o] = srcv[(ic * h + y) * w + x] + addv.as_ref().map_or(0.0, |a| a[o]);
487                            }
488                        }
489                    }
490                }
491            }
492            bufs[src as usize] = srcv;
493        }
494    }
495}
496
497/// RGB8 `[h, w, 3]` → `[3, h, w]` in [-1, 1].
498pub fn rgb8_to_chw(data: &[u8], h: usize, w: usize) -> Vec<f32> {
499    let mut out = vec![0f32; 3 * h * w];
500    for (i, px) in data.chunks_exact(3).enumerate() {
501        for c in 0..3 {
502            out[c * h * w + i] = px[c] as f32 / 127.5 - 1.0;
503        }
504    }
505    out
506}
507
508/// `[3, h, w]` in [-1, 1] → RGB8 `[h, w, 3]` (clamped, rounded).
509pub fn chw_to_rgb8(x: &[f32], h: usize, w: usize) -> Vec<u8> {
510    let mut out = vec![0u8; 3 * h * w];
511    for i in 0..h * w {
512        for c in 0..3 {
513            let v = ((x[c * h * w + i] + 1.0) * 127.5).round().clamp(0.0, 255.0);
514            out[i * 3 + c] = v as u8;
515        }
516    }
517    out
518}
519
520/// PSNR in dB between two `[3, h, w]` images in [-1, 1] (on the 0..255
521/// scale after clamping, as an 8-bit comparison).
522pub fn psnr(a: &[f32], b: &[f32]) -> f64 {
523    let mut se = 0f64;
524    for (&x, &y) in a.iter().zip(b) {
525        let (x, y) = (((x + 1.0) * 127.5).clamp(0.0, 255.0) as f64, ((y + 1.0) * 127.5).clamp(0.0, 255.0) as f64);
526        se += (x - y) * (x - y);
527    }
528    let mse = se / a.len() as f64;
529    if mse == 0.0 {
530        f64::INFINITY
531    } else {
532        10.0 * (255.0f64 * 255.0 / mse).log10()
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn bufs_of(sizes: &[usize]) -> Vec<Vec<f32>> {
541        sizes.iter().map(|&n| vec![0f32; n]).collect()
542    }
543
544    /// `pixel_unshuffle(2)` then `pixel_shuffle(2)` is the identity, and the
545    /// channel order is `c·4 + i·2 + j` as in PyTorch.
546    #[test]
547    fn shuffles_invert_each_other_in_torch_order() {
548        let (c, h, w) = (2usize, 4usize, 6usize);
549        let x: Vec<f32> = (0..c * h * w).map(|v| v as f32).collect();
550        let mut bufs = bufs_of(&[c * h * w, c * h * w, c * h * w]);
551        bufs[0].copy_from_slice(&x);
552        let wts = PvWeights::default();
553        host_step(&wts, &PvStep::Unshuffle { src: 0, dst: 1, c, h, w }, &mut bufs, None);
554        // channel 1 of the output = (c 0, i 0, j 1): x[0][2y][2x+1]
555        assert_eq!(bufs[1][(1 * 2) * 3], x[1]);
556        host_step(&wts, &PvStep::Shuffle { src: 1, dst: 2, add: None, c: 4 * c, h: h / 2, w: w / 2 }, &mut bufs, None);
557        assert_eq!(bufs[2], x);
558    }
559
560    /// A dilated 3×3 conv with a centre-only kernel copies the input, and
561    /// an off-centre tap reads `dil` pixels away with zero padding.
562    #[test]
563    fn dilated_conv_taps_and_padding() {
564        let (h, w) = (5usize, 5usize);
565        let x: Vec<f32> = (0..h * w).map(|v| v as f32 + 1.0).collect();
566        let mut wt = vec![0f32; 9];
567        wt[5] = 1.0; // tap (ky 1, kx 2): x + dil
568        let cv = PvConv { w: wt, b: vec![0.5], cin: 1, cout: 1 };
569        let mut out = vec![0f32; h * w];
570        host_conv(&cv, &x, &mut out, None, 2, h, w, None);
571        assert_eq!(out[0], x[2] + 0.5);
572        assert_eq!(out[3], 0.5); // x + 2 is outside: zero padding
573        let res = vec![1f32; h * w];
574        host_conv(&cv, &x, &mut out, Some(&res), 2, h, w, None);
575        assert_eq!(out[0], x[2] + 1.5);
576    }
577
578    /// Channel RMSNorm × γ then SiLU, per pixel.
579    #[test]
580    fn norm_silu_is_per_pixel() {
581        let (c, h, w) = (2usize, 1usize, 2usize);
582        let mut bufs = bufs_of(&[c * h * w, c * h * w]);
583        bufs[0].copy_from_slice(&[3.0, 1.0, 4.0, 1.0]); // pixel 0 = (3, 4), pixel 1 = (1, 1)
584        let wts = PvWeights { convs: vec![], gammas: vec![vec![1.0, 2.0]] };
585        host_step(&wts, &PvStep::NormSilu { gi: 0, src: 0, dst: 1, c, h, w }, &mut bufs, None);
586        let silu = |v: f32| v / (1.0 + (-v).exp());
587        let inv0 = 1.0 / ((9.0f32 + 16.0) / 2.0 + EPS).sqrt();
588        assert!((bufs[1][0] - silu(3.0 * inv0)).abs() < 1e-6);
589        assert!((bufs[1][2] - silu(4.0 * inv0 * 2.0)).abs() < 1e-6);
590    }
591}