ruby_spriter 0.6.7 → 0.7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -3
  3. data/CHANGELOG.md +1035 -405
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -902
  7. data/bin/ruby_spriter +20 -20
  8. data/lib/ruby_spriter/background_sampler.rb +140 -0
  9. data/lib/ruby_spriter/batch_processor.rb +268 -212
  10. data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
  11. data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
  12. data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
  13. data/lib/ruby_spriter/cli.rb +676 -612
  14. data/lib/ruby_spriter/compression_manager.rb +101 -101
  15. data/lib/ruby_spriter/consolidator.rb +179 -179
  16. data/lib/ruby_spriter/dependency_checker.rb +224 -174
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -667
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -82
  21. data/lib/ruby_spriter/processor.rb +1230 -886
  22. data/lib/ruby_spriter/smoke_detector.rb +223 -0
  23. data/lib/ruby_spriter/threshold_stepper.rb +227 -0
  24. data/lib/ruby_spriter/utils/file_helper.rb +82 -82
  25. data/lib/ruby_spriter/utils/image_helper.rb +16 -0
  26. data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
  27. data/lib/ruby_spriter/utils/path_helper.rb +59 -59
  28. data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
  29. data/lib/ruby_spriter/version.rb +6 -7
  30. data/lib/ruby_spriter/video_processor.rb +357 -139
  31. data/lib/ruby_spriter.rb +38 -34
  32. data/ruby_spriter.gemspec +44 -42
  33. data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
  34. data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
  35. data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
  36. data/spec/fixtures/complex_background_sprite.png +0 -0
  37. data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
  38. data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
  39. data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
  40. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
  41. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
  42. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
  43. data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
  44. data/spec/fixtures/has_inner_bg.png +0 -0
  45. data/spec/fixtures/has_small_inner_bg.png +0 -0
  46. data/spec/fixtures/smoke_effect_sprite.png +0 -0
  47. data/spec/fixtures/spritesheet_with_metadata.png +0 -0
  48. data/spec/fixtures/test_sprite.png +0 -0
  49. data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
  50. data/spec/fixtures/test_video_spritesheet.png +0 -0
  51. data/spec/fixtures/transparent_bg_sprite.png +0 -0
  52. data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
  53. data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
  54. data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
  55. data/spec/ruby_spriter/cli_spec.rb +2026 -1892
  56. data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
  57. data/spec/ruby_spriter/consolidator_spec.rb +538 -538
  58. data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
  59. data/spec/ruby_spriter/platform_spec.rb +92 -82
  60. data/spec/ruby_spriter/processor_spec.rb +911 -735
  61. data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
  62. data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
  63. data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
  64. data/spec/ruby_spriter/video_processor_spec.rb +346 -29
  65. data/spec/spec_helper.rb +41 -41
  66. data/spec/tmp/cli_test_output.png +0 -0
  67. data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
  68. data/spec/tmp/combined_test.png +0 -0
  69. data/spec/tmp/compat_test.png +0 -0
  70. data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
  71. data/spec/tmp/final_all_features.png +0 -0
  72. data/spec/tmp/final_test_all_features.png +0 -0
  73. data/spec/tmp/full_pipeline_test.png +0 -0
  74. data/spec/tmp/inner_test.png +0 -0
  75. data/spec/tmp/integration_test.png +0 -0
  76. data/spec/tmp/validation_test.png +0 -0
  77. data/spec/unit/background_sampler_spec.rb +132 -0
  78. data/spec/unit/cell_cleanup_config_spec.rb +32 -0
  79. data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
  80. data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
  81. data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
  82. data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
  83. data/spec/unit/smoke_detector_spec.rb +246 -0
  84. data/spec/unit/threshold_stepper_spec.rb +195 -0
  85. metadata +56 -10
@@ -0,0 +1,21 @@
1
+ module RubySpriter
2
+ class CellCleanupConfig
3
+ attr_accessor :threshold, :parallel, :skip_empty
4
+
5
+ def initialize(options = {})
6
+ @threshold = options[:cell_cleanup_threshold] || 15.0
7
+ @parallel = options.fetch(:cell_cleanup_parallel, true)
8
+ @skip_empty = options.fetch(:cell_cleanup_skip_empty, true)
9
+
10
+ validate!
11
+ end
12
+
13
+ private
14
+
15
+ def validate!
16
+ unless @threshold.between?(1.0, 50.0)
17
+ raise ValidationError, "cell_cleanup_threshold must be between 1.0 and 50.0 (got: #{@threshold})"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,123 @@
1
+ module RubySpriter
2
+ module CellCleanupGimpScript
3
+ def self.generate_cleanup_script(input_path, output_path, dominant_colors)
4
+ # Normalize paths for Python
5
+ input_normalized = Utils::PathHelper.normalize_for_python(input_path)
6
+ output_normalized = Utils::PathHelper.normalize_for_python(output_path)
7
+
8
+ # Convert RGB strings to Python color strings
9
+ colors_py = dominant_colors.map do |color_str|
10
+ # Parse "rgb(255,0,0)" format
11
+ match = color_str.match(/rgb\((\d+),(\d+),(\d+)\)/)
12
+ r = match[1].to_i
13
+ g = match[2].to_i
14
+ b = match[3].to_i
15
+ "{'r': #{r}, 'g': #{g}, 'b': #{b}}"
16
+ end.join(', ')
17
+
18
+ <<~PYTHON
19
+ import sys
20
+ import gc
21
+ from gi.repository import Gimp, Gio, Gegl
22
+
23
+ img = None
24
+ layer = None
25
+
26
+ try:
27
+ print("Loading image...")
28
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE,
29
+ Gio.File.new_for_path(r'#{input_normalized}'))
30
+
31
+ w = img.get_width()
32
+ h = img.get_height()
33
+ print(f"Image size: {w}x{h}")
34
+
35
+ layers = img.get_layers()
36
+ if not layers or len(layers) == 0:
37
+ raise Exception("No layers found")
38
+ layer = layers[0]
39
+
40
+ # Add alpha channel if needed
41
+ if not layer.has_alpha():
42
+ layer.add_alpha()
43
+ print("Added alpha channel")
44
+
45
+ pdb = Gimp.get_pdb()
46
+
47
+ # Select dominant colors for removal
48
+ print("Selecting dominant colors...")
49
+ select_proc = pdb.lookup_procedure('gimp-image-select-color')
50
+ if not select_proc:
51
+ raise Exception("Could not find gimp-image-select-color procedure")
52
+
53
+ for i, color_dict in enumerate([#{colors_py}]):
54
+ r, g, b = color_dict['r'], color_dict['g'], color_dict['b']
55
+ print(f" Selecting color {i+1}: RGB({r}, {g}, {b})")
56
+
57
+ # Create Gegl.Color from normalized RGB values
58
+ color = Gegl.Color.new(f"rgb({r/255.0}, {g/255.0}, {b/255.0})")
59
+
60
+ config = select_proc.create_config()
61
+ config.set_property('image', img)
62
+ # Use REPLACE for first color, ADD for subsequent colors
63
+ if i == 0:
64
+ config.set_property('operation', Gimp.ChannelOps.REPLACE)
65
+ else:
66
+ config.set_property('operation', Gimp.ChannelOps.ADD)
67
+ config.set_property('drawable', layer)
68
+ config.set_property('color', color)
69
+ select_proc.run(config)
70
+
71
+ print("Colors selected")
72
+
73
+ # Delete selection (make transparent)
74
+ print("Removing selected colors...")
75
+ edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
76
+ if edit_clear:
77
+ config = edit_clear.create_config()
78
+ config.set_property('drawable', layer)
79
+ edit_clear.run(config)
80
+ print("Selection cleared")
81
+
82
+ # Deselect
83
+ print("Deselecting...")
84
+ select_none = pdb.lookup_procedure('gimp-selection-none')
85
+ if select_none:
86
+ config = select_none.create_config()
87
+ config.set_property('image', img)
88
+ select_none.run(config)
89
+
90
+ # Export
91
+ print("Exporting...")
92
+ export_proc = pdb.lookup_procedure('file-png-export')
93
+ if export_proc:
94
+ config = export_proc.create_config()
95
+ config.set_property('image', img)
96
+ config.set_property('file', Gio.File.new_for_path(r'#{output_normalized}'))
97
+ export_proc.run(config)
98
+ print("SUCCESS - Cell cleanup complete!")
99
+ else:
100
+ raise Exception("Could not find file-png-export procedure")
101
+
102
+ except Exception as e:
103
+ print(f"ERROR: {e}")
104
+ import traceback
105
+ traceback.print_exc()
106
+ sys.exit(1)
107
+ finally:
108
+ # Explicit cleanup to minimize GEGL warnings
109
+ try:
110
+ if layer is not None:
111
+ layer = None
112
+ if img is not None:
113
+ gc.collect() # Force garbage collection
114
+ img.delete()
115
+ img = None
116
+ gc.collect() # Force again after deletion
117
+ except Exception as cleanup_error:
118
+ print(f"Cleanup warning: {cleanup_error}")
119
+ pass
120
+ PYTHON
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,230 @@
1
+ require 'open3'
2
+ require 'fileutils'
3
+ require 'tmpdir'
4
+ require_relative 'cell_cleanup_config'
5
+ require_relative 'cell_cleanup_gimp_script'
6
+ require_relative 'gimp_processor'
7
+ require_relative 'utils/image_helper'
8
+
9
+ module RubySpriter
10
+ class CellCleanupProcessor
11
+
12
+ def initialize(options = {})
13
+ @config = CellCleanupConfig.new(options)
14
+ @gimp_processor = GimpProcessor.new(options[:gimp_path], options)
15
+ @options = options
16
+ end
17
+
18
+ # Main method to cleanup background colors in spritesheet cells
19
+ # @param spritesheet_path [String] Path to the spritesheet PNG
20
+ # @param options [Hash] Processing options
21
+ # @return [Hash] Statistics about cleanup process
22
+ def cleanup_cells(spritesheet_path, options)
23
+ cell_dims = calculate_cell_dimensions(spritesheet_path, options)
24
+ rows = calculate_rows(options)
25
+ columns = options[:columns]
26
+
27
+ temp_dir = create_temp_dir
28
+ cleaned_cells = []
29
+ stats = { processed: 0, cleaned: 0, skipped: 0, colors_removed: 0 }
30
+
31
+ puts " Analyzing spritesheet: #{columns}×#{rows} grid (#{rows * columns} cells)"
32
+ puts " Dominance threshold: #{@config.threshold}%\n\n"
33
+
34
+ (0...rows).each do |row|
35
+ (0...columns).each do |col|
36
+ stats[:processed] += 1
37
+
38
+ # Extract cell region
39
+ cell_path = extract_cell(spritesheet_path, row, col, cell_dims[:width], cell_dims[:height], temp_dir)
40
+
41
+ # Analyze for dominant colors
42
+ dominant_colors = analyze_cell_colors(cell_path)
43
+
44
+ if dominant_colors && !dominant_colors.empty?
45
+ # Remove dominant colors via GIMP
46
+ cleaned_cell = remove_dominant_colors(cell_path, dominant_colors, options, temp_dir)
47
+ cleaned_cells << cleaned_cell
48
+ stats[:cleaned] += 1
49
+ stats[:colors_removed] += dominant_colors.length
50
+
51
+ puts " Cell [#{row},#{col}]: Removed #{dominant_colors.length} dominant color(s)"
52
+ else
53
+ # No cleanup needed
54
+ cleaned_cells << cell_path
55
+ stats[:skipped] += 1
56
+ puts " Cell [#{row},#{col}]: No dominant colors detected (skipped)"
57
+ end
58
+ end
59
+ end
60
+
61
+ # Reassemble cleaned cells
62
+ reassemble_spritesheet(cleaned_cells, columns, rows, spritesheet_path)
63
+
64
+ puts "\n ✓ Cleanup complete"
65
+ puts " - Processed: #{stats[:processed]} cells"
66
+ puts " - Cleaned: #{stats[:cleaned]} cells"
67
+ puts " - Skipped: #{stats[:skipped]} cells"
68
+ puts " - Dominant colors removed: #{stats[:colors_removed]} total\n"
69
+
70
+ stats
71
+ ensure
72
+ cleanup_temp_dir(temp_dir) if temp_dir
73
+ end
74
+
75
+ private
76
+
77
+ def calculate_cell_dimensions(spritesheet_path, options)
78
+ image_info = Utils::ImageHelper.get_dimensions(spritesheet_path)
79
+
80
+ columns = options[:columns]
81
+ raise ProcessingError, "columns is nil or zero" if columns.nil? || columns == 0
82
+
83
+ rows = calculate_rows(options)
84
+ raise ProcessingError, "rows is nil or zero" if rows.nil? || rows == 0
85
+
86
+ {
87
+ width: image_info[:width] / columns,
88
+ height: image_info[:height] / rows
89
+ }
90
+ end
91
+
92
+ def calculate_rows(options)
93
+ frames = options[:frames]
94
+ columns = options[:columns]
95
+
96
+ raise ProcessingError, "frames is nil or zero: #{frames.inspect}" if frames.nil? || frames == 0
97
+ raise ProcessingError, "columns is nil or zero: #{columns.inspect}" if columns.nil? || columns == 0
98
+
99
+ (frames.to_f / columns).ceil
100
+ end
101
+
102
+ def parse_histogram(histogram_output)
103
+ colors = {}
104
+
105
+ histogram_output.each_line do |line|
106
+ # Parse ImageMagick histogram format:
107
+ # "1234: (255,0,0) #FF0000 srgb(255,0,0)"
108
+ next unless line.match(/^\s*(\d+):\s*\((\d+),(\d+),(\d+)/)
109
+
110
+ count = $1.to_i
111
+ r = $2.to_i
112
+ g = $3.to_i
113
+ b = $4.to_i
114
+
115
+ # Skip fully transparent pixels (indicated by rgba format with alpha=0)
116
+ next if line.include?('srgba') && line.include?(',0)')
117
+
118
+ colors["rgb(#{r},#{g},#{b})"] = count
119
+ end
120
+
121
+ colors
122
+ end
123
+
124
+ def analyze_cell_colors(cell_image_path)
125
+ # Extract histogram using ImageMagick
126
+ convert_cmd = Platform.imagemagick_convert_cmd
127
+ cmd = "#{convert_cmd} #{Utils::PathHelper.quote_path(cell_image_path)} -define histogram:unique-colors=true -format %c histogram:info:-"
128
+ histogram_output = execute_command(cmd)
129
+
130
+ # Parse histogram into color => pixel_count hash
131
+ colors = parse_histogram(histogram_output)
132
+
133
+ # Calculate total non-transparent pixels
134
+ total_pixels = colors.values.sum
135
+ return nil if total_pixels == 0 # Empty cell
136
+
137
+ # Find colors exceeding dominance threshold
138
+ dominant_colors = colors.select do |color, count|
139
+ percentage = (count.to_f / total_pixels) * 100
140
+ percentage >= @config.threshold
141
+ end
142
+
143
+ # Return dominant colors or nil if none found
144
+ dominant_colors.empty? ? nil : dominant_colors.keys
145
+ end
146
+
147
+ def execute_command(cmd)
148
+ stdout, stderr, status = Open3.capture3(cmd)
149
+ raise ProcessingError, "Command failed: #{stderr}" unless status.success?
150
+ stdout
151
+ end
152
+
153
+ def create_temp_dir
154
+ Dir.mktmpdir('cell_cleanup')
155
+ end
156
+
157
+ def cleanup_temp_dir(temp_dir)
158
+ FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
159
+ end
160
+
161
+ def extract_cell(spritesheet_path, row, col, cell_width, cell_height, temp_dir)
162
+ x_offset = col * cell_width
163
+ y_offset = row * cell_height
164
+ cell_path = File.join(temp_dir, "cell_#{row}_#{col}.png")
165
+
166
+ # Use ImageMagick crop: convert spritesheet.png -crop WxH+X+Y +repage cell.png
167
+ convert_cmd = Platform.imagemagick_convert_cmd
168
+ cmd = [
169
+ convert_cmd,
170
+ Utils::PathHelper.quote_path(spritesheet_path),
171
+ '-crop', "#{cell_width}x#{cell_height}+#{x_offset}+#{y_offset}",
172
+ '+repage',
173
+ Utils::PathHelper.quote_path(cell_path)
174
+ ].join(' ')
175
+
176
+ stdout, stderr, status = Open3.capture3(cmd)
177
+
178
+ unless status.success?
179
+ raise ProcessingError, "Failed to extract cell: #{stderr}"
180
+ end
181
+
182
+ cell_path
183
+ end
184
+
185
+ def remove_dominant_colors(cell_path, dominant_colors, options, temp_dir)
186
+ cleaned_path = cell_path.sub('.png', '_cleaned.png')
187
+
188
+ # Generate GIMP Python-fu script
189
+ script_content = CellCleanupGimpScript.generate_cleanup_script(
190
+ cell_path,
191
+ cleaned_path,
192
+ dominant_colors
193
+ )
194
+
195
+ # Execute GIMP script - pass script content and expected output file
196
+ success = @gimp_processor.execute_python_script(script_content, cleaned_path)
197
+
198
+ unless success
199
+ raise ProcessingError, "GIMP script failed to create output file: #{cleaned_path}"
200
+ end
201
+
202
+ # Validate output was created
203
+ Utils::FileHelper.validate_exists!(cleaned_path)
204
+
205
+ cleaned_path
206
+ end
207
+
208
+ def reassemble_spritesheet(cell_paths, columns, rows, output_path)
209
+ # Use ImageMagick montage to reassemble cells
210
+ quoted_paths = cell_paths.map { |p| Utils::PathHelper.quote_path(p) }.join(' ')
211
+
212
+ cmd = [
213
+ 'magick', 'montage',
214
+ quoted_paths,
215
+ '-tile', "#{columns}x#{rows}",
216
+ '-geometry', '+0+0', # No gaps/borders
217
+ '-background', 'none',
218
+ Utils::PathHelper.quote_path(output_path)
219
+ ].join(' ')
220
+
221
+ stdout, stderr, status = Open3.capture3(cmd)
222
+
223
+ unless status.success?
224
+ raise ProcessingError, "Failed to reassemble spritesheet: #{stderr}"
225
+ end
226
+
227
+ Utils::FileHelper.validate_exists!(output_path)
228
+ end
229
+ end
230
+ end