Last active
June 3, 2022 09:33
-
-
Save nickbutcher/3aeddeabb9c33a671e9ef743b25e09af to your computer and use it in GitHub Desktop.
A prototype implementation of gradient mapping (https://bjango.com/articles/gradientmaps/) on Android.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2017 Google Inc. | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file | |
* except in compliance with the License. You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software distributed under the | |
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
* KIND, either express or implied. See the License for the specific language governing | |
* permissions and limitations under the License. | |
*/ | |
package com.example.gradientmap | |
import android.content.Context | |
import android.graphics.Bitmap | |
import android.graphics.BitmapFactory | |
import android.graphics.Canvas | |
import android.graphics.drawable.BitmapDrawable | |
import android.graphics.drawable.Drawable | |
import android.os.Build.VERSION.SDK_INT | |
import android.os.Build.VERSION_CODES.KITKAT | |
import android.os.Trace | |
import android.support.annotation.ColorInt | |
import android.support.annotation.DrawableRes | |
/** | |
* See https://bjango.com/articles/gradientmaps/ | |
*/ | |
object GradientMap { | |
private const val ROW_STEPS = 10 | |
fun of(source: Bitmap, gradient: Gradient): Bitmap { | |
if (SDK_INT >= KITKAT) Trace.beginSection("Gradient") | |
val width = source.width | |
val dest = Bitmap.createBitmap(width, source.height, Bitmap.Config.ARGB_8888) | |
val row = IntArray(width * ROW_STEPS) | |
val steps = Math.ceil(source.height / ROW_STEPS.toDouble()).toInt() | |
for (rowStep in 0 until steps) { | |
source.getPixels(row, 0, width, 0, rowStep * ROW_STEPS, width, ROW_STEPS) | |
for (i in 0 until row.size) { | |
row[i] = gradient.map(row[i]) | |
} | |
dest.setPixels(row, 0, width, 0, rowStep * ROW_STEPS, width, ROW_STEPS) | |
} | |
if (SDK_INT >= KITKAT) Trace.endSection() | |
return dest | |
} | |
fun of(context: Context, @DrawableRes resId: Int, gradient: Gradient): Bitmap { | |
return of(BitmapFactory.decodeResource(context.resources, resId), gradient) | |
} | |
fun of(drawable: Drawable, gradient: Gradient): Bitmap { | |
val source = if (drawable is BitmapDrawable) { | |
drawable.bitmap | |
} else { | |
Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, | |
Bitmap.Config.ARGB_8888).apply { | |
drawable.setBounds(0, 0, width, height) | |
drawable.draw(Canvas(this)) | |
} | |
} | |
return of(source, gradient) | |
} | |
} | |
class Gradient private constructor(private val colors: FloatArray, private val offsets: FloatArray) { | |
class Builder { | |
private var stops = mutableListOf<ColorStop>() | |
fun addStop(@ColorInt color: Int, offset: Float): Builder { | |
stops.add(ColorStop.of(color, offset.coerceIn(0.0f, 1.0f))) | |
return this | |
} | |
fun build(): Gradient { | |
if (stops.size < 2) throw IllegalArgumentException("Gradient requires 2 or more colors") | |
val sortedStops = mutableListOf<ColorStop>() | |
sortedStops.addAll(stops.sortedBy(ColorStop::offset)) | |
// check that offsets 0.0 & 1.0 have been provided | |
// else clamp with the lowest/highest offset colors | |
if (sortedStops[0].offset > 0.0) { | |
sortedStops.add(0, sortedStops[0].copy(offset = 0.0f)) | |
} | |
if (stops[stops.size - 1].offset < 1.0) { | |
sortedStops.add(sortedStops[stops.size - 1].copy(offset = 1.0f)) | |
} | |
return Gradient( | |
sortedStops.flatMap { listOf(it.r, it.g, it.b) }.toFloatArray(), | |
sortedStops.map(ColorStop::offset).toFloatArray()) | |
} | |
} | |
private data class ColorStop(val r: Float, val g: Float, val b: Float, val offset: Float) { | |
companion object { | |
fun of(color: Int, offset: Float): ColorStop { | |
// store color stops in linear space | |
val r: Float = ((color shr 16) and 0xff) / 255.0f | |
val g: Float = ((color shr 8) and 0xff) / 255.0f | |
val b: Float = ((color ) and 0xff) / 255.0f | |
return ColorStop(r * r, g * g, b * b, offset) | |
} | |
} | |
} | |
@ColorInt | |
fun map(@ColorInt color: Int): Int { | |
val offset = calculateLuminance(color) | |
// calculate which color stops this offset sits between | |
var index1 = 0 | |
val end = offsets.size - 1 | |
while (index1 < end && offsets[index1] < offset) { index1++ } | |
index1 = (index1 - 1).coerceAtLeast(0) | |
val index2 = (index1 + 1).coerceAtMost(end) | |
val offset1 = offsets[index1] | |
val offset2 = offsets[index2] | |
val fraction = (offset - offset1) / (offset2 - offset1) | |
val color1 = index1 * 3 | |
val color2 = index2 * 3 | |
// calculate the color at the fraction between the color stops | |
return lerp(fraction, | |
colors[color1], colors[color1 + 1], colors[color1 + 2], | |
colors[color2], colors[color2 + 1], colors[color2 + 2]) | |
} | |
private fun calculateLuminance(@ColorInt color: Int): Float { | |
return rgbToLuminance( | |
(color shr 16) and 0xff, | |
(color shr 8) and 0xff, | |
(color ) and 0xff) | |
} | |
@Suppress("NOTHING_TO_INLINE") | |
private inline fun rgbToLuminance(r: Int, g: Int, b: Int): Float { | |
// convert to XYZ color space & return the Y component | |
return rgbToLinear(r) * 0.2126f + rgbToLinear(g) * 0.7152f + rgbToLinear(b) * 0.0722f | |
} | |
@Suppress("NOTHING_TO_INLINE") | |
private inline fun rgbToLinear(srgb: Int): Float { | |
val linear = srgb / 255.0f | |
// approximate linear conversion using x^2 | |
return linear * linear | |
} | |
private fun lerp(fraction: Float, | |
startR: Float, startG: Float, startB: Float, | |
endR: Float, endG: Float, endB: Float): Int { | |
var r = startR + fraction * (endR - startR) | |
var g = startG + fraction * (endG - startG) | |
var b = startB + fraction * (endB - startB) | |
// approximate conversion back from linear using sqrt | |
r = Math.sqrt(r.toDouble()).toFloat() * 255.0f | |
g = Math.sqrt(g.toDouble()).toFloat() * 255.0f | |
b = Math.sqrt(b.toDouble()).toFloat() * 255.0f | |
return 0xff000000.toInt() or (Math.round(r) shl 16) or (Math.round(g) shl 8) or Math.round(b) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Copyright 2017 Google Inc. | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file | |
* except in compliance with the License. You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software distributed under the | |
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
* KIND, either express or implied. See the License for the specific language governing | |
* permissions and limitations under the License. | |
*/ | |
package com.example.gradientmap | |
import android.graphics.Color | |
import android.os.Bundle | |
import android.support.v7.app.AppCompatActivity | |
import android.widget.ImageView | |
class MainActivity : AppCompatActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
setContentView(R.layout.activity_main) | |
val iv = findViewById<ImageView>(R.id.image) | |
val bmp = GradientMap.of(this, R.drawable.ball, | |
Gradient.Builder() | |
.addStop(Color.RED, 0.0f) | |
.addStop(Color.YELLOW, 1.0f) | |
.build()) | |
iv.setImageBitmap(bmp) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment