Changes to Tk port

This commit is contained in:
Wesley R. Elsberry 2025-11-29 14:58:01 -05:00
parent b17647d2fa
commit a6b0298253
2 changed files with 1302 additions and 0 deletions

View File

@ -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("<<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)
# 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("<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)
# 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("<<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, 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 <<Modified>> 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()

376
ImageEditing/wimage_core.py Normal file
View File

@ -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 01
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: 01
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)