#!/usr/bin/env python3 """ WImageEditTk.py Tkinter-based GUI for WImageEdit, using wimage_core for: - ImageMagick command building (including rotation and parametric settings). - "Actions" (Color Pos/Neg, BW Pos/Neg). - Granite background with adjustable scale. - Simple per-file save/load of settings (imgedit_proc.json). - Directory defaults. Layout: - Left panel: directory selection, file list, navigation, info. - Right panel: top canvas = Original, bottom canvas = Preview, controls on the right. This version: - Shows TWO views stacked vertically: Top = original (rotated, no processing except resize) Bottom = preview with current options applied (Pillow approximation). - Includes parametric controls for: - Sigmoidal contrast (contrast, midpoint) - Sharpen (radius, amount, threshold) - Includes rotation controls: - None / 90 / 180 / 270 / Custom - Displays a live ImageMagick command string based on current options. """ import sys, os import subprocess from pathlib import Path from dataclasses import dataclass, field from typing import Optional, Tuple, List import tkinter as tk from tkinter import ttk, filedialog, messagebox from PIL import ( Image, ImageTk, ExifTags, ImageOps, ImageFilter, ImageDraw, ImageFont, ) from wimage_core import ( EditOptions, build_imagemagick_cmd, build_granite_background_cmd, apply_action_preset, load_options_for_file, save_options_for_file, load_directory_defaults, save_directory_defaults, ) import tempfile # ---------------------- helpers / file listing / EXIF ---------------------- def get_exif_datetime_original(path: Path) -> Optional[str]: try: img = Image.open(path) exif = img._getexif() if not exif: return None rev = {v: k for k, v in ExifTags.TAGS.items()} dto_tag = rev.get("DateTimeOriginal") if not dto_tag: return None return exif.get(dto_tag) except Exception: return None def list_image_files(directory: Path) -> List[Path]: """ List files that are likely to be images. Includes common RAW extensions so directories with only RAWs won't appear empty. """ exts = { ".jpg", ".jpeg", ".png", ".gif", ".tif", ".tiff", ".bmp", ".nef", ".cr2", ".cr3", ".arw", ".rw2", ".orf", ".dng", ".pef", ".srw", } return [p for p in sorted(directory.iterdir()) if p.suffix.lower() in exts] # ----------------------------- app state ------------------------------------ @dataclass class AppState: directory: Optional[Path] = None files: List[Path] = field(default_factory=list) current_index: int = 0 base_image: Optional[Image.Image] = None # unrotated full-res PIL image current_image: Optional[Image.Image] = None # rotated version used for crop/preview current_photo_orig: Optional[ImageTk.PhotoImage] = None current_photo_prev: Optional[ImageTk.PhotoImage] = None crop_start: Optional[Tuple[int, int]] = None crop_rect_id: Optional[int] = None crop_box_canvas: Optional[Tuple[int, int, int, int]] = None image_size: Optional[Tuple[int, int]] = None canvas_size_orig: Optional[Tuple[int, int]] = None canvas_size_prev: Optional[Tuple[int, int]] = None # ----------------------------- main GUI ------------------------------------- class WImageEditTk(tk.Tk): def __init__(self): super().__init__() self.title("WImageEditTk (Tkinter)") self.state = AppState() # IM command string (for display/copy) self.current_cmd_str: str = "" # UI variables self.dir_var = tk.StringVar(value="~/Downloads") self.color_var = tk.BooleanVar(value=True) self.sigmoidal_var = tk.BooleanVar(value=True) self.sig_contrast_var = tk.DoubleVar(value=6.0) self.sig_midpoint_var = tk.DoubleVar(value=0.5) self.sharpen_var = tk.BooleanVar(value=True) self.sharp_radius_var = tk.DoubleVar(value=0.0) self.sharp_amount_var = tk.DoubleVar(value=0.5) self.sharp_thresh_var = tk.DoubleVar(value=0.02) self.gray_var = tk.BooleanVar(value=False) self.invert_var = tk.BooleanVar(value=False) self.background_var = tk.BooleanVar(value=False) self.background_scale_var = tk.DoubleVar(value=1.1) # Rotation self.rotation_mode_var = tk.StringVar(value="None") # None/90/180/270/Custom self.rotation_custom_var = tk.DoubleVar(value=0.0) # Annotation self.annot_size_var = tk.IntVar(value=32) self.annot_pos_var = tk.StringVar(value="Bottom") self.info_var = tk.StringVar(value="Select a directory to begin.") self._build_ui() # --------------------------- UI building --------------------------------- def _build_ui(self): # Main frame splits left (files) and right (images + controls) main = ttk.Frame(self, padding=5) main.pack(fill=tk.BOTH, expand=True) # LEFT PANEL: directory picker + file list + nav + info left = ttk.Frame(main, padding=5) left.pack(side=tk.LEFT, fill=tk.Y) # Directory picker at top *within* left panel dir_frame = ttk.Frame(left) dir_frame.pack(fill=tk.X, pady=(0, 5)) ttk.Label(dir_frame, text="Directory:").pack(side=tk.LEFT) ttk.Entry(dir_frame, textvariable=self.dir_var, width=30).pack(side=tk.LEFT, padx=4) ttk.Button(dir_frame, text="...", width=3, command=self.select_directory).pack(side=tk.LEFT) ttk.Label(left, text="Files:").pack(anchor=tk.W) self.file_listbox = tk.Listbox(left, width=35, height=24) self.file_listbox.pack(fill=tk.BOTH, expand=True) self.file_listbox.bind("<>", self.on_file_select) nav_frame = ttk.Frame(left) nav_frame.pack(fill=tk.X, pady=4) ttk.Button(nav_frame, text="Prev", command=self.prev_image).pack(side=tk.LEFT, expand=True, fill=tk.X) ttk.Button(nav_frame, text="Next", command=self.next_image).pack(side=tk.LEFT, expand=True, fill=tk.X) ttk.Label(left, textvariable=self.info_var, wraplength=260, justify=tk.LEFT).pack(fill=tk.X, pady=5) # RIGHT PANEL: top canvas (original), bottom canvas (preview), controls on the right right = ttk.Frame(main, padding=5) right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Right is split: left area = canvases (stacked), right area = controls right_canvases = ttk.Frame(right) right_canvases.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) right_controls = ttk.Frame(right, padding=(5, 0)) right_controls.pack(side=tk.LEFT, fill=tk.Y) # Canvases stacked: Original (top) and Preview (bottom) orig_label = ttk.Label(right_canvases, text="Original") orig_label.pack(anchor=tk.W) self.canvas_orig = tk.Canvas(right_canvases, bg="gray", width=600, height=300) self.canvas_orig.pack(fill=tk.BOTH, expand=True, pady=(0, 4)) prev_label = ttk.Label(right_canvases, text="Preview") prev_label.pack(anchor=tk.W) self.canvas_prev = tk.Canvas(right_canvases, bg="gray", width=600, height=300) self.canvas_prev.pack(fill=tk.BOTH, expand=True) # Crop is defined by dragging on the ORIGINAL canvas self.canvas_orig.bind("", self.on_canvas_press) self.canvas_orig.bind("", self.on_canvas_drag) self.canvas_orig.bind("", self.on_canvas_release) # Controls on the right # Actions frame actions_frame = ttk.LabelFrame(right_controls, text="Actions", padding=5) actions_frame.pack(fill=tk.X) ttk.Button(actions_frame, text="Color Pos", command=lambda: self.apply_action("Color Pos")).pack(fill=tk.X, pady=1) ttk.Button(actions_frame, text="Color Neg", command=lambda: self.apply_action("Color Neg")).pack(fill=tk.X, pady=1) ttk.Button(actions_frame, text="BW Pos", command=lambda: self.apply_action("BW Pos")).pack(fill=tk.X, pady=1) ttk.Button(actions_frame, text="BW Neg", command=lambda: self.apply_action("BW Neg")).pack(fill=tk.X, pady=1) # Processing options opts_frame = ttk.LabelFrame(right_controls, text="Processing", padding=5) opts_frame.pack(fill=tk.X, pady=(5, 0)) ttk.Checkbutton( opts_frame, text="Color correction", variable=self.color_var, command=self.options_changed ).pack(anchor=tk.W) # Sigmoidal contrast + params ttk.Checkbutton( opts_frame, text="Sigmoidal contrast", variable=self.sigmoidal_var, command=self.options_changed ).pack(anchor=tk.W) sig_frame = ttk.Frame(opts_frame) sig_frame.pack(fill=tk.X, pady=(2, 0)) ttk.Label(sig_frame, text="C:").pack(side=tk.LEFT) ttk.Spinbox( sig_frame, from_=0.0, to=20.0, increment=0.5, textvariable=self.sig_contrast_var, width=5, command=self.options_changed ).pack(side=tk.LEFT) ttk.Label(sig_frame, text="M:").pack(side=tk.LEFT, padx=(4, 0)) ttk.Spinbox( sig_frame, from_=0.0, to=1.0, increment=0.05, textvariable=self.sig_midpoint_var, width=5, command=self.options_changed ).pack(side=tk.LEFT) # Sharpen + params ttk.Checkbutton( opts_frame, text="Sharpen", variable=self.sharpen_var, command=self.options_changed ).pack(anchor=tk.W) sharp_frame = ttk.Frame(opts_frame) sharp_frame.pack(fill=tk.X, pady=(2, 0)) ttk.Label(sharp_frame, text="R:").pack(side=tk.LEFT) ttk.Spinbox( sharp_frame, from_=0.0, to=5.0, increment=0.1, textvariable=self.sharp_radius_var, width=5, command=self.options_changed ).pack(side=tk.LEFT) ttk.Label(sharp_frame, text="Amt:").pack(side=tk.LEFT, padx=(4, 0)) ttk.Spinbox( sharp_frame, from_=0.0, to=5.0, increment=0.1, textvariable=self.sharp_amount_var, width=5, command=self.options_changed ).pack(side=tk.LEFT) ttk.Label(sharp_frame, text="Thr:").pack(side=tk.LEFT, padx=(4, 0)) ttk.Spinbox( sharp_frame, from_=0.0, to=1.0, increment=0.01, textvariable=self.sharp_thresh_var, width=5, command=self.options_changed ).pack(side=tk.LEFT) ttk.Checkbutton( opts_frame, text="Grayscale", variable=self.gray_var, command=self.options_changed ).pack(anchor=tk.W) ttk.Checkbutton( opts_frame, text="Invert (neg)", variable=self.invert_var, command=self.options_changed ).pack(anchor=tk.W) ttk.Checkbutton( opts_frame, text="Granite background", variable=self.background_var, command=self.options_changed ).pack(anchor=tk.W) scale_row = ttk.Frame(opts_frame) scale_row.pack(fill=tk.X, pady=(4, 0)) ttk.Label(scale_row, text="Background scale:").pack(side=tk.LEFT) ttk.Spinbox( scale_row, from_=1.0, to=2.0, increment=0.05, textvariable=self.background_scale_var, width=6, format="%.2f", command=self.options_changed, ).pack(side=tk.LEFT, padx=4) ttk.Label(scale_row, text="(1.1 ≈ 10% border)").pack(side=tk.LEFT) # Rotation controls rot_frame = ttk.LabelFrame(right_controls, text="Rotation", padding=5) rot_frame.pack(fill=tk.X, pady=5) def rot_rb(text, value): return ttk.Radiobutton( rot_frame, text=text, value=value, variable=self.rotation_mode_var, command=self.rotation_changed ) rot_rb("None", "None").pack(anchor=tk.W) rot_rb("90°", "90").pack(anchor=tk.W) rot_rb("180°", "180").pack(anchor=tk.W) rot_rb("270°", "270").pack(anchor=tk.W) custom_row = ttk.Frame(rot_frame) custom_row.pack(fill=tk.X, pady=(2, 0)) ttk.Radiobutton( custom_row, text="Custom:", value="Custom", variable=self.rotation_mode_var, command=self.rotation_changed ).pack(side=tk.LEFT) ttk.Spinbox( custom_row, from_=-360.0, to=360.0, increment=1.0, textvariable=self.rotation_custom_var, width=6, command=self.rotation_changed, ).pack(side=tk.LEFT, padx=4) # Annotation annot_frame = ttk.LabelFrame(right_controls, text="Text annotation", padding=5) annot_frame.pack(fill=tk.X, pady=5) ttk.Label(annot_frame, text="Text:").pack(anchor=tk.W) self.annot_text = tk.Text(annot_frame, width=30, height=3) self.annot_text.pack(fill=tk.X) self.annot_text.bind("<>", self.on_annot_modified) ttk.Label(annot_frame, text="Font size:").pack(anchor=tk.W) ttk.Spinbox( annot_frame, from_=8, to=120, textvariable=self.annot_size_var, width=5, command=self.options_changed, ).pack(anchor=tk.W) ttk.Label(annot_frame, text="Position:").pack(anchor=tk.W) ttk.Radiobutton( annot_frame, text="Top", value="Top", variable=self.annot_pos_var, command=self.options_changed ).pack(anchor=tk.W) ttk.Radiobutton( annot_frame, text="Center", value="Center", variable=self.annot_pos_var, command=self.options_changed ).pack(anchor=tk.W) ttk.Radiobutton( annot_frame, text="Bottom", value="Bottom", variable=self.annot_pos_var, command=self.options_changed ).pack(anchor=tk.W) # Directory defaults defaults_frame = ttk.LabelFrame(right_controls, text="Directory defaults", padding=5) defaults_frame.pack(fill=tk.X, pady=5) ttk.Button( defaults_frame, text="Set from current image", command=self.set_directory_defaults_from_current ).pack(fill=tk.X) # IM command display cmd_frame = ttk.LabelFrame(right_controls, text="Current IM command", padding=5) cmd_frame.pack(fill=tk.BOTH, expand=True, pady=5) self.cmd_text = tk.Text(cmd_frame, width=40, height=6, wrap="word") self.cmd_text.pack(fill=tk.BOTH, expand=True) self.cmd_text.configure(state="disabled") btn_row = ttk.Frame(cmd_frame) btn_row.pack(fill=tk.X, pady=(4, 0)) ttk.Button(btn_row, text="Copy IM Cmd", command=self.copy_cmd_to_clipboard).pack(side=tk.LEFT, expand=True, fill=tk.X) ttk.Button(btn_row, text="IM Preview", command=self.run_im_preview).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(4, 0)) ttk.Label( cmd_frame, text="Note: if 'Granite background' is checked, two IM\n" "commands are used (subject + background).", justify=tk.LEFT, wraplength=260, ).pack(fill=tk.X, pady=(4, 0)) # Actions buttons (bottom) action_frame = ttk.Frame(right_controls) action_frame.pack(fill=tk.X, pady=10) ttk.Button(action_frame, text="Process", command=self.process_current).pack(fill=tk.X) ttk.Button(action_frame, text="Quit", command=self.destroy).pack(fill=tk.X, pady=(5, 0)) # Initial action # print(dir(self.dir_var)) if os.path.exists(self.dir_var.get()): self.state.directory = Path(self.dir_var.get()) self.load_files() # ------------------------------ directory & files ------------------------ def select_directory(self): dirname = filedialog.askdirectory(title="Select image directory") if not dirname: return self.dir_var.set(dirname) self.state.directory = Path(dirname) self.load_files() def load_files(self): if not self.state.directory: return self.state.files = list_image_files(self.state.directory) self.file_listbox.delete(0, tk.END) for p in self.state.files: self.file_listbox.insert(tk.END, p.name) if self.state.files: self.state.current_index = 0 self.file_listbox.selection_set(0) self.load_image(0) else: self.state.current_index = 0 self.canvas_orig.delete("all") self.canvas_prev.delete("all") self.info_var.set("No image files found in directory.") self.update_cmd_display(clear=True) def on_file_select(self, event=None): sel = self.file_listbox.curselection() if not sel: return self.load_image(sel[0]) def load_image(self, index: int): if index < 0 or index >= len(self.state.files): return self.state.current_index = index path = self.state.files[index] try: img = Image.open(path) self.state.base_image = img self.state.image_size = img.size # Reset crop self.state.crop_rect_id = None self.state.crop_box_canvas = None dto = get_exif_datetime_original(path) info = f"{path.name}\n{img.size[0]}x{img.size[1]}" if dto: info += f"\nEXIF DateTimeOriginal: {dto}" # Attempt to restore saved options or directory defaults self.restore_options_for_file(path) # Build rotated original + preview self.rebuild_original_and_preview(reset_crop=True) self.info_var.set(info) except Exception as e: messagebox.showerror("Error", f"Failed to load image:\n{e}") def display_on_canvas(self, canvas: tk.Canvas, img: Image.Image, is_original: bool): """Scale and display img on the given canvas, center it.""" canvas_w = canvas.winfo_width() or 600 canvas_h = canvas.winfo_height() or 300 scale = min(canvas_w / img.size[0], canvas_h / img.size[1]) new_size = (max(1, int(img.size[0] * scale)), max(1, int(img.size[1] * scale))) disp_img = img.resize(new_size, Image.LANCZOS) x0 = (canvas_w - new_size[0]) // 2 y0 = (canvas_h - new_size[1]) // 2 photo = ImageTk.PhotoImage(disp_img) canvas.delete("all") canvas.create_image(x0, y0, anchor=tk.NW, image=photo) canvas.image = photo # prevent GC if is_original: self.state.canvas_size_orig = new_size self.state.current_photo_orig = photo else: self.state.canvas_size_prev = new_size self.state.current_photo_prev = photo def prev_image(self): if not self.state.files: return idx = (self.state.current_index - 1) % len(self.state.files) self.file_listbox.selection_clear(0, tk.END) self.file_listbox.selection_set(idx) self.load_image(idx) def next_image(self): if not self.state.files: return idx = (self.state.current_index + 1) % len(self.state.files) self.file_listbox.selection_clear(0, tk.END) self.file_listbox.selection_set(idx) self.load_image(idx) # ----------------------------- canvas / cropping ------------------------- def on_canvas_press(self, event): self.state.crop_start = (event.x, event.y) if self.state.crop_rect_id is not None: self.canvas_orig.delete(self.state.crop_rect_id) self.state.crop_rect_id = None def on_canvas_drag(self, event): if not self.state.crop_start: return x0, y0 = self.state.crop_start x1, y1 = event.x, event.y if self.state.crop_rect_id is None: self.state.crop_rect_id = self.canvas_orig.create_rectangle( x0, y0, x1, y1, outline="yellow", width=2 ) else: self.canvas_orig.coords(self.state.crop_rect_id, x0, y0, x1, y1) self.state.crop_box_canvas = (x0, y0, x1, y1) self.update_preview() def on_canvas_release(self, event): pass def canvas_to_image_coords(self, x, y) -> Tuple[int, int]: if not self.state.current_image or not self.state.canvas_size_orig: return 0, 0 img_w, img_h = self.state.current_image.size disp_w, disp_h = self.state.canvas_size_orig canvas_w = self.canvas_orig.winfo_width() or disp_w canvas_h = self.canvas_orig.winfo_height() or disp_h x0 = (canvas_w - disp_w) // 2 y0 = (canvas_h - disp_h) // 2 x_clamped = min(max(x, x0), x0 + disp_w) y_clamped = min(max(y, y0), y0 + disp_h) rel_x = x_clamped - x0 rel_y = y_clamped - y0 scale_x = img_w / disp_w scale_y = img_h / disp_h img_x = int(rel_x * scale_x) img_y = int(rel_y * scale_y) return img_x, img_y def get_crop_box_image_coords(self) -> Optional[Tuple[int, int, int, int]]: if not self.state.crop_box_canvas: return None x0c, y0c, x1c, y1c = self.state.crop_box_canvas x0i, y0i = self.canvas_to_image_coords(x0c, y0c) x1i, y1i = self.canvas_to_image_coords(x1c, y1c) x1i, x2i = sorted((x0i, x1i)) y1i, y2i = sorted((y0i, y1i)) if x2i <= x1i or y2i <= y1i: return None return (x1i, y1i, x2i, y2i) # -------------------------- rotation helpers ----------------------------- def get_rotation_degrees(self) -> float: mode = self.rotation_mode_var.get() if mode == "None": return 0.0 if mode == "90": return 90.0 if mode == "180": return 180.0 if mode == "270": return 270.0 # Custom try: return float(self.rotation_custom_var.get()) except Exception: return 0.0 def set_rotation_ui_from_degrees(self, deg: float): # Normalize to [0,360) d = deg % 360 if abs(d) < 0.01 or abs(d - 360) < 0.01: self.rotation_mode_var.set("None") self.rotation_custom_var.set(0.0) elif abs(d - 90) < 0.01: self.rotation_mode_var.set("90") elif abs(d - 180) < 0.01: self.rotation_mode_var.set("180") elif abs(d - 270) < 0.01: self.rotation_mode_var.set("270") else: self.rotation_mode_var.set("Custom") self.rotation_custom_var.set(deg) def rotation_changed(self): """ When rotation changes, we need to rebuild the original and preview, and reset the crop (since the coordinates are no longer valid). """ self.state.crop_start = None if self.state.crop_rect_id is not None: self.canvas_orig.delete(self.state.crop_rect_id) self.state.crop_rect_id = None self.state.crop_box_canvas = None self.rebuild_original_and_preview(reset_crop=True) # -------------------------- actions & options ---------------------------- def apply_action(self, action_name: str): """ Apply an action preset by updating the checkboxes/params to match. """ opts = self.gather_options_from_ui(include_crop=False) apply_action_preset(opts, action_name) # Sync UI from modified options (except crop) self.color_var.set(opts.color_correction) self.sigmoidal_var.set(opts.sigmoidal) self.sig_contrast_var.set(opts.sigmoidal_contrast) self.sig_midpoint_var.set(opts.sigmoidal_midpoint) self.sharpen_var.set(opts.sharpen) self.sharp_radius_var.set(opts.sharpen_radius) self.sharp_amount_var.set(opts.sharpen_amount) self.sharp_thresh_var.set(opts.sharpen_threshold) self.gray_var.set(opts.grayscale) self.invert_var.set(opts.invert) self.background_var.set(opts.use_background) self.background_scale_var.set(opts.background_scale) self.rebuild_original_and_preview(reset_crop=False) def gather_options_from_ui(self, include_crop: bool = True) -> EditOptions: """ Construct an EditOptions instance from current UI state. """ crop_box = self.get_crop_box_image_coords() if include_crop else None opts = EditOptions( crop_box=crop_box, rotation_degrees=self.get_rotation_degrees(), color_correction=self.color_var.get(), sigmoidal=self.sigmoidal_var.get(), sigmoidal_contrast=self.sig_contrast_var.get(), sigmoidal_midpoint=self.sig_midpoint_var.get(), sharpen=self.sharpen_var.get(), sharpen_radius=self.sharp_radius_var.get(), sharpen_amount=self.sharp_amount_var.get(), sharpen_threshold=self.sharp_thresh_var.get(), grayscale=self.gray_var.get(), invert=self.invert_var.get(), use_background=self.background_var.get(), background_scale=self.background_scale_var.get(), annotation_text=self.annot_text.get("1.0", tk.END).strip(), annotation_pos=self.annot_pos_var.get(), annotation_size=self.annot_size_var.get(), ) return opts def restore_options_for_file(self, path: Path) -> None: """ Try to load saved options for the given file, else fall back to directory defaults, and update the UI. """ if not self.state.directory: return saved = load_options_for_file(self.state.directory, path) if not saved: saved = load_directory_defaults(self.state.directory) if not saved: # No prior settings; leave UI as-is. return self.color_var.set(saved.color_correction) self.sigmoidal_var.set(saved.sigmoidal) self.sig_contrast_var.set(saved.sigmoidal_contrast) self.sig_midpoint_var.set(saved.sigmoidal_midpoint) self.sharpen_var.set(saved.sharpen) self.sharp_radius_var.set(saved.sharpen_radius) self.sharp_amount_var.set(saved.sharpen_amount) self.sharp_thresh_var.set(saved.sharpen_threshold) self.gray_var.set(saved.grayscale) self.invert_var.set(saved.invert) self.background_var.set(saved.use_background) self.background_scale_var.set(saved.background_scale) self.set_rotation_ui_from_degrees(saved.rotation_degrees) self.annot_pos_var.set(saved.annotation_pos) self.annot_size_var.set(saved.annotation_size) self.annot_text.delete("1.0", tk.END) if saved.annotation_text: self.annot_text.insert("1.0", saved.annotation_text) def set_directory_defaults_from_current(self): """ Save the current UI options as directory defaults. """ if not self.state.directory: messagebox.showerror("Error", "No directory selected.") return opts = self.gather_options_from_ui(include_crop=False) save_directory_defaults(self.state.directory, opts) messagebox.showinfo("Directory defaults", "Defaults saved for this directory.") # ---------------------- preview rendering (Pillow) ----------------------- def rebuild_original_and_preview(self, reset_crop: bool = False): """ Rebuild the rotated original and the preview from base_image + options. """ if self.state.base_image is None: self.update_cmd_display(clear=True) return opts = self.gather_options_from_ui(include_crop=False) # Rotate base image according to opts rotated = self.state.base_image.rotate(opts.rotation_degrees, expand=True) self.state.current_image = rotated # Original view: rotated, no processing except resize self.display_on_canvas(self.canvas_orig, rotated, is_original=True) # Reset crop if asked if reset_crop: self.state.crop_box_canvas = None self.state.crop_start = None if self.state.crop_rect_id is not None: self.canvas_orig.delete(self.state.crop_rect_id) self.state.crop_rect_id = None # Preview: rotated + processing (including crop if present) self.update_preview() def apply_preview_effects(self, base_img: Image.Image, opts: EditOptions) -> Image.Image: """ Apply a subset of the edit options in-memory to generate a preview image. This approximates the IM pipeline but doesn't have to be pixel-identical. NOTE: Rotation is already baked into base_img; we do NOT apply it again here. """ img = base_img # Crop if opts.crop_box is not None: x1, y1, x2, y2 = opts.crop_box img = img.crop((x1, y1, x2, y2)) # Grayscale if opts.grayscale: img = img.convert("L").convert("RGB") # Invert if opts.invert: img = ImageOps.invert(img) # Sharpen (approximate IM unsharp) if opts.sharpen: radius = max(0.0, float(opts.sharpen_radius)) amount = max(0.0, float(opts.sharpen_amount)) thresh = max(0.0, float(opts.sharpen_threshold)) percent = int(amount * 100.0) threshold = int(thresh * 255.0) img = img.filter(ImageFilter.UnsharpMask(radius=radius or 1.0, percent=percent or 150, threshold=threshold)) # Simple annotation if opts.annotation_text.strip(): draw = ImageDraw.Draw(img) text = opts.annotation_text try: font = ImageFont.truetype("DejaVuSans.ttf", opts.annotation_size) except Exception: font = ImageFont.load_default() # Measure text size compatible with older Pillow try: bbox = draw.textbbox((0, 0), text, font=font) text_w = bbox[2] - bbox[0] text_h = bbox[3] - bbox[1] except AttributeError: text_w, text_h = font.getsize(text) w, h = img.size pos_name = opts.annotation_pos if pos_name == "Top": x = (w - text_w) // 2 y = int(0.02 * h) elif pos_name == "Center": x = (w - text_w) // 2 y = (h - text_h) // 2 else: # Bottom x = (w - text_w) // 2 y = int(h - text_h - 0.02 * h) # simple text with outline outline = 2 for dx in (-outline, outline): for dy in (-outline, outline): draw.text((x + dx, y + dy), text, font=font, fill="black") draw.text((x, y), text, font=font, fill="white") # Simple granite-like background preview if opts.use_background: subject = img scale = max(1.0, float(opts.background_scale)) bg_w = max(1, int(subject.width * scale)) bg_h = max(1, int(subject.height * scale)) try: noise = Image.effect_noise((bg_w, bg_h), 64) bg = noise.filter(ImageFilter.GaussianBlur(radius=1)) except Exception: bg = Image.new("L", (bg_w, bg_h), 128) if opts.grayscale: bg = bg.convert("L") bg = bg.convert("RGB") x = (bg_w - subject.width) // 2 y = (bg_h - subject.height) // 2 bg.paste(subject, (x, y)) img = bg return img def update_preview(self): """ Recompute and display the preview image (processed) on the bottom canvas. Also updates the IM command text. """ if not self.state.current_image: self.update_cmd_display(clear=True) return opts = self.gather_options_from_ui(include_crop=True) preview_img = self.apply_preview_effects(self.state.current_image.copy(), opts) self.display_on_canvas(self.canvas_prev, preview_img, is_original=False) self.update_cmd_display(clear=False) def options_changed(self): """Callback used by various controls to trigger preview update.""" self.update_preview() def on_annot_modified(self, event): """Handle <> event from the annotation Text widget.""" self.annot_text.edit_modified(False) self.update_preview() # ----------------------- IM command display / copy ----------------------- def update_cmd_display(self, clear: bool = False): """ Build and show the current main ImageMagick command. For directories with no image selected, or clear=True, the display is emptied. If 'Granite background' is checked, note that a second command will be run on Process, but we don't synthesize that here. """ if clear or not self.state.files: self.current_cmd_str = "" self.cmd_text.configure(state="normal") self.cmd_text.delete("1.0", tk.END) self.cmd_text.configure(state="disabled") return path = self.state.files[self.state.current_index] opts = self.gather_options_from_ui(include_crop=True) # Destination name (same as Process) dest = path.with_name(path.stem + "_edit.jpg") # For background, we still show the main command (first pass). if opts.use_background: tmp_subject = path.with_name(path.stem + "_tmp_subject.jpg") opts_main = EditOptions( crop_box=opts.crop_box, rotation_degrees=opts.rotation_degrees, color_correction=opts.color_correction, sigmoidal=opts.sigmoidal, sigmoidal_contrast=opts.sigmoidal_contrast, sigmoidal_midpoint=opts.sigmoidal_midpoint, sharpen=opts.sharpen, sharpen_radius=opts.sharpen_radius, sharpen_amount=opts.sharpen_amount, sharpen_threshold=opts.sharpen_threshold, grayscale=opts.grayscale, invert=opts.invert, use_background=False, background_scale=opts.background_scale, annotation_text=opts.annotation_text, annotation_pos=opts.annotation_pos, annotation_size=opts.annotation_size, extra_args=opts.extra_args, ) cmd = build_imagemagick_cmd(path, tmp_subject, opts_main) cmd_str = " ".join(cmd) cmd_str += "\n# Note: Granite background compositing is run as a second IM command on Process." else: cmd = build_imagemagick_cmd(path, dest, opts) cmd_str = " ".join(cmd) self.current_cmd_str = cmd_str self.cmd_text.configure(state="normal") self.cmd_text.delete("1.0", tk.END) self.cmd_text.insert("1.0", cmd_str) self.cmd_text.configure(state="disabled") def run_im_preview(self): """ Generate a preview using the *actual* ImageMagick pipeline, but resized down to roughly the preview canvas size for speed. It uses the same options as Process (including crop, rotation, etc.), writes to a temporary JPEG, and displays that in the Preview canvas. """ if not self.state.files or self.state.base_image is None: return if not self.state.directory: messagebox.showerror("Error", "No directory selected.") return path = self.state.files[self.state.current_index] opts = self.gather_options_from_ui(include_crop=True) # Determine preview size from preview canvas canvas_w = self.canvas_prev.winfo_width() or 800 canvas_h = self.canvas_prev.winfo_height() or 450 resize_arg = f"{canvas_w}x{canvas_h}" # We'll add a "-resize WxH" at the end of the main subject command. # That way, all IM operations are done at preview resolution. opts_preview = EditOptions( crop_box=opts.crop_box, rotation_degrees=opts.rotation_degrees, color_correction=opts.color_correction, sigmoidal=opts.sigmoidal, sigmoidal_contrast=opts.sigmoidal_contrast, sigmoidal_midpoint=opts.sigmoidal_midpoint, sharpen=opts.sharpen, sharpen_radius=opts.sharpen_radius, sharpen_amount=opts.sharpen_amount, sharpen_threshold=opts.sharpen_threshold, grayscale=opts.grayscale, invert=opts.invert, use_background=False, # background handled separately below background_scale=opts.background_scale, annotation_text=opts.annotation_text, annotation_pos=opts.annotation_pos, annotation_size=opts.annotation_size, extra_args=["-resize", resize_arg], ) # Temporary files for subject and final preview tmp_subject = None tmp_preview = None try: if not opts.use_background: # Single-command preview with tempfile.NamedTemporaryFile(suffix="_impreview.jpg", delete=False) as tf: tmp_preview = Path(tf.name) cmd = build_imagemagick_cmd(path, tmp_preview, opts_preview) try: subprocess.run(cmd, check=True) except subprocess.CalledProcessError as e: messagebox.showerror("IM Preview error", f"IM preview failed:\n{e}") return else: # Two-step: subject + granite background with tempfile.NamedTemporaryFile(suffix="_subject_impreview.jpg", delete=False) as tf1: tmp_subject = Path(tf1.name) with tempfile.NamedTemporaryFile(suffix="_impreview.jpg", delete=False) as tf2: tmp_preview = Path(tf2.name) # 1) Subject at preview resolution cmd1 = build_imagemagick_cmd(path, tmp_subject, opts_preview) try: subprocess.run(cmd1, check=True) except subprocess.CalledProcessError as e: messagebox.showerror("IM Preview error", f"IM subject preview failed:\n{e}") return # 2) Background + subject composite try: with Image.open(tmp_subject) as s_img: subject_size = s_img.size except Exception as e: messagebox.showerror("IM Preview error", f"Failed to open subject preview:\n{e}") return cmd2 = build_granite_background_cmd(tmp_subject, tmp_preview, opts, subject_size) try: subprocess.run(cmd2, check=True) except subprocess.CalledProcessError as e: messagebox.showerror("IM Preview error", f"IM background preview failed:\n{e}") return # Load and display the resulting preview JPEG if tmp_preview and tmp_preview.exists(): try: with Image.open(tmp_preview) as im_prev: im_prev = im_prev.convert("RGB") self.display_on_canvas(self.canvas_prev, im_prev, is_original=False) except Exception as e: messagebox.showerror("IM Preview error", f"Failed to load preview image:\n{e}") finally: # Clean up temp files try: if tmp_subject and tmp_subject.exists(): tmp_subject.unlink() except Exception: pass try: if tmp_preview and tmp_preview.exists(): tmp_preview.unlink() except Exception: pass def copy_cmd_to_clipboard(self): if not self.current_cmd_str: return self.clipboard_clear() self.clipboard_append(self.current_cmd_str) self.update() # make sure clipboard is updated # -------------------------- processing (IM) ------------------------------ def process_current(self): if not self.state.files: return path = self.state.files[self.state.current_index] if not self.state.directory: messagebox.showerror("Error", "Internal error: no directory set.") return opts = self.gather_options_from_ui(include_crop=True) dest = path.with_name(path.stem + "_edit.jpg") use_bg = opts.use_background if not use_bg: cmd = build_imagemagick_cmd(path, dest, opts) if not self.confirm_and_run(cmd): return else: tmp_subject = path.with_name(path.stem + "_tmp_subject.jpg") # First pass: main edits, no background opts_main = EditOptions( crop_box=opts.crop_box, rotation_degrees=opts.rotation_degrees, color_correction=opts.color_correction, sigmoidal=opts.sigmoidal, sigmoidal_contrast=opts.sigmoidal_contrast, sigmoidal_midpoint=opts.sigmoidal_midpoint, sharpen=opts.sharpen, sharpen_radius=opts.sharpen_radius, sharpen_amount=opts.sharpen_amount, sharpen_threshold=opts.sharpen_threshold, grayscale=opts.grayscale, invert=opts.invert, use_background=False, background_scale=opts.background_scale, annotation_text=opts.annotation_text, annotation_pos=opts.annotation_pos, annotation_size=opts.annotation_size, extra_args=opts.extra_args, ) cmd1 = build_imagemagick_cmd(path, tmp_subject, opts_main) if not self.confirm_and_run(cmd1): return # Determine subject size for background scaling try: with Image.open(tmp_subject) as s_img: subject_size = s_img.size except Exception as e: messagebox.showerror("Error", f"Failed to open temp subject:\n{e}") return cmd2 = build_granite_background_cmd(tmp_subject, dest, opts, subject_size) if not self.confirm_and_run(cmd2): return # Optionally clean up tmp_subject try: tmp_subject.unlink() except Exception: pass # On success, save the options for this file save_options_for_file(self.state.directory, path, opts) messagebox.showinfo("Done", f"Created {dest.name}") def confirm_and_run(self, cmd: List[str]) -> bool: """ Ask for confirmation, then run the given command. Returns True on success, False on failure or cancel. """ if not messagebox.askyesno( "Run ImageMagick", "About to run:\n\n" + " ".join(cmd) + "\n\nProceed?", ): return False try: subprocess.run(cmd, check=True) return True except subprocess.CalledProcessError as e: messagebox.showerror("Error", f"Processing failed:\n{e}") return False # ------------------------------- main --------------------------------------- def main(): app = WImageEditTk() app.mainloop() if __name__ == "__main__": main()