Pipelines
An ImagePipeline is an immutable, ordered list of operations.
Building one does no work: it is a description of an edit that you can inspect, store, reuse
across images and threads, and execute when you are ready.
Build one
Every choice below is a builder call. Each returns a new pipeline and leaves the old one as it was - which is what makes a pipeline safe to keep in a static field and reuse for every upload.
Pipeline
Auto orient → Resize(600x600, Max) → Vibrance → Sharpen
After the optimiser
Auto orient → Resize(600x600, Max) → Vibrance → Sharpen
Geometry
Adjustments and filters
Brightness(0) and Contrast(0) do nothing, and the optimiser
drops them - watch the step count.
Building and inspecting
var pipeline = ImagePipeline.Create() .AutoOrient() .MaxSize(1920, 1920) .Sharpen(0.3f);Console.WriteLine(pipeline);// Auto orient → Resize(1920x1920, Max) → SharpenConsole.WriteLine(pipeline.GetOutputSize(new Size(4000, 3000)));// {Width=1920, Height=1440} ← computed without touching a single pixelExecuting
using var result = await pipeline.ExecuteAsync(source, new PipelineExecutionOptions{ CancellationToken = token, Progress = new Progress<double>(p => _percent = p),});
The source is never modified, and the returned buffer is yours to dispose.
ExecuteAsync yields to the scheduler between operations so a single-threaded
WebAssembly UI can repaint; Execute is the synchronous form, for use off the UI
thread or on small images.
| Option | Meaning |
|---|---|
Allocator | Where intermediate buffers come from. Defaults to the shared pool. |
Limits | Safety limits applied to every buffer allocated during the run. |
CancellationToken | Checked between operations and periodically inside them, typically every few rows. |
Progress | Receives 0 to 1 across the whole run, with each operation's own progress mapped into its slice. |
Optimize | Run the optimiser first. Default true. |
YieldBetweenOperations | Yield to the scheduler between steps. Default true. |
YieldCallback | How to yield, if the default Task.Yield is not right for your host. |
Failures are wrapped with context rather than surfacing bare - an
ImageException names the operation and its step, such as
Operation 'Crop' (step 2 of 4) failed: Crop rectangle ... does not intersect the image
(800x600).
Geometry
pipeline.AutoOrient() // apply the EXIF rotation so the image is upright .Crop(x, y, width, height) .CropToAspect(AspectRatio.Ratio16x9, Anchor.Center) .Resize(width, height, mode, filter, anchor, background) .MaxSize(1920, 1080) // shrink to fit, never enlarge .Cover(512, 512, Anchor.Top) // fill exactly, cropping the overflow .Rotate(12.5, background) // arbitrary angle, canvas expands .Orient(Orientation.Rotate90) // lossless, no resampling .FlipHorizontal() .Skew(10, 0);AutoOrient is almost always the first thing you want. Phones record rotation in
EXIF rather than rotating the pixels, so an image that looks right in a photo viewer will
appear sideways on a canvas unless it is applied. The browser decoder applies it during
decoding; AutoOrient handles the cases where it has not been.
The six resize modes
For a 1000 × 500 source asked to fit 400 × 400:
| Mode | Result | Behaviour |
|---|---|---|
Fit | 400 × 200 | Scale proportionally to fit inside. The default. |
Contain | 400 × 400 | Like Fit, then pad to exactly the target with background. |
Cover | 400 × 400 | Scale to fill, then crop the overflow according to anchor. |
Stretch | 400 × 400 | Ignore the aspect ratio. |
Max | 400 × 200 | Like Fit, but never enlarge a smaller image - the right choice for an upload cap. |
Min | 800 × 400 | Scale so both dimensions are at least the target. |
Resampling filters
| Filter | Use it for |
|---|---|
Auto | The default. Lanczos3 for downscaling, bicubic for upscaling. |
Box | Fast area averaging; excellent for large reductions. |
Bilinear | Fast, moderate quality. |
Bicubic | Good general purpose quality. |
Lanczos3 | Sharpest downscaling, slowest. |
NearestNeighbor | Pixel art, or wherever hard edges must survive. |
Large reductions automatically get an exact integer box prepass before the chosen filter, which is why a 24 megapixel thumbnail takes 52 ms rather than 368 ms. It is transparent and quality-neutral.
Smart cropping
A fixed anchor is wrong most of the time: centre-cropping a portrait to a square routinely cuts
off the top of a head. SmartCrop scores candidate positions on a small downscaled
copy using local detail, colour saturation and skin-like hue, then picks the best. It is a
heuristic, not face recognition - when you do have a detector, hand it the rectangle. The
Avatar page compares it with the fixed anchors.
pipeline.SmartCrop(AspectRatio.Square);// With a face detector of your own:var crop = SmartCrop.FindCrop(image, AspectRatio.Square, mustInclude: faceRectangle);pipeline.Crop(crop.Rectangle);Adjustments and filters
pipeline.Brightness(0.1f).Contrast(0.15f).Saturation(-0.2f).Hue(15) .Exposure(0.5f).Gamma(1.2f).Temperature(0.2f).Tint(-0.1f) .Vibrance(0.3f).ShadowsHighlights(0.2f, -0.15f).Opacity(0.9f) .Grayscale().Sepia(0.8f).Invert().Posterize(6).Threshold(0.5f) .Blur(4f).Sharpen(0.5f).Pixelate(12).Noise(0.05f) .DenoiseMedian(1).Vignette(0.4f) .ColorMatrix(myMatrix);pipeline.AutoLevels(); // stretch the tonal rangepipeline.AutoLevels(perChannel: true); // also neutralise a colour castpipeline.AutoContrast(targetMean: 0.5f); // gentler: move the mean luminance towards a target// A region confines a filter - this is how redaction works.pipeline.Pixelate(14, new Rectangle(40, 120, 300, 40));
Amounts are consistently in [-1, 1] with 0 meaning no change, except where another unit is
natural: Exposure is in stops, Hue in degrees, Gamma is
a positive multiplier and Blur a radius in pixels. Filters that take a region only
touch that region, which is how redaction works.
AutoLevels stretches the tonal range - the most useful single fix - ignoring the
darkest and brightest 0.5% by default, so one hot pixel cannot define the white point, and it
leaves a flat histogram alone rather than amplifying it into noise.
The optimiser
It runs by default, and every rewrite is exact.
| Rule | Effect |
|---|---|
| Drop identities | Brightness(0) and friends disappear. |
| Merge crops | Two crops become one. |
| Merge orientations | Rotate 90 twice becomes rotate 180; two horizontal flips vanish entirely. |
| Fuse resize with a following crop | One resample that computes only the surviving region, 2.4× faster. |
| Fuse per-pixel runs | One pass over the pixels instead of one per adjustment. |
// Three no-ops and two crops.var pipeline = ImagePipeline.Create() .Brightness(0) .Crop(10, 10, 200, 200) .Crop(5, 5, 100, 100) .Contrast(0);pipeline.Optimize(new Size(400, 400));// Crop(15,15,100,100)
Fusing per-pixel operations removes the intermediate 8-bit rounding that separate execution
performs, so a fused chain is more accurate, not merely faster. Turn the optimiser off
with new PipelineExecutionOptions { Optimize = false } if you are comparing
behaviour, but there is no reason to in production.
Previewing at another resolution
ForScale rescales every operation - crop rectangles, blur radii, pixelation block
sizes, stroke widths, font sizes. This is how the editor previews full-resolution edits on a
proxy and gets the same picture, and why there is one rendering path rather than a fast one and
an accurate one that drift apart.
var preview = pipeline.ForScale(0.25);Custom operations
Implement IImageOperation, or derive from PointOperation if your
operation maps each pixel independently - then it joins the single-pass run with the
adjustments around it for free.