Skip to main content

spritefusion_pixel_snapper/
lib.rs

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    /// Input image path only used for CLI use
30    #[allow(dead_code)]
31    input_path: String,
32    /// Output image path only used for CLI use
33    #[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, // Lowered from 3.0 to catch more skew cases
62            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/// Internal entry point used by the packaged CLI binary.
186#[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    // Estimate step sizes
265    let step_x_opt = estimate_step_size(&profile_x, &config);
266    let step_y_opt = estimate_step_size(&profile_y, &config);
267
268    // Resolve step sizes. Some instabilities so use sibling axis if one fails, or fallback if both fail
269    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    // Two-pass stabilization: first pass with raw cuts, then cross-validate
275    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    // Returns bytes for both implementations
292    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/// WASM entry point
308/// `palette_hex` is a comma-separated list of hex colors: `"0d2b45,ffecd6"`.
309#[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    // Do not silently replace inputs; maybe that's ok though
518    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    // Maybe try a faster algorithm for this? like https://crates.io/crates/kmeans_colors
765    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(&centroids);
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 &centroids {
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    // kernels: [-1, 0, 1]
961    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; // Decide later
989    }
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    // Compute diffs
1015    let mut diffs: Vec<f64> = clean_peaks
1016        .windows(2)
1017        .map(|w| (w[1] - w[0]) as f64)
1018        .collect();
1019
1020    // Median
1021    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    // Check if the results are coherent
1087    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
1131// Tried uniform grid instead of an elastic-ish walker, but the result was a bit worse.
1132// Keeping the walker for now. But some distortions might happen...
1133fn 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    // Get desired cells
1279    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}