Skip to content

Instantly share code, notes, and snippets.

@nickbutcher
Last active June 3, 2022 09:33
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save nickbutcher/3aeddeabb9c33a671e9ef743b25e09af to your computer and use it in GitHub Desktop.
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.
/*
* 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)
}
}
/*
* 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