diff --git a/Kotlin/demo/build.gradle b/Kotlin/demo/build.gradle index 6ccb0378..48059d83 100644 --- a/Kotlin/demo/build.gradle +++ b/Kotlin/demo/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "com.wolt.blurhash" - minSdkVersion 14 + minSdkVersion 17 targetSdkVersion 29 versionCode 1 versionName "1.0" @@ -16,8 +16,9 @@ android { buildTypes { release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.debug } } @@ -26,4 +27,6 @@ android { dependencies { implementation project(path: ':lib') implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' } diff --git a/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt b/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt index 274b058a..65d19118 100644 --- a/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt +++ b/Kotlin/demo/src/main/java/com/wolt/blurhashapp/MainActivity.kt @@ -1,24 +1,132 @@ package com.wolt.blurhashapp -import android.os.Bundle -import android.view.View -import android.widget.EditText -import android.widget.ImageView -import android.widget.TextView +import android.graphics.Bitmap +import android.os.* +import android.view.View.INVISIBLE +import android.view.View.VISIBLE import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.* import com.wolt.blurhashkt.BlurHashDecoder +import kotlinx.android.synthetic.main.activity_main.* +import java.util.concurrent.Executors +import kotlin.math.pow class MainActivity : AppCompatActivity() { + private lateinit var vm: Vm + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - val etInput: EditText = findViewById(R.id.etInput) - val ivResult: ImageView = findViewById(R.id.ivResult) - findViewById(R.id.tvDecode).setOnClickListener { + vm = ViewModelProvider(this).get(Vm::class.java) + vm.observe(this, Observer { + when (it) { + "START" -> progressBar.visibility = VISIBLE + "END" -> progressBar.visibility = INVISIBLE + else -> { + ivResultBenchmark.append("\n$it") + ivResultBenchmark.scrollTo(0, ivResultBenchmark.layout.lineCount) + } + } + }) + tvDecode.setOnClickListener { val bitmap = BlurHashDecoder.decode(etInput.text.toString(), 20, 12) ivResult.setImageBitmap(bitmap) + ivResultBenchmark.setText("") + vm.startBenchMark(etInput.text.toString()) } } } + +/** + * Executes a function and return the time spent in milliseconds. + */ +private inline fun timed(function: () -> Unit): Long { + val start = SystemClock.elapsedRealtimeNanos() + function() + return SystemClock.elapsedRealtimeNanos() - start +} + +private const val NANOS = 1000000.0 + +class Vm : ViewModel() { + private val liveData = MutableLiveData() + private val executor = Executors.newSingleThreadExecutor() + private val handler = Handler(Looper.getMainLooper()) + + fun observe(owner: LifecycleOwner, observer: Observer) { + liveData.observe(owner, observer) + } + + fun startBenchMark(blurHash: String) { + executor.execute { + notifyBenchmark("START") + } + executor.execute { + notifyBenchmark("-----------------------------------") + notifyBenchmark("Device: ${Build.MANUFACTURER} - ${Build.MODEL}") + notifyBenchmark("OS: Android ${Build.VERSION.CODENAME} - API ${Build.VERSION.SDK_INT}") + } + executor.execute { + notifyBenchmark("-----------------------------------") + } + for (tasks in 1..3) { + executor.execute { + notifyBenchmark("") + notifyBenchmark("-----------------------------------") + notifyBenchmark("Parallel tasks: $tasks") + notifyBenchmark("-----------------------------------") + } + for (size in 1..3) { + val width = 20 * 2.0.pow(size - 1).toInt() + val height = 12 * 2.0.pow(size - 1).toInt() + executor.execute { + notifyBenchmark("width: $width, height: $height") + } + for (imageCount in 0..2) { + executor.execute { + benchmark(10.0.pow(imageCount).toInt(), width, height, blurHash, useCache = true, tasks = tasks) + } + } + executor.execute { + notifyBenchmark("\n") + } + } + } + val s = "-----------------------------------\n" + executor.execute { + notifyBenchmark(s) + } + executor.execute { + notifyBenchmark("END") + } + } + + private fun benchmark(max: Int, width: Int, height: Int, blurHash: String, useCache: Boolean, tasks: Int) { + notifyBenchmark("-> $max bitmaps") + var bmp: Bitmap? = null + BlurHashDecoder.clearCache() + val listOfTimes = ArrayList() + for (i in 1..max) { + listOfTimes.add(timed { + bmp = BlurHashDecoder.decode(blurHash, width, height, useCache = useCache, parallelTasks = tasks) + }) + } + notifyBenchmark("<- ${listOfTimes.sum().millis().format()} ms, " + + "Avg: ${(listOfTimes.sum().millis() / max.toDouble()).format()} ms, " + + "Max: ${listOfTimes.max().millis().format()}, " + + "Min: ${listOfTimes.min().millis().format()}") + // log the bitmap size + println("bmp size: ${bmp?.byteCount}") + } + + private fun notifyBenchmark(s: String) { + handler.post { + liveData.value = s + } + } +} + +private fun Long?.millis() = (this?.toDouble() ?: 0.0) / NANOS +private fun Double.format() = "%.${2}f".format(this) diff --git a/Kotlin/demo/src/main/res/layout/activity_main.xml b/Kotlin/demo/src/main/res/layout/activity_main.xml index 84249e62..c5557ac0 100644 --- a/Kotlin/demo/src/main/res/layout/activity_main.xml +++ b/Kotlin/demo/src/main/res/layout/activity_main.xml @@ -1,5 +1,6 @@ + + + + + diff --git a/Kotlin/lib/build.gradle b/Kotlin/lib/build.gradle index 939e8b38..61f27f1e 100644 --- a/Kotlin/lib/build.gradle +++ b/Kotlin/lib/build.gradle @@ -27,4 +27,9 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7' + + androidTestImplementation 'junit:junit:4.13' + androidTestImplementation 'androidx.test:runner:1.2.0' } diff --git a/Kotlin/lib/src/androidTestDebug/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt b/Kotlin/lib/src/androidTestDebug/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt new file mode 100644 index 00000000..e44fb372 --- /dev/null +++ b/Kotlin/lib/src/androidTestDebug/java/com/wolt/blurhashkt/BlurHashDecoderTest.kt @@ -0,0 +1,112 @@ +package com.wolt.blurhashkt + +import android.graphics.Bitmap +import com.wolt.blurhashkt.BlurHashDecoder.clearCache +import com.wolt.blurhashkt.BlurHashDecoder.decode +import junit.framework.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.nio.ByteBuffer +import java.util.* + + +class BlurHashDecoderTest { + @Before + @Throws(Exception::class) + fun setUp() { + clearCache() + } + + @Test + fun decode_smallImage_cacheEnabled_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } + + @Test + fun decode_smallImage_differentCache_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } + + @Test + fun decode_smallImage_cacheDisabled_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 20, 12, useCache = false, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } + + @Test + fun decode_bigImage_cacheEnabled_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } + + @Test + fun decode_bigImage_differentCache_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } + + @Test + fun decode_bigImage_cacheDisabled_shouldGetTheSameBitmapInManyRequests() { + val bmp1 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! + val bmp2 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! + val bmp3 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false)!! + val bmp4 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false, parallelTasks = 2)!! + val bmp5 = decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj", 100, 100, useCache = false, parallelTasks = 3)!! + + bmp1.assertEquals(bmp2) + bmp2.assertEquals(bmp3) + bmp3.assertEquals(bmp4) + bmp4.assertEquals(bmp5) + } +} + +fun Bitmap.assertEquals(bitmap2: Bitmap) { + val buffer1: ByteBuffer = ByteBuffer.allocate(height * rowBytes) + copyPixelsToBuffer(buffer1) + val buffer2: ByteBuffer = ByteBuffer.allocate(bitmap2.height * bitmap2.rowBytes) + bitmap2.copyPixelsToBuffer(buffer2) + val equals = Arrays.equals(buffer1.array(), buffer2.array()) + assertTrue(equals) +} diff --git a/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt b/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt index b3e7a37a..0795cb7a 100644 --- a/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt +++ b/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt @@ -2,14 +2,25 @@ package com.wolt.blurhashkt import android.graphics.Bitmap import android.graphics.Color -import kotlin.math.PI +import kotlinx.coroutines.* import kotlin.math.cos import kotlin.math.pow import kotlin.math.withSign +private val COROUTINES_SCOPE_FOR_PARALLEL_TASKS = GlobalScope + object BlurHashDecoder { - fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + private val cacheCosinesX = HashMap() + private val cacheCosinesY = HashMap() + + fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true, parallelTasks: Int = 1): Bitmap? { + if (blurHash == null || blurHash.length < 6) { return null } @@ -31,7 +42,10 @@ object BlurHashDecoder { decodeAc(colorEnc, maxAc * punch) } } - return composeBitmap(width, height, numCompX, numCompY, colors) + return when (parallelTasks) { + 1 -> composeBitmap(width, height, numCompX, numCompY, colors, useCache) + else -> composeBitmapCoroutines(width, height, numCompX, numCompY, colors, useCache, parallelTasks) + } } private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { @@ -77,9 +91,15 @@ object BlurHashDecoder { private fun composeBitmap( width: Int, height: Int, numCompX: Int, numCompY: Int, - colors: Array + colors: Array, + useCache: Boolean ): Bitmap { - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + // 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 @@ -87,17 +107,113 @@ object BlurHashDecoder { var b = 0f for (j in 0 until numCompY) { for (i in 0 until numCompX) { - val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat() + 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 } } - bitmap.setPixel(x, y, Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))) + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) } } - return bitmap + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun composeBitmapCoroutines( + width: Int, height: Int, + numCompX: Int, numCompY: Int, + colors: Array, + useCache: Boolean, + parallelTasks: Int + ): 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) + runBlocking { + COROUTINES_SCOPE_FOR_PARALLEL_TASKS.launch { + val tasks = ArrayList>() + var step = height / parallelTasks + for (t in 0 until parallelTasks) { + val start = step * t + if (t == parallelTasks - 1 && step * parallelTasks < height) { + step += (height - step * parallelTasks) + } + tasks.add(async { + for (y in start until start + step) { + compositBitmapOnlyX(width, numCompY, numCompX, calculateCosX, cosinesX, calculateCosY, cosinesY, y, height, colors, imageArray) + } + return@async + }) + } + tasks.forEach { it.await() } + }.join() + } + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun compositBitmapOnlyX( + width: Int, + numCompY: Int, + numCompX: Int, + calculateCosX: Boolean, + cosinesX: DoubleArray, + calculateCosY: Boolean, + cosinesY: DoubleArray, + y: Int, + height: Int, + colors: Array, + imageArray: IntArray + ) { + 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)) + } + } + + 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 {