PeachImage 0.4.2
dotnet add package PeachImage --version 0.4.2
NuGet\Install-Package PeachImage -Version 0.4.2
<PackageReference Include="PeachImage" Version="0.4.2" />
<PackageVersion Include="PeachImage" Version="0.4.2" />
<PackageReference Include="PeachImage" />
paket add PeachImage --version 0.4.2
#r "nuget: PeachImage, 0.4.2"
#:package PeachImage@0.4.2
#addin nuget:?package=PeachImage&version=0.4.2
#tool nuget:?package=PeachImage&version=0.4.2
PeachImage
Pure .NET image format readers and writers for commonly used image formats on the web.
Targets .NET 8.0 and .NET 10.0. No native interop — every codec is managed code, using modern .NET APIs
(System.Runtime.Intrinsics, Span<T>/ReadOnlySpan<T>) for performance instead of P/Invoke.
Status
- JPEG: decode (baseline sequential + progressive, grayscale/YCbCr/RGB/CMYK/YCCK, all standard chroma subsampling, restart markers) and encode (baseline sequential, grayscale/YCbCr) are implemented.
- BMP: decode (OS/2 1.x/2.x and Windows BITMAPINFOHEADER through BITMAPV5HEADER variants, 1/4/8bpp indexed color, 16/24/32bpp direct color, RLE4/RLE8 compression, arbitrary BI_BITFIELDS/BI_ALPHABITFIELDS masks) and encode (24bpp truecolor, 8bpp indexed grayscale with optional RLE8, 32bpp with an explicit alpha channel via BITMAPV4HEADER + BI_BITFIELDS) are implemented, including explicit alpha-channel support on both sides.
- PNG: decode and encode for all 5 color types (grayscale, truecolor, palette, grayscale+alpha,
truecolor+alpha) at every valid bit depth (1/2/4/8/16 — including via
Gray16/Rgb48/Rgba64pixel formats), Adam7 interlacing, palette +tRNStransparency (both per-entry and single-color-key), optional opt-in gamma correction (PngDecoderOptions.ScreenGamma), and the common ancillary chunks (gAMA/cHRM/sRGB/iCCP/pHYs/tEXt/zTXt/iTXt/tIME/bKGD). Encoding can build an indexed palette automatically (PngEncoderOptions.ColorMode, defaultAuto): lossless whenever the source has at mostMaxColors(default 256) distinct opaque colors and binary alpha, otherwise falling back to grayscale/truecolor(+alpha) unlessColorMode = Indexedforces palette output via median-cut quantization with optional Floyd-Steinberg dithering (Dither) — the same quantizer GIF encoding uses. - GIF: decode (GIF87a/GIF89a, interlacing, transparency, multi-frame animation with per-frame
disposal methods and the NETSCAPE2.0 loop count via
AnimatedImage.Load) and encode (median-cut palette quantization, optional Floyd-Steinberg dithering, animation) are implemented. - WebP: decode is implemented for both of WebP's bitstream codecs — VP8 (lossy) and VP8L
(lossless) — including alpha (
ALPHchunk / VP8L's own alpha) and animation (viaAnimatedImage.Load, including the loop count) in the RIFF "simple" and "extended" container formats. Encode supports both bitstreams: lossless (VP8L, the default) with predictor-transform selection, palette/color-indexing detection, subtract-green, and a color cache; and lossy (VP8, opt in viaWebpEncoderOptions { Lossless = false }) with quality-driven quantization. Alpha-bearing sources always encode as VP8L regardless ofLossless, since lossy WebP's alpha channel isn't implemented yet. Animated WebP encode (VP8X+ANIM/ANMF, viaAnimatedImage.Save) is also implemented, reusing the same per-frame VP8/VP8L encoder as still images; every frame is written full-canvas/non-blending, sinceAnimatedImageFrameonly ever carries a fully composited frame. - AVIF: decode is implemented for baseline still images — intra-frame AV1, the full in-loop filter
chain (deblocking, CDEF, loop restoration), HEIF
gridcomposite images, alpha via the auxiliary-item mechanism, and both 8-bit and 10-bit depth. Animated AVIF, film grain synthesis, gain maps, 12-bit depth, and palette/IntraBC mode remain unimplemented and throw a clearAvifUnsupportedFeatureExceptionrather than a silently wrong result. Encode is implemented for lossy, 8-bit, 4:2:0, opaque still images only (a singleav01item; no HEIFgrid/avisanimation on the output side even though decode supports reading them, and no partition-tree size search yet — every block is a fixed 8x8 with a real intra-mode decision among DC/vertical/horizontal/smooth/Paeth candidates). Alpha-bearing sources and higher bit depths are rejected with a clear exception rather than silently dropped or downsampled. - TIFF: decode only (no encode). Covers both byte orders (
II/MM), uncompressed/LZW/PackBits compression, 1/2/4/8/16-bit depth, and grayscale (WhiteIsZero/BlackIsZero), RGB (with optional straight or premultiplied alpha), palette, and CMYK color, including the Predictor=2 horizontal-differencing variant LZW-compressed files commonly use. Tiled organization, planar (non-chunky) storage, any compression other than none/LZW/PackBits, BigTIFF, floating-point/signed samples, and photometric interpretations outside that set (YCbCr, LogLuv, Lab) are deliberately out of scope and throw a clearTiffUnsupportedFeatureExceptionrather than a silently wrong result — the goal is correctness on real-world scanner/export-tool output, not every TIFF extension ever specified. - Other formats are not yet implemented. The public API (
Image,AnimatedImagefor multi-frame formats like GIF) is designed to support them without breaking changes when they're added. Codec selection is internal — there's no format-specific type or registration step in the public API.
See LIBRARY_COMPARISON.md for performance numbers against SkiaSharp.
Installing PeachImage
Install the PeachImage package from nuget.org
dotnet add package PeachImage
Usage
Single-frame images
The format is auto-detected from the file's contents for every operation below — no setup call needed.
using PeachImage;
using PeachImage.Formats.Jpeg;
// Load, inspect, and convert between formats.
using var image = Image.Load("photo.webp");
Console.WriteLine($"{image.Width}x{image.Height} {image.PixelFormat}");
using var output = File.Create("resaved.jpg");
image.Save(output, "jpeg", new JpegEncoderOptions { Quality = 85 });
using PeachImage;
// Read dimensions/format without decoding pixel data.
using var stream = File.OpenRead("photo.avif");
ImageInfo info = Image.Identify(stream);
Console.WriteLine($"{info.Width}x{info.Height} {info.PixelFormat} ({info.FormatName})");
using PeachImage;
// Zero-copy access to the decoded pixel buffer.
using var image = Image.Load("photo.png");
Span<byte> pixels = image.GetPixelSpan();
Span<byte> firstRow = image.GetRowSpan(0);
Bytes already in memory (e.g. a buffered upload) load directly — no need to wrap them in a MemoryStream
first; a byte[] converts implicitly to ReadOnlySpan<byte>, and decoding reads straight out of that
memory with no intermediate copy:
using PeachImage;
byte[] uploadedBytes = await ReadUploadIntoMemoryAsync();
using var image = Image.Load(uploadedBytes);
SaveAsync exists for async I/O call paths. Encoding itself is CPU-bound, not I/O-bound, so only the
actual stream/file write is awaited — same as LoadAsync otherwise:
using PeachImage;
using var output = File.Create("resaved.jpg");
await image.SaveAsync(output, "jpeg", new JpegEncoderOptions { Quality = 85 });
Disposal & buffer pooling
Image implements IDisposable: most instances rent their pixel buffer from a shared ArrayPool, and
Dispose returns it for reuse by the next decode/resize/etc. This is a performance optimization, not a
correctness requirement — an un-disposed Image is simply garbage-collected like any other object, with
no leak or corruption risk. It matters most under concurrent load (e.g. a service resizing many uploads
at once), where reusing pooled buffers meaningfully cuts allocation and GC pressure compared to a fresh
buffer per call. AnimatedImage/AnimatedImageFrame don't need disposal: a frame pulled from
AnimatedImage.Frames aliases decoder-internal state rather than owning a pooled buffer (disposing it
anyway is a safe no-op), and only AnimatedImageFrame.Clone()/Image.Clone() results own one.
Animated images
Multi-frame formats (GIF and WebP) use AnimatedImage instead, with the same load/save shape:
using PeachImage;
using PeachImage.Formats.Gif;
var animation = AnimatedImage.Load("clip.gif");
foreach (AnimatedImageFrame frame in animation.Frames)
{
Console.WriteLine($"{frame.Duration.TotalMilliseconds}ms, disposal={frame.Disposal}");
}
using var output = File.Create("resaved.gif");
animation.Save(output, "gif", new GifEncoderOptions { MaxColors = 128, Dither = true });
Animated WebP works the same way (AnimatedImage.Save(stream, "webp", new WebpEncoderOptions())); WebP's
single dispose-to-background bit means FrameDisposalMethod.RestoreToPrevious collapses to
DoNotDispose on round-trip through WebP, unlike GIF which represents all three disposal methods natively.
Resizing
Image.Resize/AnimatedImage.Resize support 15 resampling filters via ResamplingFilter — Bicubic
is the default; also available: Box, CatmullRom, Hermite, Lanczos2/Lanczos3/Lanczos5/Lanczos8,
MitchellNetravali, NearestNeighbor, Robidoux, RobidouxSharp, Spline, Bilinear, and Welch.
using PeachImage;
using var image = Image.Load("photo.jpg");
// Bicubic by default.
using var thumbnail = image.Resize(200, 150);
// Or pick a specific filter.
using var sharpened = image.Resize(200, 150, new ResizeOptions { Filter = ResamplingFilter.Lanczos3 });
// ResizeMode.Max treats width/height as a bounding box instead of an exact target: scales down to the
// largest size that fits while preserving aspect ratio, and never upscales — if the source already fits,
// the same instance is returned unchanged rather than allocating a needless copy (so this may end up
// disposing `image` itself — safe, since disposing twice is a no-op, but don't keep using `image`
// afterward without checking for that case first).
using var thumbnailWithinBox = image.Resize(200, 200, new ResizeOptions { Mode = ResizeMode.Max });
AnimatedImage.Resize resizes every frame — lazily, as Frames is enumerated — preserving each frame's
duration and disposal method; ResizeOptions.Mode works the same way there too:
using PeachImage;
var animation = AnimatedImage.Load("clip.gif");
var resized = animation.Resize(160, 120, new ResizeOptions { Filter = ResamplingFilter.MitchellNetravali });
using var output = File.Create("resized.gif");
resized.Save(output, "gif");
Building & testing
dotnet build PeachImage.slnx
dotnet test PeachImage.slnx
The first dotnet test run automatically fetches JPEG, BMP, PNG, and TIFF test corpora (the Imazen
codec-corpus conformance sets, image-rs/jpeg-decoder's test assets, and — for BMP — the bmp-conformance
subset of codec-corpus, itself generated from Jason Summers' bmpsuite; for
PNG — the pngsuite subset of codec-corpus, a mirror of Willem van Schaik's classic PngSuite conformance
set; for TIFF — the tiff-conformance subset of codec-corpus, sourced from libtiff's, image-tiff's, and
image-rs's own test suites) into the gitignored tests/corpus/ directory — no separate script needed. Set
PEACHIMAGE_SKIP_CORPUS_FETCH=1 to skip network access; corpus-driven tests report as skipped rather than
failing.
TIFF also has a decode-correctness check against ffmpeg's independent TIFF decoder (SkiaSharp has no TIFF
codec, so it can't serve as the differential oracle the other formats' corpus tests use). This baseline is
checked in (tests/PeachImage.Tests/Formats/Tiff/Corpus/TiffFfmpegReference.baseline.tsv) and regenerating
it requires ffmpeg/ffprobe on PATH — set PEACHIMAGE_TIFF_FFMPEG_BASELINE=write and run
dotnet test --filter TiffFfmpegReferenceTests, then review the diff before committing. Normal test runs
never invoke ffmpeg; they only compare against the checked-in baseline.
AVIF and WebP decode are additionally checked against ffmpeg's own, independent decoders (libdav1d for
AVIF; libwebp itself for WebP) via a checked-in pixel baseline (AvifFfmpegReference.baseline.tsv/
WebpFfmpegReference.baseline.tsv) — a real correctness oracle, not just a self-referential "did the decoder's
output change" regression check. For AVIF this is the only independent oracle available at all (SkiaSharp,
this repo's oracle for the other bitmap formats, has no AVIF codec); for WebP it's a supplementary cross-check
alongside the existing SkiaSharp differential, scoped to single-frame VP8L (lossless) files, where the
comparison can be exact rather than tolerance-based (see AvifFfmpegReferenceBaseline's and
WebpFfmpegReferenceBaseline's own remarks for why AVIF needs a tolerance and WebP's lossy bitstream is out of
scope for this specific check). Normal test runs only read the checked-in baseline and never invoke ffmpeg;
regenerating it after a corpus or decoder change requires ffmpeg/ffprobe on PATH and
PEACHIMAGE_AVIF_FFMPEG_BASELINE=write/PEACHIMAGE_WEBP_FFMPEG_BASELINE=write respectively.
Benchmarking
dotnet run -c Release --project bench/PeachImage.Benchmarks
Compares PeachImage's decode/encode throughput against SkiaSharp (a dev-only dependency of the benchmark project only — never referenced by the shipped library). See LIBRARY_COMPARISON.md for the latest results.
License
MIT — see LICENSE. One algorithm's numerical structure (the AAN fast DCT/IDCT butterfly wiring) was referenced from libjpeg-turbo during implementation; see THIRD-PARTY-LICENSES.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- System.IO.Hashing (>= 9.0.0)
-
net8.0
- System.IO.Hashing (>= 9.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on PeachImage:
| Package | Downloads |
|---|---|
|
PeachPDF
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.