Skip to content

library and doc cleanup (v1.2) #2

Merged
2dubu merged 11 commits intomainfrom
feature/v1.2-finalize
May 1, 2026
Merged

library and doc cleanup (v1.2) #2
2dubu merged 11 commits intomainfrom
feature/v1.2-finalize

Conversation

@2dubu
Copy link
Copy Markdown
Owner

@2dubu 2dubu commented May 1, 2026

Summary

  • Drop size-based .auto quantizer 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. .auto always picks CPU now; Metal is opt-in for raw mode + ≥4MP input.
  • Audit MMCQ implementation against color-thief v3 (TypeScript rewrite). Constants, defaults, and algorithm logic verified identical step-by-step. One real divergence found and fixed: Phase 1 termination target now matches color-thief's effective ceiling rounding for non-multiple-of-4 colorCount values (default 10 was the most common case).
  • Demo app gains an option-tuning sheet exposing 7 most-relevant ExtractionOptions fields with help popovers and live re-extraction. Photo picker, result layout, and tap-to-copy hex affordance refreshed. New app icon.
  • README + DocC rewritten around the new "accuracy vs speed" decision tree. Bench harness explicitly tagged as internal development tooling.

What's changed

Library (Sources/PaletteKit/)

  • PaletteExtractor: remove metalAutoThreshold; .auto always returns MmcqQuantizer. .metal emits a DEBUG-only PaletteKitLog hint when sampled pixel count < 1M.
  • MmcqQuantizer: Phase 1 target rounds up to match color-thief's int >= float comparison. Regression test pins expected target across boundary maxColors values.
  • 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 .metal is selected.
  • ContentView refactored: split into per-responsibility files under Result/, Support/, and PhotoPickerLabel.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.
  • Photo picker becomes a dashed-border empty-state card before pick, and a small "Change photo" capsule afterward.
  • App icon at Assets.xcassets/AppIcon.appiconset (universal 1024² PNG).

Docs

  • README: hero copy reframed around "color-thief reimagined for Apple". ## CPU vs Metal rewritten 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.
  • DocC Options.md: new "Choosing an ImageSource" section + decision-tree section. PerformanceTuning.md Auto-selection rewritten.

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=32768 match exactly; ExtractionOptions defaults 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:

  • color-thief's vboxFromPixels uses an else if chain that leaves rmax at its initial 0 for monotonically-decreasing pixel sequences (latent edge case the unique-color short-circuit usually masks). PaletteKit uses min/max separately and avoids it.
  • Phase 1 floor(0.75 × maxColors) vs ceil(0.75 × maxColors) — fixed in this PR.

Test plan

  • swift test — 31/31 pass (added phase1CeilingRounding regression test).
  • make demo-app regenerates Xcode project; iOS simulator build succeeds.
  • Demo app: option sheet opens at medium detent with drag-to-large; Reset restores defaults; Done re-runs extraction; help popovers expand with text wrap; Metal hint appears when .metal is selected.
  • Tap on any palette chip / dominant card / swatch tile copies the hex to UIPasteboard, fires a light haptic, and shows "Copied" with checkmark for ~0.8s.
  • On-device smoke test: PaletteKit and color-thief outputs compared side-by-side on the same photo; darkVibrant matched byte-for-byte (#690304); other swatches within ~ΔE 6.

2dubu added 11 commits May 1, 2026 02:41
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.
@2dubu 2dubu self-assigned this May 1, 2026
@2dubu 2dubu changed the title Feature/v1.2 finalize library and doc cleanup (v1.2) May 1, 2026
@2dubu 2dubu merged commit c9c02da into main May 1, 2026
1 check passed
@2dubu 2dubu deleted the feature/v1.2-finalize branch May 1, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant