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
101pub 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 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 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, ¢roids)
298 .total_cmp(&weighted_distance_from_centroids(right, ¢roids))
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, ¢roids);
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 for (assignment, point) in assignments.iter_mut().zip(points) {
350 *assignment = nearest_centroid(point.lab, ¢roids);
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}