565 lines · 21.3 KB
Raw Download
1
"""
2
Image to Web-Ready JPEG Converter
3
Converts JPG, PNG, HEIC, and TIF/TIFF images to optimized web-ready JPEGs
4
with drag-and-drop GUI support for files and folders.
5
"""
6
7
import tkinter as tk
8
from tkinter import ttk, filedialog, messagebox
9
from tkinterdnd2 import DND_FILES, TkinterDnD
10
from PIL import Image, ImageCms
11
import os
12
from pathlib import Path
13
import threading
14
15
# Register HEIC support
16
try:
17
    import pillow_heif
18
19
    pillow_heif.register_heif_opener()
20
except ImportError:
21
    # pillow-heif not installed, HEIC files won't be supported
22
    pass
23
# Register AVIF
24
try:
25
    import pillow_avif
26
except ImportError:
27
    pass
28
29
# Register JPEG XL
30
try:
31
    import pillow_jxl
32
except ImportError:
33
    pass
34
35
36
class ImageConverterGUI:
37
    def __init__(self, root):
38
        self.root = root
39
        self.root.title("Web-Ready JPEG Converter")
40
        self.root.geometry("800x600")
41
42
        # Configure style
43
        style = ttk.Style()
44
        style.theme_use("clam")
45
46
        # Main frame
47
        main_frame = ttk.Frame(root, padding="10")
48
        main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
49
        root.columnconfigure(0, weight=1)
50
        root.rowconfigure(0, weight=1)
51
52
        # Configure grid weights
53
        main_frame.columnconfigure(0, weight=1)
54
        main_frame.columnconfigure(1, weight=1)
55
        main_frame.rowconfigure(1, weight=1)  # Status text gets the extra space
56
57
        # ============ TOP ROW: Drop zone and Options ============
58
        top_frame = ttk.Frame(main_frame)
59
        top_frame.grid(
60
            row=0, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N), pady=(0, 10)
61
        )
62
        top_frame.columnconfigure(0, weight=1)
63
        top_frame.columnconfigure(1, weight=0)
64
65
        # Drop zone (left side)
66
        self.drop_frame = tk.Frame(
67
            top_frame,
68
            bg="#f0f0f0",
69
            relief=tk.RIDGE,
70
            borderwidth=2,
71
            width=380,
72
            height=280,
73
        )
74
        self.drop_frame.grid(
75
            row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E), padx=(0, 10)
76
        )
77
        self.drop_frame.grid_propagate(False)
78
79
        drop_label = tk.Label(
80
            self.drop_frame,
81
            text="πŸ“ Drop images or folders here",
82
            bg="#f0f0f0",
83
            font=("Arial", 12),
84
            fg="#666",
85
        )
86
        drop_label.place(relx=0.5, rely=0.5, anchor=tk.CENTER)
87
88
        # Enable drag and drop
89
        self.drop_frame.drop_target_register(DND_FILES)
90
        self.drop_frame.dnd_bind("<<Drop>>", self.on_drop)
91
92
        # Options panel (right side)
93
        options_frame = ttk.Frame(top_frame)
94
        options_frame.grid(row=0, column=1, sticky=(tk.N, tk.W, tk.E))
95
96
        # Conversion options frame
97
        conversion_options_frame = ttk.LabelFrame(
98
            options_frame, text="Conversion Options", padding="10"
99
        )
100
        conversion_options_frame.grid(
101
            row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)
102
        )
103
104
        # Checkbox for keeping original dimensions
105
        self.keep_original_dimensions = tk.BooleanVar(value=False)
106
        self.dimensions_checkbox = ttk.Checkbutton(
107
            conversion_options_frame,
108
            text="Keep original dimensions\n(disable 2000px max resize)",
109
            variable=self.keep_original_dimensions,
110
        )
111
        self.dimensions_checkbox.grid(row=0, column=0, sticky=tk.W)
112
113
        # Output options frame
114
        output_options_frame = ttk.LabelFrame(
115
            options_frame, text="Output Options", padding="10"
116
        )
117
        output_options_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(0, 10))
118
119
        # Checkbox for output to folder
120
        self.use_output_folder = tk.BooleanVar(value=False)
121
        self.output_checkbox = ttk.Checkbutton(
122
            output_options_frame,
123
            text="Output to specific folder\n(instead of alongside originals)",
124
            variable=self.use_output_folder,
125
            command=self.toggle_output_folder,
126
        )
127
        self.output_checkbox.grid(
128
            row=0, column=0, columnspan=2, sticky=tk.W, pady=(0, 5)
129
        )
130
131
        # Output folder path
132
        self.default_output_folder = r"C:\Users\Jeff\Desktop\webimg"
133
        self.output_folder = self.default_output_folder
134
135
        self.output_entry = ttk.Entry(output_options_frame, width=30)
136
        self.output_entry.insert(0, self.default_output_folder)
137
        self.output_entry.config(state=tk.DISABLED)
138
        self.output_entry.grid(
139
            row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5)
140
        )
141
142
        self.output_browse_button = ttk.Button(
143
            output_options_frame,
144
            text="Browse...",
145
            command=self.select_output_folder,
146
            state=tk.DISABLED,
147
        )
148
        self.output_browse_button.grid(row=2, column=0, columnspan=2, pady=(0, 5))
149
150
        # Checkbox for preserving folder structure
151
        self.preserve_structure = tk.BooleanVar(value=False)
152
        self.structure_checkbox = ttk.Checkbutton(
153
            output_options_frame,
154
            text="Preserve original folder structure",
155
            variable=self.preserve_structure,
156
            state=tk.DISABLED,
157
        )
158
        self.structure_checkbox.grid(row=3, column=0, columnspan=2, sticky=tk.W)
159
160
        output_options_frame.columnconfigure(0, weight=1)
161
162
        # Buttons frame
163
        button_frame = ttk.Frame(options_frame)
164
        button_frame.grid(row=2, column=0, pady=(0, 0))
165
166
        self.select_button = ttk.Button(
167
            button_frame, text="Select Files", command=self.select_files, width=15
168
        )
169
        self.select_button.grid(row=0, column=0, padx=2)
170
171
        self.select_folder_button = ttk.Button(
172
            button_frame, text="Select Folder", command=self.select_folder, width=15
173
        )
174
        self.select_folder_button.grid(row=0, column=1, padx=2)
175
176
        # ============ BOTTOM SECTION: Status and Output ============
177
178
        # Status label and progress bar
179
        status_frame = ttk.Frame(main_frame)
180
        status_frame.grid(
181
            row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5)
182
        )
183
        status_frame.columnconfigure(1, weight=1)
184
185
        ttk.Label(status_frame, text="Status:").grid(
186
            row=0, column=0, padx=(0, 5), sticky=tk.W
187
        )
188
189
        self.status_label = ttk.Label(status_frame, text="Ready", foreground="green")
190
        self.status_label.grid(row=0, column=1, sticky=tk.W)
191
192
        # Progress bar
193
        self.progress = ttk.Progressbar(main_frame, mode="determinate")
194
        self.progress.grid(
195
            row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 5)
196
        )
197
198
        # Status text (console output)
199
        output_frame = ttk.LabelFrame(main_frame, text="Console Output", padding="5")
200
        output_frame.grid(
201
            row=3, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S)
202
        )
203
        output_frame.columnconfigure(0, weight=1)
204
        output_frame.rowconfigure(0, weight=1)
205
206
        self.status_text = tk.Text(
207
            output_frame, height=10, wrap=tk.WORD, state=tk.DISABLED
208
        )
209
        self.status_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
210
211
        # Scrollbar for status text
212
        scrollbar = ttk.Scrollbar(output_frame, command=self.status_text.yview)
213
        scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
214
        self.status_text.config(yscrollcommand=scrollbar.set)
215
216
        main_frame.rowconfigure(3, weight=1)
217
218
        # Store the root folder for structure preservation
219
        self.source_root_folder = None
220
221
    def toggle_output_folder(self):
222
        """Enable/disable output folder controls based on checkbox"""
223
        if self.use_output_folder.get():
224
            self.output_entry.config(state=tk.NORMAL)
225
            self.output_browse_button.config(state=tk.NORMAL)
226
            self.structure_checkbox.config(state=tk.NORMAL)
227
            self.output_folder = self.output_entry.get()
228
        else:
229
            self.output_entry.config(state=tk.DISABLED)
230
            self.output_browse_button.config(state=tk.DISABLED)
231
            self.structure_checkbox.config(state=tk.DISABLED)
232
            self.output_folder = None
233
234
    def log_status(self, message):
235
        """Add message to status text box"""
236
        self.status_text.config(state=tk.NORMAL)
237
        self.status_text.insert(tk.END, message + "\n")
238
        self.status_text.see(tk.END)
239
        self.status_text.config(state=tk.DISABLED)
240
        self.root.update_idletasks()
241
242
    def select_files(self):
243
        """Open file dialog to select images"""
244
        files = filedialog.askopenfilenames(
245
            title="Select Images",
246
            filetypes=[
247
                (
248
                    "Image files",
249
                    "*.jpg *.jpeg *.png *.heic *.HEIC *.avif *.AVIF *.jxl *.JXL *.webp *.WEBP *.tif *.TIF *.tiff *.TIFF",
250
                ),
251
                ("All files", "*.*"),
252
            ],
253
        )
254
        if files:
255
            # For individual files, no root folder
256
            self.source_root_folder = None
257
            self.process_files(list(files))
258
259
    def select_folder(self):
260
        """Open folder dialog to select a folder with images"""
261
        folder = filedialog.askdirectory(title="Select Folder with Images")
262
        if folder:
263
            # Set the root folder for structure preservation
264
            self.source_root_folder = folder
265
            image_files = self.find_images_in_folder(folder)
266
            if image_files:
267
                self.process_files(image_files)
268
            else:
269
                messagebox.showinfo(
270
                    "No Images",
271
                    "No supported image files found in the selected folder.",
272
                )
273
274
    def select_output_folder(self):
275
        """Select output folder for converted images"""
276
        folder = filedialog.askdirectory(
277
            title="Select Output Folder", initialdir=self.output_entry.get()
278
        )
279
        if folder:
280
            self.output_folder = folder
281
            self.output_entry.delete(0, tk.END)
282
            self.output_entry.insert(0, folder)
283
284
    def find_images_in_folder(self, folder_path):
285
        """Recursively find all image files in a folder and its subfolders"""
286
        image_extensions = (
287
            ".AVIF",
288
            ".avif",
289
            ".heic",
290
            ".HEIC",
291
            ".jpeg",
292
            ".JPEG",
293
            ".jpg",
294
            ".JPG",
295
            ".JXL",
296
            ".jxl",
297
            ".png",
298
            ".PNG",
299
            ".tif",
300
            ".TIF",
301
            ".tiff",
302
            ".TIFF",
303
            ".webp",
304
            ".WEBP",
305
        )
306
        image_files = []
307
308
        for root, dirs, files in os.walk(folder_path):
309
            for file in files:
310
                if file.lower().endswith(image_extensions):
311
                    image_files.append(os.path.join(root, file))
312
313
        return image_files
314
315
    def on_drop(self, event):
316
        """Handle drag and drop event"""
317
        files = self.root.tk.splitlist(event.data)
318
        all_image_files = []
319
320
        # Determine if we're dropping a single folder or multiple items
321
        folders = [
322
            item.strip("{}") for item in files if os.path.isdir(item.strip("{}"))
323
        ]
324
325
        # If exactly one folder is dropped, use it as the root for structure preservation
326
        if len(folders) == 1 and len(files) == 1:
327
            self.source_root_folder = folders[0]
328
        else:
329
            # Multiple items or mixed files/folders - no structure preservation
330
            self.source_root_folder = None
331
332
        for item in files:
333
            item = item.strip("{}")  # Remove curly braces
334
335
            if os.path.isdir(item):
336
                # If it's a folder, find all images in it
337
                image_files = self.find_images_in_folder(item)
338
                all_image_files.extend(image_files)
339
            elif os.path.isfile(item) and item.lower().endswith(
340
                (
341
                    ".jpg",
342
                    ".jpeg",
343
                    ".png",
344
                    ".heic",
345
                    ".webp",
346
                    ".avif",
347
                    ".jxl",
348
                    ".tif",
349
                    ".tiff",
350
                )
351
            ):
352
                # If it's an image file, add it
353
                all_image_files.append(item)
354
355
        if all_image_files:
356
            self.process_files(all_image_files)
357
        else:
358
            messagebox.showwarning("No Images", "No supported image files found.")
359
360
    def process_files(self, files):
361
        """Process files in a separate thread"""
362
        thread = threading.Thread(target=self._convert_images, args=(files,))
363
        thread.daemon = True
364
        thread.start()
365
366
    def _convert_images(self, files):
367
        """Convert images to web-ready JPEGs"""
368
        self.select_button.config(state=tk.DISABLED)
369
        self.select_folder_button.config(state=tk.DISABLED)
370
        self.output_checkbox.config(state=tk.DISABLED)
371
        self.output_browse_button.config(state=tk.DISABLED)
372
        self.structure_checkbox.config(state=tk.DISABLED)
373
        self.dimensions_checkbox.config(state=tk.DISABLED)
374
375
        self.progress["value"] = 0
376
        self.progress["maximum"] = len(files)
377
378
        self.log_status(f"\n{'='*50}")
379
        self.log_status(f"Starting conversion of {len(files)} file(s)...")
380
381
        # Get output folder setting
382
        use_output_folder = self.use_output_folder.get()
383
        output_folder = self.output_entry.get() if use_output_folder else None
384
        preserve_structure = self.preserve_structure.get() and use_output_folder
385
        keep_dimensions = self.keep_original_dimensions.get()
386
387
        if use_output_folder:
388
            self.log_status(f"Output folder: {output_folder}")
389
            if preserve_structure and self.source_root_folder:
390
                self.log_status(
391
                    f"Preserving folder structure from: {self.source_root_folder}"
392
                )
393
            # Create output folder if it doesn't exist
394
            Path(output_folder).mkdir(parents=True, exist_ok=True)
395
        else:
396
            self.log_status(f"Output: Alongside original files")
397
398
        if keep_dimensions:
399
            self.log_status(f"Resize: Disabled (keeping original dimensions)")
400
        else:
401
            self.log_status(f"Resize: Max 2000px on longest side")
402
403
        self.log_status(f"{'='*50}\n")
404
405
        success_count = 0
406
        error_count = 0
407
408
        # Get sRGB profile
409
        srgb_profile = ImageCms.createProfile("sRGB")
410
411
        for idx, file_path in enumerate(files, 1):
412
            try:
413
                file_path = file_path.strip("{}")  # Remove curly braces from drag-drop
414
                path = Path(file_path)
415
416
                self.log_status(f"[{idx}/{len(files)}] Processing: {path.name}")
417
418
                # Open image
419
                img = Image.open(file_path)
420
421
                # Auto-detect and convert CMYK to RGB (common in TIF/print files)
422
                if img.mode == "CMYK":
423
                    if "icc_profile" in img.info:
424
                        import io
425
426
                        try:
427
                            input_profile = ImageCms.ImageCmsProfile(
428
                                io.BytesIO(img.info["icc_profile"])
429
                            )
430
                            srgb_profile_obj = ImageCms.createProfile("sRGB")
431
                            img = ImageCms.profileToProfile(
432
                                img, input_profile, srgb_profile_obj, outputMode="RGB"
433
                            )
434
                            self.log_status(f"  CMYK β†’ RGB (ICC profile used)")
435
                        except Exception:
436
                            img = img.convert("RGB")
437
                            self.log_status(f"  CMYK β†’ RGB (fallback conversion)")
438
                    else:
439
                        img = img.convert("RGB")
440
                        self.log_status(
441
                            f"  CMYK β†’ RGB (no ICC profile, direct convert)"
442
                        )
443
444
                # Convert any other non-RGB modes (RGBA, L, P, etc.)
445
                if img.mode not in ("RGB",):
446
                    img = img.convert("RGB")
447
448
                # Get original dimensions
449
                width, height = img.size
450
                original_size = f"{width}x{height}"
451
452
                # Resize if needed (max 2000px on longest side) - only if not keeping original dimensions
453
                if not keep_dimensions:
454
                    max_dimension = 2000
455
                    if width > max_dimension or height > max_dimension:
456
                        if width > height:
457
                            new_width = max_dimension
458
                            new_height = int((max_dimension / width) * height)
459
                        else:
460
                            new_height = max_dimension
461
                            new_width = int((max_dimension / height) * width)
462
463
                        img = img.resize(
464
                            (new_width, new_height), Image.Resampling.LANCZOS
465
                        )
466
                        self.log_status(
467
                            f"  Resized: {original_size} β†’ {new_width}x{new_height}"
468
                        )
469
                    else:
470
                        self.log_status(f"  Size: {original_size} (no resize needed)")
471
                else:
472
                    self.log_status(f"  Size: {original_size} (original kept)")
473
474
                # Convert to sRGB color space (for non-CMYK images that have an ICC profile)
475
                try:
476
                    if "icc_profile" in img.info and img.mode == "RGB":
477
                        import io
478
479
                        input_profile = ImageCms.ImageCmsProfile(
480
                            io.BytesIO(img.info["icc_profile"])
481
                        )
482
                        img = ImageCms.profileToProfile(
483
                            img, input_profile, srgb_profile, outputMode="RGB"
484
                        )
485
                        self.log_status(f"  Converted to sRGB color space")
486
                except Exception as e:
487
                    self.log_status(f"  Warning: Could not convert color profile: {e}")
488
489
                # Determine output path
490
                if output_folder:
491
                    if preserve_structure and self.source_root_folder:
492
                        # Preserve folder structure
493
                        try:
494
                            # Get relative path from source root folder
495
                            relative_path = Path(file_path).relative_to(
496
                                self.source_root_folder
497
                            )
498
                            # Create the same structure in output folder
499
                            output_dir = Path(output_folder) / relative_path.parent
500
                            output_dir.mkdir(parents=True, exist_ok=True)
501
                            output_path = output_dir / f"{path.stem}_web.jpg"
502
                            self.log_status(f"  Folder: {relative_path.parent}")
503
                        except ValueError:
504
                            # File is not relative to source root, use flat output
505
                            output_path = Path(output_folder) / f"{path.stem}_web.jpg"
506
                    else:
507
                        # Flat output - all files in output folder root
508
                        output_path = Path(output_folder) / f"{path.stem}_web.jpg"
509
                else:
510
                    # Output alongside original
511
                    output_path = path.parent / f"{path.stem}_web.jpg"
512
513
                # Save as JPEG with sRGB profile embedded
514
                img.save(
515
                    output_path,
516
                    "JPEG",
517
                    quality=70,
518
                    optimize=True,
519
                    icc_profile=ImageCms.ImageCmsProfile(srgb_profile).tobytes(),
520
                )
521
522
                # Get file size
523
                file_size = output_path.stat().st_size / 1024  # KB
524
                self.log_status(f"  βœ“ Saved: {output_path.name} ({file_size:.1f} KB)")
525
                success_count += 1
526
527
            except Exception as e:
528
                self.log_status(f"  βœ— Error: {str(e)}")
529
                error_count += 1
530
531
            self.progress["value"] = idx
532
            self.root.update_idletasks()
533
534
        # Summary
535
        self.log_status(f"\n{'='*50}")
536
        self.log_status(f"Conversion complete!")
537
        self.log_status(f"Success: {success_count} | Errors: {error_count}")
538
        self.log_status(f"{'='*50}\n")
539
540
        self.select_button.config(state=tk.NORMAL)
541
        self.select_folder_button.config(state=tk.NORMAL)
542
        self.output_checkbox.config(state=tk.NORMAL)
543
        self.dimensions_checkbox.config(state=tk.NORMAL)
544
        if self.use_output_folder.get():
545
            self.output_browse_button.config(state=tk.NORMAL)
546
            self.structure_checkbox.config(state=tk.NORMAL)
547
548
549
def main():
550
    root = TkinterDnD.Tk()
551
    app = ImageConverterGUI(root)
552
    root.mainloop()
553
554
555
if __name__ == "__main__":
556
    # Check for required libraries
557
    try:
558
        import io
559
560
        main()
561
    except ImportError as e:
562
        print(f"Error: Missing required library - {e}")
563
        print("\nPlease install required packages:")
564
        print("pip install Pillow tkinterdnd2 pillow-heif")
565