Skip to content
This repository has been archived by the owner on Jul 8, 2022. It is now read-only.

Commit

Permalink
[KorIM] Added QOI Image Format (#535)
Browse files Browse the repository at this point in the history
  • Loading branch information
soywiz committed Mar 25, 2022
1 parent f434fb5 commit 532e8c7
Show file tree
Hide file tree
Showing 13 changed files with 302 additions and 24 deletions.
22 changes: 17 additions & 5 deletions kmem/src/commonMain/kotlin/com/soywiz/kmem/Bits.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,27 @@ public fun Long.mask(): Long = (1L shl this.toInt()) - 1L
/** Extracts [count] bits at [offset] from [this] [Int] */
public fun Int.extract(offset: Int, count: Int): Int = (this ushr offset) and count.mask()
/** Extracts a bits at [offset] from [this] [Int] (returning a [Boolean]) */
public fun Int.extract(offset: Int): Boolean = ((this ushr offset) and 1) != 0
inline fun Int.extract(offset: Int): Boolean = extract1(offset) != 0
/** Extracts a bits at [offset] from [this] [Int] (returning a [Boolean]) */
public fun Int.extractBool(offset: Int): Boolean = this.extract(offset)
inline fun Int.extractBool(offset: Int): Boolean = extract1(offset) != 0
/** Extracts 1 bit at [offset] from [this] [Int] */
inline fun Int.extract1(offset: Int): Int = (this ushr offset) and 0b1
/** Extracts 2 bits at [offset] from [this] [Int] */
inline fun Int.extract2(offset: Int): Int = (this ushr offset) and 0b11
/** Extracts 3 bits at [offset] from [this] [Int] */
inline fun Int.extract3(offset: Int): Int = (this ushr offset) and 0b111
/** Extracts 4 bits at [offset] from [this] [Int] */
public fun Int.extract4(offset: Int): Int = (this ushr offset) and 0xF
inline fun Int.extract4(offset: Int): Int = (this ushr offset) and 0b1111
/** Extracts 5 bits at [offset] from [this] [Int] */
inline fun Int.extract5(offset: Int): Int = (this ushr offset) and 0b11111
/** Extracts 6 bits at [offset] from [this] [Int] */
inline fun Int.extract6(offset: Int): Int = (this ushr offset) and 0b111111
/** Extracts 7 bits at [offset] from [this] [Int] */
inline fun Int.extract7(offset: Int): Int = (this ushr offset) and 0b1111111
/** Extracts 8 bits at [offset] from [this] [Int] */
public fun Int.extract8(offset: Int): Int = (this ushr offset) and 0xFF
inline fun Int.extract8(offset: Int): Int = (this ushr offset) and 0xFF
/** Extracts 16 bits at [offset] from [this] [Int] */
public fun Int.extract16(offset: Int): Int = (this ushr offset) and 0xFFFF
inline fun Int.extract16(offset: Int): Int = (this ushr offset) and 0xFFFF

/** Extracts [count] bits at [offset] from [this] [Int] sign-extending its result */
public fun Int.extractSigned(offset: Int, count: Int): Int = ((this ushr offset) and count.mask()).signExtend(count)
Expand Down
46 changes: 29 additions & 17 deletions korim/src/commonMain/kotlin/com/soywiz/korim/color/RGBA.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ inline class RGBA(val value: Int) : Comparable<RGBA>, Interpolable<RGBA>, Paint
override fun transformed(m: Matrix): Paint = this
val color: RGBA get() = this

val r: Int get() = (value ushr 0) and 0xFF
val g: Int get() = (value ushr 8) and 0xFF
val b: Int get() = (value ushr 16) and 0xFF
val a: Int get() = (value ushr 24) and 0xFF
val r: Int get() = value.extract8(RED_OFFSET)
val g: Int get() = value.extract8(GREEN_OFFSET)
val b: Int get() = value.extract8(BLUE_OFFSET)
val a: Int get() = value.extract8(ALPHA_OFFSET)

val rf: Float get() = r.toFloat() / 255f
val gf: Float get() = g.toFloat() / 255f
Expand All @@ -42,21 +42,27 @@ inline class RGBA(val value: Int) : Comparable<RGBA>, Interpolable<RGBA>, Paint
out[index + 3] = af
}

fun withR(v: Int) = RGBA((value and (0xFF shl 0).inv()) or (v.clamp0_255() shl 0))
fun withG(v: Int) = RGBA((value and (0xFF shl 8).inv()) or (v.clamp0_255() shl 8))
fun withB(v: Int) = RGBA((value and (0xFF shl 16).inv()) or (v.clamp0_255() shl 16))
fun withA(v: Int) = RGBA((value and (0xFF shl 24).inv()) or (v.clamp0_255() shl 24))
fun withRGB(rgb: Int) = RGBA(rgb, a)
fun withR(v: Int): RGBA = RGBA((value and (0xFF shl 0).inv()) or (v.clamp0_255() shl RED_OFFSET))
fun withG(v: Int): RGBA = RGBA((value and (0xFF shl 8).inv()) or (v.clamp0_255() shl GREEN_OFFSET))
fun withB(v: Int): RGBA = RGBA((value and (0xFF shl 16).inv()) or (v.clamp0_255() shl BLUE_OFFSET))
fun withA(v: Int): RGBA = RGBA((value and (0xFF shl 24).inv()) or (v.clamp0_255() shl ALPHA_OFFSET))
//fun withRGB(r: Int, g: Int, b: Int) = withR(r).withG(g).withB(b)
fun withRGB(r: Int, g: Int, b: Int): RGBA =
RGBA((value and 0x00FFFFFF.inv()) or (r.clamp0_255() shl RED_OFFSET) or (g.clamp0_255() shl GREEN_OFFSET) or (b.clamp0_255() shl BLUE_OFFSET))
fun withRGB(rgb: Int): RGBA = RGBA(rgb, a)

fun withRGBUnclamped(r: Int, g: Int, b: Int): RGBA =
RGBA((value and 0x00FFFFFF.inv()) or ((r and 0xFF) shl RED_OFFSET) or ((g and 0xFF) shl GREEN_OFFSET) or ((b and 0xFF) shl BLUE_OFFSET))

fun withRd(v: Double) = withR(d2i(v))
fun withGd(v: Double) = withG(d2i(v))
fun withBd(v: Double) = withB(d2i(v))
fun withAd(v: Double) = withA(d2i(v))
fun withRd(v: Double): RGBA = withR(d2i(v))
fun withGd(v: Double): RGBA = withG(d2i(v))
fun withBd(v: Double): RGBA = withB(d2i(v))
fun withAd(v: Double): RGBA = withA(d2i(v))

fun withRf(v: Float) = withR(f2i(v))
fun withGf(v: Float) = withG(f2i(v))
fun withBf(v: Float) = withB(f2i(v))
fun withAf(v: Float) = withA(f2i(v))
fun withRf(v: Float): RGBA = withR(f2i(v))
fun withGf(v: Float): RGBA = withG(f2i(v))
fun withBf(v: Float): RGBA = withB(f2i(v))
fun withAf(v: Float): RGBA = withA(f2i(v))

fun getComponent(c: Int): Int = when (c) {
0 -> r
Expand Down Expand Up @@ -111,6 +117,11 @@ inline class RGBA(val value: Int) : Comparable<RGBA>, Interpolable<RGBA>, Paint
operator fun times(other: RGBA): RGBA = RGBA.multiply(this, other)

companion object : ColorFormat32() {
internal const val RED_OFFSET = 0
internal const val GREEN_OFFSET = 8
internal const val BLUE_OFFSET = 16
internal const val ALPHA_OFFSET = 24

fun float(array: FloatArray, index: Int = 0): RGBA = float(array[index + 0], array[index + 1], array[index + 2], array[index + 3])
fun float(r: Float, g: Float, b: Float, a: Float): RGBA = unclamped(f2i(r), f2i(g), f2i(b), f2i(a))
inline fun float(r: Number, g: Number, b: Number, a: Number = 1f): RGBA = float(r.toFloat(), g.toFloat(), b.toFloat(), a.toFloat())
Expand All @@ -124,6 +135,7 @@ inline class RGBA(val value: Int) : Comparable<RGBA>, Interpolable<RGBA>, Paint
override fun getB(v: Int): Int = RGBA(v).b
override fun getA(v: Int): Int = RGBA(v).a
override fun pack(r: Int, g: Int, b: Int, a: Int): Int = RGBA(r, g, b, a).value
fun packUnsafe(r: Int, g: Int, b: Int, a: Int): RGBA = RGBA(r or (g shl 8) or (b shl 16) or (a shl 24))

//fun mutliplyByAlpha(v: Int, alpha: Double): Int = com.soywiz.korim.color.RGBA.pack(RGBA(v).r, RGBA(v).g, RGBA(v).b, (RGBA(v).a * alpha).toInt())
//fun depremultiply(v: RGBA): RGBA = v.asPremultiplied().depremultiplied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ open class ImageData constructor(
val name: String? = null,
) : Extra by Extra.Mixin() {
companion object {
operator fun invoke(simple: Bitmap): ImageData = ImageData(listOf(ImageFrame(simple)))

operator fun invoke(
loopCount: Int = 0,
layers: List<ImageLayer> = fastArrayListOf(),
Expand Down
2 changes: 1 addition & 1 deletion korim/src/commonMain/kotlin/com/soywiz/korim/format/PNG.kt
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ object PNG : ImageFormat("png") {
}

override fun readImage(s: SyncStream, props: ImageDecodingProps): ImageData =
ImageData(listOf(ImageFrame(readCommon(s, readHeader = false) as Bitmap)))
ImageData(readCommon(s, readHeader = false) as Bitmap)

fun paethPredictor(a: Int, b: Int, c: Int): Int {
val p = a + b - c
Expand Down
214 changes: 214 additions & 0 deletions korim/src/commonMain/kotlin/com/soywiz/korim/format/QOI.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package com.soywiz.korim.format

import com.soywiz.kmem.*
import com.soywiz.korim.bitmap.*
import com.soywiz.korim.color.*
import com.soywiz.korio.lang.*
import com.soywiz.korio.stream.*

object QOI : ImageFormat("qoi") {
override fun decodeHeader(s: SyncStream, props: ImageDecodingProps): ImageInfo? {
if (s.readStringz(4, ASCII) != "qoif") return null
val width = s.readS32BE()
val height = s.readS32BE()
val channels = s.readU8()
val colorspace = s.readU8()
return ImageInfo {
this.width = width
this.height = height
this.bitsPerPixel = channels * 8
}
}

override fun readImage(s: SyncStream, props: ImageDecodingProps): ImageData {
val header = decodeHeader(s, props) ?: error("Not a QOI image")
val bytes = UByteArrayInt(s.readAvailable())
val index = RgbaArray(64)
val out = Bitmap32(header.width, header.height)
val outp = out.data
val totalPixels = out.area
var o = 0
var p = 0

var r = 0
var g = 0
var b = 0
var a = 0xFF
var lastCol = RGBA(0, 0, 0, 0xFF)

while (o < totalPixels && p < bytes.size) {
val b1 = bytes[p++]

when (b1) {
QOI_OP_RGB -> {
r = bytes[p++]
g = bytes[p++]
b = bytes[p++]
}
QOI_OP_RGBA -> {
r = bytes[p++]
g = bytes[p++]
b = bytes[p++]
a = bytes[p++]
}
else -> {
when (b1.extract2(6)) {
QOI_SOP_INDEX -> {
val col = index[b1]
r = col.r
g = col.g
b = col.b
a = col.a
}
QOI_SOP_DIFF -> {
r = (r + (b1.extract2(4) - 2)) and 0xFF
g = (g + (b1.extract2(2) - 2)) and 0xFF
b = (b + (b1.extract2(0) - 2)) and 0xFF
}
QOI_SOP_LUMA -> {
val b2 = bytes[p++]
val vg = (b1.extract6(0)) - 32
r = (r + (vg - 8 + b2.extract4(4))) and 0xFF
g = (g + (vg)) and 0xFF
b = (b + (vg - 8 + b2.extract4(0))) and 0xFF
}
QOI_SOP_RUN -> {
val np = b1.extract6(0) + 1
for (n in 0 until np) outp[o++] = lastCol
continue
}
}
}
}

lastCol = RGBA.packUnsafe(r, g, b, a)
index[QOI_COLOR_HASH(r, g, b, a) % 64] = lastCol
outp[o++] = lastCol
}
return ImageData(out)
}

override fun writeImage(image: ImageData, s: SyncStream, props: ImageEncodingProps) {
val bitmap = image.mainBitmap.toBMP32IfRequired()
val pixels = bitmap.data
val index = RgbaArray(64)
val maxSize = QOI_HEADER_SIZE + (bitmap.width * bitmap.height * (4 + 1)) + QOI_PADDING_SIZE
val bytes = UByteArrayInt(maxSize)
val sbytes = bytes.bytes
var o = 0
var p = 0
var run = 0

bytes[p++] = 'q'.code
bytes[p++] = 'o'.code
bytes[p++] = 'i'.code
bytes[p++] = 'f'.code
sbytes.write32BE(p, bitmap.width); p += 4
sbytes.write32BE(p, bitmap.height); p += 4
bytes[p++] = 4
bytes[p++] = QOI_LINEAR

var px_prev = RGBA(0, 0, 0, 0xFF)
var pr = 0
var pg = 0
var pb = 0
var pa = 0xFF

while (o < pixels.size) {
val px = pixels[o++]
val cr = px.r
val cg = px.g
val cb = px.b
val ca = px.a

if (px == px_prev) {
run++
if (run == 62 || o >= pixels.size) {
bytes[p++] = QUI_SOP(QOI_SOP_RUN) or (run - 1)
run = 0
}
} else {
if (run > 0) {
bytes[p++] = QUI_SOP(QOI_SOP_RUN) or (run - 1)
run = 0
}

val index_pos = QOI_COLOR_HASH(cr, cg, cb, ca) % 64

if (index[index_pos] == px) {
bytes[p++] = QUI_SOP(QOI_SOP_INDEX) or index_pos
} else {
index[index_pos] = px

if (ca == pa) {
val vr = cr - pr
val vg = cg - pg
val vb = cb - pb

val vg_r = vr - vg
val vg_b = vb - vg

when {
vr > -3 && vr < 2 && vg > -3 && vg < 2 && vb > -3 && vb < 2 -> {
bytes[p++] = QUI_SOP(QOI_SOP_DIFF) or ((vr + 2) shl 4) or ((vg + 2) shl 2) or (vb + 2)
}
vg_r > -9 && vg_r < 8 && vg > -33 && vg < 32 && vg_b > -9 && vg_b < 8 -> {
bytes[p++] = QUI_SOP(QOI_SOP_LUMA) or (vg + 32)
bytes[p++] = ((vg_r + 8) shl 4) or (vg_b + 8)
}
else -> {
bytes[p++] = QOI_OP_RGB
bytes[p++] = cr
bytes[p++] = cg
bytes[p++] = cb
}
}
} else {
bytes[p++] = QOI_OP_RGBA
bytes[p++] = cr
bytes[p++] = cg
bytes[p++] = cb
bytes[p++] = ca
}
}
}

px_prev = px
pr = cr
pg = cg
pb = cb
pa = ca
}

for (n in 0 until QOI_PADDING.size) sbytes[p++] = QOI_PADDING[n]

s.writeBytes(sbytes, 0, p)
}

private const val QOI_SRGB = 0
private const val QOI_LINEAR = 1

private fun QUI_SOP(op: Int): Int = (op shl 6)

private const val QOI_SOP_INDEX = 0b00 /* 00xxxxxx */
private const val QOI_SOP_DIFF = 0b01 /* 01xxxxxx */
private const val QOI_SOP_LUMA = 0b10 /* 10xxxxxx */
private const val QOI_SOP_RUN = 0b11 /* 11xxxxxx */

private const val QOI_OP_RGB = 0xfe /* 11111110 */
private const val QOI_OP_RGBA = 0xff /* 11111111 */

private const val QOI_MASK_2 = 0xc0 /* 11000000 */

private fun QOI_COLOR_HASH(r: Int, g: Int, b: Int, a: Int): Int = (r * 3 + g * 5 + b * 7 + a * 11)
private fun QOI_COLOR_HASH(C: RGBA): Int = QOI_COLOR_HASH(C.r, C.g, C.b, C.a)
val QOI_PADDING = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1)
private const val QOI_HEADER_SIZE = 14
private const val QOI_PADDING_SIZE = 8

/* 2GB is the max file size that this implementation can safely handle. We guard
against anything larger than that, assuming the worst case with 5 bytes per
pixel, rounded down to a nice clean value. 400 million pixels ought to be
enough for anybody. */
private const val QOI_PIXELS_MAX = 400_000_000
}
37 changes: 37 additions & 0 deletions korim/src/commonTest/kotlin/com/soywiz/korim/format/QOITest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.soywiz.korim.format

import com.soywiz.klock.*
import com.soywiz.korim.bitmap.*
import com.soywiz.korio.async.*
import com.soywiz.korio.file.std.*
import kotlin.test.*

class QOITest {
val formats = ImageFormats(PNG, QOI)

@Test
fun qoiTest() = suspendTestNoBrowser {
repeat(4) { resourcesVfs["testcard_rgba.png"].readBitmapOptimized() }
repeat(4) { resourcesVfs["testcard_rgba.png"].readBitmapNoNative(formats) }
repeat(4) { resourcesVfs["testcard_rgba.qoi"].readBitmapNoNative(formats) }

val pngBytes = resourcesVfs["dice.png"].readBytes()
val qoiBytes = resourcesVfs["dice.qoi"].readBytes()

val (expectedNative, expectedNativeTime) = measureTimeWithResult { nativeImageFormatProvider.decode(pngBytes) }
val (expected, expectedTime) = measureTimeWithResult { PNG.decode(pngBytes) }
val (output, outputTime) = measureTimeWithResult { QOI.decode(qoiBytes) }

//QOI=4.280875ms, PNG=37.361000000000004ms, PNG_native=24.31941600036621ms
//println("QOI=$outputTime, PNG=$expectedTime, PNG_native=$expectedNativeTime")
//AtlasPacker.pack(listOf(output.slice(), expected.slice())).atlases.first().tex.showImageAndWait()

assertEquals(0, output.matchContentsDistinctCount(expected))

for (imageName in listOf("dice.qoi", "testcard_rgba.qoi", "kodim23.qoi")) {
val original = QOI.decode(resourcesVfs[imageName])
val reencoded = QOI.decode(QOI.encode(original))
assertEquals(0, reencoded.matchContentsDistinctCount(original))
}
}
}
Binary file added korim/src/commonTest/resources/dice.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added korim/src/commonTest/resources/dice.qoi
Binary file not shown.
Binary file added korim/src/commonTest/resources/kodim23.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added korim/src/commonTest/resources/kodim23.qoi
Binary file not shown.
Binary file added korim/src/commonTest/resources/testcard_rgba.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added korim/src/commonTest/resources/testcard_rgba.qoi
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,8 @@ fun SyncOutputStream.writeStringz(str: String, len: Int, charset: Charset = UTF8

fun SyncInputStream.readBytes(len: Int): ByteArray {
val bytes = ByteArray(len)
return bytes.copyOf(read(bytes, 0, len))
val out = read(bytes, 0, len)
return if (out != len) bytes.copyOf(out) else bytes
}

fun SyncOutputStream.writeBytes(data: ByteArray): Unit = write(data, 0, data.size)
Expand Down

0 comments on commit 532e8c7

Please sign in to comment.