hqx 0.1.1

high-quality pixel-art image magnification (2x/3x/4x upscaling)
Documentation
//! High-quality pixel-art magnification by 2x, 3x, and 4x.
//!
//! `hqx` enlarges a raster of 32-bit pixels while keeping sharp edges sharp and
//! smoothing diagonals. It reads a 3x3 neighborhood around each source pixel,
//! classifies the eight neighbors as close or distant in an approximate YUV
//! space, and fills the output block from a 256-entry rule table. The result
//! suits sprites, tiles, and other pixel art far better than bilinear scaling.
//!
//! # Pixel format
//!
//! Each pixel is a `u32` read as `0xAARRGGBB`: alpha in bits 24..31, red in
//! 16..23, green in 8..15, blue in 0..7. Color comparison ignores alpha.
//! Interpolation carries alpha through. Byte order in memory is the caller's
//! concern; the functions operate on `u32` values directly.
//!
//! # Buffers
//!
//! The source holds `width * height` pixels in row-major order. The caller
//! allocates the destination. A scale of `n` writes `(width * n) * (height * n)`
//! pixels. The plain functions assume tightly packed rows. The `_rb` variants
//! take explicit row strides in pixels for padded buffers.
//!
//! # Example
//!
//! ```
//! let src = [
//!     0x00FF_0000, 0x0000_FF00,
//!     0x0000_00FF, 0x00FF_FFFF,
//! ];
//! let mut dst = vec![0u32; 2 * 2 * src.len()];
//! hqx::hq2x(&src, &mut dst, 2, 2);
//! assert_eq!(dst.len(), 16);
//! ```
//!
//! # Errors and bounds
//!
//! A zero width or height writes nothing. A destination shorter than the
//! required size panics up front, before any write, so the buffer is never left
//! partly filled. The panic message names the pixel count the buffer needs. A
//! source shorter than the pixels the scale reads panics the same way before
//! any write.

#![forbid(unsafe_code)]
#![warn(missing_docs)]

mod interp;
mod tables;
mod yuv;

/// Scale a raster by 2x into a tightly packed destination.
///
/// `src` holds `width * height` pixels. `dest` must hold `4 * width * height`
/// pixels. Rows are packed with no padding.
///
/// # Panics
///
/// Panics if `dest` is shorter than `4 * width * height`.
pub fn hq2x(src: &[u32], dest: &mut [u32], width: u32, height: u32) {
    let w = width as usize;
    hq2x_rb(src, w, dest, w * 2, width, height);
}

/// Scale a raster by 3x into a tightly packed destination.
///
/// `src` holds `width * height` pixels. `dest` must hold `9 * width * height`
/// pixels. Rows are packed with no padding.
///
/// # Panics
///
/// Panics if `dest` is shorter than `9 * width * height`.
pub fn hq3x(src: &[u32], dest: &mut [u32], width: u32, height: u32) {
    let w = width as usize;
    hq3x_rb(src, w, dest, w * 3, width, height);
}

/// Scale a raster by 4x into a tightly packed destination.
///
/// `src` holds `width * height` pixels. `dest` must hold `16 * width * height`
/// pixels. Rows are packed with no padding.
///
/// # Panics
///
/// Panics if `dest` is shorter than `16 * width * height`.
pub fn hq4x(src: &[u32], dest: &mut [u32], width: u32, height: u32) {
    let w = width as usize;
    hq4x_rb(src, w, dest, w * 4, width, height);
}

/// Scale a raster by 2x with explicit row strides in pixels.
///
/// `src_row_pixels` and `dest_row_pixels` give the pixel stride of each source
/// and destination row. A stride wider than the active region leaves padding
/// pixels at the end of each row untouched.
///
/// # Panics
///
/// Panics if `dest` cannot hold every written cell. With tight destination rows
/// (`dest_row_pixels == width * 2`) the bound is `dest.len() >= 4 * width *
/// height`. A wider stride raises the bound by the extra padding on each row
/// above the last.
pub fn hq2x_rb(
    src: &[u32],
    src_row_pixels: usize,
    dest: &mut [u32],
    dest_row_pixels: usize,
    width: u32,
    height: u32,
) {
    scale::<2>(src, src_row_pixels, dest, dest_row_pixels, width, height);
}

/// Scale a raster by 3x with explicit row strides in pixels.
///
/// See [`hq2x_rb`] for the stride contract.
///
/// # Panics
///
/// Panics if `dest` cannot hold every written cell. With tight rows the bound is
/// `dest.len() >= 9 * width * height`.
pub fn hq3x_rb(
    src: &[u32],
    src_row_pixels: usize,
    dest: &mut [u32],
    dest_row_pixels: usize,
    width: u32,
    height: u32,
) {
    scale::<3>(src, src_row_pixels, dest, dest_row_pixels, width, height);
}

/// Scale a raster by 4x with explicit row strides in pixels.
///
/// See [`hq2x_rb`] for the stride contract.
///
/// # Panics
///
/// Panics if `dest` cannot hold every written cell. With tight rows the bound is
/// `dest.len() >= 16 * width * height`.
pub fn hq4x_rb(
    src: &[u32],
    src_row_pixels: usize,
    dest: &mut [u32],
    dest_row_pixels: usize,
    width: u32,
    height: u32,
) {
    scale::<4>(src, src_row_pixels, dest, dest_row_pixels, width, height);
}

/// Core driver shared by every scale.
///
/// `SCALE` is 2, 3, or 4. The function walks each source pixel, gathers the
/// 3x3 neighborhood with edge replication, builds the eight-bit pattern, and
/// dispatches to the per-scale fill table. Source and destination rows advance
/// by their pixel strides, so padded rows work.
fn scale<const SCALE: usize>(
    src: &[u32],
    src_row_pixels: usize,
    dest: &mut [u32],
    dest_row_pixels: usize,
    width: u32,
    height: u32,
) {
    let width = width as usize;
    let height = height as usize;
    if width == 0 || height == 0 {
        return;
    }
    let spl = src_row_pixels;
    let dpl = dest_row_pixels;

    // The last pixel read is at source (width-1, height-1), index
    // (height-1)*spl + (width-1). Require the source to reach it. This runs
    // before any write, so a short source fails up front instead of indexing
    // out of bounds part way through a partly written destination.
    let src_required = (height - 1)
        .checked_mul(spl)
        .and_then(|row| row.checked_add(width))
        .expect("src too large: required pixel count does not fit in usize");
    assert!(
        src.len() >= src_required,
        "src too small: need {src_required} pixels, got {}",
        src.len()
    );

    // The last written destination index is the bottom-right cell of the block
    // at source (width-1, height-1). Require the buffer to reach it. This runs
    // before any write, so a short buffer fails without partial mutation.
    let required = (|| {
        let row_base = (height - 1).checked_mul(dpl)?.checked_mul(SCALE)?;
        let col_base = (width - 1).checked_mul(SCALE)?;
        let block_row = (SCALE - 1).checked_mul(dpl)?;
        row_base
            .checked_add(col_base)?
            .checked_add(block_row)?
            .checked_add(SCALE)
    })()
    .expect("dest too large: required pixel count does not fit in usize");
    assert!(
        dest.len() >= required,
        "dest too small: need {required} pixels, got {}",
        dest.len()
    );

    for j in 0..height {
        let prevline = if j > 0 { spl } else { 0 };
        let nextline = if j < height - 1 { spl } else { 0 };
        let row_base = j * spl;
        let dest_row = j * dpl * SCALE;

        for i in 0..width {
            let center = row_base + i;
            let up = center - prevline;
            let down = center + nextline;

            let mut w = [0u32; 10];
            w[2] = src[up];
            w[5] = src[center];
            w[8] = src[down];

            if i > 0 {
                w[1] = src[up - 1];
                w[4] = src[center - 1];
                w[7] = src[down - 1];
            } else {
                w[1] = w[2];
                w[4] = w[5];
                w[7] = w[8];
            }

            if i < width - 1 {
                w[3] = src[up + 1];
                w[6] = src[center + 1];
                w[9] = src[down + 1];
            } else {
                w[3] = w[2];
                w[6] = w[5];
                w[9] = w[8];
            }

            let pattern = pattern_of(&w);
            let base = dest_row + i * SCALE;
            match SCALE {
                2 => tables::fill2(pattern, &w, dest, base, dpl),
                3 => tables::fill3(pattern, &w, dest, base, dpl),
                _ => tables::fill4(pattern, &w, dest, base, dpl),
            }
        }
    }
}

/// Build the eight-bit pattern from the center and its eight neighbors.
///
/// A neighbor sets its bit when it differs from the center on the full 32-bit
/// value and also differs in YUV. The center slot `w[5]` is skipped, so the
/// bits map `w1..w4` to 0..3 and `w6..w9` to 4..7.
#[inline]
fn pattern_of(w: &[u32; 10]) -> u8 {
    let yuv1 = yuv::rgb_to_yuv(w[5]);
    let mut pattern = 0u8;
    let mut flag = 1u8;
    for k in 1..=9 {
        if k == 5 {
            continue;
        }
        if w[k] != w[5] {
            let yuv2 = yuv::rgb_to_yuv(w[k]);
            if yuv::yuv_diff(yuv1, yuv2) {
                pattern |= flag;
            }
        }
        flag <<= 1;
    }
    pattern
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    /// Read a fixture grid into row-major pixels with its dimensions.
    fn load_grid(name: &str) -> (usize, usize, Vec<u32>) {
        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/inputs")
            .join(format!("{name}.txt"));
        let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
        let mut tok = text.split_whitespace();
        let w: usize = tok.next().unwrap().parse().unwrap();
        let h: usize = tok.next().unwrap().parse().unwrap();
        let px: Vec<u32> = tok.map(|t| u32::from_str_radix(t, 16).unwrap()).collect();
        assert_eq!(px.len(), w * h, "pixel count mismatch in {name}");
        (w, h, px)
    }

    /// Collect the center pattern of each pixel in a grid, with edge clamping.
    fn patterns(w: usize, h: usize, px: &[u32]) -> Vec<u8> {
        let at = |x: i64, y: i64| -> u32 {
            let xc = x.clamp(0, w as i64 - 1) as usize;
            let yc = y.clamp(0, h as i64 - 1) as usize;
            px[yc * w + xc]
        };
        let mut out = Vec::with_capacity(w * h);
        for y in 0..h as i64 {
            for x in 0..w as i64 {
                let mut wn = [0u32; 10];
                wn[1] = at(x - 1, y - 1);
                wn[2] = at(x, y - 1);
                wn[3] = at(x + 1, y - 1);
                wn[4] = at(x - 1, y);
                wn[5] = at(x, y);
                wn[6] = at(x + 1, y);
                wn[7] = at(x - 1, y + 1);
                wn[8] = at(x, y + 1);
                wn[9] = at(x + 1, y + 1);
                out.push(pattern_of(&wn));
            }
        }
        out
    }

    #[test]
    fn corpus_exercises_every_fill_pattern() {
        // The all_patterns fixtures realize every value in 0..=255, so each of
        // the 256 fill arms is reached by a golden case. This pins the whole
        // dispatch table, not a subset.
        const CASES: &[&str] = &[
            "all_patterns",
            "all_patterns_multicolor",
            "rng_24x24",
            "checkerboard",
            "cross",
        ];
        let mut seen = [false; 256];
        for name in CASES {
            let (w, h, px) = load_grid(name);
            for p in patterns(w, h, &px) {
                seen[p as usize] = true;
            }
        }
        let missing: Vec<usize> = (0..256).filter(|&i| !seen[i]).collect();
        assert!(missing.is_empty(), "patterns never exercised: {missing:?}");
    }

    #[test]
    fn wrapped_destination_preflight_panics_without_writing() {
        let src = [0x0012_3456u32];
        let mut dst = [0xDEAD_BEEFu32];

        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            hq2x_rb(&src, 1, &mut dst, usize::MAX, 1, 1);
        }));

        assert!(result.is_err(), "oversized destination stride must panic");
        assert_eq!(dst, [0xDEAD_BEEF]);
    }
}