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.
- checksums.yaml +4 -4
- data/.rspec +3 -3
- data/CHANGELOG.md +1035 -405
- data/Gemfile +17 -17
- data/LICENSE +21 -21
- data/README.md +183 -902
- data/bin/ruby_spriter +20 -20
- data/lib/ruby_spriter/background_sampler.rb +140 -0
- data/lib/ruby_spriter/batch_processor.rb +268 -212
- data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
- data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
- data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
- data/lib/ruby_spriter/cli.rb +676 -612
- data/lib/ruby_spriter/compression_manager.rb +101 -101
- data/lib/ruby_spriter/consolidator.rb +179 -179
- data/lib/ruby_spriter/dependency_checker.rb +224 -174
- data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
- data/lib/ruby_spriter/gimp_processor.rb +1188 -667
- data/lib/ruby_spriter/metadata_manager.rb +117 -116
- data/lib/ruby_spriter/platform.rb +137 -82
- data/lib/ruby_spriter/processor.rb +1230 -886
- data/lib/ruby_spriter/smoke_detector.rb +223 -0
- data/lib/ruby_spriter/threshold_stepper.rb +227 -0
- data/lib/ruby_spriter/utils/file_helper.rb +82 -82
- data/lib/ruby_spriter/utils/image_helper.rb +16 -0
- data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
- data/lib/ruby_spriter/utils/path_helper.rb +59 -59
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
- data/lib/ruby_spriter/version.rb +6 -7
- data/lib/ruby_spriter/video_processor.rb +357 -139
- data/lib/ruby_spriter.rb +38 -34
- data/ruby_spriter.gemspec +44 -42
- data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
- data/spec/fixtures/complex_background_sprite.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
- data/spec/fixtures/has_inner_bg.png +0 -0
- data/spec/fixtures/has_small_inner_bg.png +0 -0
- data/spec/fixtures/smoke_effect_sprite.png +0 -0
- data/spec/fixtures/spritesheet_with_metadata.png +0 -0
- data/spec/fixtures/test_sprite.png +0 -0
- data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
- data/spec/fixtures/test_video_spritesheet.png +0 -0
- data/spec/fixtures/transparent_bg_sprite.png +0 -0
- data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
- data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
- data/spec/ruby_spriter/cli_spec.rb +2026 -1892
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
- data/spec/ruby_spriter/consolidator_spec.rb +538 -538
- data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
- data/spec/ruby_spriter/platform_spec.rb +92 -82
- data/spec/ruby_spriter/processor_spec.rb +911 -735
- data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
- data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
- data/spec/ruby_spriter/video_processor_spec.rb +346 -29
- data/spec/spec_helper.rb +41 -41
- data/spec/tmp/cli_test_output.png +0 -0
- data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
- data/spec/tmp/combined_test.png +0 -0
- data/spec/tmp/compat_test.png +0 -0
- data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
- data/spec/tmp/final_all_features.png +0 -0
- data/spec/tmp/final_test_all_features.png +0 -0
- data/spec/tmp/full_pipeline_test.png +0 -0
- data/spec/tmp/inner_test.png +0 -0
- data/spec/tmp/integration_test.png +0 -0
- data/spec/tmp/validation_test.png +0 -0
- data/spec/unit/background_sampler_spec.rb +132 -0
- data/spec/unit/cell_cleanup_config_spec.rb +32 -0
- data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
- data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
- data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
- data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
- data/spec/unit/smoke_detector_spec.rb +246 -0
- data/spec/unit/threshold_stepper_spec.rb +195 -0
- 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
|