spritesheet_detector/
lib.rs

1use image::{DynamicImage, GenericImageView};
2
3/// Information about the spritesheet.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct SpritesheetInfo {
6    /// The width of a single sprite frame.
7    pub sprite_width: u32,
8    /// The height of a single sprite frame.
9    pub sprite_height: u32,
10    /// Number of columns in the spritesheet grid.
11    pub columns: u32,
12    /// Number of rows in the spritesheet grid.
13    pub rows: u32,
14    /// Count of non-empty frames detected.
15    pub frame_count: u32,
16}
17
18/// Analyze a spritesheet image and return its grid information.
19///
20/// # Arguments
21///
22/// * `img` - A reference to a DynamicImage representing the spritesheet.
23/// * `gap_threshold` - Optional threshold for gaps between sprites. Defaults to 40.
24///
25/// # Returns
26///
27/// A [`SpritesheetInfo`] struct containing the frame dimensions, grid (columns/rows), and valid frame count.
28///
29/// # Details
30///
31/// This function assumes:
32/// - If the image width is evenly divisible by its height, the sprites are square frames.
33/// - Otherwise, it uses the pixel at `(0, 0)` as the margin/padding color to detect boundaries.
34/// - A cell is counted as a valid frame if any pixel inside it is not equal to the margin color.
35pub fn analyze_spritesheet(img: &DynamicImage, gap_threshold: Option<u32>) -> SpritesheetInfo {
36    let (width, height) = img.dimensions();
37
38    // Shortcut: if width is evenly divisible by height,
39    // assume single row square frames.
40    if width % height == 0 && width != height {
41        let frames = width / height;
42        return SpritesheetInfo {
43            sprite_width: height,
44            sprite_height: height,
45            columns: frames,
46            rows: 1,
47            frame_count: frames,
48        };
49    }
50
51    // Use the color at (0,0) as the margin/padding color.
52    let margin_color = img.get_pixel(0, 0);
53
54    // Find vertical boundaries: columns where every pixel equals the margin color.
55    let mut vertical_boundaries: Vec<u32> = (0..width)
56        .filter(|&x| (0..height).all(|y| img.get_pixel(x, y) == margin_color))
57        .collect();
58    if vertical_boundaries.first() != Some(&0) {
59        vertical_boundaries.insert(0, 0);
60    }
61    if vertical_boundaries.last() != Some(&(width - 1)) {
62        vertical_boundaries.push(width - 1);
63    }
64
65    // Find horizontal boundaries: rows where every pixel equals the margin color.
66    let mut horizontal_boundaries: Vec<u32> = (0..height)
67        .filter(|&y| (0..width).all(|x| img.get_pixel(x, y) == margin_color))
68        .collect();
69    if horizontal_boundaries.first() != Some(&0) {
70        horizontal_boundaries.insert(0, 0);
71    }
72    if horizontal_boundaries.last() != Some(&(height - 1)) {
73        horizontal_boundaries.push(height - 1);
74    }
75
76    // A gap must be at least a certain amount of pixels to be considered a valid cell boundary.
77    let gap_threshold = gap_threshold.unwrap_or(40);
78    let columns = std::cmp::max(
79        vertical_boundaries
80            .windows(2)
81            .filter(|w| w[1] > w[0] + gap_threshold)
82            .count(),
83        1,
84    ) as u32;
85    let rows = std::cmp::max(
86        horizontal_boundaries
87            .windows(2)
88            .filter(|w| w[1] > w[0] + gap_threshold)
89            .count(),
90        1,
91    ) as u32;
92
93    // Determine the uniform sprite dimensions.
94    let sprite_width = width / columns;
95    let sprite_height = height / rows;
96
97    // Count valid frames: a cell is valid if any pixel in it is not the margin color.
98    let frame_count = (0..rows)
99        .flat_map(|row_idx| {
100            (0..columns).filter(move |&col_idx| {
101                let x_start = col_idx * sprite_width;
102                let y_start = row_idx * sprite_height;
103                let x_end = x_start + sprite_width;
104                let y_end = y_start + sprite_height;
105                !(y_start..y_end)
106                    .all(|y| (x_start..x_end).all(|x| img.get_pixel(x, y) == margin_color))
107            })
108        })
109        .count() as u32;
110
111    SpritesheetInfo {
112        sprite_width,
113        sprite_height,
114        columns,
115        rows,
116        frame_count,
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use image::RgbaImage;
124
125    #[test]
126    fn test_square_frames() {
127        // Create an image 200x50, which implies 4 square frames of size 50x50.
128        let mut img = RgbaImage::new(200, 50);
129        // Fill the image with non-margin color (e.g., white).
130        for pixel in img.pixels_mut() {
131            *pixel = image::Rgba([255, 255, 255, 255]);
132        }
133        let dyn_img = DynamicImage::ImageRgba8(img);
134        let info = analyze_spritesheet(&dyn_img, None);
135        assert_eq!(info.sprite_width, 50);
136        assert_eq!(info.sprite_height, 50);
137        assert_eq!(info.columns, 4);
138        assert_eq!(info.rows, 1);
139        assert_eq!(info.frame_count, 4);
140    }
141    #[test]
142    fn test_asset_example() {
143        let img = image::open("assets/example.png").unwrap();
144        let info = analyze_spritesheet(&img, None);
145        assert_eq!(info.sprite_width, 193);
146        assert_eq!(info.sprite_height, 155);
147        assert_eq!(info.columns, 5);
148        assert_eq!(info.rows, 4);
149        assert_eq!(info.frame_count, 18);
150    }
151}