Rust + NDK on Android, Part 5 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Project Structure: Gradle + Cargo in One Build, ABI Splits, Debug/Release Flavors. Next: Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy (coming soon).
The fast path for image processing on Android is to stop copying the image. Pass the android.graphics.Bitmap itself across JNI, call AndroidBitmap_lockPixels from Rust to get a pointer to the bitmap’s own pixel storage, filter in place, unlock, and draw the object you started with (NDK Bitmap API). The whole trip costs one JNI crossing per filter and copies nothing.
This part builds that pipeline end to end: a Rust crate with grayscale and a separable box blur, the JNI glue that turns a Bitmap into a &mut [u8], the same filter in idiomatic Kotlin, and a benchmark harness you could defend in review. It assumes the toolchain from Part 1 and the Gradle wiring from Part 4. One refusal up front: no benchmark table until I have measured one; the numbers land when the companion module ships.
Key Takeaways
AndroidBitmap_lockPixelshands Rust a direct pointer to a Bitmap’s pixels: filter in place, copy nothing (NDK Bitmap API).- The
getPixelsandsetPixelsroute copies a 12 megapixel photo twice and allocates a 48 MBIntArrayon every filter run.- The ndk crate’s
bitmapfeature wraps lock and unlock and linkslibjnigraphics; the filters stay plain safe Rust, testable withcargo teston a laptop.- Honest benchmarks need release builds, warm-up, medians, and a named device. The harness is in this post; numbers arrive with the companion module.
Android image processing has a copy problem
Take one 12 megapixel photo, 4000 by 3000 pixels: as ARGB_8888, 48 MB of pixel data. The standard Kotlin route is getPixels, which allocates a 48 MB IntArray and copies everything into it; after your loop, setPixels copies everything back. Before the filter does any real work, the bus has moved 96 MB and the garbage collector has inherited a 48 MB array. At 60 Hz a frame is 16.6 ms; copying is a poor way to spend it.
Passing that IntArray over JNI instead fixes nothing. GetIntArrayElements may pin the array or may copy it; the contract leaves the choice to the runtime (JNI tips). You have added a border crossing and kept the copies.
The fast path skips all of it. libjnigraphics, a small NDK library built for exactly this, exposes AndroidBitmap_lockPixels: give it the Bitmap and it returns the address of the bitmap’s own pixel storage, guaranteed stable until AndroidBitmap_unlockPixels (NDK Bitmap API). Rust mutates the exact memory the bitmap draws from.
The win comes from how you cross the border, not from the language; C++ gets it too. What Rust adds: the in-place mutation goes through a bounds-checked &mut [u8] instead of a bare pointer, so a stray index panics instead of corrupting the heap.
The Rust side: two filters over a raw RGBA buffer
The crate is rust/imagefx, a member of the workspace from Part 4. It needs the jni crate for the boundary and the ndk crate for the bitmap API; the bitmap feature generates bindings and emits the libjnigraphics link flag. This is rust/imagefx/Cargo.toml:
[package]
name = "imagefx"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
jni = "0.21"
ndk = { version = "0.9", features = ["bitmap"] }
No profile section: cargo ignores profiles in workspace members, so the release settings stay in the workspace root, per Part 4.
Two facts about the buffer. RGBA_8888 names the memory order: R, G, B, A, one byte each. Kotlin calls the same configuration ARGB_8888 after the packed Int that getPixels returns; mix the conventions up and your filter swaps red and blue. And rows can be padded: stride is bytes per row and may exceed width times four, so index rows by stride.
The filters are plain safe Rust with no JNI types, so cargo test exercises them on your laptop: core pure, boundary thin, the shape I argued for in the series hub. The first half of rust/imagefx/src/lib.rs:
/// Rec. 601 luma, integer approximation: luma = (77 r + 150 g + 29 b) / 256.
pub fn grayscale_rgba(pixels: &mut [u8], width: usize, height: usize, stride: usize) {
for y in 0..height {
let row = y * stride;
for px in pixels[row..row + width * 4].chunks_exact_mut(4) {
let luma = (px[0] as u32 * 77 + px[1] as u32 * 150 + px[2] as u32 * 29) >> 8;
px[0] = luma as u8;
px[1] = luma as u8;
px[2] = luma as u8;
}
}
}
/// Separable box blur: horizontal pass, then vertical pass. Edges clamp.
pub fn box_blur_rgba(
pixels: &mut [u8],
width: usize,
height: usize,
stride: usize,
radius: usize,
) {
if width == 0 || height == 0 || radius == 0 {
return;
}
let mut scratch = vec![0u8; stride * height];
// Rows: lane per row, step one pixel (4 bytes).
blur_axis(pixels, &mut scratch, height, stride, width, 4, radius);
// Columns: lane per column, step one row (stride bytes).
blur_axis(&scratch, pixels, width, 4, height, stride, radius);
}
fn blur_axis(
src: &[u8],
dst: &mut [u8],
lanes: usize,
lane_stride: usize,
len: usize,
step: usize,
radius: usize,
) {
let window = (2 * radius + 1) as u32;
for lane in 0..lanes {
let base = lane * lane_stride;
for c in 0..4 {
let mut sum = 0u32;
for i in -(radius as isize)..=radius as isize {
let j = i.clamp(0, len as isize - 1) as usize;
sum += src[base + j * step + c] as u32;
}
for j in 0..len {
dst[base + j * step + c] = (sum / window) as u8;
let entering = (j + radius + 1).min(len - 1);
let leaving = j.saturating_sub(radius);
sum += src[base + entering * step + c] as u32;
sum -= src[base + leaving * step + c] as u32;
}
}
}
}
The structure matters more than the language. A naive box blur reads the whole kernel per output pixel; the separable version keeps a running window sum, one value entering and one leaving per step, so cost per pixel is constant at any radius. Both sides of the benchmark must share this structure, otherwise it compares algorithms, not languages. Alpha is averaged too: on opaque photos it stays 255, but remember Android stores translucent bitmaps premultiplied.
The glue: one JNI call from Bitmap to a mutable slice
The second half of the file is the boundary. Both crates speak the raw jni-sys types, so env.get_raw() and bitmap.as_raw() plug into Bitmap::from_jni without casts. The symbol names follow the JNI rule Part 2 took apart token by token. The rest of rust/imagefx/src/lib.rs:
use jni::objects::JObject;
use jni::sys::jint;
use jni::JNIEnv;
use ndk::bitmap::{Bitmap, BitmapFormat};
fn with_locked_bitmap(
env: &JNIEnv,
bitmap: &JObject,
filter: impl FnOnce(&mut [u8], usize, usize, usize),
) {
let bitmap = unsafe { Bitmap::from_jni(env.get_raw(), bitmap.as_raw()) };
let Ok(info) = bitmap.info() else { return };
if !matches!(info.format(), BitmapFormat::RGBA_8888) {
return;
}
let (width, height, stride) = (
info.width() as usize,
info.height() as usize,
info.stride() as usize,
);
let Ok(ptr) = bitmap.lock_pixels() else { return };
let pixels =
unsafe { std::slice::from_raw_parts_mut(ptr.cast::<u8>(), stride * height) };
filter(pixels, width, height, stride);
let _ = bitmap.unlock_pixels();
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ImageFx_nativeGrayscale<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
bitmap: JObject<'local>,
) {
with_locked_bitmap(&env, &bitmap, grayscale_rgba);
}
#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ImageFx_nativeBoxBlur<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
bitmap: JObject<'local>,
radius: jint,
) {
let radius = radius.max(0) as usize;
with_locked_bitmap(&env, &bitmap, |pixels, width, height, stride| {
box_blur_rgba(pixels, width, height, stride, radius)
});
}
The helper does the dance once: wrap, read metadata, refuse anything that is not RGBA_8888, lock, slice, filter, unlock. The from_raw_parts_mut justification fits in one sentence: the pointer is valid because the lock succeeded, the length matches the metadata, and the slice never leaves the lock. The silent early returns are deliberate laziness; Part 8, Error Handling and Panics: Keeping Rust Crashes Out of Your Android App, turns them into real exceptions.
The Kotlin face is small: app/src/main/java/dev/davthecoder/rustseries/ImageFx.kt:
package dev.davthecoder.rustseries
import android.graphics.Bitmap
object ImageFx {
init {
System.loadLibrary("imagefx")
}
fun grayscale(bitmap: Bitmap) {
requireEditable(bitmap)
nativeGrayscale(bitmap)
}
fun boxBlur(bitmap: Bitmap, radius: Int) {
requireEditable(bitmap)
require(radius >= 1) { "radius must be at least 1" }
nativeBoxBlur(bitmap, radius)
}
private fun requireEditable(bitmap: Bitmap) {
require(bitmap.config == Bitmap.Config.ARGB_8888) {
"ImageFx needs ARGB_8888, got ${bitmap.config}"
}
require(bitmap.isMutable) { "ImageFx mutates pixels in place" }
}
private external fun nativeGrayscale(bitmap: Bitmap)
private external fun nativeBoxBlur(bitmap: Bitmap, radius: Int)
}
The require checks mirror the Rust-side guard, and failing loudly in Kotlin beats failing silently in Rust. Usage: decode, .copy(Bitmap.Config.ARGB_8888, true) if the source is immutable, ImageFx.boxBlur(bmp, 12), set it on the ImageView. Same object throughout.
The same filter in idiomatic Kotlin
The comparison is only honest if the Kotlin side is the best Kotlin you would actually write: one getPixels, integer math, no per-pixel allocation. This is app/src/main/java/dev/davthecoder/rustseries/KotlinFilters.kt:
package dev.davthecoder.rustseries
import android.graphics.Bitmap
object KotlinFilters {
fun grayscale(bitmap: Bitmap) {
val width = bitmap.width
val height = bitmap.height
val pixels = IntArray(width * height)
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
for (i in pixels.indices) {
val p = pixels[i]
val r = p ushr 16 and 0xFF
val g = p ushr 8 and 0xFF
val b = p and 0xFF
val luma = (r * 77 + g * 150 + b * 29) shr 8
pixels[i] = (p and 0xFF000000.toInt()) or
(luma shl 16) or (luma shl 8) or luma
}
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)
}
}
Same 77, 150, 29 weights, because different formulas would compare nothing. The Kotlin box blur is the same two passes translated to IntArray indexing; it ships in the companion module with the measurements instead of padding this post.
How to benchmark Rust against Kotlin without fooling yourself
Six rules produce numbers you can defend; skip one and they lie.
- Release builds on both sides. R8 for the APK, the release cargo profile from Part 4 for the
.so. Debug Rust skips the optimizer and loses races it would otherwise win. - Warm up first. ART interprets a method until the JIT finds it hot, so early iterations measure the interpreter. Jetpack Microbenchmark builds in a warm-up phase for exactly this reason.
- Report medians. GC pauses and scheduler noise land in the tail and poison a mean.
- Fix and name the device. Screen on, charged, idle, with pauses between suites so thermals settle. A number without a device name is an anecdote.
- Use realistic sizes. A 1080p preview and a 12 megapixel capture stress caches and bandwidth differently; benchmark what your feature processes.
- Time only the filter. Decodes and copies stay outside the window, unless the copy is what you are measuring.
The harness is deliberately boring: app/src/main/java/dev/davthecoder/rustseries/FilterBenchmark.kt:
package dev.davthecoder.rustseries
import android.graphics.Bitmap
import android.os.SystemClock
object FilterBenchmark {
data class Result(val label: String, val medianMs: Double, val runs: Int)
fun measure(
label: String,
source: Bitmap,
warmups: Int = 5,
runs: Int = 21,
filter: (Bitmap) -> Unit,
): Result {
val working = source.copy(Bitmap.Config.ARGB_8888, true)
repeat(warmups) { filter(working) }
val timingsMs = DoubleArray(runs)
for (i in 0 until runs) {
val start = SystemClock.elapsedRealtimeNanos()
filter(working)
timingsMs[i] = (SystemClock.elapsedRealtimeNanos() - start) / 1_000_000.0
}
working.recycle()
timingsMs.sort()
return Result(label, timingsMs[runs / 2], runs)
}
}
Reusing one working bitmap is fine because neither filter’s cost depends on pixel values; if yours does, copy fresh bitmaps outside the timed window. Twenty-one runs makes timingsMs[runs / 2] a true median. Outgrow this and Microbenchmark is the next step; it detects thermal throttling too.
From experience: nearly every “native lost to Kotlin” benchmark I have been shown was built with cargo’s dev profile: no optimizer, bounds checks intact, nothing inlined. Ask which profile built the binary before debating anything else about the numbers.
The results table and its chart will live right here. The companion module has not shipped, so there is nothing honest to print yet. When it ships, the table goes in under the rules above.
Where Kotlin is close enough not to bother
Be suspicious of anyone promising ten times faster on every pixel loop. ART compiles hot methods to native code too; a single integer pass over a flat array becomes a tight native loop in both worlds. The Kotlin gap is the 96 MB of copying around the loop, not the loop. For one grayscale pass the measured gap may be modest, which is why the harness exists.
Where Rust should pull away is structural. Chained filters keep intermediate buffers native instead of round-tripping an IntArray per stage, and multi-pass algorithms compound Kotlin’s copy tax while a locked buffer pays it once. Threads and SIMD arrive in later parts of the series. Granularity is the other quiet lever: this pipeline crosses JNI once per filter by construction, while APIs that cross per row or per pixel die of overhead however fast the far side is.
A third answer skips both languages: on API 31 or later, RenderEffect.createBlurEffect runs a blur on the GPU in one line. Write Rust when the filter is yours, not when the platform already ships it.
Frequently Asked Questions
How do I access Android Bitmap pixels from Rust without copying?
Pass the Bitmap across JNI and call AndroidBitmap_lockPixels, which the ndk crate wraps as Bitmap::lock_pixels. It returns a pointer to the bitmap’s own pixel storage, stable until unlock_pixels. Check the format is RGBA_8888 first, and index rows by the reported stride.
Is Rust faster than Kotlin for image processing on Android?
Not automatically. ART’s JIT produces a competitive native loop for a single arithmetic pass, and memory bandwidth often dominates both. Rust’s structural advantage is skipping the getPixels and setPixels copies and keeping buffers native across chained filters. Measure release builds with warm-up and medians before trusting any table, including mine.
What replaced RenderScript for image processing on Android?
RenderScript was deprecated in Android 12. Google’s migration guide points built-in effects like blur to RenderEffect and custom GPU kernels to Vulkan compute. CPU-side native code, like this post’s crate, covers the ground between: custom per-pixel work that does not justify a GPU pipeline.
Can I use the Rust image crate instead of writing filters by hand?
For decoding, encoding and offline processing, yes. But the image crate’s types own their pixel buffers, so using it on a live Bitmap means copying in and out, the exact cost this post removes. I use it for file-to-file pipelines and raw slices for on-screen work.
Where this leaves you
You now have the division of labor the later use-case posts reuse: Kotlin decodes and displays, and Rust does its work on the buffer it locked in between, crossing the border once per operation. The lock, slice, filter, unlock sequence applies to any memory Android owns, so it will show up again well past bitmaps.
Part 6, Passing Data Between Kotlin and Rust: Strings, Arrays, ByteBuffers, and Zero-Copy, turns this post’s copy accounting into measured crossing costs for every data shape. The full map lives at the hub, Rust on Android: How to Use It, Debug It, and Why.

Loading comments…