From 3a9637ccb9c38f8beb2cc6d40c2c58068f085e77 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 31 May 2024 14:24:55 -0400 Subject: [PATCH] Optimizes Blurhash generation --- .../amethyst/service/BlurHashImage.kt | 20 +- .../ui/components/ZoomableContentView.kt | 16 +- .../amethyst/benchmark/BlurhashBenchmark.kt | 56 +++ .../amethyst/commons/preview/BlurhashTest.kt | 63 ++++ .../commons/preview}/BlurHashDecoder.kt | 100 +++--- .../commons/preview/BlurHashDecoderOld.kt | 318 ++++++++++++++++++ 6 files changed, 508 insertions(+), 65 deletions(-) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt create mode 100644 commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt rename {app/src/main/java/com/vitorpamplona/amethyst/service => commons/src/main/java/com/vitorpamplona/amethyst/commons/preview}/BlurHashDecoder.kt (81%) create mode 100644 commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt index fd18a2415..412289fbb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt @@ -31,9 +31,9 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.request.ImageRequest import coil.request.Options +import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder import java.net.URLDecoder import java.net.URLEncoder -import kotlin.math.roundToInt @Stable class BlurHashFetcher( @@ -43,23 +43,9 @@ class BlurHashFetcher( override suspend fun fetch(): FetchResult { checkNotInMainThread() - val encodedHash = data.toString().removePrefix("bluehash:") - val hash = URLDecoder.decode(encodedHash, "utf-8") + val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8") - val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f - - val preferredWidth = 100 - - val bitmap = - BlurHashDecoder.decode( - hash, - preferredWidth, - (preferredWidth * (1 / aspectRatio)).roundToInt(), - ) - - if (bitmap == null) { - throw Exception("Unable to convert Bluehash $hash") - } + val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data") return DrawableResult( drawable = bitmap.toDrawable(options.context.resources), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 52902c017..755a44508 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -665,14 +665,16 @@ fun DisplayBlurHash( if (blurhash == null) return val context = LocalContext.current + val model = + remember { + BlurHashRequester.imageRequest( + context, + blurhash, + ) + } + AsyncImage( - model = - remember { - BlurHashRequester.imageRequest( - context, - blurhash, - ) - }, + model = model, contentDescription = description, contentScale = contentScale, modifier = modifier, diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt new file mode 100644 index 000000000..a0dbf7394 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/amethyst/benchmark/BlurhashBenchmark.kt @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlurhashBenchmark { + @get:Rule + val benchmarkRule = BenchmarkRule() + + val warmHex = "[45#Y7_2^-xt%OSb%4S0-qt0xbotaRInV|M{RlD~M{M_IVIUNHM{M{M{M{RjNGRkoyj]o[t8tPt8" + val testHex = "|NHL-]~pabocs+jDM{j?of4T9ZR+WBWZbdR-WCog04ITn\$t6t6t6t6oJoLZ}?bIUWBs:M{WCogRjs:s+o#R+WBoft7axWBx]IV%LogM{t5xaWBay%KRjxus.WCNGWWt7j[j]s+R-S5ofjYV@j[ofD%t8RPoJt7t7R*WCof" + + @Test + fun testAspectRatio() { + BlurHashDecoder.aspectRatio(warmHex) + + benchmarkRule.measureRepeated { + BlurHashDecoder.aspectRatio(testHex) + } + } + + @Test + fun testDecoder() { + BlurHashDecoder.decodeKeepAspectRatio(warmHex, 50) + + benchmarkRule.measureRepeated { + BlurHashDecoder.decodeKeepAspectRatio(testHex, 50) + } + } +} diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt new file mode 100644 index 000000000..ba2af780f --- /dev/null +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/preview/BlurhashTest.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import junit.framework.TestCase.assertTrue +import org.junit.Assert +import org.junit.Test +import kotlin.math.roundToInt + +class BlurhashTest { + val warmHex = "[45#Y7_2^-xt%OSb%4S0-qt0xbotaRInV|M{RlD~M{M_IVIUNHM{M{M{M{RjNGRkoyj]o[t8tPt8" + val testHex = "|NHL-]~pabocs+jDM{j?of4T9ZR+WBWZbdR-WCog04ITn\$t6t6t6t6oJoLZ}?bIUWBs:M{WCogRjs:s+o#R+WBoft7axWBx]IV%LogM{t5xaWBay%KRjxus.WCNGWWt7j[j]s+R-S5ofjYV@j[ofD%t8RPoJt7t7R*WCof" + + @Test + fun testAspectRatioWarm() { + Assert.assertEquals(0.44444445f, BlurHashDecoderOld.aspectRatio(warmHex)!!, 0.001f) + Assert.assertEquals(0.44444445f, BlurHashDecoder.aspectRatio(warmHex)!!, 0.001f) + } + + @Test + fun testDecoderWarm() { + val aspectRatio = BlurHashDecoder.aspectRatio(warmHex) ?: 1.0f + + val bmp1 = BlurHashDecoderOld.decode(warmHex, 100, (100 * (1 / aspectRatio)).roundToInt()) + val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(warmHex, 100) + + assertTrue(bmp1!!.sameAs(bmp2!!)) + } + + @Test + fun testAspectRatioTest() { + Assert.assertEquals(1.0f, BlurHashDecoderOld.aspectRatio(testHex)!!) + Assert.assertEquals(1.0f, BlurHashDecoder.aspectRatio(testHex)!!) + } + + @Test + fun testDecoderTest() { + val aspectRatio = BlurHashDecoder.aspectRatio(testHex) ?: 1.0f + + val bmp1 = BlurHashDecoderOld.decode(testHex, 100, (100 * (1 / aspectRatio)).roundToInt()) + val bmp2 = BlurHashDecoder.decodeKeepAspectRatio(testHex, 100) + + assertTrue(bmp1!!.sameAs(bmp2!!)) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt similarity index 81% rename from app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt rename to commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt index c39688628..5f6841fd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoder.kt @@ -18,12 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service +package com.vitorpamplona.amethyst.commons.preview import android.graphics.Bitmap import android.graphics.Color import kotlin.math.cos import kotlin.math.pow +import kotlin.math.roundToInt import kotlin.math.withSign object BlurHashDecoder { @@ -49,7 +50,7 @@ object BlurHashDecoder { if (blurHash == null || blurHash.length < 6) { return null } - val numCompEnc = decode83(blurHash, 0, 1) + val numCompEnc = decode83At(blurHash, 0) val numCompX = (numCompEnc % 9) + 1 val numCompY = (numCompEnc / 9) + 1 if (blurHash.length != 4 + 2 * numCompX * numCompY) { @@ -66,40 +67,48 @@ object BlurHashDecoder { * if the cache does not exist yet it will be created and populated with new calculations. By * default it is true. */ - fun decode( + fun decodeKeepAspectRatio( blurHash: String?, width: Int, - height: Int, - punch: Float = 1f, useCache: Boolean = true, ): Bitmap? { - checkNotInMainThread() - if (blurHash == null || blurHash.length < 6) { return null } - val numCompEnc = decode83(blurHash, 0, 1) + val numCompEnc = decode83At(blurHash, 0) val numCompX = (numCompEnc % 9) + 1 val numCompY = (numCompEnc / 9) + 1 if (blurHash.length != 4 + 2 * numCompX * numCompY) { return null } - val maxAcEnc = decode83(blurHash, 1, 2) - val maxAc = (maxAcEnc + 1) / 166f + val height = (100 * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt() + val maxAc = (decode83At(blurHash, 1) + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> if (i == 0) { - val colorEnc = decode83(blurHash, 2, 6) - decodeDc(colorEnc) + decodeDc(decode83(blurHash, 2, 6)) } else { - val from = 4 + i * 2 - val colorEnc = decode83(blurHash, from, from + 2) - decodeAc(colorEnc, maxAc * punch) + decodeAc(decode83Fixed2(blurHash, 4 + i * 2), maxAc) } } return composeBitmap(width, height, numCompX, numCompY, colors, useCache) } + private fun decode83At( + str: String, + at: Int = 0, + ): Int { + return charMap[str[at].code] + } + + private fun decode83Fixed2( + str: String, + from: Int = 0, + ): Int { + return charMap[str[from].code] * 83 + charMap[str[from + 1].code] + } + private fun decode83( str: String, from: Int = 0, @@ -107,10 +116,7 @@ object BlurHashDecoder { ): Int { var result = 0 for (i in from until to) { - val index = charMap[str[i]] ?: -1 - if (index != -1) { - result = result * 83 + index - } + result = result * 83 + charMap[str[i].code] } return result } @@ -161,15 +167,20 @@ object BlurHashDecoder { val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + + var r = 0.0f + var g = 0.0f + var b = 0.0f + for (y in 0 until height) { for (x in 0 until width) { - var r = 0f - var g = 0f - var b = 0f + r = 0.0f + g = 0.0f + b = 0.0f for (j in 0 until numCompY) { for (i in 0 until numCompX) { - val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) - val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val cosY = cosinesY[j + numCompY * y] + val cosX = cosinesX[i + numCompX * x] val basis = (cosX * cosY).toFloat() val color = colors[j * numCompX + i] r += color[0] * basis @@ -177,6 +188,7 @@ object BlurHashDecoder { b += color[2] * basis } } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) } } @@ -189,7 +201,13 @@ object BlurHashDecoder { numCompY: Int, ) = when { calculate -> { - DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it } + DoubleArray(height * numCompY) { + val y = it / numCompY + val j = it % numCompY + cos(Math.PI * y * j / height) + }.also { + cacheCosinesY[height * numCompY] = it + } } else -> { cacheCosinesY[height * numCompY]!! @@ -202,24 +220,15 @@ object BlurHashDecoder { numCompX: Int, ) = when { calculate -> { - DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it } + DoubleArray(width * numCompX) { + val x = it / numCompX + val i = it % numCompX + cos(Math.PI * x * i / width) + }.also { cacheCosinesX[width * numCompX] = it } } else -> cacheCosinesX[width * numCompX]!! } - private fun DoubleArray.getCos( - calculate: Boolean, - x: Int, - numComp: Int, - y: Int, - size: Int, - ): Double { - if (calculate) { - this[x + numComp * y] = cos(Math.PI * y * x / size) - } - return this[x + numComp * y] - } - private fun linearToSrgb(value: Float): Int { val v = value.coerceIn(0f, 1f) return if (v <= 0.0031308f) { @@ -229,6 +238,11 @@ object BlurHashDecoder { } } + private val linToSrgbApproximation = + Array(255) { + linearToSrgb(it / 255f) + } + private val charMap = listOf( '0', @@ -315,6 +329,10 @@ object BlurHashDecoder { '}', '~', ) - .mapIndexed { i, c -> c to i } - .toMap() + .mapIndexed { i, c -> c.code to i } + .toMap().let { charMap -> + Array(255) { + charMap[it] ?: 0 + } + } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt new file mode 100644 index 000000000..fa7012e47 --- /dev/null +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/BlurHashDecoderOld.kt @@ -0,0 +1,318 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoderOld { + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * + // 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images + // are displayed + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + /** + * Clear calculations stored in memory cache. The cache is not big, but will increase when many + * image sizes are used, if the app needs memory it is recommended to clear it. + */ + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** Returns width/height */ + fun aspectRatio(blurHash: String?): Float? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + + return numCompX.toFloat() / numCompY.toFloat() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. By + * default it is true. + */ + fun decode( + blurHash: String?, + width: Int, + height: Int, + punch: Float = 1f, + useCache: Boolean = true, + ): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = + Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83( + str: String, + from: Int = 0, + to: Int = str.length, + ): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc( + value: Int, + maxAc: Float, + ): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc, + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean, + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY( + calculate: Boolean, + height: Int, + numCompY: Int, + ) = when { + calculate -> { + DoubleArray(height * numCompY).also { cacheCosinesY[height * numCompY] = it } + } + else -> { + cacheCosinesY[height * numCompY]!! + } + } + + private fun getArrayForCosinesX( + calculate: Boolean, + width: Int, + numCompX: Int, + ) = when { + calculate -> { + DoubleArray(width * numCompX).also { cacheCosinesX[width * numCompX] = it } + } + else -> cacheCosinesX[width * numCompX]!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int, + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = + listOf( + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', + ) + .mapIndexed { i, c -> c to i } + .toMap() +}