1#![warn(missing_docs)]
35#![warn(clippy::all)]
36
37pub mod algorithms;
38mod color;
39mod error;
40
41pub mod wasm;
43
44pub use color::{Color, ColorPalette};
45pub use error::{DominantColorError, Result};
46
47use image::DynamicImage;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum Algorithm {
52 #[default]
55 KMeans,
56 MedianCut,
59 Octree,
62}
63
64#[derive(Debug, Clone)]
66pub struct Config {
67 pub max_colors: usize,
69 pub sample_size: Option<u32>,
72 pub kmeans_seed: u64,
74 pub kmeans_max_iterations: usize,
76 pub kmeans_convergence_threshold: f64,
79}
80
81impl Default for Config {
82 fn default() -> Self {
83 Self {
84 max_colors: 8,
85 sample_size: Some(256),
86 kmeans_seed: 42,
87 kmeans_max_iterations: 100,
88 kmeans_convergence_threshold: 1.0,
89 }
90 }
91}
92
93impl Config {
94 #[must_use]
100 pub fn max_colors(mut self, n: usize) -> Self {
101 assert!(n > 0, "max_colors must be at least 1");
102 self.max_colors = n;
103 self
104 }
105
106 #[must_use]
108 pub fn sample_size(mut self, size: Option<u32>) -> Self {
109 self.sample_size = size;
110 self
111 }
112
113 #[must_use]
115 pub fn kmeans_seed(mut self, seed: u64) -> Self {
116 self.kmeans_seed = seed;
117 self
118 }
119
120 #[must_use]
122 pub fn kmeans_max_iterations(mut self, iters: usize) -> Self {
123 self.kmeans_max_iterations = iters;
124 self
125 }
126}
127
128pub struct DominantColors {
142 image: DynamicImage,
143 config: Config,
144}
145
146impl DominantColors {
147 pub fn new(image: DynamicImage) -> Self {
149 Self {
150 image,
151 config: Config::default(),
152 }
153 }
154
155 #[must_use]
157 pub fn config(mut self, config: Config) -> Self {
158 self.config = config;
159 self
160 }
161
162 pub fn extract(self, algorithm: Algorithm) -> Result<ColorPalette> {
170 let pixels = self.sample_pixels();
171
172 if pixels.is_empty() {
173 return Err(DominantColorError::EmptyImage);
174 }
175
176 let mut palette = match algorithm {
177 Algorithm::KMeans => algorithms::kmeans::extract(&pixels, &self.config)?,
178 Algorithm::MedianCut => algorithms::median_cut::extract(&pixels, &self.config)?,
179 Algorithm::Octree => algorithms::octree::extract(&pixels, &self.config)?,
180 };
181
182 palette.sort_by(|a, b| b.percentage.partial_cmp(&a.percentage).unwrap());
184 Ok(palette)
185 }
186
187 fn sample_pixels(&self) -> Vec<[u8; 3]> {
189 let img = if let Some(size) = self.config.sample_size {
190 let (w, h) = (self.image.width(), self.image.height());
191 if w > size || h > size {
192 self.image.thumbnail(size, size).into_rgb8()
194 } else {
195 self.image.to_rgb8()
196 }
197 } else {
198 self.image.to_rgb8()
199 };
200
201 img.pixels().map(|p| [p.0[0], p.0[1], p.0[2]]).collect()
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use image::{ImageBuffer, Rgb};
209
210 fn make_image(pixels: &[[u8; 3]]) -> DynamicImage {
212 let width = pixels.len() as u32;
213 let buf: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_fn(width, 1, |x, _| {
214 let p = pixels[x as usize];
215 Rgb([p[0], p[1], p[2]])
216 });
217 DynamicImage::ImageRgb8(buf)
218 }
219
220 #[test]
221 fn test_empty_image_error() {
222 let img = DynamicImage::ImageRgb8(ImageBuffer::new(0, 0));
224 for alg in [Algorithm::KMeans, Algorithm::MedianCut, Algorithm::Octree] {
225 let result = DominantColors::new(img.clone())
226 .config(Config::default().sample_size(None))
227 .extract(alg);
228 assert!(
229 matches!(result, Err(DominantColorError::EmptyImage)),
230 "{alg:?} 应返回 EmptyImage"
231 );
232 }
233 }
234
235 #[test]
236 fn test_single_color_image() {
237 let pixels = vec![[255u8, 0, 0]; 100];
239 let img = make_image(&pixels);
240 for alg in [Algorithm::KMeans, Algorithm::MedianCut, Algorithm::Octree] {
241 let palette = DominantColors::new(img.clone())
242 .config(Config::default().max_colors(3).sample_size(None))
243 .extract(alg)
244 .expect("纯色图片应成功");
245 assert!(!palette.is_empty(), "{alg:?} 调色板不应为空");
246 assert!(
247 palette.iter().map(|c| c.percentage).sum::<f32>() > 0.99,
248 "{alg:?} 占比之和应约等于 1.0"
249 );
250 }
251 }
252
253 #[test]
254 fn test_two_color_image_separation() {
255 let mut pixels = vec![[255u8, 0, 0]; 50];
257 pixels.extend(vec![[0u8, 0, 255]; 50]);
258 let img = make_image(&pixels);
259
260 for alg in [Algorithm::KMeans, Algorithm::MedianCut, Algorithm::Octree] {
261 let palette = DominantColors::new(img.clone())
262 .config(Config::default().max_colors(2).sample_size(None))
263 .extract(alg)
264 .expect("双色图片应成功");
265
266 assert_eq!(palette.len(), 2, "{alg:?} 应识别出 2 种颜色");
267 for color in &palette {
268 assert!(
269 (color.percentage - 0.5).abs() < 0.1,
270 "{alg:?}: 期望约 50%,实际 {:.1}%",
271 color.percentage * 100.0
272 );
273 }
274 }
275 }
276
277 #[test]
278 fn test_palette_sorted_descending() {
279 let mut pixels = vec![[255u8, 0, 0]; 60];
281 pixels.extend(vec![[0u8, 255, 0]; 30]);
282 pixels.extend(vec![[0u8, 0, 255]; 10]);
283 let img = make_image(&pixels);
284
285 for alg in [Algorithm::KMeans, Algorithm::MedianCut, Algorithm::Octree] {
286 let palette = DominantColors::new(img.clone())
287 .config(Config::default().max_colors(3).sample_size(None))
288 .extract(alg)
289 .expect("三色图片应成功");
290
291 let percentages: Vec<f32> = palette.iter().map(|c| c.percentage).collect();
292 let mut sorted = percentages.clone();
293 sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
294 assert_eq!(percentages, sorted, "{alg:?} 调色板应按占比降序排列");
295 }
296 }
297
298 #[test]
299 fn test_config_builder() {
300 let cfg = Config::default()
302 .max_colors(10)
303 .sample_size(Some(128))
304 .kmeans_seed(99)
305 .kmeans_max_iterations(50);
306 assert_eq!(cfg.max_colors, 10);
307 assert_eq!(cfg.sample_size, Some(128));
308 assert_eq!(cfg.kmeans_seed, 99);
309 assert_eq!(cfg.kmeans_max_iterations, 50);
310 }
311
312 #[test]
313 #[should_panic(expected = "max_colors must be at least 1")]
314 fn test_config_zero_colors_panics() {
315 let _ = Config::default().max_colors(0);
317 }
318}