zenraw [](https://github.com/imazen/zenraw/actions/workflows/ci.yml) [](https://crates.io/crates/zenraw) [](https://lib.rs/crates/zenraw) [](https://docs.rs/zenraw) [](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) [](#license)

August 30, 2026 · View on GitHub

Camera RAW and DNG decoder in pure Rust (#![forbid(unsafe_code)]). Display-ready sRGB output by default (OutputMode::Develop, u16); scene-referred linear f32 is opt-in (OutputMode::Linear). Three swappable backends trade camera coverage against dependency weight.

Quick start

[dependencies]
zenraw = "0.2.0"
# Helper crates the zenraw API hands you types from — add the ones you touch:
enough = "0.4.3"     # `Unstoppable` / the `Stop` trait (decode's 3rd argument)
zenpixels = "0.2.10" # `PixelBuffer` (what `output.pixels` is)
bytemuck = "1.25.0"  # only for the `cast_slice` byte→f32/u16 views shown below
use zenraw::{decode, RawDecodeConfig};
use enough::Unstoppable;

let data: &[u8] = &[/* RAW file bytes */];
let output = decode(data, &RawDecodeConfig::default(), &Unstoppable)?;
println!("{}x{} {} {}", output.info.width, output.info.height,
    output.info.make, output.info.model);
// output.pixels is a `zenpixels::PixelBuffer`. The default OutputMode::Develop
// produces display-ready u16 sRGB (3×u16 per pixel) — NOT 8-bit and NOT linear.

Pick the output representation with with_output (RawDecodeConfig is #[non_exhaustive], so configure it with the with_* builders, not a struct literal):

use zenraw::OutputMode;

// Scene-referred linear f32 (white-balanced, color-matrixed):
let config = RawDecodeConfig::default().with_output(OutputMode::Linear);
let output = decode(data, &config, &Unstoppable)?;
// output.pixels is now f32 linear RGB. (OutputMode::Develop = u16 sRGB [default],
//  OutputMode::CameraRaw = raw camera values as f32, no color processing.)

Output modes

OutputModePixel format (PixelDescriptor)Bytes/channelValues
Develop (default)RGB16_SRGB — display-ready sRGB-encoded2 (u16)[0, 65535], gamma-encoded, in with_target primaries
LinearRGBF32_LINEAR — scene-referred linear4 (f32)white-balanced + colour-matrixed, clamped to [0, 1] (see below)
CameraRawRGBF32_LINEAR (primaries Unknown)4 (f32)raw camera values, no WB / matrix, not clamped to 1.0

There is no 8-bit output mode: Develop is u16, so a thumbnail/web pipeline that wants u8 sRGB must narrow the u16 samples itself (v >> 8, or (v as u32 * 255 + 32767) / 65535 for rounding). The darktable backend is the one exception — it returns whatever darktable-cli wrote (RGB8_SRGB for its sRGB profile, f32 linear otherwise).

Reading the pixels out

output.pixels is a zenpixels::PixelBuffer — an untyped buffer: the element type is not in the Rust type, it is decided by the OutputMode you asked for (table above) and reported by output.pixels.descriptor(). So into_vec() / as_contiguous_bytes() give you u8 bytes whose meaning changes with the mode: pairs of bytes per u16 sample under Develop, four bytes per f32 under Linear / CameraRaw. Reinterpret them for the channel type the mode produced — interleaved f32 RGB for Linear (and CameraRaw), interleaved u16 sRGB for the default Develop.

use zenraw::{decode, OutputMode, RawDecodeConfig};
use enough::Unstoppable;

let data: &[u8] = &[/* RAW file bytes */];
let config = RawDecodeConfig::default().with_output(OutputMode::Linear);
let output = decode(data, &config, &Unstoppable)?;

let w = output.pixels.width() as usize;
let h = output.pixels.height() as usize;

// The RGB-f32 buffer is tightly packed (stride == width * 12 bytes), so the
// zero-copy `as_contiguous_bytes()` always returns `Some` for this format.
let bytes: &[u8] = output.pixels.as_contiguous_bytes().unwrap();
let rgb: &[f32] = bytemuck::cast_slice(bytes); // 3 floats per pixel, R,G,B

assert_eq!(rgb.len(), w * h * 3);

// Pixel (x, y), channel order R, G, B:
let pixel = |x: usize, y: usize| {
    let i = (y * w + x) * 3;
    (rgb[i], rgb[i + 1], rgb[i + 2])
};
let (r, g, b) = pixel(0, 0);

Key facts about the layout and value range (verified against the decode path):

  • Channel order is interleaved R, G, B — three samples per pixel, no alpha, in width * height * 3 order (row-major, top-to-bottom). The pixel format is PixelDescriptor::RGBF32_LINEAR (ChannelType::F32, ChannelLayout::Rgb, TransferFunction::Linear).
  • OutputMode::Linear is scene-referred in its transfer function, not in its dynamic range — white-balanced and colour-matrixed, with no tone curve and no gamma, but clamped to [0, 1]. Sensor samples are normalised with clamp(0.0, 1.0) against the black/white levels, and the combined WB + camera→output matrix clamps every component again as it writes it, so highlights above the sensor's white level are not preserved and negative out-of-gamut components are floored at zero. Values above 1.0 reach the output only via exposure_ev, which multiplies after the clamp.
  • OutputMode::CameraRaw is the mode that keeps values above 1.0. Sensor samples are normalised with clamp(0.0, 1.0), but demosaicing runs next and the Malvar-He-Cutler Laplacian correction floors at zero without a ceiling, so high-contrast neighbourhoods overshoot. Linear and Develop lose that overshoot to the colour matrix's clamp; CameraRaw skips the colour pipeline and keeps it. Samples are always finite and never negative.
  • OutputMode::Develop (the default) is display-ready u16 sRGB: read it the same way but bytemuck::cast_slice::<u8, u16>(bytes)$ \text{for} 3 \times $u16 per pixel in [0, 65535].
  • Don't re-apply gamma. Develop output is already sRGB-encoded. The two public helpers in zenraw::color split the job in two: apply_srgb_gamma (linear f32 → sRGB-encoded f32, still [0, 1]) and f32_to_u8_srgb (a plain clamp-and-scale of already-encoded [0, 1] f32 to u8 — it applies no transfer function). Run them in that order on Linear output; running apply_srgb_gamma on Develop output double-encodes.

If you'd rather own the bytes (e.g. to hand off to a thread or FFI), use output.pixels.copy_to_contiguous_bytes() for a fresh Vec<u8> with stride padding stripped, or output.pixels.into_vec() to consume the buffer. To walk row by row, output.pixels.as_slice().row(y) returns one row's width * bytes_per_pixel bytes. (width() / height() / stride() / descriptor() / as_contiguous_bytes() / copy_to_contiguous_bytes() / as_slice() are all on PixelBuffer.)

Cancellation (Stop)

decode's third argument is a &dyn enough::Stop — the cooperative-cancellation / deadline hook. Pass the no-op when you don't need it:

use enough::Unstoppable;
let output = decode(data, &RawDecodeConfig::default(), &Unstoppable)?;

For a token you can cancel from another thread (timeout, request abort, etc.), use almost_enough::Stopper. Stopper itself implements Stop (so you pass &stopper straight to decode) and is a cheap Arc-backed handle — clone it to share the cancellation state across threads, then cancel() from any clone:

use almost_enough::Stopper;

let stopper = Stopper::new();
let watcher = stopper.clone();      // hand the clone to a timeout/abort task

// e.g. on another thread / after a deadline: `watcher.cancel();`

match decode(data, &RawDecodeConfig::default(), &stopper) {
    Ok(output) => { /* … */ }
    Err(e) => match e.error() {
        // cancellation surfaces as `RawError::Stopped(enough::StopReason)`
        zenraw::RawError::Stopped(_) => { /* cancelled / deadline hit */ }
        _ => return Err(e),
    },
}

decode checks the token between pipeline stages, so cancellation is bounded by how long a single stage runs. Errors are whereat::At<RawError>; reach the underlying RawError with .error(). Add almost-enough = "0.4.4" for the cancellable Stopper; enough (the Unstoppable no-op and the Stop trait) comes in via zenraw, which depends on enough 0.4.

Resource limits & server error handling

Two caps on RawDecodeConfig bound what a single decode may cost, and both are checked against the header before any sensor-sized allocation:

BuilderDefaultRejects with
with_max_pixels(n)200,000,000 (200 MP)RawError::LimitExceeded(RawLimitKind::Pixels, _)
with_max_decode_bytes(n)1 GiBRawError::LimitExceeded(RawLimitKind::Memory, _) — the intermediate RGB f32 working set is width × height × 12 bytes

probe applies the default 200 MP cap too, so a hostile header can't make even a metadata-only call report absurd dimensions.

Every fallible call returns Result<_, whereat::At<RawError>>: At wraps the error with the source location it was raised at. Call .error() to borrow the RawError and match on it (it is #[non_exhaustive], so keep a _ arm):

use zenraw::{decode, RawDecodeConfig, RawError, RawLimitKind};
use enough::Unstoppable;

let config = RawDecodeConfig::default()
    .with_max_pixels(50_000_000)
    .with_max_decode_bytes(600 * 1024 * 1024);

match decode(data, &config, &Unstoppable) {
    Ok(output) => { /* … */ }
    Err(e) => match e.error() {
        // Too big for this server's per-request budget — a 413-style reply.
        RawError::LimitExceeded(RawLimitKind::Pixels, msg)
        | RawError::LimitExceeded(RawLimitKind::Memory, msg) => eprintln!("rejected: {msg}"),
        // Bad / truncated / unknown-dialect upload — a 4xx, not a server bug.
        RawError::Malformed(_) | RawError::UnexpectedEof(_)
        | RawError::UnsupportedType(_) | RawError::UnsupportedFeature(_) => eprintln!("bad input: {e}"),
        // Allocation actually failed (distinct from a configured cap).
        RawError::OutOfMemory(_) => eprintln!("out of memory: {e}"),
        // Cancelled via the `Stop` token (see above).
        RawError::Stopped(_) => eprintln!("cancelled"),
        _ => eprintln!("decode failed: {e}"), // Io / Dependency / Buffer / …
    },
}

e.to_string() includes the location; e.decompose() gives you the owned RawError back. With the zencodec feature every variant also maps onto zencodec's ErrorCategory (Image / Request / Resource / Io / Internal / Stopped) via CategorizedError, so a format-agnostic pipeline can classify without matching zenraw's variants.

Decoding untrusted input (panic safety)

decode returns Result, and both backends are panic-isolated: each wraps its underlying parser in std::panic::catch_unwind and converts a backend panic into RawError::Malformed(...) (and the decode path also rejects inputs shorter than 64 bytes up front, as RawError::UnexpectedEof(...)). So with either backend a malformed file is expected to come back as Err, not a host crash.

That guard is not total, and you should not rely on it alone for hostile uploads:

  • catch_unwind cannot stop an abort (a panic = "abort" profile, a double-panic, or an allocation failure under that profile), and the broader decode path has not been exhaustively proven panic-free on adversarial input.

The big sensor-sized allocations (the normalized sensor buffer, the demosaiced RGB f32 buffer, and the crop copy) go through a fallible allocation path: an out-of-memory allocation returns RawError::OutOfMemory rather than aborting. Combined with the up-front with_max_pixels / with_max_decode_bytes caps (which reject with RawError::LimitExceeded(RawLimitKind::Pixels | Memory, _)), a crafted header that demands gigabytes is rejected before allocating, or fails gracefully if it slips past. (With the zencodec feature, the fallibility is driven by ResourceLimits::prefer_fallible_allocations.)

For a server decoding untrusted RAW, wrap the call so a panic can't take down the worker — run it on an isolated thread (a panicking thread unwinds without killing the process) or in your own std::panic::catch_unwind, and keep the resource caps (with_max_pixels / with_max_decode_bytes) tight:

let result = std::thread::spawn(move || {
    decode(&data, &config, &Unstoppable)
})
.join(); // `Err(_)` here means the decode thread panicked

Decode pipeline

  1. Parse camera RAW/DNG file (via rawloader or rawler)
  2. Normalize sensor values using per-channel black/white levels
  3. Demosaic Bayer CFA → RGB (Malvar-He-Cutler by default, bilinear available)
  4. Demosaic X-Trans 6×6 CFA → RGB (bilinear, rawler backend only)
  5. Apply white balance coefficients
  6. Apply camera → XYZ → sRGB color matrix
  7. Crop to active area (from camera metadata)
  8. Apply EXIF orientation (rotation/flip)
  9. Develop only: sRGB transfer function + quantise to u16

Backends

BackendFeatureCamerasFormatsNotes
rawloaderrawloader (default)~200Bayer onlyLightweight, LGPL-2.1. No 10-bit lossless-JPEG DNGs (iPhone ProRAW) — see below
rawlerrawler~300+Bayer + X-Trans, CR3, JXL DNG, 10-bit LJPEG DNGBroader support, LGPL-2.1
darktabledarktable900+Everything darktable handlesShells out to darktable-cli

When both rawloader and rawler are enabled, rawler takes priority. The darktable backend is independent — it delegates the entire pipeline to darktable-cli and returns its processed output.

iPhone ProRAW / 10-bit lossless-JPEG DNGs need rawler. The default rawloader backend does not decode DNGs whose lossless-JPEG tiles use a 10-bit sample precision (sof.precision 10) — the common iPhone 15/16 Pro ProRAW case. zenraw contains the resulting upstream panic and returns RawError::Malformed, so nothing crashes, but the file does not decode. Build with --features rawler for those files; rawler can also be used without rawloader (--no-default-features --features "std,rawler") to drop the extra dependency.

Supported formats

FormatExtensionsBackend
Adobe DNG.dngrawloader, rawler
Canon.cr2, .cr3rawloader (CR2), rawler (CR2+CR3)
Nikon.nef, .nrwrawloader, rawler
Sony.arw, .srf, .sr2rawloader, rawler
Fujifilm.rafrawler (X-Trans + Bayer)
Panasonic/Leica.rw2rawloader, rawler
Pentax.pefrawloader, rawler
Olympus.orfrawloader, rawler
Hasselblad.3frrawloader, rawler
Phase One.iiqrawloader, rawler
Epson.erfrawloader, rawler

Plus many more via rawler. Detection works on file content, not extension.

Features

FeatureDefaultDescription
stdyesEnable std (required for darktable, rawler)
rawloaderyesrawloader decode backend
ultrahdryesUltraHDR gain map support via ultrahdr-core
rawlernorawler decode backend (broader camera support)
darktablenodarktable-cli backend (requires darktable installed)
exifnoEXIF metadata extraction via kamadak-exif
xmpnoXMP metadata extraction
applenoApple APPLEDNG/AMPF metadata (implies exif + xmp)
zencodecnozencodec trait integration (DecoderConfig, ImageInfo)

Configuration

RawDecodeConfig is #[non_exhaustive]; build it with the with_* builders:

use zenraw::{RawDecodeConfig, DemosaicMethod, OutputMode, OutputPrimaries};

let config = RawDecodeConfig::default()
    .with_demosaic(DemosaicMethod::MalvarHeCutler) // or Bilinear
    .with_output(OutputMode::Linear)               // Develop (u16 sRGB, default) | Linear (f32) | CameraRaw (f32)
    .with_target(OutputPrimaries::DisplayP3)       // Srgb (default) | DisplayP3 | Bt2020 (Develop/Linear only)
    .with_exposure_ev(0.0)                          // exposure compensation in stops (2^ev); Develop/Linear only
    .with_wb([1.0, 1.0, 1.0])                       // override the as-shot white balance (RGB multipliers)
    .with_crop(true)                               // use the camera's crop / active area
    .with_orientation(true)                        // apply the EXIF rotation/flip
    .with_max_pixels(120_000_000)                  // reject images above this (width × height); default 200 MP
    .with_max_decode_bytes(1024 * 1024 * 1024);    // cap the intermediate RGB-f32 working set; default 1 GiB

zencodec integration

With the zencodec feature, zenraw implements DecoderConfig for use in format-agnostic decode pipelines. Two format definitions are exported: DNG_FORMAT and RAW_FORMAT.

use zenraw::RawDecoderConfig;
use zencodec::decode::DecoderConfig;

let config = RawDecoderConfig::new();
let job = config.job();
let info = job.probe(data)?;
println!("{}x{}, orientation={}", info.width, info.height, info.orientation());

The zencodec integration populates ImageInfo with orientation, bit depth, and XMP metadata (when the xmp feature is enabled), honors OrientationHint (default Preserve), and routes allocations through ResourceLimits::prefer_fallible_allocations. The adapter also implements estimate_decode_resources$, \text{predicting} \text{peak} \text{memory} (≈ 3 \times \text{the} $RGB f32 working set plus fixed overhead), serial threading, and wall-time for resource-aware schedulers.

Benchmarks

zenraw ships two profiling harnesses rather than a cross-codec comparison: benches/decode_bench.rs (decode + probe throughput, via zenbench) and examples/heaptrack_decode.rs (heap-allocation profile). Both run against a local RAW corpus that isn't committed — RAW files are large and licensing- encumbered — so fetch one public sample per format with just fetch-samples (pulls from raw.pixls.us), then just bench or just heaptrack-decode. Methodology and exact repro live in benchmarks/README.md.

No throughput numbers are committed here — they are hardware-dependent; reproduce them locally with the commands above.

AI-Generated Code Notice

Developed with Claude (Anthropic). Not all code manually reviewed. Review critical paths before production use.

License

Dual-licensed: AGPL-3.0 or commercial.

I've maintained and developed open-source image server software — and the 40+ library ecosystem it depends on — full-time since 2011. Fifteen years of continual maintenance, backwards compatibility, support, and the (very rare) security patch. That kind of stability requires sustainable funding, and dual-licensing is how we make it work without venture capital or rug-pulls. Support sustainable and secure software; swap patch tuesday for patch leap-year.

Our open-source products

Your options:

  • Startup license — $1 if your company has under $1M revenue and fewer than 5 employees. Get a key →
  • Commercial subscription — Governed by the Imazen Site-wide Subscription License v1.1 or later. Apache 2.0-like terms, no source-sharing requirement. Sliding scale by company size. Pricing & 60-day free trial →
  • AGPL v3 — Free and open. Share your source if you distribute.

See LICENSE-COMMERCIAL for details.

Image tech I maintain

Codecs ¹zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenjxl-decoder · jxl-encoder · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internalszenrav1e · rav1d-safe · zenravif · zenavif-parse · zenavif-serialize
Compressionzenflate · zenzop · zenzstd
Processingzenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & colorzenpixels · zenpixels-convert · linear-srgb · garb · zenyuv
Pipeline & frameworkzenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metricszensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & MLzenanalyze · zenpredict · zenpicker · zenanalyze-api
Test corporacodec-corpus · imazen-26
ProductsImageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter · zenutils

Open source · @imazen · @lilith · lib.rs/~lilith