Changes to Tk port
This commit is contained in:
parent
b17647d2fa
commit
a6b0298253
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
Loading…
Reference in New Issue