Skip to content

Color Palette Extraction - #1509

Open
olivervogel wants to merge 88 commits into
developfrom
feat/color-palette-lazy
Open

Color Palette Extraction#1509
olivervogel wants to merge 88 commits into
developfrom
feat/color-palette-lazy

Conversation

@olivervogel

@olivervogel olivervogel commented Aug 10, 2026

Copy link
Copy Markdown
Member

The ability to generate color palettes from an image instance.

// palette extraction
$popular = $image->colors()->popular(limit: 512);
$dominant = $image->colors()->dominant(limit: 5);
$swatches = $image->colors()->swatches();

// swatches
$vibrant = $swatches->vibrant;
$muted = $swatches->muted;
$darkVibrant = $swatches->darkVibrant;
$darkMuted = $swatches->darkMuted;
$lightVibrant = $swatches->lightVibrant;
$lightMuted = $swatches->lightMuted;

// get color from palettes
$mostPopular = $popular->first();
$mostDominant = $dominant->first();

// color space transformation
$cmykPopular = $popular->toColorspace(Cmyk::class)

@olivervogel olivervogel changed the title Color Palettes Color Palette Extraction Aug 10, 2026
@olivervogel
olivervogel marked this pull request as draft August 10, 2026 15:19
@deluxetom

Copy link
Copy Markdown
Contributor

@olivervogel no problem, want me to post a PR to this branch with the fixes? I have some extra claude usage I can dedicate to this :)

@olivervogel

olivervogel commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@deluxetom That would be really great. I pushed my last local commits.

deluxetom and others added 23 commits August 14, 2026 17:04
… array access

- rebuild bin keys on toColorspace() so lookups work after conversion
  and colors that become identical are merged into one bin
- convert each color individually in sortByChannel() so palettes with
  mixed colorspaces no longer throw
- throw NotSupportedException on array write access instead of
  silently mutating a temporary array
…tization

- bins keep the first actual color as representative instead of the
  calculated bin center, so every reported color exists in the image
- alpha is left out of the bin key, colors that only differ slightly
  in transparency no longer occupy multiple palette slots
- popular() sorts by presence before applying the limit, so the result
  contains the globally most frequent colors
- the LEVEL_MAX decision is made from the distinct sampled colors
  instead of a driver-specific color count, GD and Imagick now return
  identical palettes and the full resolution Imagick histogram
  pre-scan is gone
- VibrantMuted uses a coarsely quantized palette so the population
  weight has an actual influence on swatch scoring
- the RNG is re-seeded on every analyze() call, so a reused analyzer
  instance returns identical results for the same image
- colors are flattened once to plain Oklab float triples and centroid
  updates accumulate per-cluster sums in a single pass over the
  assignments, O(n) instead of O(k*n) per iteration

Measured on a 2000x1500 image (GD):

    dominant(8):  0.64s -> 0.04s
    dominant(16): 3.05s -> 0.10s
    dominant(64): 16.43s -> 0.43s
The previous fitsWithin() guard compared only width and height, so an
offset region passed validation and failed later with a misleading
pixel coordinate error. The test also moves to tests/Unit/Analyzers
and receives a CoversClass attribute.
Checking the type after new $swatches() let undeclared errors escape
for abstract classes or constructors with required parameters. The
unsound @return T generic is removed because the returned object comes
from the color filter, not from the given argument.
unset() on a typed property leaves it uninitialized and every later
read fatals. The offset is set to null instead. Also removes the
duplicated reflection filter in getIterator().
All public entry points are typed ImageInterface, without the
declaration the color extractor was unreachable for static analysis.
@olivervogel

Copy link
Copy Markdown
Member Author

@deluxetom I've moved a few things around. Mainly put the quantization logic directly to the palette and added a method to quantize but keep original channel values.

Thanks again for the support.

@deluxetom

Copy link
Copy Markdown
Contributor

@olivervogel last review, almost there :)

Worth fixing before merge (both cheap, both hit the documented $swatches['...'] API):

1. src/Colors/Swatches/AbstractSwatches.php:55 - offsetExists() uses property_exists(), so isset($swatches['vibrant']) returns true when the swatch is null (the normal "no match" result, see testFilterColorsNotFound). if (isset($swatches['lightMuted'])) { $swatches['lightMuted']->toHex(); } fatals. Use $this->{$offset} !== null.
2. AbstractSwatches.php:75 and :85 - offsetSet()/offsetUnset() accept any offset, so $swatches['foo'] = $color creates a dynamic property. Deprecated on PHP 8.3+, an Error in PHP 9. Guard against unknown offsets.

Worth a maintainer's opinion:

3. src/Analyzers/AbstractPaletteAnalyzer.php:25 - every sampled pixel calls $image->colorAt(), which goes through Image::analyze() and a full AbstractDriver::specialize(). Up to ~111k specializations per analysis on a 10000x10000 image. Reuse one specialized analyzer, or do a driver-level bulk pixel read.

@olivervogel

olivervogel commented Aug 16, 2026

Copy link
Copy Markdown
Member Author

Unfinished Business

1. Optimization of sample pixel collection

src/Analyzers/AbstractPaletteAnalyzer.php:25 - every sampled pixel calls $image->colorAt(), which goes through Image::analyze() and a full AbstractDriver::specialize(). Up to ~111k specializations per analysis on a 10000x10000 image. Reuse one specialized analyzer, or do a driver-level bulk pixel read.

2. Standardize SwatchesInterface and PaletteInterface

We could consider extending SwatchesInterface from PaletteInterface to standardize the handling, since they both do almost the same thing.

The main difference is that Palette objects use integers as iteration keys, while Swatches objects use strings.

The implementation of sorting functions in Swatches is also still undecided.

3. Palettes and ColorFilters

I think it makes more sense overall if ColorFilterInterface::filterColors() returns its input (Palette objects) in the same type. Currently the method returns SwatchesInterface.

A new PaletteInterface::filter() method is a good option; it accepts ColorFilterInterface objects and returns PaletteInterface objects.

It's unclear where to put the swatch object instantiation.

4. Pick most used color in reduce()

Pick most used color instead of just the first one.

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.

2 participants