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 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}