ruby_spriter 0.6.7.1 → 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 -524
  4. data/Gemfile +17 -17
  5. data/LICENSE +21 -21
  6. data/README.md +183 -950
  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 -214
  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 -224
  17. data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
  18. data/lib/ruby_spriter/gimp_processor.rb +1188 -1058
  19. data/lib/ruby_spriter/metadata_manager.rb +117 -116
  20. data/lib/ruby_spriter/platform.rb +137 -137
  21. data/lib/ruby_spriter/processor.rb +1230 -891
  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 -92
  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,59 @@
1
+ require 'spec_helper'
2
+ require_relative '../../lib/ruby_spriter/cell_cleanup_gimp_script'
3
+
4
+ RSpec.describe RubySpriter::CellCleanupGimpScript do
5
+ describe '.generate_cleanup_script' do
6
+ it 'generates valid GIMP 3.x Python-fu script' do
7
+ script = described_class.generate_cleanup_script(
8
+ '/input.png',
9
+ '/output.png',
10
+ ['rgb(255,0,0)']
11
+ )
12
+
13
+ expect(script).to include("from gi.repository import Gimp, Gio, Gegl")
14
+ expect(script).to include('gimp-image-select-color')
15
+ # Paths are converted to forward slashes for GIMP Python compatibility
16
+ expect(script).to include('/input.png')
17
+ expect(script).to include('/output.png')
18
+ end
19
+
20
+ it 'stores RGB colors as integers for later normalization' do
21
+ script = described_class.generate_cleanup_script(
22
+ '/input.png',
23
+ '/output.png',
24
+ ['rgb(255,0,0)']
25
+ )
26
+
27
+ # Red (255,0,0) should be stored as integers
28
+ expect(script).to include("'r': 255")
29
+ expect(script).to include("'g': 0")
30
+ expect(script).to include("'b': 0")
31
+ end
32
+
33
+ it 'uses exact color matching (GIMP 3.x uses default threshold)' do
34
+ script = described_class.generate_cleanup_script(
35
+ '/input.png',
36
+ '/output.png',
37
+ ['rgb(255,0,0)']
38
+ )
39
+
40
+ # GIMP 3.x gimp-image-select-color does not have a threshold property
41
+ # It uses the default color selection behavior
42
+ expect(script).to include('gimp-image-select-color')
43
+ expect(script).to include("'color', color")
44
+ end
45
+
46
+ it 'uses REPLACE for first color and ADD for subsequent colors' do
47
+ script = described_class.generate_cleanup_script(
48
+ '/input.png',
49
+ '/output.png',
50
+ ['rgb(255,0,0)', 'rgb(0,255,0)']
51
+ )
52
+
53
+ expect(script).to include('Gimp.ChannelOps.REPLACE')
54
+ expect(script).to include('Gimp.ChannelOps.ADD')
55
+ expect(script).to include("'r': 255") # Red
56
+ expect(script).to include("'r': 0") # Green (no red)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,261 @@
1
+ require 'spec_helper'
2
+ require_relative '../../lib/ruby_spriter/cell_cleanup_processor'
3
+ require_relative '../../lib/ruby_spriter/cell_cleanup_gimp_script'
4
+ require_relative '../../lib/ruby_spriter/utils/image_helper'
5
+
6
+ RSpec.describe RubySpriter::CellCleanupProcessor do
7
+ describe '#calculate_cell_dimensions' do
8
+ it 'calculates cell width and height from spritesheet dimensions' do
9
+ # Mock the image helper to return dimensions
10
+ allow(RubySpriter::Utils::ImageHelper).to receive(:get_dimensions)
11
+ .and_return({ width: 1280, height: 960 })
12
+
13
+ processor = described_class.new
14
+ options = { columns: 8, frames: 32 }
15
+
16
+ dimensions = processor.send(:calculate_cell_dimensions, '/spritesheet.png', options)
17
+
18
+ expect(dimensions[:width]).to eq(160) # 1280 / 8 columns
19
+ expect(dimensions[:height]).to eq(240) # 960 / 4 rows (32 frames / 8 columns)
20
+ end
21
+ end
22
+
23
+ describe '#parse_histogram' do
24
+ it 'parses ImageMagick histogram output into color hash' do
25
+ processor = described_class.new
26
+
27
+ histogram_output = <<~HISTOGRAM
28
+ 1234: (255,0,0) #FF0000 srgb(255,0,0)
29
+ 5678: (0,255,0) #00FF00 srgb(0,255,0)
30
+ 910: (0,0,255) #0000FF srgb(0,0,255)
31
+ HISTOGRAM
32
+
33
+ colors = processor.send(:parse_histogram, histogram_output)
34
+
35
+ expect(colors['rgb(255,0,0)']).to eq(1234)
36
+ expect(colors['rgb(0,255,0)']).to eq(5678)
37
+ expect(colors['rgb(0,0,255)']).to eq(910)
38
+ expect(colors.size).to eq(3)
39
+ end
40
+
41
+ it 'skips transparent pixels in histogram' do
42
+ processor = described_class.new
43
+
44
+ histogram_output = <<~HISTOGRAM
45
+ 1234: (255,0,0) #FF0000 srgb(255,0,0)
46
+ 5678: (0,0,0,0) #00000000 srgba(0,0,0,0)
47
+ HISTOGRAM
48
+
49
+ colors = processor.send(:parse_histogram, histogram_output)
50
+
51
+ expect(colors['rgb(255,0,0)']).to eq(1234)
52
+ expect(colors.size).to eq(1) # Transparent pixel skipped
53
+ end
54
+ end
55
+
56
+ describe '#analyze_cell_colors' do
57
+ let(:processor) { described_class.new(cell_cleanup_threshold: 15.0) }
58
+
59
+ it 'detects single dominant color above threshold' do
60
+ # Mock execute_command to return histogram with 85% red, 10% other (below 15% threshold)
61
+ allow(processor).to receive(:execute_command).and_return(<<~HISTOGRAM)
62
+ 8500: (255,0,0) #FF0000 srgb(255,0,0)
63
+ 1000: (128,128,128) #808080 srgb(128,128,128)
64
+ HISTOGRAM
65
+
66
+ dominant_colors = processor.send(:analyze_cell_colors, '/cell.png')
67
+
68
+ expect(dominant_colors).to eq(['rgb(255,0,0)'])
69
+ end
70
+
71
+ it 'detects multiple dominant colors' do
72
+ # Mock execute_command to return histogram with 45% red, 40% blue, 5% gray (gray below threshold)
73
+ # Total = 10000: Red=4500 (45%), Blue=4000 (40%), Gray=500 (5%)
74
+ allow(processor).to receive(:execute_command).and_return(<<~HISTOGRAM)
75
+ 4500: (255,0,0) #FF0000 srgb(255,0,0)
76
+ 4000: (0,0,255) #0000FF srgb(0,0,255)
77
+ 500: (128,128,128) #808080 srgb(128,128,128)
78
+ HISTOGRAM
79
+
80
+ dominant_colors = processor.send(:analyze_cell_colors, '/cell.png')
81
+
82
+ expect(dominant_colors).to include('rgb(255,0,0)')
83
+ expect(dominant_colors).to include('rgb(0,0,255)')
84
+ expect(dominant_colors).not_to include('rgb(128,128,128)') # Below 15% threshold
85
+ expect(dominant_colors.size).to eq(2)
86
+ end
87
+
88
+ it 'returns nil when no dominant colors found' do
89
+ # Mock execute_command with 7 equal colors, all below 15% threshold
90
+ # Total = 7000: Each color = 1000/7000 = 14.3% < 15%
91
+ allow(processor).to receive(:execute_command).and_return(<<~HISTOGRAM)
92
+ 1000: (255,0,0) #FF0000 srgb(255,0,0)
93
+ 1000: (0,255,0) #00FF00 srgb(0,255,0)
94
+ 1000: (0,0,255) #0000FF srgb(0,0,255)
95
+ 1000: (128,128,128) #808080 srgb(128,128,128)
96
+ 1000: (255,255,0) #FFFF00 srgb(255,255,0)
97
+ 1000: (255,0,255) #FF00FF srgb(255,0,255)
98
+ 1000: (0,255,255) #00FFFF srgb(0,255,255)
99
+ HISTOGRAM
100
+
101
+ dominant_colors = processor.send(:analyze_cell_colors, '/cell.png')
102
+
103
+ expect(dominant_colors).to be_nil
104
+ end
105
+ end
106
+
107
+ describe '#extract_cell' do
108
+ let(:processor) { described_class.new }
109
+ let(:temp_dir) { '/temp/cleanup' }
110
+
111
+ it 'extracts cell using ImageMagick crop' do
112
+ # Mock Open3.capture3 to simulate ImageMagick execution
113
+ expect(Open3).to receive(:capture3) do |cmd|
114
+ # Matches 'magick' (Windows) or 'convert' (Unix)
115
+ expect(cmd).to match(/(magick|convert)/)
116
+ expect(cmd).to include('-crop')
117
+ expect(cmd).to include('160x240+320+240') # Width x Height + X + Y
118
+ expect(cmd).to include('+repage')
119
+ expect(cmd).to include('/spritesheet.png')
120
+ expect(cmd).to include('/temp/cleanup/cell_1_2.png')
121
+
122
+ ['', '', double(success?: true)]
123
+ end
124
+
125
+ cell_path = processor.send(:extract_cell, '/spritesheet.png', 1, 2, 160, 240, temp_dir)
126
+
127
+ expect(cell_path).to eq('/temp/cleanup/cell_1_2.png')
128
+ end
129
+
130
+ it 'raises error when ImageMagick fails' do
131
+ allow(Open3).to receive(:capture3).and_return(['', 'Error: invalid image', double(success?: false)])
132
+
133
+ expect {
134
+ processor.send(:extract_cell, '/spritesheet.png', 0, 0, 160, 240, temp_dir)
135
+ }.to raise_error(RubySpriter::ProcessingError, /Failed to extract cell/)
136
+ end
137
+ end
138
+
139
+ describe '#remove_dominant_colors' do
140
+ let(:processor) { described_class.new(gimp_path: '/path/to/gimp') }
141
+ let(:temp_dir) { '/temp/cleanup' }
142
+ let(:options) { { gimp_path: '/path/to/gimp' } }
143
+
144
+ it 'generates GIMP script and executes it to remove colors' do
145
+ cell_path = '/temp/cleanup/cell_0_0.png'
146
+ cleaned_path = '/temp/cleanup/cell_0_0_cleaned.png'
147
+ dominant_colors = ['rgb(255,0,0)', 'rgb(0,255,0)']
148
+
149
+ # Expect GIMP script generation
150
+ expect(RubySpriter::CellCleanupGimpScript).to receive(:generate_cleanup_script)
151
+ .with(cell_path, cleaned_path, dominant_colors)
152
+ .and_return('GIMP_SCRIPT_CONTENT')
153
+
154
+ # Expect GIMP execution with script content and output file
155
+ mock_gimp = double('GimpProcessor')
156
+ expect(processor.instance_variable_get(:@gimp_processor)).to receive(:execute_python_script)
157
+ .with('GIMP_SCRIPT_CONTENT', cleaned_path)
158
+ .and_return(true)
159
+
160
+ # Mock file validation
161
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!).with(cleaned_path)
162
+
163
+ result = processor.send(:remove_dominant_colors, cell_path, dominant_colors, options, temp_dir)
164
+
165
+ expect(result).to eq(cleaned_path)
166
+ end
167
+
168
+ it 'raises error if cleaned file not created' do
169
+ cell_path = '/temp/cleanup/cell_0_0.png'
170
+ cleaned_path = '/temp/cleanup/cell_0_0_cleaned.png'
171
+ dominant_colors = ['rgb(255,0,0)']
172
+
173
+ allow(RubySpriter::CellCleanupGimpScript).to receive(:generate_cleanup_script).and_return('SCRIPT')
174
+ # Mock execute_python_script to return false (indicating GIMP script failed)
175
+ allow(processor.instance_variable_get(:@gimp_processor)).to receive(:execute_python_script).and_return(false)
176
+
177
+ expect {
178
+ processor.send(:remove_dominant_colors, cell_path, dominant_colors, options, temp_dir)
179
+ }.to raise_error(RubySpriter::ProcessingError, /GIMP script failed/)
180
+ end
181
+ end
182
+
183
+ describe '#reassemble_spritesheet' do
184
+ let(:processor) { described_class.new }
185
+
186
+ it 'reassembles cells using ImageMagick montage' do
187
+ cell_paths = [
188
+ '/temp/cell_0_0.png',
189
+ '/temp/cell_0_1.png',
190
+ '/temp/cell_1_0.png',
191
+ '/temp/cell_1_1.png'
192
+ ]
193
+ output_path = '/spritesheet.png'
194
+
195
+ # Expect ImageMagick montage command
196
+ expect(Open3).to receive(:capture3) do |cmd|
197
+ # On Windows: 'magick montage', on Unix: just 'montage'
198
+ expect(cmd).to match(/(magick\s+montage|montage)/)
199
+ expect(cmd).to include('-tile')
200
+ expect(cmd).to include('2x2') # 2 columns × 2 rows
201
+ expect(cmd).to include('-geometry')
202
+ expect(cmd).to include('+0+0') # No gaps
203
+ expect(cmd).to include('-background')
204
+ expect(cmd).to include('none')
205
+ cell_paths.each { |path| expect(cmd).to include(path) }
206
+ expect(cmd).to include(output_path)
207
+
208
+ ['', '', double(success?: true)]
209
+ end
210
+
211
+ # Mock file validation
212
+ allow(RubySpriter::Utils::FileHelper).to receive(:validate_exists!).with(output_path)
213
+
214
+ processor.send(:reassemble_spritesheet, cell_paths, 2, 2, output_path)
215
+ end
216
+
217
+ it 'raises error when ImageMagick montage fails' do
218
+ cell_paths = ['/temp/cell_0_0.png']
219
+
220
+ allow(Open3).to receive(:capture3).and_return(['', 'Montage error', double(success?: false)])
221
+
222
+ expect {
223
+ processor.send(:reassemble_spritesheet, cell_paths, 1, 1, '/output.png')
224
+ }.to raise_error(RubySpriter::ProcessingError, /Failed to reassemble spritesheet/)
225
+ end
226
+ end
227
+
228
+ describe '#cleanup_cells (full workflow)' do
229
+ let(:processor) { described_class.new(cell_cleanup_threshold: 15.0, gimp_path: '/path/to/gimp') }
230
+ let(:options) { { columns: 4, frames: 16, cell_cleanup_threshold: 15.0, gimp_path: '/path/to/gimp' } }
231
+
232
+ it 'processes all cells and returns statistics' do
233
+ # Mock the methods we've already tested
234
+ allow(processor).to receive(:calculate_cell_dimensions)
235
+ .and_return({ width: 160, height: 240 })
236
+
237
+ # Mock cell extraction (will be implemented)
238
+ allow(processor).to receive(:extract_cell).and_return('/temp/cell.png')
239
+
240
+ # Mock color analysis - simulate 8 cells with dominant colors, 8 without
241
+ call_count = 0
242
+ allow(processor).to receive(:analyze_cell_colors) do
243
+ call_count += 1
244
+ call_count <= 8 ? ['rgb(0,0,0)', 'rgb(15,15,17)'] : nil
245
+ end
246
+
247
+ # Mock GIMP removal
248
+ allow(processor).to receive(:remove_dominant_colors).and_return('/temp/cell_cleaned.png')
249
+
250
+ # Mock reassembly
251
+ allow(processor).to receive(:reassemble_spritesheet)
252
+
253
+ stats = processor.cleanup_cells('/spritesheet.png', options)
254
+
255
+ expect(stats[:processed]).to eq(16)
256
+ expect(stats[:cleaned]).to eq(8)
257
+ expect(stats[:skipped]).to eq(8)
258
+ expect(stats[:colors_removed]).to eq(16) # 8 cells × 2 colors each
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubySpriter::GhostEdgeCleaner do
6
+ let(:config) do
7
+ double('InnerBgConfig',
8
+ multi_pass: true,
9
+ ghost_threshold: 30)
10
+ end
11
+
12
+ let(:input_image) { 'spec/fixtures/transparent_bg_sprite.png' }
13
+ let(:output_image) { 'spec/tmp/ghost_cleaned.png' }
14
+
15
+ before do
16
+ FileUtils.mkdir_p('spec/tmp')
17
+ end
18
+
19
+ after do
20
+ FileUtils.rm_f(output_image) if File.exist?(output_image)
21
+ end
22
+
23
+ describe '#initialize' do
24
+ it 'accepts input image, output image, and config' do
25
+ cleaner = described_class.new(input_image, output_image, config)
26
+ expect(cleaner).to be_a(RubySpriter::GhostEdgeCleaner)
27
+ end
28
+ end
29
+
30
+ describe '#process' do
31
+ subject { described_class.new(input_image, output_image, config) }
32
+
33
+ it 'creates an output image file' do
34
+ subject.process
35
+ expect(File.exist?(output_image)).to be true
36
+ end
37
+
38
+ it 'removes semi-transparent ghost pixels' do
39
+ subject.process
40
+ report = subject.report
41
+
42
+ expect(report[:ghost_pixels_detected]).to be_a(Integer)
43
+ expect(report[:ghost_pixels_detected]).to be >= 0
44
+ end
45
+
46
+ it 'preserves fully opaque pixels' do
47
+ subject.process
48
+
49
+ # Verify output file exists and has valid format
50
+ expect(File.exist?(output_image)).to be true
51
+
52
+ # Check file size is reasonable (not empty)
53
+ file_size = File.size(output_image)
54
+ expect(file_size).to be > 100 # At least 100 bytes
55
+ end
56
+
57
+ it 'uses the configured ghost_threshold' do
58
+ subject.process
59
+ report = subject.report
60
+
61
+ expect(report[:threshold_used]).to eq(30)
62
+ end
63
+ end
64
+
65
+ describe '#detect_ghost_pixels' do
66
+ subject { described_class.new(input_image, output_image, config) }
67
+
68
+ it 'identifies pixels with alpha below threshold' do
69
+ ghost_count = subject.detect_ghost_pixels
70
+
71
+ expect(ghost_count).to be_a(Integer)
72
+ expect(ghost_count).to be >= 0
73
+ end
74
+ end
75
+
76
+ describe '#clean_edges' do
77
+ subject { described_class.new(input_image, output_image, config) }
78
+
79
+ it 'removes pixels below alpha threshold' do
80
+ # Copy input to output first
81
+ FileUtils.cp(input_image, output_image)
82
+
83
+ subject.clean_edges
84
+
85
+ expect(File.exist?(output_image)).to be true
86
+ end
87
+
88
+ it 'preserves edge anti-aliasing for high-alpha pixels' do
89
+ FileUtils.cp(input_image, output_image)
90
+
91
+ subject.clean_edges
92
+
93
+ # Check that some semi-transparent pixels remain (anti-aliasing)
94
+ cmd = "magick \"#{output_image}\" -channel A -separate -format '%[fx:mean]' info:"
95
+ mean_alpha = `#{cmd}`.strip.to_f
96
+
97
+ # Should have some transparency (not all fully opaque)
98
+ expect(mean_alpha).to be < 1.0
99
+ end
100
+ end
101
+
102
+ describe '#multi_pass_cleanup' do
103
+ subject { described_class.new(input_image, output_image, config) }
104
+
105
+ it 'performs multiple cleanup passes' do
106
+ FileUtils.cp(input_image, output_image)
107
+
108
+ passes = subject.multi_pass_cleanup
109
+
110
+ expect(passes).to be >= 1
111
+ expect(passes).to be <= 3 # Default max passes
112
+ end
113
+
114
+ it 'stops when no more ghost pixels are detected' do
115
+ FileUtils.cp(input_image, output_image)
116
+
117
+ passes = subject.multi_pass_cleanup
118
+ report = subject.report
119
+
120
+ expect(report[:passes_performed]).to eq(passes)
121
+ end
122
+ end
123
+
124
+ describe '#report' do
125
+ subject { described_class.new(input_image, output_image, config) }
126
+
127
+ it 'generates a processing report' do
128
+ subject.process
129
+ report = subject.report
130
+
131
+ expect(report).to be_a(Hash)
132
+ expect(report).to have_key(:ghost_pixels_detected)
133
+ expect(report).to have_key(:threshold_used)
134
+ expect(report).to have_key(:passes_performed)
135
+ expect(report).to have_key(:processing_time)
136
+ end
137
+ end
138
+
139
+ describe 'threshold variations' do
140
+ context 'with low threshold (10)' do
141
+ let(:config) do
142
+ double('InnerBgConfig',
143
+ multi_pass: true,
144
+ ghost_threshold: 10)
145
+ end
146
+
147
+ subject { described_class.new(input_image, output_image, config) }
148
+
149
+ it 'removes more aggressive (keeps more pixels)' do
150
+ subject.process
151
+ report = subject.report
152
+
153
+ expect(report[:threshold_used]).to eq(10)
154
+ end
155
+ end
156
+
157
+ context 'with high threshold (50)' do
158
+ let(:config) do
159
+ double('InnerBgConfig',
160
+ multi_pass: true,
161
+ ghost_threshold: 50)
162
+ end
163
+
164
+ subject { described_class.new(input_image, output_image, config) }
165
+
166
+ it 'removes more pixels' do
167
+ subject.process
168
+ report = subject.report
169
+
170
+ expect(report[:threshold_used]).to eq(50)
171
+ end
172
+ end
173
+ end
174
+
175
+ describe 'performance' do
176
+ subject { described_class.new(input_image, output_image, config) }
177
+
178
+ it 'completes processing in reasonable time' do
179
+ start_time = Time.now
180
+ subject.process
181
+ elapsed = Time.now - start_time
182
+
183
+ # Should complete in under 10 seconds for small test image
184
+ expect(elapsed).to be < 10
185
+ end
186
+ end
187
+
188
+ describe 'quality preservation' do
189
+ subject { described_class.new(input_image, output_image, config) }
190
+
191
+ it 'maintains sprite RGB data integrity' do
192
+ subject.process
193
+
194
+ # Verify RGB channels are not degraded
195
+ # Use Platform helper for cross-platform compatibility
196
+ magick_cmd = RubySpriter::Platform.imagemagick_convert_cmd
197
+ cmd = "#{magick_cmd} \"#{output_image}\" -format '%[colorspace]' info:"
198
+ colorspace = `#{cmd}`.strip
199
+
200
+ # Skip if ImageMagick isn't working in CI (empty output)
201
+ skip "ImageMagick not available or command failed" if colorspace.empty?
202
+
203
+ expect(colorspace).to match(/sRGB|RGB/)
204
+ end
205
+
206
+ it 'preserves image dimensions' do
207
+ subject.process
208
+
209
+ # Get dimensions using Platform helper
210
+ magick_cmd = RubySpriter::Platform.imagemagick_convert_cmd
211
+ cmd = "#{magick_cmd} identify -format '%wx%h' \"#{output_image}\""
212
+ output_dims = `#{cmd}`.strip
213
+
214
+ cmd = "#{magick_cmd} identify -format '%wx%h' \"#{input_image}\""
215
+ input_dims = `#{cmd}`.strip
216
+
217
+ # Skip if ImageMagick isn't working in CI
218
+ skip "ImageMagick not available or command failed" if output_dims.empty? || input_dims.empty?
219
+
220
+ expect(output_dims).to eq(input_dims)
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+ require 'ruby_spriter/gimp_processor'
3
+ require 'ruby_spriter/platform'
4
+
5
+ RSpec.describe RubySpriter::GimpProcessor, 'single point selection (not 4 corners)' do
6
+ let(:gimp_path) { 'gimp-console' }
7
+ let(:input_file) { 'spec/fixtures/test_sprite.png' }
8
+ let(:output_file) { 'spec/tmp/output.png' }
9
+
10
+ describe '#generate_fuzzy_select_code' do
11
+ it 'selects from a single point, not multiple corners' do
12
+ options = {
13
+ remove_bg: true,
14
+ fuzzy_select: true,
15
+ threshold: 52.0
16
+ }
17
+
18
+ processor = described_class.new(gimp_path, options)
19
+ code = processor.send(:generate_fuzzy_select_code)
20
+
21
+ # Should NOT loop through multiple corners
22
+ expect(code).not_to include('for i, (x, y) in enumerate(corners):')
23
+ expect(code).not_to include('enumerate(corners)')
24
+
25
+ # Should NOT use ADD operation (only REPLACE)
26
+ expect(code).not_to include('Gimp.ChannelOps.ADD')
27
+
28
+ # Should use REPLACE operation for single selection
29
+ expect(code).to include('Gimp.ChannelOps.REPLACE')
30
+
31
+ # Should use x and y variables (defined in parent script)
32
+ expect(code).to include('float(x)')
33
+ expect(code).to include('float(y)')
34
+ end
35
+ end
36
+
37
+ describe '#generate_global_select_code' do
38
+ it 'selects from a single point, not multiple corners' do
39
+ options = {
40
+ remove_bg: true,
41
+ fuzzy_select: false,
42
+ threshold: 52.0
43
+ }
44
+
45
+ processor = described_class.new(gimp_path, options)
46
+ code = processor.send(:generate_global_select_code)
47
+
48
+ # Should NOT loop through multiple corners
49
+ expect(code).not_to include('for i, (x, y) in enumerate(corners):')
50
+
51
+ # Should NOT use ADD operation
52
+ expect(code).not_to include('Gimp.ChannelOps.ADD')
53
+
54
+ # Should use REPLACE operation
55
+ expect(code).to include('Gimp.ChannelOps.REPLACE')
56
+ end
57
+ end
58
+
59
+ describe '#generate_remove_bg_script' do
60
+ it 'does not define a corners array with 4 points' do
61
+ options = {
62
+ remove_bg: true,
63
+ fuzzy_select: true
64
+ }
65
+
66
+ processor = described_class.new(gimp_path, options)
67
+ script = processor.send(:generate_remove_bg_script, input_file, output_file)
68
+
69
+ # Should NOT define corners array with 4 points
70
+ expect(script).not_to include('(0, 0), # Top-left')
71
+ expect(script).not_to include('(w-1, 0), # Top-right')
72
+ expect(script).not_to include('(0, h-1), # Bottom-left')
73
+ expect(script).not_to include('(w-1, h-1) # Bottom-right')
74
+
75
+ # Should define a single sampling point
76
+ # Example: x = 5, y = 5 (interior point to avoid edge artifacts)
77
+ expect(script).to match(/x\s*=\s*\d+/)
78
+ expect(script).to match(/y\s*=\s*\d+/)
79
+ end
80
+ end
81
+ end