diff --git a/src/pixie.nim b/src/pixie.nim index a9fac34..7afede4 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -1,13 +1,14 @@ import bumpy, chroma, flatty/binny, os, pixie/common, pixie/contexts, pixie/fileformats/bmp, pixie/fileformats/gif, pixie/fileformats/jpg, - pixie/fileformats/png, pixie/fileformats/svg, pixie/fonts, pixie/images, - pixie/masks, pixie/paints, pixie/paths, strutils, vmath + pixie/fileformats/png, pixie/fileformats/qoi, pixie/fileformats/svg, + pixie/fonts, pixie/images, pixie/masks, pixie/paints, pixie/paths, + strutils, vmath export bumpy, chroma, common, contexts, fonts, images, masks, paints, paths, vmath type FileFormat* = enum - ffPng, ffBmp, ffJpg, ffGif + ffPng, ffBmp, ffJpg, ffGif, ffQoi converter autoStraightAlpha*(c: ColorRGBX): ColorRGBA {.inline, raises: [].} = ## Convert a premultiplied alpha RGBA to a straight alpha RGBA. @@ -30,6 +31,8 @@ proc decodeImage*(data: string | seq[uint8]): Image {.raises: [PixieError].} = decodeSvg(data) elif data.len > 6 and data.readStr(0, 6) in gifSignatures: decodeGif(data) + elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature: + decodeQoi(data) else: raise newException(PixieError, "Unsupported image file format") @@ -63,6 +66,8 @@ proc encodeImage*(image: Image, fileFormat: FileFormat): string {.raises: [Pixie image.encodeJpg() of ffBmp: image.encodeBmp() + of ffQoi: + image.encodeQoi() of ffGif: raise newException(PixieError, "Unsupported file format") @@ -80,6 +85,7 @@ proc writeFile*(image: Image, filePath: string) {.raises: [PixieError].} = of ".png": ffPng of ".bmp": ffBmp of ".jpg", ".jpeg": ffJpg + of ".qoi": ffQoi else: raise newException(PixieError, "Unsupported file extension") @@ -94,6 +100,7 @@ proc writeFile*(mask: Mask, filePath: string) {.raises: [PixieError].} = of ".png": ffPng of ".bmp": ffBmp of ".jpg", ".jpeg": ffJpg + of ".qoi": ffQoi else: raise newException(PixieError, "Unsupported file extension") diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim new file mode 100644 index 0000000..12a6498 --- /dev/null +++ b/src/pixie/fileformats/qoi.nim @@ -0,0 +1,209 @@ +import std/endians, chroma, flatty/binny +import pixie/[common, images, internal] + +# See: https://qoiformat.org/qoi-specification.pdf + +const + qoiSignature* = "qoif" + indexLen = 64 + opRgb = 0b11111110'u8 + opRgba = 0b11111111'u8 + opMask2 = 0b11000000'u8 + opIndex = 0b00000000'u8 + opDiff = 0b01000000'u8 + opLuma = 0b10000000'u8 + opRun = 0b11000000'u8 + +type + Colorspace* = enum sRBG = 0, linear = 1 + Qoi* = ref object + ## Raw QOI image data. + data*: seq[ColorRGBA] + width*, height*, channels*: int + colorspace*: Colorspace + + Index = array[indexLen, ColorRGBA] + +func hash(p: ColorRGBA): int = + (p.r.int * 3 + p.g.int * 5 + p.b.int * 7 + p.a.int * 11) mod indexLen + +func toImage*(qoi: Qoi): Image = + ## Converts raw QOI data to `Image`. + result = newImage(qoi.width, qoi.height) + copyMem(result.data[0].addr, qoi.data[0].addr, qoi.data.len * 4) + result.data.toPremultipliedAlpha() + +func toQoi*(img: Image; channels: range[3..4]): Qoi = + ## Converts an `Image` to raw QOI data. + result = Qoi( + data: newSeq[ColorRGBA](img.data.len), + width: img.width, + height: img.height, + channels: channels) + result.data.toStraightAlpha() + +proc decompressQoi*(data: string): Qoi {.raises: [PixieError].} = + ## Decompress QOI file format data. + if data.len <= 14 or data[0 .. 3] != qoiSignature: + raise newException(PixieError, "Invalid QOI header") + var + width, height: uint32 + channels, colorspace: uint8 + block: + when cpuEndian == bigEndian: + width = data.readUint32(4) + height = data.readUint32(8) + else: + var (wBe, hBe) = (data.readUint32(4), data.readUint32(8)) + swapEndian32(addr width, addr wBe) + swapEndian32(addr height, addr hBe) + channels = data.readUint8(12) + colorspace = data.readUint8(13) + if channels notin {3, 4} or colorspace notin {0, 1}: + raise newException(PixieError, "Invalid QOI header") + if width.int * height.int > uint32.high.int: + raise newException(PixieError, "QOI is too large to decode") + + result = Qoi( + data: newSeq[ColorRGBA](int width * height), + width: int width, + height: int height, + channels: int channels, + colorspace: Colorspace colorspace) + + var + index: Index + p = 14 + run: uint8 + px = rgba(0, 0, 0, 0xff) + + for dst in result.data.mitems: + if p > data.len-8: + raise newException(PixieError, "Underrun of QOI decoder") + if run > 0: + dec(run) + else: + let b0 = data.readUint8(p) + inc(p) + case b0 + of opRgb: + px.r = data.readUint8(p+0) + px.g = data.readUint8(p+1) + px.b = data.readUint8(p+2) + inc(p, 3) + of opRgba: + px.r = data.readUint8(p+0) + px.g = data.readUint8(p+1) + px.b = data.readUint8(p+2) + px.a = data.readUint8(p+3) + inc(p, 4) + else: + case b0 and opMask2 + of opIndex: + px = index[b0] + of opDiff: + px.r = px.r + uint8((b0 shr 4) and 0x03) - 2 + px.g = px.g + uint8((b0 shr 2) and 0x03) - 2 + px.b = px.b + uint8((b0 shr 0) and 0x03) - 2 + of opLuma: + let b1 = data.readUint8(p) + inc(p) + let vg = (b0.uint8 and 0x3f) - 32 + px.r = px.r + vg - 8 + ((b1 shr 4) and 0x0f) + px.g = px.g + vg + px.b = px.b + vg - 8 + ((b1 shr 0) and 0x0f) + of opRun: + run = b0 and 0x3f + else: assert false + index[hash(px)] = px + dst = px + while p < data.len: + case data[p] + of '\0': discard + of '\1': break # ignore trailing data + else: + raise newException(PixieError, "Invalid QOI padding") + inc(p) + +proc decodeQoi*(data: string): Image {.raises: [PixieError].} = + ## Decodes data in the QOI file format to an `Image`. + decompressQoi(data).toImage() + +proc decodeQoi*(data: seq[uint8]): Image {.inline, raises: [PixieError].} = + ## Decodes data in the QOI file format to an `Image`. + decodeQoi(cast[string](data)) + +proc compressQoi*(qoi: Qoi): string = + ## Encodes raw QOI pixels to the QOI file format. + result = newStringOfCap(14 + 8 + qoi.data.len * 3) + # allocate a buffer 3/4 the size of the pathological encoding + result.add(qoiSignature) + when cpuEndian == bigEndian: + result.addUint32(uint32 qoi.width) + result.addUint32(uint32 qoi.height) + else: + var + (wLe, hLe) = (uint32 qoi.width, uint32 qoi.height) + result.setLen(12) + swapEndian32(addr result[4], addr wLe) + swapEndian32(addr result[8], addr hLe) + result.addUint8(uint8 qoi.channels) + result.addUint8(uint8 qoi.colorspace) + + var + index: Index + run: uint8 + pxPrev = rgba(0, 0, 0, 0xff) + + for off, px in qoi.data: + if px == pxPrev: + inc run + if run == 62 or off == qoi.data.high: + result.addUint8(opRun or pred(run)) + reset run + else: + if run > 0: + result.addUint8(opRun or pred(run)) + reset run + let i = hash(px) + if index[i] == px: result.addUint8(opIndex or uint8(i)) + else: + index[i] = px + if px.a == pxPrev.a: + let + vr = px.r.int - pxPrev.r.int + vg = px.g.int - pxPrev.g.int + vb = px.b.int - pxPrev.b.int + vgr = vr - vg + vgb = vb - vg + if (vr > -3) and (vr < 2) and + (vg > -3) and (vg < 2) and + (vb > -3) and (vb < 2): + let b = opDiff or uint8( + ((vr + 2) shl 4) or + ((vg + 2) shl 2) or + ((vb + 2) shl 0)) + result.addUint8(b) + elif vgr > -9 and vgr < 8 and + vg > -33 and vg < 32 and + vgb > -9 and vgb < 8: + result.addUint8(opLuma or uint8(vg + 32)) + result.addUint8(uint8 ((vgr + 8) shl 4) or (vgb + 8)) + else: + result.addUint8(opRgb) + result.addUint8(px.r) + result.addUint8(px.g) + result.addUint8(px.b) + else: + result.addUint8(opRgba) + result.addUint8(px.r) + result.addUint8(px.g) + result.addUint8(px.b) + result.addUint8(px.a) + pxPrev = px + for _ in 0..6: result.addUint8(0x00) + result.addUint8(0x01) + +proc encodeQoi*(img: Image): string {.raises: [].} = + ## Encodes an image to the QOI file format. + compressQoi(toQoi(img, 4)) diff --git a/tests/all.nim b/tests/all.nim index e0c13a0..69850f8 100644 --- a/tests/all.nim +++ b/tests/all.nim @@ -10,6 +10,7 @@ import test_paints, test_paths, test_png, + test_qoi, test_svg, ../examples/text, ../examples/text_spans, diff --git a/tests/benchmark_qoi.nim b/tests/benchmark_qoi.nim new file mode 100644 index 0000000..616f317 --- /dev/null +++ b/tests/benchmark_qoi.nim @@ -0,0 +1,6 @@ +import benchy, pixie/fileformats/qoi + +let data = readFile("tests/fileformats/qoi/testcard_rgba.qoi") + +timeIt "pixie decode": + keep decodeQOI(data) diff --git a/tests/fileformats/qoi/testcard.png b/tests/fileformats/qoi/testcard.png new file mode 100644 index 0000000..8b0cf5f Binary files /dev/null and b/tests/fileformats/qoi/testcard.png differ diff --git a/tests/fileformats/qoi/testcard.qoi b/tests/fileformats/qoi/testcard.qoi new file mode 100644 index 0000000..d6fccc9 Binary files /dev/null and b/tests/fileformats/qoi/testcard.qoi differ diff --git a/tests/fileformats/qoi/testcard_rgba.png b/tests/fileformats/qoi/testcard_rgba.png new file mode 100644 index 0000000..2454247 Binary files /dev/null and b/tests/fileformats/qoi/testcard_rgba.png differ diff --git a/tests/fileformats/qoi/testcard_rgba.qoi b/tests/fileformats/qoi/testcard_rgba.qoi new file mode 100644 index 0000000..997aed4 Binary files /dev/null and b/tests/fileformats/qoi/testcard_rgba.qoi differ diff --git a/tests/fuzz_qoi.nim b/tests/fuzz_qoi.nim new file mode 100644 index 0000000..9149822 --- /dev/null +++ b/tests/fuzz_qoi.nim @@ -0,0 +1,25 @@ +import std/[random, strformat] +import pixie/[common, fileformats/qoi] + +randomize() + +let original = readFile("tests/fileformats/qoi/testcard_rgba.qoi") + +for i in 0 ..< 10_000: + var data = original + let + pos = rand(data.len) + value = rand(255).char + data[pos] = value + try: + let img = decodeQOI(data) + doAssert img.height > 0 and img.width > 0 + except PixieError: + discard + + data = data[0 ..< pos] + try: + let img = decodeQOI(data) + doAssert img.height > 0 and img.width > 0 + except PixieError: + discard diff --git a/tests/test_qoi.nim b/tests/test_qoi.nim new file mode 100644 index 0000000..90ab472 --- /dev/null +++ b/tests/test_qoi.nim @@ -0,0 +1,15 @@ +import pixie, pixie/fileformats/qoi, pixie/fileformats/png + +const tests = ["testcard", "testcard_rgba"] + +for name in tests: + var input = decodeQoi(readFile("tests/fileformats/qoi/" & name & ".qoi")) + var control = decodePng(readFile("tests/fileformats/qoi/" & name & ".png")) + doAssert(input.data == control.data, "input mismatch of " & name) + +for name in tests: + var + input: Qoi = decompressQoi(readFile("tests/fileformats/qoi/" & name & ".qoi")) + output: Qoi = decompressQoi(compressQoi(input)) + doAssert(output.data.len == input.data.len) + doAssert(output.data == input.data)