Skip to main content

chromasync_extract/
lib.rs

1use std::{
2    collections::BTreeMap,
3    path::{Path, PathBuf},
4};
5
6use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType};
7use palette::{FromColor, Oklab, Srgb};
8use thiserror::Error;
9
10pub const MAX_PROCESSING_DIMENSION: u32 = 128;
11const MAX_SEEDS: usize = 3;
12const MAX_CLUSTERS: usize = 12;
13const MAX_K_MEANS_ITERATIONS: usize = 16;
14const MIN_VISIBLE_ALPHA: u8 = 16;
15const MIN_SEED_DISTANCE: f64 = 0.06;
16const FULL_DIVERSITY_DISTANCE: f64 = 0.18;
17const CHROMA_REFERENCE: f64 = 0.22;
18const POPULATION_WEIGHT: f64 = 0.70;
19const CHROMA_WEIGHT: f64 = 0.20;
20const CENTRALITY_WEIGHT: f64 = 0.10;
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct ExtractedSeed {
24    pub hex: String,
25    pub dominance: f32,
26    pub source_region: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq)]
30pub struct ExtractionResult {
31    pub original_width: u32,
32    pub original_height: u32,
33    pub processed_width: u32,
34    pub processed_height: u32,
35    pub seeds: Vec<ExtractedSeed>,
36}
37
38#[derive(Debug, Clone, Default)]
39struct PointAccumulator {
40    weight: f64,
41    sum_x: f64,
42    sum_y: f64,
43    sum_centrality: f64,
44}
45
46#[derive(Debug, Clone, Copy)]
47struct ColorPoint {
48    rgb: [u8; 3],
49    lab: [f64; 3],
50    weight: f64,
51    average_x: f64,
52    average_y: f64,
53    centrality: f64,
54}
55
56#[derive(Debug, Clone, Default)]
57struct ClusterAccumulator {
58    weight: f64,
59    sum_lab: [f64; 3],
60    sum_rgb: [f64; 3],
61    sum_x: f64,
62    sum_y: f64,
63    sum_centrality: f64,
64}
65
66#[derive(Debug, Clone)]
67struct ClusterCandidate {
68    rgb: [u8; 3],
69    lab: [f64; 3],
70    dominance: f64,
71    average_x: f64,
72    average_y: f64,
73    score: f64,
74}
75
76#[derive(Debug, Error)]
77pub enum ExtractError {
78    #[error("failed to load image '{path}': {source}")]
79    ImageLoad {
80        path: PathBuf,
81        #[source]
82        source: image::ImageError,
83    },
84    #[error("failed to decode image bytes: {source}")]
85    ImageDecode {
86        #[source]
87        source: image::ImageError,
88    },
89    #[error("image '{path}' does not contain any visible pixels")]
90    NoVisiblePixels { path: PathBuf },
91}
92
93pub fn extract_seed_candidates(image: &Path) -> Result<ExtractionResult, ExtractError> {
94    let loaded = image::open(image).map_err(|source| ExtractError::ImageLoad {
95        path: image.to_path_buf(),
96        source,
97    })?;
98    extract_seed_candidates_from_image(loaded, image)
99}
100
101/// Extract seed candidates from an encoded PNG, JPEG, or WebP image.
102///
103/// This is the in-memory equivalent of [`extract_seed_candidates`] and is
104/// suitable for browser, server, and embedded callers that do not have a file
105/// path. Both entry points share the same resize and clustering pipeline.
106pub fn extract_seed_candidates_from_bytes(
107    image_bytes: &[u8],
108) -> Result<ExtractionResult, ExtractError> {
109    let loaded = image::load_from_memory(image_bytes)
110        .map_err(|source| ExtractError::ImageDecode { source })?;
111    extract_seed_candidates_from_image(loaded, Path::new("<memory>"))
112}
113
114fn extract_seed_candidates_from_image(
115    loaded: DynamicImage,
116    source: &Path,
117) -> Result<ExtractionResult, ExtractError> {
118    let (original_width, original_height) = loaded.dimensions();
119    let processed = preprocess_image(loaded);
120    let (processed_width, processed_height) = processed.dimensions();
121    let seeds = cluster_image(&processed.to_rgba8(), source)?;
122
123    Ok(ExtractionResult {
124        original_width,
125        original_height,
126        processed_width,
127        processed_height,
128        seeds,
129    })
130}
131
132fn preprocess_image(image: DynamicImage) -> DynamicImage {
133    let (width, height) = image.dimensions();
134
135    if width <= MAX_PROCESSING_DIMENSION && height <= MAX_PROCESSING_DIMENSION {
136        return image;
137    }
138
139    image.resize(
140        MAX_PROCESSING_DIMENSION,
141        MAX_PROCESSING_DIMENSION,
142        FilterType::Triangle,
143    )
144}
145
146fn cluster_image(image: &RgbaImage, path: &Path) -> Result<Vec<ExtractedSeed>, ExtractError> {
147    let (width, height) = image.dimensions();
148    let mut points = BTreeMap::<[u8; 3], PointAccumulator>::new();
149    let mut total_weight = 0.0;
150
151    for (x, y, pixel) in image.enumerate_pixels() {
152        let [r, g, b, alpha] = pixel.0;
153
154        if alpha < MIN_VISIBLE_ALPHA {
155            continue;
156        }
157
158        let weight = f64::from(alpha) / 255.0;
159        let normalized_x = normalized_coordinate(x, width);
160        let normalized_y = normalized_coordinate(y, height);
161        let centrality = radial_centrality(normalized_x, normalized_y);
162        let point = points.entry([r, g, b]).or_default();
163        point.weight += weight;
164        // Retain the public region-label boundary used by the original
165        // extractor while using edge-normalized coordinates for centrality.
166        point.sum_x += (f64::from(x) / f64::from(width)) * weight;
167        point.sum_y += (f64::from(y) / f64::from(height)) * weight;
168        point.sum_centrality += centrality * weight;
169        total_weight += weight;
170    }
171
172    if points.is_empty() {
173        return Err(ExtractError::NoVisiblePixels {
174            path: path.to_path_buf(),
175        });
176    }
177
178    let points = points
179        .into_iter()
180        .map(|(rgb, point)| ColorPoint {
181            rgb,
182            lab: rgb_to_oklab(rgb),
183            weight: point.weight,
184            average_x: point.sum_x / point.weight,
185            average_y: point.sum_y / point.weight,
186            centrality: point.sum_centrality / point.weight,
187        })
188        .collect::<Vec<_>>();
189
190    let centroids = initialize_centroids(&points, MAX_CLUSTERS.min(points.len()));
191    let (assignments, centroids) = run_k_means(&points, centroids);
192    let mut clusters = vec![ClusterAccumulator::default(); centroids.len()];
193
194    for (point, assignment) in points.iter().zip(assignments) {
195        clusters[assignment].push(point);
196    }
197
198    let mut candidates = clusters
199        .into_iter()
200        .filter(|cluster| cluster.weight > 0.0)
201        .map(|cluster| {
202            let rgb = cluster.average_rgb();
203            // Seed diversity is enforced on the actual rounded color returned
204            // to callers, rather than on an unobservable centroid.
205            let lab = rgb_to_oklab(rgb);
206            let dominance = cluster.weight / total_weight;
207            let chroma = lab[1].hypot(lab[2]);
208            let population_score = dominance.sqrt();
209            let chroma_score = (chroma / CHROMA_REFERENCE).clamp(0.0, 1.0);
210            let centrality_score = cluster.sum_centrality / cluster.weight;
211            let score = population_score * POPULATION_WEIGHT
212                + chroma_score * CHROMA_WEIGHT
213                + centrality_score * CENTRALITY_WEIGHT;
214
215            ClusterCandidate {
216                rgb,
217                lab,
218                dominance,
219                average_x: cluster.sum_x / cluster.weight,
220                average_y: cluster.sum_y / cluster.weight,
221                score,
222            }
223        })
224        .collect::<Vec<_>>();
225
226    candidates.sort_by(|left, right| {
227        right.score.total_cmp(&left.score).then_with(|| {
228            right
229                .dominance
230                .total_cmp(&left.dominance)
231                .then_with(|| left.rgb.cmp(&right.rgb))
232        })
233    });
234
235    let mut selected = Vec::<ClusterCandidate>::new();
236    while selected.len() < MAX_SEEDS {
237        let next = candidates
238            .iter()
239            .filter(|candidate| {
240                selected.is_empty()
241                    || selected
242                        .iter()
243                        .all(|seed| oklab_distance(candidate.lab, seed.lab) >= MIN_SEED_DISTANCE)
244            })
245            .max_by(|left, right| {
246                diversity_adjusted_score(left, &selected)
247                    .total_cmp(&diversity_adjusted_score(right, &selected))
248                    .then_with(|| left.score.total_cmp(&right.score))
249                    .then_with(|| right.rgb.cmp(&left.rgb))
250            })
251            .cloned();
252
253        let Some(next) = next else {
254            break;
255        };
256        candidates.retain(|candidate| candidate.rgb != next.rgb);
257        selected.push(next);
258    }
259
260    Ok(selected
261        .into_iter()
262        .map(|candidate| ExtractedSeed {
263            hex: format_hex(candidate.rgb),
264            dominance: candidate.dominance as f32,
265            source_region: Some(region_label(
266                candidate.average_x as f32,
267                candidate.average_y as f32,
268            )),
269        })
270        .collect())
271}
272
273fn initialize_centroids(points: &[ColorPoint], count: usize) -> Vec<[f64; 3]> {
274    let total_weight = points.iter().map(|point| point.weight).sum::<f64>();
275    let mut mean = [0.0; 3];
276    for point in points {
277        for (sum, value) in mean.iter_mut().zip(point.lab) {
278            *sum += value * point.weight;
279        }
280    }
281    for value in &mut mean {
282        *value /= total_weight;
283    }
284
285    let first = points
286        .iter()
287        .min_by(|left, right| {
288            squared_oklab_distance(left.lab, mean)
289                .total_cmp(&squared_oklab_distance(right.lab, mean))
290                .then_with(|| left.rgb.cmp(&right.rgb))
291        })
292        .expect("non-empty color points");
293    let mut centroids = vec![first.lab];
294
295    while centroids.len() < count {
296        let next = points.iter().max_by(|left, right| {
297            weighted_distance_from_centroids(left, &centroids)
298                .total_cmp(&weighted_distance_from_centroids(right, &centroids))
299                .then_with(|| right.rgb.cmp(&left.rgb))
300        });
301        let Some(next) = next else {
302            break;
303        };
304        if centroids.contains(&next.lab) {
305            break;
306        }
307        centroids.push(next.lab);
308    }
309
310    centroids
311}
312
313fn weighted_distance_from_centroids(point: &ColorPoint, centroids: &[[f64; 3]]) -> f64 {
314    point.weight
315        * centroids
316            .iter()
317            .map(|centroid| squared_oklab_distance(point.lab, *centroid))
318            .min_by(f64::total_cmp)
319            .unwrap_or(0.0)
320}
321
322fn run_k_means(points: &[ColorPoint], mut centroids: Vec<[f64; 3]>) -> (Vec<usize>, Vec<[f64; 3]>) {
323    let mut assignments = vec![usize::MAX; points.len()];
324
325    for _ in 0..MAX_K_MEANS_ITERATIONS {
326        let mut changed = false;
327        for (index, point) in points.iter().enumerate() {
328            let assignment = nearest_centroid(point.lab, &centroids);
329            changed |= assignments[index] != assignment;
330            assignments[index] = assignment;
331        }
332        if !changed {
333            break;
334        }
335
336        let mut sums = vec![ClusterAccumulator::default(); centroids.len()];
337        for (point, assignment) in points.iter().zip(&assignments) {
338            sums[*assignment].push(point);
339        }
340        for (centroid, sum) in centroids.iter_mut().zip(sums) {
341            if sum.weight > 0.0 {
342                *centroid = sum.average_lab();
343            }
344        }
345    }
346
347    // The iteration limit can stop immediately after centroids move. Reassign
348    // once so returned memberships always correspond to the returned centroids.
349    for (assignment, point) in assignments.iter_mut().zip(points) {
350        *assignment = nearest_centroid(point.lab, &centroids);
351    }
352    (assignments, centroids)
353}
354
355fn nearest_centroid(lab: [f64; 3], centroids: &[[f64; 3]]) -> usize {
356    centroids
357        .iter()
358        .enumerate()
359        .min_by(|(left_index, left), (right_index, right)| {
360            squared_oklab_distance(lab, **left)
361                .total_cmp(&squared_oklab_distance(lab, **right))
362                .then_with(|| left_index.cmp(right_index))
363        })
364        .map(|(index, _)| index)
365        .expect("at least one centroid")
366}
367
368fn diversity_adjusted_score(candidate: &ClusterCandidate, selected: &[ClusterCandidate]) -> f64 {
369    if selected.is_empty() {
370        return candidate.score;
371    }
372
373    let diversity = selected
374        .iter()
375        .map(|seed| oklab_distance(candidate.lab, seed.lab))
376        .min_by(f64::total_cmp)
377        .unwrap_or(FULL_DIVERSITY_DISTANCE);
378    let diversity_factor = (diversity / FULL_DIVERSITY_DISTANCE).clamp(0.0, 1.0);
379    candidate.score * (0.9 + 0.1 * diversity_factor)
380}
381
382fn normalized_coordinate(value: u32, extent: u32) -> f64 {
383    if extent <= 1 {
384        0.5
385    } else {
386        f64::from(value) / f64::from(extent - 1)
387    }
388}
389
390fn radial_centrality(x: f64, y: f64) -> f64 {
391    let distance = (x - 0.5).hypot(y - 0.5);
392    (1.0 - distance / std::f64::consts::FRAC_1_SQRT_2).clamp(0.0, 1.0)
393}
394
395fn rgb_to_oklab(rgb: [u8; 3]) -> [f64; 3] {
396    let encoded = Srgb::new(rgb[0], rgb[1], rgb[2]).into_format::<f64>();
397    let lab = Oklab::from_color(encoded.into_linear());
398    [lab.l, lab.a, lab.b]
399}
400
401fn squared_oklab_distance(left: [f64; 3], right: [f64; 3]) -> f64 {
402    left.into_iter()
403        .zip(right)
404        .map(|(left, right)| (left - right).powi(2))
405        .sum()
406}
407
408fn oklab_distance(left: [f64; 3], right: [f64; 3]) -> f64 {
409    squared_oklab_distance(left, right).sqrt()
410}
411
412fn region_label(normalized_x: f32, normalized_y: f32) -> String {
413    let horizontal = axis_label(normalized_x, "left", "center", "right");
414    let vertical = axis_label(normalized_y, "top", "center", "bottom");
415
416    if horizontal == "center" && vertical == "center" {
417        "center".to_owned()
418    } else {
419        format!("{vertical}-{horizontal}")
420    }
421}
422
423fn axis_label(
424    value: f32,
425    low: &'static str,
426    middle: &'static str,
427    high: &'static str,
428) -> &'static str {
429    if value < (1.0 / 3.0) {
430        low
431    } else if value < (2.0 / 3.0) {
432        middle
433    } else {
434        high
435    }
436}
437
438fn format_hex(rgb: [u8; 3]) -> String {
439    format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
440}
441
442impl ClusterAccumulator {
443    fn push(&mut self, point: &ColorPoint) {
444        self.weight += point.weight;
445        for ((lab_sum, rgb_sum), (lab, rgb)) in self
446            .sum_lab
447            .iter_mut()
448            .zip(&mut self.sum_rgb)
449            .zip(point.lab.into_iter().zip(point.rgb))
450        {
451            *lab_sum += lab * point.weight;
452            *rgb_sum += f64::from(rgb) * point.weight;
453        }
454        self.sum_x += point.average_x * point.weight;
455        self.sum_y += point.average_y * point.weight;
456        self.sum_centrality += point.centrality * point.weight;
457    }
458
459    fn average_rgb(&self) -> [u8; 3] {
460        self.sum_rgb
461            .map(|sum| (sum / self.weight).round().clamp(0.0, 255.0) as u8)
462    }
463
464    fn average_lab(&self) -> [f64; 3] {
465        self.sum_lab.map(|sum| sum / self.weight)
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use image::{DynamicImage, GenericImageView, RgbImage, Rgba, RgbaImage};
472
473    use super::{
474        ExtractError, MAX_PROCESSING_DIMENSION, MIN_SEED_DISTANCE, cluster_image, oklab_distance,
475        preprocess_image, region_label, rgb_to_oklab,
476    };
477
478    #[test]
479    fn preprocess_resizes_large_images() {
480        let image = DynamicImage::ImageRgb8(RgbImage::new(4096, 2048));
481
482        let processed = preprocess_image(image);
483
484        assert_eq!(processed.dimensions(), (128, 64));
485        assert!(processed.width() <= MAX_PROCESSING_DIMENSION);
486        assert!(processed.height() <= MAX_PROCESSING_DIMENSION);
487    }
488
489    #[test]
490    fn region_labels_cover_grid_positions() {
491        assert_eq!(region_label(0.5, 0.5), "center");
492        assert_eq!(region_label(0.1, 0.1), "top-left");
493        assert_eq!(region_label(0.9, 0.2), "top-right");
494        assert_eq!(region_label(0.5, 0.9), "bottom-center");
495        assert_eq!(region_label(0.1, 0.6), "center-left");
496    }
497
498    #[test]
499    fn perceptually_near_colors_do_not_become_duplicate_seeds() {
500        let image = row_image(&[
501            ([127, 48, 48, 255], 4),
502            ([128, 48, 48, 255], 4),
503            ([30, 80, 220, 255], 2),
504        ]);
505
506        let seeds = cluster_image(&image, std::path::Path::new("synthetic"))
507            .expect("synthetic image should extract");
508
509        assert_eq!(seeds.len(), 2);
510        assert!(seeds.iter().any(|seed| seed.hex == "#1e50dc"));
511        assert_eq!(
512            seeds
513                .iter()
514                .filter(|seed| seed.hex == "#7f3030" || seed.hex == "#803030")
515                .count(),
516            1
517        );
518    }
519
520    #[test]
521    fn selected_seeds_have_a_perceptual_diversity_floor() {
522        let image = row_image(&[
523            ([190, 190, 190, 255], 8),
524            ([220, 40, 40, 255], 3),
525            ([35, 70, 220, 255], 3),
526            ([35, 190, 95, 255], 3),
527        ]);
528
529        let seeds = cluster_image(&image, std::path::Path::new("synthetic"))
530            .expect("synthetic image should extract");
531
532        assert_eq!(seeds.len(), 3);
533        for (index, seed) in seeds.iter().enumerate() {
534            for other in seeds.iter().skip(index + 1) {
535                let distance = oklab_distance(hex_to_oklab(&seed.hex), hex_to_oklab(&other.hex));
536                assert!(distance >= MIN_SEED_DISTANCE, "{seed:?} and {other:?}");
537            }
538        }
539    }
540
541    #[test]
542    fn small_colorful_accents_survive_a_large_neutral_background() {
543        let mut image = RgbaImage::from_pixel(20, 20, Rgba([105, 105, 105, 255]));
544        for y in 8..12 {
545            for x in 8..12 {
546                image.put_pixel(x, y, Rgba([235, 35, 45, 255]));
547            }
548        }
549
550        let seeds = cluster_image(&image, std::path::Path::new("synthetic"))
551            .expect("synthetic image should extract");
552
553        assert_eq!(seeds.len(), 2);
554        assert!(seeds.iter().any(|seed| seed.hex == "#eb232d"));
555        let accent = seeds
556            .iter()
557            .find(|seed| seed.hex == "#eb232d")
558            .expect("accent should be selected");
559        assert!((accent.dominance - 0.04).abs() < 0.001);
560        assert_eq!(accent.source_region.as_deref(), Some("center"));
561    }
562
563    #[test]
564    fn clustering_is_deterministic_and_ignores_transparent_noise() {
565        let mut image = RgbaImage::from_pixel(8, 8, Rgba([20, 90, 180, 255]));
566        for x in 0..8 {
567            image.put_pixel(x, 0, Rgba([255, (x * 20) as u8, 10, 0]));
568        }
569
570        let first = cluster_image(&image, std::path::Path::new("synthetic"))
571            .expect("synthetic image should extract");
572        let second = cluster_image(&image, std::path::Path::new("synthetic"))
573            .expect("synthetic image should extract");
574
575        assert_eq!(first, second);
576        assert_eq!(first.len(), 1);
577        assert_eq!(first[0].hex, "#145ab4");
578        assert!((first[0].dominance - 1.0).abs() < f32::EPSILON);
579    }
580
581    #[test]
582    fn fully_transparent_images_return_no_visible_pixels() {
583        let image = RgbaImage::from_pixel(4, 4, Rgba([240, 30, 40, 0]));
584
585        let error = cluster_image(&image, std::path::Path::new("transparent"))
586            .expect_err("transparent image should not produce a seed");
587
588        assert!(matches!(error, ExtractError::NoVisiblePixels { .. }));
589    }
590
591    fn row_image(runs: &[([u8; 4], u32)]) -> RgbaImage {
592        let width = runs.iter().map(|(_, count)| count).sum();
593        let mut image = RgbaImage::new(width, 1);
594        let mut x = 0;
595        for (rgba, count) in runs {
596            for _ in 0..*count {
597                image.put_pixel(x, 0, Rgba(*rgba));
598                x += 1;
599            }
600        }
601        image
602    }
603
604    fn hex_to_oklab(hex: &str) -> [f64; 3] {
605        let component = |start| u8::from_str_radix(&hex[start..start + 2], 16).unwrap();
606        rgb_to_oklab([component(1), component(3), component(5)])
607    }
608}