Compare commits

...

18 commits
v0.1.1 ... main

13 changed files with 353 additions and 52 deletions

View file

@ -1,24 +1,20 @@
[package] [workspace]
name = "sqp" resolver = "2"
description = """ members = [
The squishiest image format! "sqp",
""" "sqp_tools",
repository = "https://github.com/Dangoware/sqp" ]
license = "MIT OR Apache-2.0"
authors = ["G2 <ke0bhogsg@gmail.com>"]
version = "0.1.0"
edition = "2021"
categories = ["encoding", "compression", "graphics", "multimedia::images", "multimedia::encoding"]
[dependencies] [workspace.package]
byteorder = "1.5" authors = ["G2-Games <ke0bhogsg@gmail.com>"]
integer-encoding = "4.0"
rayon = "1.10" [workspace.lints.rust]
thiserror = "1.0" unsafe_code = "forbid"
[profile.production] [profile.production]
inherits = "release" inherits = "release"
lto = true
strip = true strip = true
lto = true
opt-level = "z"
codegen-units = 1 codegen-units = 1
panic = "abort" panic = "abort"

View file

@ -1,8 +1,10 @@
<p align="center"> <p align="center">
<img width="400px" src="https://github.com/user-attachments/assets/98f94c1c-ed6f-49a3-b906-c328035d981e"> <img title="SQP" alt="SQP Logo" width="500px" src="https://github.com/user-attachments/assets/cf2fd7f4-f825-4bb4-9427-1b7181be4639">
</p> </p>
# SQP [![Lib.rs Version](https://img.shields.io/crates/v/sqp?style=for-the-badge&logo=rust&label=lib.rs&color=%23a68bfc)](https://lib.rs/crates/sqp)
[![docs.rs](https://img.shields.io/docsrs/cross_usb?style=for-the-badge)](https://docs.rs/sqp/)
**SQP** (**SQ**uishy **P**icture Format) is an image format designed **SQP** (**SQ**uishy **P**icture Format) is an image format designed
for ease of implementation and learning about compression and image formats for ease of implementation and learning about compression and image formats
while attaining a relatively good compression ratio. The general idea is to while attaining a relatively good compression ratio. The general idea is to
@ -19,6 +21,7 @@ speeds.
- Support for various color formats (RGBA, Grayscale, etc.) - Support for various color formats (RGBA, Grayscale, etc.)
- Decent compression ratios, the lossless compression can often beat PNG - Decent compression ratios, the lossless compression can often beat PNG
especially on images with transparency especially on images with transparency
- Lossy alpha compression!
- Relatively simple - Relatively simple
- Squishy! 🍡 - Squishy! 🍡
@ -30,3 +33,14 @@ speeds.
- Decoder-based frame interpolation - Decoder-based frame interpolation
- Floating point color - Floating point color
- Metadata? - Metadata?
## Examples
All examples are at 30% quality in both JPEG and SQP.
| Original | JPEG | SQP |
|----------|--------------|-------------|
| <img width="300px" src="https://github.com/user-attachments/assets/e4f7b620-4cf5-407d-851b-800c52c8a14d"> | <img width="300px" src="https://github.com/user-attachments/assets/84691e8c-2f73-4a1d-b979-0863066b159f"> | <img width="300px" src="https://github.com/user-attachments/assets/ccaa8770-b641-437f-80d1-3658f94c2e21"> |
| <img width="300px" src="https://github.com/user-attachments/assets/f0056e3b-8988-4d0d-88bf-bc73ac5b8be0"> | <img width="300px" src="https://github.com/user-attachments/assets/400c4072-ba69-45d7-8051-46a4e2867c7f"> | <img width="300px" src="https://github.com/user-attachments/assets/c4c84f64-7564-433a-a922-17da472578d9"> |
Images obtained from the following source:
[https://r0k.us/graphics/kodak/](https://r0k.us/graphics/kodak/)

17
sqp/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "sqp"
description = """
The squishiest image format!
"""
repository = "https://github.com/Dangoware/sqp"
license = "MIT OR Apache-2.0"
authors.workspace = true
version = "0.1.1"
edition = "2021"
categories = ["encoding", "compression", "graphics", "multimedia::images", "multimedia::encoding"]
[dependencies]
byteorder = "1.5"
integer-encoding = "4.0"
rayon = "1.10"
thiserror = "2.0"

View file

@ -1,9 +1,7 @@
use std::io::{Read, Write}; use std::io::{Read, Write};
use byteorder::{ReadBytesExt, WriteBytesExt};
/// A simple way to write individual bits to an input implementing [Write]. /// A simple way to write individual bits to an input implementing [Write].
pub struct BitWriter<'a, O: Write + WriteBytesExt> { pub struct BitWriter<'a, O: Write> {
output: &'a mut O, output: &'a mut O,
current_byte: u8, current_byte: u8,
@ -14,7 +12,7 @@ pub struct BitWriter<'a, O: Write + WriteBytesExt> {
byte_size: usize, byte_size: usize,
} }
impl<'a, O: Write + WriteBytesExt> BitWriter<'a, O> { impl<'a, O: Write> BitWriter<'a, O> {
/// Create a new BitWriter wrapper around something which /// Create a new BitWriter wrapper around something which
/// implements [Write]. /// implements [Write].
pub fn new(output: &'a mut O) -> Self { pub fn new(output: &'a mut O) -> Self {
@ -35,22 +33,12 @@ impl<'a, O: Write + WriteBytesExt> BitWriter<'a, O> {
self.byte_size self.byte_size
} }
/// Get the bit offset within the current byte.
pub fn bit_offset(&self) -> u8 {
self.bit_offset as u8
}
/// Check if the stream is aligned to a byte.
pub fn aligned(&self) -> bool {
self.bit_offset() == 0
}
/// Align the writer to the nearest byte by padding with zero bits. /// Align the writer to the nearest byte by padding with zero bits.
pub fn flush(&mut self) { pub fn flush(&mut self) {
self.byte_offset += 1; self.byte_offset += 1;
// Write out the current byte unfinished // Write out the current byte unfinished
self.output.write_u8(self.current_byte).unwrap(); self.output.write_all(&[self.current_byte]).unwrap();
self.current_byte = 0; self.current_byte = 0;
} }
@ -79,12 +67,12 @@ impl<'a, O: Write + WriteBytesExt> BitWriter<'a, O> {
self.byte_offset += 1; self.byte_offset += 1;
self.bit_offset = 0; self.bit_offset = 0;
self.output.write_u8(self.current_byte).unwrap(); self.output.write_all(&[self.current_byte]).unwrap();
self.current_byte = 0; self.current_byte = 0;
} }
} }
self.byte_size = self.byte_offset + (self.bit_offset + 7) / 8; self.byte_size = self.byte_offset + self.bit_offset.div_ceil(8);
} }
/// Write some bytes to the output. /// Write some bytes to the output.
@ -100,12 +88,12 @@ impl<'a, O: Write + WriteBytesExt> BitWriter<'a, O> {
.unwrap(); .unwrap();
self.byte_offset += byte_len; self.byte_offset += byte_len;
self.byte_size = self.byte_offset + (self.bit_offset + 7) / 8; self.byte_size = self.byte_offset + self.bit_offset.div_ceil(8);
} }
} }
/// A simple way to read individual bits from an input implementing [Read]. /// A simple way to read individual bits from an input implementing [Read].
pub struct BitReader<'a, I: Read + ReadBytesExt> { pub struct BitReader<'a, I: Read> {
input: &'a mut I, input: &'a mut I,
current_byte: Option<u8>, current_byte: Option<u8>,
@ -114,15 +102,16 @@ pub struct BitReader<'a, I: Read + ReadBytesExt> {
bit_offset: usize, bit_offset: usize,
} }
impl<'a, I: Read + ReadBytesExt> BitReader<'a, I> { impl<'a, I: Read> BitReader<'a, I> {
/// Create a new BitReader wrapper around something which /// Create a new BitReader wrapper around something which
/// implements [Write]. /// implements [Write].
pub fn new(input: &'a mut I) -> Self { pub fn new(input: &'a mut I) -> Self {
let first = input.read_u8().unwrap(); let mut buf = [0u8];
input.read_exact(&mut buf).unwrap();
Self { Self {
input, input,
current_byte: Some(first), current_byte: Some(buf[0]),
byte_offset: 0, byte_offset: 0,
bit_offset: 0, bit_offset: 0,
@ -155,7 +144,10 @@ impl<'a, I: Read + ReadBytesExt> BitReader<'a, I> {
self.byte_offset += 1; self.byte_offset += 1;
self.bit_offset = 0; self.bit_offset = 0;
self.current_byte = Some(self.input.read_u8().unwrap()); let mut buf = [0u8];
self.input.read_exact(&mut buf).unwrap();
self.current_byte = Some(buf[0]);
} }
result |= bit_value << i; result |= bit_value << i;

View file

@ -100,6 +100,9 @@ pub fn idct(input: &[f32], width: usize, height: usize) -> Vec<u8> {
/// ///
/// Instead of using this, use the [`quantization_matrix`] function to /// Instead of using this, use the [`quantization_matrix`] function to
/// get a quantization matrix corresponding to the image quality value. /// get a quantization matrix corresponding to the image quality value.
///
/// TODO: In the future, it would be cool to figure out how to generate a
/// quantization matrix of any size.
const BASE_QUANTIZATION_MATRIX: [u16; 64] = [ const BASE_QUANTIZATION_MATRIX: [u16; 64] = [
16, 11, 10, 16, 24, 40, 51, 61, 16, 11, 10, 16, 24, 40, 51, 61,
12, 12, 14, 19, 26, 58, 60, 55, 12, 12, 14, 19, 26, 58, 60, 55,

View file

@ -7,6 +7,7 @@ use crate::picture::Error;
/// A DPF file header. This must be included at the beginning /// A DPF file header. This must be included at the beginning
/// of a valid DPF file. /// of a valid DPF file.
#[derive(Debug, Clone, Copy)]
pub struct Header { pub struct Header {
/// Identifier. Must be set to "dangoimg". /// Identifier. Must be set to "dangoimg".
pub magic: [u8; 8], pub magic: [u8; 8],

View file

@ -18,8 +18,10 @@
//! let width = 2; //! let width = 2;
//! let height = 2; //! let height = 2;
//! let bitmap = vec![ //! let bitmap = vec![
//! 255, 255, 255, 255, 0, 255, 0, 128, //! 0xFF, 0xFF, 0xFF, 0xFF,
//! 255, 255, 255, 255, 0, 255, 0, 128 //! 0x00, 0x80, 0x00, 0x80,
//! 0xFF, 0xFF, 0xFF, 0xFF,
//! 0x00, 0x80, 0x00, 0x80,
//! ]; //! ];
//! //!
//! // Create a 2×2 image in memory. Nothing is compressed or encoded //! // Create a 2×2 image in memory. Nothing is compressed or encoded

View file

@ -58,9 +58,9 @@ pub fn add_rows(width: u32, height: u32, color_format: ColorFormat, data: &[u8])
// Interleave the offset alpha into the RGB bytes // Interleave the offset alpha into the RGB bytes
data[rgb_index..rgb_index + width as usize * (color_format.pbc() - 1)] data[rgb_index..rgb_index + width as usize * (color_format.pbc() - 1)]
.chunks(color_format.pbc() - 1) .chunks(color_format.pbc() - 1)
.zip(data[alpha_index..alpha_index + width as usize].into_iter()) .zip(data[alpha_index..alpha_index + width as usize].iter())
.flat_map(|(a, b)| { .flat_map(|(a, b)| {
a.into_iter().chain(vec![b]) a.iter().chain(vec![b])
}) })
.copied() .copied()
.collect() .collect()

View file

@ -1,8 +1,6 @@
//! Functions and other utilities surrounding the [`SquishyPicture`] type. //! Functions and other utilities surrounding the [`SquishyPicture`] type.
use std::{fs::File, io::{self, BufWriter, Read, Write}, path::Path}; use std::{fs::File, io::{self, BufWriter, Read, Write}, path::Path};
use byteorder::{ReadBytesExt, WriteBytesExt};
use integer_encoding::VarInt; use integer_encoding::VarInt;
use thiserror::Error; use thiserror::Error;
@ -146,7 +144,7 @@ impl SquishyPicture {
/// Encode the image into anything that implements [`Write`]. /// Encode the image into anything that implements [`Write`].
/// ///
/// Returns the number of bytes written. /// Returns the number of bytes written.
pub fn encode<O: Write + WriteBytesExt>(&self, mut output: O) -> Result<usize, Error> { pub fn encode<O: Write>(&self, mut output: O) -> Result<usize, Error> {
let mut count = 0; let mut count = 0;
// Write out the header // Write out the header
@ -205,14 +203,14 @@ impl SquishyPicture {
} }
/// Decode the image from anything that implements [`Read`] /// Decode the image from anything that implements [`Read`]
pub fn decode<I: Read + ReadBytesExt>(mut input: I) -> Result<Self, Error> { pub fn decode<I: Read>(mut input: I) -> Result<Self, Error> {
let header = Header::read_from(&mut input)?; let header = Header::read_from(&mut input)?;
let compression_info = CompressionInfo::read_from(&mut input); let compression_info = CompressionInfo::read_from(&mut input);
let pre_bitmap = decompress(&mut input, &compression_info); let pre_bitmap = decompress(&mut input, &compression_info);
let bitmap = match header.compression_type { let mut bitmap = match header.compression_type {
CompressionType::None => pre_bitmap, CompressionType::None => pre_bitmap,
CompressionType::Lossless => { CompressionType::Lossless => {
add_rows( add_rows(
@ -235,6 +233,8 @@ impl SquishyPicture {
}, },
}; };
bitmap.truncate(header.width as usize * header.height as usize * header.color_format.pbc());
Ok(Self { header, bitmap }) Ok(Self { header, bitmap })
} }
@ -242,6 +242,25 @@ impl SquishyPicture {
pub fn as_raw(&self) -> &Vec<u8> { pub fn as_raw(&self) -> &Vec<u8> {
&self.bitmap &self.bitmap
} }
/// Get the underlying raw buffer
pub fn into_raw(self) -> Vec<u8> {
self.bitmap
}
/// The width of the image in pixels
pub fn width(&self) -> u32 {
self.header.width
}
/// The height of the image in pixels
pub fn height(&self) -> u32 {
self.header.height
}
pub fn color_format(&self) -> ColorFormat {
self.header.color_format
}
} }
/// Decode a stream encoded as varints. /// Decode a stream encoded as varints.
@ -264,5 +283,5 @@ fn decode_varint_stream(stream: &[u8]) -> Vec<i16> {
pub fn open<P: AsRef<Path>>(path: P) -> Result<SquishyPicture, Error> { pub fn open<P: AsRef<Path>>(path: P) -> Result<SquishyPicture, Error> {
let input = File::open(path)?; let input = File::open(path)?;
Ok(SquishyPicture::decode(input)?) SquishyPicture::decode(input)
} }

15
sqp_tools/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "sqp_tools"
version = "0.1.0"
edition = "2024"
authors.workspace = true
[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "pnm", "qoi", "rayon", "tga", "tiff", "webp"] }
sqp = { path = "../sqp" }
text_io = "0.1"
[lints]
workspace = true

168
sqp_tools/src/main.rs Normal file
View file

@ -0,0 +1,168 @@
mod utils;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use image::ImageReader;
use anyhow::{bail, Result};
use sqp::{ColorFormat, CompressionType};
use utils::{color_format, color_format_to_type, color_type_to_format, exists_decision, Assume};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Subcommands,
/// Overwrite output files
#[arg(short = 'y', long = "overwrite", conflicts_with = "assumeno")]
assumeyes: bool,
/// Do not overwrite output files
#[arg(short = 'n', long = "preserve", conflicts_with = "assumeyes")]
assumeno: bool,
}
#[derive(Debug, Subcommand)]
enum Subcommands {
/// Encode an image to SQP format
Encode(EncodeArgs),
/// Decode an SQP image into another format
Decode(DecodeArgs),
}
#[derive(Debug, Args)]
struct EncodeArgs {
/// Input image file of any type supported by `image`
input: PathBuf,
/// Output path to SQP location
output: PathBuf,
/// Quality setting, a higher value = higher quality.
#[arg(default_value_t = 100, short, long, conflicts_with = "uncompressed", value_parser = clap::value_parser!(u8).range(1..=100))]
quality: u8,
/// Create an uncompressed image.
///
/// Incompatible with quality setting.
#[arg(short, long, conflicts_with = "quality")]
uncompressed: bool,
/// The color format to use for the output image
///
/// Valid values:
/// - RGBA8
/// - RGB8
/// - GrayA8
/// - Gray8
#[arg(short, long, value_parser = color_format, verbatim_doc_comment)]
color_format: Option<ColorFormat>,
}
#[derive(Debug, Args)]
struct DecodeArgs {
/// Input SQP image file
input: PathBuf,
/// Output image file
output: PathBuf,
}
fn main() -> Result<()> {
let args = Cli::parse();
let assume = if args.assumeyes {
Some(Assume::Yes)
} else if args.assumeno {
Some(Assume::No)
} else {
None
};
match args.command {
Subcommands::Encode(a) => encode(a, assume),
Subcommands::Decode(a) => decode(a, assume),
}
}
fn encode(args: EncodeArgs, assume: Option<Assume>) -> Result<()> {
if !args.input.try_exists()? {
bail!("Input file {:?} does not exist", args.input);
}
if args.output.try_exists()?
&& !exists_decision("Output", "Overwrite", &args.output, assume)
{
return Ok(())
}
let image = ImageReader::open(args.input)?
.decode()?;
let width = image.width();
let height = image.height();
let color_format = args.color_format.or_else(
|| color_type_to_format(image.color())
).unwrap_or(ColorFormat::Rgba8);
let bitmap = match color_format {
ColorFormat::Rgba8 => image.into_rgba8().into_vec(),
ColorFormat::Rgb8 => image.into_rgb8().into_vec(),
ColorFormat::GrayA8 => image.into_luma_alpha8().into_vec(),
ColorFormat::Gray8 => image.into_luma8().into_vec(),
};
let (compression_type, quality) = if args.uncompressed {
(CompressionType::None, None)
} else if args.quality == 100 {
(CompressionType::Lossless, None)
} else {
(CompressionType::LossyDct, Some(args.quality))
};
let sqp_img = sqp::SquishyPicture::from_raw(
width,
height,
color_format,
compression_type,
quality,
bitmap,
);
sqp_img.save(&args.output)?;
Ok(())
}
fn decode(args: DecodeArgs, assume: Option<Assume>) -> Result<()> {
if !args.input.try_exists()? {
bail!("Input file {:?} does not exist", args.input);
}
if args.output.try_exists()?
&& !exists_decision("Output", "Overwrite", &args.output, assume)
{
return Ok(())
}
let sqp_img = sqp::open(args.input)?;
let width = sqp_img.width();
let height = sqp_img.height();
let color_format = color_format_to_type(sqp_img.color_format());
let mut img = sqp_img.into_raw();
img.truncate(width as usize * height as usize * color_format.bytes_per_pixel() as usize);
image::save_buffer(
args.output,
&img,
width,
height,
color_format,
)?;
Ok(())
}

74
sqp_tools/src/utils.rs Normal file
View file

@ -0,0 +1,74 @@
use std::path::Path;
use image::ColorType;
use sqp::ColorFormat;
use text_io::read;
pub enum Assume {
Yes,
No,
}
pub fn color_format(s: &str) -> Result<ColorFormat, String> {
if !s.is_ascii() {
return Err(format!("Invalid color format {}", s))
}
let s_lower = s.to_lowercase();
let color_format = match s_lower.as_str() {
"rgba8" => ColorFormat::Rgba8,
"rgb8" => ColorFormat::Rgb8,
"graya8" => ColorFormat::GrayA8,
"gray8" => ColorFormat::Gray8,
_ => return Err(format!("Invalid color format {}", s)),
};
Ok(color_format)
}
pub fn color_type_to_format(img_color_format: ColorType) -> Option<ColorFormat> {
Some(match img_color_format {
ColorType::L8 => ColorFormat::Gray8,
ColorType::La8 => ColorFormat::GrayA8,
ColorType::Rgb8 => ColorFormat::Rgb8,
ColorType::Rgba8 => ColorFormat::Rgba8,
_ => return None,
})
}
pub fn color_format_to_type(img_color_format: ColorFormat) -> ColorType {
match img_color_format {
ColorFormat::Gray8 => ColorType::L8,
ColorFormat::GrayA8 => ColorType::La8,
ColorFormat::Rgb8 => ColorType::Rgb8,
ColorFormat::Rgba8 => ColorType::Rgba8,
}
}
pub fn exists_decision<P: AsRef<Path>>(place: &str, action: &str, path: &P, assume: Option<Assume>) -> bool {
let path = path.as_ref();
match assume {
Some(Assume::Yes) => return true,
Some(Assume::No) => return false,
None => (),
}
loop {
print!("{place} file {path:?} already exists. {action}? [y/N] ");
let opt: String = read!("{}\n");
let opt = opt.to_lowercase();
if !opt.is_empty() && opt == "y" {
return true
} else if !opt.is_empty() && opt != "y" {
continue
}
if opt.is_empty() {
return false
}
}
}