Convolution Filters

Concept

Convolution filters, or filters for short, transform pictures at the level of individual pixels. They work by replacing each pixel with a weighted sum of its neighboring pixels. The weights are determined by two-dimensional matrix known as a kernel. Filters can be used for a variety of image processing operations and creative effects such as blurring, edge detection, and drop shadows.

The filter functionality is provided by the Filter algebra. It provides a method to apply a user-defined Kernel to a Picture, but also conveniences to apply common operations, like a Gaussian blur, without having to construct a Kernel. These conveniences may also have more efficient implementations, depending upon the backend.

Blur

The blur method creates a soft, out-of-focus effect using a Gaussian blur:

val circleShape = circle(80).fillColor(Color.red)
val blurredCircle = circleShape.blur(5.0)

The argument to blur controls the intensity of the effect, as shown below.

Box Blur

The boxBlur method provides an alternative blur implementation. Unlike Gaussian blur which creates a smooth falloff, box blur averages pixels uniformly within a square area:

val orangeCircle = circle(80).fillColor(Color.orange)
val boxBlurred = orangeCircle.boxBlur(5)

Box blur creates a uniform blur effect, while Gaussian blur produces a smoother, more natural result. However, box blur may be faster than Gaussian blur.

Sharpen

The sharpen method enhances edges and details:

val randomCircle: Random[Picture[Unit]] =
  for {
    pt <- (
      Random.double.map(r => Math.sqrt(r) * 100),
      Random.double.map(_.turns)
    )
      .mapN(Point.polar)
    r <- Random.int(15, 45)
    l <- Random.double(0.3, 0.8)
    c <- Random.double(0.1, 0.4)
    h = (pt.r * 0.35 / 100.0).turns
  } yield Picture
    .circle(r)
    .at(pt)
    .noStroke
    .fillColor(Color.oklch(l, c, h, 0.5))

val randomCircles = randomCircle.replicateA(200).map(_.allOn.margin(20)).run()

val sharpenedShape = randomCircles.sharpen(4.0)

The method's parameter controls the intensity of the effect. Values above 1.0 increase sharpness, while values between 0 and 1 reduce it.

Edge Detection

The detectEdges method highlights boundaries and contours:

val layeredShape = circle(60).on(square(100))
  .fillColor(Color.lightBlue)
  .strokeColor(Color.darkBlue)
  .strokeWidth(4)

val edgeDetected = layeredShape.detectEdges

Edge detection is particularly effective on shapes with color gradients or multiple overlapping elements.

Emboss

The emboss method creates a 3D raised surface effect:

val concentricCircles = {
  def loop(count: Int): Picture[Unit] =
    count match {
      case 0 => Picture.empty
      case n =>
        Picture
          .circle(n * 15)
          .fillColor(Color.crimson.spin(10.degrees * n).alpha(0.7.normalized))
          .strokeColor(
            Color.red.spin(15.degrees * n).alpha(0.7.normalized)
          )
          .strokeWidth(4.0)
          .under(loop(n - 1))
    }

  loop(7)
}
val embossedShape = concentricCircles.emboss

Drop Shadow

The dropShadow method adds depth and dimension:

val starShape = star(5, 50, 25).fillColor(Color.gold)
val shadowedStar = starShape.dropShadow(
  offsetX = 8,
  offsetY = 8,
  blur = 4,
  color = Color.black.alpha(Normalized(0.5))
)

You can control the shadow's position (offsetX, offsetY), softness (blur), and appearance (color with alpha transparency).

Combining Effects

Filter effects can be chained to create complex transformations:

val hexagon = regularPolygon(6, 60)
  .fillColor(Color.crimson)
  .strokeColor(Color.white)
  .strokeWidth(3)

val multiFiltered = hexagon
  .blur(2.0)
  .sharpen(1.5)
  .dropShadow(10, 10, 3, Color.black.alpha(Normalized(0.4)))

The order of operations is important when combining filters. For example, blur before sharpen creates a different effect to sharpen before blur.

Custom Convolutions

For advanced effects, create custom kernels with the convolve method. A kernel is a matrix of values that determines how each pixel is combined with its neighbors:

import doodle.algebra.Kernel

// Custom emboss kernel
val customEmboss = Kernel(3, 3, IArray(
    -9, -2, 1, 
    -2,  1, 2, 
     1,  2, 9
  )
)

val shape = text("Convolution")
  .font(Font.defaultSerif.bold.italic.size(FontSize.points(36)))
  .fillGradient(
    Gradient.linear(
      Point(0, 0),
      Point(1, 1),
      List(
        (Color.purple, 0.0),
        (Color.hotPink, 0.5),
        (Color.orange, 1.0)
      ),
      Gradient.CycleMethod.NoCycle
    )
  )
  .strokeColor(Color.black)
  .strokeWidth(2)

val enhancedShape = shape.convolve(customEmboss)

Convolution kernels work by multiplying each pixel and its neighbors by the corresponding kernel values, then summing the results. Common kernel patterns include:

Size→