1145 lines
43 KiB
Python
1145 lines
43 KiB
Python
#!/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("<<ListboxSelect>>", 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("<ButtonPress-1>", self.on_canvas_press)
|
|
self.canvas_orig.bind("<B1-Motion>", self.on_canvas_drag)
|
|
self.canvas_orig.bind("<ButtonRelease-1>", 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("<<Modified>>", 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 <<Modified>> 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()
|
|
|