Language: English | 简体中文
A Jpeg XL viewer for android using libjxl.
- Get it on IzzyOnDroid !
- Or install apk from latest release (install universal if you don't know your abi).
Notes on how HDR JXL images (Ultra HDR, intensity_target > 0) are displayed
with the device's hardware HDR pipeline, plus the load-time and zoom
optimizations that came with it. This is a development summary, not a user
manual — the app behaves identically for SDR images.
- Show HDR JXL at absolute luminance on HDR panels (540 nit-class devices), with highlights preserved.
- The SDR path must stay bit-identical in behavior: any HDR failure degrades to the plain SDR route, it never aborts the decode.
Two architectural dead ends were found empirically before the final design:
- Tone-mapping in the decoder (route A1) produces a correct-looking SDR image but does not trigger the hardware HDR pipeline — the panel never boosts.
- Display-referred F16 through the Compose/Canvas path (route A2-via-Compose):
the rendering container tone-maps the
RGBA_F16+ PQ bitmap into the 8-bit sRGB window buffer, flattening highlights before the display stack sees them. SurfaceFlinger confirmed the window layer asV0_SCRGBwith no HDR metadata even thoughhdrSdrRatiowas 5x. Display-referred HDR only works if the window itself runs in the HDR pipeline.
| Stage | Format |
|---|---|
| JXL source | scene-referred, BT.2100, PQ, intensity_target ≈ 1000 nit |
| Decoder output profile | BT.2020 primaries + PQ transfer, SetDesiredIntensityTarget(10000) |
| Bitmap | RGBA_F16, stores PQ code values (1.0 = 10000 nit) |
| Bitmap color space | ColorSpace.Named.BT2020_PQ (ICC parametric, 10000 nit reference), set at creation time |
| Window | colorMode = ENHANCED (value 2) on API 31+, WIDE_COLOR_GAMUT below |
Numerically verified on Mac with the same libjxl 0.12.0: a 1000 nit peak source decodes back to ≈ 1125 nit (p50 = 41.8 nit, p999 = 1015 nit) — dynamic range preserved through the round trip.
JXL_DEC_COLOR_ENCODING(Decoder.cpp): for HDR + F16 output, requestBT.2020 + PQwith a 10000 nit intensity target viaJxlDecoderSetDesiredIntensityTarget+JxlDecoderSetCms+JxlDecoderSetOutputColorProfile. Enable the HDR passthrough flag in the image-out callback.- Image-out callback (
ImageOutCallbackData.h): passthrough mode copies the F16 code values verbatim into the bitmap and pads alpha = 1.0f.skcms_Transformmust not run here — it would re-encode the values. - Bitmap creation:
createBitmap(w, h, RGBA_F16, false, Named.BT2020_PQ)(API 28+ overload), resolved once in the JNI constructor and held as a global ref. - Window (
ViewerScreen.kt):ENHANCEDcolor mode on Android 13+ so SurfaceFlinger composites the F16 buffer in the HDR pipeline. - Fallbacks: every JNI resolution step clears any pending exception and degrades to the SDR route. A missing API, a failed method lookup, or a bitmap-creation exception never aborts the decode.
Bitmap.setColorSpace()rejects re-tagging an existing sRGB bitmap to BT.2020_PQ ("cannot increase the minimum value for any of the components"). The color space must be supplied at creation time.- Custom
ColorSpace.Rgbwith a linear lambda OETF is also rejected —setColorSpace()only accepts ICC parametric spaces.Named.BT2020_PQis the platform-provided anchor that matches the 10000 nit decoder reference. - A pending JNI exception poisons the next call and blanks every image: clear it on every failure path.
jxl_cmsmust be linked explicitly forJxlGetDefaultCms()to resolve.- In this NDK setup the
ALOGImacro does not expand; use__android_log_printdirectly.
A 4032×2268 F16 image took ~11 s to decode on device. The root cause was
not the algorithm: building the same libjxl 0.12.0 with 15 threads on an
M-series Mac took 44 ms for the same file — a 250x gap, far beyond any
CPU difference. The AGP debug build never set CMAKE_BUILD_TYPE, so the
entire native library was compiled at -O0.
Fixes (all in the perf commit):
-DCMAKE_BUILD_TYPE=Releasefor the libjxl native build → ~300 ms on device.- JNI input buffer 4 KiB → 256 KiB (fewer feed/
ProcessInputround trips). - Parallel runner given the hardware concurrency (capped at 8) instead of the
conservative resolution-based
SuggestThreads()suggestion.
The old painter down-sampled the bitmap to the canvas size on every draw,
so GPU zoom enlarged an already-blurred copy. Textures now keep the
native image resolution (sources above the ~190 MB Canvas byte budget are
band-down-sampled once), with FilterQuality.High, so zoom reaches 1:1 real
pixels. F16 bitmaps are scaled inside their source color space to avoid the
re-tagging validation.
Images whose single texture would overshoot the per-texture budget
(64 MB / 4096 px) are split into 2048 px tiles carrying a wide
"bleed" ring (48 px of neighbor content copied into the texture on
each side). The GPU resample kernel when down-scaling a texture has
support of ~3x the down-scale ratio and clamps outside the texture,
which paints a visible seam line at every grid line; the ring keeps
the kernel inside real content and the neighbor over-draws it with
identical data. Each tile is
drawn with exact float geometry - a Compose drawImage under
withTransform { translate(x0, y0); scale(sx, sy) } where (x0, y0) is the
tile's exact source position in screen units - so every tile follows one
continuous source->screen map and the grid lines are seamless by construction
(verified on-device: colors across a tile boundary are identical).
Two better-on-paper alternatives fail on this stack (verified on-device):
- platform
BitmapShaderlocal matrices - the effective sampling is the matrix inverted relative to the Skia-documented convention, and any matrix that magnifies the texture relative to its rect is silently dropped / rendered empty on the HWUI display list; - int-geometry tiling - per-tile rounding phases leave a visible 1 px band at every grid line.
F16 (HDR) bitmaps keep integer bleed-overlap tiling: a shader paint (and any CPU-side composite) would lose the F16 layer typing and the display stack would tone-map the buffer.
- Device: Android 16 (API 36), OPPO PJX110, 1264×2780, peak 540 nit,
supportedHdrTypes=[1,2,3,4]. - HDR test image activates the hardware HDR pipeline (confirmed in SurfaceFlinger layer dump) and displays highlights correctly.
- 13/13 ad-hoc checks on the final commit state: Release build type, binary markers, no debug leftovers, install + process smoke, no FATAL.
- Upstream PR: oupson/jxlviewer#46
Licensed under MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT).