Posterize

A filter to smooth edges and reduce an image to a defined set of colours.

import blur from "./stack-blur-alpha.asset.mjs"
import palette from "/scratchpad/_lib/palette.asset.mjs"

// Clamp between min and max
const clamp = (n) => Math.max(Math.min(n, 255), 0)

// Exponential easing
const ramp = (t, p = 3) =>
  t <= 0.5 ? Math.pow(t * 2, p) / 2 : 1 - Math.pow(2 - t * 2, p) / 2

// Store the ramp calculations for greater speed
const rampMap = new Uint8ClampedArray(256)

// Distance between two colours. If only one color is passed,
// the maximum distance is returned.
// Use perceived strengths of each RGB channel
const distance = (r1, g1, b1, r2, g2, b2) =>
  r2 != undefined
    ? Math.abs(r1 - r2) * 0.299 +
      Math.abs(g1 - g2) * 0.587 +
      Math.abs(b1 - b2) * 0.114
    : 255

const merge = (layers, colors, width, height) => {
  const base = new ImageData(width, height)

  for (let i = 0; i < layers[0].length; i++) {
    const ii = i << 2

    let r = 0
    let g = 0
    let b = 0
    let sum = 0

    layers.forEach((layer, j) => {
      const a = layer[i]
      sum += a
      r += colors[j][0] * a
      g += colors[j][1] * a
      b += colors[j][2] * a
    })

    base.data[ii + 0] = clamp(r / sum)
    base.data[ii + 1] = clamp(g / sum)
    base.data[ii + 2] = clamp(b / sum)
    base.data[ii + 3] = clamp(sum)
  }

  return base
}

const parseColors = (colors, ctx) =>
  colors &&
  colors.map((c) => {
    if (typeof c === "string") {
      ctx.save()
      ctx.fillStyle = c
      c = ctx.fillStyle
      ctx.restore()
      return c.match(/[\dA-F]{2}/gi).map((d) => parseInt(d, 16))
    } else {
      return c
    }
  })

export default ({
  ctx,

  // Blur radius and iterations
  radius = 1,
  iterations = 1,

  // De-blur ramping (should be approximately 10x radius)
  ramp: pow = 8,

  // Color range to support
  colors = [palette.dark],

  // Optional clipping
  x = 0,
  y = 0,
  height,
  width,

  // Optional color tweaks
  overshoot = 0,
  colorMap = false,
  ignoreDelta = false,
  background = false,
}) => {
  const image = ctx.getImageData(
    x,
    y,
    width || ctx.canvas.width,
    height || ctx.canvas.height
  )

  radius = ~~radius

  colors = parseColors(colors, ctx)
  colorMap = parseColors(colorMap, ctx)

  if (rampMap.pow !== pow) {
    rampMap.pow = pow
    Array.from({ length: 256 }).forEach(
      (e, i) => (rampMap[i] = ramp(i / 255, pow) * 255)
    )
  }

  const bg = background ? parseColors([background], ctx)[0] : []

  // Create a new alpha channel for each color
  const layers = colors.map(([r1, g1, b1, a1], i) => {
    const layer = new Uint8ClampedArray(image.width * image.height)

    // The threshold is the smallest distance between adjacent colors
    const d0 = distance(r1, g1, b1, ...(colors[i - 1] || bg))
    const d1 = distance(r1, g1, b1, ...(colors[i + 1] || []))
    const threshold = Math.min(d0, d1) * 0.5 * (1 + overshoot)

    // If this pixel is closest to this [r1,g1,b1,a1], set the alpha
    for (let i = 0; i < layer.length; i++) {
      const j = i << 2

      const r2 = image.data[j + 0]
      const g2 = image.data[j + 1]
      const b2 = image.data[j + 2]
      const a2 = image.data[j + 3]
      const d = distance(r1, g1, b1, r2, g2, b2)

      if (d < threshold) {
        layer[i] = ignoreDelta ? a2 : clamp(a2 * (1 - d / threshold))
      }
    }

    if (radius >= 1) {
      for (let i = 0; i < iterations; i++) {
        blur({
          layer,
          radius,
          width: image.width,
          height: image.height,
        })
      }
    }

    return layer.map((v) => rampMap[v])
  })

  const mix = merge(layers, colorMap || colors, image.width, image.height)

  ctx.putImageData(mix, x, y)

  if (background) {
    ctx.save()
    ctx.globalCompositeOperation = "destination-over"
    ctx.fillStyle = background
    ctx.fillRect(0, 0, image.width, image.height)
    ctx.restore()
  }
}