378 lines
11 KiB
Python
378 lines
11 KiB
Python
#!/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)
|
||
|