diff --git a/ImageEditing/WImageEditTk.py b/ImageEditing/WImageEditTk.py index 0d0858a..e5e60bd 100644 --- a/ImageEditing/WImageEditTk.py +++ b/ImageEditing/WImageEditTk.py @@ -9,17 +9,22 @@ Tkinter-based GUI for WImageEdit, using wimage_core for: - 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: - Left = original (with rotation applied, no processing except resize) - Right = preview with current options applied (Pillow approximation). + - 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 @@ -49,6 +54,8 @@ from wimage_core import ( save_directory_defaults, ) +import tempfile + # ---------------------- helpers / file listing / EXIF ---------------------- @@ -111,8 +118,11 @@ class WImageEditTk(tk.Tk): self.state = AppState() + # IM command string (for display/copy) + self.current_cmd_str: str = "" + # UI variables - self.dir_var = tk.StringVar() + self.dir_var = tk.StringVar(value="~/Downloads") self.color_var = tk.BooleanVar(value=True) self.sigmoidal_var = tk.BooleanVar(value=True) @@ -145,19 +155,23 @@ class WImageEditTk(tk.Tk): # --------------------------- UI building --------------------------------- def _build_ui(self): - top = ttk.Frame(self, padding=5) - top.pack(side=tk.TOP, fill=tk.X) - - # 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) + # 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=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.bind("<>", 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="Next", command=self.next_image).pack(side=tk.LEFT, expand=True, fill=tk.X) - # Center: two canvases (Original + Preview) - center = ttk.Frame(self, padding=5) - center.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + ttk.Label(left, textvariable=self.info_var, wraplength=260, justify=tk.LEFT).pack(fill=tk.X, pady=5) - canvas_labels = ttk.Frame(center) - canvas_labels.pack(fill=tk.X) - #ttk.Label(canvas_labels, text="Original").pack(side=tk.LEFT, 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) + # 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) - canvases = ttk.Frame(center) - canvases.pack(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) - self.canvas_orig = tk.Canvas(canvases, bg="gray", width=600, height=400) - #self.canvas_orig.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 2)) - self.canvas_orig.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(0, 2)) + right_controls = ttk.Frame(right, padding=(5, 0)) + right_controls.pack(side=tk.LEFT, fill=tk.Y) - self.canvas_prev = tk.Canvas(canvases, bg="gray", width=600, height=400) - #self.canvas_prev.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(2, 0)) - self.canvas_prev.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=(2, 0)) + # 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) - # Right: controls - right = ttk.Frame(self, padding=5) - right.pack(side=tk.RIGHT, fill=tk.Y) - + # Controls on the right # 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) 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) # 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)) ttk.Checkbutton( @@ -296,7 +309,7 @@ class WImageEditTk(tk.Tk): ttk.Label(scale_row, text="(1.1 ≈ 10% border)").pack(side=tk.LEFT) # 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) def rot_rb(text, value): @@ -323,11 +336,11 @@ class WImageEditTk(tk.Tk): ).pack(side=tk.LEFT, padx=4) # 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) 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.bind("<>", self.on_annot_modified) @@ -356,24 +369,47 @@ class WImageEditTk(tk.Tk): ).pack(anchor=tk.W) # 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) ttk.Button( defaults_frame, text="Set from current image", command=self.set_directory_defaults_from_current ).pack(fill=tk.X) - # Actions buttons - action_frame = ttk.Frame(right) + # 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)) - ttk.Label( - right, textvariable=self.info_var, - wraplength=220, justify=tk.LEFT - ).pack(fill=tk.X, pady=5) - + # 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): @@ -400,6 +436,7 @@ class WImageEditTk(tk.Tk): 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() @@ -439,8 +476,8 @@ class WImageEditTk(tk.Tk): 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 400 - canvas_h = canvas.winfo_height() or 600 + 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) @@ -693,6 +730,7 @@ class WImageEditTk(tk.Tk): 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 @@ -806,13 +844,16 @@ class WImageEditTk(tk.Tk): 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: + 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.""" @@ -823,6 +864,186 @@ class WImageEditTk(tk.Tk): 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): @@ -867,7 +1088,6 @@ class WImageEditTk(tk.Tk): ) cmd1 = build_imagemagick_cmd(path, tmp_subject, opts_main) - print(cmd1) if not self.confirm_and_run(cmd1): return @@ -880,7 +1100,6 @@ class WImageEditTk(tk.Tk): return cmd2 = build_granite_background_cmd(tmp_subject, dest, opts, subject_size) - print(cmd2) if not self.confirm_and_run(cmd2): return @@ -916,11 +1135,10 @@ class WImageEditTk(tk.Tk): def main(): - print("start ...") app = WImageEditTk() app.mainloop() - print("done.") if __name__ == "__main__": main() + diff --git a/ImageEditing/wimage_core.py b/ImageEditing/wimage_core.py index a3f8ff7..9de29ec 100644 --- a/ImageEditing/wimage_core.py +++ b/ImageEditing/wimage_core.py @@ -110,7 +110,8 @@ def build_imagemagick_cmd( # Tone & color operations 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: # Clamp / format midpoint to something IM likes: 0–1