Files
c3c/lib/std/compression/qoi.c3
Christoffer Lerno acc4a900f5 - New const enum declaration syntax.
- New enum associated value syntax.
2026-02-12 14:43:56 +01:00

467 lines
12 KiB
Plaintext

module std::compression::qoi;
const uint PIXELS_MAX = 400000000;
<*
Colorspace.
Purely informative. It will be saved to the file header,
but does not affect how chunks are en-/decoded.
*>
const enum QOIColorspace : char
{
<* sRGB with linear alpha *>
SRGB = 0,
<* all channels linear *>
LINEAR = 1
}
<*
Channels.
The channels used in an image.
AUTO can be used when decoding to automatically determine
the channels from the file's header.
*>
const enum QOIChannels : inline char
{
AUTO = 0,
RGB = 3,
RGBA = 4
}
<*
Descriptor.
Contains information about an image.
*>
struct QOIDesc
{
uint width;
uint height;
QOIChannels channels;
QOIColorspace colorspace;
}
<*
QOI Errors.
These are all the possible bad outcomes.
*>
faultdef INVALID_PARAMETERS, FILE_OPEN_FAILED, FILE_WRITE_FAILED, INVALID_DATA, TOO_MANY_PIXELS;
// Let the user decide if they want to use std::io
module std::compression::qoi @if(!$feature(QOI_NO_STDIO));
import std::io;
<*
Encode raw RGB or RGBA pixels into a QOI image and write it to the
file system.
The desc struct must be filled with the image width, height, the
used channels (QOIChannels.RGB or RGBA) and the colorspace
(QOIColorspace.SRGB or LINEAR).
The function returns an optional, which can either be a QOIError
or the number of bytes written on success.
@param [in] filename : `The file's name to write the image to`
@param [in] input : `The raw RGB or RGBA pixels to encode`
@param [&in] desc : `The descriptor of the image`
*>
fn usz? write(String filename, char[] input, QOIDesc* desc) => @pool()
{
// encode data
char[] output = encode(tmem, input, desc)!;
file::save(filename, output)!;
return output.len;
}
<*
Read and decode a QOI image from the file system.
If channels is set to QOIChannels.AUTO, the function will
automatically determine the channels from the file's header.
However, if channels is RGB or RGBA, the output format will be
forced into this number of channels.
The desc struct will be filled with the width, height,
channels and colorspace of the image.
The function returns an optional, which can either be a QOIError
or a char[] pointing to the decoded pixels on success.
The returned pixel data should be free()d after use, or the decoding
and use of the data should be wrapped in a @pool() { ... }; block.
@param [in] filename : `The file's name to read the image from`
@param [&out] desc : `The descriptor to fill with the image's info`
@param channels : `The channels to be used`
@return? FILE_OPEN_FAILED, INVALID_DATA, TOO_MANY_PIXELS
*>
fn char[]? read(Allocator allocator, String filename, QOIDesc* desc, QOIChannels channels = AUTO) => @pool()
{
// read file
char[] data = file::load_temp(filename) ?? FILE_OPEN_FAILED~!;
// pass data to decode function
return decode(allocator, data, desc, channels);
}
// Back to basic non-stdio mode
module std::compression::qoi;
import std::bits;
<*
Encode raw RGB or RGBA pixels into a QOI image in memory.
The function returns an optional, which can either be a QOIError
or a char[] pointing to the encoded data on success.
The returned qoi data should be free()d after use, or the encoding
and use of the data should be wrapped in a @pool() { ... }; block.
See the write() function for an example.
@param [in] input : `The raw RGB or RGBA pixels to encode`
@param [&in] desc : `The descriptor of the image`
@return? INVALID_PARAMETERS, TOO_MANY_PIXELS, INVALID_DATA
*>
fn char[]? encode(Allocator allocator, char[] input, QOIDesc* desc) @nodiscard
{
// check info in desc
if (desc.width == 0 || desc.height == 0) return INVALID_PARAMETERS~;
if (desc.channels == AUTO) return INVALID_PARAMETERS~;
uint pixels = desc.width * desc.height;
if (pixels > PIXELS_MAX) return TOO_MANY_PIXELS~;
// check input data size
uint image_size = pixels * desc.channels;
if (image_size != input.len) return INVALID_DATA~;
// allocate memory for encoded data (output)
// header + chunk tag and RGB(A) data for each pixel + end of stream
uint max_size = Header.sizeof + pixels + image_size + END_OF_STREAM.len;
char[] output = allocator::alloc_array(allocator, char, max_size); // no need to init
defer catch allocator::free(allocator, output);
// write header
*(Header*)output.ptr = {
.be_magic = bswap('qoif'),
.be_width = bswap(desc.width),
.be_height = bswap(desc.height),
.channels = desc.channels,
.colorspace = desc.colorspace
};
uint pos = Header.sizeof; // Current position in output
uint loc; // Current position in image (top-left corner)
uint loc_end = image_size - desc.channels; // End of image data
char run_length = 0; // Length of the current run
Pixel[64] palette; // Zero-initialized by default
Pixel prev = { 0, 0, 0, 255 };
Pixel p = { 0, 0, 0, 255 };
ichar[<3>] diff; // pre-allocate for diff
ichar[<3>] luma; // ...and luma
// write chunks
for (loc = 0; loc < image_size; loc += desc.channels)
{
// set previous pixel
prev = p;
// get current pixel
p[:3] = input[loc:3]; // cutesy slices :3
if (desc.channels == RGBA) p.a = input[loc + 3];
// check if we can run the previous pixel
if (prev == p)
{
run_length++;
if (run_length == 62 || loc == loc_end)
{
*@extract(OpRun, output, &pos) = { OP_RUN, run_length - 1 };
run_length = 0;
}
continue;
}
// end last run if there was one
if (run_length > 0)
{
*@extract(OpRun, output, &pos) = { OP_RUN, run_length - 1 };
run_length = 0;
}
switch
{
// check if we can index the palette
case (palette[p.hash()] == p):
*@extract(OpIndex, output, &pos) = {
OP_INDEX,
p.hash()
};
// check if we can use diff or luma
case (prev != p && prev.a == p.a):
// diff the pixels
diff = p.rgb - prev.rgb;
if (diff.r > -3 && diff.r < 2
&& diff.g > -3 && diff.g < 2
&& diff.b > -3 && diff.b < 2)
{
*@extract(OpDiff, output, &pos) = {
OP_DIFF,
(char)diff.r + 2,
(char)diff.g + 2,
(char)diff.b + 2
};
palette[p.hash()] = p;
break;
}
// check luma eligibility
luma = { diff.r - diff.g, diff.g, diff.b - diff.g };
if (luma.r >= -8 && luma.r <= 7
&& luma.g >= -32 && luma.g <= 31
&& luma.b >= -8 && luma.b <= 7)
{
*@extract(OpLuma, output, &pos) = {
OP_LUMA,
(char)luma.g + 32,
(char)luma.r + 8,
(char)luma.b + 8
};
palette[p.hash()] = p;
break;
}
nextcase;
// worst case scenario: just encode the raw pixel
default:
if (prev.a != p.a)
{
*@extract(OpRGBA, output, &pos) = { OP_RGBA, p.r, p.g, p.b, p.a };
}
else
{
*@extract(OpRGB, output, &pos) = { OP_RGB, p.r, p.g, p.b };
}
palette[p.hash()] = p;
}
}
// write end of stream
output[pos:END_OF_STREAM.len] = END_OF_STREAM[..];
pos += END_OF_STREAM.len;
return output[:pos];
}
<*
Decode a QOI image from memory.
If channels is set to QOIChannels.AUTO, the function will
automatically determine the channels from the file's header.
However, if channels is RGB or RGBA, the output format will be
forced into this number of channels.
The desc struct will be filled with the width, height,
channels and colorspace of the image.
The function returns an optional, which can either be a QOIError
or a char[] pointing to the decoded pixels on success.
The returned pixel data should be free()d after use, or the decoding
and use of the data should be wrapped in a @pool() { ... }; block.
@param [in] data : `The QOI image data to decode`
@param [&out] desc : `The descriptor to fill with the image's info`
@param channels : `The channels to be used`
@return? INVALID_DATA, TOO_MANY_PIXELS
*>
fn char[]? decode(Allocator allocator, char[] data, QOIDesc* desc, QOIChannels channels = AUTO) @nodiscard
{
// check input data
if (data.len < Header.sizeof + END_OF_STREAM.len) return INVALID_DATA~;
// get header
Header* header = (Header*)data.ptr;
// check magic bytes (FourCC)
if (bswap(header.be_magic) != 'qoif') return INVALID_DATA~;
// copy header data to desc
uint width = desc.width = bswap(header.be_width);
uint height = desc.height = bswap(header.be_height);
QOIChannels desc_channels = desc.channels = header.channels;
desc.colorspace = header.colorspace;
if (desc_channels == AUTO) return INVALID_DATA~; // Channels must be specified in the header
// check width and height
if (width == 0 || height == 0) return INVALID_DATA~;
// check pixel count
ulong pixels = (ulong)width * (ulong)height;
if (pixels > PIXELS_MAX) return TOO_MANY_PIXELS~;
uint pos = Header.sizeof; // Current position in data
uint loc; // Current position in image (top-left corner)
char run_length = 0; // Length of the current run
char tag; // Current chunk tag
Pixel[64] palette; // Zero-initialized by default
Pixel p = { 0, 0, 0, 255 };
if (channels == AUTO) channels = desc_channels;
// allocate memory for image data
usz image_size = (usz)pixels * channels;
char[] image = allocator::alloc_array(allocator, char, image_size);
defer catch allocator::free(allocator, image);
for (loc = 0; loc < image_size; loc += channels)
{
// get chunk tag
tag = data[pos];
// check for chunk type
switch
{
case run_length > 0:
run_length--;
case tag == OP_RGB:
OpRGB* op = @extract(OpRGB, data, &pos);
p = { op.red, op.green, op.blue, p.a };
palette[p.hash()] = p;
case tag == OP_RGBA:
OpRGBA* op = @extract(OpRGBA, data, &pos);
p = { op.red, op.green, op.blue, op.alpha };
palette[p.hash()] = p;
case tag >> 6 == OP_INDEX:
OpIndex* op = @extract(OpIndex, data, &pos);
p = palette[op.index];
case tag >> 6 == OP_DIFF:
OpDiff* op = @extract(OpDiff, data, &pos);
p.r += op.diff_red - 2;
p.g += op.diff_green - 2;
p.b += op.diff_blue - 2;
palette[p.hash()] = p;
case tag >> 6 == OP_LUMA:
OpLuma* op = @extract(OpLuma, data, &pos);
int diff_green = op.diff_green - 32;
p.r += (char)(op.diff_red_minus_green - 8 + diff_green);
p.g += (char)(diff_green);
p.b += (char)(op.diff_blue_minus_green - 8 + diff_green);
palette[p.hash()] = p;
case tag >> 6 == OP_RUN:
OpRun* op = @extract(OpRun, data, &pos);
run_length = op.run;
}
// draw the pixel
if (channels == RGBA) { image[loc:4] = p.rgba[..]; } else { image[loc:3] = p.rgb[..]; }
}
return image;
}
// ***************************************************************************
// *** ***
// *** Main functions are at the top to make the file more readable. ***
// *** From here on, helper functions and types are defined. ***
// *** ***
// ***************************************************************************
module std::compression::qoi @private;
// 8-bit opcodes
const OP_RGB = 0b11111110;
const OP_RGBA = 0b11111111;
// 2-bit opcodes
const OP_INDEX = 0b00;
const OP_DIFF = 0b01;
const OP_LUMA = 0b10;
const OP_RUN = 0b11;
struct Header @packed
{
<* magic bytes "qoif" *>
uint be_magic;
<* image width in pixels (BE) *>
uint be_width;
<* image height in pixels (BE) *>
uint be_height;
// informative fields
<* 3 = RGB, 4 = RGB *>
QOIChannels channels;
<* 0 = sRGB with linear alpha, 1 = all channels linear *>
QOIColorspace colorspace;
}
const char[*] END_OF_STREAM = {0, 0, 0, 0, 0, 0, 0, 1};
typedef Pixel = inline char[<4>];
macro char Pixel.hash(Pixel p)
{
return (p.r * 3 + p.g * 5 + p.b * 7 + p.a * 11) % 64;
}
struct OpRGB // No need to use @packed here, the alignment is 1 anyways.
{
char tag;
char red;
char green;
char blue;
}
struct OpRGBA @packed
{
char tag;
char red;
char green;
char blue;
char alpha;
}
bitstruct OpIndex : char
{
char tag : 6..7;
char index : 0..5;
}
bitstruct OpDiff : char
{
char tag : 6..7;
char diff_red : 4..5;
char diff_green : 2..3;
char diff_blue : 0..1;
}
bitstruct OpLuma : ushort @align(1)
{
char tag : 6..7;
char diff_green : 0..5;
char diff_red_minus_green : 12..15;
char diff_blue_minus_green : 8..11;
}
bitstruct OpRun : char
{
char tag : 6..7;
char run : 0..5;
}
// Macro used to locate chunks in data buffers.
// Can be used both for reading and writing.
macro @extract($Type, char[] data, uint* pos)
{
// slice data, then double cast
$Type* chunk = ($Type*)data[*pos : $Type.sizeof].ptr;
*pos += $Type.sizeof;
return chunk;
}