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,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RubySpriter
|
|
7
|
+
# SmokeDetector identifies and optionally removes smoke-like transparency gradients
|
|
8
|
+
# Detects alpha values between 20-80% in contiguous regions
|
|
9
|
+
class SmokeDetector
|
|
10
|
+
attr_reader :input_image, :output_image, :config
|
|
11
|
+
attr_reader :smoke_regions, :processing_time
|
|
12
|
+
|
|
13
|
+
# Smoke detection thresholds
|
|
14
|
+
MIN_ALPHA = 0.2 # 20% - minimum alpha for smoke
|
|
15
|
+
MAX_ALPHA = 0.8 # 80% - maximum alpha for smoke
|
|
16
|
+
MIN_AREA = 50 # Minimum contiguous area in pixels
|
|
17
|
+
|
|
18
|
+
def initialize(input_image, output_image, config)
|
|
19
|
+
@input_image = input_image
|
|
20
|
+
@output_image = output_image
|
|
21
|
+
@config = config
|
|
22
|
+
@smoke_regions = []
|
|
23
|
+
@processing_time = 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Main processing method
|
|
27
|
+
def process
|
|
28
|
+
start_time = Time.now
|
|
29
|
+
|
|
30
|
+
# Validate input file exists
|
|
31
|
+
unless File.exist?(@input_image)
|
|
32
|
+
warn "SmokeDetector: Input image does not exist: #{@input_image}"
|
|
33
|
+
@processing_time = Time.now - start_time
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Always detect smoke (for reporting)
|
|
38
|
+
@smoke_regions = detect
|
|
39
|
+
|
|
40
|
+
# Remove smoke if configured
|
|
41
|
+
if @config.remove_smoke
|
|
42
|
+
FileUtils.cp(@input_image, @output_image)
|
|
43
|
+
remove_smoke_regions(@smoke_regions)
|
|
44
|
+
else
|
|
45
|
+
# Just copy input to output
|
|
46
|
+
FileUtils.cp(@input_image, @output_image)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@processing_time = Time.now - start_time
|
|
50
|
+
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Detect smoke-like transparency gradients
|
|
55
|
+
def detect
|
|
56
|
+
regions = []
|
|
57
|
+
|
|
58
|
+
# Use ImageMagick to analyze alpha channel
|
|
59
|
+
# Find regions with alpha in the smoke range (20-80%)
|
|
60
|
+
regions = find_smoke_regions
|
|
61
|
+
|
|
62
|
+
# Filter by minimum area
|
|
63
|
+
regions.select { |r| r[:area] >= MIN_AREA }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Remove detected smoke regions
|
|
67
|
+
def remove_smoke_regions(regions)
|
|
68
|
+
return true if regions.empty?
|
|
69
|
+
|
|
70
|
+
# Apply smoke removal to the entire image
|
|
71
|
+
# Remove all pixels with alpha in the smoke range
|
|
72
|
+
remove_smoke_pixels
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check if alpha value is smoke-like
|
|
78
|
+
def is_smoke_like?(alpha)
|
|
79
|
+
alpha >= MIN_ALPHA && alpha <= MAX_ALPHA
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Generate detection report
|
|
83
|
+
def report
|
|
84
|
+
{
|
|
85
|
+
smoke_detected: @smoke_regions.length,
|
|
86
|
+
smoke_removed: @config.remove_smoke,
|
|
87
|
+
smoke_regions: @smoke_regions.map do |r|
|
|
88
|
+
{
|
|
89
|
+
x: r[:x],
|
|
90
|
+
y: r[:y],
|
|
91
|
+
area: r[:area],
|
|
92
|
+
alpha_range: r[:alpha_range]
|
|
93
|
+
}
|
|
94
|
+
end,
|
|
95
|
+
processing_time: @processing_time.round(3),
|
|
96
|
+
min_alpha_threshold: MIN_ALPHA,
|
|
97
|
+
max_alpha_threshold: MAX_ALPHA,
|
|
98
|
+
min_area_threshold: MIN_AREA
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def find_smoke_regions
|
|
105
|
+
regions = []
|
|
106
|
+
|
|
107
|
+
# Get image dimensions
|
|
108
|
+
cmd = "magick identify -format \"%w %h\" #{Utils::PathHelper.quote_path(@input_image)}"
|
|
109
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
110
|
+
|
|
111
|
+
return regions unless status.success?
|
|
112
|
+
|
|
113
|
+
width, height = stdout.strip.split.map(&:to_i)
|
|
114
|
+
return regions if width == 0 || height == 0
|
|
115
|
+
|
|
116
|
+
# Sample grid to find smoke-like regions
|
|
117
|
+
step = 20
|
|
118
|
+
visited = {}
|
|
119
|
+
|
|
120
|
+
(step...height).step(step) do |y|
|
|
121
|
+
(step...width).step(step) do |x|
|
|
122
|
+
next if visited["#{x},#{y}"]
|
|
123
|
+
|
|
124
|
+
alpha = get_alpha_at_point(x, y)
|
|
125
|
+
|
|
126
|
+
if alpha && is_smoke_like?(alpha)
|
|
127
|
+
# Found a smoke-like pixel, estimate region
|
|
128
|
+
area = estimate_smoke_area(x, y, visited)
|
|
129
|
+
|
|
130
|
+
if area >= MIN_AREA
|
|
131
|
+
regions << {
|
|
132
|
+
x: x,
|
|
133
|
+
y: y,
|
|
134
|
+
area: area,
|
|
135
|
+
alpha_range: [MIN_ALPHA, MAX_ALPHA]
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
regions
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def get_alpha_at_point(x, y)
|
|
146
|
+
cmd = "magick #{Utils::PathHelper.quote_path(@input_image)} " \
|
|
147
|
+
"-format \"%[pixel:p{#{x},#{y}}]\" info:"
|
|
148
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
149
|
+
|
|
150
|
+
return nil unless status.success?
|
|
151
|
+
|
|
152
|
+
# Parse alpha from output like "srgba(255,255,255,0.5)" or "gray(128,0.5)"
|
|
153
|
+
if stdout =~ /[a-z]+\([^)]+,([0-9.]+)\)/
|
|
154
|
+
$1.to_f
|
|
155
|
+
elsif stdout =~ /[a-z]+\([^)]+\)/ && stdout !~ /,/
|
|
156
|
+
# No alpha in output, fully opaque
|
|
157
|
+
1.0
|
|
158
|
+
else
|
|
159
|
+
# Try to extract from different format
|
|
160
|
+
1.0
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def estimate_smoke_area(x, y, visited)
|
|
165
|
+
# Simple flood fill estimation for smoke region
|
|
166
|
+
queue = [[x, y]]
|
|
167
|
+
area = 0
|
|
168
|
+
max_checks = 100 # Limit to prevent long processing
|
|
169
|
+
|
|
170
|
+
while !queue.empty? && area < max_checks
|
|
171
|
+
cx, cy = queue.shift
|
|
172
|
+
key = "#{cx},#{cy}"
|
|
173
|
+
|
|
174
|
+
next if visited[key]
|
|
175
|
+
|
|
176
|
+
alpha = get_alpha_at_point(cx, cy)
|
|
177
|
+
|
|
178
|
+
if alpha && is_smoke_like?(alpha)
|
|
179
|
+
visited[key] = true
|
|
180
|
+
area += 1
|
|
181
|
+
|
|
182
|
+
# Add neighbors (simplified 4-directional)
|
|
183
|
+
[[cx + 20, cy], [cx - 20, cy], [cx, cy + 20], [cx, cy - 20]].each do |nx, ny|
|
|
184
|
+
queue << [nx, ny] unless visited["#{nx},#{ny}"]
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
visited[key] = true
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Approximate actual area (we sampled every 20 pixels)
|
|
192
|
+
area * 400 # 20x20 = 400 pixels per sample
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def remove_smoke_pixels
|
|
196
|
+
# Use ImageMagick to remove pixels with alpha in smoke range
|
|
197
|
+
# Convert pixels with alpha 20-80% to fully transparent
|
|
198
|
+
|
|
199
|
+
min_threshold = (MIN_ALPHA * 100).to_i
|
|
200
|
+
max_threshold = (MAX_ALPHA * 100).to_i
|
|
201
|
+
|
|
202
|
+
# Use -fx to conditionally set alpha:
|
|
203
|
+
# if alpha is between MIN_ALPHA and MAX_ALPHA, set to 0 (transparent)
|
|
204
|
+
# otherwise keep original alpha
|
|
205
|
+
cmd = "magick #{Utils::PathHelper.quote_path(@output_image)} " \
|
|
206
|
+
"-define png:color-type=6 " \
|
|
207
|
+
"-alpha set " \
|
|
208
|
+
"-channel A " \
|
|
209
|
+
"-fx \"(u >= #{MIN_ALPHA} && u <= #{MAX_ALPHA}) ? 0 : u\" " \
|
|
210
|
+
"+channel " \
|
|
211
|
+
"#{Utils::PathHelper.quote_path(@output_image)}"
|
|
212
|
+
|
|
213
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
214
|
+
|
|
215
|
+
unless status.success?
|
|
216
|
+
warn "Failed to remove smoke pixels: #{stderr}"
|
|
217
|
+
return false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module RubySpriter
|
|
8
|
+
# ThresholdStepper applies multiple threshold-based background removal passes
|
|
9
|
+
# using GIMP Python-fu with edge-sampled background colors
|
|
10
|
+
class ThresholdStepper
|
|
11
|
+
attr_reader :input_file, :output_file, :background_palette, :gimp_processor, :options
|
|
12
|
+
|
|
13
|
+
def initialize(input_file, output_file, background_palette, gimp_processor, options = {})
|
|
14
|
+
@input_file = input_file
|
|
15
|
+
@output_file = output_file
|
|
16
|
+
@background_palette = background_palette
|
|
17
|
+
@gimp_processor = gimp_processor
|
|
18
|
+
@options = options
|
|
19
|
+
@threshold_values = parse_threshold_values(options[:threshold_values])
|
|
20
|
+
@threshold_timeout = options[:threshold_timeout] || 60
|
|
21
|
+
@total_timeout = options[:total_threshold_timeout] || 300
|
|
22
|
+
@thresholds_processed = 0
|
|
23
|
+
@skipped_thresholds = 0
|
|
24
|
+
@start_time = nil
|
|
25
|
+
@end_time = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def process
|
|
29
|
+
@start_time = Time.now
|
|
30
|
+
temp_results = []
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
Timeout.timeout(@total_timeout) do
|
|
34
|
+
@threshold_values.each_with_index do |threshold, index|
|
|
35
|
+
temp_output = File.join(Dir.tmpdir, "threshold_#{threshold}_#{Time.now.to_i}_#{index}.png")
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
Timeout.timeout(@threshold_timeout) do
|
|
39
|
+
script = generate_gimp_script(threshold, temp_output)
|
|
40
|
+
|
|
41
|
+
if @gimp_processor.execute_python_script(script, temp_output)
|
|
42
|
+
temp_results << temp_output
|
|
43
|
+
@thresholds_processed += 1
|
|
44
|
+
else
|
|
45
|
+
@skipped_thresholds += 1
|
|
46
|
+
log_debug "Threshold #{threshold} failed to process"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue Timeout::Error
|
|
50
|
+
@skipped_thresholds += 1
|
|
51
|
+
log_debug "Threshold #{threshold} timed out after #{@threshold_timeout}s"
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@skipped_thresholds += 1
|
|
54
|
+
log_debug "Threshold #{threshold} error: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue Timeout::Error
|
|
59
|
+
log_debug "Total threshold stepping timed out after #{@total_timeout}s"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@end_time = Time.now
|
|
63
|
+
|
|
64
|
+
# Composite all threshold results
|
|
65
|
+
if temp_results.any?
|
|
66
|
+
composite_results(temp_results)
|
|
67
|
+
else
|
|
68
|
+
# Fallback: copy input to output if no thresholds succeeded
|
|
69
|
+
FileUtils.cp(@input_file, @output_file)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Cleanup temp files
|
|
73
|
+
temp_results.each { |f| File.delete(f) if File.exist?(f) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def report
|
|
77
|
+
{
|
|
78
|
+
thresholds_processed: @thresholds_processed,
|
|
79
|
+
skipped_thresholds: @skipped_thresholds,
|
|
80
|
+
total_time: @end_time && @start_time ? (@end_time - @start_time).round(2) : 0
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def parse_threshold_values(custom_values)
|
|
87
|
+
if custom_values.is_a?(String)
|
|
88
|
+
custom_values.split(',').map(&:strip).map(&:to_f)
|
|
89
|
+
elsif custom_values.is_a?(Array)
|
|
90
|
+
custom_values.map(&:to_f)
|
|
91
|
+
else
|
|
92
|
+
# Default threshold values
|
|
93
|
+
[0.0, 0.5, 1.0, 3.0, 5.0, 10.0]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def generate_gimp_script(threshold, output_path)
|
|
98
|
+
# Build color list for GIMP script
|
|
99
|
+
color_definitions = @background_palette.map.with_index do |color, idx|
|
|
100
|
+
" color#{idx} = Gegl.Color.new('rgb(#{color[:r] / 255.0}, #{color[:g] / 255.0}, #{color[:b] / 255.0})')"
|
|
101
|
+
end.join("\n")
|
|
102
|
+
|
|
103
|
+
color_selections = @background_palette.map.with_index do |color, idx|
|
|
104
|
+
operation = idx == 0 ? 'Gimp.ChannelOps.REPLACE' : 'Gimp.ChannelOps.ADD'
|
|
105
|
+
<<~PYTHON.chomp
|
|
106
|
+
# Select color #{idx + 1}
|
|
107
|
+
config = select_proc.create_config()
|
|
108
|
+
config.set_property('image', img)
|
|
109
|
+
config.set_property('operation', #{operation})
|
|
110
|
+
config.set_property('drawable', layer)
|
|
111
|
+
config.set_property('color', color#{idx})
|
|
112
|
+
config.set_property('threshold', #{threshold})
|
|
113
|
+
select_proc.run(config)
|
|
114
|
+
PYTHON
|
|
115
|
+
end.join("\n\n")
|
|
116
|
+
|
|
117
|
+
<<~PYTHON
|
|
118
|
+
#!/usr/bin/env python3
|
|
119
|
+
import gi
|
|
120
|
+
gi.require_version('Gimp', '3.0')
|
|
121
|
+
gi.require_version('Gegl', '0.4')
|
|
122
|
+
from gi.repository import Gimp, GLib, Gio, Gegl
|
|
123
|
+
import sys
|
|
124
|
+
|
|
125
|
+
def threshold_step():
|
|
126
|
+
try:
|
|
127
|
+
Gegl.init(None)
|
|
128
|
+
|
|
129
|
+
# Load image
|
|
130
|
+
img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE,
|
|
131
|
+
Gio.File.new_for_path(r'#{@input_file}'))
|
|
132
|
+
|
|
133
|
+
if not img:
|
|
134
|
+
raise Exception("Failed to load image")
|
|
135
|
+
|
|
136
|
+
layers = img.get_layers()
|
|
137
|
+
if not layers:
|
|
138
|
+
raise Exception("No layers found in image")
|
|
139
|
+
|
|
140
|
+
layer = layers[0]
|
|
141
|
+
|
|
142
|
+
# Add alpha channel if needed
|
|
143
|
+
if not layer.has_alpha():
|
|
144
|
+
layer.add_alpha()
|
|
145
|
+
|
|
146
|
+
pdb = Gimp.get_pdb()
|
|
147
|
+
|
|
148
|
+
# Get select-color procedure
|
|
149
|
+
select_proc = pdb.lookup_procedure('gimp-image-select-color')
|
|
150
|
+
if not select_proc:
|
|
151
|
+
raise Exception("Could not find gimp-image-select-color procedure")
|
|
152
|
+
|
|
153
|
+
# Define background colors
|
|
154
|
+
#{color_definitions}
|
|
155
|
+
|
|
156
|
+
# Select all background colors with threshold
|
|
157
|
+
#{color_selections}
|
|
158
|
+
|
|
159
|
+
# Delete selection (make transparent)
|
|
160
|
+
edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
|
|
161
|
+
config = edit_clear.create_config()
|
|
162
|
+
config.set_property('drawable', layer)
|
|
163
|
+
edit_clear.run(config)
|
|
164
|
+
|
|
165
|
+
# Deselect
|
|
166
|
+
select_none = pdb.lookup_procedure('gimp-selection-none')
|
|
167
|
+
config = select_none.create_config()
|
|
168
|
+
config.set_property('image', img)
|
|
169
|
+
select_none.run(config)
|
|
170
|
+
|
|
171
|
+
# Export
|
|
172
|
+
export_proc = pdb.lookup_procedure('file-png-export')
|
|
173
|
+
config = export_proc.create_config()
|
|
174
|
+
config.set_property('image', img)
|
|
175
|
+
config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
|
|
176
|
+
export_proc.run(config)
|
|
177
|
+
|
|
178
|
+
print("SUCCESS - Threshold #{threshold} complete!")
|
|
179
|
+
return 0
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"ERROR: {e}")
|
|
182
|
+
import traceback
|
|
183
|
+
traceback.print_exc()
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
sys.exit(threshold_step())
|
|
187
|
+
PYTHON
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def composite_results(temp_files)
|
|
191
|
+
# Use ImageMagick to composite all threshold results
|
|
192
|
+
# Layer them with DstOver mode (later layers go behind earlier ones)
|
|
193
|
+
|
|
194
|
+
if temp_files.length == 1
|
|
195
|
+
# Only one result, just copy it
|
|
196
|
+
FileUtils.cp(temp_files.first, @output_file)
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Build composite command
|
|
201
|
+
# Start with the first result as base
|
|
202
|
+
cmd_parts = ['magick', Utils::PathHelper.quote_path(temp_files.first)]
|
|
203
|
+
|
|
204
|
+
# Add each subsequent result as a layer
|
|
205
|
+
temp_files[1..-1].each do |temp_file|
|
|
206
|
+
cmd_parts << Utils::PathHelper.quote_path(temp_file)
|
|
207
|
+
cmd_parts << '-compose' << 'DstOver' << '-composite'
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Output final result
|
|
211
|
+
cmd_parts << Utils::PathHelper.quote_path(@output_file)
|
|
212
|
+
|
|
213
|
+
cmd = cmd_parts.join(' ')
|
|
214
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
215
|
+
|
|
216
|
+
unless status.success?
|
|
217
|
+
raise ProcessingError, "Failed to composite threshold results: #{stderr}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def log_debug(message)
|
|
222
|
+
return unless @options[:debug]
|
|
223
|
+
|
|
224
|
+
Utils::OutputFormatter.indent("DEBUG: #{message}")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RubySpriter
|
|
4
|
-
module Utils
|
|
5
|
-
# File naming and size utilities
|
|
6
|
-
class FileHelper
|
|
7
|
-
class << self
|
|
8
|
-
# Generate spritesheet filename from video file
|
|
9
|
-
# @param video_file [String] Path to video file
|
|
10
|
-
# @return [String] Generated spritesheet filename
|
|
11
|
-
def spritesheet_filename(video_file)
|
|
12
|
-
dir = File.dirname(video_file)
|
|
13
|
-
basename = File.basename(video_file, '.*')
|
|
14
|
-
File.join(dir, "#{basename}_spritesheet.png")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Generate output filename with suffix
|
|
18
|
-
# @param input_file [String] Original input file
|
|
19
|
-
# @param suffix [String] Suffix to add to filename
|
|
20
|
-
# @return [String] Generated output filename
|
|
21
|
-
def output_filename(input_file, suffix)
|
|
22
|
-
dir = File.dirname(input_file)
|
|
23
|
-
basename = File.basename(input_file, '.*')
|
|
24
|
-
File.join(dir, "#{basename}-#{suffix}.png")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Format file size in human-readable format
|
|
28
|
-
# @param bytes [Integer] File size in bytes
|
|
29
|
-
# @return [String] Formatted file size
|
|
30
|
-
def format_size(bytes)
|
|
31
|
-
if bytes >= 1024 * 1024
|
|
32
|
-
"#{(bytes / (1024.0 * 1024.0)).round(2)} MB"
|
|
33
|
-
elsif bytes >= 1024
|
|
34
|
-
"#{(bytes / 1024.0).round(2)} KB"
|
|
35
|
-
else
|
|
36
|
-
"#{bytes} bytes"
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Validate file exists
|
|
41
|
-
# @param path [String] File path to validate
|
|
42
|
-
# @raise [ValidationError] if file doesn't exist
|
|
43
|
-
def validate_exists!(path)
|
|
44
|
-
raise ValidationError, "File not found: #{path}" unless File.exist?(path)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Validate file is readable
|
|
48
|
-
# @param path [String] File path to validate
|
|
49
|
-
# @raise [ValidationError] if file isn't readable
|
|
50
|
-
def validate_readable!(path)
|
|
51
|
-
validate_exists!(path)
|
|
52
|
-
raise ValidationError, "File not readable: #{path}" unless File.readable?(path)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Generate unique filename by adding timestamp if file exists
|
|
56
|
-
# @param path [String] Original file path
|
|
57
|
-
# @return [String] Unique file path (adds timestamp if original exists)
|
|
58
|
-
def unique_filename(path)
|
|
59
|
-
return path unless File.exist?(path)
|
|
60
|
-
|
|
61
|
-
dir = File.dirname(path)
|
|
62
|
-
ext = File.extname(path)
|
|
63
|
-
basename = File.basename(path, ext)
|
|
64
|
-
timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%3N') # Include milliseconds
|
|
65
|
-
|
|
66
|
-
File.join(dir, "#{basename}_#{timestamp}#{ext}")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Ensure output filename is unique based on overwrite option
|
|
70
|
-
# @param path [String] Desired output path
|
|
71
|
-
# @param overwrite [Boolean] If true, return original path; if false, make unique
|
|
72
|
-
# @return [String] Output path (unique if overwrite is false and file exists)
|
|
73
|
-
def ensure_unique_output(path, overwrite: false)
|
|
74
|
-
return path if overwrite
|
|
75
|
-
return path unless File.exist?(path)
|
|
76
|
-
|
|
77
|
-
unique_filename(path)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubySpriter
|
|
4
|
+
module Utils
|
|
5
|
+
# File naming and size utilities
|
|
6
|
+
class FileHelper
|
|
7
|
+
class << self
|
|
8
|
+
# Generate spritesheet filename from video file
|
|
9
|
+
# @param video_file [String] Path to video file
|
|
10
|
+
# @return [String] Generated spritesheet filename
|
|
11
|
+
def spritesheet_filename(video_file)
|
|
12
|
+
dir = File.dirname(video_file)
|
|
13
|
+
basename = File.basename(video_file, '.*')
|
|
14
|
+
File.join(dir, "#{basename}_spritesheet.png")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Generate output filename with suffix
|
|
18
|
+
# @param input_file [String] Original input file
|
|
19
|
+
# @param suffix [String] Suffix to add to filename
|
|
20
|
+
# @return [String] Generated output filename
|
|
21
|
+
def output_filename(input_file, suffix)
|
|
22
|
+
dir = File.dirname(input_file)
|
|
23
|
+
basename = File.basename(input_file, '.*')
|
|
24
|
+
File.join(dir, "#{basename}-#{suffix}.png")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Format file size in human-readable format
|
|
28
|
+
# @param bytes [Integer] File size in bytes
|
|
29
|
+
# @return [String] Formatted file size
|
|
30
|
+
def format_size(bytes)
|
|
31
|
+
if bytes >= 1024 * 1024
|
|
32
|
+
"#{(bytes / (1024.0 * 1024.0)).round(2)} MB"
|
|
33
|
+
elsif bytes >= 1024
|
|
34
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
35
|
+
else
|
|
36
|
+
"#{bytes} bytes"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate file exists
|
|
41
|
+
# @param path [String] File path to validate
|
|
42
|
+
# @raise [ValidationError] if file doesn't exist
|
|
43
|
+
def validate_exists!(path)
|
|
44
|
+
raise ValidationError, "File not found: #{path}" unless File.exist?(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Validate file is readable
|
|
48
|
+
# @param path [String] File path to validate
|
|
49
|
+
# @raise [ValidationError] if file isn't readable
|
|
50
|
+
def validate_readable!(path)
|
|
51
|
+
validate_exists!(path)
|
|
52
|
+
raise ValidationError, "File not readable: #{path}" unless File.readable?(path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate unique filename by adding timestamp if file exists
|
|
56
|
+
# @param path [String] Original file path
|
|
57
|
+
# @return [String] Unique file path (adds timestamp if original exists)
|
|
58
|
+
def unique_filename(path)
|
|
59
|
+
return path unless File.exist?(path)
|
|
60
|
+
|
|
61
|
+
dir = File.dirname(path)
|
|
62
|
+
ext = File.extname(path)
|
|
63
|
+
basename = File.basename(path, ext)
|
|
64
|
+
timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%3N') # Include milliseconds
|
|
65
|
+
|
|
66
|
+
File.join(dir, "#{basename}_#{timestamp}#{ext}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Ensure output filename is unique based on overwrite option
|
|
70
|
+
# @param path [String] Desired output path
|
|
71
|
+
# @param overwrite [Boolean] If true, return original path; if false, make unique
|
|
72
|
+
# @return [String] Output path (unique if overwrite is false and file exists)
|
|
73
|
+
def ensure_unique_output(path, overwrite: false)
|
|
74
|
+
return path if overwrite
|
|
75
|
+
return path unless File.exist?(path)
|
|
76
|
+
|
|
77
|
+
unique_filename(path)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module RubySpriter
|
|
2
|
+
module Utils
|
|
3
|
+
module ImageHelper
|
|
4
|
+
def self.get_dimensions(image_path)
|
|
5
|
+
# Execute ImageMagick to get image dimensions
|
|
6
|
+
cmd = "magick identify -format \"%wx%h\" #{PathHelper.quote_path(image_path)}"
|
|
7
|
+
stdout, _stderr, status = Open3.capture3(cmd)
|
|
8
|
+
|
|
9
|
+
raise ProcessingError, "Failed to get image dimensions: #{image_path}" unless status.success?
|
|
10
|
+
|
|
11
|
+
dimensions = stdout.strip.split('x')
|
|
12
|
+
{ width: dimensions[0].to_i, height: dimensions[1].to_i }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|