Ollin

Shades

four colors taken from a photograph of woven blankets, each grown into a ramp of shades whose hue warms in the light and cools in the shadow, the photograph snapped cell by cell to the nearest shade, and the lightness histograms of both

Run
swift run --package-path Examples Example-Color-Shades
Imports
OllinSamplePhotos
Source
Examples/Color/Shades/Sketch.swift
import Ollin
import OllinSamplePhotos

/// A photograph mapped onto a palette the way a pixel artist builds one: a few
/// ramps of shades, each turning warmer toward its light and cooler toward its
/// shadow, and every cell of the picture snapped to the swatch it looks most
/// like.
///
/// The four bases are the colors the picture is mostly made of
/// (`extractPalette(from:)`), and `Palette.shades(of:count:hueDrift:)` grows a
/// ramp from each. A cell's average color goes to `palette.nearestIndex(to:)`,
/// measured in OKLab, or in CIEDE2000 with the parameter on. The bars under
/// the picture are two lightness histograms: the photograph's in gray, the
/// mapped picture's in color, with the photograph's 2% and 98% percentiles
/// marked, the working black and white a levels adjustment would start from.
///
/// Turn `Hue drift` to zero and the ramps go flat: the same lightness steps,
/// one hue each, and the picture loses the warm light and cool shade the drift
/// gives it.
@main
final class Shades: Sketch {
    @Param("Hue drift", 0...0.25) var hueDrift = 1.0 / 12
    @Param("Shades per ramp", 3...8) var perRamp = 5
    @Param("Measure in CIEDE2000") var measuresCIEDE2000 = false

    private let columns = 72
    private var rows = 48
    private var photograph = Image(width: 1, height: 1)
    private var bases: [Color] = []
    private var cells: [Color] = []
    private var photoHistogram: Histogram?

    // The mapped picture, kept until a parameter changes.
    private var mappedKey: [Double] = []
    private var palette = Palette([])
    private var mapped: [Int] = []
    private var mappedHistogram: Histogram?

    override func setup() {
        noStroke()
        photograph = SamplePhoto.textiles.load()
        rows = Int((Double(columns) * Double(photograph.height) / Double(photograph.width)).rounded())
        bases = Array(extractPalette(from: photograph, count: 4, seed: 3))
        photoHistogram = photograph.histogram(of: .lightness, bins: 96)

        // One average per cell, read once: the picture never changes.
        let cellWidth = Double(photograph.width) / Double(columns)
        let cellHeight = Double(photograph.height) / Double(rows)
        cells = (0 ..< rows).flatMap { row in
            (0 ..< columns).map { column in
                photograph.averageColor(in: Rectangle(x: Double(column) * cellWidth,
                                                      y: Double(row) * cellHeight,
                                                      width: cellWidth, height: cellHeight))
            }
        }
    }

    override func draw() {
        background(Color(hex: 0x111317))
        remapIfNeeded()

        // The mapped picture, then the ramps and the histograms under it, the
        // whole block centered on the canvas.
        let swatch = 34.0
        let panelHeight = Double(bases.count) * (swatch + 4) - 4
        let aspect = Double(rows) / Double(columns)
        let pictureWidth = min(width - 180, (height - 180 - 40 - panelHeight) / aspect)
        let pictureHeight = pictureWidth * aspect
        let top = (height - pictureHeight - 40 - panelHeight) / 2
        let frame = Rectangle(x: (width - pictureWidth) / 2, y: top,
                              width: pictureWidth, height: pictureHeight)
        let panel = Rectangle(x: frame.x, y: frame.y + frame.height + 40,
                              width: frame.width, height: panelHeight)
        let cell = frame.width / Double(columns)
        for row in 0 ..< rows {
            for column in 0 ..< columns {
                fill(palette[mapped[row * columns + column]])
                drawRect(frame.x + Double(column) * cell, frame.y + Double(row) * cell, cell + 0.5, cell + 0.5)
            }
        }

        // The ramps, one row each, darkest on the left.
        for r in 0 ..< bases.count {
            for i in 0 ..< perRamp {
                fill(palette[r * perRamp + i])
                drawRect(panel.x + Double(i) * (swatch + 4), panel.y + Double(r) * (swatch + 4),
                         swatch, swatch)
            }
        }

        // The two lightness histograms, and the photograph's working ends.
        let chartLeft = panel.x + Double(perRamp) * (swatch + 4) + 40
        let chart = Rectangle(x: chartLeft, y: panel.y,
                              width: panel.x + panel.width - chartLeft, height: panelHeight)
        if let photoHistogram, let mappedHistogram {
            drawBars(photoHistogram, in: chart, color: Color(white: 0.42))
            drawBars(mappedHistogram, in: chart, color: Color(hex: 0xF2B880).withAlpha(0.85))
            stroke(Color(white: 0.85))
            strokeWeight(1.5)
            for fraction in [0.02, 0.98] {
                let x = chart.x + chart.width * photoHistogram.percentile(fraction)
                drawLine(x, chart.y - 6, x, chart.y + chart.height)
            }
            noStroke()
        }

        drawCaption(measuresCIEDE2000 ? "snapped to the nearest shade, CIEDE2000"
                                      : "snapped to the nearest shade, OKLab")
    }

    private func remapIfNeeded() {
        let key = [hueDrift, Double(perRamp), measuresCIEDE2000 ? 1 : 0]
        guard key != mappedKey else { return }
        mappedKey = key
        palette = Palette(bases.flatMap { Palette.shades(of: $0, count: perRamp, hueDrift: hueDrift).colors })
        let measure: ColorDistance = measuresCIEDE2000 ? .ciede2000 : .oklab
        mapped = cells.map { palette.nearestIndex(to: $0, measure: measure) ?? 0 }

        // The mapped picture as an image of its own, one pixel a cell, so its
        // histogram counts the same cells the photograph's averages came from.
        let picture = Image(width: columns, height: rows)
        for (i, entry) in mapped.enumerated() { picture[i % columns, i / columns] = palette[entry] }
        mappedHistogram = picture.histogram(of: .lightness, bins: 96)
    }

    private func drawBars(_ histogram: Histogram, in box: Rectangle, color: Color) {
        guard let tallest = histogram.counts.max(), tallest > 0 else { return }
        fill(color)
        let bar = box.width / Double(histogram.counts.count)
        for (i, count) in histogram.counts.enumerated() where count > 0 {
            let h = box.height * Double(count) / Double(tallest)
            drawRect(box.x + Double(i) * bar, box.y + box.height - h, bar * 0.8, h)
        }
    }
}

↑↓ move ↩ open