whoami
Lucas Jenß
cat /etc/motd
The Coding Journal ツ — Notes taken on an epic coding journey. Technical solutions, debugging notes, and practical guides from the trenches of software development.
ls -la ~/languages/
- drwxr-xr-x
- ▶ PHP
- ▶ Ruby
- ▶ Scala
- ▶ C#
- ▶ JavaScript
- ▶ Objective-C
- ▶ Shell Scripting
ls -la ~/toolchain/
- drwxr-xr-x
- ▶ Typo3
- ▶ Akka
- ▶ Capistrano
- ▶ Git
- ▶ MAMP
- ▶ Adobe Illustrator
- ▶ NSTrackingArea (Cocoa)
uname -a
- drwxr-xr-x
- ▶ Mac OS X
- ▶ Unix
Faster Image Pipelines on Android by Combining NDK and OpenCV
Modern smartphones pack multi-camera sensors and dedicated image signal processors, yet the Java layer running regular Android apps often becomes the bottleneck when processing high-resolution frames. Object detection, AR overlays, document scanning and burst-mode filters all demand frame-by-frame work that easily blows past the comfortable budget of a managed runtime. The cost shows up as dropped frames, jank in the preview, and batteries that drain within an hour.
Sydney and Melbourne have built a reputation as healthy centres for Android product teams, and locally-built apps in finance, agritech and field-service software routinely process images captured on remote stations, in metro traffic, or along coastal routes. Those use cases feed large bitmaps through pre-processing chains before they ever reach a model. A pure Java approach normally allocates intermediate buffers on each pass, triggers frequent garbage collection, and forces the ART JIT to recompile hot loops when optimisations are invalidated. The result is measurable lag that users feel before any object tracker has a chance to run.
Native code sidesteps these limits, which is exactly why Android ships the NDK and why Google keeps evolving its tooling. Pairing the NDK with a tuned computer-vision library such as OpenCV gives developers a path to predictable throughput, predictable memory, and per-frame latencies that stay within a normal camera pipeline's window.
Why pure Java falters under heavy pixel loads
The ART runtime has matured considerably since the early KitKat days, but image processing exposes the weak points that don't surface in typical business apps. Allocator-sensitive loops and large intermediate Bitmap copies force the runtime to do memory accounting work between every filter stage. Hot methods get recompiled once their call sites drift, and the JIT cost shows up as the first few frames of a recording looking noticeably softer than the steady state. Developers in Brisbane's growing startup scene often assume the JIT will catch up; in practice, for a 4K preview running at 30 fps, it rarely does.
Where Java hurts most is in data layout. An int[] of ARGB pixels is read as four separate byte lanes by the CPU, and the standard loops touch memory in patterns that defeat the line-fill buffers. SIMD-friendly code in C or C++, by contrast, can stride through a row in 16-byte chunks and let the compiler auto-vectorise the kernel. Once you cross into a library like OpenCV that already ships hand-tuned SSE2, NEON and AVX paths, the same filter can be several times faster on identical silicon. Thermal behaviour matters too: an Adelaide summer afternoon easily pushes a phone into throttling, and a faster-per-watt algorithm gives the device more headroom before clocks drop.
A runtime snapshot at a glance
A snapshot of typical timings across runtimes illustrates the difference clearly. The numbers come from a mid-range Pixel-class device processing a 12-megapixel RGB buffer through a 5×5 Gaussian followed by Canny edges, measured after a warm-up pass.
| Stage | Pure Java | JNI + OpenCV | RenderScript | Vulkan compute |
|---|---|---|---|---|
| Gaussian filter | 78 ms | 9 ms | 14 ms | 6 ms |
| Canny edges | 142 ms | 17 ms | — | 11 ms |
| Memory per frame | 38 MB | 4 MB | 9 MB | 6 MB |
| Warm-up to steady state | 600 ms | 80 ms | 320 ms | 150 ms |
| Minimum API level | 1 | 21 | 17 (deprecated) | 24 |
RenderScript is a useful historical reference, but Google deprecated it in API 31 and the official guidance now points toward the NDK or Vulkan. Vulkan compute offers the best ceiling but adds substantial code complexity; for most product teams, JNI with OpenCV remains the practical sweet spot. The memory column tells a quieter story: 38 MB per frame is a guaranteed GC pause, while 4 MB tends to fit inside a single arena without pressure on the runtime.
Wiring OpenCV into an NDK module
The standard pattern today is CMake-based. The OpenCV Android SDK ships an OpenCV-android-sdk directory containing prebuilt .so files organised by ABI, plus header directories under sdk/native/jni/include. A simple CMakeLists.txt imports the library and links it against the native target; in app/build.gradle, an externalNativeBuild block declares the ABI filters you actually need — usually arm64-v8a for shipping devices and x86_64 for emulators. Skipping the 32-bit ABIs trims the APK by tens of megabytes and avoids the well-known crashes that come from mixing mismatched builds.
When iterating on filters, you'll spend most of your time on the emulator running synthetic frames. The official write-up on different screen sizes and densities in the emulator is worth a read once layout-dependent performance quirks appear. Native code, however, still needs a real device pass before numbers stop drifting, especially when validating that NEON paths are active at runtime.
The JNI bridge itself should be thin. Keep one native function per stage of the pipeline, convert the incoming Bitmap to a contiguous Mat once, and return ownership cleanly so the GC doesn't reclaim pixel buffers mid-process. Memory leaks in native code look the same as leaks in Java, but the standard tools on Android don't always catch them.
A practical filter chain
A minimal pipeline has three stages: convert from Bitmap to Mat, run the kernel sequence, convert back. Keeping the Mat in NV21 or RGBA avoids redundant colour-space work, and the same Mat can be passed between filters without copying if the lifetime is clear. For a starter edge-detection flow, Gaussian blur followed by Canny is a reasonable baseline.
extern "C" JNIEXPORT void JNICALL
Java_com_example_vision_NativeFilter_process(JNIEnv* env, jobject, jbyteArray input, jint width, jint height) {
jbyte* pixels = env->GetByteArrayElements(input, nullptr);
cv::Mat yuv(height + height/2, width, CV_8UC1, pixels);
cv::Mat gray, edges;
cv::cvtColor(yuv, gray, cv::COLOR_YUV2GRAY_NV21);
cv::GaussianBlur(gray, gray, cv::Size(5,5), 1.5);
cv::Canny(gray, edges, 50, 150);
env->ReleaseByteArrayElements(input, pixels, JNI_ABORT);
}
A few production rules keep this stable. Avoid passing large arrays across JNI more than once per frame; reuse a single scratch Mat and zero it between stages. Profile with simpleperf rather than guessing which stage dominates. If you sideload any scripting helpers for the QA team, a small Python bridge that runs the same OpenCV kernel against recorded clips is invaluable for cross-checking what the on-device code should produce.
Habits that keep NDK pipelines reliable
A few recurring routines consistently save time once a project grows past a prototype, and they hold up well across the range of devices Australians carry.
- Pin ABI filters to
arm64-v8aandx86_64only; 32-bit ABIs inflate the APK and complicate symbol resolution. - Build a CI matrix that compiles against every NDK version still in use across your installed user base.
- Treat native crashes as a separate triage bucket;
ndk-stackoutput belongs in crash reports, not buried in logcat. - Validate that NEON or SSE paths are actually loaded by running a CPU-feature check at startup.
- Keep JNI functions stateless; thread-local caches prevent leaks but require explicit teardown.
- Profile after a few minutes of steady state, not at first launch; thermal effects in a Pilbara summer or a humid Cairns afternoon are very real.
These routines are easy to skip when deadlines close in, but treating each as a default pays off once the codebase starts to grow.
Shipping an image-processing app to Australian users carries a few local specifics worth designing for. The Privacy Act 1988 and the Australian Privacy Principles govern how biometric and facial data is handled, and any app that uploads faces, licence plates or location-tagged frames needs a clearly stated collection notice. The Office of the Australian Information Commissioner has been active in this space, and small teams have learned the hard way that a permissive camera permission message is not a substitute for a real disclosure.
Network behaviour also diverges from the US. The NBN rollout has left a long tail of satellite and fixed-wireless connections in regional areas, so aggressively downsampling before upload is a kindness to users on western pastoral stations who would otherwise wait minutes per frame. Conversely, in central business districts of Sydney and Melbourne, where 5G mid-band is common, the same app can afford near-lossless transfer.
Local device mix adds another wrinkle. The Australian market leans heavily on Samsung Galaxy A-series handsets sold through Telstra plans, which use mid-tier Exynos chips that lack some of the high-end NEON extensions, so treating the minimum CPU feature set as a deliberate design choice prevents the silent performance cliff a regional user might hit while photographing a sunset in the Flinders Ranges. Outside the editor, I've found that the visual feedback loop from working through a hands-on project such as sewing a tunic with side slits and a henley placket sharpens an eye for seam-aligned textures that translate cleanly into UI overlay decisions.
If you've been wrestling with a sluggish preview on a flagship device, the move to NDK plus OpenCV is straightforward and rarely regretful. Start with one filter, measure it with simpleperf, and let the numbers drive the next refactor. The Coding Journal has step-by-step notes on the surrounding plumbing — from ABI configuration to the camera2 wiring that feeds pixels into the JNI layer — so you can follow the chain end to end without guesswork. Pick a real frame, pick a real device, and time your first native pass today.
cat ~/interests.json
| Key | Value |
|---|---|
| editor | Terminal-first workflow |
| os | Mac OS X / Unix |
| vcs | Git, distributed version control |
| deploy | Capistrano, cron automation |
| graphics | SVG, Adobe Illustrator troubleshooting |
| networking | IP validation, SSH, VPN |
git log --oneline --reverse
Solving SVG import issues in Adobe Illustrator CS6 and CC
When importing an SVG into Illustrator, the operation fails with an unknown error [CANT]. A workaround for this Adobe-side bug.
Solving NDK build issues on OS X
Troubleshooting native development kit compilation problems on Mac OS X.
Programmatically adding PHP generated TypoScript to the backend configuration
Integrating dynamically generated TypoScript into Typo3 backend setups using PHP.
ArgumentError: Could not parse PKey: no start line
Debugging an SSH key parsing error encountered during deployment.
Validating IP-Addresses in PHP
Using PHP filter functions with flags like FILTER_FLAG_IPV4 and FILTER_FLAG_IPV6, and understanding how filter_var handles reserved IP addresses.
Cocoa: Using NSTrackingArea
A short tutorial on using Cocoa's NSTrackingArea to capture mouseEntered and mouseExited events.
cat ~/contact.txt