Skip to main content

chromasync_extract/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::{Path, PathBuf},
4};
5
6use image::{DynamicImage, GenericImageView, RgbaImage, imageops::FilterType};
7use thiserror::Error;
8
9pub const MAX_PROCESSING_DIMENSION: u32 = 128;
10const MAX_SEEDS: usize = 3;
11const QUANTIZATION_SHIFT: u8 = 4;
12const MIN_VISIBLE_ALPHA: u8 = 16;
13const NOISY_IMAGE_THRESHOLD: f32 = 0.04;
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct ExtractedSeed {
17    pub hex: String,
18    pub dominance: f32,
19    pub source_region: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct ExtractionResult {
24    pub original_width: u32,
25    pub original_height: u32,
26    pub processed_width: u32,
27    pub processed_height: u32,
28    pub seeds: Vec<ExtractedSeed>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32struct BucketKey(u8, u8, u8);
33
34#[derive(Debug, Clone, Default)]
35struct BucketAccumulator {
36    count: u32,
37    sum_r: u64,
38    sum_g: u64,
39    sum_b: u64,
40    sum_x: u64,
41    sum_y: u64,
42}
43
44#[derive(Debug, Clone)]
45struct BucketSummary {
46    key: BucketKey,
47    count: u32,
48    weight: f32,
49    average_rgb: [u8; 3],
50    average_x: f32,
51    average_y: f32,
52}
53
54#[derive(Debug, Error)]
55pub enum ExtractError {
56    #[error("failed to load image '{path}': {source}")]
57    ImageLoad {
58        path: PathBuf,
59        #[source]
60        source: image::ImageError,
61    },
62    #[error("image '{path}' does not contain any visible pixels")]
63    NoVisiblePixels { path: PathBuf },
64}
65
66pub fn extract_seed_candidates(image: &Path) -> Result<ExtractionResult, ExtractError> {
67    let loaded = image::open(image).map_err(|source| ExtractError::ImageLoad {
68        path: image.to_path_buf(),
69        source,
70    })?;
71    let (original_width, original_height) = loaded.dimensions();
72    let processed = preprocess_image(loaded);
73    let (processed_width, processed_height) = processed.dimensions();
74    let seeds = cluster_image(&processed.to_rgba8(), image)?;
75
76    Ok(ExtractionResult {
77        original_width,
78        original_height,
79        processed_width,
80        processed_height,
81        seeds,
82    })
83}
84
85fn preprocess_image(image: DynamicImage) -> DynamicImage {
86    let (width, height) = image.dimensions();
87
88    if width <= MAX_PROCESSING_DIMENSION && height <= MAX_PROCESSING_DIMENSION {
89        return image;
90    }
91
92    image.resize(
93        MAX_PROCESSING_DIMENSION,
94        MAX_PROCESSING_DIMENSION,
95        FilterType::Triangle,
96    )
97}
98
99fn cluster_image(image: &RgbaImage, path: &Path) -> Result<Vec<ExtractedSeed>, ExtractError> {
100    let (width, height) = image.dimensions();
101    let mut buckets = BTreeMap::<BucketKey, BucketAccumulator>::new();
102    let mut overall = BucketAccumulator::default();
103
104    for (x, y, pixel) in image.enumerate_pixels() {
105        let [r, g, b, alpha] = pixel.0;
106
107        if alpha < MIN_VISIBLE_ALPHA {
108            continue;
109        }
110
111        overall.push([r, g, b], x, y);
112        buckets
113            .entry(BucketKey(
114                r >> QUANTIZATION_SHIFT,
115                g >> QUANTIZATION_SHIFT,
116                b >> QUANTIZATION_SHIFT,
117            ))
118            .or_default()
119            .push([r, g, b], x, y);
120    }
121
122    if overall.count == 0 {
123        return Err(ExtractError::NoVisiblePixels {
124            path: path.to_path_buf(),
125        });
126    }
127
128    let mut summaries = buckets
129        .into_iter()
130        .map(|(key, bucket)| {
131            let rgb = bucket.average_rgb();
132            let colorfulness = (rgb[0].max(rgb[1]).max(rgb[2]) as f32
133                - rgb[0].min(rgb[1]).min(rgb[2]) as f32)
134                / 255.0;
135            // Boost buckets that have more color, but don't ignore count.
136            // A bucket with 1% of pixels but 100% colorfulness should compete with
137            // a bucket with 5% of pixels but 0% colorfulness.
138            let weight = bucket.count as f32 * (1.0 + colorfulness * 4.0);
139
140            BucketSummary {
141                key,
142                count: bucket.count,
143                weight,
144                average_rgb: rgb,
145                average_x: bucket.average_x(),
146                average_y: bucket.average_y(),
147            }
148        })
149        .collect::<Vec<_>>();
150
151    summaries.sort_by(|left, right| {
152        right
153            .weight
154            .partial_cmp(&left.weight)
155            .unwrap_or(std::cmp::Ordering::Equal)
156            .then_with(|| right.count.cmp(&left.count))
157            .then_with(|| left.key.cmp(&right.key))
158    });
159
160    if summaries
161        .first()
162        .is_some_and(|bucket| bucket.count as f32 / (overall.count as f32) < NOISY_IMAGE_THRESHOLD)
163    {
164        return Ok(vec![overall.average_seed(width, height)]);
165    }
166
167    let mut seen_hex = BTreeSet::new();
168    let mut seeds = Vec::new();
169
170    for summary in summaries {
171        let hex = format_hex(summary.average_rgb);
172
173        if !seen_hex.insert(hex.clone()) {
174            continue;
175        }
176
177        seeds.push(ExtractedSeed {
178            hex,
179            dominance: summary.count as f32 / overall.count as f32,
180            source_region: Some(region_label(
181                summary.average_x / width as f32,
182                summary.average_y / height as f32,
183            )),
184        });
185
186        if seeds.len() == MAX_SEEDS {
187            break;
188        }
189    }
190
191    if seeds.is_empty() {
192        seeds.push(overall.average_seed(width, height));
193    }
194
195    Ok(seeds)
196}
197
198fn region_label(normalized_x: f32, normalized_y: f32) -> String {
199    let horizontal = axis_label(normalized_x, "left", "center", "right");
200    let vertical = axis_label(normalized_y, "top", "center", "bottom");
201
202    if horizontal == "center" && vertical == "center" {
203        "center".to_owned()
204    } else {
205        format!("{vertical}-{horizontal}")
206    }
207}
208
209fn axis_label(
210    value: f32,
211    low: &'static str,
212    middle: &'static str,
213    high: &'static str,
214) -> &'static str {
215    if value < (1.0 / 3.0) {
216        low
217    } else if value < (2.0 / 3.0) {
218        middle
219    } else {
220        high
221    }
222}
223
224fn format_hex(rgb: [u8; 3]) -> String {
225    format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])
226}
227
228impl BucketAccumulator {
229    fn push(&mut self, rgb: [u8; 3], x: u32, y: u32) {
230        self.count += 1;
231        self.sum_r += u64::from(rgb[0]);
232        self.sum_g += u64::from(rgb[1]);
233        self.sum_b += u64::from(rgb[2]);
234        self.sum_x += u64::from(x);
235        self.sum_y += u64::from(y);
236    }
237
238    fn average_rgb(&self) -> [u8; 3] {
239        [
240            (self.sum_r / u64::from(self.count)) as u8,
241            (self.sum_g / u64::from(self.count)) as u8,
242            (self.sum_b / u64::from(self.count)) as u8,
243        ]
244    }
245
246    fn average_x(&self) -> f32 {
247        self.sum_x as f32 / self.count as f32
248    }
249
250    fn average_y(&self) -> f32 {
251        self.sum_y as f32 / self.count as f32
252    }
253
254    fn average_seed(&self, width: u32, height: u32) -> ExtractedSeed {
255        ExtractedSeed {
256            hex: format_hex(self.average_rgb()),
257            dominance: 1.0,
258            source_region: Some(region_label(
259                self.average_x() / width as f32,
260                self.average_y() / height as f32,
261            )),
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use image::{DynamicImage, GenericImageView, RgbImage};
269
270    use super::{MAX_PROCESSING_DIMENSION, preprocess_image, region_label};
271
272    #[test]
273    fn preprocess_resizes_large_images() {
274        let image = DynamicImage::ImageRgb8(RgbImage::new(4096, 2048));
275
276        let processed = preprocess_image(image);
277
278        assert_eq!(processed.dimensions(), (128, 64));
279        assert!(processed.width() <= MAX_PROCESSING_DIMENSION);
280        assert!(processed.height() <= MAX_PROCESSING_DIMENSION);
281    }
282
283    #[test]
284    fn region_labels_cover_grid_positions() {
285        assert_eq!(region_label(0.5, 0.5), "center");
286        assert_eq!(region_label(0.1, 0.1), "top-left");
287        assert_eq!(region_label(0.9, 0.2), "top-right");
288        assert_eq!(region_label(0.5, 0.9), "bottom-center");
289        assert_eq!(region_label(0.1, 0.6), "center-left");
290    }
291}