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. 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
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
| SkiaSharp | OneImage |
|---|---|
SKBitmap: a decoded, mutable raster in memory | Image: 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 GPU | The same Image; there is one image type |
SKCodec for the header, the EXIF origin and scaled decoding | Image.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 draw | Canvas, a mutable tiled surface, or the same drawing calls on an Image, recorded lazily |
SKPaint carries color, style, stroke width and effects | A Brush (fills) or Pen (strokes) passed to each call |
Encode(format, quality) returns SKData | Save(path or stream), with options in SaveOptions |
SKColor, color types such as Bgra8888 and Rgba8888, premultiplied or not | Rgba32 and other pixel structs, which appear only when you read or write pixels |
Native libSkiaSharp per OS and architecture | One 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
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}");
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:
| SkiaSharp | OneImage 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
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}");
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:
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}");
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
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");
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:
| Output | SkiaSharp 4.153.1 | OneImage 1.10.1 |
|---|---|---|
| JPEG, quality 85 | 1,279,007 bytes | 1,296,726 bytes |
| WebP, quality 80 | 600,408 bytes | 619,106 bytes |
| PNG | 12,869,113 bytes | 12,873,472 bytes |
| Lossless WebP | 11,739,508 bytes | 11,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:
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}");
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:
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());
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:
| SkiaSharp | OneImage |
|---|---|
SKSurface.Create(new SKImageInfo(w, h)), surface.Canvas | new Canvas(w, h, PixelFormat.Rgba8) |
canvas.Clear(color) | canvas.Clear(color) |
DrawCircle, DrawRect, DrawPath with a fill paint | canvas.Fill(color or brush, Path.Circle(...)), Path.Rectangle, Path.RoundedRectangle, Path.Ellipse, Path.Star |
SKPath.ParseSvgPathData, SKPath methods | Path.ParseSvg, PathBuilder |
SKPaintStyle.Stroke, StrokeWidth, StrokeJoin, StrokeCap | new Pen(brush, width, join, cap) with canvas.Draw(pen, path) |
SKPathEffect.CreateDash(intervals, phase) | dashPattern: and dashOffset: on Pen, or Pen.Dash |
SKShader.CreateLinearGradient, CreateRadialGradient | Brush.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
SKPainthasIsAntialiasset to false. OneImage’santialias:parameter defaults to true. - Text is positioned by its box, not by the baseline. SkiaSharp’s
yis the baseline. OneImage places the text block relative toOriginaccording toVerticalAlignment(defaultTop). The sample usesBottomto 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.SystemFontsfinds 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:
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); }
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:
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
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());
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:
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:
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
- Orientation. Photos load upright. Remove
EncodedOriginhandling, or the picture will be turned twice. - Sizes. The computed side is rounded, not truncated, so a few outputs may be one pixel taller or wider than before.
- 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: falseandfilter:to get closer to your old settings. - Error timing.
Loadreads only the headers, so damaged pixel data can surface atSave. Keep both in thetry, or load withnew LoadOptions { Validate = true }. Errors are exceptions (ImageFormatExceptionandImageLimitException, both derived fromImageException), notnull.SKBitmap.Decodereturns null on error, and in our testSKImage.FromEncodedDataandSKCodec.Createalso returned null for a text file, so delete the null checks and catch the exceptions instead. - 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. - File lifetime.
Image.Load(path)rereads the file when pixels are needed, so leave the file in place until you’ve saved. Load from aStreamorbyte[]to detach from it. - Threads. An
Imageis safe to read from several threads at once. ACanvashas one writer, and you handSnapshot()images to other threads. - 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
SKDocumentand SVG throughSKSvgCanvas. OneImage reads SVG and PDF but writes only raster formats. - Runtime shaders.
SKRuntimeEffectcompiles 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.