MemorySharing/ImageEditing/wimage_core.py

378 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
wimage_core.py
Core image-editing logic for WImageEdit-style tools.
This module is GUI-agnostic. It defines:
- EditOptions: a dataclass describing the operations to apply.
- build_imagemagick_cmd(): build an ImageMagick command list for the main edits.
- build_granite_background_cmd(): wrap a processed image in a granite background.
- Simple JSON persistence for per-file settings and directory defaults.
Typical GUI flow:
1. Construct an EditOptions instance from UI.
2. Call build_imagemagick_cmd() to make a “subject” image.
3. Optionally call build_granite_background_cmd() if use_background is True.
4. Save/load EditOptions via the persistence helpers.
"""
import sys
import os
import traceback
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
import json
# ---------------------------------------------------------------------------
# Options structure
# ---------------------------------------------------------------------------
@dataclass
class EditOptions:
"""High-level edit options to map onto ImageMagick flags."""
# Geometry
crop_box: Optional[Tuple[int, int, int, int]] = None # (x1, y1, x2, y2) in image coords
rotation_degrees: float = 0.0 # positive = CCW
# Basic tone/sharpness
color_correction: bool = True
sigmoidal: bool = True
sigmoidal_contrast: float = 6.0 # “contrast” param
sigmoidal_midpoint: float = 0.5 # typically 01
sharpen: bool = True
sharpen_radius: float = 0.0 # 0 means “auto” / IM default
sharpen_amount: float = 0.5 # gain
sharpen_threshold: float = 0.02 # threshold
grayscale: bool = False
invert: bool = False # for negatives
# Background composition
use_background: bool = False
background_scale: float = 1.1 # >1 means background larger than subject
# Text annotation
annotation_text: str = ""
annotation_pos: str = "Bottom" # "Top" | "Center" | "Bottom"
annotation_size: int = 32
# Future extensibility: custom IM args
extra_args: Optional[List[str]] = None
def _gravity_from_pos(pos: str) -> str:
"""Map a logical position to ImageMagick gravity."""
mapping = {
"Top": "North",
"Center": "Center",
"Bottom": "South",
}
return mapping.get(pos, "South")
# ---------------------------------------------------------------------------
# Command builders
# ---------------------------------------------------------------------------
def build_imagemagick_cmd(
src: Path,
dest: Path,
opts: EditOptions,
magick_binary: str = "magick",
) -> List[str]:
"""
Build an ImageMagick command as a list of arguments for the *main* edit.
This step does NOT include the granite background; that is handled
separately by build_granite_background_cmd() if desired.
Order:
- rotate (if any)
- crop (if any)
- tone/contrast/sharpen/etc.
- annotation
"""
cmd: List[str] = [magick_binary, str(src)]
# Rotate first so crop box is in rotated coordinates
if abs(opts.rotation_degrees) > 0.01:
cmd.extend(["-rotate", f"{opts.rotation_degrees}"])
# Crop
if opts.crop_box is not None:
x1, y1, x2, y2 = opts.crop_box
w = max(1, x2 - x1)
h = max(1, y2 - y1)
cmd.extend(["-crop", f"{w}x{h}+{x1}+{y1}", "+repage"])
# Tone & color operations
if opts.color_correction:
# cmd.extend(["-channel", "RGB", "-auto-level", "-auto-gamma"])
cmd.extend(["-channel", "RGB", "-auto-level", "-contrast-stretch"])
if opts.sigmoidal:
# Clamp / format midpoint to something IM likes: 01
mid = max(0.0, min(1.0, float(opts.sigmoidal_midpoint)))
cmd.extend(["-sigmoidal-contrast", f"{opts.sigmoidal_contrast},{mid}"])
if opts.sharpen:
# IM unsharp: radiusxsigma+amount+threshold
# We'll fix sigma=1 and let radius/amount/threshold be user-tunable.
radius = max(0.0, float(opts.sharpen_radius))
amount = max(0.0, float(opts.sharpen_amount))
thresh = max(0.0, float(opts.sharpen_threshold))
cmd.extend(["-unsharp", f"{radius}x1+{amount}+{thresh}"])
if opts.grayscale:
cmd.extend(["-colorspace", "Gray"])
if opts.invert:
cmd.append("-negate")
# Extra args hook
if opts.extra_args:
cmd.extend(opts.extra_args)
# Annotation
if opts.annotation_text.strip():
gravity = _gravity_from_pos(opts.annotation_pos)
cmd.extend([
"-gravity", gravity,
"-pointsize", str(opts.annotation_size),
"-fill", "white",
"-stroke", "black",
"-strokewidth", "2",
"-annotate", "+0+20", opts.annotation_text,
])
cmd.append(str(dest))
print(cmd)
return cmd
def build_granite_background_cmd(
subject: Path,
dest: Path,
opts: EditOptions,
subject_size: Tuple[int, int],
magick_binary: str = "magick",
) -> List[str]:
"""
Build an ImageMagick command that:
- Creates a granite background.
- Scales it slightly larger than the subject image (background_scale).
- (Optionally) makes the background grayscale.
- Centers the subject on top of the background.
"""
sw, sh = subject_size
scale_factor = max(1.0, float(opts.background_scale))
target_w = max(1, int(sw * scale_factor))
target_h = max(1, int(sh * scale_factor))
cmd: List[str] = [
magick_binary,
"granite:",
"-sigmoidal-contrast", "4,99%",
"-crop", "128x96+0+0",
"-resize", f"{target_w}x{target_h}",
]
if opts.grayscale:
cmd.extend(["-colorspace", "Gray"])
cmd.extend([
"+repage",
str(subject),
"-gravity", "center",
"-compose", "over",
"-composite",
str(dest),
])
print(cmd)
return cmd
# ---------------------------------------------------------------------------
# Action presets
# ---------------------------------------------------------------------------
ACTION_PRESETS: Dict[str, Dict[str, object]] = {
"Color Pos": {
"color_correction": True,
"sigmoidal": True,
"sigmoidal_contrast": 6.0,
"sigmoidal_midpoint": 0.5,
"sharpen": True,
"sharpen_radius": 0.0,
"sharpen_amount": 0.5,
"sharpen_threshold": 0.02,
"grayscale": False,
"invert": False,
"use_background": False,
},
"Color Neg": {
"color_correction": True,
"sigmoidal": True,
"sigmoidal_contrast": 6.0,
"sigmoidal_midpoint": 0.5,
"sharpen": True,
"sharpen_radius": 0.0,
"sharpen_amount": 0.5,
"sharpen_threshold": 0.02,
"grayscale": False,
"invert": True,
"use_background": False,
},
"BW Pos": {
"color_correction": True,
"sigmoidal": True,
"sigmoidal_contrast": 6.0,
"sigmoidal_midpoint": 0.5,
"sharpen": True,
"sharpen_radius": 0.0,
"sharpen_amount": 0.5,
"sharpen_threshold": 0.02,
"grayscale": True,
"invert": False,
"use_background": False,
},
"BW Neg": {
"color_correction": True,
"sigmoidal": True,
"sigmoidal_contrast": 6.0,
"sigmoidal_midpoint": 0.5,
"sharpen": True,
"sharpen_radius": 0.0,
"sharpen_amount": 0.5,
"sharpen_threshold": 0.02,
"grayscale": True,
"invert": True,
"use_background": False,
},
}
def apply_action_preset(opts: EditOptions, action_name: str) -> EditOptions:
"""
Modify an EditOptions instance in-place according to an action preset.
Returns the same instance for convenience.
"""
preset = ACTION_PRESETS.get(action_name)
if not preset:
return opts
for field_name, value in preset.items():
setattr(opts, field_name, value)
print(opts)
return opts
# ---------------------------------------------------------------------------
# Persistence (imgedit_proc.json)
# ---------------------------------------------------------------------------
PROCJSON_NAME = "imgedit_proc.json"
def _options_to_dict(opts: EditOptions) -> Dict[str, Any]:
"""
Convert EditOptions to a JSON-serializable dict.
We intentionally do NOT persist crop_box (crop tends to be per-session).
"""
d = asdict(opts)
d.pop("crop_box", None)
print(d)
return d
def _options_from_dict(d: Dict[str, Any]) -> EditOptions:
"""
Convert a dict back into an EditOptions instance.
Missing keys fall back to EditOptions defaults.
"""
base = EditOptions()
for k, v in d.items():
if hasattr(base, k):
setattr(base, k, v)
return base
def load_proc_db(directory: Path) -> Dict[str, Any]:
"""
Load (or initialize) the processing metadata JSON for a directory.
Structure:
{
"images": {
"filename.jpg": { ... options dict ... },
...
},
"defaults": { ... options dict ... } # optional
}
"""
p = directory / PROCJSON_NAME
print(p)
if not p.exists():
return {"images": {}, "defaults": {}}
try:
with p.open("r", encoding="utf-8") as f:
db = json.load(f)
except Exception:
db = {"images": {}, "defaults": {}}
if "images" not in db or not isinstance(db["images"], dict):
db["images"] = {}
if "defaults" not in db or not isinstance(db["defaults"], dict):
db["defaults"] = {}
return db
def save_proc_db(directory: Path, db: Dict[str, Any]) -> None:
p = directory / PROCJSON_NAME
with p.open("w", encoding="utf-8") as f:
json.dump(db, f, indent=4)
def load_options_for_file(directory: Path, filename: Path) -> Optional[EditOptions]:
"""
Load EditOptions for a given file, if present (does NOT fall back to defaults).
"""
db = load_proc_db(directory)
img_key = filename.name
od = db["images"].get(img_key)
if not od:
print(f"No optiions found for {filename}")
return None
return _options_from_dict(od)
def save_options_for_file(directory: Path, filename: Path, opts: EditOptions) -> None:
"""
Save EditOptions for a given file.
"""
db = load_proc_db(directory)
img_key = filename.name
db["images"][img_key] = _options_to_dict(opts)
save_proc_db(directory, db)
def load_directory_defaults(directory: Path) -> Optional[EditOptions]:
"""
Load directory-wide default options, if any.
"""
db = load_proc_db(directory)
d = db.get("defaults") or {}
if not d:
return None
return _options_from_dict(d)
def save_directory_defaults(directory: Path, opts: EditOptions) -> None:
"""
Save directory-wide default options.
"""
db = load_proc_db(directory)
db["defaults"] = _options_to_dict(opts)
save_proc_db(directory, db)