#!/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"]) cmd.extend(["-channel", "RGB", "-auto-level", "-contrast-stretch"]) 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)