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
@@ -1,667 +1,1188 @@
1
- # frozen_string_literal: true
2
-
3
- require 'open3'
4
- require 'tmpdir'
5
-
6
- module RubySpriter
7
- # Processes images with GIMP
8
- class GimpProcessor
9
- attr_reader :options, :gimp_path
10
-
11
- def initialize(gimp_path, options = {})
12
- @gimp_path = gimp_path
13
- @options = options
14
- end
15
-
16
- # Process image with GIMP operations
17
- # @param input_file [String] Path to input image
18
- # @return [String] Path to processed output file
19
- def process(input_file)
20
- Utils::FileHelper.validate_readable!(input_file)
21
-
22
- Utils::OutputFormatter.header("GIMP Processing")
23
-
24
- # Inform user if automatic operation order optimization is applied
25
- if options[:scale_percent] && options[:remove_bg] &&
26
- options[:operation_order] == :scale_then_remove_bg
27
- Utils::OutputFormatter.note("Auto-optimized: Removing background before scaling for better quality")
28
- end
29
-
30
- working_file = input_file
31
- operations = determine_operations
32
-
33
- # Execute operations in configured order
34
- operations.each do |operation|
35
- working_file = send(operation, working_file)
36
- end
37
-
38
- # Apply sharpening at the very end, after all GIMP operations
39
- if options[:sharpen]
40
- working_file = apply_sharpen_imagemagick(working_file)
41
- end
42
-
43
- working_file
44
- end
45
-
46
- private
47
-
48
- def determine_operations
49
- ops = []
50
-
51
- # Automatically use bg_first when both scaling and background removal are enabled
52
- # This produces cleaner results because:
53
- # - Background removal works better at higher resolution
54
- # - Scaling smooths out any rough edges from background removal
55
- auto_bg_first = options[:scale_percent] && options[:remove_bg] &&
56
- options[:operation_order] == :scale_then_remove_bg
57
-
58
- if options[:operation_order] == :remove_bg_then_scale || auto_bg_first
59
- ops << :remove_background if options[:remove_bg]
60
- ops << :scale_image if options[:scale_percent]
61
- else # :scale_then_remove_bg (when only one operation, or explicitly requested)
62
- ops << :scale_image if options[:scale_percent]
63
- ops << :remove_background if options[:remove_bg]
64
- end
65
-
66
- ops
67
- end
68
-
69
- def scale_image(input_file)
70
- percent = options[:scale_percent]
71
- desired_output = Utils::FileHelper.output_filename(input_file, "scaled-#{percent}pct")
72
- output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
73
-
74
- Utils::OutputFormatter.indent("Scaling to #{percent}%...")
75
-
76
- script = generate_scale_script(input_file, output_file, percent)
77
- execute_gimp_script(script, output_file, "Scale")
78
-
79
- # Preserve metadata from input file
80
- preserve_metadata(input_file, output_file)
81
-
82
- # Note: Sharpening is applied at the end in process() method
83
-
84
- output_file
85
- end
86
-
87
- def remove_background(input_file)
88
- method = options[:fuzzy_select] ? 'fuzzy' : 'global'
89
- desired_output = Utils::FileHelper.output_filename(input_file, "nobg-#{method}")
90
- output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
91
-
92
- Utils::OutputFormatter.indent("Removing background (#{method} select)...")
93
-
94
- script = generate_remove_bg_script(input_file, output_file)
95
- execute_gimp_script(script, output_file, "Background Removal")
96
-
97
- # Preserve metadata from input file
98
- preserve_metadata(input_file, output_file)
99
-
100
- output_file
101
- end
102
-
103
- def generate_scale_script(input_file, output_file, percent)
104
- input_path = Utils::PathHelper.normalize_for_python(input_file)
105
- output_path = Utils::PathHelper.normalize_for_python(output_file)
106
- interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
107
-
108
- # Get sharpen parameters with proper defaults
109
- sharpen_enabled = options[:sharpen] || false
110
- sharpen_radius = options[:sharpen_radius] || 3.0
111
- sharpen_amount = options[:sharpen_amount] || 0.5
112
- sharpen_threshold = options[:sharpen_threshold] || 0
113
-
114
- <<~PYTHON
115
- import sys
116
- import gc
117
- from gi.repository import Gimp, Gio, Gegl
118
-
119
- img = None
120
- layer = None
121
-
122
- try:
123
- print("Loading image...")
124
- img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
125
-
126
- w = img.get_width()
127
- h = img.get_height()
128
- print(f"Image size: {w}x{h}")
129
-
130
- layers = img.get_layers()
131
- if not layers or len(layers) == 0:
132
- raise Exception("No layers found")
133
- layer = layers[0]
134
-
135
- # Calculate new dimensions
136
- new_width = int(w * #{percent} / 100.0)
137
- new_height = int(h * #{percent} / 100.0)
138
- print(f"Scaling to: {new_width}x{new_height}")
139
- print(f"Interpolation: #{interpolation}")
140
-
141
- # Set interpolation method via context
142
- pdb = Gimp.get_pdb()
143
- context_set_interp = pdb.lookup_procedure('gimp-context-set-interpolation')
144
- if context_set_interp:
145
- config = context_set_interp.create_config()
146
- config.set_property('interpolation', #{interpolation})
147
- context_set_interp.run(config)
148
- print("Interpolation method set in context")
149
-
150
- # Scale layer using the context interpolation
151
- scale_proc = pdb.lookup_procedure('gimp-layer-scale')
152
- if scale_proc:
153
- config = scale_proc.create_config()
154
- config.set_property('layer', layer)
155
- config.set_property('new-width', new_width)
156
- config.set_property('new-height', new_height)
157
- config.set_property('local-origin', False)
158
- scale_proc.run(config)
159
- print("Layer scaled with interpolation")
160
-
161
- # Resize canvas to match layer
162
- img.resize(new_width, new_height, 0, 0)
163
- print("Canvas resized")
164
-
165
- # Only flatten if there are multiple layers AND no transparency is needed
166
- # Otherwise, preserve the alpha channel for transparent images
167
- layers = img.get_layers()
168
- if len(layers) > 1:
169
- # Merge down multiple layers while preserving alpha
170
- merge_proc = pdb.lookup_procedure('gimp-image-merge-visible-layers')
171
- if merge_proc:
172
- config = merge_proc.create_config()
173
- config.set_property('image', img)
174
- config.set_property('merge-type', Gimp.MergeType.EXPAND_AS_NECESSARY)
175
- merge_proc.run(config)
176
- print("Multiple layers merged (alpha preserved)")
177
- else:
178
- print("Single layer - no merge needed, alpha preserved")
179
-
180
- # Get the final layer
181
- layers = img.get_layers()
182
- final_layer = layers[0]
183
-
184
- # Note: Sharpening will be applied using ImageMagick after GIMP export
185
- # This is because GEGL operations in GIMP 3.x batch mode are unreliable
186
-
187
- # Export with alpha channel intact
188
- print("Exporting with alpha channel...")
189
- export_proc = pdb.lookup_procedure('file-png-export')
190
- if export_proc:
191
- config = export_proc.create_config()
192
- config.set_property('image', img)
193
- config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
194
- export_proc.run(config)
195
-
196
- print("SUCCESS - Image scaled!")
197
-
198
- except Exception as e:
199
- print(f"ERROR: {e}")
200
- import traceback
201
- traceback.print_exc()
202
- sys.exit(1)
203
- finally:
204
- # Explicit cleanup to minimize GEGL warnings
205
- try:
206
- if layer is not None:
207
- layer = None
208
- if img is not None:
209
- gc.collect() # Force garbage collection
210
- img.delete()
211
- img = None
212
- gc.collect() # Force again after deletion
213
- except Exception as cleanup_error:
214
- print(f"Cleanup warning: {cleanup_error}")
215
- pass
216
- PYTHON
217
- end
218
-
219
- def generate_remove_bg_script(input_file, output_file)
220
- input_path = Utils::PathHelper.normalize_for_python(input_file)
221
- output_path = Utils::PathHelper.normalize_for_python(output_file)
222
-
223
- use_fuzzy = options[:fuzzy_select]
224
-
225
- # Build the selection code block
226
- selection_code = if use_fuzzy
227
- generate_fuzzy_select_code
228
- else
229
- generate_global_select_code
230
- end
231
-
232
- # Build optional processing code
233
- grow_code = generate_grow_selection_code
234
- feather_code = generate_feather_selection_code
235
-
236
- <<~PYTHON
237
- import sys
238
- import gc
239
- from gi.repository import Gimp, Gio, Gegl
240
-
241
- img = None
242
- layer = None
243
-
244
- try:
245
- print("Loading image...")
246
- img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
247
-
248
- w = img.get_width()
249
- h = img.get_height()
250
- print(f"Image size: {w}x{h}")
251
-
252
- layers = img.get_layers()
253
- if not layers or len(layers) == 0:
254
- raise Exception("No layers found")
255
- layer = layers[0]
256
-
257
- # Add alpha channel if needed
258
- if not layer.has_alpha():
259
- layer.add_alpha()
260
- print("Added alpha channel")
261
-
262
- pdb = Gimp.get_pdb()
263
-
264
- # Sample all four corners
265
- corners = [
266
- (0, 0), # Top-left
267
- (w-1, 0), # Top-right
268
- (0, h-1), # Bottom-left
269
- (w-1, h-1) # Bottom-right
270
- ]
271
-
272
- print(f"Sampling {len(corners)} corners...")
273
-
274
- #{selection_code.split("\n").map { |line| " " + line }.join("\n")}
275
-
276
- print("Selection complete")
277
-
278
- #{grow_code.split("\n").map { |line| " " + line }.join("\n")}
279
-
280
- #{feather_code.split("\n").map { |line| " " + line }.join("\n")}
281
-
282
- # Delete selection (clear background)
283
- print("Removing background...")
284
- edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
285
- if edit_clear:
286
- config = edit_clear.create_config()
287
- config.set_property('drawable', layer)
288
- edit_clear.run(config)
289
- print("Background removed")
290
-
291
- # Deselect
292
- print("Deselecting...")
293
- select_none = pdb.lookup_procedure('gimp-selection-none')
294
- if select_none:
295
- config = select_none.create_config()
296
- config.set_property('image', img)
297
- select_none.run(config)
298
-
299
- # Export
300
- print("Exporting...")
301
- export_proc = pdb.lookup_procedure('file-png-export')
302
- if export_proc:
303
- config = export_proc.create_config()
304
- config.set_property('image', img)
305
- config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
306
- export_proc.run(config)
307
-
308
- print("SUCCESS - Background removed!")
309
-
310
- except Exception as e:
311
- print(f"ERROR: {e}")
312
- import traceback
313
- traceback.print_exc()
314
- sys.exit(1)
315
- finally:
316
- # Explicit cleanup to minimize GEGL warnings
317
- try:
318
- if layer is not None:
319
- layer = None
320
- if img is not None:
321
- gc.collect() # Force garbage collection
322
- img.delete()
323
- img = None
324
- gc.collect() # Force again after deletion
325
- except Exception as cleanup_error:
326
- print(f"Cleanup warning: {cleanup_error}")
327
- pass
328
- PYTHON
329
- end
330
-
331
- def generate_fuzzy_select_code
332
- <<~PYTHON.chomp
333
- # Fuzzy select (contiguous regions only)
334
- print("Using FUZZY SELECT (contiguous regions only)")
335
- select_proc = pdb.lookup_procedure('gimp-image-select-contiguous-color')
336
-
337
- if not select_proc:
338
- raise Exception("Could not find gimp-image-select-contiguous-color procedure")
339
-
340
- for i, (x, y) in enumerate(corners):
341
- print(f" Corner {i+1} at ({x}, {y})")
342
-
343
- config = select_proc.create_config()
344
- config.set_property('image', img)
345
- config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
346
- config.set_property('drawable', layer)
347
- config.set_property('x', float(x))
348
- config.set_property('y', float(y))
349
- select_proc.run(config)
350
- PYTHON
351
- end
352
-
353
- def generate_global_select_code
354
- <<~PYTHON.chomp
355
- # Global color select (all matching pixels)
356
- print("Using GLOBAL COLOR SELECT (all matching pixels)")
357
- select_proc = pdb.lookup_procedure('gimp-image-select-color')
358
-
359
- if not select_proc:
360
- raise Exception("Could not find gimp-image-select-color procedure")
361
-
362
- for i, (x, y) in enumerate(corners):
363
- print(f" Corner {i+1} at ({x}, {y})")
364
- color = layer.get_pixel(x, y)
365
-
366
- config = select_proc.create_config()
367
- config.set_property('image', img)
368
- config.set_property('operation', Gimp.ChannelOps.REPLACE if i == 0 else Gimp.ChannelOps.ADD)
369
- config.set_property('drawable', layer)
370
- config.set_property('color', color)
371
- select_proc.run(config)
372
- PYTHON
373
- end
374
-
375
- def generate_grow_selection_code
376
- grow = options[:grow_selection] || 1
377
- return "# No selection growth" if grow <= 0
378
-
379
- <<~PYTHON.chomp
380
- # Grow selection
381
- print(f"Growing selection by #{grow} pixels...")
382
- grow_proc = pdb.lookup_procedure('gimp-selection-grow')
383
- if grow_proc:
384
- config = grow_proc.create_config()
385
- config.set_property('image', img)
386
- config.set_property('steps', #{grow})
387
- grow_proc.run(config)
388
- print("Selection grown")
389
- PYTHON
390
- end
391
-
392
- def generate_feather_selection_code
393
- threshold = options[:bg_threshold] || 0.0
394
- return "# No feathering" if threshold <= 0
395
-
396
- <<~PYTHON.chomp
397
- # Feather selection
398
- print(f"Feathering selection by #{threshold} pixels...")
399
- feather_proc = pdb.lookup_procedure('gimp-selection-feather')
400
- if feather_proc:
401
- config = feather_proc.create_config()
402
- config.set_property('image', img)
403
- config.set_property('radius', #{threshold})
404
- feather_proc.run(config)
405
- print("Selection feathered")
406
- PYTHON
407
- end
408
-
409
- def execute_gimp_script(script_content, expected_output, operation_name)
410
- script_file = File.join(Dir.tmpdir, "gimp_script_#{Time.now.to_i}_#{rand(10000)}.py")
411
- log_file = File.join(Dir.tmpdir, "gimp_log_#{Time.now.to_i}_#{rand(10000)}.txt")
412
-
413
- begin
414
- File.write(script_file, script_content)
415
-
416
- if options[:debug]
417
- Utils::OutputFormatter.indent("DEBUG: Script file: #{script_file}")
418
- Utils::OutputFormatter.indent("DEBUG: Log file: #{log_file}")
419
- Utils::OutputFormatter.indent("DEBUG: Expected output: #{expected_output}")
420
- end
421
-
422
- # Build GIMP command based on platform
423
- if Platform.windows?
424
- execute_gimp_windows(script_file, log_file)
425
- else
426
- execute_gimp_unix(script_file, log_file)
427
- end
428
-
429
- gimp_output = ""
430
- if File.exist?(log_file)
431
- gimp_output = File.read(log_file)
432
- end
433
-
434
- # Filter GEGL warnings but keep actual errors and success messages
435
- filtered_output = filter_gimp_output(gimp_output)
436
-
437
- # Only show output if debug mode OR if there are actual messages (not just warnings)
438
- if options[:debug] && !filtered_output.strip.empty?
439
- Utils::OutputFormatter.indent("=== GIMP Output ===")
440
- filtered_output.lines.each do |line|
441
- Utils::OutputFormatter.indent(line.chomp)
442
- end
443
- Utils::OutputFormatter.indent("==================\n")
444
- elsif !options[:debug] && has_important_messages?(gimp_output)
445
- # Show important messages even without debug mode
446
- Utils::OutputFormatter.indent("=== GIMP Messages ===")
447
- filtered_output.lines.each do |line|
448
- Utils::OutputFormatter.indent(line.chomp)
449
- end
450
- Utils::OutputFormatter.indent("====================\n")
451
- end
452
-
453
- Utils::FileHelper.validate_exists!(expected_output)
454
-
455
- size = Utils::FileHelper.format_size(File.size(expected_output))
456
- Utils::OutputFormatter.success("#{operation_name} complete (#{size})\n")
457
-
458
- ensure
459
- cleanup_temp_files(script_file, log_file) unless options[:keep_temp]
460
- end
461
- end
462
-
463
- # Windows execution with GEGL warning suppression
464
- def execute_gimp_windows(script_file, log_file)
465
- batch_file = File.join(Dir.tmpdir, "gimp_run_#{Time.now.to_i}_#{rand(10000)}.bat")
466
-
467
- batch_content = <<~BATCH
468
- @echo off
469
- REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
470
- set GEGL_DEBUG=
471
- "#{gimp_path}" --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
472
- exit /b %errorlevel%
473
- BATCH
474
-
475
- File.write(batch_file, batch_content)
476
-
477
- if options[:debug]
478
- Utils::OutputFormatter.indent("DEBUG: Batch file: #{batch_file}")
479
- Utils::OutputFormatter.indent("DEBUG: Batch content:")
480
- batch_content.lines.each do |line|
481
- Utils::OutputFormatter.indent(" #{line.chomp}")
482
- end
483
- end
484
-
485
- # Use Open3.capture3 with cmd.exe wrapper - this is the v0.6 approach that works
486
- stdout, stderr, status = Open3.capture3("cmd.exe /c \"#{batch_file}\"")
487
-
488
- if options[:debug]
489
- Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
490
- Utils::OutputFormatter.indent("DEBUG: stdout: #{stdout}") unless stdout.strip.empty?
491
- Utils::OutputFormatter.indent("DEBUG: stderr: #{stderr}") unless stderr.strip.empty?
492
- end
493
-
494
- unless status.success?
495
- log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
496
- raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
497
- end
498
-
499
- # Clean up batch file
500
- File.delete(batch_file) if File.exist?(batch_file) && !options[:keep_temp]
501
- end
502
-
503
- # Unix execution (Linux/macOS)
504
- def execute_gimp_unix(script_file, log_file)
505
- cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
506
-
507
- if options[:debug]
508
- Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
509
- end
510
-
511
- stdout, stderr, status = Open3.capture3(cmd)
512
-
513
- if options[:debug]
514
- Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
515
- end
516
-
517
- unless status.success?
518
- log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
519
- raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
520
- end
521
- end
522
-
523
- # Filter out known GEGL/GIMP warnings that are cosmetic
524
- def filter_gimp_output(output)
525
- lines = output.lines.reject do |line|
526
- # Filter known GEGL buffer leak warnings (cosmetic in GIMP 3.x batch mode)
527
- line.match?(/GEGL-WARNING/) ||
528
- line.match?(/gegl_tile_cache_destroy/) ||
529
- line.match?(/runtime check failed/) ||
530
- line.match?(/To debug GeglBuffer leaks/) ||
531
- line.match?(/GEGL_DEBUG.*buffer-alloc/) ||
532
- line.match?(/GeglBuffers leaked/) ||
533
- line.match?(/EEEEeEeek!/) ||
534
- line.match?(/batch command executed successfully/) ||
535
- line.strip.empty?
536
- end
537
- lines.join
538
- end
539
-
540
- # Check if output has important messages beyond warnings
541
- def has_important_messages?(output)
542
- filtered = filter_gimp_output(output)
543
- # Has content other than SUCCESS messages
544
- filtered.strip.split("\n").any? { |line| !line.match?(/SUCCESS/) && !line.strip.empty? }
545
- end
546
-
547
- # Preserve metadata from input file to output file
548
- # GIMP strips metadata during export, so we need to copy it
549
- def preserve_metadata(input_file, output_file)
550
- # Read metadata from input file
551
- input_metadata = MetadataManager.read(input_file)
552
-
553
- return unless input_metadata # No metadata to preserve
554
-
555
- if options[:debug]
556
- Utils::OutputFormatter.indent("DEBUG: Preserving metadata from input file")
557
- Utils::OutputFormatter.indent(" Columns: #{input_metadata[:columns]}")
558
- Utils::OutputFormatter.indent(" Rows: #{input_metadata[:rows]}")
559
- Utils::OutputFormatter.indent(" Frames: #{input_metadata[:frames]}")
560
- end
561
-
562
- # Create temporary file for re-embedding metadata
563
- temp_file = output_file.sub('.png', '_temp_meta.png')
564
- File.rename(output_file, temp_file)
565
-
566
- # Re-embed metadata
567
- MetadataManager.embed(
568
- temp_file,
569
- output_file,
570
- columns: input_metadata[:columns],
571
- rows: input_metadata[:rows],
572
- frames: input_metadata[:frames],
573
- debug: options[:debug]
574
- )
575
-
576
- # Clean up temp file
577
- File.delete(temp_file) if File.exist?(temp_file)
578
-
579
- if options[:debug]
580
- Utils::OutputFormatter.indent("DEBUG: Metadata preserved in output file")
581
- end
582
- rescue StandardError => e
583
- # If metadata preservation fails, keep the file but warn
584
- if options[:debug]
585
- Utils::OutputFormatter.warning("Could not preserve metadata: #{e.message}")
586
- end
587
- # Restore original file if temp exists
588
- File.rename(temp_file, output_file) if defined?(temp_file) && File.exist?(temp_file) && !File.exist?(output_file)
589
- end
590
-
591
- def cleanup_temp_files(script_file, log_file)
592
- batch_file = script_file.sub('.py', '.bat').sub('gimp_script', 'gimp_run')
593
-
594
- [script_file, log_file, batch_file].each do |file|
595
- File.delete(file) if File.exist?(file)
596
- rescue StandardError => e
597
- puts "Warning: Could not delete temp file #{file}: #{e.message}" if options[:debug]
598
- end
599
- end
600
-
601
- # Map interpolation method names to GIMP interpolation type enum values
602
- def map_interpolation_method(method)
603
- # GIMP 3.x GimpInterpolationType enum values
604
- case method.to_s.downcase
605
- when 'none'
606
- 'Gimp.InterpolationType.NONE'
607
- when 'linear'
608
- 'Gimp.InterpolationType.LINEAR'
609
- when 'cubic'
610
- 'Gimp.InterpolationType.CUBIC'
611
- when 'nohalo'
612
- 'Gimp.InterpolationType.NOHALO'
613
- when 'lohalo'
614
- 'Gimp.InterpolationType.LOHALO'
615
- else
616
- 'Gimp.InterpolationType.NOHALO' # Default to NoHalo for quality
617
- end
618
- end
619
-
620
- # Apply unsharp mask using ImageMagick
621
- def apply_sharpen_imagemagick(input_file)
622
- radius = options[:sharpen_radius] || 2.0
623
- gain = options[:sharpen_gain] || 0.5
624
- threshold = options[:sharpen_threshold] || 0.03
625
-
626
- desired_output = Utils::FileHelper.output_filename(input_file, "sharpened")
627
- output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
628
-
629
- Utils::OutputFormatter.indent("Applying unsharp mask (ImageMagick)...")
630
- Utils::OutputFormatter.indent(" radius=#{radius}, gain=#{gain}, threshold=#{threshold}")
631
-
632
- magick_cmd = Platform.imagemagick_convert_cmd
633
-
634
- # Build ImageMagick unsharp command
635
- # Format: -unsharp {radius}x{sigma}+{gain}+{threshold}
636
- # sigma is typically radius * 0.5 for good results
637
- sigma = radius * 0.5
638
- unsharp_params = "#{radius}x#{sigma}+#{gain}+#{threshold}"
639
-
640
- cmd = [
641
- magick_cmd,
642
- Utils::PathHelper.quote_path(input_file),
643
- '-unsharp', unsharp_params,
644
- Utils::PathHelper.quote_path(output_file)
645
- ].join(' ')
646
-
647
- if options[:debug]
648
- Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
649
- end
650
-
651
- stdout, stderr, status = Open3.capture3(cmd)
652
-
653
- unless status.success?
654
- raise ProcessingError, "ImageMagick sharpen failed: #{stderr}"
655
- end
656
-
657
- Utils::FileHelper.validate_exists!(output_file)
658
-
659
- # Preserve metadata
660
- preserve_metadata(input_file, output_file)
661
-
662
- Utils::OutputFormatter.success("Sharpening complete")
663
-
664
- output_file
665
- end
666
- end
667
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tmpdir'
5
+
6
+ module RubySpriter
7
+ # Processes images with GIMP
8
+ class GimpProcessor
9
+ attr_reader :options, :gimp_path, :gimp_version
10
+
11
+ # Default background color tolerance for selection operations (0-100 scale)
12
+ DEFAULT_BG_THRESHOLD = 15.0
13
+
14
+ def initialize(gimp_path, options = {})
15
+ @gimp_path = gimp_path
16
+ @options = options
17
+ @gimp_version = options[:gimp_version] || { major: 3, minor: 0 } # Default to GIMP 3
18
+ end
19
+
20
+ # Process image with GIMP operations
21
+ # @param input_file [String] Path to input image
22
+ # @return [String] Path to processed output file
23
+ def process(input_file)
24
+ Utils::FileHelper.validate_readable!(input_file)
25
+
26
+ Utils::OutputFormatter.header("GIMP Processing")
27
+
28
+ # Inform about Xvfb usage on Linux
29
+ if Platform.linux?
30
+ Utils::OutputFormatter.note("Using GIMP via Xvfb (virtual display - no GUI windows)")
31
+ end
32
+
33
+ # Inform user if automatic operation order optimization is applied
34
+ if options[:scale_percent] && options[:remove_bg] &&
35
+ options[:operation_order] == :scale_then_remove_bg
36
+ Utils::OutputFormatter.note("Auto-optimized: Removing background before scaling for better quality")
37
+ end
38
+
39
+ working_file = input_file
40
+ operations = determine_operations
41
+
42
+ # Execute operations in configured order
43
+ operations.each do |operation|
44
+ working_file = send(operation, working_file)
45
+ end
46
+
47
+ # Apply sharpening at the very end, after all GIMP operations
48
+ if options[:sharpen]
49
+ working_file = apply_sharpen_imagemagick(working_file)
50
+ end
51
+
52
+ working_file
53
+ end
54
+
55
+ # Execute a Python script with GIMP (used by ThresholdStepper)
56
+ # @param script [String] The Python script content
57
+ # @param output_file [String] Expected output file path
58
+ # @return [Boolean] True if successful, false otherwise
59
+ def execute_python_script(script, output_file)
60
+ script_file = File.join(Dir.tmpdir, "gimp_threshold_#{Time.now.to_i}_#{rand(10_000)}.py")
61
+ log_file = File.join(Dir.tmpdir, "gimp_threshold_log_#{Time.now.to_i}_#{rand(10_000)}.txt")
62
+
63
+ begin
64
+ File.write(script_file, script)
65
+
66
+ if options[:debug]
67
+ Utils::OutputFormatter.indent("DEBUG: Threshold script: #{script_file}")
68
+ Utils::OutputFormatter.indent("DEBUG: Expected output: #{output_file}")
69
+ end
70
+
71
+ # Execute using existing platform-specific methods
72
+ if Platform.windows?
73
+ execute_gimp_windows(script_file, log_file)
74
+ else
75
+ execute_gimp_unix(script_file, log_file)
76
+ end
77
+
78
+ # Check if output was created
79
+ if File.exist?(output_file) && File.size(output_file).positive?
80
+ true
81
+ else
82
+ if options[:debug]
83
+ Utils::OutputFormatter.indent("WARNING: Threshold script did not produce output")
84
+ end
85
+ false
86
+ end
87
+ rescue StandardError => e
88
+ if options[:debug]
89
+ Utils::OutputFormatter.indent("ERROR in threshold script: #{e.message}")
90
+ end
91
+ false
92
+ ensure
93
+ cleanup_temp_files(script_file, log_file) unless options[:keep_temp]
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def gimp_major_version
100
+ @gimp_version[:major]
101
+ end
102
+
103
+ def gimp2?
104
+ gimp_major_version == 2
105
+ end
106
+
107
+ def gimp3?
108
+ gimp_major_version == 3
109
+ end
110
+
111
+ def determine_operations
112
+ ops = []
113
+
114
+ # Automatically use bg_first when both scaling and background removal are enabled
115
+ # This produces cleaner results because:
116
+ # - Background removal works better at higher resolution
117
+ # - Scaling smooths out any rough edges from background removal
118
+ auto_bg_first = options[:scale_percent] && options[:remove_bg] &&
119
+ options[:operation_order] == :scale_then_remove_bg
120
+
121
+ if options[:operation_order] == :remove_bg_then_scale || auto_bg_first
122
+ ops << :remove_background if options[:remove_bg]
123
+ ops << :scale_image if options[:scale_percent]
124
+ else # :scale_then_remove_bg (when only one operation, or explicitly requested)
125
+ ops << :scale_image if options[:scale_percent]
126
+ ops << :remove_background if options[:remove_bg]
127
+ end
128
+
129
+ ops
130
+ end
131
+
132
+ def scale_image(input_file)
133
+ percent = options[:scale_percent]
134
+ desired_output = Utils::FileHelper.output_filename(input_file, "scaled-#{percent}pct")
135
+ output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
136
+
137
+ Utils::OutputFormatter.indent("Scaling to #{percent}%...")
138
+
139
+ script = generate_scale_script(input_file, output_file, percent)
140
+ execute_gimp_script(script, output_file, "Scale")
141
+
142
+ # Preserve metadata from input file
143
+ preserve_metadata(input_file, output_file)
144
+
145
+ # Note: Sharpening is applied at the end in process() method
146
+
147
+ output_file
148
+ end
149
+
150
+ def remove_background(input_file)
151
+ method = options[:fuzzy_select] ? 'fuzzy' : 'global'
152
+ desired_output = Utils::FileHelper.output_filename(input_file, "nobg-#{method}")
153
+ output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
154
+
155
+ Utils::OutputFormatter.indent("Removing background (#{method} select)...")
156
+
157
+ # Collect background colors for --no-fuzzy mode (default)
158
+ background_colors = nil
159
+ if options[:remove_bg] && !options[:fuzzy_select]
160
+ Utils::OutputFormatter.indent("Sampling background colors for global selection...")
161
+
162
+ sample_offset = options[:bg_sample_offset] || 5
163
+ sample_count = options[:bg_sample_count] || 10
164
+ max_rows = 20
165
+
166
+ sampler = BackgroundSampler.new(input_file, sample_offset, sample_count, max_rows)
167
+ background_colors = sampler.collect_unique_colors
168
+
169
+ Utils::OutputFormatter.indent(" Collected #{background_colors.length} unique background colors")
170
+ end
171
+
172
+ script = generate_remove_bg_script(input_file, output_file, background_colors)
173
+ execute_gimp_script(script, output_file, "Background Removal")
174
+
175
+ # Preserve metadata from input file
176
+ preserve_metadata(input_file, output_file)
177
+
178
+ output_file
179
+ end
180
+
181
+ def generate_scale_script(input_file, output_file, percent)
182
+ if gimp2?
183
+ generate_scale_script_gimp2(input_file, output_file, percent)
184
+ else
185
+ generate_scale_script_gimp3(input_file, output_file, percent)
186
+ end
187
+ end
188
+
189
+ def generate_scale_script_gimp3(input_file, output_file, percent)
190
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
191
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
192
+ interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
193
+
194
+ # Get sharpen parameters with proper defaults
195
+ sharpen_enabled = options[:sharpen] || false
196
+ sharpen_radius = options[:sharpen_radius] || 3.0
197
+ sharpen_amount = options[:sharpen_amount] || 0.5
198
+ sharpen_threshold = options[:sharpen_threshold] || 0
199
+
200
+ <<~PYTHON
201
+ import sys
202
+ import gc
203
+ from gi.repository import Gimp, Gio, Gegl
204
+
205
+ img = None
206
+ layer = None
207
+
208
+ try:
209
+ print("Loading image...")
210
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
211
+
212
+ w = img.get_width()
213
+ h = img.get_height()
214
+ print(f"Image size: {w}x{h}")
215
+
216
+ layers = img.get_layers()
217
+ if not layers or len(layers) == 0:
218
+ raise Exception("No layers found")
219
+ layer = layers[0]
220
+
221
+ # Calculate new dimensions
222
+ new_width = int(w * #{percent} / 100.0)
223
+ new_height = int(h * #{percent} / 100.0)
224
+ print(f"Scaling to: {new_width}x{new_height}")
225
+ print(f"Interpolation: #{interpolation}")
226
+
227
+ # Set interpolation method via context
228
+ pdb = Gimp.get_pdb()
229
+ context_set_interp = pdb.lookup_procedure('gimp-context-set-interpolation')
230
+ if context_set_interp:
231
+ config = context_set_interp.create_config()
232
+ config.set_property('interpolation', #{interpolation})
233
+ context_set_interp.run(config)
234
+ print("Interpolation method set in context")
235
+
236
+ # Scale layer using the context interpolation
237
+ scale_proc = pdb.lookup_procedure('gimp-layer-scale')
238
+ if scale_proc:
239
+ config = scale_proc.create_config()
240
+ config.set_property('layer', layer)
241
+ config.set_property('new-width', new_width)
242
+ config.set_property('new-height', new_height)
243
+ config.set_property('local-origin', False)
244
+ scale_proc.run(config)
245
+ print("Layer scaled with interpolation")
246
+
247
+ # Resize canvas to match layer
248
+ img.resize(new_width, new_height, 0, 0)
249
+ print("Canvas resized")
250
+
251
+ # Only flatten if there are multiple layers AND no transparency is needed
252
+ # Otherwise, preserve the alpha channel for transparent images
253
+ layers = img.get_layers()
254
+ if len(layers) > 1:
255
+ # Merge down multiple layers while preserving alpha
256
+ merge_proc = pdb.lookup_procedure('gimp-image-merge-visible-layers')
257
+ if merge_proc:
258
+ config = merge_proc.create_config()
259
+ config.set_property('image', img)
260
+ config.set_property('merge-type', Gimp.MergeType.EXPAND_AS_NECESSARY)
261
+ merge_proc.run(config)
262
+ print("Multiple layers merged (alpha preserved)")
263
+ else:
264
+ print("Single layer - no merge needed, alpha preserved")
265
+
266
+ # Get the final layer
267
+ layers = img.get_layers()
268
+ final_layer = layers[0]
269
+
270
+ # Note: Sharpening will be applied using ImageMagick after GIMP export
271
+ # This is because GEGL operations in GIMP 3.x batch mode are unreliable
272
+
273
+ # Export with alpha channel intact
274
+ print("Exporting with alpha channel...")
275
+ export_proc = pdb.lookup_procedure('file-png-export')
276
+ if export_proc:
277
+ config = export_proc.create_config()
278
+ config.set_property('image', img)
279
+ config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
280
+ export_proc.run(config)
281
+
282
+ print("SUCCESS - Image scaled!")
283
+
284
+ except Exception as e:
285
+ print(f"ERROR: {e}")
286
+ import traceback
287
+ traceback.print_exc()
288
+ sys.exit(1)
289
+ finally:
290
+ # Explicit cleanup to minimize GEGL warnings
291
+ try:
292
+ if layer is not None:
293
+ layer = None
294
+ if img is not None:
295
+ gc.collect() # Force garbage collection
296
+ img.delete()
297
+ img = None
298
+ gc.collect() # Force again after deletion
299
+ except Exception as cleanup_error:
300
+ print(f"Cleanup warning: {cleanup_error}")
301
+ pass
302
+ PYTHON
303
+ end
304
+
305
+ def generate_scale_script_gimp2(input_file, output_file, percent)
306
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
307
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
308
+ interpolation = map_interpolation_method_gimp2(options[:scale_interpolation] || 'nohalo')
309
+
310
+ <<~PYTHON
311
+ from gimpfu import *
312
+ import sys
313
+
314
+ def scale_image():
315
+ try:
316
+ print "Loading image..."
317
+ img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
318
+
319
+ w = img.width
320
+ h = img.height
321
+ print "Image size: %dx%d" % (w, h)
322
+
323
+ if len(img.layers) == 0:
324
+ raise Exception("No layers found")
325
+ layer = img.layers[0]
326
+
327
+ # Calculate new dimensions
328
+ new_width = int(w * #{percent} / 100.0)
329
+ new_height = int(h * #{percent} / 100.0)
330
+ print "Scaling to: %dx%d" % (new_width, new_height)
331
+ print "Interpolation: #{interpolation}"
332
+
333
+ # Scale layer with interpolation
334
+ pdb.gimp_layer_scale(layer, new_width, new_height, False, #{interpolation})
335
+ print "Layer scaled with interpolation"
336
+
337
+ # Resize canvas to match layer
338
+ pdb.gimp_image_resize(img, new_width, new_height, 0, 0)
339
+ print "Canvas resized"
340
+
341
+ # Handle multiple layers while preserving alpha
342
+ if len(img.layers) > 1:
343
+ pdb.gimp_image_merge_visible_layers(img, EXPAND_AS_NECESSARY)
344
+ print "Multiple layers merged (alpha preserved)"
345
+ else:
346
+ print "Single layer - no merge needed, alpha preserved"
347
+
348
+ # Export with alpha channel intact
349
+ print "Exporting with alpha channel..."
350
+ pdb.file_png_save(img, img.layers[0], r'#{output_path}', r'#{output_path}',
351
+ 0, 9, 0, 0, 0, 0, 0)
352
+
353
+ print "SUCCESS - Image scaled!"
354
+
355
+ except Exception as e:
356
+ print "ERROR: %s" % str(e)
357
+ import traceback
358
+ traceback.print_exc()
359
+ sys.exit(1)
360
+
361
+ scale_image()
362
+ PYTHON
363
+ end
364
+
365
+ def generate_remove_bg_script(input_file, output_file, background_colors = nil)
366
+ if gimp2?
367
+ generate_remove_bg_script_gimp2(input_file, output_file, background_colors)
368
+ else
369
+ generate_remove_bg_script_gimp3(input_file, output_file, background_colors)
370
+ end
371
+ end
372
+
373
+ def generate_remove_bg_script_gimp3(input_file, output_file, background_colors = nil)
374
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
375
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
376
+
377
+ use_fuzzy = options[:fuzzy_select]
378
+
379
+ # Build the selection code block
380
+ selection_code = if use_fuzzy
381
+ generate_fuzzy_select_code
382
+ else
383
+ generate_global_select_code
384
+ end
385
+
386
+ # If background colors provided, use global select for inner backgrounds
387
+ if background_colors && !background_colors.empty?
388
+ selection_code << "\n" << generate_global_select_with_colors(background_colors)
389
+ end
390
+
391
+ # Build optional processing code
392
+ grow_code = generate_grow_selection_code
393
+ feather_code = generate_feather_selection_code
394
+
395
+ <<~PYTHON
396
+ import sys
397
+ import gc
398
+ from gi.repository import Gimp, Gio, Gegl
399
+
400
+ img = None
401
+ layer = None
402
+
403
+ try:
404
+ print("Loading image...")
405
+ img = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(r'#{input_path}'))
406
+
407
+ w = img.get_width()
408
+ h = img.get_height()
409
+ print(f"Image size: {w}x{h}")
410
+
411
+ layers = img.get_layers()
412
+ if not layers or len(layers) == 0:
413
+ raise Exception("No layers found")
414
+ layer = layers[0]
415
+
416
+ # Add alpha channel if needed
417
+ if not layer.has_alpha():
418
+ layer.add_alpha()
419
+ print("Added alpha channel")
420
+
421
+ pdb = Gimp.get_pdb()
422
+
423
+ # Sample from single interior point to avoid edge artifacts
424
+ x = 5
425
+ y = 5
426
+
427
+ #{selection_code.split("\n").map { |line| " " + line }.join("\n")}
428
+
429
+ print("Selection complete")
430
+
431
+ #{grow_code.split("\n").map { |line| " " + line }.join("\n")}
432
+
433
+ #{feather_code.split("\n").map { |line| " " + line }.join("\n")}
434
+
435
+ # Delete selection (clear background)
436
+ print("Removing background...")
437
+ edit_clear = pdb.lookup_procedure('gimp-drawable-edit-clear')
438
+ if edit_clear:
439
+ config = edit_clear.create_config()
440
+ config.set_property('drawable', layer)
441
+ edit_clear.run(config)
442
+ print("Background removed")
443
+
444
+ # Deselect
445
+ print("Deselecting...")
446
+ select_none = pdb.lookup_procedure('gimp-selection-none')
447
+ if select_none:
448
+ config = select_none.create_config()
449
+ config.set_property('image', img)
450
+ select_none.run(config)
451
+
452
+ # Export
453
+ print("Exporting...")
454
+ export_proc = pdb.lookup_procedure('file-png-export')
455
+ if export_proc:
456
+ config = export_proc.create_config()
457
+ config.set_property('image', img)
458
+ config.set_property('file', Gio.File.new_for_path(r'#{output_path}'))
459
+ export_proc.run(config)
460
+
461
+ print("SUCCESS - Background removed!")
462
+
463
+ except Exception as e:
464
+ print(f"ERROR: {e}")
465
+ import traceback
466
+ traceback.print_exc()
467
+ sys.exit(1)
468
+ finally:
469
+ # Explicit cleanup to minimize GEGL warnings
470
+ try:
471
+ if layer is not None:
472
+ layer = None
473
+ if img is not None:
474
+ gc.collect() # Force garbage collection
475
+ img.delete()
476
+ img = None
477
+ gc.collect() # Force again after deletion
478
+ except Exception as cleanup_error:
479
+ print(f"Cleanup warning: {cleanup_error}")
480
+ pass
481
+ PYTHON
482
+ end
483
+
484
+ def generate_remove_bg_script_gimp2(input_file, output_file, background_colors = nil)
485
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
486
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
487
+
488
+ use_fuzzy = options[:fuzzy_select]
489
+ grow = options[:grow_selection] || 1
490
+ feather = options[:feather_radius] || 0.0
491
+
492
+ # Build selection method
493
+ if use_fuzzy
494
+ select_method = "CHANNEL_OP_REPLACE" # First corner
495
+ select_add = "CHANNEL_OP_ADD" # Additional corners
496
+ select_call = "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)"
497
+ else
498
+ select_method = "CHANNEL_OP_REPLACE"
499
+ select_add = "CHANNEL_OP_ADD"
500
+ select_call = "pdb.gimp_image_select_color(img, select_op, layer, color)"
501
+ end
502
+
503
+ <<~PYTHON
504
+ from gimpfu import *
505
+ import sys
506
+
507
+ def remove_background():
508
+ try:
509
+ print "Loading image..."
510
+ img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
511
+
512
+ w = img.width
513
+ h = img.height
514
+ print "Image size: %dx%d" % (w, h)
515
+
516
+ if len(img.layers) == 0:
517
+ raise Exception("No layers found")
518
+ layer = img.layers[0]
519
+
520
+ # Add alpha channel if needed
521
+ if not pdb.gimp_layer_has_alpha(layer):
522
+ pdb.gimp_layer_add_alpha(layer)
523
+ print "Added alpha channel"
524
+
525
+ # Sample all four corners
526
+ corners = [
527
+ (0, 0), # Top-left
528
+ (w-1, 0), # Top-right
529
+ (0, h-1), # Bottom-left
530
+ (w-1, h-1) # Bottom-right
531
+ ]
532
+
533
+ print "Sampling %d corners..." % len(corners)
534
+ #{"print \"Using FUZZY SELECT (contiguous regions only)\"" if use_fuzzy}
535
+ #{"print \"Using GLOBAL COLOR SELECT (all matching pixels)\"" unless use_fuzzy}
536
+
537
+ for i, (x, y) in enumerate(corners):
538
+ print " Corner %d at (%d, %d)" % (i+1, x, y)
539
+ select_op = CHANNEL_OP_REPLACE if i == 0 else CHANNEL_OP_ADD
540
+ #{use_fuzzy ? "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)" : "color = pdb.gimp_image_get_pixel_color(img, layer, x, y)[1]\n pdb.gimp_image_select_color(img, select_op, layer, color)"}
541
+
542
+ print "Selection complete"
543
+
544
+ # Grow selection if configured
545
+ #{grow > 0 ? "print \"Growing selection by #{grow} pixels...\"\n pdb.gimp_selection_grow(img, #{grow})\n print \"Selection grown\"" : "# No selection growth"}
546
+
547
+ # Feather selection if configured
548
+ #{feather > 0 ? "print \"Feathering selection by #{feather} pixels...\"\n pdb.gimp_selection_feather(img, #{feather})\n print \"Selection feathered\"" : "# No feathering"}
549
+
550
+ # Delete selection (clear background)
551
+ print "Removing background..."
552
+ pdb.gimp_edit_clear(layer)
553
+ print "Background removed"
554
+
555
+ # Deselect
556
+ print "Deselecting..."
557
+ pdb.gimp_selection_none(img)
558
+
559
+ # Export
560
+ print "Exporting..."
561
+ pdb.file_png_save(img, layer, r'#{output_path}', r'#{output_path}',
562
+ 0, 9, 0, 0, 0, 0, 0)
563
+
564
+ print "SUCCESS - Background removed!"
565
+
566
+ except Exception as e:
567
+ print "ERROR: %s" % str(e)
568
+ import traceback
569
+ traceback.print_exc()
570
+ sys.exit(1)
571
+
572
+ remove_background()
573
+ PYTHON
574
+ end
575
+
576
+ def generate_fuzzy_select_code
577
+ # Use nil-coalescing to ensure default is applied when option is nil
578
+ threshold = options[:bg_threshold].nil? ? DEFAULT_BG_THRESHOLD : options[:bg_threshold]
579
+
580
+ <<~PYTHON.chomp
581
+ # Fuzzy select (contiguous regions only)
582
+ print("Using FUZZY SELECT (contiguous regions only)")
583
+ print(f"Threshold: #{threshold}")
584
+
585
+ # Set ALL context settings to match GUI defaults EXACTLY
586
+ Gimp.context_set_antialias(True)
587
+ Gimp.context_set_feather(False)
588
+ Gimp.context_set_sample_merged(False)
589
+ Gimp.context_set_sample_criterion(Gimp.SelectCriterion.COMPOSITE)
590
+ Gimp.context_set_sample_threshold_int(int(#{threshold}))
591
+ Gimp.context_set_sample_transparent(True)
592
+ Gimp.context_set_diagonal_neighbors(False)
593
+
594
+ select_proc = pdb.lookup_procedure('gimp-image-select-contiguous-color')
595
+
596
+ if not select_proc:
597
+ raise Exception("Could not find gimp-image-select-contiguous-color procedure")
598
+
599
+ print(f"Sampling background at ({x}, {y})")
600
+ config = select_proc.create_config()
601
+ config.set_property('image', img)
602
+ config.set_property('operation', Gimp.ChannelOps.REPLACE)
603
+ config.set_property('drawable', layer)
604
+ config.set_property('x', float(x))
605
+ config.set_property('y', float(y))
606
+ select_proc.run(config)
607
+ PYTHON
608
+ end
609
+
610
+ def generate_global_select_code
611
+ # Use nil-coalescing to ensure default is applied when option is nil
612
+ threshold = options[:bg_threshold].nil? ? DEFAULT_BG_THRESHOLD : options[:bg_threshold]
613
+
614
+ <<~PYTHON.chomp
615
+ # Global color select (all matching pixels)
616
+ print("Using GLOBAL COLOR SELECT (all matching pixels)")
617
+ print(f"Threshold: #{threshold}")
618
+
619
+ # Set ALL context settings to match GUI defaults EXACTLY
620
+ Gimp.context_set_antialias(True)
621
+ Gimp.context_set_feather(False)
622
+ Gimp.context_set_sample_merged(False)
623
+ Gimp.context_set_sample_criterion(Gimp.SelectCriterion.COMPOSITE)
624
+ Gimp.context_set_sample_threshold_int(int(#{threshold}))
625
+ Gimp.context_set_sample_transparent(True)
626
+
627
+ select_proc = pdb.lookup_procedure('gimp-image-select-color')
628
+
629
+ if not select_proc:
630
+ raise Exception("Could not find gimp-image-select-color procedure")
631
+
632
+ print(f"Sampling background at ({x}, {y})")
633
+ color = layer.get_pixel(x, y)
634
+ config = select_proc.create_config()
635
+ config.set_property('image', img)
636
+ config.set_property('operation', Gimp.ChannelOps.REPLACE)
637
+ config.set_property('drawable', layer)
638
+ config.set_property('color', color)
639
+ select_proc.run(config)
640
+ PYTHON
641
+ end
642
+
643
+ def generate_global_select_with_colors(background_colors)
644
+ # Convert Ruby array of hashes to Python list of dicts
645
+ colors_python = background_colors.map { |c| "{'r': #{c[:r]}, 'g': #{c[:g]}, 'b': #{c[:b]}}" }.join(', ')
646
+
647
+ <<~PYTHON.chomp
648
+ # Global color select for inner backgrounds
649
+ print("Selecting inner background colors...")
650
+ select_proc = pdb.lookup_procedure('gimp-image-select-color')
651
+
652
+ if not select_proc:
653
+ raise Exception("Could not find gimp-image-select-color procedure")
654
+
655
+ for i, bg_color in enumerate([#{colors_python}]):
656
+ print(f" Selecting color {i+1}: RGB({bg_color['r']}, {bg_color['g']}, {bg_color['b']})")
657
+
658
+ # Create Gegl.Color
659
+ color = Gegl.Color.new(f"rgb({bg_color['r']/255.0}, {bg_color['g']/255.0}, {bg_color['b']/255.0})")
660
+
661
+ config = select_proc.create_config()
662
+ config.set_property('image', img)
663
+ config.set_property('operation', Gimp.ChannelOps.ADD)
664
+ config.set_property('drawable', layer)
665
+ config.set_property('color', color)
666
+ select_proc.run(config)
667
+
668
+ print("Inner background colors selected")
669
+ PYTHON
670
+ end
671
+
672
+ def generate_grow_selection_code
673
+ grow = options[:grow_selection].nil? ? 0 : options[:grow_selection] # DEFAULT TO 0!
674
+ return "# No selection growth" if grow <= 0
675
+
676
+ <<~PYTHON.chomp
677
+ # Grow selection
678
+ print(f"Growing selection by #{grow} pixels...")
679
+ grow_proc = pdb.lookup_procedure('gimp-selection-grow')
680
+ if grow_proc:
681
+ config = grow_proc.create_config()
682
+ config.set_property('image', img)
683
+ config.set_property('steps', #{grow})
684
+ grow_proc.run(config)
685
+ print("Selection grown")
686
+ PYTHON
687
+ end
688
+
689
+ def generate_feather_selection_code
690
+ feather_radius = options[:feather_radius] || 0.0
691
+
692
+ if feather_radius > 0
693
+ # Set feathering via context
694
+ <<~PYTHON.chomp
695
+ # Feather selection
696
+ print(f"Feathering selection by #{feather_radius} pixels...")
697
+ Gimp.context_set_feather(True)
698
+ Gimp.context_set_feather_radius(#{feather_radius})
699
+
700
+ feather_proc = pdb.lookup_procedure('gimp-selection-feather')
701
+ if feather_proc:
702
+ config = feather_proc.create_config()
703
+ config.set_property('image', img)
704
+ config.set_property('radius', #{feather_radius})
705
+ feather_proc.run(config)
706
+ print("Selection feathered")
707
+ PYTHON
708
+ else
709
+ "# No feathering"
710
+ end
711
+ end
712
+
713
+ def execute_gimp_script(script_content, expected_output, operation_name)
714
+ script_file = File.join(Dir.tmpdir, "gimp_script_#{Time.now.to_i}_#{rand(10000)}.py")
715
+ log_file = File.join(Dir.tmpdir, "gimp_log_#{Time.now.to_i}_#{rand(10000)}.txt")
716
+
717
+ begin
718
+ File.write(script_file, script_content)
719
+
720
+ if options[:debug]
721
+ Utils::OutputFormatter.indent("DEBUG: Script file: #{script_file}")
722
+ Utils::OutputFormatter.indent("DEBUG: Log file: #{log_file}")
723
+ Utils::OutputFormatter.indent("DEBUG: Expected output: #{expected_output}")
724
+ end
725
+
726
+ # Build GIMP command based on platform
727
+ if Platform.windows?
728
+ execute_gimp_windows(script_file, log_file)
729
+ else
730
+ execute_gimp_unix(script_file, log_file)
731
+ end
732
+
733
+ gimp_output = ""
734
+ if File.exist?(log_file)
735
+ gimp_output = File.read(log_file)
736
+ end
737
+
738
+ # Filter GEGL warnings but keep actual errors and success messages
739
+ filtered_output = filter_gimp_output(gimp_output)
740
+
741
+ # Only show output if debug mode OR if there are actual messages (not just warnings)
742
+ if options[:debug] && !filtered_output.strip.empty?
743
+ Utils::OutputFormatter.indent("=== GIMP Output ===")
744
+ filtered_output.lines.each do |line|
745
+ Utils::OutputFormatter.indent(line.chomp)
746
+ end
747
+ Utils::OutputFormatter.indent("==================\n")
748
+ elsif !options[:debug] && has_important_messages?(gimp_output)
749
+ # Show important messages even without debug mode
750
+ Utils::OutputFormatter.indent("=== GIMP Messages ===")
751
+ filtered_output.lines.each do |line|
752
+ Utils::OutputFormatter.indent(line.chomp)
753
+ end
754
+ Utils::OutputFormatter.indent("====================\n")
755
+ end
756
+
757
+ Utils::FileHelper.validate_exists!(expected_output)
758
+
759
+ size = Utils::FileHelper.format_size(File.size(expected_output))
760
+ Utils::OutputFormatter.success("#{operation_name} complete (#{size})\n")
761
+
762
+ ensure
763
+ cleanup_temp_files(script_file, log_file) unless options[:keep_temp]
764
+ end
765
+ end
766
+
767
+ # Windows execution with GEGL warning suppression
768
+ def execute_gimp_windows(script_file, log_file)
769
+ batch_file = File.join(Dir.tmpdir, "gimp_run_#{Time.now.to_i}_#{rand(10000)}.bat")
770
+
771
+ batch_content = <<~BATCH
772
+ @echo off
773
+ REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
774
+ set GEGL_DEBUG=
775
+ "#{gimp_path}" --no-splash --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
776
+ exit /b %errorlevel%
777
+ BATCH
778
+
779
+ File.write(batch_file, batch_content)
780
+
781
+ if options[:debug]
782
+ Utils::OutputFormatter.indent("DEBUG: Batch file: #{batch_file}")
783
+ Utils::OutputFormatter.indent("DEBUG: Batch content:")
784
+ batch_content.lines.each do |line|
785
+ Utils::OutputFormatter.indent(" #{line.chomp}")
786
+ end
787
+ end
788
+
789
+ # Use Open3.capture3 with cmd.exe wrapper - this is the v0.6 approach that works
790
+ stdout, stderr, status = Open3.capture3("cmd.exe /c \"#{batch_file}\"")
791
+
792
+ if options[:debug]
793
+ Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
794
+ Utils::OutputFormatter.indent("DEBUG: stdout: #{stdout}") unless stdout.strip.empty?
795
+ Utils::OutputFormatter.indent("DEBUG: stderr: #{stderr}") unless stderr.strip.empty?
796
+ end
797
+
798
+ unless status.success?
799
+ log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
800
+ raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
801
+ end
802
+
803
+ # Clean up batch file
804
+ File.delete(batch_file) if File.exist?(batch_file) && !options[:keep_temp]
805
+ end
806
+
807
+ # Unix execution (Linux/macOS)
808
+ def execute_gimp_unix(script_file, log_file)
809
+ # On Linux, always use xvfb-run for headless operation (prevents GUI windows)
810
+ # On macOS, run GIMP directly
811
+ use_xvfb = Platform.linux?
812
+ is_flatpak = gimp_path.start_with?('flatpak:')
813
+
814
+ if gimp2?
815
+ # GIMP 2.x: Use gimp-console for batch processing
816
+ gimp_console_path = gimp_path.sub('/gimp', '/gimp-console')
817
+ cmd = "#{Utils::PathHelper.quote_path(gimp_console_path)} -i --no-splash --batch-interpreter python-fu-eval -b 'exec(open(\"#{script_file}\").read())' -b '(gimp-quit 0)' > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
818
+ else
819
+ # GIMP 3.x command
820
+ if is_flatpak
821
+ # Flatpak GIMP with xvfb-run for headless operation
822
+ # Use --nosocket options to prevent Flatpak from accessing host display
823
+ flatpak_app = gimp_path.sub('flatpak:', '')
824
+ if use_xvfb
825
+ cmd = "env -u DISPLAY xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' flatpak run --nosocket=x11 --nosocket=wayland #{flatpak_app} --no-interface --console-messages --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
826
+ else
827
+ cmd = "flatpak run --nosocket=x11 --nosocket=wayland #{flatpak_app} --no-interface --console-messages --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
828
+ end
829
+ else
830
+ # Native GIMP 3.x installation
831
+ if use_xvfb
832
+ # On Linux, unset DISPLAY and wrap with xvfb-run to prevent GUI windows
833
+ cmd = "env -u DISPLAY xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' #{Utils::PathHelper.quote_path(gimp_path)} --no-interface --console-messages --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
834
+ else
835
+ # On macOS, run GIMP directly
836
+ cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --no-interface --console-messages --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
837
+ end
838
+ end
839
+ end
840
+
841
+ if options[:debug]
842
+ Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
843
+ end
844
+
845
+ stdout, stderr, status = Open3.capture3(cmd)
846
+
847
+ if options[:debug]
848
+ Utils::OutputFormatter.indent("DEBUG: Command exit status: #{status.exitstatus}")
849
+ end
850
+
851
+ unless status.success?
852
+ log_content = File.exist?(log_file) ? File.read(log_file) : "No log file created"
853
+ raise ProcessingError, "GIMP processing failed (exit code: #{status.exitstatus})\n#{log_content}"
854
+ end
855
+ end
856
+
857
+ # Filter out known GEGL/GIMP warnings that are cosmetic
858
+ def filter_gimp_output(output)
859
+ lines = output.lines.reject do |line|
860
+ # Filter known GEGL buffer leak warnings (cosmetic in GIMP 3.x batch mode)
861
+ line.match?(/GEGL-WARNING/) ||
862
+ line.match?(/gegl_tile_cache_destroy/) ||
863
+ line.match?(/runtime check failed/) ||
864
+ line.match?(/To debug GeglBuffer leaks/) ||
865
+ line.match?(/GEGL_DEBUG.*buffer-alloc/) ||
866
+ line.match?(/GeglBuffers leaked/) ||
867
+ line.match?(/EEEEeEeek!/) ||
868
+ line.match?(/batch command executed successfully/) ||
869
+ # Filter Linux/Wayland/Flatpak cosmetic warnings
870
+ line.match?(/Gdk-WARNING.*Failed to read portal settings/) ||
871
+ line.match?(/set device.*to mode: disabled/) ||
872
+ line.match?(/Gdk-WARNING.*Server is missing xdg_foreign support/) ||
873
+ line.match?(/gimp_widget_set_handle_on_mapped.*gdk_wayland_window_export_handle/) ||
874
+ line.match?(/It will not be possible to set windows in other processes/) ||
875
+ line.match?(/LibGimp-WARNING.*gimp_flush.*Broken pipe/) ||
876
+ line.match?(/Gimp-Core-WARNING.*gimp_finalize.*list of contexts not empty/) ||
877
+ line.match?(/stale context:/) ||
878
+ line.match?(/F: X11 socket.*does not exist in filesystem/) ||
879
+ line.strip.empty?
880
+ end
881
+ lines.join
882
+ end
883
+
884
+ # Check if output has important messages beyond warnings
885
+ def has_important_messages?(output)
886
+ filtered = filter_gimp_output(output)
887
+ # Has content other than SUCCESS messages
888
+ filtered.strip.split("\n").any? { |line| !line.match?(/SUCCESS/) && !line.strip.empty? }
889
+ end
890
+
891
+ # Preserve metadata from input file to output file
892
+ # GIMP strips metadata during export, so we need to copy it
893
+ def preserve_metadata(input_file, output_file)
894
+ # Read metadata from input file
895
+ input_metadata = MetadataManager.read(input_file)
896
+
897
+ return unless input_metadata # No metadata to preserve
898
+
899
+ if options[:debug]
900
+ Utils::OutputFormatter.indent("DEBUG: Preserving metadata from input file")
901
+ Utils::OutputFormatter.indent(" Columns: #{input_metadata[:columns]}")
902
+ Utils::OutputFormatter.indent(" Rows: #{input_metadata[:rows]}")
903
+ Utils::OutputFormatter.indent(" Frames: #{input_metadata[:frames]}")
904
+ end
905
+
906
+ # Create temporary file for re-embedding metadata
907
+ temp_file = output_file.sub('.png', '_temp_meta.png')
908
+ File.rename(output_file, temp_file)
909
+
910
+ # Re-embed metadata
911
+ MetadataManager.embed(
912
+ temp_file,
913
+ output_file,
914
+ columns: input_metadata[:columns],
915
+ rows: input_metadata[:rows],
916
+ frames: input_metadata[:frames],
917
+ debug: options[:debug]
918
+ )
919
+
920
+ # Clean up temp file
921
+ File.delete(temp_file) if File.exist?(temp_file)
922
+
923
+ if options[:debug]
924
+ Utils::OutputFormatter.indent("DEBUG: Metadata preserved in output file")
925
+ end
926
+ rescue StandardError => e
927
+ # If metadata preservation fails, keep the file but warn
928
+ if options[:debug]
929
+ Utils::OutputFormatter.warning("Could not preserve metadata: #{e.message}")
930
+ end
931
+ # Restore original file if temp exists
932
+ File.rename(temp_file, output_file) if defined?(temp_file) && File.exist?(temp_file) && !File.exist?(output_file)
933
+ end
934
+
935
+ def cleanup_temp_files(script_file, log_file)
936
+ batch_file = script_file.sub('.py', '.bat').sub('gimp_script', 'gimp_run')
937
+
938
+ [script_file, log_file, batch_file].each do |file|
939
+ File.delete(file) if File.exist?(file)
940
+ rescue StandardError => e
941
+ puts "Warning: Could not delete temp file #{file}: #{e.message}" if options[:debug]
942
+ end
943
+ end
944
+
945
+ # Map interpolation method names to GIMP 3.x interpolation type enum values
946
+ def map_interpolation_method(method)
947
+ # GIMP 3.x GimpInterpolationType enum values
948
+ case method.to_s.downcase
949
+ when 'none'
950
+ 'Gimp.InterpolationType.NONE'
951
+ when 'linear'
952
+ 'Gimp.InterpolationType.LINEAR'
953
+ when 'cubic'
954
+ 'Gimp.InterpolationType.CUBIC'
955
+ when 'nohalo'
956
+ 'Gimp.InterpolationType.NOHALO'
957
+ when 'lohalo'
958
+ 'Gimp.InterpolationType.LOHALO'
959
+ else
960
+ 'Gimp.InterpolationType.NOHALO' # Default to NoHalo for quality
961
+ end
962
+ end
963
+
964
+ # Map interpolation method names to GIMP 2.x interpolation type constants
965
+ def map_interpolation_method_gimp2(method)
966
+ # GIMP 2.x interpolation constants
967
+ case method.to_s.downcase
968
+ when 'none'
969
+ 'INTERPOLATION_NONE'
970
+ when 'linear'
971
+ 'INTERPOLATION_LINEAR'
972
+ when 'cubic'
973
+ 'INTERPOLATION_CUBIC'
974
+ when 'nohalo'
975
+ 'INTERPOLATION_NOHALO'
976
+ when 'lohalo'
977
+ 'INTERPOLATION_LOHALO'
978
+ else
979
+ 'INTERPOLATION_NOHALO' # Default to NoHalo for quality
980
+ end
981
+ end
982
+
983
+ # Remove background using ImageMagick (fallback for Linux)
984
+ # Uses edge detection and multiple techniques for better results
985
+ def remove_background_imagemagick(input_file, output_file)
986
+ magick_cmd = Platform.imagemagick_convert_cmd
987
+ identify_cmd = Platform.imagemagick_identify_cmd
988
+
989
+ # Get options
990
+ use_fuzzy = options[:fuzzy_select]
991
+ fuzz_percent = options[:bg_threshold] || 15.0
992
+ grow = options[:grow_selection] || 1
993
+
994
+ # Get image dimensions
995
+ stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%w %h' #{Utils::PathHelper.quote_path(input_file)}")
996
+ unless status.success?
997
+ raise ProcessingError, "Could not get image dimensions"
998
+ end
999
+ width, height = stdout.strip.split.map(&:to_i)
1000
+
1001
+ # Sample more points around the border (not just corners)
1002
+ sample_points = [
1003
+ # Corners
1004
+ [0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1],
1005
+ # Mid-edges
1006
+ [width / 2, 0], [width / 2, height - 1],
1007
+ [0, height / 2], [width - 1, height / 2]
1008
+ ]
1009
+
1010
+ # Get colors from all sample points
1011
+ sampled_colors = []
1012
+ sample_points.each do |x, y|
1013
+ color_stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%[pixel:p{#{x},#{y}}]' #{Utils::PathHelper.quote_path(input_file)}")
1014
+ sampled_colors << color_stdout.strip if status.success? && !color_stdout.strip.empty?
1015
+ end
1016
+
1017
+ # Find the most common color (likely the background)
1018
+ bg_color = sampled_colors.group_by(&:itself).max_by { |_, v| v.size }.first
1019
+
1020
+ if options[:debug]
1021
+ Utils::OutputFormatter.indent("DEBUG: Image size: #{width}x#{height}")
1022
+ Utils::OutputFormatter.indent("DEBUG: Sampled #{sampled_colors.size} border colors")
1023
+ Utils::OutputFormatter.indent("DEBUG: Unique colors: #{sampled_colors.uniq.size}")
1024
+ Utils::OutputFormatter.indent("DEBUG: Using background color: #{bg_color}")
1025
+ Utils::OutputFormatter.indent("DEBUG: Fuzz: #{fuzz_percent}%")
1026
+ end
1027
+
1028
+ # Create a multi-pass approach for better results
1029
+ # Pass 1: Use floodfill from edges with fuzz tolerance
1030
+ temp_file1 = File.join(Dir.tmpdir, "bg_removal_pass1_#{Time.now.to_i}.png")
1031
+
1032
+ draw_commands = sample_points.map { |x, y| "'color #{x},#{y} floodfill'" }.join(' -draw ')
1033
+
1034
+ cmd1 = "#{magick_cmd} #{Utils::PathHelper.quote_path(input_file)} -alpha set -fuzz #{fuzz_percent}% -fill none -draw #{draw_commands} #{temp_file1}"
1035
+
1036
+ if options[:debug]
1037
+ Utils::OutputFormatter.indent("DEBUG: Pass 1 - Floodfill from #{sample_points.size} points")
1038
+ end
1039
+
1040
+ stdout, stderr, status = Open3.capture3(cmd1)
1041
+ unless status.success?
1042
+ File.delete(temp_file1) if File.exist?(temp_file1)
1043
+ raise ProcessingError, "Background removal pass 1 failed: #{stderr}"
1044
+ end
1045
+
1046
+ # Pass 2: Remove the detected background color globally with fuzz
1047
+ temp_file2 = File.join(Dir.tmpdir, "bg_removal_pass2_#{Time.now.to_i}.png")
1048
+
1049
+ cmd2 = "#{magick_cmd} #{temp_file1} -fuzz #{fuzz_percent}% -transparent '#{bg_color}' #{temp_file2}"
1050
+
1051
+ if options[:debug]
1052
+ Utils::OutputFormatter.indent("DEBUG: Pass 2 - Remove color #{bg_color} globally")
1053
+ end
1054
+
1055
+ stdout, stderr, status = Open3.capture3(cmd2)
1056
+ unless status.success?
1057
+ File.delete(temp_file1) if File.exist?(temp_file1)
1058
+ File.delete(temp_file2) if File.exist?(temp_file2)
1059
+ raise ProcessingError, "Background removal pass 2 failed: #{stderr}"
1060
+ end
1061
+
1062
+ # Pass 3: Minimal cleanup - preserve quality
1063
+ cmd3_parts = [
1064
+ magick_cmd,
1065
+ temp_file2
1066
+ ]
1067
+
1068
+ # Only clean up the alpha channel, don't touch the RGB data
1069
+ # This preserves sprite quality while cleaning edges
1070
+ cmd3_parts += [
1071
+ '-channel', 'A',
1072
+ # Very gentle cleanup - only remove nearly-transparent pixels
1073
+ '-threshold', '5%', # Anything less than 5% alpha becomes fully transparent
1074
+ '+channel'
1075
+ ]
1076
+
1077
+ # Optionally grow the transparent areas
1078
+ if grow > 0
1079
+ cmd3_parts += ['-morphology', 'Dilate', "Disk:#{grow}"]
1080
+ end
1081
+
1082
+ cmd3_parts << Utils::PathHelper.quote_path(output_file)
1083
+ cmd3 = cmd3_parts.join(' ')
1084
+
1085
+ if options[:debug]
1086
+ Utils::OutputFormatter.indent("DEBUG: Pass 3 - Minimal alpha cleanup (quality-preserving)")
1087
+ end
1088
+
1089
+ stdout, stderr, status = Open3.capture3(cmd3)
1090
+
1091
+ # Cleanup temp files
1092
+ File.delete(temp_file1) if File.exist?(temp_file1)
1093
+ File.delete(temp_file2) if File.exist?(temp_file2)
1094
+
1095
+ unless status.success?
1096
+ raise ProcessingError, "Background removal pass 3 failed: #{stderr}"
1097
+ end
1098
+
1099
+ Utils::FileHelper.validate_exists!(output_file)
1100
+ size = Utils::FileHelper.format_size(File.size(output_file))
1101
+ Utils::OutputFormatter.success("Background removal complete (#{size})\n")
1102
+ end
1103
+
1104
+ # Scale image using ImageMagick (fallback for GIMP 2.x)
1105
+ def scale_with_imagemagick(input_file, output_file, percent)
1106
+ magick_cmd = Platform.imagemagick_convert_cmd
1107
+
1108
+ # Map interpolation to ImageMagick filters
1109
+ interpolation = options[:scale_interpolation] || 'nohalo'
1110
+ filter = case interpolation.to_s.downcase
1111
+ when 'none' then 'Point'
1112
+ when 'linear' then 'Triangle'
1113
+ when 'cubic' then 'Catrom'
1114
+ when 'nohalo', 'lohalo' then 'Lanczos' # Best available quality
1115
+ else 'Lanczos'
1116
+ end
1117
+
1118
+ cmd = [
1119
+ magick_cmd,
1120
+ Utils::PathHelper.quote_path(input_file),
1121
+ '-filter', filter,
1122
+ '-resize', "#{percent}%",
1123
+ Utils::PathHelper.quote_path(output_file)
1124
+ ].join(' ')
1125
+
1126
+ if options[:debug]
1127
+ Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
1128
+ end
1129
+
1130
+ stdout, stderr, status = Open3.capture3(cmd)
1131
+
1132
+ unless status.success?
1133
+ raise ProcessingError, "ImageMagick scaling failed: #{stderr}"
1134
+ end
1135
+
1136
+ Utils::FileHelper.validate_exists!(output_file)
1137
+ size = Utils::FileHelper.format_size(File.size(output_file))
1138
+ Utils::OutputFormatter.success("Scale complete (#{size})\n")
1139
+ end
1140
+
1141
+ # Apply unsharp mask using ImageMagick
1142
+ def apply_sharpen_imagemagick(input_file)
1143
+ radius = options[:sharpen_radius] || 2.0
1144
+ gain = options[:sharpen_gain] || 0.5
1145
+ threshold = options[:sharpen_threshold] || 0.03
1146
+
1147
+ desired_output = Utils::FileHelper.output_filename(input_file, "sharpened")
1148
+ output_file = Utils::FileHelper.ensure_unique_output(desired_output, overwrite: options[:overwrite])
1149
+
1150
+ Utils::OutputFormatter.indent("Applying unsharp mask (ImageMagick)...")
1151
+ Utils::OutputFormatter.indent(" radius=#{radius}, gain=#{gain}, threshold=#{threshold}")
1152
+
1153
+ magick_cmd = Platform.imagemagick_convert_cmd
1154
+
1155
+ # Build ImageMagick unsharp command
1156
+ # Format: -unsharp {radius}x{sigma}+{gain}+{threshold}
1157
+ # sigma is typically radius * 0.5 for good results
1158
+ sigma = radius * 0.5
1159
+ unsharp_params = "#{radius}x#{sigma}+#{gain}+#{threshold}"
1160
+
1161
+ cmd = [
1162
+ magick_cmd,
1163
+ Utils::PathHelper.quote_path(input_file),
1164
+ '-unsharp', unsharp_params,
1165
+ Utils::PathHelper.quote_path(output_file)
1166
+ ].join(' ')
1167
+
1168
+ if options[:debug]
1169
+ Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
1170
+ end
1171
+
1172
+ stdout, stderr, status = Open3.capture3(cmd)
1173
+
1174
+ unless status.success?
1175
+ raise ProcessingError, "ImageMagick sharpen failed: #{stderr}"
1176
+ end
1177
+
1178
+ Utils::FileHelper.validate_exists!(output_file)
1179
+
1180
+ # Preserve metadata
1181
+ preserve_metadata(input_file, output_file)
1182
+
1183
+ Utils::OutputFormatter.success("Sharpening complete")
1184
+
1185
+ output_file
1186
+ end
1187
+ end
1188
+ end