palettekit # PaletteKit [![Swift](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F2dubu%2FPaletteKit%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/2dubu/PaletteKit) [![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F2dubu%2FPaletteKit%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/2dubu/PaletteKit) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Extract dominant colors and render adaptive SwiftUI/UIKit graphics from any image — OKLCH, Display P3, Swift 6. PaletteKit also provides semantic swatches plus static, mesh, and animated graphics.

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 ```swift // Package.swift dependencies: [ .package(url: "https://github.com/2dubu/PaletteKit", from: "2.1.1"), ] ``` Minimum iOS 17 · Xcode 16+. Client apps can remain in Swift 5 language mode. For on-device naming and summaries, also add the optional `PaletteKitInsights` product (iOS 26+). ## Quick start ### SwiftUI ```swift 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 ```swift 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 ```swift 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. ```swift 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](https://swiftpackageindex.com/2dubu/palettekit/main/documentation/palettekit/asyncloading) 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. ```swift 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: ```swift 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 ```swift 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](https://swiftpackageindex.com/2dubu/PaletteKit/documentation/palettekit). **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. ```swift 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. | ```swift // 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 ```swift 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](https://swiftpackageindex.com/2dubu/PaletteKit/documentation). ## 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. ```bash 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`](./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+ - Client targets may use Swift 5 or Swift 6 language mode. ## Contributing Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow, testing expectations, and pull request guidelines. ## Acknowledgements Thanks to [color-thief](https://github.com/lokesh/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](./LICENSE).