Skip to content

imazen/zentone

Repository files navigation

zentone CI crates.io lib.rs docs.rs MSRV license

HDR to SDR tone mapping curves in safe Rust. Classical curves (Reinhard, Hable, Narkowicz, ACES, AgX), ITU-R BT.2408 / BT.2446 EETFs, the darktable/Blender filmic spline, and an ISO 21496-1 / Apple Ultra HDR gain-map splitter.

zentone is a curve library, not a color pipeline. It expects linear-light f32 input and returns linear-light f32 output. Transfer-function decode/encode (PQ, HLG, sRGB), primary conversion, and ICC handling live elsewhere — linear-srgb for the math, zenpixels-convert for format negotiation. The pipeline module composes a few of those pieces into fused PQ/HLG → sRGB strip kernels for the common case. There's no codec dependency.

Active development as of April 2026. Public APIs may rename or reorganize through the next few minor releases; anything under the experimental feature is explicitly unstable and may change without semver bumps. Pin minor versions and read CHANGELOG.md before upgrading. This is the first publish to crates.io — the in-development &[f32] + channels: u8 pipeline forms were removed before release in favor of the SIMD strip-form APIs (&[[f32; 3]] / &[[f32; 4]]).

[dependencies]
zentone = "0.1"

Or cargo add zentone. std and avx512 are on by default; build with --no-default-features for no_std, and add experimental for the adaptive/streaming tonemappers.

Getting started

A per-pixel curve, no setup:

use zentone::{AgxLook, ToneMap, ToneMapCurve};

let sdr = ToneMapCurve::Agx(AgxLook::Default).map_rgb([2.5, 1.8, 0.4]);

A stateful, display-aware curve constructed once and applied to many rows:

use zentone::{Bt2408Tonemapper, ToneMap};

let tm = Bt2408Tonemapper::new(4000.0, 1000.0); // content peak, display peak (nits)
let mut row = vec![0.3_f32, 0.5, 0.2, 0.7, 0.1, 0.9];
tm.map_row(&mut row, 3); // 3 = RGB, 4 = RGBA (alpha preserved)

map_row rewrites interleaved linear-light f32 in place to linear SDR. channels is a u8 that must be 3 or 4 (any other value panics), and the row length must be a multiple of it. Output is mostly [0, 1], though some unclamped curves slightly exceed it — the sRGB OETF and 8-bit quantization live in linear-srgb, or use the fused pipeline kernels below for direct sRGB-u8 output.

Fused SIMD strip pipeline — PQ EOTF → tone map → BT.2020→BT.709 → soft clip → sRGB OETF:

use zentone::{Bt2408Tonemapper, TonemapScratch, pipeline::tonemap_pq_to_srgb8_row_simd};

let tm = Bt2408Tonemapper::new(4000.0, 1000.0);
let mut scratch = TonemapScratch::new();          // amortizes per-thread; default 4096-px chunk
let pq = vec![[0.58_f32, 0.58, 0.58]; 1024];      // PQ-encoded BT.2020 RGB
let mut srgb_out = vec![[0u8; 3]; 1024];
tonemap_pq_to_srgb8_row_simd(&mut scratch, &pq, &mut srgb_out, &tm);

TonemapScratch owns the per-chunk intermediates, caps working-set memory at chunk_size pixels regardless of strip length, and makes the pipelines allocation-free per call. One scratch per worker thread or video stream.

ISO 21496-1 / Ultra HDR gain-map splitter — round-trippable HDR ↔ (SDR, log2 gain):

use zentone::{HableFilmic, LumaGainMapSplitter, SplitConfig, SplitStats};

let splitter = LumaGainMapSplitter::new(HableFilmic::new(), SplitConfig::default());
let hdr = vec![0.5_f32, 1.2, 0.3]; // one interleaved RGB pixel, linear light
let mut sdr_out = vec![0.0_f32; 3];
let mut gain_out = vec![0.0_f32; 1];
let mut stats = SplitStats::default();
splitter.split_row(&hdr, &mut sdr_out, &mut gain_out, 3, &mut stats);

The splitter emits raw f32 log2 gain; u8 quantization and gamma encoding are the encoder's job. See the gainmap module docs for the full contract.

API tiers

  • Hot path — strip / row SIMD. pipeline::tonemap_pq_*_row_simd, pipeline::tonemap_hlg_*_row_simd, gamut::apply_matrix_row_simd, gamut::soft_clip_row_simd, hlg::hlg_ootf_row_simd, and the ToneMap::map_strip_simd trait method (with SIMD overrides on Bt2408Tonemapper, Bt2446A/B/C, and CompiledFilmicSpline). Use these for any non-trivial workload.
  • Per-pixel reference. ToneMap::map_rgb, the named-curve scalar functions in curves (reinhard_simple, bt2390_tonemap, narkowicz_aces, …), gamut::apply_matrix, gamut::soft_clip. Suitable for one-off use, doctests, and cross-checks against external implementations. Don't put these in inner loops.
  • Stateful tonemappers. Bt2408Tonemapper, Bt2446A, Bt2446B, Bt2446C, CompiledFilmicSpline. Constructed once with (content_peak_nits, display_peak_nits) (or a FilmicSplineConfig); apply via the ToneMap trait.
  • Gain map splitter. LumaGainMapSplitter, LumaToneMap, SplitConfig, SplitStats, plus the curve adapters Bt2408Yrgb, ExtendedReinhardLuma, HableFilmic, and the LumaFn closure wrapper.
  • Experimental. experimental::AdaptiveTonemapper (LUT fitter from an HDR/SDR pair), experimental::StreamingTonemapper (single-pass spatially-local tonemap), experimental::ProfileToneCurve (DNG camera-profile tone curve), experimental::detect::detect_standard. Feature-gated, semver-unstable.

Curves

Family Members
Stateless ToneMapCurve Reinhard, ExtendedReinhard, ReinhardJodie, TunedReinhard, Narkowicz, HableFilmic, AcesAp1, Agx(AgxLook::{Default, Punchy, Golden}), Bt2390, Clamp
ITU broadcast standards Bt2408Tonemapper (BT.2408 Annex 5 PQ-domain Hermite, YRGB or MaxRGB), Bt2446A / Bt2446B / Bt2446C (BT.2446 Methods A, B, C)
Filmic spline CompiledFilmicSpline + FilmicSplineConfig (darktable/Blender rational spline with toe/linear/shoulder regions and per-pixel highlight desaturation)
Luma curves for the splitter Bt2408Yrgb, ExtendedReinhardLuma, HableFilmic (also re-exported as a stateless ToneMapCurve variant), and any Bt2446{A,B,C} / CompiledFilmicSpline (they implement LumaToneMap directly)

Curves that need RGB→Y weights take them at construction. Use LUMA_BT709, LUMA_BT2020, or LUMA_P3 from the crate root, picking the constant that matches the input primaries.

Utility modules

Module Contents
gamut Six gamut conversion matrices (BT.709 ↔ BT.2020 ↔ Display P3), hue-preserving soft_clip, SIMD strip forms
hlg HLG system gamma, OOTF and inverse OOTF (spec-correct and libultrahdr-compat variants), SIMD strip forms
sdr_hdr Reference-white scaling (100 ↔ 203 nits), OOTF gamma adjustments per BT.2408 §5.1
pipeline Fused PQ/HLG → tone-map → BT.709 → soft-clip strip kernels, with optional sRGB-u8 output
gainmap LumaGainMapSplitter, LumaToneMap, SplitConfig, SplitStats, plus adapters and PQ/HLG row helpers

The six gamut matrices are public consts in the gamut module — gamut::BT2020_TO_BT709, BT709_TO_BT2020, P3_TO_BT709, BT709_TO_P3, BT2020_TO_P3, P3_TO_BT2020 — applied with gamut::apply_matrix (one [f32; 3]), gamut::apply_matrix_row (interleaved, channels: usize), or gamut::apply_matrix_row_simd / apply_matrix_row_simd_rgba (a &mut [[f32; 3]] / &mut [[f32; 4]] strip). The common HDR step is BT2020_TO_BT709.

Architecture

SIMD dispatch goes through archmage and magetypes. The #[archmage::magetypes(...)] macro generates per-tier kernels (AVX-512 → AVX2 → SSE4.2 → NEON → WASM-SIMD → scalar) from a single source body and dispatches at runtime via CPU capability tokens. Coverage varies per kernel — the simpler curves cover all six tiers; transcendental-using kernels (AgX log2/pow, BT.2390 Hermite) ship V3+NEON+WASM128+scalar.

#![forbid(unsafe_code)]. no_std + alloc is the default-supported configuration; std is opt-in for ergonomics in downstream consumers. Tested on thumbv7em-none-eabihf for no_std integrity.

Features

  • std (default) — passes through to linear-srgb, archmage, and magetypes.
  • avx512 (default) — gates the AVX-512 (v4) magetypes tier in archmage and magetypes. Disable to fall back to AVX2 as the top tier.
  • experimental — opt-in. Adds AdaptiveTonemapper, StreamingTonemapper, ProfileToneCurve, and detect_standard. Light test coverage; APIs may change without semver bumps until stabilized.

Compatibility

  • MSRV: Rust 1.89, 2024 edition.
  • CI: Linux x86_64, Windows ARM64 (windows-11-arm), macOS Intel + Apple Silicon, i686-unknown-linux-gnu (via cross), wasm32-wasip1 (unit tests under wasmtime), wasm32-unknown-unknown (build check), thumbv7em-none-eabihf (no_std build check).

Reference parity

Curves that claim a standard name are validated against their reference implementation. Golden CSVs from standalone C++ extractions live under reference-checks/golden/; property tests in tests/exhaustive_properties.rs verify monotonicity, finite output, alpha preservation, and channel-count consistency across a 14×14×14 grid for all stateless curve configurations.

Limitations

  • No transfer-function support beyond what the pipelines need. sRGB / PQ / HLG decode and encode live in linear-srgb. zentone's pipeline module composes them for the PQ→sRGB and HLG→sRGB cases; for other combinations, do the linearization yourself and feed linear-light f32 in.
  • No perceptual gamut mapping. The pipeline applies a hue-preserving soft_clip after the BT.2020→BT.709 matrix, which preserves channel ratios for out-of-gamut highlights. Hellwig 2022 JMh / ACES 2.0 perceptual compression is not implemented (#14).
  • Gain map encode/decode container handling. zentone produces and consumes raw f32 log2 gain; ISO 21496-1 / Ultra HDR container math (MPF, XMP, gamma encoding, u8 quantization) lives in ultrahdr-core.
  • No pixel-format conversion. Inputs are &mut [f32] or packed &[[f32; 3]] / &[[f32; 4]]. For u8/u16/planar buffers, convert first via zenpixels-convert or your own pipeline.

Links

License

AGPL-3.0-only OR LicenseRef-Imazen-Commercial. Use under AGPL-3.0 (see LICENSE-AGPL3) or a commercial license from Imazen (see LICENSE-COMMERCIAL).

About

No description, website, or topics provided.

Resources

License

AGPL-3.0, Unknown licenses found

Licenses found

AGPL-3.0
LICENSE-AGPL3
Unknown
LICENSE-COMMERCIAL

Stars

Watchers

Forks

Packages

 
 
 

Contributors