diff --git a/kmem/src/commonMain/kotlin/com/soywiz/kmem/Bits.kt b/kmem/src/commonMain/kotlin/com/soywiz/kmem/Bits.kt index 0ecc7093a..76015d9ae 100644 --- a/kmem/src/commonMain/kotlin/com/soywiz/kmem/Bits.kt +++ b/kmem/src/commonMain/kotlin/com/soywiz/kmem/Bits.kt @@ -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) diff --git a/korim/src/commonMain/kotlin/com/soywiz/korim/color/RGBA.kt b/korim/src/commonMain/kotlin/com/soywiz/korim/color/RGBA.kt index 8ff97c417..423626e6d 100644 --- a/korim/src/commonMain/kotlin/com/soywiz/korim/color/RGBA.kt +++ b/korim/src/commonMain/kotlin/com/soywiz/korim/color/RGBA.kt @@ -18,10 +18,10 @@ inline class RGBA(val value: Int) : Comparable, Interpolable, 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 @@ -42,21 +42,27 @@ inline class RGBA(val value: Int) : Comparable, Interpolable, 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 @@ -111,6 +117,11 @@ inline class RGBA(val value: Int) : Comparable, Interpolable, 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()) @@ -124,6 +135,7 @@ inline class RGBA(val value: Int) : Comparable, Interpolable, 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 diff --git a/korim/src/commonMain/kotlin/com/soywiz/korim/format/ImageData.kt b/korim/src/commonMain/kotlin/com/soywiz/korim/format/ImageData.kt index 1fed5daf5..35b25b179 100644 --- a/korim/src/commonMain/kotlin/com/soywiz/korim/format/ImageData.kt +++ b/korim/src/commonMain/kotlin/com/soywiz/korim/format/ImageData.kt @@ -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 = fastArrayListOf(), diff --git a/korim/src/commonMain/kotlin/com/soywiz/korim/format/PNG.kt b/korim/src/commonMain/kotlin/com/soywiz/korim/format/PNG.kt index 96549d633..ede1269a9 100644 --- a/korim/src/commonMain/kotlin/com/soywiz/korim/format/PNG.kt +++ b/korim/src/commonMain/kotlin/com/soywiz/korim/format/PNG.kt @@ -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 diff --git a/korim/src/commonMain/kotlin/com/soywiz/korim/format/QOI.kt b/korim/src/commonMain/kotlin/com/soywiz/korim/format/QOI.kt new file mode 100644 index 000000000..3cef4638c --- /dev/null +++ b/korim/src/commonMain/kotlin/com/soywiz/korim/format/QOI.kt @@ -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 +} diff --git a/korim/src/commonTest/kotlin/com/soywiz/korim/format/QOITest.kt b/korim/src/commonTest/kotlin/com/soywiz/korim/format/QOITest.kt new file mode 100644 index 000000000..5922e65cc --- /dev/null +++ b/korim/src/commonTest/kotlin/com/soywiz/korim/format/QOITest.kt @@ -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)) + } + } +} diff --git a/korim/src/commonTest/resources/dice.png b/korim/src/commonTest/resources/dice.png new file mode 100644 index 000000000..46a247cd4 Binary files /dev/null and b/korim/src/commonTest/resources/dice.png differ diff --git a/korim/src/commonTest/resources/dice.qoi b/korim/src/commonTest/resources/dice.qoi new file mode 100644 index 000000000..bbc8154fc Binary files /dev/null and b/korim/src/commonTest/resources/dice.qoi differ diff --git a/korim/src/commonTest/resources/kodim23.png b/korim/src/commonTest/resources/kodim23.png new file mode 100644 index 000000000..ff22e8373 Binary files /dev/null and b/korim/src/commonTest/resources/kodim23.png differ diff --git a/korim/src/commonTest/resources/kodim23.qoi b/korim/src/commonTest/resources/kodim23.qoi new file mode 100644 index 000000000..078918d5a Binary files /dev/null and b/korim/src/commonTest/resources/kodim23.qoi differ diff --git a/korim/src/commonTest/resources/testcard_rgba.png b/korim/src/commonTest/resources/testcard_rgba.png new file mode 100644 index 000000000..2454247b5 Binary files /dev/null and b/korim/src/commonTest/resources/testcard_rgba.png differ diff --git a/korim/src/commonTest/resources/testcard_rgba.qoi b/korim/src/commonTest/resources/testcard_rgba.qoi new file mode 100644 index 000000000..997aed4d9 Binary files /dev/null and b/korim/src/commonTest/resources/testcard_rgba.qoi differ diff --git a/korio/src/commonMain/kotlin/com/soywiz/korio/stream/SyncStream.kt b/korio/src/commonMain/kotlin/com/soywiz/korio/stream/SyncStream.kt index 92cf2e30d..df1f43797 100644 --- a/korio/src/commonMain/kotlin/com/soywiz/korio/stream/SyncStream.kt +++ b/korio/src/commonMain/kotlin/com/soywiz/korio/stream/SyncStream.kt @@ -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)