Recursive AI

Migrate from SkiaSharp to OneImage

Port SkiaSharp code to OneImage: SKBitmap and SKCanvas to Image and Canvas, resize, crop, EXIF orientation, encoders, drawing, text, SVG and ASP.NET Core.

To migrate from SkiaSharp to OneImage, you replace a native graphics engine with one managed .NET library. SKBitmap, SKImage and SKCodec become a single immutable Image. SKSurface and SKCanvas become a Canvas. SKPaint splits into a Brush for fills and a Pen for strokes, and there’s no libSkiaSharp binary to ship for each platform. This page puts common SkiaSharp code next to its OneImage equivalent. Every sample on both sides was compiled and run on .NET 10 on Linux x64, using SkiaSharp 4.153.1 (with SkiaSharp.NativeAssets.Linux.NoDependencies 4.153.1 and Svg.Skia 5.2.3) and OneImage 1.10.1 and its add-on packages, all on the same input files.

Most teams looking for a SkiaSharp alternative want to stop deploying native assets. The SkiaSharp 4.153.1 package lists only the macOS and Windows native-asset packages as dependencies for net10.0. Its package documentation says the Linux assets must be added manually, to the application project. Our SkiaSharp on Linux and Docker guide covers the errors that follow when they aren’t. If you use SkiaSharp for GPU rendering or UI controls, read what SkiaSharp has that OneImage doesn’t first.

Swap the packages

TerminalNuGet
dotnet remove package SkiaSharp
dotnet remove package SkiaSharp.NativeAssets.Linux.NoDependencies   # or SkiaSharp.NativeAssets.Linux
dotnet remove package Svg.Skia                                      # if you render SVG
 
dotnet add package RecursiveAI.OneImage          # decode, resize, encode, drawing, Canvas
dotnet add package RecursiveAI.OneImage.Text     # fonts and text
dotnet add package RecursiveAI.OneImage.Svg      # SVG input
dotnet add package RecursiveAI.OneImage.Pdf      # PDF pages as images
dotnet add package RecursiveAI.OneImage.Web      # ASP.NET Core middleware

Add only the packages you need. Replace using SkiaSharp; with using OneImage;, plus using OneImage.Drawing; for paths, brushes and pens. OneImage targets net10.0 only and is licensed MIT OR Apache-2.0. SkiaSharp’s NuGet listing also covers .NET Framework 4.6.2, .NET Standard 2.0 and the mobile targets, so check your target frameworks before you start. The getting-started page has the install details.

Concepts that differ

SkiaSharpOneImage
SKBitmap: a decoded, mutable raster in memoryImage: an immutable description of a pipeline; pixels are computed tile by tile when you save or read them
SKImage: immutable, possibly backed by encoded data or the GPUThe same Image; there is one image type
SKCodec for the header, the EXIF origin and scaled decodingImage.Identify for the header; orientation is applied at Load; a JPEG is decoded at 1/2, 1/4 or 1/8 scale automatically when the output is small enough
SKSurface + SKCanvas to drawCanvas, a mutable tiled surface, or the same drawing calls on an Image, recorded lazily
SKPaint carries color, style, stroke width and effectsA Brush (fills) or Pen (strokes) passed to each call
Encode(format, quality) returns SKDataSave(path or stream), with options in SaveOptions
SKColor, color types such as Bgra8888 and Rgba8888, premultiplied or notRgba32 and other pixel structs, which appear only when you read or write pixels
Native libSkiaSharp per OS and architectureOne managed assembly, the same on every platform

Microsoft’s API reference describes SKBitmap as a raster bitmap. It says the content of an SKImage “is always immutable” and that it may store its data on the GPU or as drawing commands. OneImage has one Image type. Each operation (Resize, Crop, Fill) returns a new Image that records what to do, and nothing is decoded until you save it or read its pixels. That’s how a thumbnail of a large JPEG avoids holding the full-size picture in memory. The OneImage guide explains the lazy model.

Disposal

In SkiaSharp 4.153.1, SKBitmap, SKImage, SKCanvas, SKSurface, SKPaint, SKPath, SKData, SKCodec, SKFont and SKTypeface all derive from SKNativeObject and are IDisposable, because each wraps a native object. That’s why SkiaSharp code is full of using. In OneImage, Image has no Dispose. Only Canvas is disposable, and disposing it returns its pooled tile memory. Forgetting to dispose a canvas is safe, because the garbage collector reclaims the memory, just later.

Native assets

We published the resize program below for linux-x64 with each library. The SkiaSharp build carried a 12.2 MB libSkiaSharp.so from the NoDependencies package next to SkiaSharp.dll. Published without a runtime identifier, its runtimes folder held a libSkiaSharp build for each of 17 OS and architecture combinations. The OneImage build carried OneImage.dll (1.5 MB), the same file on Windows, macOS, Linux, Alpine and ARM. There’s no NativeAssets package to pick, no fontconfig question, and nothing to install in a container image. OneImage’s decoders are bounds-checked C#, and it supports trimming and NativeAOT.

Load, resize and save

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKBitmap input = SKBitmap.Decode("photo.jpg");
int height = input.Height * 800 / input.Width;
 
using SKBitmap resized = input.Resize(new SKImageInfo(800, height), new SKSamplingOptions(SKCubicResampler.Mitchell));
using SKData jpeg = resized.Encode(SKEncodedImageFormat.Jpeg, 85);
File.WriteAllBytes("photo-800.jpg", jpeg.ToArray());
 
Console.WriteLine($"{resized.Width}x{resized.Height}");
OneImage1.10.1 · .NET 10
using OneImage;
 
Image resized = Image.Load("photo.jpg").Resize(width: 800);
resized.Save("photo-800.jpg", new SaveOptions { EncoderOptions = new JpegEncoderOptions { Quality = 85 } });
 
Console.WriteLine($"{resized.Width}x{resized.Height}");

From our 3000×3002 photo, SkiaSharp wrote 800×800 and OneImage wrote 800×801. The integer division truncates 800.53 to 800, and OneImage rounds the computed side to the nearest pixel. Pass height: as well if you need an exact size. With only width:, the aspect ratio is kept. The output format follows the file extension.

SKSamplingOptions maps to the filter: argument of Resize:

SkiaSharpOneImage filter:
new SKSamplingOptions(SKFilterMode.Nearest)ResamplingFilter.Nearest
new SKSamplingOptions(SKFilterMode.Linear)ResamplingFilter.Bilinear
new SKSamplingOptions(SKCubicResampler.Mitchell)ResamplingFilter.Mitchell
new SKSamplingOptions(SKCubicResampler.CatmullRom)ResamplingFilter.CatmullRom
(no equivalent)ResamplingFilter.Auto, the default: Lanczos3 when shrinking, Catmull-Rom when enlarging

SkiaSharp has no notion of fitting a box, so code that crops to fill a square usually computes a source rectangle and calls DrawBitmap. In OneImage that’s a single call, Resize(width: 400, height: 400, fit: Fit.Cover). Fit.Contain (the default), Fit.Pad with background:, and Fit.Stretch cover the other cases, and anchor: chooses which part a crop keeps. OneImage resamples in linear light, so its output can differ slightly from SkiaSharp’s at edges and in gradients. Our guide to resizing images in C# has more.

Crop

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKImage image = SKImage.FromEncodedData("photo.jpg");
using SKImage cropped = image.Subset(SKRectI.Create(100, 80, 600, 400));
using SKData png = cropped.Encode(SKEncodedImageFormat.Png, 100);
File.WriteAllBytes("crop.png", png.ToArray());
 
Console.WriteLine($"{cropped.Width}x{cropped.Height}");
OneImage1.10.1 · .NET 10
using OneImage;
 
Image cropped = Image.Load("photo.jpg").Crop(100, 80, 600, 400);
cropped.Save("crop.png");
 
Console.WriteLine($"{cropped.Width}x{cropped.Height}");

Both wrote a 600×400 PNG, and the two crops were pixel-identical. SKRectI.Create(x, y, width, height) becomes four integers or an ImageRect, with the same meaning. OneImage never decodes the tiles outside a crop. See cropping an image in C# for crops by aspect ratio and around a focal point.

EXIF orientation and rotation

SKBitmap.Decode returns the pixels in their stored layout. A phone photo taken in portrait often comes out sideways, because the camera stored it landscape and recorded the turn in the EXIF orientation tag. SkiaSharp reports that tag as SKCodec.EncodedOrigin, and applying it is up to you:

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKCodec codec = SKCodec.Create("portrait.jpg");
using SKBitmap stored = SKBitmap.Decode(codec);
SKEncodedOrigin origin = codec.EncodedOrigin;
 
// Origins 5 to 8 (LeftTop .. LeftBottom) swap width and height.
bool swap = (int)origin >= 5;
int w = swap ? stored.Height : stored.Width;
int h = swap ? stored.Width : stored.Height;
 
SKMatrix matrix = origin switch
{
    SKEncodedOrigin.TopRight    => new SKMatrix(-1, 0, w, 0, 1, 0, 0, 0, 1),
    SKEncodedOrigin.BottomRight => new SKMatrix(-1, 0, w, 0, -1, h, 0, 0, 1),
    SKEncodedOrigin.BottomLeft  => new SKMatrix(1, 0, 0, 0, -1, h, 0, 0, 1),
    SKEncodedOrigin.LeftTop     => new SKMatrix(0, 1, 0, 1, 0, 0, 0, 0, 1),
    SKEncodedOrigin.RightTop    => new SKMatrix(0, -1, w, 1, 0, 0, 0, 0, 1),
    SKEncodedOrigin.RightBottom => new SKMatrix(0, -1, w, -1, 0, h, 0, 0, 1),
    SKEncodedOrigin.LeftBottom  => new SKMatrix(0, 1, 0, -1, 0, h, 0, 0, 1),
    _                           => SKMatrix.Identity,
};
 
using var upright = new SKBitmap(w, h);
using (var canvas = new SKCanvas(upright))
{
    canvas.SetMatrix(matrix);
    canvas.DrawBitmap(stored, 0, 0, SKSamplingOptions.Default);
}
 
using SKData jpeg = upright.Encode(SKEncodedImageFormat.Jpeg, 90);
File.WriteAllBytes("portrait-upright.jpg", jpeg.ToArray());
 
Console.WriteLine($"{origin}: stored {stored.Width}x{stored.Height}, upright {upright.Width}x{upright.Height}");
OneImage1.10.1 · .NET 10
using OneImage;
 
// Load applies the EXIF orientation: Width and Height are the upright size.
Image upright = Image.Load("portrait.jpg");
upright.Save("portrait-upright.jpg", new SaveOptions { EncoderOptions = new JpegEncoderOptions { Quality = 90 } });
 
// An explicit turn or mirror is one call.
upright.Rotate(90).Flip(FlipAxis.Horizontal).Save("portrait-turned.png");
 
Console.WriteLine($"upright {upright.Width}x{upright.Height}");

Our test photo is stored as 1200×800 with orientation 6. SkiaSharp printed RightTop: stored 1200x800, upright 800x1200, and OneImage printed upright 800x1200. The two upright images were pixel-identical. We also ran the SkiaSharp matrix table on all eight orientations, and each result matched the upright reference. When you port, delete the EncodedOrigin code. If it stays in front of OneImage, the picture is turned twice. To get the stored layout and the original tag, load with new LoadOptions { AutoOrient = false }. EXIF orientation in C# goes into the details.

Encode JPEG, PNG and WebP with quality

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKBitmap bitmap = SKBitmap.Decode("photo.jpg");
 
using (SKData jpeg = bitmap.Encode(SKEncodedImageFormat.Jpeg, 85))
    File.WriteAllBytes("photo-q85.jpg", jpeg.ToArray());
 
using (SKData webp = bitmap.Encode(SKEncodedImageFormat.Webp, 80))
    File.WriteAllBytes("photo-q80.webp", webp.ToArray());
 
using (SKData png = bitmap.Encode(SKEncodedImageFormat.Png, 100))
    File.WriteAllBytes("photo.png", png.ToArray());
 
// Lossless WebP: SKWebpEncoderOptions go through SKPixmap.Encode.
using (SKPixmap pixmap = bitmap.PeekPixels())
using (SKData? lossless = pixmap.Encode(new SKWebpEncoderOptions(SKWebpEncoderCompression.Lossless, 75)))
    File.WriteAllBytes("photo-lossless.webp", lossless!.ToArray());
 
foreach (string f in new[] { "photo-q85.jpg", "photo-q80.webp", "photo.png", "photo-lossless.webp" })
    Console.WriteLine($"{f}: {new FileInfo(f).Length:N0} bytes");
OneImage1.10.1 · .NET 10
using OneImage;
 
Image image = Image.Load("photo.jpg");
 
image.Save("photo-q85.jpg", new SaveOptions { EncoderOptions = new JpegEncoderOptions { Quality = 85 } });
image.Save("photo-q80.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Quality = 80 } });
image.Save("photo.png");
image.Save("photo-lossless.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Lossless = true } });
 
// Streams have no extension, so name the format.
using var buffer = new MemoryStream();
image.Save(buffer, WebPFormat.Instance);
 
foreach (string f in new[] { "photo-q85.jpg", "photo-q80.webp", "photo.png", "photo-lossless.webp" })
    Console.WriteLine($"{f}: {new FileInfo(f).Length:N0} bytes");

From the same 3000×3002 photo:

OutputSkiaSharp 4.153.1OneImage 1.10.1
JPEG, quality 851,279,007 bytes1,296,726 bytes
WebP, quality 80600,408 bytes619,106 bytes
PNG12,869,113 bytes12,873,472 bytes
Lossless WebP11,739,508 bytes11,159,354 bytes

The two lossless WebP files decode to identical pixels. Quality numbers are each encoder’s own scale, so compare sizes and looks on your images rather than carrying the numbers over. The names map one to one. SKEncodedImageFormat.Jpeg, .Png and .Webp become the file extension, or JpegFormat.Instance, PngFormat.Instance and WebPFormat.Instance for streams. SKJpegEncoderOptions and SKWebpEncoderOptions become JpegEncoderOptions and WebPEncoderOptions inside SaveOptions. OneImage also writes GIF (animated too), TIFF, BMP and ICO. On save it keeps the ICC profile and EXIF, and corrects the EXIF size and orientation tags to match the saved image. Our WebP guide covers the WebP settings.

Thumbnails

A fast SkiaSharp thumbnail decodes the JPEG at reduced scale with SKCodec, then resizes the rest of the way:

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKCodec codec = SKCodec.Create("photo.jpg");
 
// Decode at the smallest scale the JPEG decoder supports that is still at least 400 px wide.
SKSizeI scaled = codec.Info.Size;
foreach (float scale in new[] { 0.125f, 0.25f, 0.5f })
{
    SKSizeI candidate = codec.GetScaledDimensions(scale);
    if (candidate.Width >= 400) { scaled = candidate; break; }
}
 
using var decoded = new SKBitmap(codec.Info.WithSize(scaled.Width, scaled.Height));
codec.GetPixels(decoded.Info, decoded.GetPixels());
 
int height = decoded.Height * 400 / decoded.Width;
using SKBitmap thumb = decoded.Resize(new SKImageInfo(400, height), new SKSamplingOptions(SKCubicResampler.Mitchell));
using SKData webp = thumb.Encode(SKEncodedImageFormat.Webp, 80);
File.WriteAllBytes("thumb.webp", webp.ToArray());
 
Console.WriteLine($"decoded {decoded.Width}x{decoded.Height}, thumbnail {thumb.Width}x{thumb.Height}");
OneImage1.10.1 · .NET 10
using OneImage;
 
// Upright, shrink-on-load for JPEG, sRGB, EXIF/XMP/IPTC (and GPS) removed.
Image thumb = Image.Thumbnail("photo.jpg", 400);
thumb.Save("thumb.webp", new SaveOptions { EncoderOptions = new WebPEncoderOptions { Quality = 80 } });
 
Console.WriteLine($"thumbnail {thumb.Width}x{thumb.Height}");

SkiaSharp decoded at 750×751 and resized to 400×400, and OneImage wrote 400×400 directly. The loop in the SkiaSharp version is there for a reason. GetScaledDimensions returns a size that “approximately” matches the scale, and asking it for 400 / 3000 returned 375×376, below the target, which then had to be enlarged. Image.Thumbnail does the scale choice for you. It also applies EXIF orientation, converts to sRGB, never enlarges unless you pass upscale: true, and removes EXIF, XMP and IPTC, so GPS positions don’t leak into public thumbnails. Pass keepMetadata: true if you want the metadata kept.

Drawing shapes, paths and text

Vector drawing is in the core OneImage package, and text is in RecursiveAI.OneImage.Text. This draws the same picture both ways:

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKSurface surface = SKSurface.Create(new SKImageInfo(800, 400));
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.White);
 
using var blue = new SKPaint { Color = new SKColor(30, 90, 200), IsAntialias = true };
canvas.DrawCircle(200, 190, 120, blue);
 
using SKPath triangle = SKPath.ParseSvgPathData("M420 300 L540 70 L660 300 Z");
using var red = new SKPaint
{
    Color = new SKColor(200, 30, 30),
    IsAntialias = true,
    Style = SKPaintStyle.Stroke,
    StrokeWidth = 8,
    StrokeJoin = SKStrokeJoin.Round,
};
canvas.DrawPath(triangle, red);
 
using var dashed = new SKPaint
{
    Color = SKColors.Gray,
    IsAntialias = true,
    Style = SKPaintStyle.Stroke,
    StrokeWidth = 4,
    PathEffect = SKPathEffect.CreateDash([12, 4], 0),
};
canvas.DrawLine(40, 340, 760, 340, dashed);
 
using SKTypeface typeface = SKTypeface.FromFile("NotoSans-Regular.ttf");
using var font = new SKFont(typeface, 32);
using var black = new SKPaint { Color = SKColors.Black, IsAntialias = true };
canvas.DrawText("Drawn in C#", 40, 385, SKTextAlign.Left, font, black);   // y is the baseline
 
using SKImage snapshot = surface.Snapshot();
using SKData png = snapshot.Encode(SKEncodedImageFormat.Png, 100);
File.WriteAllBytes("drawing.png", png.ToArray());
OneImage + OneImage.Text1.10.1 · .NET 10
using System.Numerics;
using OneImage;
using OneImage.Drawing;
using OneImage.Text;
using Path = OneImage.Drawing.Path;
 
using var canvas = new Canvas(800, 400, PixelFormat.Rgba8);
canvas.Clear(new Rgba32(255, 255, 255, 255));
 
canvas.Fill(new Rgba32(30, 90, 200, 255), Path.Circle(200, 190, 120));
 
Path triangle = Path.ParseSvg("M420 300 L540 70 L660 300 Z");
canvas.Draw(new Pen(Brush.Solid(new Rgba32(200, 30, 30, 255)), 8, StrokeJoin.Round), triangle);
 
var dashed = new Pen(Brush.Solid(new Rgba32(128, 128, 128, 255)), 4, dashPattern: [12, 4]);
canvas.DrawLine(dashed, new Vector2(40, 340), new Vector2(760, 340));
 
var fonts = new FontCollection();
Font font = fonts.Add("NotoSans-Regular.ttf").CreateFont(32);
var options = new TextOptions(font) { Origin = new(40, 385), VerticalAlignment = VerticalAlignment.Bottom };
canvas.DrawText("Drawn in C#", options, new Rgba32(0, 0, 0, 255));
 
canvas.Snapshot().Save("drawing.png");

The two 800×400 PNGs show the same picture. Differences are confined to anti-aliased edges and glyph rendering, a mean of 0.65 levels across the image. The text ink sits within 3 pixels of SkiaSharp’s. The calls map like this, with details in the drawing and text docs:

SkiaSharpOneImage
SKSurface.Create(new SKImageInfo(w, h)), surface.Canvasnew Canvas(w, h, PixelFormat.Rgba8)
canvas.Clear(color)canvas.Clear(color)
DrawCircle, DrawRect, DrawPath with a fill paintcanvas.Fill(color or brush, Path.Circle(...)), Path.Rectangle, Path.RoundedRectangle, Path.Ellipse, Path.Star
SKPath.ParseSvgPathData, SKPath methodsPath.ParseSvg, PathBuilder
SKPaintStyle.Stroke, StrokeWidth, StrokeJoin, StrokeCapnew Pen(brush, width, join, cap) with canvas.Draw(pen, path)
SKPathEffect.CreateDash(intervals, phase)dashPattern: and dashOffset: on Pen, or Pen.Dash
SKShader.CreateLinearGradient, CreateRadialGradientBrush.LinearGradient, Brush.RadialGradient
canvas.ClipPath(path)clip: on Fill and Draw, or Clip(path, i => ...) to apply any operation inside a path
SKTypeface.FromFile, new SKFont(typeface, size)new FontCollection().Add(path).CreateFont(size)
canvas.DrawText(text, x, y, align, font, paint)canvas.DrawText(text, new TextOptions(font) { Origin = ... }, color)
surface.Snapshot()canvas.Snapshot(), which is O(1) and returns an Image

Three differences to watch for:

  • Anti-aliasing is on by default in OneImage. In SkiaSharp 4.153.1, a new SKPaint has IsAntialias set to false. OneImage’s antialias: parameter defaults to true.
  • Text is positioned by its box, not by the baseline. SkiaSharp’s y is the baseline. OneImage places the text block relative to Origin according to VerticalAlignment (default Top). The sample uses Bottom to land near the same line.
  • Text layout does more. OneImage.Text wraps at WrappingLength, aligns lines, shapes complex scripts (Arabic, Indic and others), runs the Unicode bidirectional algorithm, falls back across font families and draws COLR color glyphs. It reads TrueType, OpenType CFF, collections, WOFF and WOFF2. SystemFonts finds installed fonts by scanning the standard directories, without native calls, so it doesn’t depend on the fontconfig library.

Drawing works lazily on an Image too. Image.Load("photo.jpg").Fill(color, path).Save(...) only computes the tiles the path touches.

SVG: Svg.Skia to OneImage.Svg

If your SkiaSharp code renders SVG through Svg.Skia (the sample below follows its README), the OneImage replacement is the RecursiveAI.OneImage.Svg package, which registers SVG as an image format:

Svg.Skia5.2.3 · SkiaSharp 4.153.1
using SkiaSharp;
using Svg.Skia;
 
using var svg = new SKSvg();
if (svg.Load("icon.svg") is { } picture)
{
    float scale = 512f / picture.CullRect.Width;
    svg.Save("icon-512.png", SKColors.Empty, SKEncodedImageFormat.Png, 100, scale, scale);
}
OneImage.Svg1.10.1 · .NET 10
using OneImage;
using OneImage.Svg;
 
var load = new LoadOptions { Configuration = new ImageConfiguration().Add(SvgFormat.Instance) };
 
// Drawn at 512 px wide, not scaled up from the 24 px natural size.
Image.Load("icon.svg", load).Resize(width: 512).Save("icon-512.png");

Both drew our 24×24 icon at 512×512, with a mean difference of 0.09 levels. The Svg.Skia 5.2.3 listing depends on SkiaSharp and on HarfBuzzSharp with its native assets, so it brings native binaries of its own. OneImage.Svg is managed code. It draws at the requested size instead of scaling up a small render, and it never fetches files or URLs named by the document. See converting SVG to PNG in C# and the SVG docs for what it supports.

PDF pages

SkiaSharp’s SKDocument.CreatePdf writes PDF documents. Turning existing PDF pages into images is a different job. In OneImage it’s the RecursiveAI.OneImage.Pdf package, which treats each page as a frame of an Image:

OneImage.Pdf1.10.1 · .NET 10
using OneImage;
using OneImage.Pdf;
 
var config = new ImageConfiguration().Add(new PdfFormat(new PdfRenderOptions { Dpi = 150 }));
Image pdf = Image.Load("fw9.pdf", new LoadOptions { Configuration = config });   // every page is a frame
 
Image page1 = pdf.Frames[0].ToImage();
page1.Save("page1.png");
page1.Resize(width: 400).Save("page1-thumb.webp");   // drawn at 400 px
 
Console.WriteLine($"{pdf.Frames.Count} pages, page 1 at {page1.Width}x{page1.Height}");

On the six-page IRS Form W-9, this printed 6 pages, page 1 at 1275x1650, which is a US Letter page at 150 dpi. The thumbnail came out 400×518 and was drawn at that size rather than reduced from the full page. OneImage doesn’t write PDF documents, so keep SKDocument for that. There’s more in converting a PDF to an image in C# and the PDF docs.

Pixel access

SkiaSharp4.153.1 · .NET 10
using SkiaSharp;
 
using SKCodec codec = SKCodec.Create("logo.png");
SKImageInfo info = codec.Info.WithColorType(SKColorType.Rgba8888).WithAlphaType(SKAlphaType.Unpremul);
using SKBitmap bitmap = SKBitmap.Decode(codec, info);
 
// 4 bytes per pixel, R G B A; rows may be padded, so step by RowBytes.
Span<byte> pixels = bitmap.GetPixelSpan();
for (int y = 0; y < bitmap.Height; y++)
{
    Span<byte> row = pixels.Slice(y * bitmap.RowBytes, bitmap.Width * 4);
    for (int x = 0; x < row.Length; x += 4)
    {
        byte gray = (byte)((row[x] * 77 + row[x + 1] * 150 + row[x + 2] * 29) >> 8);
        row[x] = row[x + 1] = row[x + 2] = gray;
    }
}
 
using SKData png = bitmap.Encode(SKEncodedImageFormat.Png, 100);
File.WriteAllBytes("logo-gray.png", png.ToArray());
OneImage1.10.1 · .NET 10
using OneImage;
 
using Canvas canvas = Image.Load("logo.png").ToCanvas();
 
// T must match the canvas format: this PNG decodes to Rgba8, so T is Rgba32.
using (PixelLock<Rgba32> pixels = canvas.Lock<Rgba32>(new ImageRect(0, 0, canvas.Width, canvas.Height), LockAccess.ReadWrite))
{
    for (int y = 0; y < pixels.Height; y++)
    {
        Span<Rgba32> row = pixels.GetRow(y);
        for (int x = 0; x < row.Length; x++)
        {
            Rgba32 p = row[x];
            byte gray = (byte)((p.R * 77 + p.G * 150 + p.B * 29) >> 8);
            row[x] = new Rgba32(gray, gray, gray, p.A);
        }
    }
}
 
canvas.Snapshot().Save("logo-gray.png");

The two grayscale PNGs had identical pixels. In SkiaSharp the byte layout depends on the color type and alpha type. Here, SKBitmap.Decode("logo.png") without an SKImageInfo returned Bgra8888 premultiplied, which is why the sample asks for Rgba8888 unpremultiplied. In OneImage you pick a pixel struct (Rgba32, Rgb24, Gray8 and others) that must match the canvas format. Lock never converts, and a mismatch throws an exception naming the right struct. PixelLock.GetRow hands you one row as a Span, so there’s no RowBytes arithmetic. For read-only work, image.ToArray<Rgba32>() and CopyPixelsTo convert from any format. Image.FromPixels<Rgba32>(pixels, width, height) goes the other way, replacing SKBitmap.InstallPixels or a copy into GetPixels().

Serving images in ASP.NET Core

SkiaSharp leaves the web side to you, so a resizing endpoint is usually written by hand:

SkiaSharp4.153.1 · ASP.NET Core 10
using SkiaSharp;
 
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
// GET /resize/photo.jpg?width=400 -> WebP, 400 px wide
app.MapGet("/resize/{name}", (string name, int width, IWebHostEnvironment env) =>
{
    if (width is < 1 or > 2000) return Results.BadRequest();
    string file = Path.Combine(env.WebRootPath, "photos", Path.GetFileName(name));
    if (!File.Exists(file)) return Results.NotFound();
 
    using SKBitmap input = SKBitmap.Decode(file);
    int height = Math.Max(1, input.Height * width / input.Width);
    using SKBitmap resized = input.Resize(new SKImageInfo(width, height), new SKSamplingOptions(SKCubicResampler.Mitchell));
    using SKData webp = resized.Encode(SKEncodedImageFormat.Webp, 80);
    return Results.File(webp.ToArray(), "image/webp");
});
 
app.Run();

With OneImage, the RecursiveAI.OneImage.Web middleware serves any image under wwwroot resized, cropped or converted by query string:

OneImage.Web1.10.1 · ASP.NET Core 10
using OneImage.Web;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOneImage(o => o.MaxWidth = 2000);
 
var app = builder.Build();
app.UseOneImage();      // before UseStaticFiles
app.UseStaticFiles();
app.Run();

We requested the same photos from both. The hand-written endpoint returned a 400×400 WebP for /resize/photo.jpg?width=400, but returned the portrait photo sideways at 400×266, because SKBitmap.Decode ignores the EXIF origin. OneImage.Web returned /photos/photo.jpg?width=400&format=webp&quality=80 as a 400×400 WebP, the portrait upright at 400×600, and ?w=300&h=200&fit=cover as a 300×200 crop. Both refused width=5000 with a 400. OneImage.Web also writes a disk cache and sends ETag, Last-Modified and Cache-Control: public, max-age=604800, and it can require signed URLs (HmacSecretKey) or named presets. Use one of those on a public site so arbitrary sizes can’t fill the cache. The OneImage.Web docs list every command and setting.

Behavior to expect after porting

  1. Orientation. Photos load upright. Remove EncodedOrigin handling, or the picture will be turned twice.
  2. Sizes. The computed side is rounded, not truncated, so a few outputs may be one pixel taller or wider than before.
  3. Resize output. Resampling runs in linear light on premultiplied alpha, and the default filter is Lanczos3 when shrinking. Compare a few outputs, or pass linear: false and filter: to get closer to your old settings.
  4. Error timing. Load reads only the headers, so damaged pixel data can surface at Save. Keep both in the try, or load with new LoadOptions { Validate = true }. Errors are exceptions (ImageFormatException and ImageLimitException, both derived from ImageException), not null. SKBitmap.Decode returns null on error, and in our test SKImage.FromEncodedData and SKCodec.Create also returned null for a text file, so delete the null checks and catch the exceptions instead.
  5. Limits. Size limits are on by default and checked from the headers before anything large is allocated. Very large or suspicious files throw ImageLimitException, so raise the limits for trusted inputs that need it.
  6. File lifetime. Image.Load(path) rereads the file when pixels are needed, so leave the file in place until you’ve saved. Load from a Stream or byte[] to detach from it.
  7. Threads. An Image is safe to read from several threads at once. A Canvas has one writer, and you hand Snapshot() images to other threads.
  8. Formats. OneImage reads and writes PNG, JPEG, WebP, GIF, TIFF, BMP and ICO, with add-on packages for SVG, PDF and Photoshop files. Check your inputs against that list.

What SkiaSharp has that OneImage doesn’t

OneImage covers server-side image work: decoding, resizing, editing, drawing, text and encoding. If you depend on any of the following, keep SkiaSharp for that part:

  • GPU rendering. SkiaSharp draws through OpenGL, Vulkan, Direct3D and Metal via GRContext. OneImage renders on the CPU.
  • UI integration. SkiaSharp has view packages for app frameworks, such as SkiaSharp.Views.Maui.Controls. OneImage has no UI controls.
  • Vector document output. SkiaSharp writes PDF and XPS through SKDocument and SVG through SKSvgCanvas. OneImage reads SVG and PDF but writes only raster formats.
  • Runtime shaders. SKRuntimeEffect compiles shader, color filter and blender programs at run time. OneImage has no shader language. Custom brushes are C# classes.
  • Older and mobile targets. The SkiaSharp 4.153.1 listing includes .NET Framework 4.6.2 and 4.8, .NET Standard 2.0 and 2.1, and Android, iOS, Mac Catalyst, tvOS and Tizen. OneImage runs on net10.0.
  • Some speed. In OneImage’s published benchmarks (AMD Ryzen 9 7950X3D, .NET 10; the benchmark project references SkiaSharp 4.153.1), SkiaSharp decodes a 50-frame 500×500 GIF in 1.0 ms against OneImage’s 2.3 ms, and encodes a 2000×1500 lossless WebP in 101.4 ms against 146.8 ms. The same table has OneImage ahead on PNG, lossy and lossless WebP, BMP and JPEG-to-RGBA decoding.
  • Maturity. SkiaSharp wraps Google’s Skia graphics library. OneImage is new.

The project’s comparison page sets OneImage against SkiaSharp, ImageSharp, Magick.NET and NetVips. The migration reference covers more of the API side by side. If you’re also moving off ImageSharp, see migrating from ImageSharp to OneImage. For SkiaSharp’s licensing, see our SkiaSharp license guide.