Resizing and geometry
Every operation is a step in a chain you record in a lambda. Apply runs the chain in place; Derive returns a new raster and
leaves the source alone. Both take a CancellationToken, checked between steps and between bands of rows.
Resize, fit and cover
// Exactly 400 × 300, stretching if the aspect ratio differs.
using Raster stretched = photo.Derive(ops => ops.Resize(400, 300), Ct);
// 400 wide; 0 for one side keeps the aspect ratio.
using Raster wide = photo.Derive(ops => ops.Resize(400, 0), Ct);
// Fit inside 400 × 400 with the aspect ratio kept; enlarge: false never scales up.
using Raster fitted = photo.Derive(ops => ops.Fit(400, 400, enlarge: false), Ct);
// Cover 400 × 400 and crop what sticks out, keeping the top of the image.
using Raster covered = photo.Derive(ops => ops.Cover(400, 400, Anchor.Top), Ct);
| Operation | Output size | Aspect ratio |
|---|---|---|
Resize(w, h) |
Exactly w × h; 0 for one side derives it | Kept only when a side is 0 |
Fit(w, h) |
At most w × h | Kept; the image fits inside |
Cover(w, h, anchor) |
Exactly w × h | Kept; the image covers the box, and anchor picks the part kept |
Cover reads only the part it keeps: nothing is cropped first.
Filters and linear light
// Lanczos3 is sharper than the default Bicubic; Nearest keeps hard pixel edges (pixel art, masks).
using Raster sharp = photo.Derive(ops => ops.Resize(200, 0, ResampleFilter.Lanczos3), Ct);
// Blending in linear light keeps fine bright detail on dark backgrounds from going dim when reduced.
using Raster linear = photo.Derive(ops => ops.Resize(200, 0, ResampleFilter.Mitchell, ColorSpace.Linear), Ct);
| Filter | Use |
|---|---|
Bicubic (default) |
Catmull-Rom: sharp and fast, the usual choice |
Lanczos3 |
Sharpest, for downscaling photos |
Mitchell |
Softer, fewer halos, good for upscaling |
Bilinear, Box |
Fast, soft |
Nearest |
No blending: pixel art, masks, label images |
ColorSpace.Linear converts to linear light, resamples and converts back. It costs a little time and matters most for small
bright detail on dark backgrounds, such as text or stars.
Four more filters follow ImageMagick's definitions: Lanczos2 (a two-lobe windowed sinc, nearly as sharp as Lanczos3 with less
ringing), Hermite (a one-pixel cubic with no ringing), Gaussian (σ = ½, soft) and Spline (the cubic B-spline, the smoothest).
They work everywhere a filter does: resizes, rotations, transforms and warps.
Four filters reproduce GDI+'s InterpolationModes, for images that must look like Graphics.DrawImage's; see
Matching System.Drawing.
Many sizes from one image
MipChain halves an image again and again, averaging 2 × 2 blocks, and resizes each target from the smallest level at least twice
its size, so a set of thumbnails reads each source pixel about once. Results stay within 40 dB of a direct resize.
// A responsive image set: one chain, then each width from the smallest level at least twice its size.
using MipChain chain = MipChain.Build(photo);
foreach (int width in (int[])[320, 240, 160, 120, 80])
{
using Raster thumbnail = chain.Fit(width, width, ResampleFilter.Lanczos2);
thumbnail.Save($"egret-{width}.png");
}
Crop, pad, rotate, flip
photo.Apply(ops => ops
.Crop(10, 10, 120, 90) // keep a rectangle
.Pad(140, 110, Rgba8.White, Anchor.Center) // add a white border
.Rotate(90) // exact for multiples of 90°
.Rotate(-3.5f, ResampleFilter.Bicubic, Rgba8.Transparent) // resampled for other angles
.FlipHorizontal(), Ct);
Crop, Pad, FlipHorizontal, FlipVertical and rotations by multiples of 90° move pixels exactly. Other angles resample onto
a canvas the size of the rotated bounding box, with the corners filled with the background color. FillColor paints a rectangle,
or the whole image.
Transforms and perspective
Transform takes any affine matrix (Matrix3x2), perspective matrix (Homography) or 3D matrix (Matrix4x4, projected as CSS
matrix3d draws a flat element). Matrices map source positions to destination positions, with the origin at the image's top-left
corner and pixel centers at +0.5, as Skia and ImageMagick's -distort do; they compose with *, left first.
// Turn by 20° and shrink to 40% in one pass. Matrices map source positions to destination positions and compose with *,
// left first; shrinking averages each pixel's footprint, so fine detail fades instead of aliasing.
Matrix3x2 matrix = Matrix3x2.CreateRotation(MathF.PI / 9) * Matrix3x2.CreateScale(0.4f);
using Raster turned = photo.Derive(ops => ops.Transform(matrix), Ct);
// A 3D tilt, as CSS draws a flat element: rotate about the vertical axis and look at it from 800 pixels away.
Matrix4x4 tilt = Matrix4x4.CreateTranslation(-320, -240, 0) * Matrix4x4.CreateRotationY(0.5f) * new Matrix4x4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -1f / 800, 0, 0, 0, 1);
using Raster tilted = photo.Derive(ops => ops.Transform(tilt), Ct);
The result is the transformed image's bounding box, rounded to whole pixels, unless TransformOptions.Viewport picks a rectangle
of the destination plane. Where the image keeps or gains size, each output pixel blends the filter's kernel around its source point,
exactly as Rotate does; where it shrinks or tilts away, the filter weighs an ellipse covering the pixel's footprint in the source
(elliptical weighted averaging, as ImageMagick's -distort does), so fine detail fades to its average instead of aliasing into
moiré. Footprints are capped at 32 source pixels, so a plane stretching to the horizon aliases slightly in the far distance. A
transform that only scales equals Resize, and a pure rotation equals Rotate, pixel for pixel.
Homography.FromQuad builds the perspective that moves four points onto four others, such as a photographed page onto an upright
canvas:
// The four corners of a page photographed at an angle, top-left first and clockwise...
Vector2[] corners = [new(120, 60), new(560, 40), new(600, 430), new(80, 420)];
// ...go to the corners of an upright 420 × 594 (A4-shaped) canvas.
Homography straighten = Homography.FromQuad(corners, new Rect(0, 0, 420, 594));
using Raster page = photo.Derive(ops => ops.Transform(straighten, new TransformOptions { Viewport = new Rect(0, 0, 420, 594) }), Ct);
// Homography maps points too, and inverts.
Vector2 middle = straighten.Map(new Vector2(340, 240));
bool invertible = straighten.TryInvert(out Homography back);
Give the page's four corners in the photo, top-left first and clockwise; the perspective maps them onto an upright canvas.
TransformOptions (a SamplingOptions with a viewport) chooses the filter, the color space it blends in, what samples outside
the source read (Edges: transparent background, or the image clamped, mirrored or wrapped), and the background color:
// Keep the input's size (and the matrix's translation), fill the uncovered corners with white, blend in linear light.
TransformOptions keep = new()
{
Viewport = new Rect(0, 0, photo.Width, photo.Height),
Background = Rgba8.White,
ColorSpace = ColorSpace.Linear,
};
photo.Apply(ops => ops.Transform(Matrix3x2.CreateRotation(0.2f, new Vector2(photo.Width / 2f, photo.Height / 2f)), keep), Ct);
// Wrap extends the image as a tiling, so a turned copy fills the whole canvas.
using Raster<Rgba8> pattern = photo.Derive(ops => ops.Transform(Matrix3x2.CreateRotation(0.5f), new TransformOptions
{
Viewport = new Rect(0, 0, 800, 600),
Edges = EdgeMode.Wrap,
Filter = ResampleFilter.Lanczos3,
}), Ct);
Skew shears about the image center and QuadDistort moves the image's four corners to four points:
// Lean a banner 12° to the right, filling the uncovered corners with white; the canvas grows to fit.
using Raster banner = photo.Derive(ops => ops.Skew(12, 0, new TransformOptions { Background = Rgba8.White }), Ct);
// Skew a region in place: a viewport the size of the area keeps it the same size inside Within.
using Raster region = photo.Derive(ops => ops.Within(new Rect(100, 100, 200, 120), s => s.Skew(0, 8, new TransformOptions { Viewport = new Rect(0, 0, 200, 120) })), Ct);
// Pin the image onto a trapezoid, as if it lay on a table: its corners go to these four points (pixel edges, so the
// image's own corners are (0, 0) and (640, 480)).
using Raster onTable = photo.Derive(ops => ops.QuadDistort(new(200, 0), new(440, 0), new(640, 300), new(0, 300)), Ct);
A transform whose viewport has the input's size keeps the size, so it runs inside Within. A perspective that sends a corner of
the image to infinity or behind the viewer has no finite bounds, so it needs a viewport. Like Rotate, a transform sets the EXIF
orientation to Normal, updates the dimensions, drops the thumbnail and moves the subject area through the matrix.
Warps
A warp maps every output pixel back to a source point, and Egami samples those points as it samples transforms, so warps get
antialiasing where they shrink the image, and edge modes. Warps holds ImageMagick's distortions with ImageMagick's parameters
(Barrel and BarrelInverse lens correction, Polar, DePolar, Arc, Swirl, Wave, Implode), so their documentation applies
and their pixels land where ImageMagick's do, and Skia's Magnifier:
// Correct a wide-angle lens with the PanoTools coefficients a lens database gives (ImageMagick's Barrel), then swirl a
// square region in place; size-keeping warps run inside Within.
using Raster corrected = photo.Derive(ops => ops
.Warp(Warps.Barrel(0.0, -0.018, 0.0))
.Within(new Rect(300, 200, 200, 200), s => s.Warp(Warps.Swirl(90))), Ct);
// Size-changing ones: wrap the image into a circle, or bend it into an arc.
using Raster disc = photo.Derive(ops => ops.Warp(Warps.Polar(), new SamplingOptions { Background = Rgba8.White }), Ct);
using Raster arc = photo.Derive(ops => ops.Warp(Warps.Arc(120)), Ct);
// A magnifying glass over the duck's head, blending into the photo over 12 pixels.
using Raster loupe = photo.Derive(ops => ops.Warp(Warps.Magnifier(new Rect(380, 60, 160, 160), 2.5f, 12)), Ct);
Barrel takes the PanoTools coefficients a lens database such as lensfun gives for the camera and focal length.
Your own warp is an IWarp, which maps a whole output row per call, or Warp.FromFunction for a quick experiment:
// Any mapping from output points back to source points; the sampler antialiases wherever it shrinks the image.
IWarp ripple = Warp.FromFunction(p => new Vector2(p.X + (6 * MathF.Sin(p.Y / 15)), p.Y));
using Raster rippled = photo.Derive(ops => ops.Warp(ripple, new SamplingOptions { Edges = EdgeMode.Mirror }), Ct);
// The same as an IWarp, mapping a whole row per call: no per-pixel delegate, so it is faster.
using Raster again = photo.Derive(ops => ops.Warp(new Ripple(6, 15), new SamplingOptions { Edges = EdgeMode.Mirror }), Ct);
Displace moves each pixel by a displacement map's channels, as Skia's DisplacementMap and ImageMagick's -displace do:
// A displacement map the image's size: mid-gray moves nothing, red moves pixels sideways and green up and down.
using Raster<Rgba8> map = Raster.Create<Rgba8>(photo.Width, photo.Height);
map.ForEachRow((row, y) =>
{
for (int x = 0; x < row.Length; x++)
{
byte wave = (byte)(128 + (100 * Math.Sin((x + y) / 12.0)));
row[x] = new Rgba8(wave, 128, 128, 255);
}
});
using Raster<Rgba8> rippled = photo.Derive(ops => ops.Displace(map, 16, 0), Ct);
Offset moves the image by whole pixels with no resampling, wrapping round by default as ImageMagick's -roll does, and Tile fills
a new canvas with copies of the image or a rectangle of it:
// Roll the image half way round, so its edges meet in the middle, which shows whether it tiles seamlessly.
using Raster rolled = photo.Derive(ops => ops.Offset(photo.Width / 2, photo.Height / 2), Ct);
// Fill a 1920 × 1080 wallpaper with copies of the duck's head.
using Raster wallpaper = photo.Derive(ops => ops.Tile(1920, 1080, source: new Rect(380, 60, 160, 160)), Ct);
Warps that keep the size (Barrel, Swirl, Implode, Magnifier, Displace, Offset) run inside Within; Polar, DePolar,
Arc, Wave and Tile change it and throw there. A warp can move the subject anywhere, so it drops the EXIF subject area.
Trimming and content crops
Trim cuts away the border: the edge rows and columns whose pixels all lie within a tolerance of the border color (the top-left
pixel's, or one you give), by ImageMagick's -fuzz distance, so its rectangle equals ImageMagick's -trim. EntropyCrop keeps the
busiest part of the image: the 16 × 16 tiles whose luminance entropy reaches a threshold.
Raster.TrimBounds and Raster.EntropyBounds return the rectangles without changing anything.
// Cut away the white margin, counting near-white (within 10% by ImageMagick's -fuzz distance) as margin too.
Rect content = scan.TrimBounds(tolerance: 0.1f);
scan.Apply(ops => ops.Trim(tolerance: 0.1f).Fit(400, 400), Ct);
// Keep the part with detail in it: flat tiles (a plain backdrop) fall away.
Rect busy = padded.EntropyBounds();
using Raster<Rgba8> cropped = padded.Derive(ops => ops.EntropyCrop(), Ct);
Their size depends on the pixels, so the chain runs up to them, measures, and plans the rest from the real size; it is still one
Apply or Derive. Every frame of an animation is measured and the union of their rectangles kept, so frames keep one size. An
image that is all border, or has no busy tile, is left as it is. They cannot run inside Within, and Transcode decodes the
whole image before running a chain that holds one.
Straightening pages
Deskew finds the tilt of a scanned or photographed page's text lines and turns it back, keeping the page size with the corners in
the border color; Raster.DetectSkew returns the angle alone. A gray copy about 1,500 pixels on its long side is binarized with
Otsu's threshold and each angle up to maxAngle (15° by default) is scored by how sharply the ink's projection piles up in rows,
first in half-degree steps and then to 0.05°. A picture, noise or an already straight page returns 0 and is left alone.
// Measure the tilt first, if you want to show it or decide yourself...
float tilt = photo.DetectSkew(maxAngle: 10);
// ...or straighten in one step, keeping the page size with white corners.
using Raster straight = photo.Derive(ops => ops.Deskew(background: Rgba8.White), Ct);
Combining images
Raster.Append lays images side by side or stacks them, as ImageMagick's +append and -append do; Raster.Grid makes a contact
sheet, as montage does; SpriteSheet.Pack packs images into one sheet with the MaxRects algorithm and returns every sprite's
rectangle. The result takes the first image's pixel type, metadata and color profile, and each input's first frame is used.
// Side by side with a 10-pixel white gap, the shorter image centered vertically (ImageMagick's +append).
using Raster strip = Raster.Append([duck, egret], Axis.Horizontal, spacing: 10, background: Rgba8.White, alignment: Alignment.Center);
// Stacked, as -append does.
using Raster column = Raster.Append([duck, egret], Axis.Vertical);
// A contact sheet three cells wide; each photo shrinks to fit a 200 × 200 cell and sits in its middle.
using Raster sheet = Raster.Grid(photos, columns: 3, cell: new Size(200, 200), spacing: 8, background: Rgba8.White);
// Pack sprites with a 2-pixel gap and 1 pixel of extruded edge, so GPU filtering never bleeds between them.
using SpriteSheet sheet = SpriteSheet.Pack([small, tiny, small, tiny], new SpritePackOptions { Padding = 2, Extrude = 1, PowerOfTwo = true });
sheet.Image.Save("sprites.png");
// The rectangles are plain data: writing them for a game engine is one line of System.Text.Json.
File.WriteAllText("sprites.json", JsonSerializer.Serialize(sheet.Frames.Select(f => new { f.X, f.Y, w = f.Width, h = f.Height })));
Sprites are never rotated, and the same inputs always give the same sheet. SpritePackOptions sets the largest width, the gap
between sprites, edge extrusion for GPU sampling, power-of-two sizes and trimming of each sprite's transparent border, whose
offsets SpriteSheet.Offsets returns.
Orientation
// Load returns pixels as stored. A phone photo stored sideways says so in its EXIF orientation.
RasterInfo info = Raster.Probe("sideways.jpg");
Console.WriteLine(info.Orientation); // Rotate90
// AutoOrient turns the pixels upright and resets the tag. Followed by a resize, the resize runs first, on the stored image,
// so only the small result is turned.
using Raster photo = Raster.Load("sideways.jpg");
photo.Apply(ops => ops.AutoOrient().Fit(300, 300), Ct);
Rotating and flipping keep the EXIF orientation honest, and resizing and cropping update the EXIF pixel dimensions and drop the embedded thumbnail, which no longer matches.
Planning
// The whole chain is planned before any pixel changes: a bad argument throws first, a Crop before a resize reads only the
// crop, and adjacent color steps run as one pass. Each step runs in parallel bands of rows.
try
{
photo.Apply(ops => ops.Fit(100, 100).Crop(0, 0, 500, 500), Ct);
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("The crop does not fit the 100-pixel result; the photo is unchanged.");
}
Animations: every step runs on every frame. See the animation guide.
Recipes
Short answers to common tasks in this area. Each one compiles and runs in Egami's test run.
Thumbnails for the web
// Decode at the smallest JPEG scale that still covers 400 × 400, turn it upright, fit it, and save at quality 80.
using Raster photo = Raster.Load("sideways.jpg", options: new ReaderOptions { MinimumSize = new Size(400, 400) });
photo.Apply(ops => ops.AutoOrient().Fit(400, 400, enlarge: false));
photo.Save("thumb.jpg", new JpegWriterOptions { Quality = 80, StripMetadata = true });
The JPEG decodes at 1/2, 1/4 or 1/8 scale, AutoOrient turns only the small result, and the metadata is left out of the file.
Rotate a region in place
// Turn a 200 × 200 square of the photo by 30° about its own center; the rest of the photo stays as it was.
Rect area = new(380, 40, 200, 200);
Matrix3x2 turn = Matrix3x2.CreateRotation(MathF.PI / 6, new Vector2(100, 100));
using Raster turned = photo.Derive(ops => ops.Within(area, s => s.Transform(turn, new TransformOptions { Viewport = new Rect(0, 0, 200, 200), Edges = EdgeMode.Clamp })), Ct);
A viewport the size of the area keeps the size, so the transform runs inside Within.
Run it
samples/Geometry is a console app that does what this guide shows on the bundled
images: dotnet run --project samples/Geometry writes its results to ./output.