Created
June 28, 2026 11:55
-
-
Save amirrajan/8f8e6772da962c37af0255bd50c754d4 to your computer and use it in GitHub Desktop.
Chopping up a sprite sheet with ruby
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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