Skip to content

2dubu/PaletteKit

Repository files navigation

palettekit

PaletteKit

Swift Platforms License: MIT

color-thief reimagined for Apple — a modern, iOS-native palette extractor with a built-in palette-driven graphic primitive. Swift Package, SwiftUI- and UIKit-friendly: OKLCH perceptual quantization, Display P3 wide-gamut support, Semantic Swatches, async-only Sendable API.

Demo: palette extraction (photo 1) Demo: PaletteGraphic Lab (photo 1) Demo: palette extraction (photo 2) Demo: PaletteGraphic Lab (photo 2)

Demo app: palette extraction → PaletteGraphic, on two different photos

Demo: palette extraction (photo 3) Demo: PaletteGraphic Lab (photo 3) Demo: PaletteGraphic Lab (photo 3)

Demo app: palette extraction → MeshGraphic & AnimatedGraphic

Install

// Package.swift
dependencies: [
    .package(url: "https://github.com/2dubu/PaletteKit", from: "2.1.0"),
]

Minimum iOS 17 · Swift 6.0 · Xcode 16+. For on-device naming and summaries, also add the optional PaletteKitInsights product (iOS 26+).

Quick start

SwiftUI

import PaletteKit
import SwiftUI

let extractor = PaletteExtractor()
let palette = try await extractor.palette(from: .data(imageData))
let swatches = try await extractor.swatches(from: .data(imageData))

Rectangle()
    .fill(palette.dominant ?? .black)

Text("Hello")
    .foregroundStyle(swatches.titleTextColor(for: .vibrant, fallback: .black))

UIKit

import PaletteKit
import UIKit

let extractor = PaletteExtractor()
let palette = try await extractor.palette(from: .data(imageData))
let swatches = try await extractor.swatches(from: .data(imageData))

view.backgroundColor = UIColor(palette.dominant ?? .black)
label.textColor = UIColor(swatches.titleTextColor(for: .vibrant, fallback: .black))

Generate a graphic

import PaletteKit
import SwiftUI

let configuration = PaletteGraphic.Configuration(
    direction: .linear,
    colorCount: .three,
    swatchStrategy: .vibrant,
    grain: .standard
)

PaletteGraphic(palette: palette, swatches: swatches, configuration: configuration)
    .frame(width: 320, height: 320)
    .clipShape(RoundedRectangle(cornerRadius: 24))

UIKit: PaletteGraphicView (UIView, no UIHostingController). For a static UIImage, use PaletteGraphic.makeImage(size:scale:) or PaletteGraphicView.snapshotImage(scale:).

Load asynchronously

AsyncPaletteGraphic extracts the palette internally and shows your placeholder while loading — no explicit PaletteExtractor step.

import PaletteKit
import SwiftUI

AsyncPaletteGraphic(image: .url(url)) {
    Color.gray.opacity(0.1)   // shown during loading and on failure
}
.frame(width: 320, height: 320)
.clipShape(RoundedRectangle(cornerRadius: 24))

UIKit pair: AsyncPaletteGraphicView. Both share a process-wide PaletteCache.shared. See Loading palettes asynchronously for caching, transitions, and error handling.

Animate a graphic

AnimatedPaletteGraphic renders the palette as a slowly morphing "living gradient" — a multi-point, LAB-blended flow with organic, non-periodic motion. It fills its frame; clip to any shape.

AnimatedPaletteGraphic(
    palette: palette,
    configuration: .init(
        colorCount: .three,   // .two ... .five
        speed: .regular,      // .slow / .regular / .fast
        isAnimated: true
    )
)
.frame(width: 320, height: 420)
.clipShape(RoundedRectangle(cornerRadius: 24))

UIKit pair: AnimatedPaletteGraphicView. Both honor Reduce Motion and Low Power Mode (hold a static frame) and pause while off-screen.

Generate insights

PaletteInsightsGenerator (iOS 26+, PaletteKitInsights module) names a palette and writes a one-line summary with the on-device FoundationModels model — no network. With the product added:

import PaletteKit
import PaletteKitInsights

let palette = try await PaletteExtractor().palette(from: image)
let generator = PaletteInsightsGenerator()

guard generator.isAvailable, generator.supportsLocale() else { return }   // fallback otherwise

let insights = try await generator.insights(
    for: palette,
    guidance: "warm and nostalgic"   // optional
)
print(insights.name)      // e.g. "Faded Coastline"
print(insights.summary)   // one sentence, in the device language

Features

  • Async, Sendable, Swift 6 strict concurrency. Every entry point is async throws. PaletteExtractor is a value type — one per call site or shared across actors.
  • Palette-driven graphic primitive. PaletteGraphic (SwiftUI) and PaletteGraphicView (UIKit) render gradient + grain artwork from any palette via a shared Core Image / Core Graphics renderer — pixel-equivalent across platforms, NSCache-memoized, configurable along four axes (direction, color count, swatch strategy, grain).
  • Rich PaletteColor. hex, HSL, OKLCH, contrast, isDark/isLight, and ShapeStyle conformance for direct use in any SwiftUI fill / foreground / background modifier.
  • OKLCH perceptual quantization by default. Palettes feel evenly spaced to the eye, not in sRGB.
  • Display P3 native. iPhone photos keep their chroma instead of clipping to sRGB.
  • CPU by default, Metal opt-in. MmcqQuantizer (CPU, Accelerate) is the default and what .auto selects. MetalMmcqQuantizer (GPU compute shader) is opt-in for raw mode on ≥4MP inputs (~5-10% quantize speedup). Bring your own via the Quantizer protocol.
  • Automatic pre-downsampling keeps memory bounded for 12-megapixel photos.
  • Semantic swatches. Six OKLCH roles (vibrant, muted, darkVibrant, darkMuted, lightVibrant, lightMuted) with accessible text-color recommendations.
  • EXIF auto-orientation for real-world iPhone photos.
  • os.Logger + signposts wired into Instruments' "Points of Interest".
  • Typed errors via PaletteError.

API

extractor.dominantColor(from:)    // PaletteColor?
extractor.palette(from:)          // Palette
extractor.swatches(from:)         // SwatchMap

ImageSource (.cgImage / .data / .url) and the full ExtractionOptions surface (colorCount, quality, colorSpace, downsample, quantizer, …) are documented in the DocC reference.

Tip: Prefer .data(...) for HEIC/JPEG bytes already in memory or fetched over the network — the data path skips file-system overhead (~17% faster than .url(...) on iPhone 15 Pro for a 4MP HEIC). Use .url(...) for on-disk files so the decoder can mmap them directly.

Color space handling

PaletteKit keeps palette colors in the source color space (CGImage.colorSpace): Display P3 input → Display P3 output. OKLCH is used only internally during quantization for perceptual uniformity.

let palette = try await extractor.palette(from: .url(hdrPhotoURL))
palette.colorSpaceUsed  // .displayP3 on an iPhone HEIC, .sRGB elsewhere

CPU vs Metal: choose by goal, not by image size

Default (.auto) always uses CPU MMCQ. On-device measurements (iPhone 15 Pro, 4096² photos) put CPU and Metal within ≤4ms after auto-downsample, so size-based routing added complexity without measurable wins. Metal helps in one narrow band — raw mode + ≥4MP input, ~5-10% off quantize; use .metal only with downsampling disabled.

You want… quantizer downsample Notes
A palette, no fuss .auto default The default. CPU + auto-downsample.
Maximum color accuracy .cpu .disabled Process every pixel. Slowest, most accurate.
Accuracy + speed on large inputs .metal .disabled ≥4MP only. ~5-10% quantize win vs CPU raw.
Ensure work runs on GPU .metal default Falls back to CPU if Metal is unavailable.
// Default — CPU with auto-downsample to ~1M pixels:
try await extractor.palette(from: source)

// Maximum accuracy — every pixel, CPU MMCQ:
try await extractor.palette(from: source,
    options: ExtractionOptions(downsample: .disabled, quantizer: .cpu))

// Large-input accuracy + Metal (≥4MP raw):
try await extractor.palette(from: source,
    options: ExtractionOptions(downsample: .disabled, quantizer: .metal))

Metal warms up on first MetalContext use (shader compile + pipeline build); later calls are steady-state. DEBUG builds log a hint if you select .metal on input too small for the speedup to land.

Instrumentation

let palette = try await extractor.palette(
    from: .url(url),
    options: ExtractionOptions(collectTimings: true)
)
palette.timings?.decode          // Duration
palette.timings?.sample          // Duration
palette.timings?.quantize        // Duration
palette.timings?.total           // Duration
palette.timings?.quantizerUsed   // "MMCQ-CPU" or "MMCQ-Metal"

Traces are annotated via os_signpost (com.paletteKit / pointsOfInterest) — use the "Points of Interest" template for decode / sample / quantize intervals.

Documentation

Full DocC catalog ships with the package:

  • PaletteKit reference
  • GettingStarted.md · Options.md · PerformanceTuning.md · Card.md
  • AlgorithmDeepDive.md — MMCQ, OKLCH, Swatches internals

Build locally with xcodebuild docbuild or browse on Swift Package Index.

Example

Examples/PaletteKitDemo — a minimal SwiftUI app: photo-picker → palette grid → swatches → timings. Tap Generate Graphic on the result screen for the Graphic Lab, an interactive playground for every configuration axis on your actual extracted palette.

make setup       # one-time: installs xcodegen via Homebrew if missing
make demo-app    # generate PaletteKitDemo.xcodeproj and open it in Xcode

See Examples/PaletteKitDemo/README.md for how the app is wired.

Benchmark on your device

The demo app ships an on-device benchmark harness (speedometer icon, top-right): pick a real photo or the synthesized fixture, vary size / quantizer / downsample, Run, then Export per-stage timings (decode, sample, quantize) as CSV for cross-device comparison. It's primarily an internal discipline tool — every CPU/GPU change clears a measurement gate before it lands — so most apps don't need to run it.

Requirements

  • iOS 17+
  • Xcode 16+
  • Swift 6.0 (strict concurrency)

Acknowledgements

Thanks to color-thief by Lokesh Dhakar (MIT) — the MMCQ algorithm family, OKLCH-first quantization, and the six-role swatch layout shaped PaletteKit's direction. PaletteKit reimagines those ideas for iOS with a Metal compute histogram, Display P3 preservation, Swift 6 concurrency, and CGImageSource-based decoding, while keeping the algorithmic core compatible so outputs can be cross-verified against the reference.

License

MIT. See LICENSE.

About

PaletteKit — Extraction dominant color & OKLCH semantic palette extraction for iOS.

Topics

Resources

License

Stars

20 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages