Table of Contents

JPEG XL

Egami reads and writes JPEG XL (ISO/IEC 18181), written from the specification with no native code. JxlFormat.Info is registered in EgamiContext.Default, so Raster.Load recognizes a bare codestream (FF 0A) or the box format from the first bytes, and Save("x.jxl") picks the writer. JxlDetails describes the container.

Raster.Probe reads the headers alone: size, sample format, channels, orientation and frame count.

// The headers alone: size, natural pixel type, orientation and frame count, without decoding any pixels.
RasterInfo info = Raster.Probe("duck.jxl");
bool isJxl = info.Format == JxlFormat.Info;
long bytes = info.EstimatedBytes;                       // what a full load would allocate

Reading

Every kind of still decodes:

  • Lossless Modular: every predictor, RCT, palette and squeeze, 1 to 31-bit and float samples, alpha.
  • Lossy VarDCT or Modular in XYB: every transform size, Gaborish, the edge-preserving filter, LF frames, upsampling, patches, splines and noise.
// JPEG XL loads like any format: lossless, lossy, animated or recompressed from a JPEG, in its natural pixel type.
using Raster duck = Raster.Load("duck.jxl");
duck.Save("duck.png");

The natural pixel type follows the bit depth: Rgb8, Rgba8 or Gray8, their 16-bit forms, RgbaF16 for 16-bit float, and RgbF32, RgbaF32 or GrayF32 beyond. CMYK loads as Cmyk8 or Cmyk16.

Color: lossy (XYB) files come out in the file's own color space, enumerated (as djxl gives it) or from its ICC profile through Egami's color pipeline, and that space goes on the raster. PQ and HLG files keep their curve, named by H.273 code points, with intensity_target as HdrMetadata.MaxContentLightLevel; converting to sRGB as they load tone maps them:

// An HDR (PQ or HLG) file, tone mapped into sRGB as it loads; the peak comes from the file's intensity_target.
using Raster<Rgb8> sdr = Raster.Load<Rgb8>("hdr.jxl", options: new ReaderOptions { ConvertColorTo = Egami.Colors.ColorProfile.Srgb });
sdr.Save("hdr-as-sdr.jpg");

A jhgm gain map loads into RasterMetadata.GainMap, and ReaderOptions.Hdr renders HDR from it as for Ultra HDR JPEGs (see the HDR guide). Groups decode on several threads (EgamiContext.MaxParallelism), and ReaderOptions.Region on a still decodes only the groups it touches.

JxlReaderOptions adds three choices on top of the general reader options:

Option Default Does
MaxDownscale 1 8 or more decodes only a lossy still's LF image, an eighth of the size each way and far faster
RenderSpotColors true Mixes spot color channels into the color as djxl does; false leaves the color as coded
LinearOutput false Gives linear light in float, in the file's own primaries, instead of its encoded color space

SampleImage.Load gives an integer image's samples at its own bits per sample, as coded (see Stored samples).

Animations, layers and previews

Animations load as their blended frames with delays and loop count. JxlImage.ReadLayers gives the frames before blending, each a JxlLayer with its offset, duration and JxlLayerBlend mode, for an editor; JxlImage.ReadPreview gives the preview a file may carry. Writing them is under Writing.

// An animation loads as its frames, blended onto the canvas, each with its delay.
using Raster animation = Raster.Load("sprite.jxl");
int frames = animation.FrameCount;

// The layers before blending, for an editor: each at its own size and place.
IReadOnlyList<JxlLayer> layers = JxlImage.ReadLayers("sprite.jxl");
foreach (JxlLayer layer in layers)
{
    Console.WriteLine($"{layer.Raster.Width}x{layer.Raster.Height} at {layer.X},{layer.Y}: {layer.Blend}, {layer.Duration.TotalMilliseconds} ms");
    layer.Dispose();
}

// The preview some files carry for quick display, or null.
using Raster? preview = JxlImage.ReadPreview("duck.jxl");

// A lossy still at an eighth of its size, from its LF image alone.
using Raster thumbnail = Raster.Load("duck.jxl", options: new JxlReaderOptions { MaxDownscale = 8 });

Partly downloaded files

While a file arrives through RasterDecoder.OpenIncremental, RenderPreview draws what libjxl's progressive flush draws of the bytes so far (djxl --allow_partial_files), within one level at 8 bits of it. Nothing shows until a frame's LF global section and LF groups have arrived. Then a lossy file shows its LF image upsampled 8 times, each 256-pixel group sharpening as its passes arrive (Progressive files in three steps). A lossless file fills in group by group, with the groups still missing black, or level by level when it is squeezed (cjxl -R 1). Rows come out once the last byte has, and are the same pixels as a whole-file load. Animations show nothing before the end.

// A JPEG XL file shown while it downloads: each preview is what djxl's progressive flush shows of the bytes so far
// (the LF image upsampled, then each pass and group as it arrives); the rows come once the last byte has.
using RasterDecoder decoder = RasterDecoder.OpenIncremental();
Raster<Rgba8>? view = null;
foreach (byte[] chunk in file.Chunk(16384))
{
    decoder.Append(chunk);
    if (decoder.HeaderRead)
    {
        view ??= Raster.Create<Rgba8>(decoder.Info.Width, decoder.Info.Height);
        if (decoder.RenderPreview(view))
        {
            previews++; // show view
        }
    }
}

decoder.EndOfInput();
decoder.ReadRows(view!); // the finished image

Extra channels

Alpha loads straight (premultiplied color is divided out). JxlDetails.ExtraChannels lists every channel as a JxlExtraChannel with its JxlExtraChannelKind (alpha, depth, spot color, selection mask and others), and JxlImage.ReadExtraChannel reads any one alone as gray floats.

// Alpha joins the pixels (straight, even when the file premultiplies); other extra channels are listed in the details.
using Raster image = Raster.Load("sprite.jxl");
JxlDetails details = (JxlDetails)image.Metadata.FormatDetails!;
foreach (JxlExtraChannel channel in details.ExtraChannels)
{
    Console.WriteLine($"{channel.Kind} '{channel.Name}', {channel.BitsPerSample} bits");
}

// Any one of them alone, such as depth or a selection mask, as gray floats.
using Raster<GrayF32> alpha = JxlImage.ReadExtraChannel("sprite.jxl", 0);

// Spot colors are mixed into the color as djxl does, unless turned off.
using Raster plain = Raster.Load("sprite.jxl", options: new JxlReaderOptions { RenderSpotColors = false });

Recompressed JPEGs

JPEG XL can hold a JPEG losslessly, about a fifth smaller. JxlImage.RecompressJpeg makes such a file from a JPEG, and JxlImage.ReconstructJpeg gives the original back byte for byte. JxlDetails.HasJpegReconstruction says which files can.

// A JPEG becomes a JPEG XL file about a fifth smaller with the same pixels, and comes back byte for byte.
JxlImage.RecompressJpeg("duck.jpg", "duck.jxl");
JxlImage.ReconstructJpeg("duck.jxl", "duck-again.jpg");

Baseline and progressive JPEGs with any Huffman tables, restart markers, ICC, Exif and XMP are taken; arithmetic-coded, lossless, hierarchical, 12-bit and CMYK JPEGs, and 4:1:1 sampling, are refused.

Such files also load as pixels like any other:

// A JPEG recompressed into JPEG XL loads as pixels like any other file, and gives its JPEG back byte for byte.
using Raster image = Raster.Load("recompressed.jxl");
if (((JxlDetails)image.Metadata.FormatDetails!).HasJpegReconstruction)
{
    JxlImage.ReconstructJpeg("recompressed.jxl", "original.jpg");
}

Writing

The writer makes lossless Modular files in the raster's own precision, or lossy VarDCT files in XYB, with ICC, EXIF, XMP and orientation. JxlWriterOptions follows cjxl's own knobs, so the same numbers give about the same files:

Option Default Does
Lossless null true writes Modular, false VarDCT; null keeps the kind of the JPEG XL file the raster came from and writes lossy otherwise
Effort 5 1 (fastest) to 10 (smallest), cjxl's -e
Quality 90 Lossy quality, 0 to 100, cjxl's -q scale; 90 is distance 1.0, visually lossless
Distance null A distance as cjxl's -d instead of a quality, 0.1 to 25
AlphaDistance 0 Lossy files: 0 keeps alpha exact, larger values code it lossy
KeepTransparentColors true Lossy files keep the color under fully transparent pixels; false gives smaller files
Progressive false Lossy files as cjxl -p makes them: a coarse image first that sharpens
PhotonNoiseIso null Film-like noise the decoder adds, as cjxl's --photon_noise_iso
FirstFrameOnly false Writes only the first frame of a multi-frame raster, as a still
ExtraChannels null Extra channels to write beside the pixels: depth, masks, thermal data, spot colors
// Lossless JPEG XL, about the size cjxl -d 0 makes at the same effort (1 fastest to 7 smallest, 5 by default).
using Raster photo = Raster.Load("duck.png");
photo.Save("duck-lossless.jxl", new JxlWriterOptions { Lossless = true, Effort = 7 });

Lossless files give back every pixel exactly, in the raster's own precision: 8 or 16-bit, half or single float, gray or RGB, with alpha when it is not opaque everywhere. The effort follows cjxl's: 1 is its fast path, 7 learns a context tree and tries palettes and color transforms, 8 and 9 learn from more properties, try more color transforms and weighted predictor settings and find LZ77 copies, and 10 tries every predictor under one context tree for the whole image, for files within a few bytes of cjxl's at each effort. Effort 10 is slow, as cjxl's is (seconds a megapixel), and keeps the whole image in memory. From effort 5, shapes that repeat, such as the letters of a screenshot, are coded once as patches. The ICC profile (or an enumerated sRGB, linear or HDR space), EXIF, XMP and orientation are kept; EXIF and XMP go in Brotli-compressed (brob) boxes, as cjxl writes them, in lossless and lossy files alike.

// Lossy JPEG XL at cjxl's default: quality 90, which is distance 1.0 (visually lossless).
using Raster photo = Raster.Load("duck.png");
photo.Save("duck-lossy.jxl", new JxlWriterOptions { Lossless = false });

// A distance as cjxl -d takes it instead of a quality, and more effort for a smaller file.
photo.Save("duck-small.jxl", new JxlWriterOptions { Lossless = false, Distance = 2.5f, Effort = 7 });

Lossy files are VarDCT in XYB, as cjxl writes them for PNG input, and come out within 5% of cjxl's size and Butteraugli score at the same settings (libjxl 0.12.0). Pick either a quality or a distance: Quality is cjxl's -q (90 by default, 100 lossless) and Distance its -d, where 1.0 is visually lossless and each step up gives a smaller file with more visible loss; a distance, when set, wins. Effort 6 adds the full block-size search and a per-block edge-preserving filter strength, 7 patches for repeated shapes such as text. From effort 8 the encoder decodes its own frame and measures it with Butteraugli, then spends bits where the distance asked for is exceeded and saves them elsewhere: two rounds at effort 8, four from 9, with slower entropy coding; effort 10 also tries every 32 × 32 block position and, from distance 10, downsamples iteratively. These efforts cost several times effort 7, as cjxl's do. Large images are coded in 2048-pixel chunks, as cjxl does, so memory stays bounded, except at efforts 8 and 9 above distance 0.5 and always at 10, which code the whole image at once.

A JPEG source is better recompressed than re-encoded: recompressing keeps its pixels exactly and the original file recoverable, where lossy encoding its decoded pixels loses detail a second time.

// Alpha stays exact in lossy files by default, and fully transparent pixels keep their color.
using Raster<Rgba8> logo = Raster.Load<Rgba8>("duck.png");
logo.Save("logo.jxl", new JxlWriterOptions { Lossless = false, Quality = 85 });

// Smaller: alpha coded lossy too, and the color under transparent pixels left to the encoder (as cjxl does).
logo.Save("logo-small.jxl", new JxlWriterOptions { Lossless = false, AlphaDistance = 1, KeepTransparentColors = false });

Alpha is an extra channel coded apart from the color, exact by default, so cut-outs keep their edges (below distance 10; from there cjxl and Egami code the whole image at half size). AlphaDistance above 0 codes it lossy as cjxl's --alpha_distance does. Fully transparent pixels keep their color unless KeepTransparentColors is false, which lets the encoder replace it, as libjxl does by default, for a smaller file.

A frame of more than eight groups (256-pixel squares) is coded group by group at lossless efforts 2, 8 and 9, each group with its own context model, as libjxl does by default (screenshots and other images with flat areas come out smaller that way), and then without patches, as with libjxl; effort 10 always codes the whole frame at once.

Animations

A raster with more than one frame is saved as an animation, through the same Save, lossless or lossy, as cjxl makes one from an APNG or GIF: each frame's FrameMetadata.Delay (in ticks of a millisecond, at least one) and the raster's LoopCount. Each frame is cut to the rectangle that changed from the one before. A lossless rectangle whose changed pixels are all opaque is blended over the canvas, its unchanged pixels made transparent; any other rectangle, and every lossy one, replaces the pixels, so lossy errors cannot pile up from frame to frame. Effort 1 codes animation frames at effort 2, as cjxl does. FirstFrameOnly writes the first frame alone. An animation is coded a whole frame at a time, so only Save writes one, not a streamed encode.

// A raster with several frames is saved as an animation: each frame cut to what changed, with its delay and the loop count.
using Raster animation = Raster.Load("loop.gif");
animation.Save("loop.jxl", new JxlWriterOptions { Lossless = true });

// Or only the first frame, as a still.
animation.Save("first.jxl", new JxlWriterOptions { Lossless = true, FirstFrameOnly = true });

Layers

JxlImage.WriteLayers writes JxlLayers as one frame each, for an editor: each at its own place on the canvas (offsets can be negative or reach past it), with its JxlLayerBlend mode (replace, blend, add, multiply-add or multiply), duration and name, blended onto the layers below. ReadLayers gives every field back, and Load gives the image they make. All layers take the first one's pixel format, with alpha when any layer has it; the file is lossless unless the options ask for lossy, and animated when a layer has a duration.

// Layers for an editor: each at its own place, blended onto the ones below, and named. ReadLayers gives them back.
using Raster<Rgba8> photo = Raster.Load<Rgba8>("duck.png");
Raster<Rgba8> badge = Raster.Create<Rgba8>(120, 60, new Rgba8(255, 200, 0, 160));
List<JxlLayer> layers =
[
    new(photo.As<Rgba8>(), name: "photo"),
    new(badge, x: -20, y: 30, blend: JxlLayerBlend.Blend, name: "badge"),
];
JxlImage.WriteLayers("layered.jxl", photo.Width, photo.Height, layers);
foreach (JxlLayer layer in layers)
{
    layer.Dispose();
}

// Load gives them blended; ReadLayers as they were written.
using Raster flat = Raster.Load("layered.jxl");
IReadOnlyList<JxlLayer> read = JxlImage.ReadLayers("layered.jxl");

Extra channels

ExtraChannels writes more channels beside the pixels: a JxlExtraChannelImage each, a JxlExtraChannel description (kind, name, 1 to 24 integer bits or a 16 or 32-bit float, subsampling 0 to 3, and a spot color's color) with its samples as a Raster<GrayF32> at the subsampled size. They are coded lossless in lossless and lossy files alike, after the raster's own alpha, and ReadExtraChannel gives them back exactly. Still images only. A CMYK raster (Cmyk8 or Cmyk16 with a CMYK ICC profile) keeps its black channel in lossless files too.

// A depth map written beside the color, 16 bits, at half the size each way.
using Raster photo = Raster.Load("duck.png");
using Raster<GrayF32> depth = Raster.Create<GrayF32>((photo.Width + 1) / 2, (photo.Height + 1) / 2);
for (int y = 0; y < depth.Height; y++)
{
    for (int x = 0; x < depth.Width; x++)
    {
        depth.Row(y)[x] = new GrayF32((float)y / depth.Height);
    }
}

JxlExtraChannel description = new() { Kind = JxlExtraChannelKind.Depth, Name = "depth", BitsPerSample = 16, Subsampling = 1 };
photo.Save("with-depth.jxl", new JxlWriterOptions { Lossless = true, ExtraChannels = [new JxlExtraChannelImage(description, depth)] });

// The channels of a loaded file are not on its raster: read them, and pass them on to keep them.
using Raster loaded = Raster.Load("with-depth.jxl");
JxlDetails details = (JxlDetails)loaded.Metadata.FormatDetails!;
using Raster<GrayF32> again = JxlImage.ReadExtraChannel("with-depth.jxl", 0);