Compare commits

...

14 Commits

Author SHA1 Message Date
5b3c58fad5 Create a readme, incl. instructions for test exec
It's about time I have a README for this thing. I need somewhere to put
the test setup instructions, making now as good a time as any to get
around to it.
2025-10-14 13:23:43 -05:00
e2ba02f4d1 Make test-case magic ctor's image size-aware.
Those construction functions were setting an image size of 0x0, which is
no longer acceptable because of the iteration limiter. There is now an
extra argument for the expected pixel count.
2025-10-13 11:02:45 -05:00
8ccf6bd475 Track decode progress and stop at W*H pixels
I was thinking about doing some partial parsing where I retrieve a few
extra bytes to see if it's a footer, but that's really hard with an
iterator as an input stream.

Instead, I'm just going to count how many pixels the decoder has output
and stop iterating at that point.

Now to fix the test cases that don't have an image size assigned.
2025-10-13 10:56:23 -05:00
04f83a55d2 Add "full" image decode test to decoder.rs
This is a full QOI file decode routine, although the image it contains
is just one single blue pixel. I've added this because I finally found
the bug I've been hunting:

I'm using `tests/codec.rs` to load reference files and decode them, but
this is resulting in data size errors. There are 32 extra bytes coming
out of the decoder -- too much for the WIDTH x HEIGHT number of pixels,
and it happens to every image.

The cause is the 8-byte end marker. It is not properly detected by the
decoder and is instead used as additional data (probably QOI_OP_INDEX).
Since the decoder emits *pixels*, each of which are 4 bytes, we have 32
bytes of garbage data coming out of the decoder.

Bug found! Now to make the test pass...
2025-10-13 10:05:31 -05:00
0b2408bda1 Fix test: panic when decoded data is wrong size
This started life as a dev utility so I was only printing the message.
Now that it's a `#[test]`, I'll have it panic on the error path.
2025-10-13 08:33:29 -05:00
a6f18e5ea9 Rename codec.rs test function, use new err enum
I'm trying to minimize the number of panics even in test code. Some are
going to stay because I'm lazy and it makes no difference for a test.
2025-10-13 08:03:43 -05:00
7ba2e7760b Add an error enum to test/codec.rs 2025-10-13 08:02:38 -05:00
e31aa5decb Move main.rs to a test, drop dep on png crate
The codec library doesn't use any external dependencies. The PNG crate
is only to load some source material for testing the codec. Turning the
test driver into a real test lets me turn the PNG dependency into a
dev-dependency.
2025-10-12 21:18:32 -05:00
5ae4aa5823 Drop hack to fix byte count.
The byte count on this 800x600 32-bit image should come to 1,920,000
bytes... But it came to 1,920,032 bytes. I'm not sure where the extra
32 bytes come from, but I have to go fix it before I get to call the
decoder complete.
2024-10-14 21:07:30 -05:00
ef5d962621 Add PNG write-out machinery
I've pulled in the PNG crate to write the images back out as regular
PNG images. Now I can compare the results of my decoder against the
reference images.

I also made a weird utility iterator thing to wrap the Decoder's pixel
iterator because the PNG crate wants a &[u8], not a bunch of u32's or
custom pixels structs. I should learn how to impl the right traits so
that I can just `.to_components()` on an Iterator<Item=PixelRGBA>
instead of the wrapper constructor thing I have now.
2024-10-14 21:07:30 -05:00
9cffd665ca Make Decoder parts public so they can be used
I should maybe set the properties to be private and accessible strictly
through accessor methods. That way nobody can accidentally write into
the values. Oh well, that's a thing for later.
2024-10-14 21:07:30 -05:00
431c808b72 Fix test module Decoder calls and constructors
The other magic constructors were no longer valid because of the added
struct members. The calls to `new()` have been reattached to
`new_with_no_metadata()`. Tests pass, still!
2024-10-14 21:07:30 -05:00
f4eef2f863 Relocate the old Decoder::new method to mod test
The old `Decoder::new()` method would not pull the image header out of
the iterator. This was fine for the design then, but I've just decided
that I want to collect this info and store it in the Decoder struct.

I've moved the old method down into the test module so that I can still
use it to test the decoder parts alone. Same philosophy as the
`new_with_previous_pixel()` and `new_with_backbuffer()` methods.
2024-10-14 21:07:30 -05:00
d5e7b0c818 New Decoder creation function, more metadata
The qoi_header is specified as knowing the width, height, channels, and
colorspace. I figure this information would be captured by the decoder,
if anywhere. I've added more properties to the struct, and created a
new, fallible construction function.
2024-10-14 21:07:30 -05:00
5 changed files with 235 additions and 50 deletions

View File

@@ -5,4 +5,5 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[dev-dependencies]
png = "0.17.14"

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# QOI Codec Crate
The [Quite Okay Image format](https://qoiformat.org/) is an image compression scheme whose specification fits on one single page.
This crate is my (work in progress) implementation of that specification. At this time (v0.1) it can only decode some of the test images. Some don't work correctly and some don't work at all.
## Running the tests
1. Download the sample data set: https://qoiformat.org/qoi_test_images.zip
2. Unpack them into the project folder at `./qoi_test_images/`
The "integration test" in `tests/codec.rs` reads a QOI file and decodes it into a PNG. Since these images are not part of the repo, test will fail without this manual step.
Other tests, like those inside the regular source tree will run just fine. E.g.: The module `crate::decoder::test` will run properly with no additional setup actions. All data is contained as constants inside the source code.

View File

@@ -35,6 +35,7 @@ fn try_read_u32<I: Iterator<Item=u8>>(bytes: &mut I) -> Result<u32, DecodeError>
}
mod codec_utils {
use super::DecodeError;
use super::PixelRGBA;
pub(crate) fn hash(pixel: PixelRGBA) -> u8 {
pixel
@@ -45,34 +46,51 @@ mod codec_utils {
.wrapping_add(pixel.a.wrapping_mul(11))
% 64
}
pub (crate) fn read_u8_rewrap<I: Iterator<Item = u8>>(bytes: &mut I) -> Result<u8, DecodeError> {
if let Some(byte) = bytes.next() {
return Ok(byte);
} else {
return Err(DecodeError::EarlyIteratorExhaustion);
}
}
}
struct Decoder<I: Iterator<Item = u8>> {
pub struct Decoder<I: Iterator<Item = u8>> {
// Image metadata
pub width: u32,
pub height: u32,
pub channels: u8,
pub colorspace: u8,
// QOI codec state information
back_buffer: [PixelRGBA; 64],
prev_pixel: PixelRGBA,
bytes: I,
run_len: u8,
// Counter to ensure we stop after getting to Self::width * Self::height pixels
output_count: usize,
}
impl<I> Decoder<I>
where
I: Iterator<Item = u8>,
{
fn new(bytes: I) -> Self {
Self {
pub fn try_new(mut bytes: I) -> Result<Self, DecodeError> {
let _ = try_read_magic(&mut bytes)?;
Ok(Self {
width: try_read_u32(&mut bytes)?,
height: try_read_u32(&mut bytes)?,
channels: codec_utils::read_u8_rewrap(&mut bytes)?,
colorspace: codec_utils::read_u8_rewrap(&mut bytes)?,
back_buffer: [PixelRGBA::zero(); 64],
prev_pixel: PixelRGBA {
r: 0,
g: 0,
b: 0,
a: 255,
},
prev_pixel: PixelRGBA { r:0, g:0, b:0, a: 255},
bytes,
run_len: 0,
}
output_count: 0,
})
}
}
@@ -83,6 +101,13 @@ where
type Item = PixelRGBA;
fn next(&mut self) -> Option<Self::Item> {
// count pixels. When we reach WIDTH x HEIGHT, check for footer and quit.
if self.output_count == (self.width * self.height) as usize {
return None;
} else {
self.output_count += 1;
}
if self.run_len > 0 {
self.run_len -= 1;
Some(self.prev_pixel)
@@ -176,8 +201,12 @@ mod test {
// then extracting back-referenced data out of it. A complete test should
// feed in other valid operations that populate the backbuffer, and then index
// op codes to demonstrate the indexing operations.
fn new_with_backbuffer(bytes: I, back_buffer: [PixelRGBA; 64]) -> Self {
fn new_with_backbuffer(bytes: I, back_buffer: [PixelRGBA; 64], size: u32) -> Self {
Self {
width: size,
height: 1,
channels: 4,
colorspace: 0,
back_buffer,
prev_pixel: PixelRGBA {
r: 0,
@@ -187,17 +216,41 @@ mod test {
},
bytes,
run_len: 0,
output_count: 0,
}
}
// A hack to unit test the run behavior. Same idea as the new_with_backbuffer()
// function, but for testing a run of pixels.
fn new_with_previous_pixel(bytes: I, prev_pixel: PixelRGBA) -> Self {
fn new_with_previous_pixel(bytes: I, prev_pixel: PixelRGBA, size: u32) -> Self {
Self {
width: size,
height: 1,
channels: 4,
colorspace: 0,
back_buffer: [PixelRGBA::zero(); 64],
prev_pixel,
bytes,
run_len: 0,
output_count: 0,
}
}
// The decoder includes image metadata, now, and construction attempts
// to extract it from the input iterator. For the decoder tests, this
// needs to be skipped. Thus, we get *another* magic constructor
// available only to the test module.
fn new_with_no_metadata(bytes: I, size: u32) -> Self {
Self {
width: size,
height: 1,
channels: 4,
colorspace: 0,
back_buffer: [PixelRGBA::zero(); 64],
prev_pixel: PixelRGBA { r: 0, g: 0, b: 0, a: 255 },
bytes,
run_len: 0,
output_count: 0,
}
}
@@ -252,7 +305,7 @@ mod test {
},
];
let decoder = Decoder::new(compressed.into_iter());
let decoder = Decoder::new_with_no_metadata(compressed.into_iter(), expected.len() as u32);
let result = decoder.collect::<Vec<PixelRGBA>>();
assert_eq!(result, expected);
}
@@ -298,7 +351,7 @@ mod test {
},
];
let decoder = Decoder::new(compressed.into_iter());
let decoder = Decoder::new_with_no_metadata(compressed.into_iter(), expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -333,7 +386,7 @@ mod test {
PixelRGBA::zero(),
];
let decoder = Decoder::new_with_backbuffer(compressed.into_iter(), backbuffer);
let decoder = Decoder::new_with_backbuffer(compressed.into_iter(), backbuffer, expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -364,7 +417,7 @@ mod test {
PixelRGBA::new(0, 0, 0, 255),
];
let decoder = Decoder::new(compressed.into_iter());
let decoder = Decoder::new_with_no_metadata(compressed.into_iter(), expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -383,7 +436,7 @@ mod test {
PixelRGBA::new(1, 1, 1, 255), // holds at 1s
];
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel);
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel, expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -402,7 +455,7 @@ mod test {
PixelRGBA::new(254, 254, 254, 255),
];
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel);
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel, expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -430,7 +483,7 @@ mod test {
PixelRGBA::new(37, 19, 28, 255),
];
let decoder = Decoder::new(compressed.into_iter());
let decoder = Decoder::new_with_no_metadata(compressed.into_iter(), expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -444,7 +497,7 @@ mod test {
];
let expected = PixelRGBA::new(37, 30, 37, 255);
let mut decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel);
let mut decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel, 1);
let result = decoder
.next()
.expect("Oops, didn't get a Pixel back from the Decoder");
@@ -460,7 +513,7 @@ mod test {
];
let expected = PixelRGBA::new(247, 243, 238, 255);
let mut decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel);
let mut decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel, 1);
let result = decoder
.next()
.expect("Oops, didn't get a Pixel back from the Decoder");
@@ -481,7 +534,7 @@ mod test {
// lets pretend an encoder did this for some reason. The decoder can still
// unpack this correctly, it's just a sub-optimal compression is all.
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel);
let decoder = Decoder::new_with_previous_pixel(compressed.into_iter(), init_pixel, expected.len() as u32);
let result: Vec<PixelRGBA> = decoder.collect();
assert_eq!(result, expected);
}
@@ -523,7 +576,7 @@ mod test {
PixelRGBA::new(0xFF, 0xFF, 0xFF, 0xFF),
];
let mut decoder = Decoder::new(compressed.into_iter());
let mut decoder = Decoder::new_with_no_metadata(compressed.into_iter(), expected.len() as u32);
let mut result = Vec::<PixelRGBA>::new();
loop {
@@ -592,7 +645,7 @@ mod test {
39, // run x3
38, // final RGBA
];
let mut decoder = Decoder::new(compressed.into_iter());
let mut decoder = Decoder::new_with_no_metadata(compressed.into_iter(), indices.len() as u32);
let mut iters = 0;
loop {
if let Some(pixel) = decoder.next() {
@@ -626,4 +679,38 @@ mod test {
expected
);
}
/// Try decoding an image consisting of a single pixel.
#[test]
fn decode_1_pixel_image() {
let input: [u8; 26] = [
0x71, 0x6f, 0x69, 0x66, // 'qoif' magic bytes
0x00, 0x00, 0x00, 0x01, // u32 width
0x00, 0x00, 0x00, 0x01, // u32 height
0x03, // u8 channels (3 for RGB mode)
0x00, // u8 colorspace (0 for sRGB w/ linear alpha)
// One single blue pixel, QOI_OP_RGB
0xFE, 0x00, 0x00, 0xFF,
// footer
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01
];
let mut decoder = Decoder::try_new(input.into_iter())
.expect("Failed to initialize decoder from byte array");
// Check the metadata
assert_eq!(decoder.width, 1);
assert_eq!(decoder.height, 1);
assert_eq!(decoder.channels, 3);
assert_eq!(decoder.colorspace, 0);
// Grab the one single pixel and check that it's solid blue.
let pixel = decoder.next().expect("Couldn't get pixel from decoder");
assert_eq!(pixel, PixelRGBA::new(0, 0, 255, 255));
// Assert that there are no more pixels.
let pixel = decoder.next();
assert!(pixel.is_none());
}
}

View File

@@ -1,25 +0,0 @@
use qoicodec::DecodeError;
use qoicodec::try_read_magic;
use std::{fs::File, io::Read};
fn main() {
let file = File::open("qoi_test_images/dice.qoi").unwrap();
let mut bytes = file.bytes()
.map(|maybe_bytes|
{
match maybe_bytes {
Ok(byte) => byte,
Err(oops) => panic!("Oops, the file failed to load: {}", oops)
}
});
if let Err(e) = try_read_magic(&mut bytes) {
match e {
DecodeError::Magic => panic!("QOI Magic bytes are bad!"),
DecodeError::EarlyIteratorExhaustion => panic!("File iterator exhausted earlier than expected."),
_ => panic!("Unhandled error reading magic: {:?}", e),
}
}
todo!("The rest of the main function");
}

108
tests/codec.rs Normal file
View File

@@ -0,0 +1,108 @@
use qoicodec::DecodeError;
use qoicodec::Decoder;
use qoicodec::PixelRGBA;
use std::io::BufWriter;
use std::path::Path;
use std::{fs::File, io::Read};
#[derive(Debug)]
enum TestError {
SetupFailure, // an error during test initialization (missing file, no write permission for output)
CodecFailure(DecodeError),
}
impl From<DecodeError> for TestError {
fn from(err: DecodeError) -> Self {
Self::CodecFailure(err)
}
}
#[test]
/// There are test images provided by the QOI spec author: https://qoiformat.org/qoi_test_images.zip
/// This function looks for them in `./qoi_test_images/` and turns them all
/// into PNGs. Manual inspection is required to evaluate the test result.
fn decode_qoi_samples() -> Result<(), TestError> {
let file = File::open("qoi_test_images/dice.qoi").map_err(|_io_err| TestError::SetupFailure)?;
// TODO: feed the Result<> forward, don't let a panic path be given to an unaware caller.
let mut bytes = file.bytes()
.map(|maybe_bytes|
{
match maybe_bytes {
Ok(byte) => byte,
Err(oops) => panic!("Oops, the file failed to load: {}", oops)
}
});
let decoder = Decoder::try_new(bytes)?;
let test_out_path = Path::new("test_output.png");
let file = File::create(test_out_path).unwrap();
let ref mut w = BufWriter::new(file);
let mut encoder = png::Encoder::new(w, decoder.width, decoder.height);
encoder.set_color(match decoder.channels {
3 => png::ColorType::Rgb,
4 => png::ColorType::Rgba,
_ => panic!("Bad colorspace in qoicodec::Decoder!")
});
encoder.set_depth(png::BitDepth::Eight);
encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
encoder.set_source_gamma(png::ScaledFloat::new(1.0 / 2.2));
let source_chromaticities = png::SourceChromaticities::new(
(0.31270, 0.32900),
(0.64000, 0.33000),
(0.30000, 0.60000),
(0.15000, 0.06000),
);
encoder.set_source_chromaticities(source_chromaticities);
let mut writer = encoder.write_header().unwrap();
let data: Vec<u8> = PixelIterExpander::try_new(decoder).unwrap().collect();
let result = writer.write_image_data(&data);
if let Err(res) = result {
panic!("{}", res);
}
return Ok(())
}
struct PixelIterExpander <I: Iterator<Item = qoicodec::PixelRGBA>> {
wrapped_iterator: I,
current_pixel: qoicodec::PixelRGBA,
color_element_idx: u8,
}
impl <I: std::iter::Iterator<Item = PixelRGBA>> PixelIterExpander <I> {
fn try_new(mut pixels: I) -> Option<Self> {
let px = pixels.next()?;
Some(Self {
wrapped_iterator: pixels,
current_pixel: px,
color_element_idx: 0,
})
}
}
impl <I: Iterator<Item=PixelRGBA>> Iterator for PixelIterExpander<I> {
type Item = u8;
fn next(&mut self) -> Option<Self::Item> {
// at index 4, reset counter and get next pixel
if self.color_element_idx == 4 {
self.color_element_idx = 0;
self.current_pixel = self.wrapped_iterator.next()?;
}
// increment pixel value, match over index and return that byte
self.color_element_idx += 1;
match self.color_element_idx {
1 => Some(self.current_pixel.r),
2 => Some(self.current_pixel.g),
3 => Some(self.current_pixel.b),
4 => Some(self.current_pixel.a),
_ => None
}
}
}