ruby_spriter 0.6.7.1 → 0.7.0.1

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