1use image::{GenericImageView, ImageBuffer, Rgba, RgbaImage};
2use rand::prelude::*;
3use rand::SeedableRng;
4use rand_chacha::ChaCha8Rng;
5use rand_distr::{Distribution, WeightedIndex};
6#[cfg(not(target_arch = "wasm32"))]
7use rayon::prelude::*;
8use std::cmp::Ordering;
9use std::collections::HashMap;
10#[cfg(not(target_arch = "wasm32"))]
11use std::env;
12use std::error::Error;
13use std::fmt;
14#[cfg(not(target_arch = "wasm32"))]
15use std::path::{Path, PathBuf};
16
17#[cfg(target_arch = "wasm32")]
18use wasm_bindgen::prelude::*;
19
20const MAX_PALETTE_COLORS: usize = 256;
21
22#[derive(Debug, Clone)]
23#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
24pub struct Config {
25 pub k_colors: usize,
26 pub pixel_size_override: Option<f64>,
27 palette: Option<Vec<[u8; 3]>>,
28 k_seed: u64,
29 #[allow(dead_code)]
31 input_path: String,
32 #[allow(dead_code)]
34 output_path: String,
35 max_kmeans_iterations: usize,
36 peak_threshold_multiplier: f64,
37 peak_distance_filter: usize,
38 walker_search_window_ratio: f64,
39 walker_min_search_window: f64,
40 walker_strength_threshold: f64,
41 min_cuts_per_axis: usize,
42 fallback_target_segments: usize,
43 max_step_ratio: f64,
44}
45
46impl Default for Config {
47 fn default() -> Self {
48 Self {
49 k_colors: 16,
50 k_seed: 42,
51 input_path: "samples/2/skeleton.png".to_string(),
52 output_path: "samples/2/skeleton_fixed_clean2.png".to_string(),
53 max_kmeans_iterations: 15,
54 peak_threshold_multiplier: 0.2,
55 peak_distance_filter: 4,
56 walker_search_window_ratio: 0.35,
57 walker_min_search_window: 2.0,
58 walker_strength_threshold: 0.5,
59 min_cuts_per_axis: 4,
60 fallback_target_segments: 64,
61 max_step_ratio: 1.8, pixel_size_override: None,
63 palette: None,
64 }
65 }
66}
67
68#[derive(Debug)]
69pub enum PixelSnapperError {
70 ImageError(image::ImageError),
71 InvalidInput(String),
72 ProcessingError(String),
73}
74
75impl fmt::Display for PixelSnapperError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 PixelSnapperError::ImageError(e) => write!(f, "Image error: {}", e),
79 PixelSnapperError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
80 PixelSnapperError::ProcessingError(msg) => write!(f, "Processing error: {}", msg),
81 }
82 }
83}
84
85impl Error for PixelSnapperError {}
86
87impl From<image::ImageError> for PixelSnapperError {
88 fn from(error: image::ImageError) -> Self {
89 PixelSnapperError::ImageError(error)
90 }
91}
92
93#[cfg(target_arch = "wasm32")]
94impl From<PixelSnapperError> for wasm_bindgen::JsValue {
95 fn from(err: PixelSnapperError) -> wasm_bindgen::JsValue {
96 wasm_bindgen::JsValue::from_str(&err.to_string())
97 }
98}
99
100pub type Result<T> = std::result::Result<T, PixelSnapperError>;
101
102#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
103struct ProcessedImage {
104 output_bytes: Vec<u8>,
105 pixel_size: f64,
106 pixel_size_override: bool,
107 output_width: u32,
108 output_height: u32,
109}
110
111#[cfg(not(target_arch = "wasm32"))]
112#[derive(Debug, Clone)]
113pub struct BatchConfig {
114 pub input_dir: PathBuf,
115 pub output_dir: PathBuf,
116 pub k_colors: usize,
117 pub pixel_size_override: Option<f64>,
118 pub palette: Option<Vec<[u8; 3]>>,
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122impl From<&Config> for BatchConfig {
123 fn from(config: &Config) -> Self {
124 Self {
125 input_dir: PathBuf::from(&config.input_path),
126 output_dir: PathBuf::from(&config.output_path),
127 k_colors: config.k_colors,
128 pixel_size_override: config.pixel_size_override,
129 palette: config.palette.clone(),
130 }
131 }
132}
133
134#[cfg(not(target_arch = "wasm32"))]
135impl From<&BatchConfig> for Config {
136 fn from(config: &BatchConfig) -> Self {
137 Self {
138 k_colors: config.k_colors,
139 pixel_size_override: config.pixel_size_override,
140 palette: config.palette.clone(),
141 ..Default::default()
142 }
143 }
144}
145
146#[cfg(not(target_arch = "wasm32"))]
147#[derive(Debug, Clone)]
148pub enum BatchEvent {
149 BatchStarted {
150 input_dir: PathBuf,
151 total: usize,
152 },
153 Started {
154 input: PathBuf,
155 index: usize,
156 total: usize,
157 },
158 Finished {
159 input: PathBuf,
160 output: PathBuf,
161 index: usize,
162 total: usize,
163 },
164 Failed {
165 input: PathBuf,
166 output: PathBuf,
167 error: String,
168 index: usize,
169 total: usize,
170 },
171 BatchFinished {
172 input_dir: PathBuf,
173 total: usize,
174 },
175}
176
177#[cfg(not(target_arch = "wasm32"))]
178#[derive(Debug)]
179enum CliCommand {
180 Run(Config),
181 Help,
182 Version,
183}
184
185#[cfg(not(target_arch = "wasm32"))]
187#[doc(hidden)]
188pub fn run_cli() -> std::process::ExitCode {
189 let args: Vec<String> = env::args().skip(1).collect();
190
191 match parse_cli_args(&args) {
192 Ok(CliCommand::Help) => {
193 print_cli_help();
194 std::process::ExitCode::SUCCESS
195 }
196 Ok(CliCommand::Version) => {
197 println!("spritefusion-pixel-snapper {}", env!("CARGO_PKG_VERSION"));
198 std::process::ExitCode::SUCCESS
199 }
200 Ok(CliCommand::Run(config)) => match process(&config) {
201 Ok(()) => std::process::ExitCode::SUCCESS,
202 Err(error) => {
203 eprintln!("Error: {error}");
204 std::process::ExitCode::from(1)
205 }
206 },
207 Err(error) => {
208 eprintln!("Error: {error}");
209 eprintln!("Run 'spritefusion-pixel-snapper --help' for usage.");
210 std::process::ExitCode::from(2)
211 }
212 }
213}
214
215#[cfg(not(target_arch = "wasm32"))]
216fn print_cli_help() {
217 println!(
218 concat!(
219 "Sprite Fusion Pixel Snapper {version}\n",
220 "Fix inconsistent pixel art by detecting and snapping it to its implicit grid.\n\n",
221 "USAGE:\n",
222 " spritefusion-pixel-snapper <INPUT> <OUTPUT> [COLORS] [OPTIONS]\n\n",
223 "ARGUMENTS:\n",
224 " <INPUT> Input PNG/JPEG file, or a directory for batch processing\n",
225 " <OUTPUT> Output PNG file, or a different output directory for a batch\n",
226 " [COLORS] Number of palette colors [default: 16]\n\n",
227 "OPTIONS:\n",
228 " --pixel-size <PIXELS> Override the auto-detected pixel size\n",
229 " --palette <HEX,...> Use comma-separated 6-digit hex palette colors\n",
230 " -h, --help Print help\n",
231 " -V, --version Print version\n\n",
232 "EXAMPLES:\n",
233 " spritefusion-pixel-snapper input.png output.png\n",
234 " spritefusion-pixel-snapper input.png output.png 16 --pixel-size 8\n",
235 " spritefusion-pixel-snapper inputs outputs --palette 0d2b45,ffecd6"
236 ),
237 version = env!("CARGO_PKG_VERSION")
238 );
239}
240
241fn process_image_common(input_bytes: &[u8], config: Option<Config>) -> Result<ProcessedImage> {
242 let config = config.unwrap_or_default();
243
244 let img = image::load_from_memory(input_bytes)?;
245 let (width, height) = img.dimensions();
246
247 validate_image_dimensions(width, height)?;
248
249 if let Some(px) = config.pixel_size_override {
250 if !px.is_finite() || px < 1.0 || px > (width.min(height) as f64 / 2.0) {
251 return Err(PixelSnapperError::InvalidInput(format!(
252 "pixel_size_override {:.1} is out of valid range [1, {}]",
253 px,
254 width.min(height) / 2
255 )));
256 }
257 }
258
259 let rgba_img = img.to_rgba8();
260
261 let analysis_img = quantize_image(&rgba_img, &config)?;
262 let (profile_x, profile_y) = compute_profiles(&analysis_img)?;
263
264 let step_x_opt = estimate_step_size(&profile_x, &config);
266 let step_y_opt = estimate_step_size(&profile_y, &config);
267
268 let (step_x, step_y) = resolve_step_sizes(step_x_opt, step_y_opt, width, height, &config);
270
271 let raw_col_cuts = walk(&profile_x, step_x, width as usize, &config)?;
272 let raw_row_cuts = walk(&profile_y, step_y, height as usize, &config)?;
273
274 let (col_cuts, row_cuts) = stabilize_both_axes(
276 &profile_x,
277 &profile_y,
278 raw_col_cuts,
279 raw_row_cuts,
280 width as usize,
281 height as usize,
282 &config,
283 );
284
285 let snapped_img = resample(&analysis_img, &col_cuts, &row_cuts)?;
286 let output_img = match config.palette.as_deref() {
287 Some(palette) => apply_palette(&snapped_img, palette)?,
288 None => snapped_img,
289 };
290
291 let mut output_bytes = Vec::new();
293 let mut cursor = std::io::Cursor::new(&mut output_bytes);
294 output_img
295 .write_to(&mut cursor, image::ImageFormat::Png)
296 .map_err(PixelSnapperError::ImageError)?;
297
298 Ok(ProcessedImage {
299 output_bytes,
300 pixel_size: step_x,
301 pixel_size_override: config.pixel_size_override.is_some(),
302 output_width: (col_cuts.len() - 1) as u32,
303 output_height: (row_cuts.len() - 1) as u32,
304 })
305}
306
307#[cfg(target_arch = "wasm32")]
310#[wasm_bindgen]
311pub fn process_image(
312 input_bytes: &[u8],
313 k_colors: Option<u32>,
314 pixel_size_override: Option<f64>,
315 palette_hex: Option<String>,
316) -> std::result::Result<Vec<u8>, wasm_bindgen::JsValue> {
317 let mut config = Config::default();
318 if let Some(k) = k_colors {
319 if k == 0 {
320 return Err(wasm_bindgen::JsValue::from_str(
321 "k_colors must be greater than 0",
322 ));
323 }
324 config.k_colors = k as usize;
325 }
326
327 config.pixel_size_override = pixel_size_override;
328 config.palette = palette_hex
329 .as_deref()
330 .map(parse_palette_hex)
331 .transpose()
332 .map_err(wasm_bindgen::JsValue::from)?;
333
334 process_image_common(input_bytes, Some(config))
335 .map(|processed| processed.output_bytes)
336 .map_err(wasm_bindgen::JsValue::from)
337}
338
339#[cfg(not(target_arch = "wasm32"))]
340fn parse_cli_args(args: &[String]) -> Result<CliCommand> {
341 if args.is_empty()
342 || args
343 .iter()
344 .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
345 {
346 return Ok(CliCommand::Help);
347 }
348 if args
349 .iter()
350 .any(|arg| matches!(arg.as_str(), "-V" | "--version"))
351 {
352 return Ok(CliCommand::Version);
353 }
354 if args.len() < 2 {
355 return Err(PixelSnapperError::InvalidInput(
356 "missing output path".to_string(),
357 ));
358 }
359
360 let mut config = Config {
361 input_path: args[0].clone(),
362 output_path: args[1].clone(),
363 ..Default::default()
364 };
365
366 let mut i = 2;
367 while i < args.len() {
368 match args[i].as_str() {
369 "--pixel-size" => {
370 let Some(val) = args.get(i + 1) else {
371 return Err(PixelSnapperError::InvalidInput(
372 "--pixel-size requires a value".to_string(),
373 ));
374 };
375
376 match val.parse::<f64>() {
377 Ok(px) if px.is_finite() && px > 0.0 => config.pixel_size_override = Some(px),
378 _ => {
379 return Err(PixelSnapperError::InvalidInput(format!(
380 "invalid --pixel-size '{}': expected a positive number",
381 val
382 )))
383 }
384 }
385 i += 2;
386 }
387 "--palette" => {
388 let Some(val) = args.get(i + 1) else {
389 return Err(PixelSnapperError::InvalidInput(
390 "--palette requires a value".to_string(),
391 ));
392 };
393
394 config.palette = Some(parse_palette_hex(val)?);
395 i += 2;
396 }
397 arg if arg.starts_with("--") => {
398 return Err(PixelSnapperError::InvalidInput(format!(
399 "unknown argument '{}'",
400 arg
401 )));
402 }
403 k_arg => {
404 match k_arg.parse::<usize>() {
405 Ok(k) if k > 0 => config.k_colors = k,
406 _ => {
407 return Err(PixelSnapperError::InvalidInput(format!(
408 "invalid color count '{}': expected a positive integer",
409 k_arg
410 )))
411 }
412 }
413 i += 1;
414 }
415 }
416 }
417
418 Ok(CliCommand::Run(config))
419}
420
421#[cfg(not(target_arch = "wasm32"))]
422#[allow(dead_code)]
423fn process(config: &Config) -> Result<()> {
424 let input_path = Path::new(&config.input_path);
425 if input_path.is_dir() {
426 process_batch(config)
427 } else {
428 process_single(config)
429 }
430}
431
432#[cfg(not(target_arch = "wasm32"))]
433#[allow(dead_code)]
434fn process_single(config: &Config) -> Result<()> {
435 let input_path = Path::new(&config.input_path);
436 let output_path = Path::new(&config.output_path);
437 let processed = process_file(input_path, output_path, config)?;
438 println!("Processing: {}", config.input_path);
439 print_processed_image(
440 processed.pixel_size,
441 processed.pixel_size_override,
442 processed.output_width,
443 processed.output_height,
444 );
445 println!("Saved to: {}", config.output_path);
446 Ok(())
447}
448
449#[cfg(not(target_arch = "wasm32"))]
450#[allow(dead_code)]
451fn process_batch(config: &Config) -> Result<()> {
452 process_batch_with_reporter(&BatchConfig::from(config), |event| match event {
453 BatchEvent::BatchStarted { input_dir, total } => {
454 println!(
455 "Batch processing {} image{} from: {}",
456 total,
457 if total == 1 { "" } else { "s" },
458 input_dir.display()
459 );
460 }
461 BatchEvent::Started {
462 input,
463 index,
464 total,
465 } => {
466 println!("Processing {}/{}: {}", index + 1, total, input.display());
467 }
468 BatchEvent::Finished {
469 input,
470 output,
471 index,
472 total,
473 } => {
474 println!(
475 "Done {}/{}: {} -> {}",
476 index + 1,
477 total,
478 input.display(),
479 output.display()
480 );
481 }
482 BatchEvent::Failed {
483 input,
484 output,
485 error,
486 index,
487 total,
488 } => {
489 eprintln!(
490 "Failed {}/{}: {} -> {} ({})",
491 index + 1,
492 total,
493 input.display(),
494 output.display(),
495 error
496 );
497 }
498 BatchEvent::BatchFinished { input_dir, total } => {
499 println!(
500 "Processed {} image{} in: {}",
501 total,
502 if total == 1 { "" } else { "s" },
503 input_dir.display()
504 );
505 }
506 })
507}
508
509#[cfg(not(target_arch = "wasm32"))]
510pub fn process_batch_with_reporter<F>(config: &BatchConfig, reporter: F) -> Result<()>
511where
512 F: Fn(BatchEvent) + Send + Sync,
513{
514 let input_dir = &config.input_dir;
515 let output_dir = &config.output_dir;
516
517 if input_dir == output_dir {
519 return Err(PixelSnapperError::InvalidInput(
520 "Batch output directory must be different from the input directory".to_string(),
521 ));
522 }
523
524 if output_dir.exists() && !output_dir.is_dir() {
525 return Err(PixelSnapperError::InvalidInput(format!(
526 "Batch output path must be a directory: {}",
527 output_dir.display()
528 )));
529 }
530
531 std::fs::create_dir_all(output_dir).map_err(|e| {
532 PixelSnapperError::ProcessingError(format!(
533 "Failed to create output directory '{}': {}",
534 output_dir.display(),
535 e
536 ))
537 })?;
538
539 let mut inputs = collect_batch_inputs(input_dir)?;
540 inputs.sort();
541
542 if inputs.is_empty() {
543 return Err(PixelSnapperError::InvalidInput(format!(
544 "No supported images found in '{}'",
545 input_dir.display()
546 )));
547 }
548
549 let items: Vec<(PathBuf, PathBuf)> = inputs
550 .iter()
551 .map(|input| Ok((input.clone(), get_output_path(output_dir, input)?)))
552 .collect::<Result<_>>()?;
553
554 reporter(BatchEvent::BatchStarted {
555 input_dir: input_dir.clone(),
556 total: items.len(),
557 });
558
559 let results: Vec<(PathBuf, Result<()>)> = items
560 .par_iter()
561 .enumerate()
562 .map(|(index, (input, output))| {
563 reporter(BatchEvent::Started {
564 input: input.clone(),
565 index,
566 total: items.len(),
567 });
568 let item_config = Config::from(config);
569 let result = process_file(input, output, &item_config).map(|_| ());
570 match &result {
571 Ok(()) => reporter(BatchEvent::Finished {
572 input: input.clone(),
573 output: output.clone(),
574 index,
575 total: items.len(),
576 }),
577 Err(err) => reporter(BatchEvent::Failed {
578 input: input.clone(),
579 output: output.clone(),
580 error: err.to_string(),
581 index,
582 total: items.len(),
583 }),
584 }
585 (input.clone(), result)
586 })
587 .collect();
588
589 let mut failures = Vec::new();
590 for (input, result) in results {
591 match result {
592 Ok(()) => {}
593 Err(err) => failures.push(format!("{} ({})", input.display(), err)),
594 }
595 }
596
597 if failures.is_empty() {
598 reporter(BatchEvent::BatchFinished {
599 input_dir: input_dir.clone(),
600 total: items.len(),
601 });
602 Ok(())
603 } else {
604 Err(PixelSnapperError::ProcessingError(format!(
605 "Batch completed with {} failure{}: {}",
606 failures.len(),
607 if failures.len() == 1 { "" } else { "s" },
608 failures.join("; ")
609 )))
610 }
611}
612
613#[cfg(not(target_arch = "wasm32"))]
614fn process_file(input_path: &Path, output_path: &Path, config: &Config) -> Result<ProcessedImage> {
615 let img_bytes = std::fs::read(input_path).map_err(|e| {
616 PixelSnapperError::ProcessingError(format!(
617 "Failed to read input file '{}': {}",
618 input_path.display(),
619 e
620 ))
621 })?;
622
623 let processed = process_image_common(&img_bytes, Some(config.clone()))?;
624
625 std::fs::write(output_path, &processed.output_bytes).map_err(|e| {
626 PixelSnapperError::ProcessingError(format!(
627 "Failed to write output file '{}': {}",
628 output_path.display(),
629 e
630 ))
631 })?;
632
633 Ok(processed)
634}
635
636#[cfg(not(target_arch = "wasm32"))]
637fn print_processed_image(
638 pixel_size: f64,
639 pixel_size_override: bool,
640 output_width: u32,
641 output_height: u32,
642) {
643 println!(
644 "Pixel size: {:.1}px ({})",
645 pixel_size,
646 if pixel_size_override {
647 "override"
648 } else {
649 "auto-detected"
650 }
651 );
652 println!("Output size: {}x{}", output_width, output_height);
653}
654
655#[cfg(not(target_arch = "wasm32"))]
656fn collect_batch_inputs(input_dir: &Path) -> Result<Vec<PathBuf>> {
657 let entries = std::fs::read_dir(input_dir).map_err(|e| {
658 PixelSnapperError::ProcessingError(format!(
659 "Failed to read input directory '{}': {}",
660 input_dir.display(),
661 e
662 ))
663 })?;
664
665 let mut inputs = Vec::new();
666 for entry in entries {
667 let entry = entry.map_err(|e| {
668 PixelSnapperError::ProcessingError(format!(
669 "Failed to read an entry from '{}': {}",
670 input_dir.display(),
671 e
672 ))
673 })?;
674 let path = entry.path();
675 if path.is_file() && is_supported_image_path(&path) {
676 inputs.push(path);
677 }
678 }
679
680 Ok(inputs)
681}
682
683#[cfg(not(target_arch = "wasm32"))]
684fn is_supported_image_path(path: &Path) -> bool {
685 path.extension()
686 .and_then(|ext| ext.to_str())
687 .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg"))
688 .unwrap_or(false)
689}
690
691#[cfg(not(target_arch = "wasm32"))]
692fn get_output_path(output_dir: &Path, input_path: &Path) -> Result<PathBuf> {
693 let stem = input_path
694 .file_stem()
695 .and_then(|stem| stem.to_str())
696 .filter(|stem| !stem.is_empty())
697 .ok_or_else(|| {
698 PixelSnapperError::InvalidInput(format!(
699 "Input path has no file stem: {}",
700 input_path.display()
701 ))
702 })?;
703
704 Ok(output_dir.join(format!("{}.png", stem)))
705}
706
707fn validate_image_dimensions(width: u32, height: u32) -> Result<()> {
708 if width == 0 || height == 0 {
709 return Err(PixelSnapperError::InvalidInput(
710 "Image dimensions cannot be zero".to_string(),
711 ));
712 }
713 if width > 10000 || height > 10000 {
714 return Err(PixelSnapperError::InvalidInput(
715 "Image dimensions too large (max 10000x10000)".to_string(),
716 ));
717 }
718 Ok(())
719}
720
721fn quantize_image(img: &RgbaImage, config: &Config) -> Result<RgbaImage> {
722 if config.k_colors == 0 {
723 return Err(PixelSnapperError::InvalidInput(
724 "Number of colors must be greater than 0".to_string(),
725 ));
726 }
727
728 let opaque_pixels: Vec<[f32; 3]> = img
729 .pixels()
730 .filter_map(|p| {
731 if p[3] == 0 {
732 None
733 } else {
734 Some([p[0] as f32, p[1] as f32, p[2] as f32])
735 }
736 })
737 .collect();
738 let n_pixels = opaque_pixels.len();
739 if n_pixels == 0 {
740 return Ok(img.clone());
741 }
742
743 let mut rng = ChaCha8Rng::seed_from_u64(config.k_seed);
744 let k = config.k_colors.min(n_pixels);
745
746 fn sample_index(rng: &mut ChaCha8Rng, upper: usize) -> usize {
747 debug_assert!(upper > 0);
748 let upper = upper as u64;
749 rng.gen_range(0..upper) as usize
750 }
751
752 fn dist_sq(p: &[f32; 3], c: &[f32; 3]) -> f32 {
753 let dr = p[0] - c[0];
754 let dg = p[1] - c[1];
755 let db = p[2] - c[2];
756 dr * dr + dg * dg + db * db
757 }
758
759 let mut centroids: Vec<[f32; 3]> = Vec::with_capacity(k);
760 let first_idx = sample_index(&mut rng, n_pixels);
761 centroids.push(opaque_pixels[first_idx]);
762 let mut distances = vec![f32::MAX; n_pixels];
763
764 for _ in 1..k {
766 let last_c = centroids.last().unwrap();
767 let mut sum_sq_dist = 0.0;
768
769 for (i, p) in opaque_pixels.iter().enumerate() {
770 let d_sq = dist_sq(p, last_c);
771 if d_sq < distances[i] {
772 distances[i] = d_sq;
773 }
774 sum_sq_dist += distances[i];
775 }
776
777 if sum_sq_dist <= 0.0 {
778 let idx = sample_index(&mut rng, n_pixels);
779 centroids.push(opaque_pixels[idx]);
780 } else {
781 let dist = WeightedIndex::new(&distances).map_err(|e| {
782 PixelSnapperError::ProcessingError(format!("Failed to sample new centroid: {}", e))
783 })?;
784 let idx = dist.sample(&mut rng);
785 centroids.push(opaque_pixels[idx]);
786 }
787 }
788
789 let mut prev_centroids = centroids.clone();
790 for iteration in 0..config.max_kmeans_iterations {
791 let mut sums = vec![[0.0f32; 3]; k];
792 let mut counts = vec![0usize; k];
793
794 for p in &opaque_pixels {
795 let mut min_dist = f32::MAX;
796 let mut best_k = 0;
797
798 for (i, c) in centroids.iter().enumerate() {
799 let d = dist_sq(p, c);
800 if d < min_dist {
801 min_dist = d;
802 best_k = i;
803 }
804 }
805 sums[best_k][0] += p[0];
806 sums[best_k][1] += p[1];
807 sums[best_k][2] += p[2];
808 counts[best_k] += 1;
809 }
810
811 for i in 0..k {
812 if counts[i] > 0 {
813 let fcount = counts[i] as f32;
814 centroids[i] = [
815 sums[i][0] / fcount,
816 sums[i][1] / fcount,
817 sums[i][2] / fcount,
818 ];
819 }
820 }
821
822 if iteration > 0 {
823 let mut max_movement = 0.0f32;
824 for (new_c, old_c) in centroids.iter().zip(prev_centroids.iter()) {
825 let movement = dist_sq(new_c, old_c);
826 if movement > max_movement {
827 max_movement = movement;
828 }
829 }
830
831 if max_movement < 0.01 {
832 break;
833 }
834 }
835
836 prev_centroids.copy_from_slice(¢roids);
837 }
838
839 let mut new_img = RgbaImage::new(img.width(), img.height());
840 for (x, y, pixel) in img.enumerate_pixels() {
841 if pixel[3] == 0 {
842 new_img.put_pixel(x, y, *pixel);
843 continue;
844 }
845 let p = [pixel[0] as f32, pixel[1] as f32, pixel[2] as f32];
846 let mut min_dist = f32::MAX;
847 let mut best_c = [pixel[0], pixel[1], pixel[2]];
848
849 for c in ¢roids {
850 let d = dist_sq(&p, c);
851 if d < min_dist {
852 min_dist = d;
853 best_c = [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8];
854 }
855 }
856 new_img.put_pixel(x, y, Rgba([best_c[0], best_c[1], best_c[2], pixel[3]]));
857 }
858 Ok(new_img)
859}
860
861fn parse_palette_hex(value: &str) -> Result<Vec<[u8; 3]>> {
862 if value.trim().is_empty() {
863 return Err(PixelSnapperError::InvalidInput(
864 "Palette must contain at least one color".to_string(),
865 ));
866 }
867
868 let mut seen = std::collections::HashSet::new();
869 let mut palette = Vec::new();
870 for part in value.split(',') {
871 let hex = part.trim().trim_start_matches('#');
872 if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
873 return Err(PixelSnapperError::InvalidInput(format!(
874 "invalid palette color '{}', expected a 6-digit hex code",
875 part.trim()
876 )));
877 }
878 let color = [
879 u8::from_str_radix(&hex[0..2], 16).unwrap(),
880 u8::from_str_radix(&hex[2..4], 16).unwrap(),
881 u8::from_str_radix(&hex[4..6], 16).unwrap(),
882 ];
883 if seen.insert(color) {
884 palette.push(color);
885 }
886 }
887
888 if palette.len() > MAX_PALETTE_COLORS {
889 return Err(PixelSnapperError::InvalidInput(format!(
890 "Palette must contain at most {} distinct colors",
891 MAX_PALETTE_COLORS
892 )));
893 }
894 Ok(palette)
895}
896
897fn nearest_palette_color(rgb: [u8; 3], palette: &[[u8; 3]]) -> [u8; 3] {
898 let mut best_color = palette[0];
899 let mut best_distance = u32::MAX;
900 for color in palette {
901 let dr = rgb[0] as i32 - color[0] as i32;
902 let dg = rgb[1] as i32 - color[1] as i32;
903 let db = rgb[2] as i32 - color[2] as i32;
904 let distance = (dr * dr + dg * dg + db * db) as u32;
905 if distance < best_distance {
906 best_distance = distance;
907 best_color = *color;
908 }
909 }
910 best_color
911}
912
913fn apply_palette(img: &RgbaImage, palette: &[[u8; 3]]) -> Result<RgbaImage> {
914 if palette.is_empty() {
915 return Err(PixelSnapperError::InvalidInput(
916 "Palette must contain at least one RGB color".to_string(),
917 ));
918 }
919
920 let mut cache: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
921 let mut recolored_img = RgbaImage::new(img.width(), img.height());
922
923 for (x, y, pixel) in img.enumerate_pixels() {
924 if pixel[3] == 0 {
925 recolored_img.put_pixel(x, y, *pixel);
926 continue;
927 }
928
929 let key = [pixel[0], pixel[1], pixel[2]];
930 let color = *cache
931 .entry(key)
932 .or_insert_with(|| nearest_palette_color(key, palette));
933 recolored_img.put_pixel(x, y, Rgba([color[0], color[1], color[2], pixel[3]]));
934 }
935
936 Ok(recolored_img)
937}
938
939fn compute_profiles(img: &RgbaImage) -> Result<(Vec<f64>, Vec<f64>)> {
940 let (w, h) = img.dimensions();
941
942 if w < 3 || h < 3 {
943 return Err(PixelSnapperError::InvalidInput(
944 "Image too small (minimum 3x3)".to_string(),
945 ));
946 }
947
948 let mut col_proj = vec![0.0; w as usize];
949 let mut row_proj = vec![0.0; h as usize];
950
951 let gray = |x, y| {
952 let p = img.get_pixel(x, y);
953 if p[3] == 0 {
954 0.0
955 } else {
956 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64
957 }
958 };
959
960 for y in 0..h {
962 for x in 1..w - 1 {
963 let left = gray(x - 1, y);
964 let right = gray(x + 1, y);
965 let grad = (right - left).abs();
966 col_proj[x as usize] += grad;
967 }
968 }
969 for x in 0..w {
970 for y in 1..h - 1 {
971 let top = gray(x, y - 1);
972 let bottom = gray(x, y + 1);
973 let grad = (bottom - top).abs();
974 row_proj[y as usize] += grad;
975 }
976 }
977
978 Ok((col_proj, row_proj))
979}
980
981fn estimate_step_size(profile: &[f64], config: &Config) -> Option<f64> {
982 if profile.is_empty() {
983 return None;
984 }
985
986 let max_val = profile.iter().cloned().fold(f64::NAN, f64::max);
987 if max_val == 0.0 {
988 return None; }
990 let threshold = max_val * config.peak_threshold_multiplier;
991
992 let mut peaks = Vec::new();
993 for i in 1..profile.len() - 1 {
994 if profile[i] > threshold && profile[i] > profile[i - 1] && profile[i] > profile[i + 1] {
995 peaks.push(i);
996 }
997 }
998
999 if peaks.len() < 2 {
1000 return None;
1001 }
1002
1003 let mut clean_peaks = vec![peaks[0]];
1004 for &p in peaks.iter().skip(1) {
1005 if p - clean_peaks.last().unwrap() > (config.peak_distance_filter - 1) {
1006 clean_peaks.push(p);
1007 }
1008 }
1009
1010 if clean_peaks.len() < 2 {
1011 return None;
1012 }
1013
1014 let mut diffs: Vec<f64> = clean_peaks
1016 .windows(2)
1017 .map(|w| (w[1] - w[0]) as f64)
1018 .collect();
1019
1020 diffs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1022 Some(diffs[diffs.len() / 2])
1023}
1024
1025fn resolve_step_sizes(
1026 step_x_opt: Option<f64>,
1027 step_y_opt: Option<f64>,
1028 width: u32,
1029 height: u32,
1030 config: &Config,
1031) -> (f64, f64) {
1032 if let Some(px) = config.pixel_size_override {
1033 return (px, px);
1034 }
1035
1036 match (step_x_opt, step_y_opt) {
1037 (Some(sx), Some(sy)) => {
1038 let ratio = if sx > sy { sx / sy } else { sy / sx };
1039 if ratio > config.max_step_ratio {
1040 let smaller = sx.min(sy);
1041 (smaller, smaller)
1042 } else {
1043 let avg = (sx + sy) / 2.0;
1044 (avg, avg)
1045 }
1046 }
1047
1048 (Some(sx), None) => (sx, sx),
1049
1050 (None, Some(sy)) => (sy, sy),
1051
1052 (None, None) => {
1053 let fallback_step =
1054 ((width.min(height) as f64) / config.fallback_target_segments as f64).max(1.0);
1055 (fallback_step, fallback_step)
1056 }
1057 }
1058}
1059
1060fn stabilize_both_axes(
1061 profile_x: &[f64],
1062 profile_y: &[f64],
1063 raw_col_cuts: Vec<usize>,
1064 raw_row_cuts: Vec<usize>,
1065 width: usize,
1066 height: usize,
1067 config: &Config,
1068) -> (Vec<usize>, Vec<usize>) {
1069 let col_cuts_pass1 = stabilize_cuts(
1070 profile_x,
1071 raw_col_cuts.clone(),
1072 width,
1073 &raw_row_cuts,
1074 height,
1075 config,
1076 );
1077 let row_cuts_pass1 = stabilize_cuts(
1078 profile_y,
1079 raw_row_cuts.clone(),
1080 height,
1081 &raw_col_cuts,
1082 width,
1083 config,
1084 );
1085
1086 let col_cells = col_cuts_pass1.len().saturating_sub(1).max(1);
1088 let row_cells = row_cuts_pass1.len().saturating_sub(1).max(1);
1089 let col_step = width as f64 / col_cells as f64;
1090 let row_step = height as f64 / row_cells as f64;
1091
1092 let step_ratio = if col_step > row_step {
1093 col_step / row_step
1094 } else {
1095 row_step / col_step
1096 };
1097
1098 if step_ratio > config.max_step_ratio {
1099 let target_step = col_step.min(row_step);
1100
1101 let final_col_cuts = if col_step > target_step * 1.2 {
1102 snap_uniform_cuts(
1103 profile_x,
1104 width,
1105 target_step,
1106 config,
1107 config.min_cuts_per_axis,
1108 )
1109 } else {
1110 col_cuts_pass1
1111 };
1112
1113 let final_row_cuts = if row_step > target_step * 1.2 {
1114 snap_uniform_cuts(
1115 profile_y,
1116 height,
1117 target_step,
1118 config,
1119 config.min_cuts_per_axis,
1120 )
1121 } else {
1122 row_cuts_pass1
1123 };
1124
1125 (final_col_cuts, final_row_cuts)
1126 } else {
1127 (col_cuts_pass1, row_cuts_pass1)
1128 }
1129}
1130
1131fn walk(profile: &[f64], step_size: f64, limit: usize, config: &Config) -> Result<Vec<usize>> {
1134 if profile.is_empty() {
1135 return Err(PixelSnapperError::ProcessingError(
1136 "Cannot walk on empty profile".to_string(),
1137 ));
1138 }
1139
1140 let mut cuts = vec![0];
1141 let mut current_pos = 0.0;
1142 let search_window =
1143 (step_size * config.walker_search_window_ratio).max(config.walker_min_search_window);
1144 let mean_val: f64 = profile.iter().sum::<f64>() / profile.len() as f64;
1145
1146 while current_pos < limit as f64 {
1147 let target = current_pos + step_size;
1148 if target >= limit as f64 {
1149 cuts.push(limit);
1150 break;
1151 }
1152
1153 let start_search = ((target - search_window) as usize).max((current_pos + 1.0) as usize);
1154 let end_search = ((target + search_window) as usize).min(limit);
1155
1156 if end_search <= start_search {
1157 current_pos = target;
1158 continue;
1159 }
1160
1161 let mut max_val = -1.0;
1162 let mut max_idx = start_search;
1163 for (i, &value) in profile
1164 .iter()
1165 .enumerate()
1166 .take(end_search)
1167 .skip(start_search)
1168 {
1169 if value > max_val {
1170 max_val = value;
1171 max_idx = i;
1172 }
1173 }
1174
1175 if max_val > mean_val * config.walker_strength_threshold {
1176 cuts.push(max_idx);
1177 current_pos = max_idx as f64;
1178 } else {
1179 cuts.push(target as usize);
1180 current_pos = target;
1181 }
1182 }
1183 Ok(cuts)
1184}
1185
1186fn stabilize_cuts(
1187 profile: &[f64],
1188 cuts: Vec<usize>,
1189 limit: usize,
1190 sibling_cuts: &[usize],
1191 sibling_limit: usize,
1192 config: &Config,
1193) -> Vec<usize> {
1194 if limit == 0 {
1195 return vec![0];
1196 }
1197
1198 let cuts = sanitize_cuts(cuts, limit);
1199 let min_required = config.min_cuts_per_axis.max(2).min(limit.saturating_add(1));
1200 let axis_cells = cuts.len().saturating_sub(1);
1201 let sibling_cells = sibling_cuts.len().saturating_sub(1);
1202 let sibling_has_grid =
1203 sibling_limit > 0 && sibling_cells >= min_required.saturating_sub(1) && sibling_cells > 0;
1204 let steps_skewed = sibling_has_grid && axis_cells > 0 && {
1205 let axis_step = limit as f64 / axis_cells as f64;
1206 let sibling_step = sibling_limit as f64 / sibling_cells as f64;
1207 let step_ratio = axis_step / sibling_step;
1208 step_ratio > config.max_step_ratio || step_ratio < 1.0 / config.max_step_ratio
1209 };
1210 let has_enough = cuts.len() >= min_required;
1211
1212 if has_enough && !steps_skewed {
1213 return cuts;
1214 }
1215
1216 let mut target_step = if sibling_has_grid {
1217 sibling_limit as f64 / sibling_cells as f64
1218 } else if config.fallback_target_segments > 1 {
1219 limit as f64 / config.fallback_target_segments as f64
1220 } else if axis_cells > 0 {
1221 limit as f64 / axis_cells as f64
1222 } else {
1223 limit as f64
1224 };
1225 if !target_step.is_finite() || target_step <= 0.0 {
1226 target_step = 1.0;
1227 }
1228
1229 snap_uniform_cuts(profile, limit, target_step, config, min_required)
1230}
1231
1232fn sanitize_cuts(mut cuts: Vec<usize>, limit: usize) -> Vec<usize> {
1233 if limit == 0 {
1234 return vec![0];
1235 }
1236
1237 let mut has_zero = false;
1238 let mut has_limit = false;
1239
1240 for value in cuts.iter_mut() {
1241 if *value == 0 {
1242 has_zero = true;
1243 }
1244 if *value >= limit {
1245 *value = limit;
1246 }
1247 if *value == limit {
1248 has_limit = true;
1249 }
1250 }
1251
1252 if !has_zero {
1253 cuts.push(0);
1254 }
1255 if !has_limit {
1256 cuts.push(limit);
1257 }
1258
1259 cuts.sort_unstable();
1260 cuts.dedup();
1261 cuts
1262}
1263
1264fn snap_uniform_cuts(
1265 profile: &[f64],
1266 limit: usize,
1267 target_step: f64,
1268 config: &Config,
1269 min_required: usize,
1270) -> Vec<usize> {
1271 if limit == 0 {
1272 return vec![0];
1273 }
1274 if limit == 1 {
1275 return vec![0, 1];
1276 }
1277
1278 let mut desired_cells = if target_step.is_finite() && target_step > 0.0 {
1280 (limit as f64 / target_step).round() as usize
1281 } else {
1282 0
1283 };
1284 desired_cells = desired_cells
1285 .max(min_required.saturating_sub(1))
1286 .max(1)
1287 .min(limit);
1288
1289 let cell_width = limit as f64 / desired_cells as f64;
1290 let search_window =
1291 (cell_width * config.walker_search_window_ratio).max(config.walker_min_search_window);
1292 let mean_val = if profile.is_empty() {
1293 0.0
1294 } else {
1295 profile.iter().sum::<f64>() / profile.len() as f64
1296 };
1297
1298 let mut cuts = Vec::with_capacity(desired_cells + 1);
1299 cuts.push(0);
1300 for idx in 1..desired_cells {
1301 let target = cell_width * idx as f64;
1302 let prev = *cuts.last().unwrap();
1303 if prev + 1 >= limit {
1304 break;
1305 }
1306 let mut start = ((target - search_window).floor() as isize)
1307 .max(prev as isize + 1)
1308 .max(0);
1309 let mut end = ((target + search_window).ceil() as isize).min(limit as isize - 1);
1310 if end < start {
1311 start = prev as isize + 1;
1312 end = start;
1313 }
1314 let start = start as usize;
1315 let end = end as usize;
1316 let mut best_idx = start.min(profile.len().saturating_sub(1));
1317 let mut best_val = -1.0;
1318 for i in start..=end.min(profile.len().saturating_sub(1)) {
1319 let v = profile.get(i).copied().unwrap_or(0.0);
1320 if v > best_val {
1321 best_val = v;
1322 best_idx = i;
1323 }
1324 }
1325 let strength_threshold = mean_val * config.walker_strength_threshold;
1326 if best_val < strength_threshold {
1327 let mut fallback_idx = target.round() as isize;
1328 if fallback_idx <= prev as isize {
1329 fallback_idx = prev as isize + 1;
1330 }
1331 if fallback_idx >= limit as isize {
1332 fallback_idx = (limit as isize - 1).max(prev as isize + 1);
1333 }
1334 best_idx = fallback_idx as usize;
1335 }
1336 cuts.push(best_idx);
1337 }
1338 if *cuts.last().unwrap() != limit {
1339 cuts.push(limit);
1340 }
1341 cuts = sanitize_cuts(cuts, limit);
1342 cuts
1343}
1344
1345fn resample(img: &RgbaImage, cols: &[usize], rows: &[usize]) -> Result<RgbaImage> {
1346 if cols.len() < 2 || rows.len() < 2 {
1347 return Err(PixelSnapperError::ProcessingError(
1348 "Insufficient grid cuts for resampling".to_string(),
1349 ));
1350 }
1351 let out_w = (cols.len().max(1) - 1) as u32;
1352 let out_h = (rows.len().max(1) - 1) as u32;
1353 let mut final_img: RgbaImage = ImageBuffer::new(out_w, out_h);
1354
1355 for (y_i, w_y) in rows.windows(2).enumerate() {
1356 for (x_i, w_x) in cols.windows(2).enumerate() {
1357 let ys = w_y[0];
1358 let ye = w_y[1];
1359 let xs = w_x[0];
1360 let xe = w_x[1];
1361
1362 if xe <= xs || ye <= ys {
1363 continue;
1364 }
1365
1366 let mut counts: HashMap<[u8; 4], usize> = HashMap::new();
1367
1368 for y in ys..ye {
1369 for x in xs..xe {
1370 if x < img.width() as usize && y < img.height() as usize {
1371 let p = img.get_pixel(x as u32, y as u32).0;
1372 *counts.entry(p).or_insert(0) += 1;
1373 }
1374 }
1375 }
1376
1377 let mut best_pixel = [0, 0, 0, 0];
1378
1379 let mut candidates: Vec<([u8; 4], usize)> = counts.into_iter().collect();
1380 candidates.sort_by(|a, b| {
1381 let count_cmp = b.1.cmp(&a.1);
1382 if count_cmp == Ordering::Equal {
1383 a.0.cmp(&b.0)
1384 } else {
1385 count_cmp
1386 }
1387 });
1388
1389 if let Some(winner) = candidates.first() {
1390 best_pixel = winner.0;
1391 }
1392
1393 final_img.put_pixel(x_i as u32, y_i as u32, Rgba(best_pixel));
1394 }
1395 }
1396 Ok(final_img)
1397}
1398
1399#[cfg(all(test, not(target_arch = "wasm32")))]
1400mod cli_tests {
1401 use super::*;
1402
1403 fn args(values: &[&str]) -> Vec<String> {
1404 values.iter().map(|value| (*value).to_string()).collect()
1405 }
1406
1407 #[test]
1408 fn no_arguments_show_help() {
1409 assert!(matches!(parse_cli_args(&[]).unwrap(), CliCommand::Help));
1410 }
1411
1412 #[test]
1413 fn help_and_version_flags_are_recognized() {
1414 assert!(matches!(
1415 parse_cli_args(&args(&["--help"])).unwrap(),
1416 CliCommand::Help
1417 ));
1418 assert!(matches!(
1419 parse_cli_args(&args(&["--version"])).unwrap(),
1420 CliCommand::Version
1421 ));
1422 }
1423
1424 #[test]
1425 fn output_path_is_required() {
1426 let error = parse_cli_args(&args(&["input.png"])).unwrap_err();
1427 assert!(matches!(error, PixelSnapperError::InvalidInput(_)));
1428 assert!(error.to_string().contains("missing output path"));
1429 }
1430
1431 #[test]
1432 fn parses_all_supported_cli_options() {
1433 let command = parse_cli_args(&args(&[
1434 "input.png",
1435 "output.png",
1436 "24",
1437 "--pixel-size",
1438 "8",
1439 "--palette",
1440 "0d2b45,ffecd6",
1441 ]))
1442 .unwrap();
1443
1444 let CliCommand::Run(config) = command else {
1445 panic!("expected a processing command");
1446 };
1447
1448 assert_eq!(config.input_path, "input.png");
1449 assert_eq!(config.output_path, "output.png");
1450 assert_eq!(config.k_colors, 24);
1451 assert_eq!(config.pixel_size_override, Some(8.0));
1452 assert_eq!(config.palette, Some(vec![[13, 43, 69], [255, 236, 214]]));
1453 }
1454
1455 #[test]
1456 fn rejects_unknown_options() {
1457 let error = parse_cli_args(&args(&["input.png", "output.png", "--unknown"])).unwrap_err();
1458 assert!(error.to_string().contains("unknown argument '--unknown'"));
1459 }
1460}