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);