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