Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Created June 28, 2026 11:55
Show Gist options
  • Select an option

  • Save amirrajan/8f8e6772da962c37af0255bd50c754d4 to your computer and use it in GitHub Desktop.

Select an option

Save amirrajan/8f8e6772da962c37af0255bd50c754d4 to your computer and use it in GitHub Desktop.
Chopping up a sprite sheet with ruby
#!/usr/bin/env ruby
require "optparse"
require "tty-prompt"
require_relative "lib"
ARGV.delete("--")
options = {}
OptionParser.new do |opts|
opts.on("--input_path=PATH", "Path to the source PNG") do |val|
options[:input_path] = val
end
end.parse!
ARGV.clear
input_path = options[:input_path]
unless input_path
pngs = Dir["**/*.png"].sort
abort "No PNGs found." if pngs.empty?
input_path = TTY::Prompt.new.select("Which PNG would you like to process?", pngs, filter: true)
end
abort "Error: file not found: #{input_path}" unless File.exist?(input_path)
sprite_name = File.basename(input_path, ".png")
work_dir = "./finalize-auto-output/#{sprite_name}"
final_dir = "./final-#{sprite_name}"
def dimensions(path)
`magick identify -format "%w %h" #{path}`.split.map(&:to_i)
end
def changed_dimensions?(input_path, output_pngs)
return false if output_pngs.length != 1
input_dims = dimensions(input_path)
output_dims = dimensions(output_pngs.first)
input_dims != output_dims
end
def process(start_path:, work_dir:, final_dir:)
queue = [{ path: start_path, output_base: work_dir, depth: 0 }]
while (item = queue.shift)
input_path = item[:path]
output_base = item[:output_base]
depth = item[:depth]
indent = " " * depth
label = File.basename(input_path)
# --- Try split rows ---
rows_dir = File.join(output_base, "split_rows")
split_rows(
input_path: input_path,
output_directory: rows_dir,
prefix: File.basename(input_path, ".png"),
ignore_transparent: false
)
rows_pngs = Dir["#{rows_dir}/*.png"].sort
if rows_pngs.length > 1 || changed_dimensions?(input_path, rows_pngs)
puts "#{indent}#{label} -> split rows -> #{rows_pngs.length} sprites"
rows_pngs.each do |png|
queue << { path: png, output_base: File.join(rows_dir, File.basename(png, ".png")), depth: depth + 1 }
end
next
end
FileUtils.rm_rf(rows_dir)
# --- Try split columns ---
cols_dir = File.join(output_base, "split_columns")
split_columns(
input_path: input_path,
output_directory: cols_dir,
ignore_transparent: false
)
cols_pngs = Dir["#{cols_dir}/*.png"].sort
if cols_pngs.length > 1 || changed_dimensions?(input_path, cols_pngs)
puts "#{indent}#{label} -> split columns -> #{cols_pngs.length} sprites"
cols_pngs.each do |png|
queue << { path: png, output_base: File.join(cols_dir, File.basename(png, ".png")), depth: depth + 1 }
end
next
end
FileUtils.rm_rf(cols_dir)
# --- Final sprite ---
puts "#{indent}#{label} -> final"
FileUtils.cp(input_path, final_dir)
system("chafa --format symbols --colors 256 --symbols block #{input_path}")
end
end
FileUtils.rm_rf(work_dir)
FileUtils.mkdir_p(work_dir)
FileUtils.rm_rf(final_dir)
FileUtils.mkdir_p(final_dir)
cleared_path = clear_background(
input_path: input_path,
output_directory: File.join(work_dir, "cleared"),
fuzz: 10
)
puts "Background cleared. Opening in Preview..."
system("open #{cleared_path}")
system("hs -c 'hs.alert.show(\"Edit sprite if needed, save, then close the file in Preview to continue...\")'")
filename = File.basename(cleared_path)
puts "Waiting for #{filename} to be closed in Preview..."
loop do
sleep 1
open_docs = `osascript -e 'tell application "Preview" to get name of every document' 2>/dev/null`
break unless open_docs.include?(filename)
end
puts "Preview closed. Continuing..."
puts "Processing #{cleared_path}..."
process(start_path: cleared_path, work_dir: work_dir, final_dir: final_dir)
finals = Dir["#{final_dir}/*.png"].sort
puts "\nDone. #{finals.length} final sprites saved to #{final_dir}/"
system("ruby resize.rb -- --input_directory=#{final_dir}")
resized_dir = "#{final_dir}_resized"
system("open #{resized_dir}") if Dir.exist?(resized_dir)
require "fileutils"
def get_colors(input_path:, fuzz:)
width, height = `magick identify -format "%w %h" #{input_path}`.split.map(&:to_i)
total = width * height
# Parse histogram — supports both RGB (3-channel) and RGBA (4-channel)
histogram = `magick #{input_path} -format "%c" histogram:info:-`
entries = []
histogram.each_line do |line|
if line =~ /^\s*(\d+):\s*\((\d+),(\d+),(\d+),(\d+)\)/
entries << { count: $1.to_i, r: $2.to_i, g: $3.to_i, b: $4.to_i, a: $5.to_i }
elsif line =~ /^\s*(\d+):\s*\((\d+),(\d+),(\d+)\)/
entries << { count: $1.to_i, r: $2.to_i, g: $3.to_i, b: $4.to_i, a: 255 }
end
end
# Cluster entries whose RGBA distance is within fuzz threshold
fuzz_threshold = fuzz / 100.0
max_distance = Math.sqrt(4 * 255**2)
clusters = []
entries.each do |e|
matched = clusters.find do |c|
rep = c[:color]
dist = Math.sqrt(
(e[:r] - rep[:r])**2 +
(e[:g] - rep[:g])**2 +
(e[:b] - rep[:b])**2 +
(e[:a] - rep[:a])**2
) / max_distance
dist <= fuzz_threshold
end
if matched
matched[:count] += e[:count]
else
clusters << { color: { r: e[:r], g: e[:g], b: e[:b], a: e[:a] }, count: e[:count] }
end
end
clusters.sort_by { |c| -c[:count] }.map do |c|
c.merge(percent: (c[:count].to_f / total * 100).round(4))
end
end
def make_row_transparent(input_path:, row:, output_directory:)
FileUtils.mkdir_p(output_directory)
width, height = `magick identify -format "%w %h" #{input_path}`.split.map(&:to_i)
pixels = IO.popen(["magick", input_path, "-depth", "8", "rgba:-"], "rb", &:read).bytes.each_slice(4).to_a
(0...width).each { |x| pixels[row * width + x] = [0, 0, 0, 0] }
raw = pixels.flatten.pack("C*")
original = File.basename(input_path).gsub(/\A(row_transparent_\d+_|col_transparent_\d+_)+/, "")
basename = "row_transparent_#{row}_#{original}"
output_path = File.join(output_directory, basename)
IO.popen(
["magick", "-depth", "8", "-size", "#{width}x#{height}", "rgba:-", output_path],
"wb"
) { |f| f.write(raw) }
puts "Row #{row} cleared -> #{output_path}"
output_path
end
def make_column_transparent(input_path:, col:, output_directory:)
FileUtils.mkdir_p(output_directory)
width, height = `magick identify -format "%w %h" #{input_path}`.split.map(&:to_i)
pixels = IO.popen(["magick", input_path, "-depth", "8", "rgba:-"], "rb", &:read).bytes.each_slice(4).to_a
(0...height).each { |y| pixels[y * width + col] = [0, 0, 0, 0] }
raw = pixels.flatten.pack("C*")
original = File.basename(input_path).gsub(/\A(row_transparent_\d+_|col_transparent_\d+_)+/, "")
basename = "col_transparent_#{col}_#{original}"
output_path = File.join(output_directory, basename)
IO.popen(
["magick", "-depth", "8", "-size", "#{width}x#{height}", "rgba:-", output_path],
"wb"
) { |f| f.write(raw) }
puts "Col #{col} cleared -> #{output_path}"
output_path
end
def clear_background(input_path:, output_directory:, fuzz: 30)
FileUtils.mkdir_p(output_directory)
dominant = get_colors(input_path: input_path, fuzz: fuzz).first
r, g, b = dominant[:color].values_at(:r, :g, :b)
puts "Detected mask color: rgb(#{r}, #{g}, #{b}) - #{dominant[:percent]}%"
current = apply_transparency(
input_path: input_path,
output_directory: output_directory,
mask_color: [r, g, b],
fuzz: fuzz
)
rows = get_uniform_rows(input_path: current, ignore_transparent: true)
cols = get_uniform_columns(input_path: current, ignore_transparent: true)
puts "Clearing #{rows.length} uniform rows, #{cols.length} uniform columns..."
rows.each do |entry|
current = make_row_transparent(input_path: current, row: entry[:row], output_directory: output_directory)
end
cols.each do |entry|
current = make_column_transparent(input_path: current, col: entry[:col], output_directory: output_directory)
end
puts "Done. Final PNG: #{current}"
current
end
def apply_transparency(input_path:, output_directory:, mask_color:, fuzz: 10)
FileUtils.mkdir_p(output_directory)
r, g, b = mask_color
hex = "#%02X%02X%02X" % [r, g, b]
basename = "transparent_#{File.basename(input_path)}"
output_path = File.join(output_directory, basename)
cmd = ["magick", input_path, "-fuzz", "#{fuzz}%", "-transparent", hex, output_path]
log_path = File.join(output_directory, "commands.log")
File.open(log_path, "a") { |f| f.puts cmd.join(" ") }
puts "Converting '#{input_path}' -> '#{output_path}' ..."
system(*cmd) or abort "Error: ImageMagick `magick` failed. Is ImageMagick installed?"
puts "Done. Transparent sprite saved as '#{output_path}'."
output_path
end
def get_uniform_columns(input_path:, ignore_transparent: false)
width, height = `magick identify -format "%w %h" #{input_path}`.split.map(&:to_i)
pixels = IO.popen(["magick", input_path, "-depth", "8", "rgba:-"], "rb", &:read).bytes.each_slice(4).to_a
uniform_columns = []
(0...width).each do |x|
column_pixels = (0...height).map { |y| pixels[y * width + x] }
if ignore_transparent
next if column_pixels.all? { |px| px[3] == 0 }
end
next unless column_pixels.uniq.length == 1
r, g, b, a = column_pixels.first
uniform_columns << { col: x, color: { r: r, g: g, b: b, a: a } }
end
uniform_columns
end
def get_uniform_rows(input_path:, ignore_transparent: false)
width, height = `magick identify -format "%w %h" #{input_path}`.split.map(&:to_i)
pixels = IO.popen(["magick", input_path, "-depth", "8", "rgba:-"], "rb", &:read).bytes.each_slice(4).to_a
uniform_rows = []
(0...height).each do |y|
row_pixels = (0...width).map { |x| pixels[y * width + x] }
if ignore_transparent
next if row_pixels.all? { |px| px[3] == 0 }
end
next unless row_pixels.uniq.length == 1
r, g, b, a = row_pixels.first
uniform_rows << { row: y, color: { r: r, g: g, b: b, a: a } }
end
uniform_rows
end
def get_column_dividers(input_path:, ignore_transparent: false)
cols = get_uniform_columns(input_path: input_path, ignore_transparent: ignore_transparent)
return [] if cols.empty?
groups = []
current = { start_col: cols.first[:col], end_col: cols.first[:col], color: cols.first[:color] }
cols.each_cons(2) do |a, b|
if b[:col] == a[:col] + 1 && b[:color] == a[:color]
current[:end_col] = b[:col]
else
groups << current
current = { start_col: b[:col], end_col: b[:col], color: b[:color] }
end
end
groups << current
groups.map { |g| { start_col: g[:start_col], end_col: g[:end_col], color: g[:color].slice(:r, :g, :b) } }
end
def get_row_dividers(input_path:, ignore_transparent: false)
rows = get_uniform_rows(input_path: input_path, ignore_transparent: ignore_transparent)
return [] if rows.empty?
groups = []
current = { start_row: rows.first[:row], end_row: rows.first[:row], color: rows.first[:color] }
rows.each_cons(2) do |a, b|
if b[:row] == a[:row] + 1 && b[:color] == a[:color]
current[:end_row] = b[:row]
else
groups << current
current = { start_row: b[:row], end_row: b[:row], color: b[:color] }
end
end
groups << current
groups.map { |g| { start_row: g[:start_row], end_row: g[:end_row], color: g[:color].slice(:r, :g, :b) } }
end
def split_columns(input_path:, output_directory:, ignore_transparent: false)
FileUtils.rm_rf(output_directory)
FileUtils.mkdir_p(output_directory)
log_path = File.join(output_directory, "commands.log")
log = lambda do |cmd|
File.open(log_path, "a") { |f| f.puts cmd.join(" ") }
end
# Artifact: magick identify
identify_cmd = ["magick", "identify", input_path]
log.call(identify_cmd)
identify_output = `#{identify_cmd.join(" ")}`
File.write(File.join(output_directory, "identify.txt"), identify_output)
width, height = identify_output.scan(/\d+x\d+/).first.split("x").map(&:to_i)
puts " Image size: #{width}x#{height}"
uniform_col_set = get_uniform_columns(input_path: input_path, ignore_transparent: ignore_transparent).map { |u| u[:col] }.to_set
section_start = 0
section_index = 0
seen_mixed = false
in_uniform_band = false
save_section = lambda do |x_end|
w = x_end - section_start
return if w <= 0
filename = File.join(output_directory, "%06d-%06d-%06d.png" % [section_index, section_start, x_end - 1])
cmd = ["magick", input_path, "-crop", "#{w}x#{height}+#{section_start}+0", "+repage", filename]
log.call(cmd)
system(*cmd)
puts " [#{section_index}] columns #{section_start}-#{x_end - 1} -> #{filename}"
section_index += 1
end
(0...width).each do |x|
if uniform_col_set.include?(x)
if seen_mixed && !in_uniform_band
save_section.call(x)
in_uniform_band = true
end
else
if in_uniform_band || !seen_mixed
section_start = x
in_uniform_band = false
end
seen_mixed = true
end
end
save_section.call(width) if !in_uniform_band && seen_mixed
puts "Done. #{section_index} columns saved to #{output_directory}/"
end
def split_rows(input_path:, output_directory:, prefix:, ignore_transparent: false)
FileUtils.mkdir_p(output_directory)
log_path = File.join(output_directory, "#{prefix}-commands.log")
log = lambda do |cmd|
File.open(log_path, "a") { |f| f.puts cmd.join(" ") }
end
# Artifact: magick identify
identify_cmd = ["magick", "identify", input_path]
log.call(identify_cmd)
identify_output = `#{identify_cmd.join(" ")}`
File.write(File.join(output_directory, "#{prefix}-identify.txt"), identify_output)
col_width, col_height = identify_output.scan(/\d+x\d+/).first.split("x").map(&:to_i)
pixels = IO.popen(["magick", input_path, "-depth", "8", "rgba:-"], "rb", &:read).bytes.each_slice(4).to_a
uniform_row_set = get_uniform_rows(input_path: input_path, ignore_transparent: ignore_transparent).map { |u| u[:row] }.to_set
row_start = 0
row_index = 0
seen_mixed = false
in_uniform_band = false
save_row = lambda do |y_end|
h = y_end - row_start
return if h <= 0
all_transparent = (row_start...y_end).all? do |y|
(0...col_width).all? { |x| pixels[y * col_width + x][3] == 0 }
end
return if all_transparent
filename = File.join(output_directory, "#{prefix}-%06d-%06d-%06d.png" % [row_index, row_start, y_end - 1])
cmd = ["magick", input_path, "-crop", "#{col_width}x#{h}+0+#{row_start}", "+repage", filename]
log.call(cmd)
system(*cmd)
puts " [row #{row_index}] rows #{row_start}-#{y_end - 1} -> #{filename}"
row_index += 1
end
(0...col_height).each do |y|
if uniform_row_set.include?(y)
if seen_mixed && !in_uniform_band
save_row.call(y)
in_uniform_band = true
end
else
if in_uniform_band || !seen_mixed
row_start = y
in_uniform_band = false
end
seen_mixed = true
end
end
save_row.call(col_height) if !in_uniform_band && seen_mixed
puts " #{row_index} rows saved to #{output_directory}/"
end
def get_max_width(input_directory:)
pngs = Dir["#{input_directory}/*.png"]
raise "No PNGs found in #{input_directory}" if pngs.empty?
pngs.max_by do |path|
`magick identify -format "%w" #{path}`.strip.to_i
end
end
def get_max_height(input_directory:)
pngs = Dir["#{input_directory}/*.png"]
raise "No PNGs found in #{input_directory}" if pngs.empty?
pngs.max_by do |path|
`magick identify -format "%h" #{path}`.strip.to_i
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment