ruby_spriter 0.6.7 → 0.7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rspec +3 -3
- data/CHANGELOG.md +1035 -405
- data/Gemfile +17 -17
- data/LICENSE +21 -21
- data/README.md +183 -902
- data/bin/ruby_spriter +20 -20
- data/lib/ruby_spriter/background_sampler.rb +140 -0
- data/lib/ruby_spriter/batch_processor.rb +268 -212
- data/lib/ruby_spriter/cell_cleanup_config.rb +21 -0
- data/lib/ruby_spriter/cell_cleanup_gimp_script.rb +123 -0
- data/lib/ruby_spriter/cell_cleanup_processor.rb +230 -0
- data/lib/ruby_spriter/cli.rb +676 -612
- data/lib/ruby_spriter/compression_manager.rb +101 -101
- data/lib/ruby_spriter/consolidator.rb +179 -179
- data/lib/ruby_spriter/dependency_checker.rb +224 -174
- data/lib/ruby_spriter/ghost_edge_cleaner.rb +164 -0
- data/lib/ruby_spriter/gimp_processor.rb +1188 -667
- data/lib/ruby_spriter/metadata_manager.rb +117 -116
- data/lib/ruby_spriter/platform.rb +137 -82
- data/lib/ruby_spriter/processor.rb +1230 -886
- data/lib/ruby_spriter/smoke_detector.rb +223 -0
- data/lib/ruby_spriter/threshold_stepper.rb +227 -0
- data/lib/ruby_spriter/utils/file_helper.rb +82 -82
- data/lib/ruby_spriter/utils/image_helper.rb +16 -0
- data/lib/ruby_spriter/utils/output_formatter.rb +65 -65
- data/lib/ruby_spriter/utils/path_helper.rb +59 -59
- data/lib/ruby_spriter/utils/spritesheet_splitter.rb +86 -86
- data/lib/ruby_spriter/version.rb +6 -7
- data/lib/ruby_spriter/video_processor.rb +357 -139
- data/lib/ruby_spriter.rb +38 -34
- data/ruby_spriter.gemspec +44 -42
- data/spec/fixtures/centered_sprite_with_inner_bg.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_inner_removed.png +0 -0
- data/spec/fixtures/centered_sprite_with_inner_bg_threshold_stepped.png +0 -0
- data/spec/fixtures/complex_background_sprite.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing.mp4 +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185027_431.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738-nobg-global.png +0 -0
- data/spec/fixtures/death - bloodborne rekconing_spritesheet_20251110_185125_738.png +0 -0
- data/spec/fixtures/has_inner_bg.png +0 -0
- data/spec/fixtures/has_small_inner_bg.png +0 -0
- data/spec/fixtures/smoke_effect_sprite.png +0 -0
- data/spec/fixtures/spritesheet_with_metadata.png +0 -0
- data/spec/fixtures/test_sprite.png +0 -0
- data/spec/fixtures/test_sprite_smoke_processed.png +0 -0
- data/spec/fixtures/test_video_spritesheet.png +0 -0
- data/spec/fixtures/transparent_bg_sprite.png +0 -0
- data/spec/fixtures/walk_north_sprite-sheet.png +0 -0
- data/spec/integration/no_fuzzy_mode_spec.rb +100 -0
- data/spec/ruby_spriter/batch_processor_spec.rb +509 -200
- data/spec/ruby_spriter/cli_spec.rb +2026 -1892
- data/spec/ruby_spriter/compression_manager_spec.rb +157 -157
- data/spec/ruby_spriter/consolidator_spec.rb +538 -538
- data/spec/ruby_spriter/gimp_processor_spec.rb +523 -425
- data/spec/ruby_spriter/platform_spec.rb +92 -82
- data/spec/ruby_spriter/processor_spec.rb +911 -735
- data/spec/ruby_spriter/utils/file_helper_spec.rb +150 -150
- data/spec/ruby_spriter/utils/path_helper_spec.rb +78 -78
- data/spec/ruby_spriter/utils/spritesheet_splitter_spec.rb +104 -104
- data/spec/ruby_spriter/video_processor_spec.rb +346 -29
- data/spec/spec_helper.rb +41 -41
- data/spec/tmp/cli_test_output.png +0 -0
- data/spec/tmp/cli_test_output_20251105_114647_395.png +0 -0
- data/spec/tmp/combined_test.png +0 -0
- data/spec/tmp/compat_test.png +0 -0
- data/spec/tmp/compat_test_20251030_174233_361.png +0 -0
- data/spec/tmp/final_all_features.png +0 -0
- data/spec/tmp/final_test_all_features.png +0 -0
- data/spec/tmp/full_pipeline_test.png +0 -0
- data/spec/tmp/inner_test.png +0 -0
- data/spec/tmp/integration_test.png +0 -0
- data/spec/tmp/validation_test.png +0 -0
- data/spec/unit/background_sampler_spec.rb +132 -0
- data/spec/unit/cell_cleanup_config_spec.rb +32 -0
- data/spec/unit/cell_cleanup_gimp_script_spec.rb +59 -0
- data/spec/unit/cell_cleanup_processor_spec.rb +261 -0
- data/spec/unit/ghost_edge_cleaner_spec.rb +223 -0
- data/spec/unit/gimp_processor_single_point_selection_spec.rb +81 -0
- data/spec/unit/smoke_detector_spec.rb +246 -0
- data/spec/unit/threshold_stepper_spec.rb +195 -0
- metadata +56 -10
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
when
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
].join(' ')
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|