Conversation
Resizing a 24 MP photo to 8192² is a 1.9× upscale — pure interpolation that adds no new color information. The bench case becomes a measurement of vImage interpolation cost on synthetic upscaled data, not realistic content. Hide the toggle (and skip the 8192² case in BenchRunner) when sourceKind is .photo so users can't accidentally collect misleading data. Synth source still exposes 8192² for stress-testing the algorithm at high pixel counts.
Exercises PaletteKit's .data(...) path so the ImageIO thumbnail fast-path becomes measurable. The size axis is meaningless for this source, so the grid for .photoData is quantizer × downsample only.
K-means++ init + Lloyd's iteration (max 32 iterations, drift threshold 1.0 in 8-bit RGB units). Available via .custom(KMeansQuantizer()) and exposed in the bench harness as the .kmeans QuantizerKind case.
A separate screen (entered from BenchView toolbar) runs a configurable list of scenarios in sequence and emits a single combined CSV with a new scenario column. Defaults to 6 presets covering synth/photo/ photoData × auto/raw, and a Quick size grid (1024² + 4096²).
BenchRunner is @MainActor-isolated, so PaletteExtractor.palette() and the synthesized fixture builder were running on the main thread — Xcode's hang detector flagged each big case as a main-thread hang. Wrap both in Task.detached so they run on a cooperative thread pool.
Spike 1C measured k-means at 9-131× MMCQ-CPU across the comparison suite (16-fold past the 3× gate). The Metal port couldn't plausibly recover that gap, and quality wins at K=5-10 are too uncertain to justify the surrounding engineering cost. K-means track abandoned for v1.2 — see decisions log.
Drops size-based quantizer routing. On-device measurements (iPhone 15 Pro, 4096²) showed CPU and Metal within ≤4ms after auto-downsample, so the threshold added complexity without measurable wins at default settings. Metal is now opt-in for raw + ≥4MP only (~5-10% quantize savings). - PaletteExtractor: remove metalAutoThreshold; .auto always returns MmcqQuantizer; .metal logs a DEBUG-only hint when sampled pixels < 1M. - PaletteKit: paletteKitVersion 1.0.0 → 1.2.0. - README: hero demote, .data tip, CPU-vs-Metal decision tree, refreshed Roadmap, bench-as-internal-tool note, Features bullet correctness fix. - DocC: Options.md gains "Choosing an ImageSource" + decision-tree sections; PerformanceTuning.md "Auto-selection" rewritten.
Adds an interactive option-tuning sheet to the demo app and refreshes the surrounding UI. - ExtractionOptionsSheet: 7 tunable options (colorCount, colorSpace, sampling stride, ignoreWhite, minSaturation, downsample, quantizer) in a Result/Sampling/Performance form. Reset + Done toolbar, medium/ large detents with drag indicator. Each row has a tap-to-show help popover (BenchInfo-style fixed width, vertical fixedSize). Inline hint when .metal is selected. - ContentView: gear toolbar entry presents the sheet; Done re-runs extraction. Photo picker becomes a dashed-border empty-state card before pick, and a small "Change photo" capsule afterward. Result order swapped to Swatches → Palette → Dominant → Timings since vibrant typically reads as the "main color". Re-extraction is now race-free via Task cancellation. Palette chips, dominant card, and swatch tiles are tap-to-copy: tap copies the hex to UIPasteboard, triggers a light haptic, and briefly swaps the hex label for "Copied" with a checkmark overlay. - AppIcon: universal 1024² in Assets.xcassets.
ContentView had grown to ~390 lines with five private subviews and a clipboard helper inlined. Split into per-responsibility files so the top-level view is just the scaffolding. - Result/ — DominantColorView, PaletteGrid (+ PaletteChip), SwatchesView (+ SwatchCard), TimingsView. - PhotoPickerLabel.swift — empty-state card / change-photo capsule. - Support/ClipboardCopy.swift — `copyToPasteboard(_:copied:)` helper used by all three tap-to-copy cells. Animation duration tightened to 0.8s. ContentView is 179 lines now (was 391).
Phase 1 of MMCQ splits boxes by population until reaching a target of 0.75 × maxColors. PaletteKit truncated this target to int via .rounded(.down); color-thief compares an int count against the raw fractional target, which effectively rounds up. For default colorCount = 10, target = 7.5: color-thief exits Phase 1 with 8 boxes; PaletteKit was exiting with 7 and giving Phase 2 (volume × count weighting) one extra slot. Same divergence applied to any maxColors where the product is non-integer (5, 7, 11, 14, …). Switching to .rounded(.up) brings Phase 1 in line with color-thief on the default path so palettes match for the same input. Adds a regression test pinning the expected target across boundary values.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.autoquantizer routing. On-device measurements (iPhone 15 Pro, 4096² photos) showed CPU and Metal within ≤4ms after auto-downsample, so the threshold added complexity without measurable wins at default settings..autoalways picks CPU now; Metal is opt-in for raw mode + ≥4MP input.colorCountvalues (default10was the most common case).ExtractionOptionsfields with help popovers and live re-extraction. Photo picker, result layout, and tap-to-copy hex affordance refreshed. New app icon.What's changed
Library (
Sources/PaletteKit/)PaletteExtractor: removemetalAutoThreshold;.autoalways returnsMmcqQuantizer..metalemits a DEBUG-onlyPaletteKitLoghint when sampled pixel count < 1M.MmcqQuantizer: Phase 1 target rounds up to match color-thief'sint >= floatcomparison. Regression test pins expected target across boundarymaxColorsvalues.paletteKitVersion = "1.2.0".Demo app (
Examples/PaletteKitDemo/)ExtractionOptionsSheet: new sheet (Result / Sampling / Performance) covering 7 options. Reset + Done toolbar, medium/large detents with drag indicator, per-row help popovers (BenchInfo-style fixed width). Inline hint when.metalis selected.ContentViewrefactored: split into per-responsibility files underResult/,Support/, andPhotoPickerLabel.swift. Result order swapped to Swatches → Palette → Dominant → Timings. Tap-to-copy hex on every cell with light haptic and a 0.8s "Copied" overlay. Re-extraction is race-free via Task cancellation.Assets.xcassets/AppIcon.appiconset(universal 1024² PNG).Docs
## CPU vs Metalrewritten as a 4-row "accuracy vs speed" decision tree. New.data(...)performance tip. Roadmap refreshed with v1.2/v1.3/v1.4/v2.0. Bench section tagged as internal tooling.Options.md: new "Choosing anImageSource" section + decision-tree section.PerformanceTuning.mdAuto-selectionrewritten.Audit notes
color-thief v3 (TypeScript rewrite, master branch) was read alongside PaletteKit's MMCQ. Constants
SIGBITS=5 / RSHIFT=3 / MAX_ITERATIONS=1000 / FRACT_BY_POPULATIONS=0.75 / HISTO_SIZE=32768match exactly;ExtractionOptionsdefaults match byte-for-byte within color-thief's clamp range. Algorithm logic — short-circuit, histogram build, vbox bounds, Phase 1/2 split, median-cut refinement, mass-weighted average — all step-by-step identical.Two findings worth recording:
vboxFromPixelsuses anelse ifchain that leavesrmaxat its initial0for monotonically-decreasing pixel sequences (latent edge case the unique-color short-circuit usually masks). PaletteKit usesmin/maxseparately and avoids it.floor(0.75 × maxColors)vsceil(0.75 × maxColors)— fixed in this PR.Test plan
swift test— 31/31 pass (addedphase1CeilingRoundingregression test).make demo-appregenerates Xcode project; iOS simulator build succeeds..metalis selected.darkVibrantmatched byte-for-byte (#690304); other swatches within ~ΔE 6.