From a6b0298253e94547b7195511dcd3464c7d8197e2 Mon Sep 17 00:00:00 2001 From: "Wesley R. Elsberry" Date: Sat, 29 Nov 2025 14:58:01 -0500 Subject: [PATCH] Changes to Tk port --- ImageEditing/WImageEditTk.py | 926 +++++++++++++++++++++++++++++++++++ ImageEditing/wimage_core.py | 376 ++++++++++++++ 2 files changed, 1302 insertions(+) create mode 100644 ImageEditing/WImageEditTk.py create mode 100644 ImageEditing/wimage_core.py diff --git a/ImageEditing/WImageEditTk.py b/ImageEditing/WImageEditTk.py new file mode 100644 index 0000000..0d0858a --- /dev/null +++ b/ImageEditing/WImageEditTk.py @@ -0,0 +1,926 @@ +#!/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. + +This version: + - Shows TWO views: + Left = original (with rotation applied, no processing except resize) + Right = 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 +""" + +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, +) + + +# ---------------------- 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() + + # UI variables + self.dir_var = tk.StringVar() + + 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): + 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) + + left.pack(side=tk.LEFT, fill=tk.Y) + + ttk.Label(left, text="Files:").pack(anchor=tk.W) + self.file_listbox = tk.Listbox(left, width=40, height=25) + 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) + + # Center: two canvases (Original + Preview) + center = ttk.Frame(self, padding=5) + center.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + 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) + + canvases = ttk.Frame(center) + canvases.pack(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)) + + 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)) + + # 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) + + # Actions frame + actions_frame = ttk.LabelFrame(right, 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, 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, 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, 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.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, 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) + 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) + + # ------------------------------ 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.") + + 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 400 + canvas_h = canvas.winfo_height() or 600 + 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: + 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 right canvas. + """ + if not self.state.current_image: + 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) + + 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() + + # -------------------------- 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) + print(cmd1) + 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) + print(cmd2) + 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(): + print("start ...") + app = WImageEditTk() + app.mainloop() + print("done.") + + +if __name__ == "__main__": + main() diff --git a/ImageEditing/wimage_core.py b/ImageEditing/wimage_core.py new file mode 100644 index 0000000..a3f8ff7 --- /dev/null +++ b/ImageEditing/wimage_core.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +""" +wimage_core.py + +Core image-editing logic for WImageEdit-style tools. + +This module is GUI-agnostic. It defines: +- EditOptions: a dataclass describing the operations to apply. +- build_imagemagick_cmd(): build an ImageMagick command list for the main edits. +- build_granite_background_cmd(): wrap a processed image in a granite background. +- Simple JSON persistence for per-file settings and directory defaults. + +Typical GUI flow: + 1. Construct an EditOptions instance from UI. + 2. Call build_imagemagick_cmd() to make a “subject” image. + 3. Optionally call build_granite_background_cmd() if use_background is True. + 4. Save/load EditOptions via the persistence helpers. +""" +import sys +import os +import traceback +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Optional, Tuple, List, Dict, Any +import json + + +# --------------------------------------------------------------------------- +# Options structure +# --------------------------------------------------------------------------- + +@dataclass +class EditOptions: + """High-level edit options to map onto ImageMagick flags.""" + # Geometry + crop_box: Optional[Tuple[int, int, int, int]] = None # (x1, y1, x2, y2) in image coords + rotation_degrees: float = 0.0 # positive = CCW + + # Basic tone/sharpness + color_correction: bool = True + + sigmoidal: bool = True + sigmoidal_contrast: float = 6.0 # “contrast” param + sigmoidal_midpoint: float = 0.5 # typically 0–1 + + sharpen: bool = True + sharpen_radius: float = 0.0 # 0 means “auto” / IM default + sharpen_amount: float = 0.5 # gain + sharpen_threshold: float = 0.02 # threshold + + grayscale: bool = False + invert: bool = False # for negatives + + # Background composition + use_background: bool = False + background_scale: float = 1.1 # >1 means background larger than subject + + # Text annotation + annotation_text: str = "" + annotation_pos: str = "Bottom" # "Top" | "Center" | "Bottom" + annotation_size: int = 32 + + # Future extensibility: custom IM args + extra_args: Optional[List[str]] = None + + +def _gravity_from_pos(pos: str) -> str: + """Map a logical position to ImageMagick gravity.""" + mapping = { + "Top": "North", + "Center": "Center", + "Bottom": "South", + } + return mapping.get(pos, "South") + + +# --------------------------------------------------------------------------- +# Command builders +# --------------------------------------------------------------------------- + +def build_imagemagick_cmd( + src: Path, + dest: Path, + opts: EditOptions, + magick_binary: str = "magick", +) -> List[str]: + """ + Build an ImageMagick command as a list of arguments for the *main* edit. + This step does NOT include the granite background; that is handled + separately by build_granite_background_cmd() if desired. + + Order: + - rotate (if any) + - crop (if any) + - tone/contrast/sharpen/etc. + - annotation + """ + cmd: List[str] = [magick_binary, str(src)] + + # Rotate first so crop box is in rotated coordinates + if abs(opts.rotation_degrees) > 0.01: + cmd.extend(["-rotate", f"{opts.rotation_degrees}"]) + + # Crop + if opts.crop_box is not None: + x1, y1, x2, y2 = opts.crop_box + w = max(1, x2 - x1) + h = max(1, y2 - y1) + cmd.extend(["-crop", f"{w}x{h}+{x1}+{y1}", "+repage"]) + + # Tone & color operations + if opts.color_correction: + cmd.extend(["-channel", "RGB", "-auto-level", "-auto-gamma"]) + + if opts.sigmoidal: + # Clamp / format midpoint to something IM likes: 0–1 + mid = max(0.0, min(1.0, float(opts.sigmoidal_midpoint))) + cmd.extend(["-sigmoidal-contrast", f"{opts.sigmoidal_contrast},{mid}"]) + + if opts.sharpen: + # IM unsharp: radiusxsigma+amount+threshold + # We'll fix sigma=1 and let radius/amount/threshold be user-tunable. + radius = max(0.0, float(opts.sharpen_radius)) + amount = max(0.0, float(opts.sharpen_amount)) + thresh = max(0.0, float(opts.sharpen_threshold)) + cmd.extend(["-unsharp", f"{radius}x1+{amount}+{thresh}"]) + + if opts.grayscale: + cmd.extend(["-colorspace", "Gray"]) + + if opts.invert: + cmd.append("-negate") + + # Extra args hook + if opts.extra_args: + cmd.extend(opts.extra_args) + + # Annotation + if opts.annotation_text.strip(): + gravity = _gravity_from_pos(opts.annotation_pos) + cmd.extend([ + "-gravity", gravity, + "-pointsize", str(opts.annotation_size), + "-fill", "white", + "-stroke", "black", + "-strokewidth", "2", + "-annotate", "+0+20", opts.annotation_text, + ]) + + cmd.append(str(dest)) + print(cmd) + return cmd + + +def build_granite_background_cmd( + subject: Path, + dest: Path, + opts: EditOptions, + subject_size: Tuple[int, int], + magick_binary: str = "magick", +) -> List[str]: + """ + Build an ImageMagick command that: + - Creates a granite background. + - Scales it slightly larger than the subject image (background_scale). + - (Optionally) makes the background grayscale. + - Centers the subject on top of the background. + """ + sw, sh = subject_size + scale_factor = max(1.0, float(opts.background_scale)) + target_w = max(1, int(sw * scale_factor)) + target_h = max(1, int(sh * scale_factor)) + + cmd: List[str] = [ + magick_binary, + "granite:", + "-sigmoidal-contrast", "4,99%", + "-crop", "128x96+0+0", + "-resize", f"{target_w}x{target_h}", + ] + + if opts.grayscale: + cmd.extend(["-colorspace", "Gray"]) + + cmd.extend([ + "+repage", + str(subject), + "-gravity", "center", + "-compose", "over", + "-composite", + str(dest), + ]) + print(cmd) + return cmd + + +# --------------------------------------------------------------------------- +# Action presets +# --------------------------------------------------------------------------- + +ACTION_PRESETS: Dict[str, Dict[str, object]] = { + "Color Pos": { + "color_correction": True, + "sigmoidal": True, + "sigmoidal_contrast": 6.0, + "sigmoidal_midpoint": 0.5, + "sharpen": True, + "sharpen_radius": 0.0, + "sharpen_amount": 0.5, + "sharpen_threshold": 0.02, + "grayscale": False, + "invert": False, + "use_background": False, + }, + "Color Neg": { + "color_correction": True, + "sigmoidal": True, + "sigmoidal_contrast": 6.0, + "sigmoidal_midpoint": 0.5, + "sharpen": True, + "sharpen_radius": 0.0, + "sharpen_amount": 0.5, + "sharpen_threshold": 0.02, + "grayscale": False, + "invert": True, + "use_background": False, + }, + "BW Pos": { + "color_correction": True, + "sigmoidal": True, + "sigmoidal_contrast": 6.0, + "sigmoidal_midpoint": 0.5, + "sharpen": True, + "sharpen_radius": 0.0, + "sharpen_amount": 0.5, + "sharpen_threshold": 0.02, + "grayscale": True, + "invert": False, + "use_background": False, + }, + "BW Neg": { + "color_correction": True, + "sigmoidal": True, + "sigmoidal_contrast": 6.0, + "sigmoidal_midpoint": 0.5, + "sharpen": True, + "sharpen_radius": 0.0, + "sharpen_amount": 0.5, + "sharpen_threshold": 0.02, + "grayscale": True, + "invert": True, + "use_background": False, + }, +} + + +def apply_action_preset(opts: EditOptions, action_name: str) -> EditOptions: + """ + Modify an EditOptions instance in-place according to an action preset. + Returns the same instance for convenience. + """ + preset = ACTION_PRESETS.get(action_name) + if not preset: + return opts + for field_name, value in preset.items(): + setattr(opts, field_name, value) + print(opts) + return opts + + +# --------------------------------------------------------------------------- +# Persistence (imgedit_proc.json) +# --------------------------------------------------------------------------- + +PROCJSON_NAME = "imgedit_proc.json" + + +def _options_to_dict(opts: EditOptions) -> Dict[str, Any]: + """ + Convert EditOptions to a JSON-serializable dict. + We intentionally do NOT persist crop_box (crop tends to be per-session). + """ + d = asdict(opts) + d.pop("crop_box", None) + print(d) + return d + + +def _options_from_dict(d: Dict[str, Any]) -> EditOptions: + """ + Convert a dict back into an EditOptions instance. + Missing keys fall back to EditOptions defaults. + """ + base = EditOptions() + for k, v in d.items(): + if hasattr(base, k): + setattr(base, k, v) + return base + + +def load_proc_db(directory: Path) -> Dict[str, Any]: + """ + Load (or initialize) the processing metadata JSON for a directory. + Structure: + { + "images": { + "filename.jpg": { ... options dict ... }, + ... + }, + "defaults": { ... options dict ... } # optional + } + """ + p = directory / PROCJSON_NAME + print(p) + if not p.exists(): + return {"images": {}, "defaults": {}} + try: + with p.open("r", encoding="utf-8") as f: + db = json.load(f) + except Exception: + db = {"images": {}, "defaults": {}} + if "images" not in db or not isinstance(db["images"], dict): + db["images"] = {} + if "defaults" not in db or not isinstance(db["defaults"], dict): + db["defaults"] = {} + return db + + +def save_proc_db(directory: Path, db: Dict[str, Any]) -> None: + p = directory / PROCJSON_NAME + with p.open("w", encoding="utf-8") as f: + json.dump(db, f, indent=4) + + +def load_options_for_file(directory: Path, filename: Path) -> Optional[EditOptions]: + """ + Load EditOptions for a given file, if present (does NOT fall back to defaults). + """ + db = load_proc_db(directory) + img_key = filename.name + od = db["images"].get(img_key) + if not od: + print(f"No optiions found for {filename}") + return None + return _options_from_dict(od) + + +def save_options_for_file(directory: Path, filename: Path, opts: EditOptions) -> None: + """ + Save EditOptions for a given file. + """ + db = load_proc_db(directory) + img_key = filename.name + db["images"][img_key] = _options_to_dict(opts) + save_proc_db(directory, db) + + +def load_directory_defaults(directory: Path) -> Optional[EditOptions]: + """ + Load directory-wide default options, if any. + """ + db = load_proc_db(directory) + d = db.get("defaults") or {} + if not d: + return None + return _options_from_dict(d) + + +def save_directory_defaults(directory: Path, opts: EditOptions) -> None: + """ + Save directory-wide default options. + """ + db = load_proc_db(directory) + db["defaults"] = _options_to_dict(opts) + save_proc_db(directory, db) +