Table of Contents

Edges and morphology

Edge operators measure how fast the image changes at each pixel; Canny's detector turns that into thin edge lines; and morphology grows, shrinks and cleans up shapes by taking the maximum or minimum over a neighbourhood. All of them keep the image's size, so they work inside Within scopes.

Operation Does
Edges(op, grayscale) The gradient magnitude of a fixed kernel, 0 to 1; gray by default, or per channel
CannyEdges(sigma, low, high) Thin white edges on black, as ImageMagick's -canny
Morphology(op, shape, iterations) Dilate, erode, open, close, gradient, top hat or bottom hat over a StructuringElement
Dilate(radius), Erode(radius) The maximum or minimum over a square; Dilate(rx, ry) and Erode(rx, ry) over a rectangle

Find edges

using Raster edges = photo.Derive(o => o.Edges(), Ct);                                  // Sobel, gray, 0 to 1
using Raster crisp = photo.Derive(o => o.Edges(EdgeOperator.Scharr), Ct);              // rotation-steady
using Raster colored = photo.Derive(o => o.Edges(EdgeOperator.Sobel, grayscale: false), Ct);  // per channel
using Raster lines = photo.Derive(o => o.Blur(1).Edges(EdgeOperator.Laplacian3x3), Ct); // second derivative

EdgeOperator picks the kernels. Every operator is scaled so a full black-to-white step gives 1, so their results can be compared and thresholded alike.

Operator Kernels Notes
Sobel (default) 3 × 3 pair, weights 1, 2, 1 The usual choice
Scharr 3 × 3 pair, weights 3, 10, 3 Its strength depends least on the edge's direction
Prewitt 3 × 3 pair, weights 1, 1, 1
RobertsCross 2 × 2 diagonal differences Sharpest and noisiest; the pixel and the one below right
Kirsch, Robinson Eight 3 × 3 compass kernels The strongest of the eight directions
Laplacian3x3, Laplacian5x5 One kernel, absolute value Second derivative: edges come out as double lines
LaplacianOfGaussian 5 × 5, absolute value A Laplacian smoothed against noise

Gradient pairs write sqrt(gx² + gy²); the compass operators keep the largest of their eight responses. The edges are measured on premultiplied values, so transparent areas count as black, and alpha is kept. With grayscale: true (the default) the pixels are first reduced to Rec. 709 luma and the magnitude is written as gray; with false red, green and blue each get their own. Past the border the edge pixel repeats.

A selection from Canny

// Thin white edges on black. A gray raster works as a Mask as it is; an RGBA one needs MaskChannel.Luminance,
// because a Mask of a raster with alpha reads its alpha.
using Raster<Gray8> edges = Raster.Load<Gray8>("duck.png");
edges.Apply(o => o.CannyEdges(sigma: 1.4f).Dilate(1), Ct);                 // thin edges, thickened

photo.Apply(o => o.Within(new Mask(edges), s => s.FillColor(Rgba8.Red)), Ct);  // paint over them

Canny's white-on-black map works directly as a Mask; thicken it with Dilate first to catch the pixels beside each edge.

CannyEdges runs the four steps of Canny's detector: a Gaussian blur of the luma with standard deviation sigma, the gradient from Canny's own 2 × 2 first differences, non-maximum suppression (a pixel is kept only if neither neighbour across its edge is stronger), and hysteresis: pixels at least high times the image's strongest gradient start edges, and edges follow 8-connected pixels down to low times it. The output is white edges, one pixel wide, on black, with alpha kept.

The parameters mean what ImageMagick's -canny 0x{sigma}+{low × 100}%+{high × 100}% means, and the edge maps match it pixel for pixel except where ImageMagick stops a trace early: it keeps its trace stack in one image row, so a trace deeper than the image is wide ends part way. Egami follows every connected pixel, so it finds the same edges plus the rest of those traces.

A gray edge map is a Mask as it is. A color raster used as a Mask is read by its alpha, so for an RGBA edge map pass MaskChannel.Luminance: new Mask(edges, Channel: MaskChannel.Luminance).

Clean a scanned form with Close

// Close removes dark specks smaller than the shape (dust and toner dots on the paper) and keeps strokes wider than it;
// Open does the opposite, for light pinholes in dark areas.
using Raster<Gray8> scan = Raster.Load<Gray8>("scan.tif");
scan.Apply(o => o.Morphology(MorphologyOp.Close, StructuringElement.Disk(1)), Ct);

// The outlines of shapes, and the light details smaller than a disk.
using Raster outlines = scan.Derive(o => o.Morphology(MorphologyOp.Gradient, StructuringElement.Square(1)), Ct);
using Raster details = scan.Derive(o => o.Morphology(MorphologyOp.TopHat, StructuringElement.Disk(5)), Ct);

Morphology is flat: dilation takes each channel's maximum over the shape, erosion the minimum, on the stored values, so 8 and 16-bit results are exact. The rest combine them:

MorphologyOp Is Use
Dilate Maximum Grow light areas, close dark gaps
Erode Minimum Shrink light areas, remove light specks
Open Erode, then dilate Remove light details smaller than the shape; never brightens
Close Dilate, then erode Remove dark details smaller than the shape; never darkens
Gradient Dilate minus erode Outlines as wide as the shape
TopHat Image minus its open The light details the open removed
BottomHat Its close minus the image The dark details the close removed

iterations repeats each dilation and erosion: Open with 2 erodes twice, then dilates twice, as ImageMagick's Open:2. Alpha is dilated and eroded with the color, so a logo's shape grows and shrinks with it; Gradient, TopHat and BottomHat keep alpha, since a difference of alpha would make an opaque image transparent. Past the border the edge pixel repeats, as ImageMagick does.

Shapes

StructuringElement disk = StructuringElement.Disk(4);          // x² + y² ≤ 16, ImageMagick's Disk:4
StructuringElement diamond = StructuringElement.Diamond(3);    // |x| + |y| ≤ 3
StructuringElement plus = StructuringElement.Cross(2);         // ImageMagick's Plus:2
StructuringElement corner = StructuringElement.FromMask(
    new[,]
    {
        { true, true, true },
        { true, false, false },
        { true, false, false },
    },
    new Point(0, 0));                                          // [y, x] cells; the origin sits on the pixel

using Raster rounded = photo.Derive(o => o.Morphology(MorphologyOp.Open, disk, iterations: 2), Ct);

The factories make ImageMagick's kernels of the same name, so -morphology Dilate Disk:4 and Morphology(MorphologyOp.Dilate, StructuringElement.Disk(4)) give identical pixels. Cross(r) is ImageMagick's Plus:r.

Shape Cells
Square(r) The (2r + 1) square
Rectangle(rx, ry) The (2rx + 1) × (2ry + 1) rectangle, Skia's dilate and erode radii
Disk(r) x² + y² ≤ r²
Diamond(r) |x| + |y| ≤ r
Cross(r) The row and column through the origin, 2r + 1 long
FromMask(cells, origin) Any set of cells, [y, x], with the origin on the pixel being computed

Erosion reads the cells as offsets from the pixel and dilation reads them mirrored, as ImageMagick does, so Open and Close work as expected for lopsided shapes too. Squares and rectangles cost the same per pixel whatever their size (van Herk and Gil-Werman's running maximum down the columns); other shapes cost in proportion to their height.

using Raster grown = photo.Derive(o => o.Dilate(3), Ct);       // light areas grow 3 px: a 7 × 7 square
using Raster shrunk = photo.Derive(o => o.Erode(3), Ct);       // light areas shrink 3 px
using Raster wide = photo.Derive(o => o.Dilate(6, 1), Ct);     // 13 × 3, as Skia's dilate radii