Initial Tkinter GUI

This commit is contained in:
Wesley R. Elsberry 2025-12-28 00:10:52 -05:00
parent a6b0298253
commit 725b3c351e
2 changed files with 275 additions and 56 deletions

View File

@ -9,17 +9,22 @@ Tkinter-based GUI for WImageEdit, using wimage_core for:
- Simple per-file save/load of settings (imgedit_proc.json). - Simple per-file save/load of settings (imgedit_proc.json).
- Directory defaults. - 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: This version:
- Shows TWO views: - Shows TWO views stacked vertically:
Left = original (with rotation applied, no processing except resize) Top = original (rotated, no processing except resize)
Right = preview with current options applied (Pillow approximation). Bottom = preview with current options applied (Pillow approximation).
- Includes parametric controls for: - Includes parametric controls for:
- Sigmoidal contrast (contrast, midpoint) - Sigmoidal contrast (contrast, midpoint)
- Sharpen (radius, amount, threshold) - Sharpen (radius, amount, threshold)
- Includes rotation controls: - Includes rotation controls:
- None / 90 / 180 / 270 / Custom - None / 90 / 180 / 270 / Custom
- Displays a live ImageMagick command string based on current options.
""" """
import sys, os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -49,6 +54,8 @@ from wimage_core import (
save_directory_defaults, save_directory_defaults,
) )
import tempfile
# ---------------------- helpers / file listing / EXIF ---------------------- # ---------------------- helpers / file listing / EXIF ----------------------
@ -111,8 +118,11 @@ class WImageEditTk(tk.Tk):
self.state = AppState() self.state = AppState()
# IM command string (for display/copy)
self.current_cmd_str: str = ""
# UI variables # UI variables
self.dir_var = tk.StringVar() self.dir_var = tk.StringVar(value="~/Downloads")
self.color_var = tk.BooleanVar(value=True) self.color_var = tk.BooleanVar(value=True)
self.sigmoidal_var = tk.BooleanVar(value=True) self.sigmoidal_var = tk.BooleanVar(value=True)
@ -145,19 +155,23 @@ class WImageEditTk(tk.Tk):
# --------------------------- UI building --------------------------------- # --------------------------- UI building ---------------------------------
def _build_ui(self): def _build_ui(self):
top = ttk.Frame(self, padding=5) # Main frame splits left (files) and right (images + controls)
top.pack(side=tk.TOP, fill=tk.X) main = ttk.Frame(self, padding=5)
main.pack(fill=tk.BOTH, expand=True)
# Left: file list + nav
left = ttk.Frame(self, padding=5)
ttk.Label(left, text="Dir:").pack(side=tk.LEFT)
ttk.Entry(left, textvariable=self.dir_var, width=30).pack(side=tk.LEFT, padx=4)
ttk.Button(left, text="Browse...", command=self.select_directory).pack(side=tk.LEFT)
# LEFT PANEL: directory picker + file list + nav + info
left = ttk.Frame(main, padding=5)
left.pack(side=tk.LEFT, fill=tk.Y) 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) ttk.Label(left, text="Files:").pack(anchor=tk.W)
self.file_listbox = tk.Listbox(left, width=40, height=25) self.file_listbox = tk.Listbox(left, width=35, height=24)
self.file_listbox.pack(fill=tk.BOTH, expand=True) self.file_listbox.pack(fill=tk.BOTH, expand=True)
self.file_listbox.bind("<<ListboxSelect>>", self.on_file_select) self.file_listbox.bind("<<ListboxSelect>>", self.on_file_select)
@ -166,39 +180,38 @@ class WImageEditTk(tk.Tk):
ttk.Button(nav_frame, text="Prev", command=self.prev_image).pack(side=tk.LEFT, expand=True, fill=tk.X) 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.Button(nav_frame, text="Next", command=self.next_image).pack(side=tk.LEFT, expand=True, fill=tk.X)
# Center: two canvases (Original + Preview) ttk.Label(left, textvariable=self.info_var, wraplength=260, justify=tk.LEFT).pack(fill=tk.X, pady=5)
center = ttk.Frame(self, padding=5)
center.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
canvas_labels = ttk.Frame(center) # RIGHT PANEL: top canvas (original), bottom canvas (preview), controls on the right
canvas_labels.pack(fill=tk.X) right = ttk.Frame(main, padding=5)
#ttk.Label(canvas_labels, text="Original").pack(side=tk.LEFT, expand=True) right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
ttk.Label(canvas_labels, text="Original").pack(side=tk.TOP, expand=True)
#ttk.Label(canvas_labels, text="Preview").pack(side=tk.RIGHT, expand=True)
ttk.Label(canvas_labels, text="Preview").pack(side=tk.BOTTOM, expand=True)
canvases = ttk.Frame(center) # Right is split: left area = canvases (stacked), right area = controls
canvases.pack(fill=tk.BOTH, expand=True) right_canvases = ttk.Frame(right)
right_canvases.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.canvas_orig = tk.Canvas(canvases, bg="gray", width=600, height=400) right_controls = ttk.Frame(right, padding=(5, 0))
#self.canvas_orig.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 2)) right_controls.pack(side=tk.LEFT, fill=tk.Y)
self.canvas_orig.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(0, 2))
self.canvas_prev = tk.Canvas(canvases, bg="gray", width=600, height=400) # Canvases stacked: Original (top) and Preview (bottom)
#self.canvas_prev.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(2, 0)) orig_label = ttk.Label(right_canvases, text="Original")
self.canvas_prev.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=(2, 0)) 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 # Crop is defined by dragging on the ORIGINAL canvas
self.canvas_orig.bind("<ButtonPress-1>", self.on_canvas_press) self.canvas_orig.bind("<ButtonPress-1>", self.on_canvas_press)
self.canvas_orig.bind("<B1-Motion>", self.on_canvas_drag) self.canvas_orig.bind("<B1-Motion>", self.on_canvas_drag)
self.canvas_orig.bind("<ButtonRelease-1>", self.on_canvas_release) self.canvas_orig.bind("<ButtonRelease-1>", self.on_canvas_release)
# Right: controls # Controls on the right
right = ttk.Frame(self, padding=5)
right.pack(side=tk.RIGHT, fill=tk.Y)
# Actions frame # Actions frame
actions_frame = ttk.LabelFrame(right, text="Actions", padding=5) actions_frame = ttk.LabelFrame(right_controls, text="Actions", padding=5)
actions_frame.pack(fill=tk.X) actions_frame.pack(fill=tk.X)
ttk.Button(actions_frame, text="Color Pos", ttk.Button(actions_frame, text="Color Pos",
@ -211,7 +224,7 @@ class WImageEditTk(tk.Tk):
command=lambda: self.apply_action("BW Neg")).pack(fill=tk.X, pady=1) command=lambda: self.apply_action("BW Neg")).pack(fill=tk.X, pady=1)
# Processing options # Processing options
opts_frame = ttk.LabelFrame(right, text="Processing", padding=5) opts_frame = ttk.LabelFrame(right_controls, text="Processing", padding=5)
opts_frame.pack(fill=tk.X, pady=(5, 0)) opts_frame.pack(fill=tk.X, pady=(5, 0))
ttk.Checkbutton( ttk.Checkbutton(
@ -296,7 +309,7 @@ class WImageEditTk(tk.Tk):
ttk.Label(scale_row, text="(1.1 ≈ 10% border)").pack(side=tk.LEFT) ttk.Label(scale_row, text="(1.1 ≈ 10% border)").pack(side=tk.LEFT)
# Rotation controls # Rotation controls
rot_frame = ttk.LabelFrame(right, text="Rotation", padding=5) rot_frame = ttk.LabelFrame(right_controls, text="Rotation", padding=5)
rot_frame.pack(fill=tk.X, pady=5) rot_frame.pack(fill=tk.X, pady=5)
def rot_rb(text, value): def rot_rb(text, value):
@ -323,11 +336,11 @@ class WImageEditTk(tk.Tk):
).pack(side=tk.LEFT, padx=4) ).pack(side=tk.LEFT, padx=4)
# Annotation # Annotation
annot_frame = ttk.LabelFrame(right, text="Text annotation", padding=5) annot_frame = ttk.LabelFrame(right_controls, text="Text annotation", padding=5)
annot_frame.pack(fill=tk.X, pady=5) annot_frame.pack(fill=tk.X, pady=5)
ttk.Label(annot_frame, text="Text:").pack(anchor=tk.W) ttk.Label(annot_frame, text="Text:").pack(anchor=tk.W)
self.annot_text = tk.Text(annot_frame, width=30, height=4) self.annot_text = tk.Text(annot_frame, width=30, height=3)
self.annot_text.pack(fill=tk.X) self.annot_text.pack(fill=tk.X)
self.annot_text.bind("<<Modified>>", self.on_annot_modified) self.annot_text.bind("<<Modified>>", self.on_annot_modified)
@ -356,24 +369,47 @@ class WImageEditTk(tk.Tk):
).pack(anchor=tk.W) ).pack(anchor=tk.W)
# Directory defaults # Directory defaults
defaults_frame = ttk.LabelFrame(right, text="Directory defaults", padding=5) defaults_frame = ttk.LabelFrame(right_controls, text="Directory defaults", padding=5)
defaults_frame.pack(fill=tk.X, pady=5) defaults_frame.pack(fill=tk.X, pady=5)
ttk.Button( ttk.Button(
defaults_frame, text="Set from current image", defaults_frame, text="Set from current image",
command=self.set_directory_defaults_from_current command=self.set_directory_defaults_from_current
).pack(fill=tk.X) ).pack(fill=tk.X)
# Actions buttons # IM command display
action_frame = ttk.Frame(right) 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) 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="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)) ttk.Button(action_frame, text="Quit", command=self.destroy).pack(fill=tk.X, pady=(5, 0))
ttk.Label( # Initial action
right, textvariable=self.info_var, # print(dir(self.dir_var))
wraplength=220, justify=tk.LEFT if os.path.exists(self.dir_var.get()):
).pack(fill=tk.X, pady=5) self.state.directory = Path(self.dir_var.get())
self.load_files()
# ------------------------------ directory & files ------------------------ # ------------------------------ directory & files ------------------------
def select_directory(self): def select_directory(self):
@ -400,6 +436,7 @@ class WImageEditTk(tk.Tk):
self.canvas_orig.delete("all") self.canvas_orig.delete("all")
self.canvas_prev.delete("all") self.canvas_prev.delete("all")
self.info_var.set("No image files found in directory.") self.info_var.set("No image files found in directory.")
self.update_cmd_display(clear=True)
def on_file_select(self, event=None): def on_file_select(self, event=None):
sel = self.file_listbox.curselection() sel = self.file_listbox.curselection()
@ -439,8 +476,8 @@ class WImageEditTk(tk.Tk):
def display_on_canvas(self, canvas: tk.Canvas, img: Image.Image, is_original: bool): def display_on_canvas(self, canvas: tk.Canvas, img: Image.Image, is_original: bool):
"""Scale and display img on the given canvas, center it.""" """Scale and display img on the given canvas, center it."""
canvas_w = canvas.winfo_width() or 400 canvas_w = canvas.winfo_width() or 600
canvas_h = canvas.winfo_height() or 600 canvas_h = canvas.winfo_height() or 300
scale = min(canvas_w / img.size[0], canvas_h / img.size[1]) 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))) new_size = (max(1, int(img.size[0] * scale)), max(1, int(img.size[1] * scale)))
disp_img = img.resize(new_size, Image.LANCZOS) disp_img = img.resize(new_size, Image.LANCZOS)
@ -693,6 +730,7 @@ class WImageEditTk(tk.Tk):
Rebuild the rotated original and the preview from base_image + options. Rebuild the rotated original and the preview from base_image + options.
""" """
if self.state.base_image is None: if self.state.base_image is None:
self.update_cmd_display(clear=True)
return return
opts = self.gather_options_from_ui(include_crop=False) opts = self.gather_options_from_ui(include_crop=False)
# Rotate base image according to opts # Rotate base image according to opts
@ -806,13 +844,16 @@ class WImageEditTk(tk.Tk):
def update_preview(self): def update_preview(self):
""" """
Recompute and display the preview image (processed) on the right canvas. Recompute and display the preview image (processed) on the bottom canvas.
Also updates the IM command text.
""" """
if not self.state.current_image: if not self.state.current_image:
self.update_cmd_display(clear=True)
return return
opts = self.gather_options_from_ui(include_crop=True) opts = self.gather_options_from_ui(include_crop=True)
preview_img = self.apply_preview_effects(self.state.current_image.copy(), opts) 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.display_on_canvas(self.canvas_prev, preview_img, is_original=False)
self.update_cmd_display(clear=False)
def options_changed(self): def options_changed(self):
"""Callback used by various controls to trigger preview update.""" """Callback used by various controls to trigger preview update."""
@ -823,6 +864,186 @@ class WImageEditTk(tk.Tk):
self.annot_text.edit_modified(False) self.annot_text.edit_modified(False)
self.update_preview() 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) ------------------------------ # -------------------------- processing (IM) ------------------------------
def process_current(self): def process_current(self):
@ -867,7 +1088,6 @@ class WImageEditTk(tk.Tk):
) )
cmd1 = build_imagemagick_cmd(path, tmp_subject, opts_main) cmd1 = build_imagemagick_cmd(path, tmp_subject, opts_main)
print(cmd1)
if not self.confirm_and_run(cmd1): if not self.confirm_and_run(cmd1):
return return
@ -880,7 +1100,6 @@ class WImageEditTk(tk.Tk):
return return
cmd2 = build_granite_background_cmd(tmp_subject, dest, opts, subject_size) cmd2 = build_granite_background_cmd(tmp_subject, dest, opts, subject_size)
print(cmd2)
if not self.confirm_and_run(cmd2): if not self.confirm_and_run(cmd2):
return return
@ -916,11 +1135,10 @@ class WImageEditTk(tk.Tk):
def main(): def main():
print("start ...")
app = WImageEditTk() app = WImageEditTk()
app.mainloop() app.mainloop()
print("done.")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -110,7 +110,8 @@ def build_imagemagick_cmd(
# Tone & color operations # Tone & color operations
if opts.color_correction: if opts.color_correction:
cmd.extend(["-channel", "RGB", "-auto-level", "-auto-gamma"]) # cmd.extend(["-channel", "RGB", "-auto-level", "-auto-gamma"])
cmd.extend(["-channel", "RGB", "-auto-level", "-contrast-stretch"])
if opts.sigmoidal: if opts.sigmoidal:
# Clamp / format midpoint to something IM likes: 01 # Clamp / format midpoint to something IM likes: 01