Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement QOI image format #10

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/alpha.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Ordering is pure ASCII (that is, all uppercase letters come before all lowercase
- [`PLUM_IMAGE_NONE` constant](constants.md#image-types)
- [`PLUM_IMAGE_PNG` constant](constants.md#image-types)
- [`PLUM_IMAGE_PNM` constant](constants.md#image-types)
- [`PLUM_IMAGE_QOI` constant](constants.md#image-types)
- [`PLUM_MAX_MEMORY_SIZE` constant](constants.md#special-loading-and-storing-modes)
- [`PLUM_METADATA_BACKGROUND` constant](constants.md#metadata-node-types)
- [`PLUM_METADATA_COLOR_DEPTH` constant](constants.md#metadata-node-types)
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Note: releases are listed from latest to oldest.

## Version 2.0 (in development)

- Added loading and generating QOI files
- **Break:** modified frame disposal values so that `PLUM_DISPOSAL_REPLACE` now applies to the frame declaring it, not
the previous frame (i.e., the frame declaring it replaces the previous one)
- **Break:** removed support for undocumented legacy constants `PLUM_FILENAME`, `PLUM_BUFFER` and `PLUM_CALLBACK` (now
Expand Down
1 change: 1 addition & 0 deletions docs/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ image used (when loading it) or will use (when storing it).
- `PLUM_IMAGE_JPEG`: JPEG (Joint Photographers Expert Group) file.
- `PLUM_IMAGE_PNM`: netpbm's PNM (Portable Anymap) format.
When loading, it represents any possible PNM file; however, only PPM and PAM files will be written.
- `PLUM_IMAGE_QOI`: QOI (Quite OK Image) file.

For more information, see the [Supported file formats][formats] page.

Expand Down
14 changes: 12 additions & 2 deletions docs/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All supported formats are documented here, along with their restrictions and som
- [APNG](#apng)
- [JPEG](#jpeg)
- [PNM](#pnm)
- [QOI](#qoi)

## Definitions

Expand Down Expand Up @@ -67,8 +68,8 @@ The maximum width and height for an image is `0x7fffffff`; larger dimensions wil
Images using [indexed-color mode][indexed] are fully supported, but they cannot contain transparency.
Therefore, if an image uses transparency, the file will be generated without a palette.

If an image has a [true bit depth](#definitions) of 8 or less and it doesn't use transparency, the usual RGB888 format
is used for output.
If an image has a [true bit depth](#definitions) of 8 or less and it doesn't use transparency, 8-bit RGB components
are used for output.
Otherwise, variable bit masks are used, with each component having the width determined by its true bit depth.
Variable bit width files are limited to a total of 32 bits per color, so if the sum of the true bit depths for all
components exceeds 32, they are proportionally reduced to fit.
Expand Down Expand Up @@ -341,6 +342,15 @@ All [animation-related metadata][animation] will be ignored when generating a PN

PNM files don't support palettes; [indexed-color mode images][indexed] will be converted when a PNM file is generated.

## QOI

The Quite OK Image (QOI) format version 1.0 is supported.

QOI files only support a single frame; attempting to generate a file with two or more frames will fail with
[`PLUM_ERR_NO_MULTI_FRAME`][errors].

QOI files don't support palettes; [indexed-color mode images][indexed] will be converted when a QOI file is generated.

* * *

Prev: [C++ helper methods](methods.md)
Expand Down
1 change: 1 addition & 0 deletions header/enum.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum plum_image_types {
PLUM_IMAGE_APNG,
PLUM_IMAGE_JPEG,
PLUM_IMAGE_PNM,
PLUM_IMAGE_QOI,
PLUM_NUM_IMAGE_TYPES
};

Expand Down
10 changes: 10 additions & 0 deletions src/inline.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ static inline void * append_output_node (struct context * context, size_t size)
return node -> data;
}

static inline void * resize_output_node (struct context * context, void * data, size_t size) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative I saw to "allocate one large-as-possible node and then shrink it after QOI compression is done" was "allocate lots of little nodes for every QOI chunk", and this seemed better.

struct data_node * node = (struct data_node *) ((char *) data - offsetof(struct data_node, data));
if (node -> size != size) {
node = ctxrealloc(context, node, sizeof *node + size);
node -> size = size;
if (node -> previous) node -> previous -> next = node;
}
return node -> data;
}

static inline bool bit_depth_less_than (uint32_t depth, uint32_t target) {
// formally "less than or equal to", but that would be a very long name
Copy link
Contributor Author

@Rangi42 Rangi42 Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"bit_depth_lte" would be shorter and not need a clarifying comment.

return !((target - depth) & 0x80808080u);
Expand Down
2 changes: 2 additions & 0 deletions src/load.c
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ void load_image_buffer_data (struct context * context, unsigned long flags, size
else if (bytematch(context -> data, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
// APNG files disguise as PNG files, so handle them all as PNG and split them later
load_PNG_data(context, flags, limit);
else if (bytematch(context -> data, 0x71, 0x6f, 0x69, 0x66))
load_QOI_data(context, flags, limit);
else if (*context -> data == 0x50 && context -> data[1] >= 0x31 && context -> data[1] <= 0x37)
load_PNM_data(context, flags, limit);
else if (bytematch(context -> data, 0xef, 0xbb, 0xbf, 0x50) && context -> data[4] >= 0x31 && context -> data[4] <= 0x37)
Expand Down
3 changes: 2 additions & 1 deletion src/misc.c
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ const char * plum_get_file_format_name (unsigned format) {
[PLUM_IMAGE_PNG] = "PNG",
[PLUM_IMAGE_APNG] = "APNG",
[PLUM_IMAGE_JPEG] = "JPEG",
[PLUM_IMAGE_PNM] = "PNM"
[PLUM_IMAGE_PNM] = "PNM",
[PLUM_IMAGE_QOI] = "QOI"
};
if (format >= PLUM_NUM_IMAGE_TYPES) format = PLUM_IMAGE_NONE;
return formats[format];
Expand Down
6 changes: 6 additions & 0 deletions src/proto.h
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ internal size_t write_PNM_number(unsigned char * restrict, uint32_t);
internal void generate_PNM_frame_data(struct context *, const uint64_t *, uint32_t, uint32_t, unsigned, bool);
internal void generate_PNM_frame_data_from_palette(struct context *, const uint8_t *, const uint64_t *, uint32_t, uint32_t, unsigned, bool);

// qoiread.c
internal void load_QOI_data(struct context *, unsigned, size_t);

// qoiwrite.c
internal void generate_QOI_data(struct context *);

// sort.c
internal void sort_values(uint64_t * restrict, uint64_t);
internal void quicksort_values(uint64_t * restrict, uint64_t);
Expand Down
59 changes: 59 additions & 0 deletions src/qoiread.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#include "proto.h"

#define pixelcolor(px) (((uint32_t) (px).a << 24) | ((uint32_t) (px).b << 16) | ((uint32_t) (px).g << 8) | (uint32_t) (px).r)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is basically PLUM_COLOR_VALUE_32, but (a) then I'd have to do PLUM_COLOR_VALUE_32(px.r, px.g, px.b, px.a), and (b) none of your src files actually use any of the convenience macros in header/color.h anyway.


void load_QOI_data (struct context * context, unsigned flags, size_t limit) {
Copy link
Contributor Author

@Rangi42 Rangi42 Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core QOI read/load/decode and write/generate/decode loops are simple enough that I'm not filling them with commentary; you have the spec and qoi.h for reference.

if (context -> size < 22) throw(context, PLUM_ERR_INVALID_FILE_FORMAT);
context -> image -> type = PLUM_IMAGE_QOI;
context -> image -> frames = 1;
context -> image -> width = read_be32_unaligned(context -> data + 4);
context -> image -> height = read_be32_unaligned(context -> data + 8);
validate_image_size(context, limit);
allocate_framebuffers(context, flags, false);
add_color_depth_metadata(context, 8, 8, 8, 8, 0);
size_t framesize = (size_t) context -> image -> width * context -> image -> height;
uint32_t * frame = ctxmalloc(context, sizeof *frame * framesize);
const unsigned char * data = context -> data + 14;
const unsigned char * dataend = context -> data + context -> size;
struct QOI_pixel lookup[64] = {0};
struct QOI_pixel px = {.r = 0, .g = 0, .b = 0, .a = 0xff};
for (size_t cell = 0; cell < framesize; cell ++) {
if (data + 1 < dataend) {
unsigned char v = *(data ++);
if (v == 0xfe && data + 3 < dataend) {
px.r = *(data ++);
px.g = *(data ++);
px.b = *(data ++);
} else if (v == 0xff && data + 4 < dataend) {
px.r = *(data ++);
px.g = *(data ++);
px.b = *(data ++);
px.a = *(data ++);
} else if (!(v & 0xc0) && v < sizeof lookup / sizeof *lookup)
px = lookup[v];
else if ((v & 0xc0) == 0x40) {
px.r += ((v >> 4) & 3) - 2;
px.g += ((v >> 2) & 3) - 2;
px.b += (v & 3) - 2;
} else if ((v & 0xc0) == 0x80 && data + 1 < dataend) {
unsigned char v2 = *(data ++);
int8_t dg = (v & 0x3f) - 32;
px.r += dg + ((v2 >> 4) & 0xf) - 8;
px.g += dg;
px.b += dg + (v2 & 0xf) - 8;
} else if ((v & 0xc0) == 0xc0) {
uint8_t run = v & 0x3f;
if (cell + run >= framesize) throw(context, PLUM_ERR_INVALID_FILE_FORMAT);
uint32_t color = pixelcolor(px);
while (run --) frame[cell ++] = color;
} else
throw(context, PLUM_ERR_INVALID_FILE_FORMAT);
lookup[(px.r * 3 + px.g * 5 + px.b * 7 + px.a * 11) % (sizeof lookup / sizeof *lookup)] = px;
}
frame[cell] = pixelcolor(px);
}
plum_convert_colors(context -> image -> data8, frame, framesize, flags, PLUM_COLOR_32 | PLUM_ALPHA_INVERT);
ctxfree(context, frame);
}

#undef pixelcolor
65 changes: 65 additions & 0 deletions src/qoiwrite.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#include "proto.h"

#define equalpixels(p1, p2) ((p1).r == (p2).r && (p1).g == (p2).g && (p1).b == (p2).b && (p1).a == (p2).a)

void generate_QOI_data (struct context * context) {
if (context -> source -> frames > 1) throw(context, PLUM_ERR_NO_MULTI_FRAME);
if (!(context -> source -> width && context -> source -> height)) throw(context, PLUM_ERR_IMAGE_TOO_LARGE);
unsigned char * header = append_output_node(context, 14);
bytewrite(header, 0x71, 0x6f, 0x69, 0x66);
write_be32_unaligned(header + 4, context -> source -> width);
write_be32_unaligned(header + 8, context -> source -> height);
uint8_t channels = 3 + image_has_transparency(context -> source);
bytewrite(header + 12, channels, 0);
size_t framesize = (size_t) context -> source -> width * context -> source -> height;
uint32_t * data;
if ((context -> source -> color_format & (PLUM_COLOR_MASK | PLUM_ALPHA_INVERT)) == (PLUM_COLOR_32 | PLUM_ALPHA_INVERT))
data = context -> source -> data;
else {
data = ctxmalloc(context, sizeof *data * framesize);
plum_convert_colors(data, context -> source -> data, framesize, PLUM_COLOR_32 | PLUM_ALPHA_INVERT, context -> source -> color_format);
}
unsigned char * node = append_output_node(context, framesize * (channels + 1) + 8);
unsigned char * output = node;
struct QOI_pixel lookup[64] = {0};
struct QOI_pixel prev = {.r = 0, .g = 0, .b = 0, .a = 0xff};
uint8_t run = 0;
for (size_t cell = 0; cell < framesize; cell ++) {
struct QOI_pixel px = {.r = data[cell], .g = data[cell] >> 8, .b = data[cell] >> 16, .a = data[cell] >> 24};
Comment on lines +27 to +28
Copy link
Contributor Author

@Rangi42 Rangi42 Dec 9, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other possibilities which would use an extra line:

  uint32_t * dataend = data + framesize;
  for (uint32_t * val = data; val < data; val ++) {
    struct QOI_pixel px = {.r = *val, .g = *val >> 8, .b = *val >> 16, .a = *val >> 24};
...
      if (run == 62 || val == dataend - 1) {
  for (size_t cell = 0; cell < framesize; cell ++) {
    uint32_t val = data[cell];
    struct QOI_pixel px = {.r = val, .g = val >> 8, .b = val >> 16, .a = val >> 24};
...
      if (run == 62 || cell == framesize - 1) {

if (equalpixels(px, prev)) {
run ++;
if (run == 62 || cell == framesize - 1) {
*(output ++) = 0xc0 | (run - 1);
run = 0;
}
} else {
if (run) {
*(output ++) = 0xc0 | (run - 1);
run = 0;
}
uint8_t index = (px.r * 3 + px.g * 5 + px.b * 7 + px.a * 11) % (sizeof lookup / sizeof *lookup);
if (equalpixels(px, lookup[index]))
*(output ++) = index;
else {
lookup[index] = px;
if (px.a == prev.a) {
int8_t dr = px.r - prev.r, dg = px.g - prev.g, db = px.b - prev.b;
int8_t drg = dr - dg, dbg = db - dg;
if (dr >= -2 && dr < 2 && dg >= -2 && dg < 2 && db >= -2 && db < 2)
*(output ++) = 0x40 | ((dr + 2) << 4) | ((dg + 2) << 2) | (db + 2);
else if (drg >= -8 && drg < 8 && dg >= -32 && dg < 32 && dbg >= -8 && dbg < 8)
output += byteappend(output, 0x80 | (dg + 32), ((drg + 8) << 4) | (dbg + 8));
else
output += byteappend(output, 0xfe, px.r, px.g, px.b);
} else
output += byteappend(output, 0xff, px.r, px.g, px.b, px.a);
}
prev = px;
}
}
output += byteappend(output, 0, 0, 0, 0, 0, 0, 0, 1);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered using a memset for the 0s and then *output = 1;, but it would be more lines.

resize_output_node(context, node, output - node);
if (data != context -> source -> data) ctxfree(context, data);
}

#undef equalpixels
1 change: 1 addition & 0 deletions src/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ size_t plum_store_image (const struct plum_image * image, void * restrict buffer
case PLUM_IMAGE_APNG: generate_APNG_data(context); break;
case PLUM_IMAGE_JPEG: generate_JPEG_data(context); break;
case PLUM_IMAGE_PNM: generate_PNM_data(context); break;
case PLUM_IMAGE_QOI: generate_QOI_data(context); break;
default: throw(context, PLUM_ERR_INVALID_FILE_FORMAT);
}
size_t output_size = get_total_output_size(context);
Expand Down
7 changes: 7 additions & 0 deletions src/struct.h
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,10 @@ struct PNM_image_header {
size_t datastart;
size_t datalength;
};

struct QOI_pixel {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did consider using four local uint8_t variables, or one packed uint32_t, but the overall complexity and line count would be higher, as values need to be packed into a lookup table and then unpacked for per-channel manipulation.

uint8_t r;
uint8_t g;
uint8_t b;
uint8_t a;
};