Files
Odin/core/image/qoi/qoi.odin
gingerBill 842cfee0f3 Change Odin's LICENSE to zlib from BSD 3-clause
This change was made in order to allow things produced with Odin and using Odin's core library, to not require the LICENSE to also be distributed alongside the binary form.
2025-10-28 14:38:25 +00:00

376 lines
8.8 KiB
Odin

// Reader and writer for `QOI` images.
//
// The QOI specification is at [[ https://qoiformat.org ]].
package qoi
/*
Copyright 2022 Jeroen van Rijn <nom@duclavier.com>.
Made available under Odin's license.
List of contributors:
Jeroen van Rijn: Initial implementation.
*/
import "core:image"
import "core:compress"
import "core:bytes"
Error :: image.Error
Image :: image.Image
Options :: image.Options
RGB_Pixel :: image.RGB_Pixel
RGBA_Pixel :: image.RGBA_Pixel
save_to_buffer :: proc(output: ^bytes.Buffer, img: ^Image, options := Options{}, allocator := context.allocator) -> (err: Error) {
context.allocator = allocator
if img == nil {
return .Invalid_Input_Image
}
if output == nil {
return .Invalid_Output
}
pixels := img.width * img.height
if pixels == 0 || pixels > image.MAX_DIMENSIONS {
return .Invalid_Input_Image
}
// QOI supports only 8-bit images with 3 or 4 channels.
if img.depth != 8 || img.channels < 3 || img.channels > 4 {
return .Invalid_Input_Image
}
if img.channels * pixels != len(img.pixels.buf) {
return .Invalid_Input_Image
}
written := 0
// Calculate and allocate maximum size. We'll reclaim space to actually written output at the end.
max_size := pixels * (img.channels + 1) + size_of(image.QOI_Header) + size_of(u64be)
if resize(&output.buf, max_size) != nil {
return .Unable_To_Allocate_Or_Resize
}
header := image.QOI_Header{
magic = image.QOI_Magic,
width = u32be(img.width),
height = u32be(img.height),
channels = u8(img.channels),
color_space = .Linear if .qoi_all_channels_linear in options else .sRGB,
}
header_bytes := transmute([size_of(image.QOI_Header)]u8)header
copy(output.buf[written:], header_bytes[:])
written += size_of(image.QOI_Header)
/*
Encode loop starts here.
*/
seen: [64]RGBA_Pixel
pix := RGBA_Pixel{0, 0, 0, 255}
prev := pix
input := img.pixels.buf[:]
run := u8(0)
for len(input) > 0 {
if img.channels == 4 {
pix = (^RGBA_Pixel)(raw_data(input))^
} else {
pix.rgb = (^RGB_Pixel)(raw_data(input))^
}
input = input[img.channels:]
if pix == prev {
run += 1
// As long as the pixel matches the last one, accumulate the run total.
// If we reach the max run length or the end of the image, write the run.
if run == 62 || len(input) == 0 {
// Encode and write run
output.buf[written] = u8(QOI_Opcode_Tag.RUN) | (run - 1)
written += 1
run = 0
}
} else {
if run > 0 {
// The pixel differs from the previous one, but we still need to write the pending run.
// Encode and write run
output.buf[written] = u8(QOI_Opcode_Tag.RUN) | (run - 1)
written += 1
run = 0
}
index := qoi_hash(pix)
if seen[index] == pix {
// Write indexed pixel
output.buf[written] = u8(QOI_Opcode_Tag.INDEX) | index
written += 1
} else {
// Add pixel to index
seen[index] = pix
// If the alpha matches the previous pixel's alpha, we don't need to write a full RGBA literal.
if pix.a == prev.a {
// Delta
d := pix.rgb - prev.rgb
// DIFF, biased and modulo 256
_d := d + 2
// LUMA, biased and modulo 256
_l := RGB_Pixel{ d.r - d.g + 8, d.g + 32, d.b - d.g + 8 }
if _d.r < 4 && _d.g < 4 && _d.b < 4 {
// Delta is between -2 and 1 inclusive
output.buf[written] = u8(QOI_Opcode_Tag.DIFF) | _d.r << 4 | _d.g << 2 | _d.b
written += 1
} else if _l.r < 16 && _l.g < 64 && _l.b < 16 {
// Biased luma is between {-8..7, -32..31, -8..7}
output.buf[written ] = u8(QOI_Opcode_Tag.LUMA) | _l.g
output.buf[written + 1] = _l.r << 4 | _l.b
written += 2
} else {
// Write RGB literal
output.buf[written] = u8(QOI_Opcode_Tag.RGB)
copy(output.buf[written + 1:], pix[:3])
written += 4
}
} else {
// Write RGBA literal
output.buf[written] = u8(QOI_Opcode_Tag.RGBA)
copy(output.buf[written + 1:], pix[:])
written += 5
}
}
}
prev = pix
}
trailer := []u8{0, 0, 0, 0, 0, 0, 0, 1}
copy(output.buf[written:], trailer[:])
written += len(trailer)
resize(&output.buf, written)
return nil
}
load_from_bytes :: proc(data: []byte, options := Options{}, allocator := context.allocator) -> (img: ^Image, err: Error) {
ctx := &compress.Context_Memory_Input{
input_data = data,
}
img, err = load_from_context(ctx, options, allocator)
return img, err
}
@(optimization_mode="favor_size")
load_from_context :: proc(ctx: ^$C, options := Options{}, allocator := context.allocator) -> (img: ^Image, err: Error) {
context.allocator = allocator
options := options
if .info in options {
options += {.return_metadata, .do_not_decompress_image}
options -= {.info}
}
if .return_header in options && .return_metadata in options {
options -= {.return_header}
}
header := image.read_data(ctx, image.QOI_Header) or_return
if header.magic != image.QOI_Magic {
return img, .Invalid_Signature
}
if img == nil {
img = new(Image)
}
img.which = .QOI
if .return_metadata in options {
info := new(image.QOI_Info)
info.header = header
img.metadata = info
}
if header.channels != 3 && header.channels != 4 {
return img, .Invalid_Number_Of_Channels
}
if header.color_space != .sRGB && header.color_space != .Linear {
return img, .Invalid_Color_Space
}
if header.width == 0 || header.height == 0 {
return img, .Invalid_Image_Dimensions
}
total_pixels := header.width * header.height
if total_pixels > image.MAX_DIMENSIONS {
return img, .Image_Dimensions_Too_Large
}
img.width = int(header.width)
img.height = int(header.height)
img.channels = 4 if .alpha_add_if_missing in options else int(header.channels)
img.depth = 8
if .do_not_decompress_image in options {
img.channels = int(header.channels)
return
}
bytes_needed := image.compute_buffer_size(int(header.width), int(header.height), img.channels, 8)
if resize(&img.pixels.buf, bytes_needed) != nil {
return img, .Unable_To_Allocate_Or_Resize
}
/*
Decode loop starts here.
*/
seen: [64]RGBA_Pixel
pix := RGBA_Pixel{0, 0, 0, 255}
pixels := img.pixels.buf[:]
decode: for len(pixels) > 0 {
data := image.read_u8(ctx) or_return
tag := QOI_Opcode_Tag(data)
#partial switch tag {
case .RGB:
pix.rgb = image.read_data(ctx, RGB_Pixel) or_return
#no_bounds_check {
seen[qoi_hash(pix)] = pix
}
case .RGBA:
pix = image.read_data(ctx, RGBA_Pixel) or_return
#no_bounds_check {
seen[qoi_hash(pix)] = pix
}
case:
// 2-bit tag
tag = QOI_Opcode_Tag(data & QOI_Opcode_Mask)
#partial switch tag {
case .INDEX:
pix = seen[data & 63]
case .DIFF:
diff_r := ((data >> 4) & 3) - 2
diff_g := ((data >> 2) & 3) - 2
diff_b := ((data >> 0) & 3) - 2
pix += {diff_r, diff_g, diff_b, 0}
#no_bounds_check {
seen[qoi_hash(pix)] = pix
}
case .LUMA:
data2 := image.read_u8(ctx) or_return
diff_g := (data & 63) - 32
diff_r := diff_g - 8 + ((data2 >> 4) & 15)
diff_b := diff_g - 8 + (data2 & 15)
pix += {diff_r, diff_g, diff_b, 0}
#no_bounds_check {
seen[qoi_hash(pix)] = pix
}
case .RUN:
if length := int(data & 63) + 1; (length * img.channels) > len(pixels) {
return img, .Corrupt
} else {
#no_bounds_check for _ in 0..<length {
copy(pixels, pix[:img.channels])
pixels = pixels[img.channels:]
}
}
continue decode
case:
unreachable()
}
}
#no_bounds_check {
copy(pixels, pix[:img.channels])
pixels = pixels[img.channels:]
}
}
// The byte stream's end is marked with 7 0x00 bytes followed by a single 0x01 byte.
trailer, trailer_err := compress.read_data(ctx, u64be)
if trailer_err != nil || trailer != 0x1 {
return img, .Missing_Or_Corrupt_Trailer
}
if .alpha_premultiply in options && !image.alpha_drop_if_present(img, options) {
return img, .Post_Processing_Error
}
return
}
/*
Cleanup of image-specific data.
*/
destroy :: proc(img: ^Image) {
if img == nil {
/*
Nothing to do.
Load must've returned with an error.
*/
return
}
bytes.buffer_destroy(&img.pixels)
if v, ok := img.metadata.(^image.QOI_Info); ok {
free(v)
}
free(img)
}
QOI_Opcode_Tag :: enum u8 {
// 2-bit tags
INDEX = 0b0000_0000, // 6-bit index into color array follows
DIFF = 0b0100_0000, // 3x (RGB) 2-bit difference follows (-2..1), bias of 2.
LUMA = 0b1000_0000, // Luma difference
RUN = 0b1100_0000, // Run length encoding, bias -1
// 8-bit tags
RGB = 0b1111_1110, // Raw RGB pixel follows
RGBA = 0b1111_1111, // Raw RGBA pixel follows
}
QOI_Opcode_Mask :: 0b1100_0000
QOI_Data_Mask :: 0b0011_1111
qoi_hash :: #force_inline proc(pixel: RGBA_Pixel) -> (index: u8) {
i1 := u16(pixel.r) * 3
i2 := u16(pixel.g) * 5
i3 := u16(pixel.b) * 7
i4 := u16(pixel.a) * 11
return u8((i1 + i2 + i3 + i4) & 63)
}
@(init, private)
_register :: proc "contextless" () {
image.register(.QOI, load_from_bytes, destroy)
}