diff --git a/src/cli_args.rs b/src/cli_args.rs
index 8e3db8198eba9484b4816c95f53afc32715a272f..a801c193e208002398f89f09f741b7ec50552cdf 100644
--- a/src/cli_args.rs
+++ b/src/cli_args.rs
@@ -3,7 +3,7 @@ use image::ImageFormat;
 use serde::{Deserialize, Serialize};
 
 use crate::commands::{
-	Atlas, Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split,
+	Atlas, Extract, Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split,
 };
 
 use crate::load_image;
@@ -47,6 +47,9 @@ pub enum Args {
 	#[clap(name = "atlas")]
 	#[serde(alias = "atlas")]
 	Atlas(Atlas),
+	#[clap(name = "extract")]
+	#[serde(alias = "extract")]
+	Extract(Extract),
 }
 
 impl Args {
@@ -113,6 +116,10 @@ impl Args {
 			}
 			Args::Pipeline(pipeline) => pipeline.execute(),
 			Args::Atlas(atlas) => atlas.run(),
+			Args::Extract(extract) => {
+				let image_data = load_image(&extract.input, None)?;
+				extract.run(&image_data)
+			}
 		}
 	}
 }
diff --git a/src/commands/extract.rs b/src/commands/extract.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4a0c006554bac957e821afdb967b94b5b7e899bd
--- /dev/null
+++ b/src/commands/extract.rs
@@ -0,0 +1,188 @@
+use crate::utils::new_image;
+use clap::Parser;
+use image::{GenericImage, GenericImageView, Pixel, Rgba, RgbaImage};
+use rayon::prelude::*;
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::ops::{Deref, DerefMut};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+fn default_max_size() -> usize {
+	2048
+}
+
+/// Extract individual, non-square sprites from a given spritesheet
+#[derive(Parser, Serialize, Deserialize, Clone, Debug)]
+#[clap(author, version = "0.7.0")]
+pub struct Extract {
+	/// The path to the spritesheet to extract sprites from
+	#[serde(default)]
+	pub input: String,
+	/// The path to a folder that will contain the extracted sprites
+	#[serde(default)]
+	pub output: String,
+}
+
+struct PixelData {
+	x: u32,
+	y: u32,
+	pixel: Rgba<u8>,
+}
+
+#[derive(Debug)]
+struct BlobBounds {
+	left: u32,
+	right: u32,
+	top: u32,
+	bottom: u32,
+}
+
+impl BlobBounds {
+	pub fn width(&self) -> u32 {
+		self.right.saturating_sub(self.left)
+	}
+	pub fn height(&self) -> u32 {
+		self.bottom.saturating_sub(self.top)
+	}
+}
+
+impl Default for BlobBounds {
+	fn default() -> Self {
+		Self {
+			left: u32::MAX,
+			right: u32::MIN,
+			bottom: u32::MIN,
+			top: u32::MAX,
+		}
+	}
+}
+
+#[derive(Default)]
+struct PixelBlob {
+	pixels: Vec<PixelData>,
+}
+
+impl PixelBlob {
+	pub fn find_bounds(&self) -> BlobBounds {
+		self.pixels
+			.iter()
+			.fold(BlobBounds::default(), |bounds, pix| BlobBounds {
+				bottom: bounds.bottom.max(pix.y),
+				top: bounds.top.min(pix.y),
+				left: bounds.left.min(pix.x),
+				right: bounds.right.max(pix.x),
+			})
+	}
+}
+
+impl Deref for PixelBlob {
+	type Target = Vec<PixelData>;
+
+	fn deref(&self) -> &Self::Target {
+		&self.pixels
+	}
+}
+
+impl DerefMut for PixelBlob {
+	fn deref_mut(&mut self) -> &mut Self::Target {
+		&mut self.pixels
+	}
+}
+
+macro_rules! check_point {
+	($visited: expr, $to_check: expr, $x: expr, $y: expr) => {
+		if !$visited.contains(&($x, $y)) && !$to_check.contains(&($x, $y)) {
+			$to_check.push(($x, $y));
+		}
+	};
+}
+
+impl Extract {
+	pub fn run(&self, image: &impl GenericImage<Pixel = Rgba<u8>>) -> anyhow::Result<()> {
+		let mut visited = HashSet::with_capacity((image.width() * image.height()) as usize);
+		let mut blobs = Vec::with_capacity(1);
+
+		let image_width = image.width();
+		let image_height = image.height();
+
+		for (x, y, pixel) in image.pixels() {
+			if visited.contains(&(x, y)) {
+				continue;
+			}
+
+			let alpha = pixel.channels()[3];
+			if alpha == 0 {
+				visited.insert((x, y));
+				continue;
+			}
+
+			let mut current = PixelBlob::default();
+			let mut to_check = Vec::with_capacity(4);
+			to_check.push((x, y));
+
+			while let Some((cx, cy)) = to_check.pop() {
+				if visited.contains(&(cx, cy)) {
+					continue;
+				}
+				visited.insert((cx, cy));
+
+				let pixel = image.get_pixel(cx, cy);
+				let alpha = pixel.channels()[3];
+				if alpha == 0 {
+					continue;
+				}
+
+				current.push(PixelData {
+					x: cx,
+					y: cy,
+					pixel,
+				});
+
+				let x = [-1, 0, 1];
+				let y = [-1, 0, 1];
+
+				for dx in x {
+					for dy in y {
+						let new_x = cx as i32 + dx;
+						let new_y = cy as i32 + dy;
+
+						if new_x >= 0
+							&& new_y >= 0 && new_x < image_width as i32
+							&& new_y < image_height as i32
+						{
+							check_point!(visited, to_check, new_x as u32, new_y as u32);
+						}
+					}
+				}
+			}
+
+			if !current.is_empty() {
+				blobs.push(std::mem::take(&mut current));
+			}
+		}
+
+		for (idx, blob) in blobs.iter().enumerate() {
+			let bounds = blob.find_bounds();
+			let width = bounds.width();
+			let height = bounds.height();
+
+			if width == 0 || height == 0 {
+				continue;
+			}
+
+			let sub = image.view(bounds.left, bounds.top, width, height);
+			let mut new_image = RgbaImage::from_pixel(width, height, Rgba::from([0, 0, 0, 0]));
+			let pixels = sub
+				.pixels()
+				.flat_map(|(_, _, pixel)| pixel.channels().to_vec())
+				.collect::<Vec<u8>>();
+
+			new_image.copy_from_slice(pixels.as_slice());
+			new_image
+				.save(PathBuf::from(self.output.as_str()).join(format!("image_{}.png", idx)))?;
+		}
+
+		Ok(())
+	}
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index d39c70b421e64788e03364af810702d7f92d3d3d..5a332e27b7793154be0c324e9410198be76b94cf 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -1,4 +1,5 @@
 mod atlas;
+mod extract;
 mod extrude;
 mod flip;
 mod palette;
@@ -10,6 +11,7 @@ mod scale;
 mod split;
 
 pub use atlas::Atlas;
+pub use extract::Extract;
 pub use extrude::Extrude;
 pub use flip::Flip;
 pub use palette::Palette;